From e2607ab723155a80c1d3775ac14dc69b949b47cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Tue, 1 Sep 2026 02:19:45 +0800 Subject: [PATCH] =?UTF-8?q?task-98:=20=E7=A7=BB=E9=99=A4=20similar-asin/ap?= =?UTF-8?q?pearance-patent=20=E6=A8=A1=E5=9D=97=20Coze=EF=BC=8C=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E6=9C=BA=E4=B8=8E=E5=85=B1=E4=BA=AB=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E6=94=B9=E5=90=8D=20LLM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - similarasin/appearancepatent 模块全部 Coze 工作流调用改走 direct-LLM(已确认唯一运行路径) - 共享组件改名:CozeTaskQueueGate→TaskQueueGate、CozeGroupResultPropagator→GroupResultPropagator - 状态机改名:biz_task_scope_state 的 coze_* 列→llm_*、stateJson coze 键→llm(V100 迁移已应用生产) - 删除 biz_coze_credential 表、CozeCredential* 类、SimilarAsinCozeClient、AppearancePatentCozeClient→LlmClient - 前端 brand 页 Coze 文案→LLM;Python 后端删除 cozepy 依赖与死配置 - 修复 TaskResultFileJobWorker 启动失败:TaskFileJobConfig 注册 ResultFileJobHandlerRegistry 与 13 个 handler bean(含 validateCoverage 启动校验) --- ...agator.java => GroupResultPropagator.java} | 10 +- .../config/CapacityPlanProperties.java | 2 +- .../nanri/aiimage/config/HttpClientPool.java | 2 +- .../aiimage/config/RequestTraceFilter.java | 18 +- .../aiimage/config/SimilarAsinProperties.java | 95 +- .../aiimage/config/TaskFileJobConfig.java | 135 +- ...eTaskQueueGate.java => TaskQueueGate.java} | 14 +- .../metrics/ExternalCallMetricsRecorder.java | 13 +- ...nt.java => AppearancePatentLlmClient.java} | 50 +- .../dto/AppearancePatentResultRowDto.java | 14 +- .../model/vo/AppearancePatentParsedRowVo.java | 4 +- .../AppearancePatentTaskCacheService.java | 2 +- .../service/AppearancePatentTaskService.java | 154 +- .../coze/mapper/CozeCredentialMapper.java | 9 - .../model/entity/CozeCredentialEntity.java | 25 - .../service/CozeCredentialPoolService.java | 200 -- .../service/ImageVideoAsyncTaskService.java | 12 +- .../client/SimilarAsinCozeClient.java | 1536 ------------ .../client/SimilarAsinLlmClient.java | 2 +- .../controller/SimilarAsinController.java | 4 +- .../model/dto/SimilarAsinParseRequest.java | 8 +- .../dto/SimilarAsinParsedPayloadDto.java | 6 +- .../model/dto/SimilarAsinResultRowDto.java | 40 +- .../model/vo/SimilarAsinParsedRowVo.java | 2 +- .../SimilarAsinImagePrefetchService.java | 6 +- .../service/SimilarAsinLlmService.java | 4 +- .../service/SimilarAsinTaskService.java | 2062 ++++------------- .../similarasin/util/PuzzleImageMerger.java | 4 +- .../util/SimilarAsinImageEmbedder.java | 4 +- .../util/SimilarAsinLogSupport.java | 2 +- .../model/entity/TaskImageCacheEntity.java | 2 +- .../model/entity/TaskScopeStateEntity.java | 14 +- .../AppearancePatentResultFileJobHandler.java | 43 + .../service/BrandResultFileJobHandler.java | 33 + .../CollectDataResultFileJobHandler.java | 28 + .../DeleteBrandResultFileJobHandler.java | 32 + .../PatrolDeleteResultFileJobHandler.java | 35 + .../PriceTrackResultFileJobHandler.java | 35 + .../ProductRiskResultFileJobHandler.java | 35 + .../service/PublishResultFileJobHandler.java | 39 + .../QueryAsinResultFileJobHandler.java | 35 + .../task/service/ResultFileJobHandler.java | 37 + .../service/ResultFileJobHandlerRegistry.java | 62 + .../ShopDataCrawlResultFileJobHandler.java | 48 + .../ShopMatchResultFileJobHandler.java | 37 + .../SimilarAsinResultFileJobHandler.java | 48 + .../task/service/TaskResultFileJobWorker.java | 132 +- .../service/WithdrawResultFileJobHandler.java | 45 + .../resources/application-local.example.yml | 21 +- .../src/main/resources/application.yml | 23 +- .../db/V100__drop_coze_rename_llm_columns.sql | 192 ++ .../service/TaskOwnerForwardServiceTest.java | 237 ++ ...st.java => GroupResultPropagatorTest.java} | 6 +- .../ArchitecturePlanSection6DocTest.java | 121 + .../config/HttpClientConnectionReuseTest.java | 14 +- .../config/RequestTraceConfigTest.java | 101 + .../config/RequestTraceFilterTest.java | 458 ++++ ...ueGateTest.java => TaskQueueGateTest.java} | 30 +- .../config/ThreadPoolIsolationConfigTest.java | 56 +- .../ExternalCallMetricsRecorderTest.java | 150 +- .../AppearancePatentLlmClientHttpTest.java | 4 +- ...ava => AppearancePatentLlmClientTest.java} | 24 +- .../service/object/FaultInjectionTest.java | 6 +- .../SimilarAsinCozeClientLoggingTest.java | 156 -- .../client/SimilarAsinCozeClientTest.java | 240 -- ...larAsinTaskServiceChunkMergeLimitTest.java | 29 +- ...larAsinTaskServiceCozeBufferScopeTest.java | 67 +- .../SimilarAsinTaskServiceGroupRefTest.java | 2 - ...SimilarAsinTaskServiceParseLimitsTest.java | 2 - ...imilarAsinTaskServiceParsePreviewTest.java | 2 - ...milarAsinTaskServicePreviewConfigTest.java | 2 - ...imilarAsinTaskServiceRowKeyDedupeTest.java | 39 +- ...SimilarAsinTaskServiceRowKeyIndexTest.java | 65 +- .../SimilarAsinTaskServiceSubmitTest.java | 6 +- ...larAsinTaskServiceWorkbookControlTest.java | 2 - ...earancePatentResultFileJobHandlerTest.java | 79 + .../BrandResultFileJobHandlerTest.java | 101 + .../CollectDataResultFileJobHandlerTest.java | 76 + .../DeleteBrandResultFileJobHandlerTest.java | 73 + .../PatrolDeleteResultFileJobHandlerTest.java | 75 + .../PriceTrackResultFileJobHandlerTest.java | 75 + .../ProductRiskResultFileJobHandlerTest.java | 75 + .../PublishResultFileJobHandlerTest.java | 79 + .../QueryAsinResultFileJobHandlerTest.java | 75 + ...sultFileJobHandlerMappingSnapshotTest.java | 189 ++ ...sultFileJobHandlerRegistryStartupTest.java | 128 + .../ResultFileJobHandlerRegistryTest.java | 112 + .../service/ResultFileJobHandlerTest.java | 127 + ...ShopDataCrawlResultFileJobHandlerTest.java | 80 + .../ShopMatchResultFileJobHandlerTest.java | 88 + .../SimilarAsinResultFileJobHandlerTest.java | 74 + .../TaskResultFileJobWorkerCleanupTest.java | 182 ++ .../TaskResultFileJobWorkerDispatchTest.java | 202 ++ ...askResultFileJobWorkerEquivalenceTest.java | 273 +++ ...kResultFileJobWorkerFailureNotifyTest.java | 225 ++ .../TaskResultFileJobWorkerOffloadTest.java | 264 +++ .../TaskResultFileJobWorkerOrphanTest.java | 303 +++ ...askResultFileJobWorkerOwnerScopedTest.java | 234 ++ ...askResultFileJobWorkerSpecialRuleTest.java | 325 +++ .../TaskResultFileJobWorkerStuckScanTest.java | 284 +++ .../service/TaskResultFileJobWorkerTest.java | 25 + .../WithdrawResultFileJobHandlerTest.java | 95 + backend/blueprints/admin_api.py | 63 + backend/config.py | 7 +- backend/requirement.txt | 1 - backend/web_source/admin.html | 194 +- .../BrandApiSecretSettingsButton.vue | 4 +- .../components/BrandAppearancePatentTab.vue | 34 +- .../brand/components/BrandSimilarAsinTab.vue | 30 +- 109 files changed, 6903 insertions(+), 4582 deletions(-) rename backend-java/src/main/java/com/nanri/aiimage/common/util/{CozeGroupResultPropagator.java => GroupResultPropagator.java} (97%) rename backend-java/src/main/java/com/nanri/aiimage/config/{CozeTaskQueueGate.java => TaskQueueGate.java} (87%) rename backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/{AppearancePatentCozeClient.java => AppearancePatentLlmClient.java} (96%) delete mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/coze/mapper/CozeCredentialMapper.java delete mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/coze/model/entity/CozeCredentialEntity.java delete mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/coze/service/CozeCredentialPoolService.java delete mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/AppearancePatentResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/BrandResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/CollectDataResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/DeleteBrandResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PatrolDeleteResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PriceTrackResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ProductRiskResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PublishResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/QueryAsinResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistry.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ShopDataCrawlResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ShopMatchResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/SimilarAsinResultFileJobHandler.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/task/service/WithdrawResultFileJobHandler.java create mode 100644 backend-java/src/main/resources/db/V100__drop_coze_rename_llm_columns.sql create mode 100644 backend-java/src/test/java/com/nanri/aiimage/common/service/TaskOwnerForwardServiceTest.java rename backend-java/src/test/java/com/nanri/aiimage/common/util/{CozeGroupResultPropagatorTest.java => GroupResultPropagatorTest.java} (92%) create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/ArchitecturePlanSection6DocTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/RequestTraceConfigTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/config/RequestTraceFilterTest.java rename backend-java/src/test/java/com/nanri/aiimage/config/{CozeTaskQueueGateTest.java => TaskQueueGateTest.java} (91%) rename backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/{AppearancePatentCozeClientTest.java => AppearancePatentLlmClientTest.java} (86%) delete mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java delete mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/AppearancePatentResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/BrandResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/CollectDataResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/DeleteBrandResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PatrolDeleteResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PriceTrackResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ProductRiskResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PublishResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/QueryAsinResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerMappingSnapshotTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistryStartupTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistryTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ShopDataCrawlResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ShopMatchResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/SimilarAsinResultFileJobHandlerTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerCleanupTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerDispatchTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerEquivalenceTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerFailureNotifyTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOffloadTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOrphanTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOwnerScopedTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerSpecialRuleTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerStuckScanTest.java create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/task/service/WithdrawResultFileJobHandlerTest.java diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/util/CozeGroupResultPropagator.java b/backend-java/src/main/java/com/nanri/aiimage/common/util/GroupResultPropagator.java similarity index 97% rename from backend-java/src/main/java/com/nanri/aiimage/common/util/CozeGroupResultPropagator.java rename to backend-java/src/main/java/com/nanri/aiimage/common/util/GroupResultPropagator.java index 0c76629b..5bea7ca8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/common/util/CozeGroupResultPropagator.java +++ b/backend-java/src/main/java/com/nanri/aiimage/common/util/GroupResultPropagator.java @@ -10,7 +10,7 @@ import java.util.function.BiConsumer; import java.util.function.Function; /** - * Coze 回流数据按 ID 分组传播工具。 + * LLM 回流数据按 ID 分组传播工具。 * *

业务背景: * 解析行按 Excel 行顺序排列,ID 形如 "1"、"1_1"、"1_2"、"2"、"2_1"。 @@ -33,7 +33,7 @@ import java.util.function.Function; *

使用方式: *

  *  // 专利结论列:组内任一行结论命中 "已侵权" 或 "侵权",组内都改为该标准值
- *  CozeGroupResultPropagator.propagateByGroup(
+ *  GroupResultPropagator.propagateByGroup(
  *      receivedRows,
  *      AppearancePatentParsedRowVo::getDisplayId,
  *      row -> findResultRow(row, resultMap),
@@ -44,9 +44,9 @@ import java.util.function.Function;
  *  );
  * 
*/ -public final class CozeGroupResultPropagator { +public final class GroupResultPropagator { - private static final Logger log = LoggerFactory.getLogger(CozeGroupResultPropagator.class); + private static final Logger log = LoggerFactory.getLogger(GroupResultPropagator.class); /** * 否定前缀关键字。若当前值同时包含 standard 和这些关键字之一,则不视为命中。 @@ -54,7 +54,7 @@ public final class CozeGroupResultPropagator { */ private static final List NEGATIVE_KEYWORDS = Arrays.asList("没有", "无", "不", "未"); - private CozeGroupResultPropagator() { + private GroupResultPropagator() { } /** diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/CapacityPlanProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/CapacityPlanProperties.java index 9b6e443b..2e91a85d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/CapacityPlanProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/CapacityPlanProperties.java @@ -19,7 +19,7 @@ public class CapacityPlanProperties { /** 数据库连接池(Hikari maximum-pool-size)。 */ private int dbPoolMaxSize = 30; - /** 外部 HTTP 客户端(Coze/品牌/紫鸟)连接池容量。 */ + /** 外部 HTTP 客户端(LLM/品牌/紫鸟)连接池容量。 */ private int httpClientPoolMaxSize = 32; /** RustFS/MinIO OkHttp 连接池容量。 */ diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java b/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java index 45767368..8507b19f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/HttpClientPool.java @@ -8,7 +8,7 @@ import java.time.Duration; /** * Task 77:外部 HTTP 客户端统一连接复用池。 - * Coze / 品牌检查 / 紫鸟三个外部客户端共用同一个 java.net.http.HttpClient + * LLM / 品牌检查 / 紫鸟三个外部客户端共用同一个 java.net.http.HttpClient * (内置 keep-alive 连接池),避免各自新建短命客户端导致连接无法复用、 * 每次请求都重新建连。各客户端按自身超时创建独立的 * JdkClientHttpRequestFactory(共享底层连接池),RestClient 单例懒加载。 diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/RequestTraceFilter.java b/backend-java/src/main/java/com/nanri/aiimage/config/RequestTraceFilter.java index ab022c9d..3b916461 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/RequestTraceFilter.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/RequestTraceFilter.java @@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.util.Locale; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -19,13 +20,15 @@ public class RequestTraceFilter extends OncePerRequestFilter { private static final Logger log = LoggerFactory.getLogger(RequestTraceFilter.class); + private static final int MIN_REQUEST_BODY_CACHE_LIMIT_BYTES = 1024 * 1024; + private final InstanceMetadata instanceMetadata; private final int requestBodyCacheLimitBytes; public RequestTraceFilter(InstanceMetadata instanceMetadata, - @Value("${aiimage.instance-routing.request-body-cache-limit-bytes:104857600}") int requestBodyCacheLimitBytes) { + @Value("${aiimage.instance-routing.request-body-cache-limit-bytes:1048576}") int requestBodyCacheLimitBytes) { this.instanceMetadata = instanceMetadata; - this.requestBodyCacheLimitBytes = Math.max(1024 * 1024, requestBodyCacheLimitBytes); + this.requestBodyCacheLimitBytes = Math.max(MIN_REQUEST_BODY_CACHE_LIMIT_BYTES, requestBodyCacheLimitBytes); } @Override @@ -76,16 +79,23 @@ public class RequestTraceFilter extends OncePerRequestFilter { } } - private static HttpServletRequest wrapRequestIfNeeded(HttpServletRequest request, int requestBodyCacheLimitBytes) { + static HttpServletRequest wrapRequestIfNeeded(HttpServletRequest request, int requestBodyCacheLimitBytes) { if (request instanceof ContentCachingRequestWrapper) { return request; } + // multipart 不缓存:过滤器日志不读 body,缓存会整体复制上传流到内存 + String contentType = request.getContentType(); + if (contentType != null && contentType.trim().toLowerCase(Locale.ROOT).startsWith("multipart/")) { + return request; + } String method = request.getMethod(); if ("POST".equalsIgnoreCase(method) || "PUT".equalsIgnoreCase(method) || "PATCH".equalsIgnoreCase(method) || "DELETE".equalsIgnoreCase(method)) { - return new ContentCachingRequestWrapper(request, requestBodyCacheLimitBytes); + // 阈值统一钳到 1MB 下限:0/负数会令 ContentCachingRequestWrapper 构造抛错 + return new ContentCachingRequestWrapper( + request, Math.max(MIN_REQUEST_BODY_CACHE_LIMIT_BYTES, requestBodyCacheLimitBytes)); } return request; } 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 186a7e19..6665e546 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 @@ -3,65 +3,38 @@ 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.similar-asin") public class SimilarAsinProperties { - 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 = "7635328462404583478"; - private String cozeToken = ""; - private List cozeCredentials = new ArrayList<>(); - private int cozeCredentialStripeSize = 0; /** - * P0-1:单次提交 Coze 工作流的 row 数量。 + * P0-1:单次提交 LLM 批次的 row 数量。 * 历史值 10,在含 puzzle 多图行的场景下频繁触发 720712008 * "node executed out of limit: 1000"。降到 3 以避免节点上限被打爆。 * 出现持续 720712008 时还会被 P1-1 滑窗自适应再降到 1。 - * 不影响 AppearancePatentProperties 的同名值。 */ - private int cozeBatchSize = 3; + private int llmBatchSize = 3; /** - * img_switch=false 时单次提交 Coze 的 row 数。 - * 不走图片检测时工作流压力小,恢复到 10 行一批以提高吞吐;开启图片检测时仍使用 cozeBatchSize。 + * img_switch=false 时单次提交 LLM 的 row 数。 + * 不走图片检测时压力小,恢复到 10 行一批以提高吞吐;开启图片检测时仍使用 llmBatchSize。 */ - private int cozeTextOnlyBatchSize = 10; - private int cozeConnectTimeoutMillis = 10000; - private int cozeReadTimeoutMillis = 60000; - private int cozePollIntervalMillis = 30000; - private int cozePollTimeoutMillis = 1800000; + private int llmTextOnlyBatchSize = 10; private long dbTaskTouchIntervalMillis = 120000L; private long dbJobTouchIntervalMillis = 60000L; private int staleTimeoutMinutes = 30; private String staleFinalizeCron = "0 */2 * * * *"; /** - * 同一 credential 两次提交之间的最小间隔(毫秒)。 - * 历史值硬编码 30000(持锁 sleep),导致单凭证仅 2 batch/分钟。 - * 几千行任务场景下成为提交吞吐瓶颈,下调到 5000ms 并改为锁外冷却。 - * 出现 Coze 限流加重时可通过 AIIMAGE_SIMILAR_ASIN_COZE_SUBMIT_MIN_INTERVAL_MILLIS 调高。 - */ - private long cozeSubmitMinIntervalMillis = 5000L; - - /** - * 末尾零头 batch 的强制 flush 阈值(分钟):当不足 cozeBatchSize 的零头 row + * 末尾零头 batch 的强制 flush 阈值(分钟):当不足 llmBatchSize 的零头 row * 长时间挂着(Python 慢回传)时触发提交。 * 任务级实测:345 行 / 4h 总耗时中,约 2-3 小时是 batch 永远凑不满 batchSize 在等下一波回传, - * 把阈值从 15 调到 1:最多 60s 后 1-2 行也强制提交,让 Coze 提交侧持续进票, - * 总耗时降到与 Python 回传节奏接近。配合 cozeBatchSize=3、cozeSubmitMinIntervalMillis=5000, - * 实际不会触发 Coze 限流。出现限流加重再调回 5/10。 + * 把阈值从 15 调到 1:最多 60s 后 1-2 行也强制提交,让 LLM 提交侧持续进票, + * 总耗时降到与 Python 回传节奏接近。 */ - private int cozeFlushPendingMinutes = 1; + private int llmFlushPendingMinutes = 1; /** - * 同 batch retry + split retry 共享的最大重试次数。原硬编码 5。 + * 图片下载、解码和缩放共享该池;4 核生产机默认 2,避免图片任务占满整机 CPU。 */ - private int cozeSubmitMaxRetryCount = 5; - - /** 图片下载、解码和缩放共享该池;4 核生产机默认 2,避免图片任务占满整机 CPU。 */ private int imageDownloadPoolSize = 2; /** @@ -97,40 +70,11 @@ public class SimilarAsinProperties { private boolean imageDbCacheEnabled = false; /** - * 是否在 Coze 请求 parameters 中附带 api_key 字段。 - * 默认 true:线上 Coze 工作流将该字段视为必填,缺失会得到 4000 - * "Missing required parameters";前端传入的 api_key 必须透传到 coze。 - * 仅在工作流明确不再需要 api_key 时,可通过环境变量 - * AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=false 关闭。 - */ - private boolean cozeIncludeLegacyApiKey = true; - - /** - * 是否使用旧的 item 字段顺序 {asin, sku, url, target_urls, title}。 - * 默认 false:当前实现使用 {asin, url, target_urls, title, sku}。 - * 出现兼容问题时可通过 AIIMAGE_SIMILAR_ASIN_COZE_USE_LEGACY_ITEM_ORDER=true - * 切回旧顺序进行回归对比。 - */ - private boolean cozeUseLegacyItemFieldOrder = false; - - /** - * 是否启用 P0-3 merge 增量缓冲:每个 batch DONE 时仅缓冲 cozeRows, + * 是否启用 merge 增量缓冲:每个 batch DONE 时仅缓冲 llmRows, * 不立即合并到 chunk;finalize 前一次性按 chunkScopeHash 分组合并, * 把 OSS chunk 读写从 1000+ 次降到 chunk 数量级。 - * 仅作用于"正常 poll DONE"路径;失败 batch / 单 batch 任务 / 其他 - * 11 个 mergeCozeRowsIntoChunk 调用点保留原立即 merge 行为。 - * 出现问题时可通过 AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED=false - * 一键回滚到老路径。 */ - private boolean cozeResultBufferEnabled = true; - - /** - * P0-4:单 credential 抢 Coze 提交锁的最长等待时间(毫秒)。 - * 原硬编码 1000ms,在高并发 split retry 时大量抛 "Coze submit throttle lock timeout" - * 并把整批行 markFailed。应与 cozeSubmitMinIntervalMillis(5000ms)保持 1.5-2 倍关系, - * 默认 10000ms 给抢锁更多时间。 - */ - private long cozeSubmitLockWaitMillis = 10000L; + private boolean llmResultBufferEnabled = true; /** * 解析接口返回的预览行/预览组数量上限。 @@ -193,15 +137,9 @@ public class SimilarAsinProperties { */ private int imagePrefetchBudgetSeconds = 60; - /** - * P0-4:抢 Coze 提交锁失败后下次重试间隔(毫秒)。 - * 原硬编码 500ms,会在指数退避算法中作为基础值(500/1000/2000/4000ms 上限 4000)。 - */ - private long cozeSubmitLockRetryDelayMillis = 500L; - /** * 货源查询直连 LLM 模式开关(默认 true:新任务与存量 PENDING 批次都走直连 LLM, - * 不再经过 Coze)。false 时回退到原 Coze 工作流链路(轮询/重试状态机保留)。 + * 不再经过工作流中转)。 */ private boolean directLlmEnabled = true; @@ -230,11 +168,4 @@ public class SimilarAsinProperties { /** 拼接图/主图下载超时(秒),慢源图片较多时放大该值。 */ private int llmImageDownloadTimeoutSeconds = 10; - - @Data - public static class CozeCredential { - private String name; - private String workflowId; - private String token; - } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java b/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java index 017e7189..dbd0acb0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/TaskFileJobConfig.java @@ -1,5 +1,34 @@ package com.nanri.aiimage.config; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.service.AppearancePatentResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.BrandResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.CollectDataResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.DeleteBrandResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.PatrolDeleteResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.PriceTrackResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.ProductRiskResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.PublishResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.QueryAsinResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.ResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.ResultFileJobHandlerRegistry; +import com.nanri.aiimage.modules.task.service.ShopDataCrawlResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.ShopMatchResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.SimilarAsinResultFileJobHandler; +import com.nanri.aiimage.modules.task.service.TaskResultPayloadService; +import com.nanri.aiimage.modules.task.service.WithdrawResultFileJobHandler; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; import io.micrometer.core.instrument.MeterRegistry; import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; @@ -9,6 +38,8 @@ import org.springframework.core.task.TaskExecutor; import org.springframework.scheduling.concurrent.ConcurrentTaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import java.util.List; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; @@ -16,6 +47,13 @@ import java.util.concurrent.Semaphore; @Configuration public class TaskFileJobConfig { + /** 结果文件 Job 支持的全部 moduleType(启动校验枚举源,见 ResultFileJobHandlerRegistry.validateCoverage) */ + public static final Set RESULT_FILE_JOB_MODULE_TYPES = Set.of( + "SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", + "PUBLISH", "QUERY_ASIN", "SHOP_DATA_CRAWL", "WITHDRAW", + "PATROL_DELETE", "APPEARANCE_PATENT", "SIMILAR_ASIN", + "DELETE_BRAND", "BRAND", "COLLECT_DATA"); + @Bean("taskFileJobDispatchExecutor") public TaskExecutor taskFileJobDispatchExecutor( @Value("${aiimage.result-file-job.local-dispatch-pool-size:2}") int poolSize, @@ -33,24 +71,24 @@ public class TaskFileJobConfig { } @Bean(destroyMethod = "shutdown") - public ExecutorService cozeVirtualThreadExecutor() { + public ExecutorService taskQueueVirtualThreadExecutor() { return Executors.newThreadPerTaskExecutor(Thread.ofVirtual() - .name("coze-task-", 0) + .name("task-queue-", 0) .factory()); } - @Bean("cozeTaskExecutor") - public TaskExecutor cozeTaskExecutor( - ExecutorService cozeVirtualThreadExecutor, + @Bean("taskQueueExecutor") + public TaskExecutor taskQueueExecutor( + ExecutorService taskQueueVirtualThreadExecutor, @Value("${aiimage.coze-task.max-concurrent:12}") int maxConcurrent, @Value("${aiimage.coze-task.max-waiting:1000}") int maxWaiting, ObjectProvider meterRegistryProvider) { Semaphore semaphore = new Semaphore(Math.max(1, maxConcurrent)); TaskExecutor semaphoreLimited = new ConcurrentTaskExecutor(command -> { if (command == null) { - throw new IllegalArgumentException("coze 任务不能为 null"); + throw new IllegalArgumentException("task 不能为 null"); } - cozeVirtualThreadExecutor.execute(() -> { + taskQueueVirtualThreadExecutor.execute(() -> { boolean acquired = false; try { semaphore.acquire(); @@ -65,6 +103,87 @@ public class TaskFileJobConfig { } }); }); - return new CozeTaskQueueGate(semaphoreLimited, maxWaiting, meterRegistryProvider); + return new TaskQueueGate(semaphoreLimited, maxWaiting, meterRegistryProvider); + } + + @Bean + public ResultFileJobHandlerRegistry resultFileJobHandlerRegistry(List handlers) { + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); + registry.validateCoverage(RESULT_FILE_JOB_MODULE_TYPES); + return registry; + } + + @Bean + public ResultFileJobHandler appearancePatentResultFileJobHandler( + AppearancePatentTaskService appearancePatentTaskService) { + return new AppearancePatentResultFileJobHandler(appearancePatentTaskService); + } + + @Bean + public ResultFileJobHandler brandResultFileJobHandler( + BrandTaskService brandTaskService, TaskResultPayloadService taskResultPayloadService) { + return new BrandResultFileJobHandler(brandTaskService, taskResultPayloadService); + } + + @Bean + public ResultFileJobHandler collectDataResultFileJobHandler(CollectDataService collectDataService) { + return new CollectDataResultFileJobHandler(collectDataService); + } + + @Bean + public ResultFileJobHandler deleteBrandResultFileJobHandler(DeleteBrandRunService deleteBrandRunService) { + return new DeleteBrandResultFileJobHandler(deleteBrandRunService); + } + + @Bean + public ResultFileJobHandler patrolDeleteResultFileJobHandler( + PatrolDeleteTaskService patrolDeleteTaskService, TaskResultPayloadService taskResultPayloadService) { + return new PatrolDeleteResultFileJobHandler(patrolDeleteTaskService, taskResultPayloadService); + } + + @Bean + public ResultFileJobHandler priceTrackResultFileJobHandler( + PriceTrackTaskService priceTrackTaskService, TaskResultPayloadService taskResultPayloadService) { + return new PriceTrackResultFileJobHandler(priceTrackTaskService, taskResultPayloadService); + } + + @Bean + public ResultFileJobHandler productRiskResultFileJobHandler( + ProductRiskTaskService productRiskTaskService, TaskResultPayloadService taskResultPayloadService) { + return new ProductRiskResultFileJobHandler(productRiskTaskService, taskResultPayloadService); + } + + @Bean + public ResultFileJobHandler publishResultFileJobHandler(PublishTaskService publishTaskService) { + return new PublishResultFileJobHandler(publishTaskService); + } + + @Bean + public ResultFileJobHandler queryAsinResultFileJobHandler( + QueryAsinTaskService queryAsinTaskService, TaskResultPayloadService taskResultPayloadService) { + return new QueryAsinResultFileJobHandler(queryAsinTaskService, taskResultPayloadService); + } + + @Bean + public ResultFileJobHandler shopDataCrawlResultFileJobHandler( + ShopDataCrawlTaskService shopDataCrawlTaskService, TaskResultPayloadService taskResultPayloadService) { + return new ShopDataCrawlResultFileJobHandler(shopDataCrawlTaskService, taskResultPayloadService); + } + + @Bean + public ResultFileJobHandler shopMatchResultFileJobHandler( + ShopMatchTaskService shopMatchTaskService, TaskResultPayloadService taskResultPayloadService) { + return new ShopMatchResultFileJobHandler(shopMatchTaskService, taskResultPayloadService); + } + + @Bean + public ResultFileJobHandler similarAsinResultFileJobHandler(SimilarAsinTaskService similarAsinTaskService) { + return new SimilarAsinResultFileJobHandler(similarAsinTaskService); + } + + @Bean + public ResultFileJobHandler withdrawResultFileJobHandler( + WithdrawTaskService withdrawTaskService, TaskResultPayloadService taskResultPayloadService) { + return new WithdrawResultFileJobHandler(withdrawTaskService, taskResultPayloadService); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/CozeTaskQueueGate.java b/backend-java/src/main/java/com/nanri/aiimage/config/TaskQueueGate.java similarity index 87% rename from backend-java/src/main/java/com/nanri/aiimage/config/CozeTaskQueueGate.java rename to backend-java/src/main/java/com/nanri/aiimage/config/TaskQueueGate.java index 82bb7373..4342b1e0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/CozeTaskQueueGate.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/TaskQueueGate.java @@ -11,7 +11,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** - * Task 75:虚拟线程任务排队闸门。Coze 执行池的信号量只限制"正在执行"的 + * Task 75:虚拟线程任务排队闸门。任务执行池的信号量只限制"正在执行"的 * 并发度,提交侧仍会在虚拟线程里无限排队。此闸门在提交时统计"已受理未启动" * 的等待数,达到上限立即拒绝并记录指标,防止等待队列无界堆积: *
    @@ -21,14 +21,14 @@ import java.util.concurrent.atomic.AtomicInteger; *
*/ @Slf4j -public class CozeTaskQueueGate implements TaskExecutor { +public class TaskQueueGate implements TaskExecutor { private final TaskExecutor delegate; private final int maxWaiting; private final ObjectProvider meterRegistryProvider; private final AtomicInteger waiting = new AtomicInteger(); - public CozeTaskQueueGate(TaskExecutor delegate, int maxWaiting, + public TaskQueueGate(TaskExecutor delegate, int maxWaiting, ObjectProvider meterRegistryProvider) { this.delegate = delegate; this.maxWaiting = Math.max(1, maxWaiting); @@ -43,13 +43,13 @@ public class CozeTaskQueueGate implements TaskExecutor { public void execute(Runnable command) { if (command == null) { recordRejected("invalid-input"); - throw new IllegalArgumentException("coze 任务不能为 null"); + throw new IllegalArgumentException("task 不能为 null"); } if (waiting.get() >= maxWaiting) { recordRejected("queue-full"); - log.warn("[coze-task][gate] waiting queue full, reject submit waiting={} limit={}", + log.warn("[task-queue][gate] waiting queue full, reject submit waiting={} limit={}", waiting.get(), maxWaiting); - throw new TaskRejectedException("coze 等待队列已满,limit=" + maxWaiting + throw new TaskRejectedException("task 等待队列已满,limit=" + maxWaiting + ", waiting=" + waiting.get()); } waiting.incrementAndGet(); @@ -68,7 +68,7 @@ public class CozeTaskQueueGate implements TaskExecutor { waiting.decrementAndGet(); recordQueueWait(System.nanoTime() - queuedAt); recordRejected("delegate-rejected"); - log.warn("[coze-task][gate] delegate rejected submit waiting={} limit={} msg={}", + log.warn("[task-queue][gate] delegate rejected submit waiting={} limit={} msg={}", waiting.get(), maxWaiting, ex.getMessage(), ex); throw ex; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorder.java b/backend-java/src/main/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorder.java index 22ca3e36..24fe0f3e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorder.java @@ -13,13 +13,12 @@ import java.util.concurrent.TimeUnit; /** * Task 78:外部调用统一指标记录器。 - * 所有外部 HTTP 客户端(Coze / 品牌检查 / 紫鸟)在构建 RestClient 时挂载 + * 所有外部 HTTP 客户端(LLM / 品牌检查 / 紫鸟)在构建 RestClient 时挂载 * {@link #interceptor(String)} 拦截器,统一记录: *
    *
  • 耗时:{@code aiimage.external-call.duration}(client + result 标签);
  • *
  • 失败率:{@code aiimage.external-call.total}(result=success/failure,2xx 之外计失败);
  • - *
  • payload 字节:{@code aiimage.external-call.payload.bytes}(请求体字节数);
  • - *
  • 重试次数:{@code aiimage.external-call.retry.total}(客户端重试循环内调用)。
  • + *
  • payload 字节:{@code aiimage.external-call.payload.bytes}(请求体字节数)。
  • *
* 指标注册表通过 ObjectProvider 懒获取,未配置 Micrometer 时全部静默跳过, * 不改变既有调用语义。 @@ -77,14 +76,6 @@ public class ExternalCallMetricsRecorder { }; } - /** 重试循环内每次进入下一次尝试前调用。 */ - public void recordRetry(String client) { - MeterRegistry registry = meterRegistry(); - if (registry != null) { - registry.counter("aiimage.external-call.retry.total", "client", client).increment(); - } - } - private void record(String client, String result, long startedAt, long payloadBytes) { MeterRegistry registry = meterRegistry(); if (registry == null) { 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/AppearancePatentLlmClient.java similarity index 96% rename from backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java rename to backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClient.java index 9e458949..ce5ed5b4 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/AppearancePatentLlmClient.java @@ -30,12 +30,12 @@ import java.util.regex.Pattern; /** * 外观专利检测:直连 LLM(OpenAI 兼容 /v1/chat/completions), - * 每行并发跑"商标关键词提取"与"外观侵权检测"两个请求,结果语义与原 Coze 工作流对齐。 + * 每行并发跑"商标关键词提取"与"外观侵权检测"两个请求,结果语义与原 LLM 工作流对齐。 */ @Component @RequiredArgsConstructor @Slf4j -public class AppearancePatentCozeClient { +public class AppearancePatentLlmClient { private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8); private static final String INFRINGEMENT = "侵权"; @@ -44,7 +44,7 @@ public class AppearancePatentCozeClient { private static final String APPEARANCE_ANOMALY = "外观识别异常"; private static final String MISSING_ROW_DATA = "爬虫数据缺失"; - /** 商标关键词提取系统提示词(与原 Coze 工作流一致) */ + /** 商标关键词提取系统提示词(与原 LLM 工作流一致) */ private static final String TITLE_SYSTEM_PROMPT = "你是品牌词提取工具。从用户输入的商品标题中,提取实际出现的品牌、商标、企业、平台名称及违规关键词。\n\n" + "硬性约束:\n" @@ -57,7 +57,7 @@ public class AppearancePatentCozeClient { + "* **兜底输出**:如果文本中确实没有任何商标、品牌或违规词,直接输出 `\"无\"`。\n" + "* **零干扰输出**:**绝对不要**包含任何引言、解释、前缀、多余的空格或标点符号。"; - /** 外观专利检测系统提示词(与原 Coze 工作流一致) */ + /** 外观专利检测系统提示词(与原 LLM 工作流一致) */ private static final String APPEARANCE_SYSTEM_PROMPT = "# 角色定义\n" + "你是一位极其严谨的跨境电商知识产权(IP)律师兼视觉侵权鉴定专家。你的任务是基于用户提供的产品图片和描述,评估该产品在亚马逊等平台销售时的**外观设计(Design Patent)侵权风险**。\n\n" @@ -158,7 +158,7 @@ public class AppearancePatentCozeClient { } /** - * 单行检测:与原 Coze 工作流语义对齐。 + * 单行检测:与原 LLM 工作流语义对齐。 * 任一 LLM 失败或数据缺失时走工作流"默认值"分支:appearance=外观识别异常、 * title 保留原始标题、title_reason/appearance_reason 填充对应错误信息。 */ @@ -197,7 +197,7 @@ public class AppearancePatentCozeClient { String titleReason = titleFailed ? titleError : firstNonBlank(rawTitle, MISSING_ROW_DATA); return applyRowFallback(resultRow, rawTitle, titleReason, appearanceFailed ? appearanceReason : ""); } - CozeResult result = new CozeResult( + LlmResult result = new LlmResult( row.getGroupKey(), row.getId(), row.getAsin(), @@ -219,7 +219,7 @@ public class AppearancePatentCozeClient { String title, String titleReason, String appearanceReason) { - CozeResult result = new CozeResult( + LlmResult result = new LlmResult( row.getGroupKey(), row.getId(), row.getAsin(), @@ -475,7 +475,7 @@ public class AppearancePatentCozeClient { } } - private List parseResults(String raw) throws Exception { + private List parseResults(String raw) throws Exception { JsonNode root = objectMapper.readTree(raw); String dataText = extractResultDataText(root); if (dataText.isBlank()) { @@ -483,11 +483,11 @@ public class AppearancePatentCozeClient { } JsonNode dataRoot = objectMapper.readTree(dataText); JsonNode array = dataRoot.isArray() ? dataRoot : dataRoot.path("data"); - List results = new ArrayList<>(); + List results = new ArrayList<>(); if (array.isArray()) { for (JsonNode node : array) { JsonNode itemNode = resultItemNode(node); - results.add(new CozeResult( + results.add(new LlmResult( text(firstNonNull( firstNonNull(node.get("group_key"), node.get("groupKey")), firstNonNull(itemNode.get("group_key"), itemNode.get("groupKey")))), @@ -543,13 +543,13 @@ public class AppearancePatentCozeClient { return item; } - private List mergeRows(List rows, List results) { - Map resultByGroupKey = new LinkedHashMap<>(); - Map resultByCompositeKey = new LinkedHashMap<>(); - Map resultByAsinCountry = new LinkedHashMap<>(); - Map resultByAsin = new LinkedHashMap<>(); - Map resultByRowId = new LinkedHashMap<>(); - for (CozeResult result : results) { + private List mergeRows(List rows, List results) { + Map resultByGroupKey = new LinkedHashMap<>(); + Map resultByCompositeKey = new LinkedHashMap<>(); + Map resultByAsinCountry = new LinkedHashMap<>(); + Map resultByAsin = new LinkedHashMap<>(); + Map resultByRowId = new LinkedHashMap<>(); + for (LlmResult result : results) { String groupKey = normalize(result.groupKey()); if (!groupKey.isBlank()) { resultByGroupKey.putIfAbsent(groupKey, result); @@ -576,7 +576,7 @@ public class AppearancePatentCozeClient { boolean allowIndexFallback = results.size() == rows.size() && results.stream().noneMatch(this::hasIdentity); for (int i = 0; i < rows.size(); i++) { AppearancePatentResultRowDto row = copy(rows.get(i)); - CozeResult result = resultByGroupKey.get(normalize(row.getGroupKey())); + LlmResult result = resultByGroupKey.get(normalize(row.getGroupKey())); if (result == null) { result = resultByCompositeKey.get(rowKey(row.getId(), row.getAsin(), row.getCountry())); } @@ -606,7 +606,7 @@ public class AppearancePatentCozeClient { return normalizedAsin + "::" + normalize(country); } - private boolean hasIdentity(CozeResult result) { + private boolean hasIdentity(LlmResult result) { if (result == null) { return false; } @@ -615,7 +615,7 @@ public class AppearancePatentCozeClient { || !normalize(result.asin()).isBlank(); } - void applyResult(AppearancePatentResultRowDto row, CozeResult result) { + void applyResult(AppearancePatentResultRowDto row, LlmResult result) { if (row == null || result == null) { return; } @@ -637,21 +637,21 @@ public class AppearancePatentCozeClient { row.setScore(result.score()); } - private boolean isNoTitleRisk(CozeResult result) { + private boolean isNoTitleRisk(LlmResult result) { return result != null && "无".equals(normalize(result.title())); } - private boolean shouldCheckBrand(CozeResult result) { + private boolean shouldCheckBrand(LlmResult result) { return result != null && !normalize(result.title()).isBlank() && !normalize(result.appearance()).isBlank(); } - String buildTitleRisk(String cozeTitle, BrandCheckClient.BrandCheckBatchResult brandCheck) { + String buildTitleRisk(String llmTitle, BrandCheckClient.BrandCheckBatchResult brandCheck) { List brands = brandCheck == null ? List.of() : brandCheck.brands(); if (brands == null || brands.isEmpty()) { - return firstNonBlank(cozeTitle, ""); + return firstNonBlank(llmTitle, ""); } if (brandCheck.hasFailedData()) { return INFRINGEMENT; @@ -922,7 +922,7 @@ public class AppearancePatentCozeClient { return node == null || node.isNull() ? null : node.asText(); } - private record CozeResult( + private record LlmResult( String groupKey, String rowId, String asin, 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 632a99cb..159f0dbc 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 @@ -20,7 +20,7 @@ public class AppearancePatentResultRowDto { @Schema(description = "主数据分组 key。Python 应从解析结果原样透传,用于把同组子行补回。", example = "uploads/20260426/appearance_patent_17.xlsx::2@2") private String groupKey; - @Schema(description = "Excel 中的 id。代表行通常是整数 id 或 n_1,例如 2_1;最终生成 xlsx 时,2_2、2_3 会复用同组 2_1 的 Coze 检测结果。", example = "2_1") + @Schema(description = "Excel 中的 id。代表行通常是整数 id 或 n_1,例如 2_1;最终生成 xlsx 时,2_2、2_3 会复用同组 2_1 的 LLM 检测结果。", example = "2_1") private String id; @Schema(description = "亚马逊 ASIN。后端会统一按大写处理和匹配。", example = "B0CJ8SNXXV") @@ -72,26 +72,26 @@ public class AppearancePatentResultRowDto { private String appearanceRisk; @JsonAlias({"patent ", "patent"}) - @Schema(description = "Java 调用 Coze 后生成的专利维度检测结果,对应最终 xlsx 的“专利维度(发明/实用新型专利)”列。兼容 Coze 返回字段 patent 和 patent 后带空格的情况;Python 回传请求中不要传该字段。", example = "未发现明显发明或实用新型专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Java 调用 LLM 后生成的专利维度检测结果,对应最终 xlsx 的“专利维度(发明/实用新型专利)”列。兼容 LLM 返回字段 patent 和 patent 后带空格的情况;Python 回传请求中不要传该字段。", example = "未发现明显发明或实用新型专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) private String patentRisk; - @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 conclusion; @JsonAlias({"title_reason", "titleReason"}) - @Schema(description = "Coze 返回的标题维度原因", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的标题维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String titleReason; @JsonAlias({"appearance_reason", "appearanceReason"}) - @Schema(description = "Coze 返回的外观维度原因", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的外观维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String appearanceReason; @JsonAlias({"patent_reason", "patentReason", "patent reason"}) - @Schema(description = "Coze 返回的专利维度原因", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的专利维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String patentReason; @JsonAlias({"score", "Score", "评分"}) - @Schema(description = "Coze 回流的评分(外观维度),仅保留回流值,不写入最终 xlsx。", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 回流的评分(外观维度),仅保留回流值,不写入最终 xlsx。", accessMode = Schema.AccessMode.READ_ONLY) private String score; /** diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java index 87e7c276..581f930a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/vo/AppearancePatentParsedRowVo.java @@ -39,10 +39,10 @@ public class AppearancePatentParsedRowVo { @Schema(description = "价格。", example = "12.99") private String price; - @Schema(description = "商品 SKU。Java 调用 Coze 时会放入 items[].sku。", example = "SKU-001") + @Schema(description = "商品 SKU。Java 调用 LLM 时会放入 items[].sku。", example = "SKU-001") private String sku; - @Schema(description = "商品图片 URL 或商品 URL,供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg") + @Schema(description = "商品图片 URL 或商品 URL,供 LLM 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg") private String url; @Schema(description = "商品标题。", example = "Women Floral Dress Summer Casual") 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 bfb77d50..b7a46aec 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 @@ -146,7 +146,7 @@ public class AppearancePatentTaskCacheService { 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 判据兜底去重。 + // Redis 降级时放行,由持久化层的 hasResolvedLlmFields 判据兜底去重。 return true; } } 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 014f0318..a34c7fa8 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 @@ -9,12 +9,12 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.TaskOwnerMismatchException; import com.nanri.aiimage.common.service.DistributedJobLockService; -import com.nanri.aiimage.common.util.CozeGroupResultPropagator; +import com.nanri.aiimage.common.util.GroupResultPropagator; import com.nanri.aiimage.common.util.FailedStatusRowFilter; import com.nanri.aiimage.config.AppearancePatentProperties; import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.config.StorageProperties; -import com.nanri.aiimage.modules.appearancepatent.client.AppearancePatentCozeClient; +import com.nanri.aiimage.modules.appearancepatent.client.AppearancePatentLlmClient; import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParseRequest; import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedGroupPageDto; import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedPayloadDto; @@ -128,7 +128,7 @@ public class AppearancePatentTaskService { private final TaskScopeStateMapper taskScopeStateMapper; private final TaskChunkMapper taskChunkMapper; private final ObjectMapper objectMapper; - private final AppearancePatentCozeClient cozeClient; + private final AppearancePatentLlmClient llmClient; private final AppearancePatentTaskCacheService taskCacheService; private final AppearancePatentProperties properties; private final TaskFileJobService taskFileJobService; @@ -413,7 +413,7 @@ public class AppearancePatentTaskService { completeSubmittedChunk(context); return null; }); - scheduleCozePipelineForSubmittedChunk(context); + scheduleLlmPipelineForSubmittedChunk(context); return; } FileTaskEntity task = fileTaskMapper.selectById(taskId); @@ -484,7 +484,7 @@ public class AppearancePatentTaskService { scope.setLastError(request.getError()); scope.setCompleted(Boolean.TRUE.equals(request.getDone()) ? 1 : 0); scope.setUpdatedAt(LocalDateTime.now()); - scope.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}"); + scope.setStateJson("{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}"); if (scope.getId() == null) { taskScopeStateMapper.insert(scope); } else { @@ -499,7 +499,7 @@ public class AppearancePatentTaskService { task.setUpdatedAt(LocalDateTime.now()); fileTaskMapper.updateById(task); } - scheduleCozePipelineForSubmittedChunk(context); + scheduleLlmPipelineForSubmittedChunk(context); } @Transactional @@ -758,12 +758,12 @@ public class AppearancePatentTaskService { long startedAt = System.currentTimeMillis(); List llmRows; try { - llmRows = cozeClient.inspectRows(batchRows, prompt, apiKey); + llmRows = llmClient.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); + llmRows = llmClient.markRowsFailed(batchRows, message); } mergeLlmRowsIntoChunks(task, allRowsByBaseId, llmRows, batchRows); log.info("[appearance-patent] llm batch done taskId={} jobId={} rows={} batch={}/{} costMs={}", @@ -794,11 +794,11 @@ public class AppearancePatentTaskService { deduped.put(key, row); } } - mergeCozeRowsIntoSubmittedChunks(task, new ArrayList<>(deduped.values()), allRowsByBaseId); + mergeLlmRowsIntoSubmittedChunks(task, new ArrayList<>(deduped.values()), allRowsByBaseId); } /** - * 收集未检测的候选行(行级 Redis 去重 + 已解析字段过滤),与原 Coze 语义一致。 + * 收集未检测的候选行(行级 Redis 去重 + 已解析字段过滤),与原 LLM 语义一致。 */ private List loadSubmittedChunks(Long taskId) { if (taskId == null || taskId <= 0) { @@ -823,9 +823,9 @@ public class AppearancePatentTaskService { if (persistedRows.isEmpty()) { continue; } - for (AppearancePatentResultRowDto row : pickGroupRepresentativesForCoze(persistedRows.values())) { + for (AppearancePatentResultRowDto row : pickGroupRepresentativesForLlm(persistedRows.values())) { String key = rowKey(row); - if (key.isBlank() || hasResolvedCozeFields(row)) { + if (key.isBlank() || hasResolvedLlmFields(row)) { continue; } if (taskCacheService.isRowProcessed(chunk.getTaskId(), key)) { @@ -848,7 +848,7 @@ public class AppearancePatentTaskService { if (persistedRows.isEmpty()) { continue; } - int unresolved = pickGroupRepresentativesForCoze(persistedRows.values()).size(); + int unresolved = pickGroupRepresentativesForLlm(persistedRows.values()).size(); if (unresolved > 0) { total += Math.max(1, (unresolved + batchSize - 1) / batchSize); } @@ -860,7 +860,7 @@ public class AppearancePatentTaskService { return Math.max(1, properties.getFlushPendingMinutes()) * 60_000L; } - private void submitCozeForSubmittedChunk(SubmitContext context) { + private void submitLlmForSubmittedChunk(SubmitContext context) { if (context == null || context.task() == null || context.task().getId() == null) { return; } @@ -898,7 +898,7 @@ public class AppearancePatentTaskService { touchJavaSideTaskActivity(task.getId()); } - private void scheduleCozePipelineForSubmittedChunk(SubmitContext context) { + private void scheduleLlmPipelineForSubmittedChunk(SubmitContext context) { if (context == null || context.task() == null || context.task().getId() == null) { return; } @@ -919,7 +919,7 @@ public class AppearancePatentTaskService { if (job == null || "SUCCESS".equals(job.getStatus())) { return; } - taskFileJobService.requeue(job.getId(), "Appearance patent result uploaded, scheduling Coze/file assembly"); + taskFileJobService.requeue(job.getId(), "Appearance patent result uploaded, scheduling LLM/file assembly"); touchJavaSideTaskActivity(task.getId()); } @@ -961,7 +961,7 @@ public class AppearancePatentTaskService { log.warn("[appearance-patent] Python 超时恢复继续推进检测/文件收尾 taskId={} activeAssembleJobs={}", taskId, activeAssembleJobs); } - submitCozeForSubmittedChunk(new SubmitContext(task, null, null, 0, true, null)); + submitLlmForSubmittedChunk(new SubmitContext(task, null, null, 0, true, null)); touchJavaSideTaskActivity(taskId); return true; } @@ -973,7 +973,7 @@ public class AppearancePatentTaskService { List inputStates = taskScopeStateMapper.selectList(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .isNull(TaskScopeStateEntity::getCozeStatus) + .isNull(TaskScopeStateEntity::getLlmStatus) .orderByDesc(TaskScopeStateEntity::getUpdatedAt)); if (inputStates == null || inputStates.isEmpty()) { TaskChunkEntity latestChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper() @@ -1007,7 +1007,7 @@ public class AppearancePatentTaskService { state.setLastChunkAt(now); } state.setUpdatedAt(now); - state.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}"); + state.setStateJson("{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}"); taskScopeStateMapper.updateById(state); updated++; } @@ -1020,7 +1020,7 @@ public class AppearancePatentTaskService { Integer chunkTotal, String error, boolean completed, - boolean cozeDone) { + boolean llmDone) { TaskScopeStateEntity scope = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) @@ -1043,14 +1043,14 @@ public class AppearancePatentTaskService { scope.setLastError(error); scope.setCompleted(completed ? 1 : 0); scope.setUpdatedAt(now); - scope.setStateJson(cozeDone - ? "{\"phase\":\"RECEIVED\",\"coze\":\"DONE\"}" - : "{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}"); + scope.setStateJson(llmDone + ? "{\"phase\":\"RECEIVED\",\"llm\":\"DONE\"}" + : "{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}"); if (scope.getId() == null) { try { taskScopeStateMapper.insert(scope); - log.info("[appearance-patent] scope state inserted taskId={} scope={} scopeHash={} completed={} cozeDone={}", - taskId, scopeKey, scopeHash, completed, cozeDone); + log.info("[appearance-patent] scope state inserted taskId={} scope={} scopeHash={} completed={} llmDone={}", + taskId, scopeKey, scopeHash, completed, llmDone); return; } catch (DuplicateKeyException ex) { log.info("[appearance-patent] duplicate scope state inserted concurrently taskId={} scope={}", taskId, scopeKey); @@ -1072,14 +1072,14 @@ public class AppearancePatentTaskService { scope.setLastError(error); scope.setCompleted(completed ? 1 : 0); scope.setUpdatedAt(now); - scope.setStateJson(cozeDone - ? "{\"phase\":\"RECEIVED\",\"coze\":\"DONE\"}" - : "{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}"); + scope.setStateJson(llmDone + ? "{\"phase\":\"RECEIVED\",\"llm\":\"DONE\"}" + : "{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}"); } } taskScopeStateMapper.updateById(scope); - log.info("[appearance-patent] scope state updated taskId={} scope={} scopeHash={} completed={} cozeDone={}", - taskId, scopeKey, scopeHash, completed, cozeDone); + log.info("[appearance-patent] scope state updated taskId={} scope={} scopeHash={} completed={} llmDone={}", + taskId, scopeKey, scopeHash, completed, llmDone); } private T inNewTransaction(Supplier action) { @@ -1209,7 +1209,7 @@ public class AppearancePatentTaskService { } } - private List pickGroupRepresentativesForCoze(java.util.Collection rows) { + private List pickGroupRepresentativesForLlm(java.util.Collection rows) { Map> groupedRows = new LinkedHashMap<>(); if (rows == null) { return List.of(); @@ -1223,7 +1223,7 @@ public class AppearancePatentTaskService { } List representatives = new ArrayList<>(); for (List siblings : groupedRows.values()) { - boolean alreadyResolved = siblings.stream().anyMatch(this::hasCompleteCozeResult); + boolean alreadyResolved = siblings.stream().anyMatch(this::hasCompleteLlmResult); if (alreadyResolved) { continue; } @@ -1525,11 +1525,11 @@ public class AppearancePatentTaskService { saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, plannedLlmUnits), "等待 Python 继续回传数据"); return false; } - completeCozeFileJob(task, result, job, totalProgressUnits); + completeLlmFileJob(task, result, job, totalProgressUnits); return true; } - private void completeCozeFileJob(FileTaskEntity task, + private void completeLlmFileJob(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, int totalProgressUnits) { @@ -1552,18 +1552,18 @@ public class AppearancePatentTaskService { saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits, "结果文件已生成"); } - private void mergeCozeRowsIntoSubmittedChunks(FileTaskEntity task, - List cozeRows, + private void mergeLlmRowsIntoSubmittedChunks(FileTaskEntity task, + List llmRows, Map> allRowsByBaseId) { - mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId, null, null); + mergeLlmRowsIntoSubmittedChunks(task, llmRows, allRowsByBaseId, null, null); } - private void mergeCozeRowsIntoSubmittedChunks(FileTaskEntity task, - List cozeRows, + private void mergeLlmRowsIntoSubmittedChunks(FileTaskEntity task, + List llmRows, Map> allRowsByBaseId, String fallbackScopeHash, Integer fallbackChunkIndex) { - if (task == null || cozeRows == null || cozeRows.isEmpty()) { + if (task == null || llmRows == null || llmRows.isEmpty()) { return; } List chunks = loadSubmittedChunks(task.getId()); @@ -1578,7 +1578,7 @@ public class AppearancePatentTaskService { chunkByKey.put(chunkKey, chunk); } Map> mergeRowsByChunk = new LinkedHashMap<>(); - for (AppearancePatentResultRowDto resultRow : cozeRows) { + for (AppearancePatentResultRowDto resultRow : llmRows) { for (AppearancePatentResultRowDto expandedRow : expandRows(List.of(resultRow), allRowsByBaseId)) { String rowKey = rowKey(expandedRow); if (rowKey.isBlank()) { @@ -1601,7 +1601,7 @@ public class AppearancePatentTaskService { } } if (!matched) { - log.warn("[appearance-patent] coze row has no submitted chunk taskId={} rowKey={}", + log.warn("[appearance-patent] llm row has no submitted chunk taskId={} rowKey={}", task.getId(), rowKey); } } @@ -1626,7 +1626,7 @@ public class AppearancePatentTaskService { Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .isNull(TaskScopeStateEntity::getCozeStatus) + .isNull(TaskScopeStateEntity::getLlmStatus) .isNotNull(TaskScopeStateEntity::getLastChunkAt) .eq(TaskScopeStateEntity::getCompleted, 1)); return count != null && count > 0; @@ -1839,8 +1839,8 @@ public class AppearancePatentTaskService { reasonRows += resultMap.values().stream() .filter(this::hasReasonFields) .count(); - validateCompleteCozeCoverage(task.getId(), receivedRows, resultMap); - conclusionPropagated += CozeGroupResultPropagator.propagateByGroup( + validateCompleteLlmCoverage(task.getId(), receivedRows, resultMap); + conclusionPropagated += GroupResultPropagator.propagateByGroup( receivedRows, AppearancePatentParsedRowVo::getDisplayId, row -> findResultRow(row, resultMap), @@ -2020,7 +2020,7 @@ public class AppearancePatentTaskService { return receivedRows; } - private void validateCompleteCozeCoverage(Long taskId, + private void validateCompleteLlmCoverage(Long taskId, List receivedRows, Map resultMap) { if (receivedRows == null || receivedRows.isEmpty()) { @@ -2039,26 +2039,26 @@ public class AppearancePatentTaskService { } expectedRows++; AppearancePatentResultRowDto resultRow = findResultRow(parsedRow, resultMap); - if (resultRow == null || !hasCompleteCozeResult(resultRow)) { + if (resultRow == null || !hasCompleteLlmResult(resultRow)) { missingRows++; if (sampleAsins.size() < 5) { sampleAsins.add(firstNonBlank(parsedRow.getAsin(), firstNonBlank(parsedRow.getDisplayId(), ""))); } } } - boolean enforceCompleteCozeCoverage = false; - if (enforceCompleteCozeCoverage && expectedRows > 0 && missingRows > 0) { - log.warn("[appearance-patent] incomplete coze coverage taskId={} expectedRows={} missingRows={} samples={}", + boolean enforceCompleteLlmCoverage = false; + if (enforceCompleteLlmCoverage && expectedRows > 0 && missingRows > 0) { + log.warn("[appearance-patent] incomplete llm coverage taskId={} expectedRows={} missingRows={} samples={}", taskId, expectedRows, missingRows, sampleAsins); - throw new BusinessException("Coze 结果不完整:缺少 " + missingRows + "/" + expectedRows + " 条检测结果,请等待重试或重新运行任务"); + throw new BusinessException("LLM 结果不完整:缺少 " + missingRows + "/" + expectedRows + " 条检测结果,请等待重试或重新运行任务"); } } - private boolean hasCompleteCozeResult(AppearancePatentResultRowDto row) { - return hasUsableCozeField(row.getTitleRisk()) - && hasUsableCozeField(row.getAppearanceRisk()) - && hasUsableCozeField(row.getPatentRisk()) - && hasUsableCozeField(row.getConclusion()); + private boolean hasCompleteLlmResult(AppearancePatentResultRowDto row) { + return hasUsableLlmField(row.getTitleRisk()) + && hasUsableLlmField(row.getAppearanceRisk()) + && hasUsableLlmField(row.getPatentRisk()) + && hasUsableLlmField(row.getConclusion()); } private boolean hasPersistedResultRows(Long taskId) { @@ -2231,8 +2231,8 @@ public class AppearancePatentTaskService { row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle())); row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl())); row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku())); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getTitleRisk())); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getAppearanceRisk())); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getTitleRisk())); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getAppearanceRisk())); row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingConclusion(resultRow)); row.createCell(col).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow)); } @@ -2284,7 +2284,7 @@ public class AppearancePatentTaskService { if (row == null || !normalizedAsin.equals(normalize(row.getAsin()).toUpperCase(Locale.ROOT))) { continue; } - if (hasResolvedCozeFields(row) || hasReasonFields(row)) { + if (hasResolvedLlmFields(row) || hasReasonFields(row)) { return row; } if (fallback == null) { @@ -2484,19 +2484,19 @@ public class AppearancePatentTaskService { return row != null && (!normalize(row.getTitle()).isBlank() || !normalize(row.getUrl()).isBlank()); } - private boolean hasResolvedCozeFields(AppearancePatentResultRowDto row) { + private boolean hasResolvedLlmFields(AppearancePatentResultRowDto row) { if (row == null) { return false; } - return hasUsableCozeField(row.getTitleRisk()) - || hasUsableCozeField(row.getAppearanceRisk()) - || hasUsableCozeField(row.getPatentRisk()) - || hasUsableCozeField(row.getConclusion()); + return hasUsableLlmField(row.getTitleRisk()) + || hasUsableLlmField(row.getAppearanceRisk()) + || hasUsableLlmField(row.getPatentRisk()) + || hasUsableLlmField(row.getConclusion()); } - private boolean hasUsableCozeField(String value) { + private boolean hasUsableLlmField(String value) { String normalized = normalize(value); - return !normalized.isBlank() && !isTechnicalCozeFailure(normalized); + return !normalized.isBlank() && !isTechnicalLlmFailure(normalized); } private Map buildHeaderMap(Row header, DataFormatter formatter) { @@ -2808,8 +2808,8 @@ public class AppearancePatentTaskService { return transientPayloadStorageService.storeChunkPayloadVersioned(MODULE_TYPE, taskId, scopeHash, chunkIndex, payloadJson); } - private String storeSharedCozeBatchPayload(Long taskId, String scopeHash, String payloadJson) { - requireSharedTransientPayloadStorage("coze batch payload"); + private String storeSharedLlmBatchPayload(Long taskId, String scopeHash, String payloadJson) { + requireSharedTransientPayloadStorage("llm batch payload"); return transientPayloadStorageService.storeParsedPayloadFast(MODULE_TYPE, taskId, scopeHash, payloadJson, true); } @@ -3048,13 +3048,13 @@ public class AppearancePatentTaskService { return executionFailed ? STATUS_FAILED : STATUS_SUCCESS; } - private String userFacingCozeCellValue(AppearancePatentResultRowDto row, String value) { + private String userFacingLlmCellValue(AppearancePatentResultRowDto row, String value) { String normalizedValue = normalize(value); - if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) { + if (!normalizedValue.isBlank() && !isTechnicalLlmFailure(normalizedValue)) { return value; } - // coze 技术性失败:有错误信息则放入错误信息,没有则留空 - if (row != null && isTechnicalCozeFailure(row.getError())) { + // llm 技术性失败:有错误信息则放入错误信息,没有则留空 + if (row != null && isTechnicalLlmFailure(row.getError())) { return firstNonBlank(row.getError(), ""); } return firstNonBlank(value, ""); @@ -3065,11 +3065,11 @@ public class AppearancePatentTaskService { return ""; } String conclusion = normalize(row.getConclusion()); - if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) { + if (!conclusion.isBlank() && !isTechnicalLlmFailure(conclusion)) { return row.getConclusion(); } - // coze 技术性失败:有错误信息则放入错误信息,没有则留空 - if (isTechnicalCozeFailure(row.getError())) { + // llm 技术性失败:有错误信息则放入错误信息,没有则留空 + if (isTechnicalLlmFailure(row.getError())) { return firstNonBlank(row.getError(), ""); } return firstNonBlank(row.getConclusion(), ""); @@ -3089,9 +3089,9 @@ public class AppearancePatentTaskService { return normalized.isBlank() ? "\u5931\u8d25" : "\u6210\u529f"; } - private boolean isTechnicalCozeFailure(String value) { + private boolean isTechnicalLlmFailure(String value) { String normalized = normalize(value).toLowerCase(Locale.ROOT); - return normalized.contains("coze") + return normalized.contains("coze") || normalized.contains("llm") || normalized.contains("结果不完整") || normalized.contains("工作流节点执行超限") || normalized.contains("调用超时") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/coze/mapper/CozeCredentialMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/coze/mapper/CozeCredentialMapper.java deleted file mode 100644 index 608fda32..00000000 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/coze/mapper/CozeCredentialMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.nanri.aiimage.modules.coze.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.nanri.aiimage.modules.coze.model.entity.CozeCredentialEntity; -import org.apache.ibatis.annotations.Mapper; - -@Mapper -public interface CozeCredentialMapper extends BaseMapper { -} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/coze/model/entity/CozeCredentialEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/coze/model/entity/CozeCredentialEntity.java deleted file mode 100644 index 3752f897..00000000 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/coze/model/entity/CozeCredentialEntity.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.nanri.aiimage.modules.coze.model.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -@Data -@TableName("biz_coze_credential") -public class CozeCredentialEntity { - - @TableId(type = IdType.AUTO) - private Long id; - private String moduleType; - private String credentialName; - private String workflowId; - private String token; - private Integer enabled; - private Integer maxConcurrent; - private Integer sortOrder; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/coze/service/CozeCredentialPoolService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/coze/service/CozeCredentialPoolService.java deleted file mode 100644 index a9482a15..00000000 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/coze/service/CozeCredentialPoolService.java +++ /dev/null @@ -1,200 +0,0 @@ -package com.nanri.aiimage.modules.coze.service; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.nanri.aiimage.modules.coze.mapper.CozeCredentialMapper; -import com.nanri.aiimage.modules.coze.model.entity.CozeCredentialEntity; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Service; - -import java.time.Duration; -import java.util.List; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CozeCredentialPoolService { - - private static final Duration INFLIGHT_TTL = Duration.ofMinutes(30); - - /** - * 每个 moduleType 只 WARN 一次,避免高频日志噪音。 - * key = moduleType,value = 仅作占位,仅用 putIfAbsent 语义判断"是否已经 WARN 过"。 - */ - private final ConcurrentHashMap stripeWarnedModules = new ConcurrentHashMap<>(); - - private final CozeCredentialMapper cozeCredentialMapper; - private final StringRedisTemplate stringRedisTemplate; - - public List listEnabled(String moduleType) { - if (moduleType == null || moduleType.isBlank()) { - return List.of(); - } - try { - List rows = cozeCredentialMapper.selectList(new LambdaQueryWrapper() - .eq(CozeCredentialEntity::getModuleType, moduleType) - .eq(CozeCredentialEntity::getEnabled, 1) - .orderByAsc(CozeCredentialEntity::getSortOrder) - .orderByAsc(CozeCredentialEntity::getId)); - if (rows == null || rows.isEmpty()) { - return List.of(); - } - return rows.stream() - .filter(Objects::nonNull) - .filter(row -> !blank(row.getCredentialName()) - && !blank(row.getWorkflowId()) - && !blank(row.getToken())) - .map(row -> new CozeCredential( - row.getCredentialName().trim(), - row.getWorkflowId().trim(), - row.getToken().trim(), - row.getMaxConcurrent() == null || row.getMaxConcurrent() <= 0 - ? Integer.MAX_VALUE - : row.getMaxConcurrent())) - .toList(); - } catch (Exception ex) { - log.warn("[coze-credential] list enabled failed moduleType={} err={}", moduleType, ex.getMessage()); - return List.of(); - } - } - - public CozeCredential chooseLeastInflight(String moduleType, List credentials) { - return chooseRoundRobin(moduleType, credentials, 1); - } - - public CozeCredential chooseRoundRobin(String moduleType, List credentials, int stripeSize) { - if (credentials == null || credentials.isEmpty()) { - return null; - } - // stripeSize ≤ 0 时自动按 credentials.size() 适配;正数则按配置走。 - // 注意:stripe=1 与 stripe=credentials.size() 在轮换正确性上等价,差异只在"每个凭据连续选中次数"。 - int safeStripeSize = stripeSize <= 0 ? credentials.size() : stripeSize; - // 仅当 stripe>1 且小于凭据数时 WARN:此时凭据被切到下一张前会连续选 stripe 次,但未覆盖所有凭据就回到首张,存在偏向。 - if (safeStripeSize > 1 && safeStripeSize < credentials.size() && moduleType != null && !moduleType.isBlank()) { - if (stripeWarnedModules.putIfAbsent(moduleType, Boolean.TRUE) == null) { - log.warn("[coze-credential] stripeSize({}) < credentials.size({}) for moduleType={}, " - + "round-robin may be biased; consider setting stripeSize == credentials.size() " - + "or leave it 0/negative to auto-adapt", - safeStripeSize, credentials.size(), moduleType); - } - } - long cursor = nextCursor(moduleType); - int index = (int) ((Math.max(0L, cursor) / safeStripeSize) % credentials.size()); - return credentials.get(index); - } - - public BorrowedCredential borrow(String moduleType, CozeCredential credential) { - if (credential == null) { - return null; - } - String key = inflightKey(moduleType, credential.name()); - try { - Long value = stringRedisTemplate.opsForValue().increment(key); - stringRedisTemplate.expire(key, INFLIGHT_TTL); - long inflight = value == null ? 0L : value; - if (inflight > credential.maxConcurrent()) { - release(moduleType, credential.name()); - return null; - } - return new BorrowedCredential(this, moduleType, credential.name()); - } catch (Exception ex) { - log.warn("[coze-credential] borrow failed moduleType={} credential={} err={}", - moduleType, credential.name(), ex.getMessage()); - return BorrowedCredential.noop(); - } - } - - public void release(String moduleType, String credentialName) { - if (blank(moduleType) || blank(credentialName)) { - return; - } - try { - Long value = stringRedisTemplate.opsForValue().decrement(inflightKey(moduleType, credentialName)); - if (value != null && value <= 0L) { - stringRedisTemplate.delete(inflightKey(moduleType, credentialName)); - } - } catch (Exception ex) { - log.warn("[coze-credential] release failed moduleType={} credential={} err={}", - moduleType, credentialName, ex.getMessage()); - } - } - - private long inflight(String moduleType, String credentialName) { - try { - String raw = stringRedisTemplate.opsForValue().get(inflightKey(moduleType, credentialName)); - return raw == null || raw.isBlank() ? 0L : Long.parseLong(raw); - } catch (Exception ex) { - return 0L; - } - } - - private long nextCursor(String moduleType) { - if (blank(moduleType)) { - return 0L; - } - try { - Long value = stringRedisTemplate.opsForValue().increment(cursorKey(moduleType)); - stringRedisTemplate.expire(cursorKey(moduleType), Duration.ofDays(7)); - return value == null ? 0L : Math.max(0L, value - 1L); - } catch (Exception ex) { - log.warn("[coze-credential] cursor increment failed moduleType={} err={}", moduleType, ex.getMessage()); - return System.nanoTime(); - } - } - - private String inflightKey(String moduleType, String credentialName) { - return "coze:credential:inflight:" + moduleType + ":" + credentialName; - } - - private String cursorKey(String moduleType) { - return "coze:credential:cursor:" + moduleType; - } - - private boolean blank(String value) { - return value == null || value.isBlank(); - } - - public record CozeCredential(String name, - String workflowId, - String token, - int maxConcurrent) { - } - - public static final class BorrowedCredential implements AutoCloseable { - private final CozeCredentialPoolService owner; - private final String moduleType; - private final String credentialName; - private final boolean noop; - private boolean released; - - private BorrowedCredential(CozeCredentialPoolService owner, String moduleType, String credentialName) { - this.owner = owner; - this.moduleType = moduleType; - this.credentialName = credentialName; - this.noop = false; - } - - private BorrowedCredential() { - this.owner = null; - this.moduleType = null; - this.credentialName = null; - this.noop = true; - } - - private static BorrowedCredential noop() { - return new BorrowedCredential(); - } - - @Override - public void close() { - if (released || noop) { - return; - } - released = true; - owner.release(moduleType, credentialName); - } - } -} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java index f33ae5d3..7ce5147a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/imagevideo/service/ImageVideoAsyncTaskService.java @@ -64,7 +64,7 @@ public class ImageVideoAsyncTaskService { private final ImageVideoWorkflowConfigService workflowConfigService; private final ImageVideoArchiveService archiveService; private final ObjectMapper objectMapper; - private final TaskExecutor cozeTaskExecutor; + private final TaskExecutor taskQueueExecutor; private final InstanceMetadata instanceMetadata; public ImageVideoAsyncTaskService( @@ -73,14 +73,14 @@ public class ImageVideoAsyncTaskService { ImageVideoWorkflowConfigService workflowConfigService, ImageVideoArchiveService archiveService, ObjectMapper objectMapper, - @Qualifier("cozeTaskExecutor") TaskExecutor cozeTaskExecutor, + @Qualifier("taskQueueExecutor") TaskExecutor taskQueueExecutor, InstanceMetadata instanceMetadata) { this.taskMapper = taskMapper; this.cozeService = cozeService; this.workflowConfigService = workflowConfigService; this.archiveService = archiveService; this.objectMapper = objectMapper; - this.cozeTaskExecutor = cozeTaskExecutor; + this.taskQueueExecutor = taskQueueExecutor; this.instanceMetadata = instanceMetadata; } @@ -137,7 +137,7 @@ public class ImageVideoAsyncTaskService { .or().eq(ImageVideoAsyncTaskEntity::getOwnerInstanceId, "")) .orderByAsc(ImageVideoAsyncTaskEntity::getId) .last("LIMIT " + DISPATCH_BATCH_SIZE)); - tasks.forEach(task -> cozeTaskExecutor.execute(() -> executeTask(task.getId()))); + tasks.forEach(task -> taskQueueExecutor.execute(() -> executeTask(task.getId()))); } @Scheduled(fixedDelayString = "${aiimage.image-video.async-task-poll-delay-ms:5000}") @@ -149,7 +149,7 @@ public class ImageVideoAsyncTaskService { .or().eq(ImageVideoAsyncTaskEntity::getOwnerInstanceId, "")) .orderByAsc(ImageVideoAsyncTaskEntity::getUpdatedAt) .last("LIMIT " + POLL_BATCH_SIZE)); - tasks.forEach(task -> cozeTaskExecutor.execute(() -> pollTask(task.getId()))); + tasks.forEach(task -> taskQueueExecutor.execute(() -> pollTask(task.getId()))); } @EventListener(ApplicationReadyEvent.class) @@ -190,7 +190,7 @@ public class ImageVideoAsyncTaskService { taskMapper.insert(task); log.info("[image-video] async task submitted taskId={} type={} owner={}", task.getId(), task.getTaskType(), task.getOwnerInstanceId()); - cozeTaskExecutor.execute(() -> executeTask(task.getId())); + taskQueueExecutor.execute(() -> executeTask(task.getId())); return toVo(task); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java deleted file mode 100644 index 2a93d042..00000000 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClient.java +++ /dev/null @@ -1,1536 +0,0 @@ -package com.nanri.aiimage.modules.similarasin.client; - -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService; -import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; -import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport; -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; - -import com.nanri.aiimage.config.HttpClientPool; - -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.concurrent.atomic.AtomicLong; - -@Component -@Slf4j -public class SimilarAsinCozeClient { - - private static final String MODULE_TYPE = "SIMILAR_ASIN"; - private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8); - /** Task 19:history 轮询响应正文采样频率(每 N 次记一次完整正文,其余只记状态)。 */ - static final long HISTORY_RESPONSE_LOG_EVERY_N = 20L; - - private final SimilarAsinProperties properties; - private final ObjectMapper objectMapper; - private final CozeCredentialPoolService cozeCredentialPoolService; - private final com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics; - private final AtomicLong credentialCursor = new AtomicLong(); - private final AtomicLong historyResponseLogCounter = new AtomicLong(); - /** - * P1-7:单例 RestClient。原 restClient() 每次提交/poll 都新建 SimpleClientHttpRequestFactory + RestClient, - * 几千行任务并发时会反复创建短命对象造成不必要 GC 压力。RestClient 与 SimpleClientHttpRequestFactory - * 都是线程安全的,复用一份即可;timeout 变更需要重启服务生效(与历史行为一致)。 - */ - private volatile RestClient sharedRestClient; - - public List inspect(List rows, String prompt, String apiKey) { - return inspect(rows, prompt, apiKey, false); - } - - public List inspect(List rows, String prompt, String apiKey, boolean imgSwitch) { - return inspect(rows, prompt, apiKey, imgSwitch, false); - } - - public List inspect(List rows, - String prompt, - String apiKey, - boolean imgSwitch, - boolean categorySwitch) { - if (rows == null || rows.isEmpty()) { - return List.of(); - } - if (!hasConfiguredCredential()) { - log.warn("[similar-asin] coze token not configured, keep raw rows size={}", rows.size()); - return rows.stream().map(this::copy).toList(); - } - try { - return inspectWithFallback(rows, prompt, apiKey, imgSwitch, categorySwitch); - } catch (Exception ex) { - String failureMessage = failureMessage(ex); - log.warn("[similar-asin] coze batch failed size={} err={}", rows.size(), failureMessage); - return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList(); - } - } - - public CozeSubmitResponse submitWorkflow(List rows, String prompt, String apiKey) throws Exception { - return submitWorkflow(rows, prompt, apiKey, false, nextCredential()); - } - - public CozeSubmitResponse submitWorkflow(List rows, String prompt, String apiKey, boolean imgSwitch) throws Exception { - return submitWorkflow(rows, prompt, apiKey, imgSwitch, nextCredential()); - } - - public CozeSubmitResponse submitWorkflow(List rows, - String prompt, - String apiKey, - boolean imgSwitch, - boolean categorySwitch, - CozeCredentialRef credential) throws Exception { - CozeCredentialRef resolvedCredential = resolveCredential(credential); - JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, imgSwitch, categorySwitch, resolvedCredential)); - ensureSuccess(submitRoot); - return new CozeSubmitResponse( - extractExecuteId(submitRoot), - extractResultDataText(submitRoot), - writeJson(submitRoot), - resolvedCredential.name() - ); - } - - public CozeSubmitResponse submitWorkflow(List rows, - String prompt, - String apiKey, - boolean imgSwitch, - CozeCredentialRef credential) throws Exception { - return submitWorkflow(rows, prompt, apiKey, imgSwitch, false, credential); - } - - 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 异步工作流失败") - : ""; - 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(); - } - return mergeRows(rows, parseResults(wrapDataPayload(dataText))); - } - - public List markRowsFailed(List rows, String failureMessage) { - if (rows == null || rows.isEmpty()) { - return List.of(); - } - return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList(); - } - - private List inspectWithFallback(List rows, String prompt, String apiKey, boolean imgSwitch, boolean categorySwitch) { - try { - if (rows.size() == 1) { - return inspectSingleRowWithRetry(rows, prompt, apiKey, imgSwitch, categorySwitch); - } - InspectAttempt attempt = inspectOnce(rows, prompt, apiKey, imgSwitch, categorySwitch); - 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("[similar-asin] 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, imgSwitch, categorySwitch)); - merged.addAll(inspectPartitionWithFailureFallback(rows.subList(middle, rows.size()), prompt, apiKey, imgSwitch, categorySwitch)); - return merged; - } - throw propagate(ex); - } - } - - private List inspectPartitionWithFailureFallback(List rows, String prompt, String apiKey, boolean imgSwitch, boolean categorySwitch) { - try { - return inspectWithFallback(rows, prompt, apiKey, imgSwitch, categorySwitch); - } catch (Exception ex) { - String failureMessage = failureMessage(ex); - log.warn("[similar-asin] coze partition failed size={} err={}", rows.size(), failureMessage); - return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList(); - } - } - - private List inspectSingleRowWithRetry(List rows, String prompt, String apiKey, boolean imgSwitch, boolean categorySwitch) throws Exception { - SimilarAsinResultRowDto row = rows.getFirst(); - PartialCozeResultException lastFailure = null; - for (int attemptIndex = 1; attemptIndex <= 3; attemptIndex++) { - try { - InspectAttempt attempt = inspectOnce(rows, prompt, apiKey, imgSwitch, categorySwitch); - if (attempt.resolvedCount() == rows.size()) { - return attempt.mergedRows(); - } - log.warn("[similar-asin] 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()); - } catch (Exception ex) { - if (attemptIndex >= 3 || !isRetryableBatchFailure(ex)) { - throw ex; - } - log.warn("[similar-asin] coze single retryable failure attempt={} rowId={} asin={} country={} err={}", - attemptIndex, - row.getId(), - row.getAsin(), - row.getCountry(), - failureMessage(ex)); - } - if (attemptIndex < 3) { - if (externalCallMetrics != null) { - externalCallMetrics.recordRetry("coze"); - } - sleepBeforeRetry(attemptIndex); - } - } - throw lastFailure == null ? new PartialCozeResultException(0, rows.size(), 0) : lastFailure; - } - - private InspectAttempt inspectOnce(List rows, String prompt, String apiKey, boolean imgSwitch, boolean categorySwitch) throws Exception { - String raw = runWorkflowAsyncAndWait(rows, prompt, apiKey, imgSwitch, categorySwitch); - List results = parseResults(raw); - List merged = mergeRows(rows, results); - return new InspectAttempt(raw, merged, resolvedCount(merged), results.size()); - } - - private String runWorkflowAsyncAndWait(List rows, String prompt, String apiKey, boolean imgSwitch, boolean categorySwitch) throws Exception { - CozeCredentialRef credential = nextCredential(); - JsonNode submitRoot = objectMapper.readTree(postWorkflow(rows, prompt, apiKey, imgSwitch, categorySwitch, 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 异步工作流失败")); - } - if (isSuccessfulWorkflowStatus(status)) { - String outputText = extractWorkflowOutputText(pollRoot); - if (!outputText.isBlank()) { - return wrapDataPayload(outputText); - } - throw new IllegalStateException("Coze 异步工作流已完成但没有输出结果"); - } - sleepQuietly(Math.max(200, properties.getCozePollIntervalMillis())); - } - - throw new IllegalStateException("Coze 异步工作流轮询超时"); - } - - private String postWorkflow(List rows, - String prompt, - String apiKey, - boolean imgSwitch, - boolean categorySwitch, - CozeCredentialRef credential) { - Map parameters = buildParameters(rows, prompt, apiKey, imgSwitch, categorySwitch); - Map body = new LinkedHashMap<>(); - body.put("workflow_id", credential.workflowId()); - body.put("parameters", parameters); - body.put("is_async", Boolean.TRUE); - log.debug("[similar-asin] coze request credential={} url={} body={}", - credential.name(), - joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()), - SimilarAsinLogSupport.truncate(writeJson(maskCozeRequestBody(body)))); - - RestClient.RequestBodySpec request = restClient().post() - .uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath())) - .headers(headers -> { - headers.setBearerAuth(stripBearer(credential.token())); - headers.setContentType(APPLICATION_JSON_UTF8); - headers.set(HttpHeaders.ACCEPT_CHARSET, StandardCharsets.UTF_8.name()); - }); - request.body(body); - return request.exchange((clientRequest, clientResponse) -> { - byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); - String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); - log.debug("[similar-asin] coze submit response status={} body={}", - clientResponse.getStatusCode(), - SimilarAsinLogSupport.truncate(responseText)); - return responseText; - }); - } - - private String getWorkflowHistory(String executeId, CozeCredentialRef credential) { - String path = properties.getCozeWorkflowHistoryPath() - .replace("{workflow_id}", credential.workflowId()) - .replace("{execute_id}", executeId); - long historyLogCounter = historyResponseLogCounter.getAndIncrement(); - 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.debug("[similar-asin] coze history response credential={} executeId={} status={} body={}", - credential.name(), executeId, - clientResponse.getStatusCode(), - SimilarAsinLogSupport.shouldLog(historyLogCounter, HISTORY_RESPONSE_LOG_EVERY_N) - ? SimilarAsinLogSupport.truncate(responseText) - : "[sampled out]"); - 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 configuredStripe = properties.getCozeCredentialStripeSize(); - int stripeSize = configuredStripe <= 0 ? Math.max(1, credentials.size()) : configuredStripe; - 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; - } - } - 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())); - } - if (!credentials.isEmpty()) { - return credentials; - } - if (properties.getCozeCredentials() != null) { - int index = 1; - for (SimilarAsinProperties.CozeCredential credential : properties.getCozeCredentials()) { - if (credential == null - || normalize(credential.getWorkflowId()).isBlank() - || normalize(credential.getToken()).isBlank()) { - continue; - } - String name = firstNonBlank(credential.getName(), "credential-" + index); - credentials.add(new CozeCredentialRef(name, credential.getWorkflowId(), credential.getToken(), Integer.MAX_VALUE)); - index++; - } - } - 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, boolean imgSwitch) { - return buildParameters(rows, prompt, apiKey, imgSwitch, false); - } - - private Map buildParameters(List rows, - String prompt, - String apiKey, - boolean imgSwitch, - boolean categorySwitch) { - List asins = rows.stream().map(row -> safeText(row.getAsin())).toList(); - List titles = rows.stream().map(row -> safeText(firstNonBlank(row.getTitle(), row.getAsin()))).toList(); - List skus = rows.stream().map(row -> safeText(row.getSku())).toList(); - List urls = rows.stream().map(this::primaryImageUrl).toList(); - List> urlLists = rows.stream().map(this::imageUrls).toList(); - List>> alibabaLists = rows.stream().map(this::alibabaItems).toList(); - - List> items = buildItemObjects(asins, titles, skus, urls, urlLists, alibabaLists); - logCozeItemsDiff(rows, items); - Map parameters = new LinkedHashMap<>(); - parameters.put("items", items); - // 前端没填 prompt 就一律不向 Coze 透传该字段,避免无关默认提示词污染工作流。 - if (prompt != null && !prompt.isBlank()) { - parameters.put("prompt", prompt); - } - // legacy flag:当线上 coze 工作流回退到老契约时,把环境变量 - // AIIMAGE_SIMILAR_ASIN_COZE_INCLUDE_LEGACY_API_KEY=true 即可重新塞 api_key。 - boolean includeLegacyApiKey = properties.isCozeIncludeLegacyApiKey(); - if (includeLegacyApiKey && apiKey != null && !apiKey.isBlank()) { - parameters.put("api_key", apiKey.trim()); - } - parameters.put("img_switch", imgSwitch); - parameters.put("category_switch", categorySwitch); - return parameters; - } - - /** - * 打印每一行 row 在送入 Coze 前后的关键字段,便于排查 Python -> Java -> Coze 的字段是否被改写。 - * 对照点:asin / url / urls(size+样例) / title / sku;以及组装到 Coze 后的 url / target_urls。 - */ - private void logCozeItemsDiff(List rows, List> items) { - if (rows == null || items == null) { - return; - } - int size = Math.min(rows.size(), items.size()); - log.info("[similar-asin] coze items diff start batchSize={}", size); - for (int i = 0; i < size; i++) { - SimilarAsinResultRowDto row = rows.get(i); - Map item = items.get(i); - if (row == null || item == null) { - continue; - } - List rowUrls = safeUrls(row.getUrls()); - String rowUrl = safeText(row.getUrl()); - Object outUrl = item.get("url"); - Object outAlibaba = item.get("alibaba"); - Object outTargetUrls = item.get("target_urls"); - int outTargetSize = (outTargetUrls instanceof List list) ? list.size() : 0; - int outAlibabaSize = (outAlibaba instanceof List list) ? list.size() : 0; - boolean urlMatch = String.valueOf(outUrl == null ? "" : outUrl).equals(rowUrl); - boolean targetMatch = (outTargetUrls instanceof List outList) - && outList.size() == rowUrls.size() - && outList.equals(rowUrls); - log.info("[similar-asin] coze item idx={} asin={} title={} sku={} price={} rowUrl={} rowUrlsSize={} rowUrlsHead={} rowUrlsTail={} outUrl={} outAlibabaSize={} outTargetSize={} outTargetHead={} outTargetTail={} urlMatch={} targetUrlsMatch={}", - i, - safeText(row.getAsin()), - abbreviate(safeText(row.getTitle()), 80), - safeText(row.getSku()), - safeText(row.getPrice()), - abbreviate(rowUrl, 200), - rowUrls.size(), - rowUrls.isEmpty() ? "" : abbreviate(rowUrls.get(0), 200), - rowUrls.size() <= 1 ? "" : abbreviate(rowUrls.get(rowUrls.size() - 1), 200), - abbreviate(String.valueOf(outUrl == null ? "" : outUrl), 200), - outAlibabaSize, - outTargetSize, - (outTargetUrls instanceof List headList && !headList.isEmpty()) - ? abbreviate(String.valueOf(headList.get(0)), 200) : "", - (outTargetUrls instanceof List tailList && tailList.size() > 1) - ? abbreviate(String.valueOf(tailList.get(tailList.size() - 1)), 200) : "", - urlMatch, - targetMatch); - } - } - - @SuppressWarnings("unchecked") - private Map maskCozeRequestBody(Map body) { - Map masked = new LinkedHashMap<>(body); - Object topLevelApiKey = masked.get("api_key"); - if (topLevelApiKey instanceof String apiKeyText && !apiKeyText.isBlank()) { - masked.put("api_key", maskSecret(apiKeyText)); - } - 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)); - } - masked.put("parameters", maskedParameters); - } - return masked; - } - - private String maskSecret(String secret) { - String normalized = secret == null ? "" : secret.trim(); - if (normalized.isBlank()) { - return ""; - } - if (normalized.length() <= 10) { - return "***"; - } - return normalized.substring(0, 6) + "***" + normalized.substring(normalized.length() - 4); - } - - private List> buildItemObjects(List asins, - List titles, - List skus, - List urls, - List> urlLists, - List>> alibabaLists) { - // legacy flag:保留切回旧字段顺序 {asin, sku, url, target_urls, title} 的开关, - // 默认 false 使用当前顺序 {asin, url, target_urls, title, sku}。 - boolean useLegacyOrder = properties.isCozeUseLegacyItemFieldOrder(); - List> items = new ArrayList<>(asins.size()); - for (int i = 0; i < asins.size(); i++) { - String url = urls.get(i); - List imageUrls = urlLists.get(i); - List> alibabaItems = alibabaLists.get(i); - Map item = new LinkedHashMap<>(); - if (useLegacyOrder) { - item.put("asin", asins.get(i)); - item.put("sku", skus.get(i)); - item.put("url", url); - item.put("alibaba", alibabaItems); - item.put("target_urls", imageUrls); - item.put("title", titles.get(i)); - } else { - item.put("asin", asins.get(i)); - item.put("url", url); - item.put("alibaba", alibabaItems); - item.put("target_urls", imageUrls); - item.put("title", titles.get(i)); - item.put("sku", skus.get(i)); - } - items.add(item); - } - 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(); - } - JsonNode dataRoot = objectMapper.readTree(dataText); - JsonNode array = dataRoot.isArray() ? dataRoot : dataRoot.path("data"); - List results = new ArrayList<>(); - if (array.isArray()) { - for (JsonNode node : array) { - JsonNode itemNode = resultItemNode(node); - results.add(new CozeResult( - text(firstNonNull( - firstNonNull(node.get("group_key"), node.get("groupKey")), - firstNonNull(itemNode.get("group_key"), itemNode.get("groupKey")))), - text(firstNonNull( - firstNonNull(node.get("row_token"), node.get("rowToken")), - firstNonNull(itemNode.get("row_token"), itemNode.get("rowToken")))), - text(firstNonNull( - firstNonNull(node.get("row_id"), firstNonNull(node.get("rowId"), node.get("id"))), - firstNonNull(itemNode.get("row_id"), firstNonNull(itemNode.get("rowId"), itemNode.get("id"))))), - text(firstNonNull(node.get("asin"), itemNode.get("asin"))), - text(firstNonNull( - firstNonNull(node.get("country"), node.get("site")), - firstNonNull(itemNode.get("country"), itemNode.get("site")))), - text(firstNonNull( - firstNonNull(node.get("sku"), firstNonNull(node.get("SKU"), firstNonNull(node.get("seller_sku"), node.get("sellerSku")))), - firstNonNull(itemNode.get("sku"), firstNonNull(itemNode.get("SKU"), firstNonNull(itemNode.get("seller_sku"), itemNode.get("sellerSku")))))), - text(firstNonNull( - firstNonNull(node.get("price"), node.get("价格")), - firstNonNull(itemNode.get("price"), itemNode.get("价格")))), - text(firstNonNull(node.get("title"), firstNonNull(itemNode.get("title"), itemNode.get("title_risk")))), - text(firstNonNull( - firstNonNull(node.get("is_stock"), firstNonNull(node.get("isStock"), firstNonNull(node.get("stock"), node.get("是否有货")))), - firstNonNull(itemNode.get("is_stock"), firstNonNull(itemNode.get("isStock"), firstNonNull(itemNode.get("stock"), itemNode.get("是否有货")))))), - text(firstNonNull( - firstNonNull(node.get("similarity"), firstNonNull(node.get("similarity_rate"), node.get("相似度"))), - firstNonNull(itemNode.get("similarity"), firstNonNull(itemNode.get("similarity_rate"), itemNode.get("相似度"))))), - text(firstNonNull( - firstNonNull(node.get("is_conform"), firstNonNull(node.get("isConform"), firstNonNull(node.get("conform"), node.get("是否符合类目")))), - firstNonNull(itemNode.get("is_conform"), firstNonNull(itemNode.get("isConform"), firstNonNull(itemNode.get("conform"), itemNode.get("是否符合类目")))))), - text(firstNonNull( - firstNonNull(node.get("reason"), firstNonNull(node.get("原因"), node.get("不符合理由"))), - firstNonNull(itemNode.get("reason"), firstNonNull(itemNode.get("原因"), itemNode.get("不符合理由"))))), - text(firstNonNull( - firstNonNull(node.get("category"), firstNonNull(node.get("类目"), node.get("产品类目"))), - firstNonNull(itemNode.get("category"), firstNonNull(itemNode.get("类目"), itemNode.get("产品类目"))))), - text(firstNonNull(node.get("appearance"), - firstNonNull(node.get("appearance_risk"), - firstNonNull(itemNode.get("appearance"), itemNode.get("appearance_risk"))))), - text(firstNonNull(firstNonNull(node.get("patent"), node.get("patent ")), - firstNonNull(node.get("patent_risk"), - firstNonNull(firstNonNull(itemNode.get("patent"), itemNode.get("patent ")), itemNode.get("patent_risk"))))), - text(firstNonNull(node.get("result"), - firstNonNull(node.get("conclusion"), - firstNonNull(itemNode.get("result"), itemNode.get("conclusion"))))), - text(firstNonNull( - firstNonNull(node.get("status"), firstNonNull(node.get("row_status"), node.get("rowStatus"))), - firstNonNull(itemNode.get("status"), firstNonNull(itemNode.get("row_status"), itemNode.get("rowStatus"))))), - text(firstNonNull(node.get("title_reason"), - firstNonNull(node.get("titleReason"), - firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))), - text(firstNonNull(node.get("appearance_reason"), - firstNonNull(node.get("appearanceReason"), - firstNonNull(itemNode.get("appearance_reason"), itemNode.get("appearanceReason"))))), - text(firstNonNull(node.get("patent_reason"), - firstNonNull(firstNonNull(node.get("patentReason"), node.get("patent reason")), - firstNonNull(itemNode.get("patent_reason"), - firstNonNull(itemNode.get("patentReason"), itemNode.get("patent reason")))))), - text(firstNonNull( - firstNonNull(node.get("main_url"), firstNonNull(node.get("mainUrl"), firstNonNull(node.get("main_image_url"), firstNonNull(node.get("mainImageUrl"), firstNonNull(node.get("main_img"), node.get("mainImg")))))), - firstNonNull(itemNode.get("main_url"), firstNonNull(itemNode.get("mainUrl"), firstNonNull(itemNode.get("main_image_url"), firstNonNull(itemNode.get("mainImageUrl"), firstNonNull(itemNode.get("main_img"), itemNode.get("mainImg")))))))), - text(firstNonNull( - firstNonNull(node.get("puzzle_img1"), firstNonNull(node.get("puzzleImg1"), firstNonNull(node.get("puzzle_img_1"), node.get("puzzleImg_1")))), - firstNonNull(itemNode.get("puzzle_img1"), firstNonNull(itemNode.get("puzzleImg1"), firstNonNull(itemNode.get("puzzle_img_1"), itemNode.get("puzzleImg_1")))))), - text(firstNonNull( - firstNonNull(node.get("puzzle_img2"), firstNonNull(node.get("puzzleImg2"), firstNonNull(node.get("puzzle_img_2"), node.get("puzzleImg_2")))), - firstNonNull(itemNode.get("puzzle_img2"), firstNonNull(itemNode.get("puzzleImg2"), firstNonNull(itemNode.get("puzzle_img_2"), itemNode.get("puzzleImg_2")))))) - )); - } - } - return results; - } - - private JsonNode resultItemNode(JsonNode node) { - if (node == null || node.isMissingNode() || node.isNull()) { - return objectMapper.missingNode(); - } - JsonNode item = firstNonNull(node.get("item"), node.get("items")); - if (item == null || item.isMissingNode() || item.isNull()) { - return objectMapper.missingNode(); - } - if (item.isArray()) { - return item.isEmpty() ? objectMapper.missingNode() : item.get(0); - } - return item; - } - - private List mergeRows(List rows, List results) { - Map resultByRowToken = new LinkedHashMap<>(); - Map resultByCompositeKey = new LinkedHashMap<>(); - Map resultByAsinCountry = new LinkedHashMap<>(); - Map resultByRowId = new LinkedHashMap<>(); - for (CozeResult result : results) { - String rowToken = normalize(result.rowToken()); - if (!rowToken.isBlank()) { - resultByRowToken.putIfAbsent(rowToken, result); - } - String compositeKey = rowKey(result.rowId(), result.asin(), result.country()); - if (!compositeKey.isBlank()) { - resultByCompositeKey.putIfAbsent(compositeKey, result); - } - String asinCountryKey = asinCountryKey(result.asin(), result.country()); - if (!asinCountryKey.isBlank()) { - resultByAsinCountry.putIfAbsent(asinCountryKey, result); - } - String rowIdKey = normalize(result.rowId()); - if (!rowIdKey.isBlank()) { - resultByRowId.putIfAbsent(rowIdKey, result); - } - } - - List merged = new ArrayList<>(rows.size()); - boolean allowIndexFallback = results.size() == rows.size() && results.stream().noneMatch(this::hasIdentity); - for (int i = 0; i < rows.size(); i++) { - SimilarAsinResultRowDto row = copy(rows.get(i)); - CozeResult result = resultByRowToken.get(normalize(row.getRowToken())); - if (result == null) { - result = resultByCompositeKey.get(rowKey(row.getId(), row.getAsin(), row.getCountry())); - } - if (result == null && i < results.size() && isIndexedResultForRow(results.get(i), row)) { - result = results.get(i); - } - if (result == null) { - result = resultByAsinCountry.get(asinCountryKey(row.getAsin(), row.getCountry())); - } - if (result == null) { - result = resultByRowId.get(normalize(row.getId())); - } - if (result == null && allowIndexFallback && i < results.size()) { - result = results.get(i); - } - applyResult(row, result); - merged.add(row); - } - return merged; - } - - private String asinCountryKey(String asin, String country) { - String normalizedAsin = normalize(asin).toUpperCase(Locale.ROOT); - if (normalizedAsin.isBlank()) { - return ""; - } - return normalizedAsin + "::" + normalize(country); - } - - private boolean hasIdentity(CozeResult result) { - if (result == null) { - return false; - } - return !normalize(result.rowToken()).isBlank() - || !normalize(result.groupKey()).isBlank() - || !normalize(result.rowId()).isBlank() - || !normalize(result.asin()).isBlank(); - } - - private boolean isIndexedResultForRow(CozeResult result, SimilarAsinResultRowDto row) { - if (result == null || row == null) { - return false; - } - if (!normalize(result.rowToken()).isBlank() || !normalize(result.rowId()).isBlank()) { - return false; - } - String resultAsin = normalize(result.asin()).toUpperCase(Locale.ROOT); - return resultAsin.isBlank() || resultAsin.equals(normalize(row.getAsin()).toUpperCase(Locale.ROOT)); - } - - private void applyResult(SimilarAsinResultRowDto row, CozeResult result) { - if (row == null || result == null) { - return; - } - row.setTitleRisk(result.title()); - row.setSku(nonBlank(result.sku(), row.getSku())); - row.setPrice(nonBlank(result.price(), row.getPrice())); - row.setIsStock(result.isStock()); - row.setSimilarity(result.similarity()); - row.setIsConform(result.isConform()); - row.setReason(result.reason()); - row.setCategory(result.category()); - row.setAppearanceRisk(result.appearance()); - row.setPatentRisk(result.patent()); - row.setConclusion(result.result()); - row.setStatus(result.status()); - row.setTitleReason(result.titleReason()); - row.setAppearanceReason(result.appearanceReason()); - row.setPatentReason(result.patentReason()); - row.setMainUrl(result.mainUrl()); - row.setPuzzleImg1(result.puzzleImg1()); - row.setPuzzleImg2(result.puzzleImg2()); - } - - private RestClient restClient() { - RestClient client = sharedRestClient; - if (client != null) { - return client; - } - synchronized (this) { - if (sharedRestClient == null) { - RestClient.Builder builder = RestClient.builder() - .requestFactory(HttpClientPool.requestFactory(properties.getCozeReadTimeoutMillis())); - if (externalCallMetrics != null) { - builder.requestInterceptor(externalCallMetrics.interceptor("coze")); - } - sharedRestClient = builder.build(); - } - return sharedRestClient; - } - } - - private SimilarAsinResultRowDto copy(SimilarAsinResultRowDto source) { - SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); - row.setSourceFileKey(source.getSourceFileKey()); - row.setSourceFilename(source.getSourceFilename()); - row.setRowToken(source.getRowToken()); - row.setGroupKey(source.getGroupKey()); - row.setId(source.getId()); - row.setAsin(source.getAsin()); - row.setCountry(source.getCountry()); - row.setSku(source.getSku()); - row.setPrice(source.getPrice()); - row.setUrls(source.getUrls()); - row.setAlibaba(source.getAlibaba()); - row.setTitle(source.getTitle()); - row.setError(source.getError()); - row.setDone(source.getDone()); - row.setStatus(source.getStatus()); - row.setIsConform(source.getIsConform()); - row.setReason(source.getReason()); - row.setCategory(source.getCategory()); - row.setTitleRisk(source.getTitleRisk()); - row.setAppearanceRisk(source.getAppearanceRisk()); - row.setPatentRisk(source.getPatentRisk()); - row.setConclusion(source.getConclusion()); - row.setIsStock(source.getIsStock()); - row.setSimilarity(source.getSimilarity()); - row.setTitleReason(source.getTitleReason()); - row.setAppearanceReason(source.getAppearanceReason()); - row.setPatentReason(source.getPatentReason()); - row.setMainUrl(source.getMainUrl()); - row.setPuzzleImg1(source.getPuzzleImg1()); - row.setPuzzleImg2(source.getPuzzleImg2()); - return row; - } - - private SimilarAsinResultRowDto markFailed(SimilarAsinResultRowDto row, String failureMessage) { - String reviewMessage = failureMessage == null || failureMessage.isBlank() - ? "Coze 检测失败" - : "Coze 检测失败:" + failureMessage; - if (row.getError() == null || row.getError().isBlank()) { - row.setError(failureMessage); - } - if (row.getReason() == null || row.getReason().isBlank()) { - row.setReason(failureMessage); - } - if (row.getStatus() == null || row.getStatus().isBlank()) { - row.setStatus("FAILED"); - } - if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) { - row.setTitleRisk(reviewMessage); - } - if (row.getAppearanceRisk() == null || row.getAppearanceRisk().isBlank()) { - row.setAppearanceRisk(reviewMessage); - } - if (row.getPatentRisk() == null || row.getPatentRisk().isBlank()) { - row.setPatentRisk(reviewMessage); - } - if (row.getConclusion() == null || row.getConclusion().isBlank()) { - row.setConclusion(failureMessage); - } - return row; - } - - 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.contains("429") - || message.toLowerCase(Locale.ROOT).contains("rate limit") - || message.toLowerCase(Locale.ROOT).contains("retry later") - || message.contains("限流") - || message.contains("稍后重试") - || message.toLowerCase(Locale.ROOT).contains("timeout"); - } - - private int resolvedCount(List rows) { - int resolved = 0; - for (SimilarAsinResultRowDto row : rows) { - if (hasResolvedCozeFields(row)) { - resolved++; - } - } - return resolved; - } - - private boolean hasResolvedCozeFields(SimilarAsinResultRowDto row) { - if (row == null) { - return false; - } - return !normalize(row.getTitleRisk()).isBlank() - || !normalize(row.getAppearanceRisk()).isBlank() - || !normalize(row.getPatentRisk()).isBlank() - || !normalize(row.getConclusion()).isBlank() - || !normalize(row.getIsStock()).isBlank() - || !normalize(row.getSimilarity()).isBlank() - || !normalize(row.getIsConform()).isBlank() - || !normalize(row.getReason()).isBlank() - || !normalize(row.getCategory()).isBlank() - || !normalize(row.getStatus()).isBlank() - || !normalize(row.getMainUrl()).isBlank() - || !normalize(row.getPuzzleImg1()).isBlank() - || !normalize(row.getPuzzleImg2()).isBlank(); - } - - public SimilarAsinCozeClient(SimilarAsinProperties properties, - ObjectMapper objectMapper, - CozeCredentialPoolService cozeCredentialPoolService, - com.nanri.aiimage.metrics.ExternalCallMetricsRecorder externalCallMetrics) { - this.properties = properties; - this.objectMapper = objectMapper; - this.cozeCredentialPoolService = cozeCredentialPoolService; - this.externalCallMetrics = externalCallMetrics; - } - - 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("is_stock") - || item.has("isStock") - || item.has("stock") - || item.has("是否有货") - || item.has("similarity") - || item.has("similarity_rate") - || item.has("相似度") - || item.has("is_conform") - || item.has("isConform") - || item.has("conform") - || item.has("是否符合类目") - || item.has("reason") - || item.has("原因") - || item.has("不符合理由") - || item.has("category") - || item.has("类目") - || item.has("产品类目") - || item.has("status") - || 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("main_url") - || item.has("mainUrl") - || item.has("main_image_url") - || item.has("mainImageUrl") - || item.has("main_img") - || item.has("mainImg") - || item.has("puzzle_img1") - || item.has("puzzleImg1") - || item.has("puzzle_img_1") - || item.has("puzzleImg_1") - || item.has("puzzle_img2") - || item.has("puzzleImg2") - || item.has("puzzle_img_2") - || item.has("puzzleImg_2")); - } - - 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 = firstNonBlank( - findTextByFieldName(dataNode, "error_message", "error", "msg"), - findTextByFieldName(root, "error_message", "error", "msg")); - return message == null ? "" : message; - } - - 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("Coze 请求载荷序列化失败", ex); - } - } - - private String abbreviate(String value, int maxLength) { - String normalized = value == null ? "" : value.trim(); - if (normalized.length() <= maxLength) { - return normalized; - } - return normalized.substring(0, Math.max(0, maxLength - 3)) + "..."; - } - - private void sleepBeforeRetry(int attemptIndex) { - long delayMillis = Math.max(1, attemptIndex) * 1500L; - ensureNotInterrupted(); - sleepQuietly(delayMillis); - } - - private void sleepQuietly(long delayMillis) { - try { - Thread.sleep(delayMillis); - } catch (InterruptedException interruptedException) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Coze 工作流被中断", interruptedException); - } - } - - private void ensureNotInterrupted() { - if (Thread.currentThread().isInterrupted()) { - throw new IllegalStateException("Coze 工作流被中断"); - } - } - - private RuntimeException propagate(Exception ex) { - if (ex instanceof RuntimeException runtimeException) { - return runtimeException; - } - return new IllegalStateException(nonBlank(ex.getMessage(), "Coze 调用失败"), ex); - } - - 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 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(); - } - - private String safeText(String value) { - return value == null ? "" : value.trim(); - } - - private List safeUrls(List urls) { - if (urls == null || urls.isEmpty()) { - return List.of(); - } - return urls.stream() - .map(this::safeText) - .filter(value -> !value.isBlank()) - .toList(); - } - - private void putIfPresent(Map item, String key, String value) { - String normalized = safeText(value); - if (!normalized.isBlank()) { - item.put(key, normalized); - } - } - - private void putIfPresent(Map item, String key, List values) { - if (values != null && !values.isEmpty()) { - item.put(key, values); - } - } - - private String primaryImageUrl(SimilarAsinResultRowDto row) { - if (row == null) { - return ""; - } - String url = safeText(row.getUrl()); - if (!url.isBlank()) { - return url; - } - List urls = safeUrls(row.getUrls()); - return urls.isEmpty() ? "" : urls.get(0); - } - - private List imageUrls(SimilarAsinResultRowDto row) { - if (row == null) { - return List.of(); - } - List urls = safeUrls(row.getUrls()); - if (!urls.isEmpty()) { - return urls; - } - String primaryUrl = primaryImageUrl(row); - return primaryUrl.isBlank() ? List.of() : List.of(primaryUrl); - } - - private List> alibabaItems(SimilarAsinResultRowDto row) { - if (row == null) { - return List.of(); - } - List explicitItems = row.getAlibaba(); - if (explicitItems != null && !explicitItems.isEmpty()) { - List> result = new ArrayList<>(explicitItems.size()); - for (SimilarAsinResultRowDto.AlibabaItem source : explicitItems) { - if (source == null) { - continue; - } - String sourceUrl = safeText(source.getUrl()); - Object sourcePrice = cozePrice(source.getRawPrice()); - if (sourceUrl.isBlank() && isBlankPrice(sourcePrice)) { - continue; - } - Map item = new LinkedHashMap<>(); - item.put("url", sourceUrl); - item.put("price", sourcePrice); - result.add(item); - } - return result; - } - List imageUrls = imageUrls(row); - if (imageUrls.isEmpty()) { - return List.of(); - } - Object rowPrice = cozePrice(row.getPrice()); - List> result = new ArrayList<>(imageUrls.size()); - for (String imageUrl : imageUrls) { - Map item = new LinkedHashMap<>(); - item.put("url", imageUrl); - item.put("price", rowPrice); - result.add(item); - } - return result; - } - - private Object cozePrice(Object value) { - if (value == null) { - return BigDecimal.ZERO; - } - if (value instanceof BigDecimal decimal) { - return normalizeCozeDecimal(decimal); - } - if (value instanceof Number number) { - return normalizeCozeDecimal(new BigDecimal(number.toString())); - } - String text = safeText(String.valueOf(value)); - if (text.isBlank()) { - return BigDecimal.ZERO; - } - try { - return normalizeCozeDecimal(new BigDecimal(text)); - } catch (NumberFormatException ex) { - return text; - } - } - - private BigDecimal normalizeCozeDecimal(BigDecimal value) { - BigDecimal normalized = value.stripTrailingZeros(); - if (normalized.scale() < 0) { - return normalized.setScale(0); - } - return normalized; - } - - private boolean isBlankPrice(Object value) { - return value == null || value instanceof String text && text.isBlank(); - } - - private String cozeGroupKey(SimilarAsinResultRowDto row) { - if (row == null) { - return ""; - } - String groupKey = safeText(row.getGroupKey()); - return groupKey.isBlank() ? rowKey(row) : groupKey; - } - - private String normalize(String value) { - return value == null ? "" : value.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim(); - } - - private String rowKey(SimilarAsinResultRowDto 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 结果不完整(" + partial.resolvedCount() + "/" + partial.expectedCount() + ")"; - } - String message = ex == null ? null : ex.getMessage(); - if (message == null || message.isBlank()) { - return "Coze 调用失败"; - } - if (message.contains("Workflow node execution limit exceeded")) { - return "Coze 工作流节点执行超限"; - } - if (message.contains("Read timed out")) { - return "Coze 调用超时"; - } - return "Coze 调用失败:" + message; - } - - private String stripBearer(String token) { - String normalized = token == null ? "" : token.trim(); - return normalized.regionMatches(true, 0, "Bearer ", 0, 7) ? normalized.substring(7).trim() : normalized; - } - - private String joinUrl(String baseUrl, String path) { - String base = baseUrl == null ? "" : baseUrl.trim(); - String suffix = path == null ? "" : path.trim(); - if (base.endsWith("/") && suffix.startsWith("/")) { - return base + suffix.substring(1); - } - if (!base.endsWith("/") && !suffix.startsWith("/")) { - return base + "/" + suffix; - } - return base + suffix; - } - - private record CozeResult( - String groupKey, - String rowToken, - String rowId, - String asin, - String country, - String sku, - String price, - String title, - String isStock, - String similarity, - String isConform, - String reason, - String category, - String appearance, - String patent, - String result, - String status, - String titleReason, - String appearanceReason, - String patentReason, - String mainUrl, - String puzzleImg1, - String puzzleImg2 - ) { - } - - 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, - String status, - String dataText, - String outputText, - String failureMessage, - String rawResponse, - String credentialName - ) { - 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/similarasin/client/SimilarAsinLlmClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java index a0aad228..8fdba1c7 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinLlmClient.java @@ -22,7 +22,7 @@ import java.util.Map; /** * 货源查询直连 LLM 客户端:调用 OpenAI 兼容 /v1/chat/completions, - * 替代原 Coze 工作流(similarity_asin -> similarity_image -> LLM_chat)链路, + * 替代原工作流(similarity_asin -> similarity_image -> LLM_chat)链路, * 减少一次外部平台中转。链路对齐点见 SimilarAsinLlmService。 */ @Component diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java index ac0cf47a..f3eedc86 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java @@ -42,7 +42,7 @@ import java.util.List; @RequestMapping("/api/similar-asin") @Tag( name = "相似 ASIN 检测", - description = "前端上传 Excel 后由 Java 解析并创建任务;Python 按分组抓取商品数据并回传;Java 再按批调用 Coze 并生成最终 xlsx。" + description = "前端上传 Excel 后由 Java 解析并创建任务;Python 按分组抓取商品数据并回传;Java 再按批调用 LLM 并生成最终 xlsx。" ) public class SimilarAsinController { @@ -134,7 +134,7 @@ public class SimilarAsinController { @PostMapping("/tasks/{taskId}/result") @Operation( summary = "提交 Python 回传结果", - description = "Python 请通过 groups[].items[] 回传分组抓取结果;Java 先原样保存回传数据,再内部攒批调用 Coze。done=true 表示 Python 已完成全部回传,Java 会强制处理剩余未满批的数据并生成最终 xlsx。" + description = "Python 请通过 groups[].items[] 回传分组抓取结果;Java 先原样保存回传数据,再内部攒批调用 LLM。done=true 表示 Python 已完成全部回传,Java 会强制处理剩余未满批的数据并生成最终 xlsx。" ) public ApiResponse result( @Parameter(description = "相似 ASIN 检测任务 ID,任务必须处于 RUNNING 状态", required = true, example = "3938") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParseRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParseRequest.java index e3582921..35f5fd86 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParseRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParseRequest.java @@ -24,22 +24,22 @@ public class SimilarAsinParseRequest { @JsonProperty("ai_prompt") @JsonAlias({"aiPrompt", "prompt"}) - @Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 Coze workflow 时作为 prompt 参数传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。") + @Schema(description = "AI 提示词。非必填;为空时前端会使用默认提示词。后端会保存该提示词,并在调用 LLM 时作为 prompt 参数传入。", example = "请排查这些亚马逊商品在英国及欧洲地区是否存在知识产权侵权风险。") private String aiPrompt; @JsonProperty("api_key") @JsonAlias({"apiKey"}) - @Schema(description = "传递给 Coze workflow parameters.api_key 的任务级密钥。") + @Schema(description = "传递给 LLM 的任务级 api_key。") @NotBlank(message = "密钥不能为空") private String apiKey; @JsonProperty("img_switch") @JsonAlias({"imgSwitch"}) - @Schema(description = "传递给 Coze workflow parameters.img_switch 的图片检测开关,true 为开启,false 为关闭。") + @Schema(description = "传递给 LLM 的 img_switch 图片检测开关,true 为开启,false 为关闭。") private Boolean imgSwitch = Boolean.FALSE; @JsonProperty("category_switch") @JsonAlias({"categorySwitch"}) - @Schema(description = "传递给 Coze workflow parameters.category_switch 的类目检测开关,true 为开启,false 为关闭。") + @Schema(description = "传递给 LLM 的 category_switch 类目检测开关,true 为开启,false 为关闭。") private Boolean categorySwitch = Boolean.FALSE; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParsedPayloadDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParsedPayloadDto.java index 34d18b9b..a45da844 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParsedPayloadDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinParsedPayloadDto.java @@ -15,13 +15,13 @@ public class SimilarAsinParsedPayloadDto { @Schema(description = "AI 提示词") private String aiPrompt; - @Schema(description = "传递给 Coze workflow parameters.api_key 的任务级密钥") + @Schema(description = "传递给 LLM 的任务级 api_key") private String apiKey; - @Schema(description = "传递给 Coze workflow parameters.img_switch 的图片检测开关") + @Schema(description = "传递给 LLM 的 img_switch 图片检测开关") private Boolean imgSwitch = Boolean.FALSE; - @Schema(description = "传递给 Coze workflow parameters.category_switch 的类目检测开关") + @Schema(description = "传递给 LLM 的 category_switch 类目检测开关") private Boolean categorySwitch = Boolean.FALSE; @Schema(description = "本次解析的源文件列表") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java index 20ea9e8f..008d05b1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinResultRowDto.java @@ -27,7 +27,7 @@ public class SimilarAsinResultRowDto { @Schema(description = "主数据分组 key。Python 应从解析结果原样透传,用于把同组子行补回。", example = "uploads/20260426/similar_asin_17.xlsx::2@2") private String groupKey; - @Schema(description = "Excel 中的 id。代表行通常是整数 id 或 n_1,例如 2_1;最终生成 xlsx 时,2_2、2_3 会复用同组 2_1 的 Coze 检测结果。", example = "2_1") + @Schema(description = "Excel 中的 id。代表行通常是整数 id 或 n_1,例如 2_1;最终生成 xlsx 时,2_2、2_3 会复用同组 2_1 的 LLM 检测结果。", example = "2_1") private String id; @Schema(description = "亚马逊 ASIN。后端会统一按大写处理和匹配。", example = "B0CJ8SNXXV") @@ -54,75 +54,75 @@ public class SimilarAsinResultRowDto { @Schema(description = "同类商品图 URL 列表。Python 端原样回传,后端不再去重/截断/与 url 互写。", example = "[\"https://webstatic.aiproxy.vip/output/20260425/103322/demo.jpg\"]") private List urls = new ArrayList<>(); - @Schema(description = "商品标题。Java 调用 Coze 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual") + @Schema(description = "商品标题。Java 调用 LLM 时会放入 title_list;为空时会回退使用 ASIN。", example = "Women Floral Dress Summer Casual") @JsonAlias({"productTitle", "product_title", "itemTitle", "item_title", "商品标题", "商品名称", "标题"}) private String title; @JsonAlias({"is_stock", "isStock", "stock", "是否有货"}) - @Schema(description = "Coze 返回的是否有货结果。", example = "有货", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的是否有货结果。", example = "有货", accessMode = Schema.AccessMode.READ_ONLY) private String isStock; @JsonAlias({"similarity", "similarity_rate", "similarityRate", "相似度"}) - @Schema(description = "Coze 返回的相似度结果。", example = "80%", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的相似度结果。", example = "80%", accessMode = Schema.AccessMode.READ_ONLY) private String similarity; @JsonAlias({"is_conform", "isConform", "conform", "是否符合类目"}) - @Schema(description = "Coze 返回的是否符合类目结果。", example = "符合", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的是否符合类目结果。", example = "符合", accessMode = Schema.AccessMode.READ_ONLY) private String isConform; @JsonAlias({"reason", "原因", "不符合理由"}) - @Schema(description = "Coze 返回的不符合理由。", example = "类目不匹配", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的不符合理由。", example = "类目不匹配", accessMode = Schema.AccessMode.READ_ONLY) private String reason; @JsonAlias({"category", "类目", "产品类目"}) - @Schema(description = "Coze 返回的产品类目。", example = "女装", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的产品类目。", example = "女装", accessMode = Schema.AccessMode.READ_ONLY) private String category; - @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 = "LLM 行处理状态", example = "success", accessMode = Schema.AccessMode.READ_ONLY) private String status; - @Schema(description = "兼容旧版 Coze 返回中的标题维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "兼容旧版 LLM 返回中的标题维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "标题未发现明显商标侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) private String titleRisk; - @Schema(description = "兼容旧版 Coze 返回中的外观维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "兼容旧版 LLM 返回中的外观维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "未发现明显外观设计专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) private String appearanceRisk; @JsonAlias({"patent ", "patent"}) - @Schema(description = "兼容旧版 Coze 返回中的专利维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "未发现明显发明或实用新型专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "兼容旧版 LLM 返回中的专利维度结果字段;当前相似 ASIN 结果文件不再输出该列。", example = "未发现明显发明或实用新型专利侵权风险。", accessMode = Schema.AccessMode.READ_ONLY) private String patentRisk; - @Schema(description = "兼容旧版 Coze 返回中的结论字段;当前相似 ASIN 结果文件不再输出该列。", example = "未发现明显侵权风险", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "兼容旧版 LLM 返回中的结论字段;当前相似 ASIN 结果文件不再输出该列。", example = "未发现明显侵权风险", accessMode = Schema.AccessMode.READ_ONLY) private String conclusion; @JsonAlias({"title_reason", "titleReason"}) - @Schema(description = "Coze 返回的标题维度原因", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的标题维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String titleReason; @JsonAlias({"appearance_reason", "appearanceReason"}) - @Schema(description = "Coze 返回的外观维度原因", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的外观维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String appearanceReason; @JsonAlias({"patent_reason", "patentReason", "patent reason"}) - @Schema(description = "Coze 返回的专利维度原因", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 返回的专利维度原因", accessMode = Schema.AccessMode.READ_ONLY) private String patentReason; @JsonAlias({"main_url", "mainUrl", "main_image_url", "mainImageUrl", "main_img", "mainImg", "主图URL", "主图链接"}) - @Schema(description = "Coze 回包中的亚马逊主图 URL,最终 xlsx 中以嵌入图片形式呈现", example = "https://m.media-amazon.com/images/I/xxx.jpg", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 回包中的亚马逊主图 URL,最终 xlsx 中以嵌入图片形式呈现", example = "https://m.media-amazon.com/images/I/xxx.jpg", accessMode = Schema.AccessMode.READ_ONLY) private String mainUrl; @JsonAlias({"puzzle_img1", "puzzleImg1", "puzzle_img_1", "puzzleImg_1", "拼图1"}) - @Schema(description = "Coze 回包中阿里巴巴搜图候选 1 的 URL,最终 xlsx 中以嵌入图片形式呈现", example = "https://cbu01.alicdn.com/img/ibank/xxx.jpg", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 回包中阿里巴巴搜图候选 1 的 URL,最终 xlsx 中以嵌入图片形式呈现", example = "https://cbu01.alicdn.com/img/ibank/xxx.jpg", accessMode = Schema.AccessMode.READ_ONLY) private String puzzleImg1; @JsonAlias({"puzzle_img2", "puzzleImg2", "puzzle_img_2", "puzzleImg_2", "拼图2"}) - @Schema(description = "Coze 回包中阿里巴巴搜图候选 2 的 URL,最终 xlsx 中以嵌入图片形式呈现", example = "https://cbu01.alicdn.com/img/ibank/yyy.jpg", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "LLM 回包中阿里巴巴搜图候选 2 的 URL,最终 xlsx 中以嵌入图片形式呈现", example = "https://cbu01.alicdn.com/img/ibank/yyy.jpg", accessMode = Schema.AccessMode.READ_ONLY) private String puzzleImg2; public String getUrl() { @@ -196,7 +196,7 @@ public class SimilarAsinResultRowDto { } public boolean hasImageUrl() { - // url(主图)和 urls(同类商品图)任一存在即可作为可送 Coze 的素材。 + // url(主图)和 urls(同类商品图)任一存在即可作为可送 LLM 的素材。 if (url != null && !url.isBlank()) { return true; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinParsedRowVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinParsedRowVo.java index d7848eda..0eed78d2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinParsedRowVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinParsedRowVo.java @@ -42,7 +42,7 @@ public class SimilarAsinParsedRowVo { @Schema(description = "商品 SKU。", example = "SKU-001") private String sku; - @Schema(description = "商品图片 URL 或商品 URL,供 Coze 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg") + @Schema(description = "商品图片 URL 或商品 URL,供 LLM 检测使用。", example = "https://webstatic.aiproxy.vip/output/demo.jpg") private String url; @Schema(description = "商品标题。", example = "Women Floral Dress Summer Casual") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchService.java index dd78ca6c..9e61d25d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchService.java @@ -35,12 +35,12 @@ import java.util.concurrent.atomic.AtomicInteger; /** * P2-11:相似ASIN 图片异步预热服务。 * - *

背景:assemble 阶段({@code assembleResultWorkbook})需要把 Coze 回包中的 main_url / + *

背景:assemble 阶段({@code assembleResultWorkbook})需要把 LLM 回包中的 main_url / * puzzle_img1 / puzzle_img2 下载并 resize 后嵌入 xlsx。当任务行数到 1000+ 时,串行 + * 短池下载会把整个 assemble 拖到 244s / 918s。改造点: * *

    - *
  • 每次 {@code mergeCozeRowsIntoChunk} 拿到新 cozeRows 时,调用 {@link #enqueue} + *
  • 每次 {@code mergeLlmRowsIntoChunk} 拿到新 llmRows 时,调用 {@link #enqueue} * 立即丢入预热队列;同 task 串行排队({@link #inflight}),避免多个 batch 同时打爆图片源站;
  • *
  • 预热成功的缩略图字节落表 {@code biz_task_image_cache}(由 P2-12 提供),跨任务复用;
  • *
  • 所有路径 best-effort:预热失败、DB 写入失败都吞掉,assemble 阶段会回退到原下载链路兜底。
  • @@ -125,7 +125,7 @@ public class SimilarAsinImagePrefetchService { } /** - * P2-11:由 {@code mergeCozeRowsIntoChunk} 调用,把 cozeRows 中的图片 url 异步丢入预热队列。 + * P2-11:由 {@code mergeLlmRowsIntoChunk} 调用,把 llmRows 中的图片 url 异步丢入预热队列。 * 同 task 串行入队(用 inflight map 排队),避免多个 batch 同时打爆图片源站。 */ public void enqueue(Long taskId, List urls) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java index fe6bf2b1..80bec3e3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmService.java @@ -28,8 +28,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; /** - * 货源查询直连 LLM 编排服务:复刻 Coze 工作流 similarity_asin - * (含 similarity_image、LLM_chat 子工作流)的完整语义,去掉 Coze 中转。 + * 货源查询直连 LLM 编排服务:复刻原工作流 similarity_asin + * (含 similarity_image、LLM_chat 子工作流)的完整语义,去掉工作流中转。 * * 链路对齐点(按工作流节点): * 1. 图片准备 batch(103226,无条件执行):alibaba 前 8 张拼图1、8~16 张拼图2 diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index a2df65a2..5a50b428 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -11,14 +11,12 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.exception.TaskOwnerMismatchException; import com.nanri.aiimage.common.service.DistributedJobLockService; -import com.nanri.aiimage.common.util.CozeGroupResultPropagator; +import com.nanri.aiimage.common.util.GroupResultPropagator; import com.nanri.aiimage.common.util.FailedStatusRowFilter; import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.config.StorageProperties; -import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinFilterConditionAddRequest; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto; @@ -127,14 +125,14 @@ public class SimilarAsinTaskService { 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_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"; - /** orphan-coze-rows: 跨 chunk merge 时未匹配到任何 chunk 的回流行兜底落库前缀。 */ - private static final String ORPHAN_SCOPE_KEY_PREFIX = "orphan-coze-rows:"; + private static final String LLM_STATUS_SUBMITTED = "SUBMITTED"; + private static final String LLM_STATUS_RUNNING = "RUNNING"; + private static final String LLM_STATUS_DONE = "DONE"; + private static final String LLM_STATUS_FAILED = "FAILED"; + /** orphan-llm-rows: 跨 chunk merge 时未匹配到任何 chunk 的回流行兜底落库前缀。 */ + private static final String ORPHAN_SCOPE_KEY_PREFIX = "orphan-llm-rows:"; /** orphan 行兜底状态,写入时直接置为 DONE,避免被 stale-recovery 扫描到。 */ - private static final String ORPHAN_COZE_ROW_ERROR = "orphan-coze-row(no submitted chunk)"; + private static final String ORPHAN_LLM_ROW_ERROR = "orphan-llm-row(no submitted chunk)"; 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 RESULT_ROWS_READ_RETRY_LIMIT = 3; @@ -143,10 +141,6 @@ public class SimilarAsinTaskService { private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L; private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5); private static final long TASK_LOCK_WAIT_MILLIS = 10000L; - private static final Duration COZE_SUBMIT_LOCK_TTL = Duration.ofMinutes(2); - // P0-4:原 COZE_SUBMIT_LOCK_WAIT_MILLIS / COZE_SUBMIT_LOCK_RETRY_DELAY_MILLIS 已下沉到 - // SimilarAsinProperties.cozeSubmitLockWaitMillis / cozeSubmitLockRetryDelayMillis, - // 由 acquireCozeSubmitLock 在方法内读取,并支持指数退避。 private static final int PARSE_RESPONSE_PREVIEW_LIMIT = 100; /** 预览行/预览组配置上限:超过该值 clamp,避免响应体无界增长。 */ private static final int PARSE_RESPONSE_PREVIEW_LIMIT_MAX = 1000; @@ -244,24 +238,6 @@ public class SimilarAsinTaskService { } } } - /** - * P0-2 最小风险变体:poll 调度阶段并发预取 Coze HTTP 结果时, - * 控制对单个 Coze 后端的并发度。8 与 cozeTaskExecutor 的 12 并发上限对齐留 4 余量, - * 避免一次 poll 把所有凭证打满触发 720701002 / 限流。 - */ - private static final int COZE_POLL_PREFETCH_CONCURRENCY = 8; - /** - * P0-2:等待单个 stateId 预取响应的最大时间。 - * Coze pollWorkflow 自带读超时,这里再加 30s 兜底,避免一个慢请求阻塞整轮 apply。 - */ - private static final long COZE_POLL_PREFETCH_WAIT_MILLIS = 30_000L; - - /** - * Per-credential 最近一次 Coze 提交完成时间戳。 - * submitCozeWorkflowThrottled 在提交前计算需要 sleep 的时间, - * sleep 在锁外执行,避免持锁等待造成的单凭证吞吐瓶颈。 - */ - private final ConcurrentHashMap lastCozeSubmitAtByCredential = new ConcurrentHashMap<>(); /** * P1-1:per-task 最近 10 次提交结果(true=720712008 命中)。 @@ -285,18 +261,10 @@ public class SimilarAsinTaskService { private final ConcurrentHashMap> recentPoisonByTask = new ConcurrentHashMap<>(); private final ConcurrentHashMap recentPoisonCursorByTask = new ConcurrentHashMap<>(); - /** - * P0-4 / P2-9:poll 调度链路上的"按 task 单次复用"上下文。 - * pollPendingCozeStatesForTask 在锁内一次性加载 FileTaskEntity + allRowsByBaseId(5000 行 JSON 反序列化只发生一次), - * 之后 pollPendingCozeStateLocked / retryFailedCozeBatchState / splitRetryFailedCozeBatchState 等链路统一通过 - * pollScopeCache.get() 复用同一份对象,避免重复 selectById 与 deserialize。 - * stateScope 退出时务必 clear,防止异步线程间内容串号。 - */ - private final ThreadLocal pollScopeCache = new ThreadLocal<>(); /** * 仅命中确定性输入失败: - * - "fields cannot be extracted from null values"(Coze 工作流空输入兜底) - * - 错误码 720712000(Coze 显式输入校验失败) + * - "fields cannot be extracted from null values"(工作流空输入兜底) + * - 错误码 720712000(显式输入校验失败) * 注意:曾经裸串匹配 "null values" 会把 "got null values during merge" 等 * 非确定性场景误判成 deterministic,导致行被直接 markFailed 而不进 retry。 */ @@ -326,7 +294,7 @@ public class SimilarAsinTaskService { FailedStatusRowFilter.canonicalizeHeader("is_conform"), FailedStatusRowFilter.canonicalizeHeader("conform") ); - private static final Set COZE_RESULT_HEADER_ALIASES = Set.of( + private static final Set LLM_RESULT_HEADER_ALIASES = Set.of( FailedStatusRowFilter.canonicalizeHeader("是否有货"), FailedStatusRowFilter.canonicalizeHeader("is_stock"), FailedStatusRowFilter.canonicalizeHeader("相似度"), @@ -352,7 +320,6 @@ public class SimilarAsinTaskService { private final TaskChunkMapper taskChunkMapper; private final SimilarAsinFilterConditionMapper filterConditionMapper; private final ObjectMapper objectMapper; - private final SimilarAsinCozeClient cozeClient; private final SimilarAsinLlmService similarAsinLlmService; private final SimilarAsinTaskCacheService taskCacheService; private final SimilarAsinProperties properties; @@ -363,16 +330,15 @@ public class SimilarAsinTaskService { private final PlatformTransactionManager transactionManager; private final DistributedJobLockService distributedJobLockService; private final InstanceMetadata instanceMetadata; - private final CozeCredentialPoolService cozeCredentialPoolService; private final SimilarAsinImageEmbedder imageEmbedder; /** - * P2-11:图片异步预热服务。merge cozeRows 时立即丢入预热队列,assemble 阶段优先消费 DB cache。 + * P2-11:图片异步预热服务。merge LLM 结果行时立即丢入预热队列,assemble 阶段优先消费 DB cache。 * best-effort:service 内部所有异常都已吞掉,不影响主流程。 */ private final SimilarAsinImagePrefetchService imagePrefetchService; @Autowired - @Qualifier("cozeTaskExecutor") - private TaskExecutor cozeTaskExecutor; + @Qualifier("taskQueueExecutor") + private TaskExecutor taskQueueExecutor; /** * P3-1:assembleResultWorkbook 多源文件并发化使用的固定线程池。 @@ -387,25 +353,9 @@ public class SimilarAsinTaskService { */ private final ExecutorService assembleExecutor = Executors.newFixedThreadPool(2, namedThreadFactory("similar-asin-assemble")); - /** Reused poll workers avoid creating/destroying a new executor every 30s. */ - private final ExecutorService cozePollPrefetchExecutor = Executors.newFixedThreadPool( - COZE_POLL_PREFETCH_CONCURRENCY, namedThreadFactory("similar-asin-coze-prefetch")); - - @PreDestroy - void shutdownAssembleExecutor() { - assembleExecutor.shutdownNow(); - cozePollPrefetchExecutor.shutdownNow(); - } - - /** - * Credential names normally come from configuration, but stale/rotated - * credentials can otherwise leave throttle entries in the process forever. - */ - @Scheduled(fixedDelayString = "${aiimage.similar-asin.coze-submit-throttle-cleanup-delay-ms:600000}") - void cleanupCozeThrottleState() { - long cutoff = System.currentTimeMillis() - Duration.ofHours(1).toMillis(); - lastCozeSubmitAtByCredential.entrySet().removeIf(entry -> - entry.getValue() == null || entry.getValue() < cutoff); + /** 单元测试清理入口:关闭 assemble 固定线程池,避免测试进程残留线程。 */ + public void shutdownAssembleExecutor() { + assembleExecutor.shutdown(); } private static ThreadFactory namedThreadFactory(String prefix) { @@ -791,7 +741,7 @@ public class SimilarAsinTaskService { } else { applyFinalizeSideEffects(persisted.finalizeResult()); } - scheduleCozePipelineForSubmittedChunk(context); + scheduleLlmPipelineForSubmittedChunk(context); } public void deleteTask(Long taskId, Long userId) { @@ -1055,7 +1005,7 @@ public class SimilarAsinTaskService { /** * 从解析载荷统一恢复全量行:优先 items(新规范结构),其次 allItems(旧结构), * 最后 groups 展开(最旧结构)。新旧结构均可恢复完整行集合,供 rowCount、 - * Coze 候选加载与结果文件组装复用。 + * LLM 候选加载与结果文件组装复用。 */ static List resolveAllRows(SimilarAsinParsedPayloadDto payload) { if (payload == null) { @@ -1215,7 +1165,7 @@ public class SimilarAsinTaskService { return null; } - private void submitCozeForSubmittedChunk(SubmitContext context) { + private void submitLlmForSubmittedChunk(SubmitContext context) { if (context == null || context.task() == null || context.task().getId() == null) { return; } @@ -1255,17 +1205,17 @@ public class SimilarAsinTaskService { return; } Map> allRowsByBaseId = loadAllRowsByBaseId(task); - boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); - if (pendingCoze) { + boolean pendingLlm = submitLlmBatches(task, result, job, chunks, allRowsByBaseId); + if (pendingLlm) { taskFileJobService.touchRunningIfStale(job.getId(), properties.getDbJobTouchIntervalMillis()); touchJavaSideTaskActivity(task.getId()); } else if (isResultSubmissionComplete(task.getId())) { - maybeFinalizeCozeJobLocked(task.getId(), new CozeBatchContext( + maybeFinalizeLlmJobLocked(task.getId(), new LlmBatchContext( job.getId(), result.getId(), context.scopeHash(), context.chunkIndex(), 1, 1, currentInstanceId(), 0, null, null)); } } - private void scheduleCozePipelineForSubmittedChunk(SubmitContext context) { + private void scheduleLlmPipelineForSubmittedChunk(SubmitContext context) { if (context == null || context.task() == null || context.task().getId() == null) { return; } @@ -1289,7 +1239,7 @@ public class SimilarAsinTaskService { if (job == null || "SUCCESS".equals(job.getStatus())) { return; } - taskFileJobService.requeue(job.getId(), "Similar ASIN result uploaded, scheduling Coze/file assembly"); + taskFileJobService.requeue(job.getId(), "Similar ASIN result uploaded, scheduling LLM/file assembly"); touchJavaSideTaskActivity(task.getId()); } @@ -1314,7 +1264,7 @@ public class SimilarAsinTaskService { } Long taskId = task.getId(); boolean uploadComplete = isResultSubmissionComplete(taskId); - long pendingCozeStates = countPendingCozeStates(taskId); + long pendingLlmStates = countPendingLlmStates(taskId); long activeAssembleJobs = taskFileJobService.countActiveAssembleJobs(taskId, MODULE_TYPE); if (!hasPersistedResultRows(taskId)) { return false; @@ -1324,13 +1274,13 @@ public class SimilarAsinTaskService { if (forcedScopes <= 0) { return false; } - log.warn("[similar-asin] Python 回传心跳超时,已封口上传并继续 Coze/文件收尾 taskId={} pendingCozeStates={} activeAssembleJobs={} forcedScopes={}", - taskId, pendingCozeStates, activeAssembleJobs, forcedScopes); + log.warn("[similar-asin] Python 回传心跳超时,已封口上传并继续 LLM/文件收尾 taskId={} pendingLlmStates={} activeAssembleJobs={} forcedScopes={}", + taskId, pendingLlmStates, activeAssembleJobs, forcedScopes); } else { - log.warn("[similar-asin] Python 超时恢复继续推进 Coze/文件收尾 taskId={} pendingCozeStates={} activeAssembleJobs={}", - taskId, pendingCozeStates, activeAssembleJobs); + log.warn("[similar-asin] Python 超时恢复继续推进 LLM/文件收尾 taskId={} pendingLlmStates={} activeAssembleJobs={}", + taskId, pendingLlmStates, activeAssembleJobs); } - submitCozeForSubmittedChunk(new SubmitContext(task, null, null, null, true, null, null)); + submitLlmForSubmittedChunk(new SubmitContext(task, null, null, null, true, null, null)); touchJavaSideTaskActivity(taskId); return true; } @@ -1342,7 +1292,7 @@ public class SimilarAsinTaskService { List inputStates = taskScopeStateMapper.selectList(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .isNull(TaskScopeStateEntity::getCozeStatus) + .isNull(TaskScopeStateEntity::getLlmStatus) .orderByDesc(TaskScopeStateEntity::getUpdatedAt)); if (inputStates == null || inputStates.isEmpty()) { TaskChunkEntity latestChunk = taskChunkMapper.selectOne(new LambdaQueryWrapper() @@ -1373,7 +1323,7 @@ public class SimilarAsinTaskService { state.setLastChunkAt(now); } state.setUpdatedAt(now); - state.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}"); + state.setStateJson("{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}"); taskScopeStateMapper.updateById(state); updated++; } @@ -1386,7 +1336,7 @@ public class SimilarAsinTaskService { Integer chunkTotal, String error, boolean completed, - boolean cozeDone) { + boolean llmDone) { TaskScopeStateEntity scope = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) @@ -1409,9 +1359,9 @@ public class SimilarAsinTaskService { scope.setLastError(error); scope.setCompleted(completed || Integer.valueOf(1).equals(scope.getCompleted()) ? 1 : 0); scope.setUpdatedAt(now); - scope.setStateJson(cozeDone - ? "{\"phase\":\"RECEIVED\",\"coze\":\"DONE\"}" - : "{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}"); + scope.setStateJson(llmDone + ? "{\"phase\":\"RECEIVED\",\"llm\":\"DONE\"}" + : "{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}"); if (scope.getId() == null) { try { taskScopeStateMapper.insert(scope); @@ -1436,9 +1386,9 @@ public class SimilarAsinTaskService { scope.setLastError(error); scope.setCompleted(completed || Integer.valueOf(1).equals(scope.getCompleted()) ? 1 : 0); scope.setUpdatedAt(now); - scope.setStateJson(cozeDone - ? "{\"phase\":\"RECEIVED\",\"coze\":\"DONE\"}" - : "{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}"); + scope.setStateJson(llmDone + ? "{\"phase\":\"RECEIVED\",\"llm\":\"DONE\"}" + : "{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}"); } } taskScopeStateMapper.updateById(scope); @@ -1450,11 +1400,11 @@ public class SimilarAsinTaskService { return template.execute(status -> action.get()); } - private List applyCozeInBatches(List items, FileTaskEntity task) { - return applyCozeInBatches(items, task, null); + private List applyLlmInBatches(List items, FileTaskEntity task) { + return applyLlmInBatches(items, task, null); } - private List applyCozeInBatches(List items, + private List applyLlmInBatches(List items, FileTaskEntity task, Runnable progressHook) { if (items == null || items.isEmpty()) { @@ -1464,10 +1414,10 @@ public class SimilarAsinTaskService { String apiKey = readApiKey(task); boolean imgSwitch = readImgSwitch(task); boolean categorySwitch = readCategorySwitch(task); - int batchSize = resolveCozeBatchSize(imgSwitch); + int batchSize = resolveLlmBatchSize(imgSwitch); 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, imgSwitch, categorySwitch)); + result.addAll(similarAsinLlmService.inspectRows(items.subList(i, Math.min(i + batchSize, items.size())), prompt, apiKey, imgSwitch, categorySwitch)); if (progressHook != null) { progressHook.run(); } @@ -1517,7 +1467,7 @@ public class SimilarAsinTaskService { /** * Task 10:把 chunk 结果行建立成 rowKey → chunkKey 的批量索引。 - * 供 assignCozeRowsToChunks 使用,把跨 chunk 线性扫描降为 O(1) 查找。 + * 供 assignLlmRowsToChunks 使用,把跨 chunk 线性扫描降为 O(1) 查找。 * 同一 rowKey 出现在多个 chunk 时保留第一个(putIfAbsent),行为确定。 */ Map indexRowsByChunkKey(Map> rowsByChunk) { @@ -1540,17 +1490,17 @@ public class SimilarAsinTaskService { } /** - * Task 11:Coze 结果合并前按稳定 rowKey 一次性去重。 + * Task 11:LLM 结果合并前按稳定 rowKey 一次性去重。 * 优先 rowToken,缺失时回退归一化的 legacy key(id::ASIN::country), * 保留首次出现顺序。合并路径此前对每个重复行都重复 expand/分配/写回,是 O(n²) 热点。 */ - List dedupeRowsByRowKey(List cozeRows) { + List dedupeRowsByRowKey(List llmRows) { List deduped = new ArrayList<>(); - if (cozeRows == null || cozeRows.isEmpty()) { + if (llmRows == null || llmRows.isEmpty()) { return deduped; } Set seenKeys = new java.util.HashSet<>(); - for (SimilarAsinResultRowDto row : cozeRows) { + for (SimilarAsinResultRowDto row : llmRows) { if (row == null) { continue; } @@ -1564,26 +1514,26 @@ public class SimilarAsinTaskService { } /** - * Task 10:基于 rowKey 索引为 coze 回传行分配归属 chunk。 + * Task 10:基于 rowKey 索引为 llm 回传行分配归属 chunk。 * 命中索引 → 归属该 chunk;未命中且有有效 fallback(chunkScopeHash + chunkIndex) * 且 fallback chunk 存在 → 归属 fallback;否则进 orphan 列表。 * 与原实现逐 chunk 线性扫描语义完全一致,但每个行查找降为 O(1)。 */ - Map> assignCozeRowsToChunks( + Map> assignLlmRowsToChunks( Map> rowsByChunk, - List cozeRows, + List llmRows, Map rowKeyIndex, String chunkScopeHash, Integer chunkIndex, List orphans) { Map> mergeRowsByChunk = new LinkedHashMap<>(); - if (cozeRows == null || cozeRows.isEmpty() || orphans == null) { + if (llmRows == null || llmRows.isEmpty() || orphans == null) { return mergeRowsByChunk; } String fallbackKey = chunkScopeHash != null && !chunkScopeHash.isBlank() && chunkIndex != null ? chunkStorageKey(chunkScopeHash, chunkIndex) : null; boolean fallbackValid = fallbackKey != null && rowsByChunk != null && rowsByChunk.containsKey(fallbackKey); - for (SimilarAsinResultRowDto expandedRow : cozeRows) { + for (SimilarAsinResultRowDto expandedRow : llmRows) { if (expandedRow == null) { continue; } @@ -1600,14 +1550,14 @@ public class SimilarAsinTaskService { .put(rowKey, expandedRow); } else { orphans.add(expandedRow); - log.error("[similar-asin] coze row has no submitted chunk rowKey={} asin={} country={}", + log.error("[similar-asin] llm row has no submitted chunk rowKey={} asin={} country={}", rowKey, expandedRow.getAsin(), expandedRow.getCountry()); } } return mergeRowsByChunk; } - private void applyCozeToPersistedChunks(FileTaskEntity task, Runnable progressHook) { + private void applyLlmToPersistedChunks(FileTaskEntity task, Runnable progressHook) { if (task == null || task.getId() == null) { return; } @@ -1616,20 +1566,20 @@ public class SimilarAsinTaskService { .eq(TaskChunkEntity::getTaskId, task.getId()) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) .orderByAsc(TaskChunkEntity::getChunkIndex)); - log.info("[similar-asin] async coze start taskId={} chunks={}", task.getId(), chunks.size()); + log.info("[similar-asin] async llm start taskId={} chunks={}", task.getId(), chunks.size()); for (TaskChunkEntity chunk : chunks) { Map persistedRows = readChunkRows(chunk); if (persistedRows.isEmpty()) { continue; } - List unresolvedRows = collectPendingCozeRows(persistedRows.values()); + List unresolvedRows = collectPendingLlmRows(persistedRows.values()); if (unresolvedRows.isEmpty()) { continue; } - List cozeRows = applyCozeInBatches(unresolvedRows, task, progressHook); - mergeChunkPayload(task.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), cozeRows); - log.info("[similar-asin] async coze chunk merged taskId={} chunk={} unresolved={} merged={}", - task.getId(), chunk.getChunkIndex(), unresolvedRows.size(), cozeRows.size()); + List llmRows = applyLlmInBatches(unresolvedRows, task, progressHook); + mergeChunkPayload(task.getId(), chunk.getScopeHash(), chunk.getChunkIndex(), llmRows); + log.info("[similar-asin] async llm chunk merged taskId={} chunk={} unresolved={} merged={}", + task.getId(), chunk.getChunkIndex(), unresolvedRows.size(), llmRows.size()); } } @@ -1742,7 +1692,7 @@ public class SimilarAsinTaskService { it.remove(); } } - persistOrphanCozeRows(taskId, demoted); + persistOrphanLlmRows(taskId, demoted); log.warn("[similar-asin] chunk merge row-limit exceeded taskId={} chunk={} mergedRows={} limit={} demoted={}", taskId, chunk.getChunkIndex(), mergedRowCountOf(persistedRows, rows), getChunkMergeMaxRows(), demoted.size()); } @@ -1763,7 +1713,7 @@ public class SimilarAsinTaskService { } } if (!demoted.isEmpty()) { - persistOrphanCozeRows(taskId, demoted); + persistOrphanLlmRows(taskId, demoted); log.warn("[similar-asin] chunk merge byte-limit exceeded taskId={} rows={} demoted={} releasedBytes={}", taskId, demoted.size(), demoted.size(), releasedBytes); } @@ -1808,34 +1758,34 @@ public class SimilarAsinTaskService { } } - private List collectPendingCozeRows(java.util.Collection rows) { + private List collectPendingLlmRows(java.util.Collection rows) { if (rows == null) { return List.of(); } List pendingRows = new ArrayList<>(); for (SimilarAsinResultRowDto row : rows) { - if (row != null && !hasResolvedCozeFields(row) && shouldInspectRow(row)) { + if (row != null && !hasResolvedLlmFields(row) && shouldInspectRow(row)) { pendingRows.add(row); } } return pendingRows; } - private List collectPendingCozeCandidates(List chunks, + private List collectPendingLlmCandidates(List chunks, Map> allRowsByBaseId) { if (chunks == null || chunks.isEmpty()) { return List.of(); } - List candidates = new ArrayList<>(); + List candidates = new ArrayList<>(); for (TaskChunkEntity chunk : chunks) { Map persistedRows = readChunkRows(chunk); if (persistedRows.isEmpty()) { continue; } List unresolvedRows = - enrichRowsForCoze(collectPendingCozeRows(persistedRows.values()), allRowsByBaseId); + enrichRowsForLlm(collectPendingLlmRows(persistedRows.values()), allRowsByBaseId); for (SimilarAsinResultRowDto row : unresolvedRows) { - candidates.add(new CozeCandidate(chunk.getScopeHash(), chunk.getChunkIndex(), row)); + candidates.add(new LlmCandidate(chunk.getScopeHash(), chunk.getChunkIndex(), row)); } } return candidates; @@ -1984,8 +1934,8 @@ public class SimilarAsinTaskService { /** * 打印 Python 端回传给 Java 的每一行 row 关键字段,确认 url(主图)/ urls(同类商品图)/ title / sku - * 是否按预期到达。该日志与 SimilarAsinCozeClient 的 coze items diff 日志成对, - * 便于排查"Python 回传了什么、Java 又把什么发到 Coze"。 + * 是否按预期到达。该日志与直连 LLM 的行级结果日志成对, + * 便于排查"Python 回传了什么、Java 又把什么提交给 LLM"。 * Task 19:改为 DEBUG 级别并按行采样(每 20 行记一行),减少大任务日志量。 */ private void logPythonInboundRows(List rows) { @@ -2066,8 +2016,8 @@ public class SimilarAsinTaskService { } } - private int resolveCozeBatchSize(boolean imgSwitch) { - int configured = imgSwitch ? properties.getCozeBatchSize() : properties.getCozeTextOnlyBatchSize(); + private int resolveLlmBatchSize(boolean imgSwitch) { + int configured = imgSwitch ? properties.getLlmBatchSize() : properties.getLlmTextOnlyBatchSize(); return Math.max(1, configured); } @@ -2158,7 +2108,7 @@ public class SimilarAsinTaskService { if (dispatchWhenIdle && job != null && "RUNNING".equals(job.getStatus()) - && countPendingCozeStates(task.getId()) == 0) { + && countPendingLlmStates(task.getId()) == 0) { taskFileJobService.requeue(job.getId(), "Python 上传完成,正在组装 xlsx"); } return job; @@ -2214,48 +2164,48 @@ public class SimilarAsinTaskService { .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) .orderByAsc(TaskChunkEntity::getChunkIndex)); Map> allRowsByBaseId = loadAllRowsByBaseId(task); - int batchSize = resolveCozeBatchSize(readImgSwitch(task)); - int cozeWorkUnits = countCozeWorkUnits(chunks, batchSize); - int totalProgressUnits = Math.max(3, cozeWorkUnits + 3); - if (countPendingCozeStates(task.getId()) > 0) { - finalizeTimedOutCozeStatesForTask(task.getId()); + int batchSize = resolveLlmBatchSize(readImgSwitch(task)); + int llmWorkUnits = countLlmWorkUnits(chunks, batchSize); + int totalProgressUnits = Math.max(3, llmWorkUnits + 3); + if (countPendingLlmStates(task.getId()) > 0) { + finalizeTimedOutLlmStatesForTask(task.getId()); } - if (countPendingCozeStates(task.getId()) > 0) { + if (countPendingLlmStates(task.getId()) > 0) { taskFileJobService.touchRunningIfStale(job.getId(), properties.getDbJobTouchIntervalMillis()); touchJavaSideTaskActivity(task.getId()); - saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze 已提交,等待结果"); + saveFileBuildProgress(task, job, totalProgressUnits, 1, "LLM 已提交,等待结果"); return false; } - // P0-3:stale-recovery / 异常路径兜底 —— maybeFinalizeCozeJob 可能未能成功 flush + // P0-3:stale-recovery / 异常路径兜底 —— maybeFinalizeLlmJob 可能未能成功 flush // (Redis 锁竞争 / 异常退出),在 assemble 阶段读 chunk 之前再 flush 一次,幂等。 try { - flushBufferedCozeResults(task.getId()); + flushLlmBufferedResults(task.getId()); chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() .eq(TaskChunkEntity::getTaskId, task.getId()) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) .orderByAsc(TaskChunkEntity::getChunkIndex)); } catch (Exception flushEx) { - log.warn("[相似ASIN] 结果文件任务刷新缓冲区 Coze 结果失败 任务ID={} 文件任务ID={} 错误={}", + log.warn("[相似ASIN] 结果文件任务刷新缓冲区 LLM 结果失败 任务ID={} 文件任务ID={} 错误={}", task.getId(), job.getId(), flushEx.getMessage(), flushEx); - throw new BusinessException("刷新缓冲区 Coze 结果失败:" + throw new BusinessException("刷新缓冲区 LLM 结果失败:" + firstNonBlank(flushEx.getMessage(), "未知错误")); } - saveFileBuildProgress(task, job, totalProgressUnits, 0, "正在提交 Coze"); - boolean pendingCoze = submitCozeBatches(task, result, job, chunks, allRowsByBaseId); - if (pendingCoze) { + saveFileBuildProgress(task, job, totalProgressUnits, 0, "正在提交 LLM"); + boolean pendingLlm = submitLlmBatches(task, result, job, chunks, allRowsByBaseId); + if (pendingLlm) { taskFileJobService.touchRunningIfStale(job.getId(), properties.getDbJobTouchIntervalMillis()); touchJavaSideTaskActivity(task.getId()); - saveFileBuildProgress(task, job, totalProgressUnits, 1, "Coze 已提交,等待结果"); + saveFileBuildProgress(task, job, totalProgressUnits, 1, "LLM 已提交,等待结果"); return false; } if (STATUS_RUNNING.equals(task.getStatus()) && !isResultSubmissionComplete(task.getId())) { taskFileJobService.touchRunningIfStale(job.getId(), properties.getDbJobTouchIntervalMillis()); touchJavaSideTaskActivity(task.getId()); - saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, cozeWorkUnits), - "等待 Python 上传,每 " + batchSize + " 行提交一次 Coze"); + saveFileBuildProgress(task, job, totalProgressUnits, Math.max(1, llmWorkUnits), + "等待 Python 上传,每 " + batchSize + " 行提交一次 LLM"); return false; } - completeCozeFileJob(task, result, job, totalProgressUnits, cozeWorkUnits); + completeLlmFileJob(task, result, job, totalProgressUnits, llmWorkUnits); return true; } @@ -2326,27 +2276,25 @@ public class SimilarAsinTaskService { taskFileJobService.markFailureFinalized(job.getId(), message); } - @Scheduled(fixedDelayString = "${aiimage.similar-asin.coze-poll-delay-ms:30000}") - public void pollPendingCozeJobs() { + @Scheduled(fixedDelayString = "${aiimage.similar-asin.llm-poll-delay-ms:30000}") + public void pollPendingLlmJobs() { DistributedJobLockService.LockHandle lockHandle = - distributedJobLockService.tryLock("similar-asin:coze-poll", Duration.ofMinutes(1)); + distributedJobLockService.tryLock("similar-asin:llm-poll", Duration.ofMinutes(1)); if (lockHandle == null) { return; } try (lockHandle) { - if (properties.isDirectLlmEnabled()) { - // 直连模式:轮询器退化为"兜底调度器",把还挂着 PENDING 的任务 - // 重新调度一次批量提交(submitLlmBatch 同步直连),新任务本就走直连。 - schedulePendingLlmBatches(); - } - List states = listOwnedPendingCozeStates(); + // 直连模式:轮询器退化为"兜底调度器",把还挂着 PENDING 的任务 + // 重新调度一次批量提交(submitLlmBatch 同步直连),新任务本就走直连。 + schedulePendingLlmBatches(); + List states = listOwnedPendingLlmStates(); if (states == null || states.isEmpty()) { return; } - log.info("[similar-asin] coze poll picked pending states count={}", states.size()); + log.info("[similar-asin] llm poll fallback picked pending states count={}", states.size()); Map> stateIdsByTaskId = new LinkedHashMap<>(); for (TaskScopeStateEntity state : states) { - if (state == null || state.getId() == null || state.getTaskId() == null || !isCozeStateOwnedByCurrentInstance(state)) { + if (state == null || state.getId() == null || state.getTaskId() == null || !isLlmStateOwnedByCurrentInstance(state)) { continue; } stateIdsByTaskId.computeIfAbsent(state.getTaskId(), key -> new ArrayList<>()).add(state.getId()); @@ -2354,7 +2302,7 @@ public class SimilarAsinTaskService { for (Map.Entry> entry : stateIdsByTaskId.entrySet()) { Long taskId = entry.getKey(); List stateIds = new ArrayList<>(entry.getValue()); - cozeTaskExecutor.execute(() -> pollPendingCozeStatesForTask(taskId, stateIds)); + taskQueueExecutor.execute(() -> resubmitPendingLlmStates(taskId, stateIds)); } } } @@ -2364,7 +2312,7 @@ public class SimilarAsinTaskService { * 批量提交,新批次走 submitLlmBatch 同步直连,由提交路径落 DONE 缓冲/merge。 */ private void schedulePendingLlmBatches() { - List states = listOwnedPendingCozeStates(); + List states = listOwnedPendingLlmStates(); if (states == null || states.isEmpty()) { return; } @@ -2377,7 +2325,7 @@ public class SimilarAsinTaskService { log.info("[similar-asin] direct-llm poll fallback scheduling pending tasks count={}", taskIds.size()); for (Long taskId : taskIds) { - cozeTaskExecutor.execute(() -> { + taskQueueExecutor.execute(() -> { TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(taskId, 0L); if (taskLockHandle == null) { return; @@ -2397,13 +2345,13 @@ public class SimilarAsinTaskService { .eq(TaskChunkEntity::getTaskId, task.getId()) .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) .orderByAsc(TaskChunkEntity::getChunkIndex)); - submitCozeBatches(task, result, job, chunks, loadAllRowsByBaseId(task)); + submitLlmBatches(task, result, job, chunks, loadAllRowsByBaseId(task)); } }); } } - private void pollPendingCozeStatesForTask(Long taskId, List stateIds) { + private void resubmitPendingLlmStates(Long taskId, List stateIds) { if (taskId == null || stateIds == null || stateIds.isEmpty()) { return; } @@ -2411,161 +2359,37 @@ public class SimilarAsinTaskService { if (taskLockHandle == null) { return; } - // P0-4 / P2-9:注册 thread-local cache,下游 task / allRowsByBaseId 走 cache 单一来源。 - PollScopeCache previous = pollScopeCache.get(); - pollScopeCache.set(new PollScopeCache(taskId)); try (taskLockHandle) { - // P0-2 最小风险变体:先并发预取所有 stateId 的 Coze HTTP 响应(受 COZE_POLL_PREFETCH_CONCURRENCY 限流), - // 把单 state ≈1.5s 的 HTTP RTT 从 N 次串行压缩到 ≈1.5s 一轮;预取结果存入 thread-local cache, - // 由后续 pollPendingCozeStateLocked → pollWorkflowCached 直接复用,不破坏现有 task 锁/状态机/merge 顺序语义。 - prefetchCozePollResponses(stateIds); - try { - for (Long stateId : stateIds) { - if (stateId == null) { - continue; - } - // The task lock is already held here. The outer wrapper - // would perform an extra selectById for every state before - // entering this method. - pollPendingCozeStateLocked(stateId); + for (Long stateId : stateIds) { + if (stateId == null) { + continue; + } + TaskScopeStateEntity state = taskScopeStateMapper.selectById(stateId); + if (state == null) { + continue; + } + try { + submitLlmBatchForPendingState(state); + } catch (Exception ex) { + log.warn("[similar-asin] llm pending state resubmit failed taskId={} stateId={} err={}", + taskId, stateId, firstNonBlank(ex.getMessage(), ex.getClass().getSimpleName())); } - } finally { - clearPollPrefetchCache(); - } - } finally { - if (previous == null) { - pollScopeCache.remove(); - } else { - pollScopeCache.set(previous); } } } - - /** - * P0-2 最小风险变体:在持有 task 锁、未推进状态机的前提下,先并发执行 - * cozeClient.pollWorkflow 的 HTTP 调用,把响应缓存到当前 PollScopeCache。 - * - * 设计要点: - * - 不在此处 claim / mark RUNNING / merge / markTerminal —— 只做"读 HTTP 响应",所有持久化在锁内串行 apply 阶段。 - * - 任意 stateId 预取失败仅记录日志,apply 阶段会继续走原同步分支(catch 块走 updateCozeStateRunning 兜底)。 - * - 用独立 ExecutorService 而不是 cozeTaskExecutor,避免与外层 Semaphore(12) 嵌套死锁 - * (pollPendingCozeStatesForTask 自身在 cozeTaskExecutor 中执行)。 - */ - private void prefetchCozePollResponses(List stateIds) { - if (stateIds == null || stateIds.size() <= 1) { - return; - } - PollScopeCache cache = pollScopeCache.get(); - if (cache == null) { - return; - } - // 先批量加载 state,避免每个预取线程各自查 DB;同时筛掉 claim 之前就不可能 poll 的状态。 - List candidates = new ArrayList<>(stateIds.size()); - List refreshedStates = taskScopeStateMapper.selectList( - new LambdaQueryWrapper() - .in(TaskScopeStateEntity::getId, stateIds) - .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .in(TaskScopeStateEntity::getCozeStatus, - List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING))); - for (TaskScopeStateEntity state : refreshedStates == null - ? List.of() - : refreshedStates) { - if (state == null - || state.getCozeExecuteId() == null - || state.getCozeExecuteId().isBlank()) { - continue; - } - if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) { - continue; - } - if (!isCozeStateOwnedByCurrentInstance(state)) { - continue; - } - candidates.add(state); - } - if (candidates.size() <= 1) { - return; - } - // 复用固定大小的预取线程池,限制并发并避免每轮调度反复创建线程池。 - Map> futures = new LinkedHashMap<>(); - long prefetchStart = System.currentTimeMillis(); - for (TaskScopeStateEntity state : candidates) { - final Long stateId = state.getId(); - final String executeId = state.getCozeExecuteId(); - final CozeBatchContext context = readCozeBatchContext(state); - if (context == null) { - continue; - } - final String credentialName = context.credentialName(); - Future future = cozePollPrefetchExecutor.submit( - () -> cozeClient.pollWorkflow(executeId, cozeClient.credentialByName(credentialName))); - futures.put(stateId, future); - } - for (Map.Entry> entry : futures.entrySet()) { - Long stateId = entry.getKey(); - Future future = entry.getValue(); - try { - SimilarAsinCozeClient.CozePollResponse response = - future.get(COZE_POLL_PREFETCH_WAIT_MILLIS, java.util.concurrent.TimeUnit.MILLISECONDS); - if (response != null) { - cache.cozePollResponseCache.put(stateId, response); - } - } catch (java.util.concurrent.TimeoutException timeoutEx) { - future.cancel(true); - log.warn("[similar-asin] coze poll prefetch timeout stateId={} waitMs={}", - stateId, COZE_POLL_PREFETCH_WAIT_MILLIS); - } catch (Exception ex) { - log.warn("[similar-asin] coze poll prefetch failed stateId={} err={}", - stateId, firstNonBlank(ex.getMessage(), ex.getClass().getSimpleName())); - } - } - log.info("[similar-asin] coze poll prefetch finished candidates={} cached={} costMs={}", - candidates.size(), cache.cozePollResponseCache.size(), - System.currentTimeMillis() - prefetchStart); - } - - /** - * P0-2 最小风险变体:apply 阶段调用,命中预取缓存则直接返回;未命中走原同步 HTTP(兜底)。 - */ - private SimilarAsinCozeClient.CozePollResponse pollWorkflowCached(TaskScopeStateEntity state, - String credentialName) throws Exception { - PollScopeCache cache = pollScopeCache.get(); - if (cache != null && state != null && state.getId() != null) { - SimilarAsinCozeClient.CozePollResponse cached = cache.cozePollResponseCache.remove(state.getId()); - if (cached != null) { - return cached; - } - } - return cozeClient.pollWorkflow( - state.getCozeExecuteId(), - cozeClient.credentialByName(credentialName)); - } - - private void clearPollPrefetchCache() { - PollScopeCache cache = pollScopeCache.get(); - if (cache != null) { - cache.cozePollResponseCache.clear(); - } - } - - private boolean submitCozeBatches(FileTaskEntity task, + private boolean submitLlmBatches(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("[similar-asin] coze token not configured, skip async coze taskId={} jobId={}", - task.getId(), job.getId()); - return false; + return countPendingLlmStates(task.getId()) > 0; } String prompt = readAiPrompt(task); String apiKey = readApiKey(task); boolean imgSwitch = readImgSwitch(task); boolean categorySwitch = readCategorySwitch(task); - int batchSize = resolveCozeBatchSize(imgSwitch); + int batchSize = resolveLlmBatchSize(imgSwitch); // P1-1:检测到 720712008 风暴时强制把 batch 降到 1,隔离毒行; // 持续 5+ 次提交命中率 ≥ 40% 才会触发,正常波动不影响吞吐。 if (isPoisonStormActive(task.getId())) { @@ -2573,45 +2397,45 @@ public class SimilarAsinTaskService { task.getId(), batchSize); batchSize = 1; } - List candidates = collectPendingCozeCandidates(chunks, allRowsByBaseId); + List candidates = collectPendingLlmCandidates(chunks, allRowsByBaseId); if (candidates.isEmpty()) { - return countPendingCozeStates(task.getId()) > 0; + return countPendingLlmStates(task.getId()) > 0; } // P1-2:把"仅过滤 hasImageUrl"扩展为必填字段集中校验。 - // 缺失 asin / title / 图片 url 任意一项即直接 markFailed,不进入 Coze 提交链路。 + // 缺失 asin / title / 图片 url 任意一项即直接 markFailed,不进入 LLM 提交链路。 // 原因:Python 端偶发空字段会触发 720701002 "fields cannot be extracted from null values", - // 浪费 Coze 配额且把整 batch 拖垮;提前过滤更显式、易于排错。 - // 不打算把"必填字段集合"做成 properties——Coze 工作流签名固定,过度可配置反而把错配藏起来。 + // 浪费 LLM 配额且把整 batch 拖垮;提前过滤更显式、易于排错。 + // 不打算把"必填字段集合"做成 properties——LLM 工作流签名固定,过度可配置反而把错配藏起来。 java.util.function.Predicate isMissingRequired = row -> row == null || !row.hasImageUrl() || normalize(row.getAsin()).isBlank() || normalize(row.getTitle()).isBlank(); - List missingFieldCandidates = candidates.stream() + List missingFieldCandidates = candidates.stream() .filter(candidate -> candidate != null && isMissingRequired.test(candidate.row())) .toList(); if (!missingFieldCandidates.isEmpty()) { - mergeCozeRowsIntoChunk(task, + mergeLlmRowsIntoChunk(task, null, null, - cozeClient.markRowsFailed(missingFieldCandidates.stream().map(CozeCandidate::row).toList(), - "required field missing (asin/title/url), skip Coze"), + markRowsFailed(missingFieldCandidates.stream().map(LlmCandidate::row).toList(), + "required field missing (asin/title/url), skip LLM"), allRowsByBaseId); - log.warn("[similar-asin] skip coze rows missing required fields taskId={} jobId={} rows={}", + log.warn("[similar-asin] skip llm rows missing required fields taskId={} jobId={} rows={}", task.getId(), job.getId(), missingFieldCandidates.size()); } - List readyCandidates = candidates.stream() + List readyCandidates = candidates.stream() .filter(candidate -> candidate != null && !isMissingRequired.test(candidate.row())) .toList(); boolean flushRemainder = isResultSubmissionComplete(task.getId()); - // P1-6: 防止 Python 端长时间慢回传时零头永久挂着:job.updatedAt 距今 ≥ cozeFlushPendingMinutes 分钟则强制 flush。 + // P1-6: 防止 Python 端长时间慢回传时零头永久挂着:job.updatedAt 距今 ≥ llmFlushPendingMinutes 分钟则强制 flush。 if (!flushRemainder && readyCandidates.size() > 0) { LocalDateTime jobUpdatedAt = job.getUpdatedAt(); - long pendingFlushMillis = cozeFlushPendingMillis(); + long pendingFlushMillis = llmFlushPendingMillis(); if (jobUpdatedAt != null && Duration.between(jobUpdatedAt, LocalDateTime.now()).toMillis() >= pendingFlushMillis) { flushRemainder = true; - log.warn("[similar-asin] coze batch flush triggered by stale timer taskId={} jobId={} pendingRows={} batchSize={} jobUpdatedAt={} flushAfterMillis={}", + log.warn("[similar-asin] llm batch flush triggered by stale timer taskId={} jobId={} pendingRows={} batchSize={} jobUpdatedAt={} flushAfterMillis={}", task.getId(), job.getId(), readyCandidates.size(), batchSize, jobUpdatedAt, pendingFlushMillis); } } @@ -2620,34 +2444,34 @@ public class SimilarAsinTaskService { submitLimit = readyCandidates.size(); } if (submitLimit <= 0) { - log.info("[similar-asin] coze batch waiting for more rows taskId={} jobId={} pendingRows={} batchSize={} finalUpload={}", + log.info("[similar-asin] llm batch waiting for more rows taskId={} jobId={} pendingRows={} batchSize={} finalUpload={}", task.getId(), job.getId(), readyCandidates.size(), batchSize, flushRemainder); - return countPendingCozeStates(task.getId()) > 0; + return countPendingLlmStates(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 = readyCandidates.subList(i, Math.min(i + batchSize, submitLimit)); - pending |= submitCozeBatch(task, result, job, batchCandidates, batchIndex, batchTotal, prompt, apiKey, imgSwitch, categorySwitch, allRowsByBaseId); + List batchCandidates = readyCandidates.subList(i, Math.min(i + batchSize, submitLimit)); + pending |= submitLlmBatchEntry(task, result, job, batchCandidates, batchIndex, batchTotal, prompt, apiKey, imgSwitch, categorySwitch, allRowsByBaseId); batchIndex++; } - return pending || countPendingCozeStates(task.getId()) > 0; + return pending || countPendingLlmStates(task.getId()) > 0; } - private List enrichRowsForCoze(List rows, + private List enrichRowsForLlm(List rows, Map> allRowsByBaseId) { if (rows == null || rows.isEmpty()) { return List.of(); } List enrichedRows = new ArrayList<>(rows.size()); for (SimilarAsinResultRowDto row : rows) { - enrichedRows.add(enrichRowForCoze(row, allRowsByBaseId)); + enrichedRows.add(enrichRowForLlm(row, allRowsByBaseId)); } return enrichedRows; } - private SimilarAsinResultRowDto enrichRowForCoze(SimilarAsinResultRowDto row, + private SimilarAsinResultRowDto enrichRowForLlm(SimilarAsinResultRowDto row, Map> allRowsByBaseId) { if (row == null || allRowsByBaseId == null || allRowsByBaseId.isEmpty()) { return row; @@ -2736,10 +2560,10 @@ public class SimilarAsinTaskService { return candidates.getFirst(); } - private boolean submitCozeBatch(FileTaskEntity task, + private boolean submitLlmBatchEntry(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, - List batchCandidates, + List batchCandidates, int batchIndex, int batchTotal, String prompt, @@ -2751,14 +2575,14 @@ public class SimilarAsinTaskService { return false; } List batchRows = batchCandidates.stream() - .map(CozeCandidate::row) + .map(LlmCandidate::row) .filter(Objects::nonNull) .toList(); if (batchRows.isEmpty()) { return false; } taskFileJobService.touchRunningIfStale(job.getId(), properties.getDbJobTouchIntervalMillis()); - String batchScopeKey = buildCozeBatchScopeKey(task.getId(), batchRows); + String batchScopeKey = buildLlmBatchScopeKey(task.getId(), batchRows); String batchScopeHash = DigestUtil.sha256Hex(batchScopeKey); TaskScopeStateEntity existing = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, task.getId()) @@ -2766,78 +2590,19 @@ public class SimilarAsinTaskService { .eq(TaskScopeStateEntity::getScopeHash, batchScopeHash) .last("limit 1")); if (existing != null) { - return COZE_STATUS_SUBMITTED.equals(existing.getCozeStatus()) - || COZE_STATUS_RUNNING.equals(existing.getCozeStatus()); - } - if (properties.isDirectLlmEnabled()) { - return submitLlmBatch(task, result, job, batchRows, batchScopeKey, batchScopeHash, - batchIndex, batchTotal, prompt, apiKey, imgSwitch, categorySwitch, allRowsByBaseId); - } - SimilarAsinCozeClient.CozeCredentialRef credential = cozeClient.nextCredential(); - try { - SimilarAsinCozeClient.CozeSubmitResponse submit = submitCozeWorkflowThrottled( - batchRows, prompt, apiKey, imgSwitch, categorySwitch, credential, true); - if (submit.immediateData() != null && !submit.immediateData().isBlank()) { - List cozeRows = cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); - String emptyResultMessage = emptyCozeResultMessage(cozeRows, batchRows.size()); - if (!emptyResultMessage.isBlank()) { - throw new IllegalStateException(emptyResultMessage); - } - // Task 12:同步 immediate DONE 结果也走缓冲(原立即 merge),由 finalize/assemble 前 - // 一次性 flush 合并到 chunk,减少 chunk payload 频繁读写。先落一条 DONE state 承载 - // 缓冲 pointer;缓冲关闭/失败/重复时回退立即 merge,结果不丢失。 - if (isCozeResultBufferEnabled()) { - TaskScopeStateEntity doneState = persistImmediateCozeDoneState(task, result, job, batchRows, - batchScopeKey, batchScopeHash, batchIndex, batchTotal, submit.credentialName()); - if (doneState != null) { - bufferCozeRowsOrMerge(doneState, readCozeBatchContext(doneState), cozeRows, task, allRowsByBaseId); - return false; - } - } - mergeCozeRowsIntoChunk(task, null, null, cozeRows, allRowsByBaseId); - return false; - } - if (submit.executeId() == null || submit.executeId().isBlank()) { - mergeCozeRowsIntoChunk(task, - null, - null, - 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("[similar-asin] 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("[similar-asin] coze async submit failed taskId={} jobId={} rows={} batch={}/{} err={}", - task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message); - // P1-1:同步 submit 报错路径也记录滑窗(720712008 在 immediateData 阶段就抛错时也算命中)。 - recordCozeSubmitOutcome(task.getId(), CozeFailureClassifier.isPoisonRow(message)); - if (CozeFailureClassifier.isThrottleLockTimeout(message)) { - savePendingCozeBatchState(task, result, job, batchRows, batchScopeKey, batchScopeHash, - batchIndex, batchTotal, message, credential.name()); - taskFileJobService.touchRunningIfStale(job.getId(), properties.getDbJobTouchIntervalMillis()); - touchJavaSideTaskActivity(task.getId()); - return true; - } - mergeCozeRowsIntoChunk(task, - null, - null, - cozeClient.markRowsFailed(batchRows, message), - allRowsByBaseId); - return false; + return LLM_STATUS_SUBMITTED.equals(existing.getLlmStatus()) + || LLM_STATUS_RUNNING.equals(existing.getLlmStatus()); } + // 直连 LLM 模式:同步跑完行级链路后落 DONE 缓冲/merge。 + return submitLlmBatch(task, result, job, batchRows, batchScopeKey, batchScopeHash, + batchIndex, batchTotal, prompt, apiKey, imgSwitch, categorySwitch, allRowsByBaseId); } /** - * 直连 LLM 模式(directLlmEnabled=true)下的批提交:跳过 Coze 中转, + * 直连 LLM 模式(directLlmEnabled=true)下的批提交:跳过工作流中转, * 由 SimilarAsinLlmService 逐行跑完整链路(拼图/合规/对比),成功后按 - * 原 Coze 同步 immediate DONE 结果路径集成:scope 去重 → 缓冲或立即 merge。 - * 行级失败信息经空结果检测保留,与 Coze 同步提交失败行为对齐。 + * 原同步 immediate DONE 结果路径集成:scope 去重 → 缓冲或立即 merge。 + * 行级失败信息经空结果检测保留,与同步提交失败行为对齐。 */ private boolean submitLlmBatch(FileTaskEntity task, FileResultEntity result, @@ -2859,10 +2624,11 @@ public class SimilarAsinTaskService { String message = firstNonBlank(ex.getMessage(), "LLM submit failed"); log.warn("[similar-asin] llm submit failed taskId={} jobId={} rows={} batch={}/{} err={}", task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal, message); - mergeCozeRowsIntoChunk(task, + recordLlmSubmitOutcome(task.getId(), LlmFailureClassifier.isPoisonRow(message)); + mergeLlmRowsIntoChunk(task, null, null, - cozeClient.markRowsFailed(batchRows, message), + markRowsFailed(batchRows, message), allRowsByBaseId); return false; } @@ -2870,300 +2636,40 @@ public class SimilarAsinTaskService { String message = "LLM submit returned empty result rows"; log.warn("[similar-asin] llm submit empty taskId={} jobId={} rows={} batch={}/{}", task.getId(), job.getId(), batchRows.size(), batchIndex, batchTotal); - mergeCozeRowsIntoChunk(task, + mergeLlmRowsIntoChunk(task, null, null, - cozeClient.markRowsFailed(batchRows, message), + markRowsFailed(batchRows, message), allRowsByBaseId); return false; } - String emptyResultMessage = emptyCozeResultMessage(llmRows, batchRows.size()); + String emptyResultMessage = emptyLlmResultMessage(llmRows, batchRows.size()); if (!emptyResultMessage.isBlank()) { - mergeCozeRowsIntoChunk(task, + mergeLlmRowsIntoChunk(task, null, null, - cozeClient.markRowsFailed(batchRows, emptyResultMessage), + markRowsFailed(batchRows, emptyResultMessage), allRowsByBaseId); return false; } - // 落一条 DONE state 承载缓冲 pointer(对齐 Coze 同步 immediate 路径); + // 落一条 DONE state 承载缓冲 pointer(对齐直连同步 immediate 路径); // 缓冲失败/关闭时回退立即 merge,结果不丢失。 - if (isCozeResultBufferEnabled()) { - TaskScopeStateEntity doneState = persistImmediateCozeDoneState(task, result, job, batchRows, + if (isLlmResultBufferEnabled()) { + TaskScopeStateEntity doneState = persistImmediateLlmDoneState(task, result, job, batchRows, batchScopeKey, batchScopeHash, batchIndex, batchTotal, "llm-direct"); if (doneState != null) { - bufferCozeRowsOrMerge(doneState, readCozeBatchContext(doneState), llmRows, task, allRowsByBaseId); + bufferLlmRowsOrMerge(doneState, readLlmBatchContext(doneState), llmRows, task, allRowsByBaseId); return false; } } - mergeCozeRowsIntoChunk(task, null, null, llmRows, allRowsByBaseId); + mergeLlmRowsIntoChunk(task, null, null, llmRows, allRowsByBaseId); return false; } - 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, - null - ); - String batchPayload = writeJson(batchRows, "serialize pending coze batch payload failed"); - String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast( - MODULE_TYPE, task.getId(), batchScopeHash, batchPayload, true); - 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("[similar-asin] 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("[similar-asin] 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, - null - ); - String batchPayload = writeJson(batchRows, "serialize coze batch payload failed"); - String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast( - MODULE_TYPE, task.getId(), batchScopeHash, batchPayload, true); - 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("[similar-asin] duplicate coze batch state ignored taskId={} scope={}", - task.getId(), batchScopeKey); - } - } - - private void pollPendingCozeState(Long stateId) { - TaskScopeStateEntity lockState = taskScopeStateMapper.selectById(stateId); - if (lockState == null || lockState.getCozeExecuteId() == null || lockState.getCozeExecuteId().isBlank()) { - if (lockState != null && lockState.getCozeExecuteId() == null - && (COZE_STATUS_SUBMITTED.equals(lockState.getCozeStatus()) || COZE_STATUS_RUNNING.equals(lockState.getCozeStatus()))) { - retryPendingCozeSubmitState(lockState); - } - return; - } - if (!isCozeStateOwnedByCurrentInstance(lockState)) { - log.info("[similar-asin] coze poll skipped because owner is another instance taskId={} stateId={} owner={} current={}", - lockState.getTaskId(), stateId, ownerFromCozeState(lockState), currentInstanceId()); - return; - } - TaskDistributedLockService.LockHandle taskLockHandle = acquireTaskLock(lockState.getTaskId(), 0L); - if (taskLockHandle == null) { - return; - } - try (taskLockHandle) { - pollPendingCozeStateLocked(stateId); - } - } - - private void pollPendingCozeStateLocked(Long stateId) { - 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; - } - // 直连模式:不再轮询 Coze,直接把存量批次重跑一遍直连 LLM(submitLlmBatch 内部 - // 同步落 DONE 缓冲/merge 并触发 finalize),把历史遗留 PENDING 状态清掉。 - if (properties.isDirectLlmEnabled()) { - if (submitLlmBatchForPendingState(state)) { - return; - } - } - if (!(COZE_STATUS_SUBMITTED.equals(state.getCozeStatus()) || COZE_STATUS_RUNNING.equals(state.getCozeStatus()))) { - return; - } - if (!isCozeStateOwnedByCurrentInstance(state)) { - return; - } - if (!tryClaimCozeStateForPoll(state)) { - return; - } - CozeBatchContext context = readCozeBatchContext(state); - if (context == null || context.jobId() == null || context.resultId() == null) { - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze batch context missing"); - return; - } - if (!isOwnerCurrent(context.ownerInstanceId())) { - log.info("[similar-asin] coze poll skipped after context refresh because owner is another instance taskId={} stateId={} owner={} current={}", - state.getTaskId(), state.getId(), context.ownerInstanceId(), currentInstanceId()); - return; - } - taskFileJobService.touchRunningIfStale(context.jobId(), properties.getDbJobTouchIntervalMillis()); - try { - // P0-2 最小风险变体:优先复用预取阶段的 HTTP 响应;未命中(单 state 或失败兜底)走原同步 HTTP。 - SimilarAsinCozeClient.CozePollResponse poll = pollWorkflowCached(state, context.credentialName()); - if (!poll.hasPayload() && !poll.isFinished() && !isCozeStateTimedOut(state)) { - return; - } - String failureMessage = poll.isFailed() - ? firstNonBlank(poll.failureMessage(), "Coze 异步工作流失败") - : ""; - if (!poll.hasPayload() && failureMessage.isBlank()) { - failureMessage = isCozeStateTimedOut(state) - ? "Coze 异步工作流轮询超时" - : "Coze 异步工作流已完成但没有输出结果"; - } - List batchRows = readCozeBatchRows(state); - if (batchRows.isEmpty() && failureMessage.isBlank()) { - failureMessage = "Coze 批次载荷缺失"; - } - List cozeRows; - if (failureMessage.isBlank()) { - cozeRows = cozeClient.mergeRowsFromDataText(batchRows, poll.resolvedPayloadText()); - failureMessage = emptyCozeResultMessage(cozeRows, batchRows.size()); - } else { - cozeRows = List.of(); - } - // P1-1:记录本次 poll 结果(true=720712008 类毒行命中)以驱动滑窗降档。 - // 在 split / retry 之前记录,因为后续 split/retry 拿到的 finalMessage 可能被改写。 - recordCozeSubmitOutcome(state.getTaskId(), CozeFailureClassifier.isPoisonRow(failureMessage)); - 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); - } - // Task 12:DONE 结果统一走 bufferCozeRowsOrMerge(原 P0-3 仅 poll 且 batchTotal>1 缓冲; - // 现单 batch、submit/retry 同步 immediate 结果也缓冲),缓冲失败回退立即 merge, - // flush 在 finalize/assemble 前一次性合并到 chunk,结果不丢失。 - // 失败行(markRowsFailed)保持立即 merge 语义不变。 - if (failureMessage.isBlank()) { - if (cozeRows != null && !cozeRows.isEmpty()) { - FileTaskEntity pollTask = taskForPoll(state.getTaskId()); - bufferCozeRowsOrMerge(state, context, cozeRows, pollTask, - pollTask == null ? Map.of() : allRowsByBaseIdForPoll(pollTask)); - } - } else { - FileTaskEntity pollTask = taskForPoll(state.getTaskId()); - if (pollTask != null) { - Map> allRowsByBaseId = allRowsByBaseIdForPoll(pollTask); - try { - mergeCozeRowsIntoChunk(pollTask, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); - } catch (Exception mergeEx) { - log.warn("[similar-asin] coze failed result merge failed, mark state terminal anyway taskId={} stateId={} executeId={} err={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), - firstNonBlank(mergeEx.getMessage(), mergeEx.getClass().getSimpleName())); - } - } - } - markCozeStateTerminal(state, - failureMessage.isBlank() ? COZE_STATUS_DONE : COZE_STATUS_FAILED, - failureMessage.isBlank() ? null : failureMessage); - maybeFinalizeCozeJob(state.getTaskId(), context); - } catch (Exception ex) { - String message = firstNonBlank(ex.getMessage(), "Coze poll failed"); - if (isCozeStateTimedOut(state)) { - List batchRows = readCozeBatchRows(state); - FileTaskEntity task = taskForPoll(state.getTaskId()); - if (task != null) { - Map> allRowsByBaseId = allRowsByBaseIdForPoll(task); - try { - mergeCozeRowsIntoChunk(task, - context.chunkScopeHash(), - context.chunkIndex(), - cozeClient.markRowsFailed(batchRows, message), - allRowsByBaseId); - } catch (Exception mergeEx) { - log.warn("[similar-asin] coze timeout fallback merge failed, mark state terminal anyway taskId={} stateId={} executeId={} err={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), - firstNonBlank(mergeEx.getMessage(), mergeEx.getClass().getSimpleName())); - } - } - markCozeStateTerminal(state, COZE_STATUS_FAILED, message); - maybeFinalizeCozeJob(state.getTaskId(), context); - return; - } - log.warn("[similar-asin] coze poll failed taskId={} stateId={} executeId={} err={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), message); - updateCozeStateRunning(state, message); - } - } - /** * 直连模式下清存量 PENDING 状态:把该 state 的批次载荷重跑一遍直连 LLM, - * 结果落 DONE 缓冲/merge,由 maybeFinalizeCozeJob 触发收尾,最后把 state 置为终态。 - * 重跑失败时先试 split/retry 路径(复用 Coze 分类器与重试语义),仍失败则标记失败。 - * 返回 true 表示本批次已被本轮处理完(调用方直接 return,不再走 Coze 轮询)。 + * 结果落 DONE 缓冲/merge,由 maybeFinalizeLlmJob 触发收尾,最后把 state 置为终态。 + * 返回 true 表示本批次已被本轮处理完。 */ private boolean submitLlmBatchForPendingState(TaskScopeStateEntity state) { if (state == null || state.getId() == null || state.getTaskId() == null) { @@ -3173,13 +2679,13 @@ public class SimilarAsinTaskService { if (task == null || !MODULE_TYPE.equals(task.getModuleType()) || STATUS_SUCCESS.equals(task.getStatus())) { return false; } - List batchRows = readCozeBatchRows(state); + List batchRows = readLlmBatchRows(state); if (batchRows == null || batchRows.isEmpty()) { - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze 批次载荷缺失"); - maybeFinalizeCozeJob(state.getTaskId(), readCozeBatchContext(state)); + markLlmStateTerminal(state, LLM_STATUS_FAILED, "LLM 批次载荷缺失"); + maybeFinalizeLlmJob(state.getTaskId(), readLlmBatchContext(state)); return true; } - CozeBatchContext context = readCozeBatchContext(state); + LlmBatchContext context = readLlmBatchContext(state); String prompt = readAiPrompt(task); String apiKey = readApiKey(task); boolean imgSwitch = readImgSwitch(task); @@ -3196,240 +2702,55 @@ public class SimilarAsinTaskService { } // 直连重跑未真正提交(提交异常已被 submitLlmBatch 内部消化为失败 merge): // 直接把 state 置为终态并触发收尾,避免 PENDING 永远挂着。 - markCozeStateTerminal(state, COZE_STATUS_FAILED, "直连模式重跑批次失败"); - maybeFinalizeCozeJob(state.getTaskId(), context); + markLlmStateTerminal(state, LLM_STATUS_FAILED, "直连模式重跑批次失败"); + maybeFinalizeLlmJob(state.getTaskId(), context); return true; } - private void finalizeTimedOutCozeStatesForTask(Long taskId) { + private void finalizeTimedOutLlmStatesForTask(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) + .in(TaskScopeStateEntity::getLlmStatus, List.of(LLM_STATUS_SUBMITTED, LLM_STATUS_RUNNING)) + .orderByAsc(TaskScopeStateEntity::getLlmSubmittedAt) .last("limit 50")); if (states == null || states.isEmpty()) { return; } for (TaskScopeStateEntity state : states) { - if (state == null || state.getId() == null || !isCozeStateTimedOut(state)) { + if (state == null || state.getId() == null || !isLlmStateTimedOut(state)) { continue; } - CozeBatchContext context = readCozeBatchContext(state); + LlmBatchContext context = readLlmBatchContext(state); if (context == null || context.resultId() == null) { - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze 批次上下文缺失"); + markLlmStateTerminal(state, LLM_STATUS_FAILED, "LLM 批次上下文缺失"); continue; } - List batchRows = readCozeBatchRows(state); + List batchRows = readLlmBatchRows(state); FileTaskEntity task = taskForPoll(taskId); if (task != null) { Map> allRowsByBaseId = allRowsByBaseIdForPoll(task); - mergeCozeRowsIntoChunk(task, + mergeLlmRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), - cozeClient.markRowsFailed(batchRows, "Coze 异步工作流轮询超时"), + markRowsFailed(batchRows, "LLM 异步工作流轮询超时"), allRowsByBaseId); } - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze 异步工作流轮询超时"); - maybeFinalizeCozeJob(taskId, context); - log.warn("[similar-asin] 文件任务超时兜底已将 Coze pending 批次置为失败 taskId={} stateId={} jobId={}", + markLlmStateTerminal(state, LLM_STATUS_FAILED, "LLM 异步工作流轮询超时"); + maybeFinalizeLlmJob(taskId, context); + log.warn("[similar-asin] 文件任务超时兜底已将 LLM 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 (CozeFailureClassifier.isDeterministicInputFailure(failureMessage)) { - return false; - } - if (!CozeFailureClassifier.isRetryable(failureMessage) || cozeSubmitRetryCount(context) >= cozeSubmitMaxRetryCount()) { - return false; - } - FileTaskEntity task = taskForPoll(state.getTaskId()); - if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { - return false; - } - try { - SimilarAsinCozeClient.CozeSubmitResponse submit = - submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task), readImgSwitch(task), - readCategorySwitch(task), cozeClient.credentialByName(context.credentialName()), false); - Map> allRowsByBaseId = allRowsByBaseIdForPoll(task); - if (submit.immediateData() != null && !submit.immediateData().isBlank()) { - List cozeRows = - cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); - mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); - markCozeStateTerminal(state, COZE_STATUS_DONE, null); - maybeFinalizeCozeJob(state.getTaskId(), context); - log.info("[similar-asin] 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.touchRunningIfStale(context.jobId(), properties.getDbJobTouchIntervalMillis()); - touchJavaSideTaskActivity(state.getTaskId()); - log.info("[similar-asin] 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(), cozeSubmitMaxRetryCount(), failureMessage); - return true; - } - } catch (Exception ex) { - String msg = firstNonBlank(ex.getMessage(), "Coze retry failed"); - // P1-3:同 batch retry 抢不到节流锁时也走 defer,下次 poll 自动接管, - // 避免被打到 splitRetry 兜底进而把一整个 batch markFailed。 - if (CozeFailureClassifier.isThrottleLockTimeout(msg)) { - log.warn("[similar-asin] coze retry deferred by throttle lock taskId={} stateId={} executeId={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId()); - deferStateForResubmit(state, msg); - taskFileJobService.touchRunningIfStale(context.jobId(), properties.getDbJobTouchIntervalMillis()); - touchJavaSideTaskActivity(state.getTaskId()); - return true; - } - log.warn("[similar-asin] coze retry submit failed taskId={} stateId={} executeId={} err={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), msg); - } - return false; - } - - private SimilarAsinCozeClient.CozeSubmitResponse submitCozeWorkflowThrottled( - List rows, - String prompt, - String apiKey, - boolean imgSwitch, - boolean categorySwitch, - SimilarAsinCozeClient.CozeCredentialRef credential, - boolean allowCredentialFallback) throws Exception { - int attempts = allowCredentialFallback ? Math.max(1, cozeClient.configuredCredentialCount()) : 1; - SimilarAsinCozeClient.CozeCredentialRef currentCredential = - credential == null ? cozeClient.nextCredential() : credential; - Exception lastFailure = null; - for (int i = 0; i < attempts; i++) { - // P0-1: per-credential 节流——sleep 在锁外,仅本线程等到与本凭证上次提交的最小间隔。 - String credentialKey = credentialThrottleKey(currentCredential); - long minIntervalMs = cozeSubmitMinIntervalMillis(); - if (minIntervalMs > 0L) { - long lastAt = lastCozeSubmitAtByCredential.getOrDefault(credentialKey, 0L); - long waitMs = lastAt + minIntervalMs - System.currentTimeMillis(); - if (waitMs > 0L) { - sleepQuietly(waitMs); - } - } - 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) { - SimilarAsinCozeClient.CozeSubmitResponse response = - cozeClient.submitWorkflow(rows, prompt, apiKey, imgSwitch, categorySwitch, currentCredential); - // 提交完成立即记录时间戳,用于下次进入循环时计算节流等待。 - lastCozeSubmitAtByCredential.put(credentialKey, System.currentTimeMillis()); - return response; - } catch (Exception ex) { - // 失败也算一次"占用",避免凭证刚拒绝就立刻再次轰炸。 - lastCozeSubmitAtByCredential.put(credentialKey, System.currentTimeMillis()); - throw ex; - } - } - throw lastFailure == null ? new IllegalStateException("Coze submit failed") : lastFailure; - } - - private String credentialThrottleKey(SimilarAsinCozeClient.CozeCredentialRef credential) { - if (credential == null) { - return "default"; - } - return firstNonBlank(credential.name(), "default"); - } - - private CozeCredentialPoolService.CozeCredential toPoolCredential(SimilarAsinCozeClient.CozeCredentialRef credential) { - if (credential == null) { - return null; - } - return new CozeCredentialPoolService.CozeCredential( - credential.name(), - credential.workflowId(), - credential.token(), - credential.maxConcurrent()); - } - - private DistributedJobLockService.LockHandle acquireCozeSubmitLock(SimilarAsinCozeClient.CozeCredentialRef credential) { - // P0-4:等待时长 / 退避基础值下沉到 SimilarAsinProperties,可在线上调整。 - // 原硬编码 1000ms 在高并发 split retry 时大量超时 markFailed,默认抬高到 10s。 - long waitMillis = Math.max(1000L, properties.getCozeSubmitLockWaitMillis()); - long baseDelay = Math.max(100L, properties.getCozeSubmitLockRetryDelayMillis()); - long deadline = System.currentTimeMillis() + waitMillis; - String credentialName = credential == null ? "default" : firstNonBlank(credential.name(), "default"); - int attempt = 0; - while (System.currentTimeMillis() <= deadline) { - DistributedJobLockService.LockHandle lockHandle = - distributedJobLockService.tryLock("similar-asin:coze-submit:" + credentialName, COZE_SUBMIT_LOCK_TTL); - if (lockHandle != null) { - return lockHandle; - } - // 指数退避:500/1000/2000/4000ms,上限 4000,避免抢锁失败时密集打日志。 - long delay = Math.min(baseDelay * (1L << Math.min(attempt, 3)), 4000L); - // 末次循环之前确保 sleep 不会越过 deadline。 - long left = deadline - System.currentTimeMillis(); - if (left <= 0) { - break; - } - sleepQuietly(Math.min(delay, left)); - attempt++; - } - return null; - } - - private void sleepQuietly(long millis) { - if (millis <= 0L) { - return; - } - try { - Thread.sleep(millis); - } catch (InterruptedException ex) { - Thread.currentThread().interrupt(); - } - } - /** - * P1-1:记录一次 Coze 提交结果是否触发了 720712008 类毒行。 + * P1-1:记录一次 LLM 提交结果是否触发了 720712008 类毒行。 * 滑窗大小 POISON_WINDOW_SIZE=10,环形覆盖:满了之后从 0 重新开始。 */ - private void recordCozeSubmitOutcome(Long taskId, boolean poisonHit) { + private void recordLlmSubmitOutcome(Long taskId, boolean poisonHit) { if (taskId == null) { return; } @@ -3478,312 +2799,10 @@ public class SimilarAsinTaskService { } /** - * P1-3:throttle lock timeout 是临时性失败,不应当 markFailed。 - * 把 state 改写为 cozeStatus=RUNNING / cozeExecuteId=null, - * 下一轮 poll 会通过 retryPendingCozeSubmitState 自动接管重新提交。 - * 与 savePendingCozeBatchState 一致使用 RUNNING + null executeId 表达 "pending submit"。 + * P2-10:LLM 失败分类。poll/retry 链已移除,仅保留直连提交路径仍在使用的判定。 */ - private void deferStateForResubmit(TaskScopeStateEntity state, - String reason) { - if (state == null || state.getId() == null) { - return; - } - LocalDateTime now = LocalDateTime.now(); - 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::getCozeLastPolledAt, null) - .set(TaskScopeStateEntity::getCozeCompletedAt, null) - .set(TaskScopeStateEntity::getCozeAttemptCount, 0) - .set(TaskScopeStateEntity::getCozeError, "deferred: " + firstNonBlank(reason, "throttle lock timeout")) - .set(TaskScopeStateEntity::getUpdatedAt, now)); - if (updated > 0) { - log.warn("[similar-asin] coze submit deferred by throttle lock taskId={} stateId={} reason={}", - state.getTaskId(), state.getId(), reason); - } else { - log.info("[similar-asin] coze submit defer skipped (state moved) taskId={} stateId={} status={}", - state.getTaskId(), state.getId(), state.getCozeStatus()); - } - } - - private boolean splitRetryFailedCozeBatchState(TaskScopeStateEntity state, - CozeBatchContext context, - List batchRows, - String failureMessage) { - if (state == null || context == null || batchRows == null || batchRows.size() <= 1) { - return false; - } - if (CozeFailureClassifier.isDeterministicInputFailure(failureMessage)) { - return false; - } - if (!CozeFailureClassifier.shouldSplitForRetry(failureMessage) || cozeSubmitRetryCount(context) >= cozeSubmitMaxRetryCount()) { - return false; - } - FileTaskEntity task = taskForPoll(state.getTaskId()); - if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { - return false; - } - // P1-5: 720712008 / "node executed out of limit" 等毒行错误直接切到 size=1, - // 把毒行隔离成本从二分 O(log N) 降到 O(1)。其它可重试错误仍用二分。 - boolean poisonRow = CozeFailureClassifier.isPoisonRow(failureMessage); - List> partitions; - if (poisonRow) { - partitions = new ArrayList<>(batchRows.size()); - for (SimilarAsinResultRowDto row : batchRows) { - partitions.add(new ArrayList<>(List.of(row))); - } - } else { - int middle = Math.max(1, batchRows.size() / 2); - 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 = allRowsByBaseIdForPoll(task); - int partIndex = 1; - for (List partRows : partitions) { - SimilarAsinCozeClient.CozeSubmitResponse submit = - submitCozeWorkflowThrottled(partRows, readAiPrompt(task), readApiKey(task), readImgSwitch(task), readCategorySwitch(task), - cozeClient.credentialByName(context.credentialName()), false); - if (submit.immediateData() != null && !submit.immediateData().isBlank()) { - List cozeRows = - cozeClient.mergeRowsFromDataText(partRows, submit.immediateData()); - mergeCozeRowsIntoChunk(task, null, null, 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.touchRunningIfStale(context.jobId(), properties.getDbJobTouchIntervalMillis()); - touchJavaSideTaskActivity(state.getTaskId()); - maybeFinalizeCozeJob(state.getTaskId(), context); - log.info("[similar-asin] coze split retry submitted taskId={} stateId={} chunk={} batch={}/{} parts={} retry={}/{} poisonRow={} failure={}", - state.getTaskId(), state.getId(), context.chunkIndex(), context.batchIndex(), context.batchTotal(), - partitions.size(), retryCount, cozeSubmitMaxRetryCount(), poisonRow, failureMessage); - return true; - } - } catch (Exception ex) { - String msg = firstNonBlank(ex.getMessage(), "Coze split retry failed"); - // P1-3:节流锁超时是临时性失败,把 state 回写为待重试,下个 poll 周期接管。 - // 不再走 markFailed → markRowsFailed 的死亡路径,避免一行抢不到锁就被永久落进 xlsx。 - if (CozeFailureClassifier.isThrottleLockTimeout(msg)) { - log.warn("[similar-asin] coze split retry deferred by throttle lock taskId={} stateId={} parts={}", - state.getTaskId(), state.getId(), partitions.size()); - deferStateForResubmit(state, msg); - taskFileJobService.touchRunningIfStale(context.jobId(), properties.getDbJobTouchIntervalMillis()); - touchJavaSideTaskActivity(state.getTaskId()); - return true; - } - log.warn("[similar-asin] coze split retry submit failed taskId={} stateId={} executeId={} err={}", - state.getTaskId(), state.getId(), state.getCozeExecuteId(), msg); - } - 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()), - null - ); - LocalDateTime now = LocalDateTime.now(); - String batchPayload = writeJson(batchRows, "serialize split coze batch payload failed"); - String storedBatchPayload = transientPayloadStorageService.storeParsedPayloadFast( - MODULE_TYPE, parent.getTaskId(), scopeHash, batchPayload, true); - 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("[similar-asin] duplicate split coze batch state ignored taskId={} scope={}", - parent.getTaskId(), scopeKey); - } - } - - private void retryPendingCozeSubmitState(TaskScopeStateEntity state) { - if (state == null || state.getId() == null) { - return; - } - if (!tryClaimCozeStateForPoll(state)) { - log.info("[similar-asin] 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 = taskForPoll(state.getTaskId()); - if (task == null || !MODULE_TYPE.equals(task.getModuleType())) { - markCozeStateTerminal(state, COZE_STATUS_FAILED, "Coze pending submit task missing"); - return; - } - Map currentRows = loadSubmittedRows(task.getId()); - List currentBatchRows = batchRows.stream() - .map(row -> currentRows.get(rowKey(row))) - .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("[similar-asin] coze pending submit skipped because rows already resolved taskId={} stateId={} jobId={} rows={}", - state.getTaskId(), state.getId(), context.jobId(), batchRows.size()); - return; - } - try { - taskFileJobService.touchRunningIfStale(context.jobId(), properties.getDbJobTouchIntervalMillis()); - SimilarAsinCozeClient.CozeSubmitResponse submit = - submitCozeWorkflowThrottled(batchRows, readAiPrompt(task), readApiKey(task), readImgSwitch(task), readCategorySwitch(task), - cozeClient.credentialByName(context.credentialName()), false); - Map> allRowsByBaseId = allRowsByBaseIdForPoll(task); - if (submit.immediateData() != null && !submit.immediateData().isBlank()) { - List cozeRows = - cozeClient.mergeRowsFromDataText(batchRows, submit.immediateData()); - String emptyResultMessage = emptyCozeResultMessage(cozeRows, batchRows.size()); - if (!emptyResultMessage.isBlank()) { - throw new IllegalStateException(emptyResultMessage); - } - // Task 12:retry 同步 immediate DONE 结果也走缓冲(原立即 merge), - // 缓冲失败回退立即 merge;flush 在 finalize 时一次性完成。 - bufferCozeRowsOrMerge(state, context, cozeRows, task, 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"); - mergeCozeRowsIntoChunk(task, - null, - null, - 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("[similar-asin] 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("[similar-asin] coze pending submit retry failed taskId={} stateId={} jobId={} rows={} err={}", - state.getTaskId(), state.getId(), context.jobId(), batchRows.size(), message); - if (CozeFailureClassifier.isThrottleLockTimeout(message)) { - if (isCozeStateTimedOut(state)) { - Map> allRowsByBaseId = allRowsByBaseIdForPoll(task); - String finalMessage = "等待 Coze 提交凭证超时:" + message; - mergeCozeRowsIntoChunk(task, - null, - null, - cozeClient.markRowsFailed(batchRows, finalMessage), - allRowsByBaseId); - markCozeStateTerminal(state, COZE_STATUS_FAILED, finalMessage); - maybeFinalizeCozeJobLocked(state.getTaskId(), context); - log.warn("[similar-asin] coze pending submit force-failed by timeout taskId={} stateId={} jobId={} rows={}", - state.getTaskId(), state.getId(), context.jobId(), batchRows.size()); - return; - } - keepPendingCozeSubmitState(state, message); - return; - } - int nextAttemptCount = cozeAttemptCount(state) + 1; - if (nextAttemptCount >= cozeSubmitMaxRetryCount() || isCozeStateTimedOut(state)) { - Map> allRowsByBaseId = allRowsByBaseIdForPoll(task); - String finalMessage = isCozeStateTimedOut(state) - ? message + " (timeout after " + properties.getCozePollTimeoutMillis() + "ms)" - : message + ",已重试提交 " + nextAttemptCount + " 次"; - mergeCozeRowsIntoChunk(task, - null, - null, - 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::getCozeLastPolledAt, LocalDateTime.now()) - .set(TaskScopeStateEntity::getCozeError, error) - .set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now())); - if (updated > 0) { - touchJavaSideTaskActivity(state.getTaskId()); - } - } - - /** - * P2-10:Coze 失败统一分类(替代 isRetryableCozeFailure / shouldSplitCozeBatchForRetry / - * isCozeThrottleLockTimeout / isDeterministicCozeInputFailure 等散落在多处的关键词判断)。 - * 关键词集中在此,所有调用方共享同一来源避免漂移。 - */ - static final class CozeFailureClassifier { - private CozeFailureClassifier() {} + static final class LlmFailureClassifier { + private LlmFailureClassifier() {} private static String norm(String msg) { if (msg == null) { @@ -3796,24 +2815,10 @@ public class SimilarAsinTaskService { return trimmed.toLowerCase(Locale.ROOT); } - /** Coze 工作流空输入兜底 / 720712000 显式输入校验失败:直接标记失败,不进入重试。 */ - static boolean isDeterministicInputFailure(String msg) { - String n = norm(msg); - if (n.isEmpty()) { - return false; - } - return DETERMINISTIC_INPUT_FAILURE_PATTERN.matcher(n).find(); - } - - /** 节流锁等待超时(提交锁),区别于轮询等待 Coze 自身超时。 */ - static boolean isThrottleLockTimeout(String msg) { - return norm(msg).contains("coze submit throttle lock timeout"); - } - /** * P1-5:720712008 / "node executed out of limit" / "工作流节点执行超限" - * 这一类强烈暗示“单点死循环”,拆分时直接拆到 size=1 隔离问题行, - * 避免二分多次浪费提交配额。 + * 这一类强烈暗示“单点死循环”,命中即隔离降档(batchSize=1), + * 避免毒行把整批拖垮。 */ static boolean isPoisonRow(String msg) { String n = norm(msg); @@ -3822,192 +2827,63 @@ public class SimilarAsinTaskService { || n.contains("execution limit") || n.contains("工作流节点执行超限"); } - - /** 命中后允许同批次重试。 */ - static boolean isRetryable(String msg) { - if (isDeterministicInputFailure(msg)) { - return false; - } - String n = norm(msg); - return n.contains("rate limit") - || n.contains("too many") - || n.contains("retry later") - || n.contains("timeout") - || n.contains("timed out") - || n.contains("empty result rows") - || n.contains("out of limit") - || n.contains("execution limit") - || n.contains("702093018") - || n.contains("720712008") - || n.contains("720701002") - || n.contains("plugin limit") - || n.contains("限流") - || n.contains("稍后重试") - || n.contains("工作流节点执行超限") - || n.contains("调用超时"); - } - - /** 命中后优先走拆批重试。 */ - static boolean shouldSplitForRetry(String msg) { - String n = norm(msg); - return n.contains("timeout") - || n.contains("timed out") - || n.contains("empty result rows") - || n.contains("out of limit") - || n.contains("execution limit") - || n.contains("720712008") - || n.contains("720701002") - || n.contains("工作流节点执行超限") - || n.contains("调用超时"); - } } /** - * P0-4 / P2-9:poll 链路上同 task 共享缓存。 - * task 字段允许为 null(task 已删除/被换成非 SIMILAR_ASIN),调用方需自行判空。 - */ - static final class PollScopeCache { - private final Long taskId; - private FileTaskEntity task; - private boolean taskLoaded; - private Map> allRowsByBaseId; - /** - * P0-2 最小风险变体:predict→apply 之间的 HTTP 响应缓存。 - * key=stateId,value=cozeClient.pollWorkflow 的结果。apply 阶段命中即消费,未命中走原同步 HTTP 兜底。 - * 用 ConcurrentHashMap 是因为预取阶段并发 put,apply 阶段单线程 remove。 - */ - private final ConcurrentHashMap cozePollResponseCache = - new ConcurrentHashMap<>(); - - PollScopeCache(Long taskId) { - this.taskId = taskId; - } - - Long taskId() { - return taskId; - } - } - - /** - * P0-4 / P2-9:从 thread-local cache 取 task;命中则直接返回,未命中则查 DB 并回填缓存。 - * 没有 cache 上下文(pollScopeCache.get() == null)时退化成 fileTaskMapper.selectById,保留旧语义。 + * P0-4 / P2-9:pending 重跑链路直接查 DB 取 task(poll 链移除后无同线程复用上下文)。 */ private FileTaskEntity taskForPoll(Long taskId) { if (taskId == null) { return null; } - PollScopeCache cache = pollScopeCache.get(); - if (cache == null || !taskId.equals(cache.taskId)) { - return fileTaskMapper.selectById(taskId); - } - if (!cache.taskLoaded) { - cache.task = fileTaskMapper.selectById(taskId); - cache.taskLoaded = true; - } - return cache.task; + return fileTaskMapper.selectById(taskId); } /** - * P0-4:与 taskForPoll 配套的 allRowsByBaseId 缓存入口。同一 poll 调度内, - * 5000 行的 chunk JSON 反序列化只会执行一次,下游 retry/split/poll 复用同一 Map。 + * P0-4:pending 重跑链路直接加载 allRowsByBaseId(poll 链移除后无同线程复用上下文)。 */ private Map> allRowsByBaseIdForPoll(FileTaskEntity task) { if (task == null) { return Map.of(); } - PollScopeCache cache = pollScopeCache.get(); - if (cache == null || cache.taskId == null || !cache.taskId.equals(task.getId())) { - return loadAllRowsByBaseId(task); - } - if (cache.allRowsByBaseId == null) { - cache.allRowsByBaseId = loadAllRowsByBaseId(task); - } - return cache.allRowsByBaseId; + return loadAllRowsByBaseId(task); } - private int cozeSubmitMaxRetryCount() { - int v = properties.getCozeSubmitMaxRetryCount(); - return v <= 0 ? 5 : v; - } - - private long cozeSubmitMinIntervalMillis() { - long v = properties.getCozeSubmitMinIntervalMillis(); - return v < 0 ? 0 : v; - } - - private long cozeFlushPendingMillis() { - int minutes = properties.getCozeFlushPendingMinutes(); + private long llmFlushPendingMillis() { + int minutes = properties.getLlmFlushPendingMinutes(); return Math.max(1, minutes) * 60_000L; } - private String emptyCozeResultMessage(List cozeRows, int expectedRows) { - if (cozeRows == null || cozeRows.isEmpty()) { - return expectedRows > 0 ? "Coze async workflow returned empty result rows" : ""; + private String emptyLlmResultMessage(List llmRows, int expectedRows) { + if (llmRows == null || llmRows.isEmpty()) { + return expectedRows > 0 ? "LLM async workflow returned empty result rows" : ""; } - long unresolved = cozeRows.stream() + long unresolved = llmRows.stream() .filter(row -> row != null - && !hasResolvedCozeFields(row) - && !isTechnicalCozeFailure(row.getError())) + && !hasResolvedLlmFields(row) + && !isTechnicalLlmFailure(row.getError())) .count(); if (unresolved <= 0) { return ""; } - return "Coze async workflow returned empty result rows: " + unresolved + "/" + Math.max(expectedRows, cozeRows.size()); + return "LLM async workflow returned empty result rows: " + unresolved + "/" + Math.max(expectedRows, llmRows.size()); } - 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::getCozeAttemptCount, cozeAttemptCount(state) + 1) - .set(TaskScopeStateEntity::getCozeError, null) - .set(TaskScopeStateEntity::getUpdatedAt, now)) > 0; - } - - private void markCozeStateTerminal(TaskScopeStateEntity state, String status, String error) { + private void markLlmStateTerminal(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) + .in(TaskScopeStateEntity::getLlmStatus, List.of(LLM_STATUS_SUBMITTED, LLM_STATUS_RUNNING)) + .set(TaskScopeStateEntity::getLlmStatus, status) + .set(TaskScopeStateEntity::getLlmCompletedAt, LocalDateTime.now()) + .set(TaskScopeStateEntity::getLlmLastPolledAt, LocalDateTime.now()) + .set(TaskScopeStateEntity::getLlmAttemptCount, llmAttemptCount(state) + 1) + .set(TaskScopeStateEntity::getLlmError, 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) { + private void maybeFinalizeLlmJob(Long taskId, LlmBatchContext context) { + if (taskId == null || context == null || countPendingLlmStates(taskId) > 0) { return; } if (!isOwnerCurrent(context.ownerInstanceId())) { @@ -4018,7 +2894,7 @@ public class SimilarAsinTaskService { return; } try (lockHandle) { - maybeFinalizeCozeJobLocked(taskId, context); + maybeFinalizeLlmJobLocked(taskId, context); } catch (Exception ex) { TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId()); if (job != null) { @@ -4027,22 +2903,22 @@ public class SimilarAsinTaskService { finalizeExhaustedResultFileJob(job, ex.getMessage()); } } - log.warn("[相似ASIN] Coze 异步收尾失败 任务ID={} 结果ID={} 错误={}", + log.warn("[相似ASIN] LLM 异步收尾失败 任务ID={} 结果ID={} 错误={}", taskId, context.resultId(), ex.getMessage(), ex); } } - private void maybeFinalizeCozeJobLocked(Long taskId, CozeBatchContext context) { - if (taskId == null || context == null || countPendingCozeStates(taskId) > 0) { + private void maybeFinalizeLlmJobLocked(Long taskId, LlmBatchContext context) { + if (taskId == null || context == null || countPendingLlmStates(taskId) > 0) { return; } DistributedJobLockService.LockHandle lockHandle = - distributedJobLockService.tryLock("similar-asin:coze-finalize:" + taskId, Duration.ofMinutes(5)); + distributedJobLockService.tryLock("similar-asin:llm-finalize:" + taskId, Duration.ofMinutes(5)); if (lockHandle == null) { return; } try (lockHandle) { - if (countPendingCozeStates(taskId) > 0) { + if (countPendingLlmStates(taskId) > 0) { return; } TaskFileJobEntity job = taskFileJobService.findAssembleJob(taskId, MODULE_TYPE, context.resultId()); @@ -4059,20 +2935,20 @@ public class SimilarAsinTaskService { touchJavaSideTaskActivity(taskId); return; } - // P0-3:requeue assemble 之前一次性把缓冲的 cozeRows 合并到 chunk。 + // P0-3:requeue assemble 之前一次性把缓冲的 llmRows 合并到 chunk。 // 失败时 markFailed job 并阻止 requeue,避免 assemble 阶段读到不完整 chunk。 try { - flushBufferedCozeResults(taskId); + flushLlmBufferedResults(taskId); } catch (Exception flushEx) { - String message = firstNonBlank(flushEx.getMessage(), "刷新缓冲区 Coze 结果失败"); - log.warn("[相似ASIN] Coze 收尾刷新缓冲区失败,已标记文件任务失败 任务ID={} 文件任务ID={} 错误={}", + String message = firstNonBlank(flushEx.getMessage(), "刷新缓冲区 LLM 结果失败"); + log.warn("[相似ASIN] LLM 收尾刷新缓冲区失败,已标记文件任务失败 任务ID={} 文件任务ID={} 错误={}", taskId, job.getId(), message, flushEx); taskFileJobService.markFailed(job, message); return; } - boolean requeued = taskFileJobService.requeue(job.getId(), "Coze 结果已就绪,正在组装 xlsx"); + boolean requeued = taskFileJobService.requeue(job.getId(), "LLM 结果已就绪,正在组装 xlsx"); if (requeued) { - log.info("[相似ASIN] Coze 异步结果已就绪,结果文件任务已重新入队 任务ID={} 文件任务ID={} 结果ID={}", + log.info("[相似ASIN] LLM 异步结果已就绪,结果文件任务已重新入队 任务ID={} 文件任务ID={} 结果ID={}", taskId, job.getId(), context.resultId()); } else if (taskFileJobService.isRetryExhausted(job.getId())) { finalizeExhaustedResultFileJob(job, job.getErrorMessage()); @@ -4096,15 +2972,15 @@ public class SimilarAsinTaskService { return lockHandle; } - private void completeCozeFileJob(FileTaskEntity task, + private void completeLlmFileJob(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, int totalProgressUnits, - int cozeWorkUnits) { - if (countPendingCozeStates(task.getId()) > 0) { - throw new BusinessException("Coze result is still processing, cannot generate result file yet"); + int llmWorkUnits) { + if (countPendingLlmStates(task.getId()) > 0) { + throw new BusinessException("LLM result is still processing, cannot generate result file yet"); } - int assembleProgress = Math.max(1, Math.min(totalProgressUnits - 2, cozeWorkUnits)); + int assembleProgress = Math.max(1, Math.min(totalProgressUnits - 2, llmWorkUnits)); saveFileBuildProgress(task, job, totalProgressUnits, assembleProgress, "Assembling xlsx"); assembleResultWorkbook(task, result); saveFileBuildProgress(task, job, totalProgressUnits, totalProgressUnits - 1, "Uploading result file"); @@ -4125,12 +3001,12 @@ public class SimilarAsinTaskService { clearPoisonWindow(task.getId()); } - private void mergeCozeRowsIntoChunk(FileTaskEntity task, + private void mergeLlmRowsIntoChunk(FileTaskEntity task, String chunkScopeHash, Integer chunkIndex, - List cozeRows, + List llmRows, Map> allRowsByBaseId) { - if (task == null || cozeRows == null || cozeRows.isEmpty()) { + if (task == null || llmRows == null || llmRows.isEmpty()) { return; } List chunks = loadSubmittedChunks(task.getId()); @@ -4140,14 +3016,14 @@ public class SimilarAsinTaskService { // P2-11:把当前 batch 命中的图片 url 异步丢入预热队列。 // 预热失败不影响主流程,assemble 阶段无 DB cache 命中也会走原下载链路兜底。 try { - List prefetchUrls = new ArrayList<>(cozeRows.size() * 3); - for (SimilarAsinResultRowDto cozeRow : cozeRows) { - if (cozeRow == null) { + List prefetchUrls = new ArrayList<>(llmRows.size() * 3); + for (SimilarAsinResultRowDto llmRow : llmRows) { + if (llmRow == null) { continue; } - addNonBlank(prefetchUrls, cozeRow.getMainUrl()); - addNonBlank(prefetchUrls, cozeRow.getPuzzleImg1()); - addNonBlank(prefetchUrls, cozeRow.getPuzzleImg2()); + addNonBlank(prefetchUrls, llmRow.getMainUrl()); + addNonBlank(prefetchUrls, llmRow.getPuzzleImg1()); + addNonBlank(prefetchUrls, llmRow.getPuzzleImg2()); } imagePrefetchService.enqueue(task.getId(), prefetchUrls); } catch (Exception ex) { @@ -4162,7 +3038,7 @@ public class SimilarAsinTaskService { chunkByKey.put(chunkKey, chunk); } // Task 11:合并前按稳定 rowKey 去重,消除重复行逐行 expand/分配/写回 的 O(n²) 热点。 - List uniqueRows = dedupeRowsByRowKey(cozeRows); + List uniqueRows = dedupeRowsByRowKey(llmRows); List expandedAll = new ArrayList<>(); for (SimilarAsinResultRowDto resultRow : uniqueRows) { expandedAll.addAll(expandRows(List.of(resultRow), allRowsByBaseId)); @@ -4171,9 +3047,9 @@ public class SimilarAsinTaskService { Map rowKeyIndex = indexRowsByChunkKey(rowsByChunk); List orphanRows = new ArrayList<>(); Map> mergeRowsByChunk = - assignCozeRowsToChunks(rowsByChunk, expandedAll, rowKeyIndex, chunkScopeHash, chunkIndex, orphanRows); + assignLlmRowsToChunks(rowsByChunk, expandedAll, rowKeyIndex, chunkScopeHash, chunkIndex, orphanRows); if (!orphanRows.isEmpty()) { - persistOrphanCozeRows(task.getId(), orphanRows); + persistOrphanLlmRows(task.getId(), orphanRows); } for (Map.Entry> entry : mergeRowsByChunk.entrySet()) { TaskChunkEntity chunk = chunkByKey.get(entry.getKey()); @@ -4201,25 +3077,90 @@ public class SimilarAsinTaskService { .toList(); } - private Map loadSubmittedRows(Long taskId) { - Map rows = new LinkedHashMap<>(); - for (TaskChunkEntity chunk : loadSubmittedChunks(taskId)) { - rows.putAll(readChunkRows(chunk)); - } - return rows; - } - private String chunkStorageKey(String scopeHash, Integer chunkIndex) { return firstNonBlank(scopeHash, "") + ":" + (chunkIndex == null ? 0 : chunkIndex); } - private List readCozeBatchRows(TaskScopeStateEntity state) { + private List markRowsFailed(List rows, String failureMessage) { + if (rows == null || rows.isEmpty()) { + return List.of(); + } + return rows.stream() + .map(this::copyRowForFailure) + .map(row -> markRowFailed(row, failureMessage)) + .toList(); + } + + private SimilarAsinResultRowDto copyRowForFailure(SimilarAsinResultRowDto source) { + SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); + row.setSourceFileKey(source.getSourceFileKey()); + row.setSourceFilename(source.getSourceFilename()); + row.setRowToken(source.getRowToken()); + row.setGroupKey(source.getGroupKey()); + row.setId(source.getId()); + row.setAsin(source.getAsin()); + row.setCountry(source.getCountry()); + row.setSku(source.getSku()); + row.setPrice(source.getPrice()); + row.setUrls(source.getUrls()); + row.setAlibaba(source.getAlibaba()); + row.setTitle(source.getTitle()); + row.setError(source.getError()); + row.setDone(source.getDone()); + row.setStatus(source.getStatus()); + row.setIsConform(source.getIsConform()); + row.setReason(source.getReason()); + row.setCategory(source.getCategory()); + row.setTitleRisk(source.getTitleRisk()); + row.setAppearanceRisk(source.getAppearanceRisk()); + row.setPatentRisk(source.getPatentRisk()); + row.setConclusion(source.getConclusion()); + row.setIsStock(source.getIsStock()); + row.setSimilarity(source.getSimilarity()); + row.setTitleReason(source.getTitleReason()); + row.setAppearanceReason(source.getAppearanceReason()); + row.setPatentReason(source.getPatentReason()); + row.setMainUrl(source.getMainUrl()); + row.setPuzzleImg1(source.getPuzzleImg1()); + row.setPuzzleImg2(source.getPuzzleImg2()); + return row; + } + + private SimilarAsinResultRowDto markRowFailed(SimilarAsinResultRowDto row, String failureMessage) { + String reviewMessage = failureMessage == null || failureMessage.isBlank() + ? "检测失败" + : "检测失败:" + failureMessage; + if (row.getError() == null || row.getError().isBlank()) { + row.setError(failureMessage); + } + if (row.getReason() == null || row.getReason().isBlank()) { + row.setReason(failureMessage); + } + if (row.getStatus() == null || row.getStatus().isBlank()) { + row.setStatus("FAILED"); + } + if (row.getTitleRisk() == null || row.getTitleRisk().isBlank()) { + row.setTitleRisk(reviewMessage); + } + if (row.getAppearanceRisk() == null || row.getAppearanceRisk().isBlank()) { + row.setAppearanceRisk(reviewMessage); + } + if (row.getPatentRisk() == null || row.getPatentRisk().isBlank()) { + row.setPatentRisk(reviewMessage); + } + if (row.getConclusion() == null || row.getConclusion().isBlank()) { + row.setConclusion(failureMessage); + } + return row; + } + + private List readLlmBatchRows(TaskScopeStateEntity state) { if (state == null || state.getParsedPayloadJson() == null || state.getParsedPayloadJson().isBlank()) { return List.of(); } try { String payloadJson = transientPayloadStorageService.resolvePayload( - state.getParsedPayloadJson(), "read similar ASIN coze batch failed"); + state.getParsedPayloadJson(), "read similar ASIN llm batch failed"); JsonNode array = objectMapper.readTree(payloadJson); if (!array.isArray()) { return List.of(); @@ -4230,24 +3171,24 @@ public class SimilarAsinTaskService { } return rows; } catch (Exception ex) { - log.warn("[similar-asin] read coze batch failed taskId={} stateId={} err={}", + log.warn("[similar-asin] read llm batch failed taskId={} stateId={} err={}", state.getTaskId(), state.getId(), ex.getMessage()); return List.of(); } } /** - * P0-3:读取 state 缓冲的 cozeRows。 - * 与 readCozeBatchRows(读输入 batchRows)不同,这里读的是 - * 通过 bufferCozeResultForFlush 写入 transient storage 的 DONE 结果。 + * P0-3:读取 state 缓冲的 llmRows。 + * 与 readLlmBatchRows(读输入 batchRows)不同,这里读的是 + * 通过 bufferLlmResultForFlush 写入 transient storage 的 DONE 结果。 */ - private List readBufferedCozeRows(String resultPayloadPointer) { + private List readLlmBufferedRows(String resultPayloadPointer) { if (resultPayloadPointer == null || resultPayloadPointer.isBlank()) { return List.of(); } try { String payloadJson = transientPayloadStorageService.resolvePayload( - resultPayloadPointer, "read similar ASIN coze result buffer failed"); + resultPayloadPointer, "read similar ASIN llm result buffer failed"); if (payloadJson == null || payloadJson.isBlank()) { return List.of(); } @@ -4261,33 +3202,33 @@ public class SimilarAsinTaskService { } return rows; } catch (Exception ex) { - log.warn("[similar-asin] read coze result buffer failed pointer={} err={}", + log.warn("[similar-asin] read llm result buffer failed pointer={} err={}", resultPayloadPointer, ex.getMessage()); return List.of(); } } /** - * P0-3:把单个 DONE batch 的 cozeRows 缓冲到 transient storage(不立即写 chunk)。 - * 返回更新后的 CozeBatchContext(含 resultPayloadPointer),调用方需写回 stateJson。 + * P0-3:把单个 DONE batch 的 llmRows 缓冲到 transient storage(不立即写 chunk)。 + * 返回更新后的 LlmBatchContext(含 resultPayloadPointer),调用方需写回 stateJson。 * 缓冲失败时返回原 context,调用方应回退到立即 merge 路径。 */ - private CozeBatchContext bufferCozeResultForFlush(TaskScopeStateEntity state, - CozeBatchContext context, - List cozeRows) { - if (state == null || context == null || cozeRows == null || cozeRows.isEmpty()) { + private LlmBatchContext bufferLlmResultForFlush(TaskScopeStateEntity state, + LlmBatchContext context, + List llmRows) { + if (state == null || context == null || llmRows == null || llmRows.isEmpty()) { return context; } try { - String payloadJson = writeJson(cozeRows, "serialize coze result buffer failed"); + String payloadJson = writeJson(llmRows, "serialize llm result buffer failed"); String pointer = transientPayloadStorageService.storeParsedPayloadEntry( MODULE_TYPE, state.getTaskId(), state.getScopeHash(), - "coze-result-" + state.getId(), payloadJson, true); + "llm-result-" + state.getId(), payloadJson, true); if (pointer == null || pointer.isBlank()) { return context; } - CozeBatchContext bufferedContext = withResultPayloadPointer(context, pointer); - String updatedStateJson = writeJson(bufferedContext, "serialize coze result buffer context failed"); + LlmBatchContext bufferedContext = withResultPayloadPointer(context, pointer); + String updatedStateJson = writeJson(bufferedContext, "serialize llm result buffer context failed"); int updated = taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, state.getId()) .set(TaskScopeStateEntity::getStateJson, updatedStateJson) @@ -4299,7 +3240,7 @@ public class SimilarAsinTaskService { state.setStateJson(updatedStateJson); return bufferedContext; } catch (Exception ex) { - log.warn("[similar-asin] buffer coze result failed taskId={} stateId={} err={}", + log.warn("[similar-asin] buffer llm result failed taskId={} stateId={} err={}", state.getTaskId(), state.getId(), ex.getMessage()); return context; } @@ -4309,7 +3250,7 @@ public class SimilarAsinTaskService { * P0-3:清掉 state 上缓冲的 pointer + 删除 transient payload。 * 在 flush 成功合并到 chunk 后调用。 */ - private void clearBufferedCozeResult(TaskScopeStateEntity state, CozeBatchContext context) { + private void clearLlmBufferedResult(TaskScopeStateEntity state, LlmBatchContext context) { if (state == null || context == null) { return; } @@ -4318,8 +3259,8 @@ public class SimilarAsinTaskService { return; } try { - CozeBatchContext clearedContext = withResultPayloadPointer(context, null); - String updatedStateJson = writeJson(clearedContext, "serialize coze result buffer cleared context failed"); + LlmBatchContext clearedContext = withResultPayloadPointer(context, null); + String updatedStateJson = writeJson(clearedContext, "serialize llm result buffer cleared context failed"); taskScopeStateMapper.update(null, new LambdaUpdateWrapper() .eq(TaskScopeStateEntity::getId, state.getId()) .set(TaskScopeStateEntity::getStateJson, updatedStateJson) @@ -4327,49 +3268,49 @@ public class SimilarAsinTaskService { state.setStateJson(updatedStateJson); transientPayloadStorageService.deletePayloadIfPresent(pointer); } catch (Exception ex) { - log.warn("[similar-asin] clear coze result buffer failed taskId={} stateId={} err={}", + log.warn("[similar-asin] clear llm result buffer failed taskId={} stateId={} err={}", state.getTaskId(), state.getId(), ex.getMessage()); } } - private boolean isCozeResultBufferEnabled() { - return properties.isCozeResultBufferEnabled(); + private boolean isLlmResultBufferEnabled() { + return properties.isLlmResultBufferEnabled(); } /** - * Task 12:统一 Coze DONE 结果落库入口。 - * 缓冲开关开启时把 cozeRows 写入 transient storage(pointer 存进 state.stateJson), - * 由 flushBufferedCozeResults 在 finalize/assemble 前一次性合并到 chunk; + * Task 12:统一 LLM DONE 结果落库入口。 + * 缓冲开关开启时把 llmRows 写入 transient storage(pointer 存进 state.stateJson), + * 由 flushLlmBufferedResults 在 finalize/assemble 前一次性合并到 chunk; * 缓冲失败(存储异常 / state 更新失败 / 开关关闭)回退立即 merge,结果不丢失。 * 空 rows / 空 state / 空 context 直接返回,不产生任何写入。 */ - private void bufferCozeRowsOrMerge(TaskScopeStateEntity state, - CozeBatchContext context, - List cozeRows, + private void bufferLlmRowsOrMerge(TaskScopeStateEntity state, + LlmBatchContext context, + List llmRows, FileTaskEntity task, Map> allRowsByBaseId) { - if (state == null || context == null || cozeRows == null || cozeRows.isEmpty()) { + if (state == null || context == null || llmRows == null || llmRows.isEmpty()) { return; } - if (!isCozeResultBufferEnabled()) { - mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); + if (!isLlmResultBufferEnabled()) { + mergeLlmRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), llmRows, allRowsByBaseId); return; } - CozeBatchContext bufferedContext = bufferCozeResultForFlush(state, context, cozeRows); + LlmBatchContext bufferedContext = bufferLlmResultForFlush(state, context, llmRows); if (bufferedContext == null || bufferedContext.resultPayloadPointer() == null || bufferedContext.resultPayloadPointer().isBlank()) { // 缓冲失败:回退立即 merge,避免结果悬挂在 transient storage 之外。 - mergeCozeRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), cozeRows, allRowsByBaseId); + mergeLlmRowsIntoChunk(task, context.chunkScopeHash(), context.chunkIndex(), llmRows, allRowsByBaseId); } } /** * Task 12:为 submit 同步 immediate DONE 结果落一条 DONE state 承载缓冲 pointer。 - * 与 saveCozeBatchState(SUBMITTED 异步)不同,该 state 直接以 DONE 终态插入, - * 不会被 countPendingCozeStates 扫描;缓冲失败/重复插入时返回 null,调用方回退立即 merge。 + * 与 saveLlmBatchState(SUBMITTED 异步)不同,该 state 直接以 DONE 终态插入, + * 不会被 countPendingLlmStates 扫描;缓冲失败/重复插入时返回 null,调用方回退立即 merge。 */ - private TaskScopeStateEntity persistImmediateCozeDoneState(FileTaskEntity task, + private TaskScopeStateEntity persistImmediateLlmDoneState(FileTaskEntity task, FileResultEntity result, TaskFileJobEntity job, List batchRows, @@ -4379,7 +3320,7 @@ public class SimilarAsinTaskService { int batchTotal, String credentialName) { LocalDateTime now = LocalDateTime.now(); - CozeBatchContext context = new CozeBatchContext( + LlmBatchContext context = new LlmBatchContext( job.getId(), result.getId(), null, @@ -4396,11 +3337,11 @@ public class SimilarAsinTaskService { state.setModuleType(MODULE_TYPE); state.setScopeKey(batchScopeKey); state.setScopeHash(batchScopeHash); - state.setStateJson(writeJson(context, "serialize immediate coze done state context failed")); - state.setCozeStatus(COZE_STATUS_DONE); - state.setCozeSubmittedAt(now); - state.setCozeCompletedAt(now); - state.setCozeAttemptCount(0); + state.setStateJson(writeJson(context, "serialize immediate llm done state context failed")); + state.setLlmStatus(LLM_STATUS_DONE); + state.setLlmSubmittedAt(now); + state.setLlmCompletedAt(now); + state.setLlmAttemptCount(0); state.setChunkTotal(batchTotal); state.setReceivedChunkCount(batchIndex); state.setCompleted(1); @@ -4410,38 +3351,38 @@ public class SimilarAsinTaskService { taskScopeStateMapper.insert(state); return state; } catch (DuplicateKeyException ex) { - log.info("[similar-asin] duplicate immediate done coze state ignored taskId={} scope={}", + log.info("[similar-asin] duplicate immediate done llm state ignored taskId={} scope={}", task.getId(), batchScopeKey); return null; } } /** - * P0-3:在 finalize 之前一次性把所有 DONE/FAILED state 上缓冲的 cozeRows + * P0-3:在 finalize 之前一次性把所有 DONE/FAILED state 上缓冲的 llmRows * 按 chunkScopeHash 分组合并到 chunk。把每个 batch 的"loadSubmittedChunks + * readChunkRows × N + writeChunkPayload × M"压缩成"读 chunk 一次 + * 写 chunk 一次"。 */ - private void flushBufferedCozeResults(Long taskId) { + private void flushLlmBufferedResults(Long taskId) { if (taskId == null || taskId <= 0) { return; } - // 只取 flush 需要的列:parsed_payload_json/coze_* 等大字段不拉,避免跨库大结果集传输(每任务可达数百行大 JSON) + // 只取 flush 需要的列:parsed_payload_json/llm_* 等大字段不拉,避免跨库大结果集传输(每任务可达数百行大 JSON) List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() .select(TaskScopeStateEntity::getId, TaskScopeStateEntity::getTaskId, TaskScopeStateEntity::getModuleType, - TaskScopeStateEntity::getCozeStatus, + TaskScopeStateEntity::getLlmStatus, TaskScopeStateEntity::getStateJson) .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_DONE, COZE_STATUS_FAILED))); + .in(TaskScopeStateEntity::getLlmStatus, List.of(LLM_STATUS_DONE, LLM_STATUS_FAILED))); if (states == null || states.isEmpty()) { return; } List entries = new ArrayList<>(); for (TaskScopeStateEntity state : states) { - CozeBatchContext context = readCozeBatchContext(state); + LlmBatchContext context = readLlmBatchContext(state); if (context == null) { continue; } @@ -4449,10 +3390,10 @@ public class SimilarAsinTaskService { if (pointer == null || pointer.isBlank()) { continue; } - List rows = readBufferedCozeRows(pointer); + List rows = readLlmBufferedRows(pointer); if (rows == null || rows.isEmpty()) { // pointer 存在但 payload 已被清理(被 GC 或上一次 flush 部分成功),直接清 pointer。 - clearBufferedCozeResult(state, context); + clearLlmBufferedResult(state, context); continue; } entries.add(new BufferedFlushEntry(state, context, rows)); @@ -4465,7 +3406,7 @@ public class SimilarAsinTaskService { return; } Map> allRowsByBaseId = loadAllRowsByBaseId(task); - // 按 chunkScopeHash 分组(空字符串占位 null,让 mergeCozeRowsIntoChunk 走全 chunk 扫描匹配) + // 按 chunkScopeHash 分组(空字符串占位 null,让 mergeLlmRowsIntoChunk 走全 chunk 扫描匹配) Map> entriesByChunk = new LinkedHashMap<>(); for (BufferedFlushEntry entry : entries) { String key = entry.context().chunkScopeHash() == null ? "" : entry.context().chunkScopeHash(); @@ -4493,67 +3434,48 @@ public class SimilarAsinTaskService { } } try { - mergeCozeRowsIntoChunk(task, chunkScopeHash, chunkIndex, aggregated, allRowsByBaseId); + mergeLlmRowsIntoChunk(task, chunkScopeHash, chunkIndex, aggregated, allRowsByBaseId); for (BufferedFlushEntry entry : groupEntries) { - clearBufferedCozeResult(entry.state(), entry.context()); + clearLlmBufferedResult(entry.state(), entry.context()); } totalFlushed += aggregated.size(); } catch (Exception ex) { failedGroups++; - log.warn("[similar-asin] flush buffered coze results group failed taskId={} chunkScopeHash={} err={}", + log.warn("[similar-asin] flush buffered llm results group failed taskId={} chunkScopeHash={} err={}", taskId, chunkScopeHash, ex.getMessage(), ex); // 不 clear pointer,留待下次 finalize 或 stale-recovery 重试 } } if (totalFlushed > 0 || failedGroups > 0) { - log.info("[similar-asin] flushed buffered coze results taskId={} entries={} totalRows={} chunkGroups={} failedGroups={}", + log.info("[similar-asin] flushed buffered llm results taskId={} entries={} totalRows={} chunkGroups={} failedGroups={}", taskId, entries.size(), totalFlushed, entriesByChunk.size(), failedGroups); } if (failedGroups > 0) { // 让 finalize 能感知到失败,调用方应避免继续 requeue assemble。 - throw new IllegalStateException("刷新缓冲区 Coze 结果失败,失败分组数=" + failedGroups); + throw new IllegalStateException("刷新缓冲区 LLM 结果失败,失败分组数=" + failedGroups); } } private record BufferedFlushEntry(TaskScopeStateEntity state, - CozeBatchContext context, + LlmBatchContext context, List rows) { } - private CozeBatchContext readCozeBatchContext(TaskScopeStateEntity state) { + private LlmBatchContext readLlmBatchContext(TaskScopeStateEntity state) { if (state == null || state.getStateJson() == null || state.getStateJson().isBlank()) { return null; } try { - return objectMapper.readValue(state.getStateJson(), CozeBatchContext.class); + return objectMapper.readValue(state.getStateJson(), LlmBatchContext.class); } catch (Exception ex) { - log.warn("[similar-asin] read coze batch context failed taskId={} stateId={} err={}", + log.warn("[similar-asin] read llm 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(), - context.resultPayloadPointer() - ); - } - - private CozeBatchContext withResultPayloadPointer(CozeBatchContext context, String resultPayloadPointer) { - return new CozeBatchContext( + private LlmBatchContext withResultPayloadPointer(LlmBatchContext context, String resultPayloadPointer) { + return new LlmBatchContext( context.jobId(), context.resultId(), context.chunkScopeHash(), @@ -4567,37 +3489,37 @@ public class SimilarAsinTaskService { ); } - private boolean isCozeStateTimedOut(TaskScopeStateEntity state) { - if (state == null || state.getCozeSubmittedAt() == null) { + private boolean isLlmStateTimedOut(TaskScopeStateEntity state) { + if (state == null || state.getLlmSubmittedAt() == null) { return false; } - long timeoutMillis = Math.max(10000L, properties.getCozePollTimeoutMillis()); - return Duration.between(state.getCozeSubmittedAt(), LocalDateTime.now()).toMillis() >= timeoutMillis; + long timeoutMillis = Math.max(10000L, properties.getStaleTimeoutMinutes() * 60_000L); + return Duration.between(state.getLlmSubmittedAt(), LocalDateTime.now()).toMillis() >= timeoutMillis; } - private int cozeAttemptCount(TaskScopeStateEntity state) { - return state == null || state.getCozeAttemptCount() == null ? 0 : state.getCozeAttemptCount(); + private int llmAttemptCount(TaskScopeStateEntity state) { + return state == null || state.getLlmAttemptCount() == null ? 0 : state.getLlmAttemptCount(); } - private int countPendingCozeStates(Long taskId) { + private int countPendingLlmStates(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))); + .in(TaskScopeStateEntity::getLlmStatus, List.of(LLM_STATUS_SUBMITTED, LLM_STATUS_RUNNING))); return count == null ? 0 : count.intValue(); } - private int countCompletedCozeStates(Long taskId) { + private int countCompletedLlmStates(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))); + .in(TaskScopeStateEntity::getLlmStatus, List.of(LLM_STATUS_DONE, LLM_STATUS_FAILED))); return count == null ? 0 : count.intValue(); } @@ -4608,7 +3530,7 @@ public class SimilarAsinTaskService { List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .isNull(TaskScopeStateEntity::getCozeStatus) + .isNull(TaskScopeStateEntity::getLlmStatus) .isNotNull(TaskScopeStateEntity::getLastChunkAt)); if (states == null || states.isEmpty()) { return false; @@ -4643,7 +3565,7 @@ public class SimilarAsinTaskService { .set(FileTaskEntity::getUpdatedAt, now)); } - private String buildCozeBatchScopeKey(Long taskId, List batchRows) { + private String buildLlmBatchScopeKey(Long taskId, List batchRows) { StringBuilder rowKeys = new StringBuilder(); if (batchRows != null) { for (SimilarAsinResultRowDto row : batchRows) { @@ -4656,10 +3578,10 @@ public class SimilarAsinTaskService { } } } - return "coze:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString()); + return "llm:task:" + taskId + ":rows:" + DigestUtil.sha256Hex(rowKeys.toString()); } - record CozeCandidate(String chunkScopeHash, + record LlmCandidate(String chunkScopeHash, Integer chunkIndex, SimilarAsinResultRowDto row) { } @@ -4695,16 +3617,16 @@ public class SimilarAsinTaskService { currentInstanceId()); } - private boolean isCozeStateOwnedByCurrentInstance(TaskScopeStateEntity state) { - return state == null || isOwnerCurrent(ownerFromCozeState(state)); + private boolean isLlmStateOwnedByCurrentInstance(TaskScopeStateEntity state) { + return state == null || isOwnerCurrent(ownerFromLlmState(state)); } private boolean isOwnerCurrent(String owner) { return owner == null || owner.isBlank() || Objects.equals(owner, currentInstanceId()); } - private String ownerFromCozeState(TaskScopeStateEntity state) { - CozeBatchContext context = readCozeBatchContext(state); + private String ownerFromLlmState(TaskScopeStateEntity state) { + LlmBatchContext context = readLlmBatchContext(state); return context == null ? null : context.ownerInstanceId(); } @@ -4781,7 +3703,7 @@ public class SimilarAsinTaskService { if (stateJson == null || stateJson.isBlank()) { stateNode = objectMapper.createObjectNode(); stateNode.put("phase", "RECEIVED"); - stateNode.put("coze", "PENDING"); + stateNode.put("llm", "PENDING"); } else { JsonNode parsed = objectMapper.readTree(stateJson); stateNode = parsed.isObject() ? (ObjectNode) parsed : objectMapper.createObjectNode(); @@ -4828,7 +3750,7 @@ public class SimilarAsinTaskService { return owner.isBlank() ? null : owner; } - private int countCozeWorkUnits(List chunks, int batchSize) { + private int countLlmWorkUnits(List chunks, int batchSize) { if (chunks == null || chunks.isEmpty()) { return 0; } @@ -4838,7 +3760,7 @@ public class SimilarAsinTaskService { if (persistedRows.isEmpty()) { continue; } - total += collectPendingCozeRows(persistedRows.values()).size(); + total += collectPendingLlmRows(persistedRows.values()).size(); } return total <= 0 ? 0 : Math.max(1, (total + batchSize - 1) / batchSize); } @@ -4992,7 +3914,7 @@ public class SimilarAsinTaskService { long resolvedRows = captured.rows().stream() .filter(row -> findResultRow(row, sourceResultMap) != null) .count(); - int conformPropagated = CozeGroupResultPropagator.propagateByGroup( + int conformPropagated = GroupResultPropagator.propagateByGroup( captured.rows(), SimilarAsinParsedRowVo::getDisplayId, row -> findResultRow(row, sourceResultMap), @@ -5124,7 +4046,7 @@ public class SimilarAsinTaskService { // 公共分组处理:同 baseId 连续行视为一组(如 1、1_1、1_2),组内任一行的"是否符合类目" // 命中"不符合"(包含匹配),则组内所有行的"是否符合类目"统一为标准值"不符合"。 // 单行组跳过。仅修改 isConform 列,不影响其他列。 - int conformPropagated = CozeGroupResultPropagator.propagateByGroup( + int conformPropagated = GroupResultPropagator.propagateByGroup( parsedRows, SimilarAsinParsedRowVo::getDisplayId, row -> findResultRow(row, resultMap), @@ -5254,14 +4176,14 @@ public class SimilarAsinTaskService { } } - private List listOwnedPendingCozeStates() { + private List listOwnedPendingLlmStates() { Map merged = new LinkedHashMap<>(); - for (TaskScopeStateEntity state : queryOwnedPendingCozeStates(false, 50)) { + for (TaskScopeStateEntity state : queryOwnedPendingLlmStates(false, 50)) { if (state != null && state.getId() != null) { merged.put(state.getId(), state); } } - for (TaskScopeStateEntity state : queryOwnedPendingCozeStates(true, 50)) { + for (TaskScopeStateEntity state : queryOwnedPendingLlmStates(true, 50)) { if (state != null && state.getId() != null) { merged.putIfAbsent(state.getId(), state); } @@ -5269,10 +4191,10 @@ public class SimilarAsinTaskService { return new ArrayList<>(merged.values()); } - private List queryOwnedPendingCozeStates(boolean oldestFirst, int limit) { + private List queryOwnedPendingLlmStates(boolean oldestFirst, int limit) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .in(TaskScopeStateEntity::getCozeStatus, List.of(COZE_STATUS_SUBMITTED, COZE_STATUS_RUNNING)) + .in(TaskScopeStateEntity::getLlmStatus, List.of(LLM_STATUS_SUBMITTED, LLM_STATUS_RUNNING)) .and(query -> query .apply("JSON_UNQUOTE(JSON_EXTRACT(state_json, '$.ownerInstanceId')) IS NULL") .or() @@ -5541,7 +4463,7 @@ public class SimilarAsinTaskService { result.putAll(readChunkRows(chunk)); } // 合并 orphan 行(跨 chunk merge 兜底),仅当 chunk 中没有同 rowKey 时才补上,避免覆盖正常结果。 - Map orphans = loadOrphanCozeRows(taskId); + Map orphans = loadOrphanLlmRows(taskId); if (!orphans.isEmpty()) { int added = 0; for (Map.Entry entry : orphans.entrySet()) { @@ -5551,7 +4473,7 @@ public class SimilarAsinTaskService { } } if (added > 0) { - log.warn("[similar-asin] merged orphan coze rows into result taskId={} added={} totalOrphan={}", + log.warn("[similar-asin] merged orphan llm rows into result taskId={} added={} totalOrphan={}", taskId, added, orphans.size()); } } @@ -5684,11 +4606,11 @@ public class SimilarAsinTaskService { firstNonBlank(parsedRow.getPrice(), readValueByHeader(parsedRow, "价格", "price")))); row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", "seller-name", "sellername", "store name", "shop name")); row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "品牌", "brand")); - String isStock = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getIsStock()); - String similarity = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getSimilarity()); - String isConform = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getIsConform()); - String reason = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getReason()); - String category = resultRow == null ? "" : userFacingCozeCellValue(resultRow, resultRow.getCategory()); + String isStock = resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getIsStock()); + String similarity = resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getSimilarity()); + String isConform = resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getIsConform()); + String reason = resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getReason()); + String category = resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getCategory()); String status = resultRow == null || isSilentOutputFailure(resultRow) ? "" : resolveResultStatus(resultRow, isStock, similarity, isConform, reason, category); @@ -5818,7 +4740,7 @@ public class SimilarAsinTaskService { int asinCol = findRequiredHeader(headerMap, "asin"); int countryCol = findRequiredHeader(headerMap, "国家", "country"); // 兼容旧 xlsx:RESULT_HEADERS 已不再写出 sku 列,但仍要支持旧版结果簿重新上传, - // 因此这里仍按可选列读取并保留到 DTO,enrichRowForCoze 仍可使用,仅导出阶段不再写出。 + // 因此这里仍按可选列读取并保留到 DTO,enrichRowForLlm 仍可使用,仅导出阶段不再写出。 int skuCol = findOptionalHeaderExact(headerMap, "sku", "seller sku", "seller_sku", "msku", "货号"); int priceCol = findOptionalHeaderExact(headerMap, "价格", "price"); int urlCol = findOptionalHeaderExact(headerMap, @@ -5916,20 +4838,20 @@ public class SimilarAsinTaskService { || row.hasImageUrl()); } - private boolean hasResolvedCozeFields(SimilarAsinResultRowDto row) { + private boolean hasResolvedLlmFields(SimilarAsinResultRowDto row) { if (row == null) { return false; } - return hasUsableCozeField(row.getTitleRisk()) - || hasUsableCozeField(row.getAppearanceRisk()) - || hasUsableCozeField(row.getPatentRisk()) - || hasUsableCozeField(row.getConclusion()) - || hasUsableCozeField(row.getIsStock()) - || hasUsableCozeField(row.getSimilarity()) - || hasUsableCozeField(row.getIsConform()) - || hasUsableCozeField(row.getReason()) - || hasUsableCozeField(row.getCategory()) - || hasUsableCozeField(row.getStatus()) + return hasUsableLlmField(row.getTitleRisk()) + || hasUsableLlmField(row.getAppearanceRisk()) + || hasUsableLlmField(row.getPatentRisk()) + || hasUsableLlmField(row.getConclusion()) + || hasUsableLlmField(row.getIsStock()) + || hasUsableLlmField(row.getSimilarity()) + || hasUsableLlmField(row.getIsConform()) + || hasUsableLlmField(row.getReason()) + || hasUsableLlmField(row.getCategory()) + || hasUsableLlmField(row.getStatus()) || hasText(row.getMainUrl()) || hasText(row.getPuzzleImg1()) || hasText(row.getPuzzleImg2()); @@ -5972,7 +4894,7 @@ public class SimilarAsinTaskService { if (row == null) { return false; } - if (hasResolvedCozeFields(row)) { + if (hasResolvedLlmFields(row)) { return true; } String error = normalize(row.getError()); @@ -5984,9 +4906,9 @@ public class SimilarAsinTaskService { || row.hasImageUrl(); } - private boolean hasUsableCozeField(String value) { + private boolean hasUsableLlmField(String value) { String normalized = normalize(value); - return !normalized.isBlank() && !isTechnicalCozeFailure(normalized); + return !normalized.isBlank() && !isTechnicalLlmFailure(normalized); } private Map buildHeaderMap(Row header, DataFormatter formatter) { @@ -6271,8 +5193,8 @@ public class SimilarAsinTaskService { vo.setFileProgressMessage("结果文件已生成"); return; } - int cozeCompleted = countCompletedCozeStates(taskId); - int cozePending = countPendingCozeStates(taskId); + int llmCompleted = countCompletedLlmStates(taskId); + int llmPending = countPendingLlmStates(taskId); boolean uploadComplete = isResultSubmissionComplete(taskId); TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE); if (!uploadComplete) { @@ -6286,15 +5208,15 @@ public class SimilarAsinTaskService { if (task != null && !STATUS_RUNNING.equals(task.getStatus())) { return; } - if (cozeCompleted + cozePending > 0) { - attachCozeProgress(vo, job, snapshot, cozeCompleted, cozePending, false); + if (llmCompleted + llmPending > 0) { + attachLlmProgress(vo, job, snapshot, llmCompleted, llmPending, false); return; } attachPythonUploadProgress(vo, taskId); return; } - if (cozeCompleted + cozePending > 0) { - attachCozeProgress(vo, job, snapshot, cozeCompleted, cozePending, true); + if (llmCompleted + llmPending > 0) { + attachLlmProgress(vo, job, snapshot, llmCompleted, llmPending, true); return; } if (snapshot == null) { @@ -6315,28 +5237,28 @@ public class SimilarAsinTaskService { vo.setFileProgressMessage(snapshot.getMessage()); } - private void attachCozeProgress(SimilarAsinHistoryItemVo vo, + private void attachLlmProgress(SimilarAsinHistoryItemVo vo, TaskFileJobEntity job, TaskProgressSnapshotEntity snapshot, - int cozeCompleted, - int cozePending, + int llmCompleted, + int llmPending, boolean uploadComplete) { - int observed = Math.max(0, cozeCompleted) + Math.max(0, cozePending); - int total = calculateCozeDisplayTotal(snapshot, observed); + int observed = Math.max(0, llmCompleted) + Math.max(0, llmPending); + int total = calculateLlmDisplayTotal(snapshot, observed); if (!uploadComplete) { total = Math.max(total, observed + 1); } - int current = Math.max(0, Math.min(cozeCompleted, total)); + int current = Math.max(0, Math.min(llmCompleted, total)); vo.setFileProgressCurrent(current); vo.setFileProgressTotal(total); int percent = calculateDisplayProgressPercent(current, total, job, snapshot == null ? null : snapshot.getUpdatedAt()); percent = Math.max(percent, calculateSnapshotDisplayPercent(snapshot, job)); vo.setFileProgressPercent(Math.min(uploadComplete ? 99 : 98, percent)); vo.setFileProgressMessage(uploadComplete - ? (cozePending > 0 - ? buildCozeProgressMessage(current, total, cozePending) - : "Coze 已回流 " + current + "/" + total + " 批次,正在生成结果文件") - : buildUploadingCozeProgressMessage(current, total, cozePending)); + ? (llmPending > 0 + ? buildLlmProgressMessage(current, total, llmPending) + : "LLM 已回流 " + current + "/" + total + " 批次,正在生成结果文件") + : buildUploadingLlmProgressMessage(current, total, llmPending)); } private void attachPythonUploadProgress(SimilarAsinHistoryItemVo vo, Long taskId) { @@ -6379,7 +5301,7 @@ public class SimilarAsinTaskService { List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() .eq(TaskScopeStateEntity::getTaskId, taskId) .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .isNull(TaskScopeStateEntity::getCozeStatus) + .isNull(TaskScopeStateEntity::getLlmStatus) .orderByDesc(TaskScopeStateEntity::getUpdatedAt)); if (states == null || states.isEmpty()) { return resolvePythonChunkProgressFromChunks(taskId); @@ -6494,8 +5416,8 @@ public class SimilarAsinTaskService { return true; } - private int calculateCozeDisplayTotal(TaskProgressSnapshotEntity snapshot, int observedCozeStates) { - int observed = Math.max(0, observedCozeStates); + private int calculateLlmDisplayTotal(TaskProgressSnapshotEntity snapshot, int observedLlmStates) { + int observed = Math.max(0, observedLlmStates); // 小任务 totalCount <= 3 时直接使用 observed,避免 totalCount - 3 → 0 引起进度条 0/0。 if (snapshot == null || snapshot.getTotalCount() == null || snapshot.getTotalCount() <= 3) { return Math.max(1, observed); @@ -6504,24 +5426,24 @@ public class SimilarAsinTaskService { return Math.max(1, Math.max(observed, expectedFromSnapshot)); } - private String buildCozeProgressMessage(int completed, int total, int pending) { + private String buildLlmProgressMessage(int completed, int total, int pending) { int safeTotal = Math.max(1, total); int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); int submitted = Math.max(safeCompleted, Math.min(safeTotal, safeCompleted + Math.max(0, pending))); if (safeCompleted <= 0 && submitted > 0) { - return "Coze 已提交 " + submitted + "/" + safeTotal + " 批次,等待回流"; + return "LLM 已提交 " + submitted + "/" + safeTotal + " 批次,等待回流"; } - return "Coze 已回流 " + safeCompleted + "/" + safeTotal + " 批次,等待结果文件"; + return "LLM 已回流 " + safeCompleted + "/" + safeTotal + " 批次,等待结果文件"; } - private String buildUploadingCozeProgressMessage(int completed, int total, int pending) { + private String buildUploadingLlmProgressMessage(int completed, int total, int pending) { int safeTotal = Math.max(1, total); int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); int submitted = Math.max(safeCompleted, Math.min(safeTotal, safeCompleted + Math.max(0, pending))); if (pending > 0) { - return "Python 仍在回传,Coze 已提交 " + submitted + "/" + safeTotal + " 批次"; + return "Python 仍在回传,LLM 已提交 " + submitted + "/" + safeTotal + " 批次"; } - return "Python 仍在回传,Coze 已回流 " + safeCompleted + "/" + safeTotal + " 批次"; + return "Python 仍在回传,LLM 已回流 " + safeCompleted + "/" + safeTotal + " 批次"; } private String fmt(LocalDateTime t) { @@ -6738,7 +5660,7 @@ public class SimilarAsinTaskService { * 中读回这些 orphan 行并合入最终 xlsx。 * 使用 best-effort 策略:transient storage 出问题时仅记录 warn,不打断主流程。 */ - private void persistOrphanCozeRows(Long taskId, List orphanRows) { + private void persistOrphanLlmRows(Long taskId, List orphanRows) { if (taskId == null || taskId <= 0 || orphanRows == null || orphanRows.isEmpty()) { return; } @@ -6749,7 +5671,7 @@ public class SimilarAsinTaskService { continue; } String existingError = row.getError(); - String marker = ORPHAN_COZE_ROW_ERROR; + String marker = ORPHAN_LLM_ROW_ERROR; if (existingError == null || existingError.isBlank()) { row.setError(marker); } else if (!existingError.contains(marker)) { @@ -6758,7 +5680,7 @@ public class SimilarAsinTaskService { } String orphanScopeKey = ORPHAN_SCOPE_KEY_PREFIX + UUID.randomUUID(); String orphanScopeHash = DigestUtil.sha256Hex(orphanScopeKey); - String payloadJson = writeJson(orphanRows, "serialize orphan coze rows failed"); + String payloadJson = writeJson(orphanRows, "serialize orphan llm rows failed"); String storedPayload = transientPayloadStorageService.storeParsedPayloadEntry( MODULE_TYPE, taskId, orphanScopeHash, "orphan-" + UUID.randomUUID(), payloadJson, true); LocalDateTime now = LocalDateTime.now(); @@ -6768,13 +5690,13 @@ public class SimilarAsinTaskService { state.setScopeKey(orphanScopeKey); state.setScopeHash(orphanScopeHash); state.setParsedPayloadJson(storedPayload); - state.setStateJson("{\"phase\":\"ORPHAN_COZE_ROWS\"}"); - // cozeStatus 直接置 DONE,避免被 listOwnedPendingCozeStates 扫描到。 - state.setCozeStatus(COZE_STATUS_DONE); - state.setCozeSubmittedAt(now); - state.setCozeCompletedAt(now); - state.setCozeAttemptCount(0); - state.setCozeError(ORPHAN_COZE_ROW_ERROR); + state.setStateJson("{\"phase\":\"ORPHAN_LLM_ROWS\"}"); + // llmStatus 直接置 DONE,避免被 listOwnedPendingLlmStates 扫描到。 + state.setLlmStatus(LLM_STATUS_DONE); + state.setLlmSubmittedAt(now); + state.setLlmCompletedAt(now); + state.setLlmAttemptCount(0); + state.setLlmError(ORPHAN_LLM_ROW_ERROR); state.setChunkTotal(orphanRows.size()); state.setReceivedChunkCount(orphanRows.size()); state.setCompleted(1); @@ -6783,7 +5705,7 @@ public class SimilarAsinTaskService { state.setUpdatedAt(now); try { taskScopeStateMapper.insert(state); - log.warn("[similar-asin] orphan coze rows persisted taskId={} rows={} scopeKey={}", + log.warn("[similar-asin] orphan llm rows persisted taskId={} rows={} scopeKey={}", taskId, orphanRows.size(), orphanScopeKey); } catch (DuplicateKeyException ex) { transientPayloadStorageService.deletePayloadIfPresent(storedPayload); @@ -6791,17 +5713,17 @@ public class SimilarAsinTaskService { taskId, orphanScopeKey); } } catch (Exception ex) { - log.warn("[similar-asin] persist orphan coze rows failed taskId={} rows={} err={}", + log.warn("[similar-asin] persist orphan llm rows failed taskId={} rows={} err={}", taskId, orphanRows.size(), ex.getMessage()); } } /** - * 读回 {@link #persistOrphanCozeRows(Long, List)} 落库的 orphan 行集合。 + * 读回 {@link #persistOrphanLlmRows(Long, List)} 落库的 orphan 行集合。 * 仅 assembleResult 阶段调用,与 chunk 行合并时使用 putIfAbsent 语义: * 同一 rowKey 下 chunk 已有结果时优先用 chunk 行,避免覆盖正常路径数据。 */ - private Map loadOrphanCozeRows(Long taskId) { + private Map loadOrphanLlmRows(Long taskId) { Map rows = new LinkedHashMap<>(); if (taskId == null || taskId <= 0) { return rows; @@ -6833,7 +5755,7 @@ public class SimilarAsinTaskService { rows.putIfAbsent(key, row); } } catch (Exception ex) { - log.warn("[similar-asin] read orphan coze rows failed taskId={} stateId={} err={}", + log.warn("[similar-asin] read orphan llm rows failed taskId={} stateId={} err={}", taskId, state.getId(), ex.getMessage()); } } @@ -6942,7 +5864,7 @@ public class SimilarAsinTaskService { } for (Map.Entry entry : row.getValues() == null ? Map.of().entrySet() : row.getValues().entrySet()) { - if (COZE_RESULT_HEADER_ALIASES.contains(FailedStatusRowFilter.canonicalizeHeader(entry.getKey())) + if (LLM_RESULT_HEADER_ALIASES.contains(FailedStatusRowFilter.canonicalizeHeader(entry.getKey())) && !isBlankOrSpreadsheetError(entry.getValue())) { return false; } @@ -7012,16 +5934,16 @@ public class SimilarAsinTaskService { return false; } - private String userFacingCozeCellValue(SimilarAsinResultRowDto row, String value) { + private String userFacingLlmCellValue(SimilarAsinResultRowDto row, String value) { String normalizedValue = normalize(value); if (isSilentOutputFailure(row) || isSilentOutputFailure(normalizedValue)) { return ""; } - if (!normalizedValue.isBlank() && !isTechnicalCozeFailure(normalizedValue)) { + if (!normalizedValue.isBlank() && !isTechnicalLlmFailure(normalizedValue)) { return value; } - // coze 技术性失败:有错误信息则放入错误信息,没有则留空 - if (row != null && isTechnicalCozeFailure(row.getError())) { + // LLM 技术性失败:有错误信息则放入错误信息,没有则留空 + if (row != null && isTechnicalLlmFailure(row.getError())) { return firstNonBlank(row.getError(), ""); } return firstNonBlank(value, ""); @@ -7032,11 +5954,11 @@ public class SimilarAsinTaskService { return ""; } String conclusion = normalize(row.getConclusion()); - if (!conclusion.isBlank() && !isTechnicalCozeFailure(conclusion)) { + if (!conclusion.isBlank() && !isTechnicalLlmFailure(conclusion)) { return row.getConclusion(); } - // coze 技术性失败:有错误信息则放入错误信息,没有则留空 - if (isTechnicalCozeFailure(row.getError())) { + // LLM 技术性失败:有错误信息则放入错误信息,没有则留空 + if (isTechnicalLlmFailure(row.getError())) { return firstNonBlank(row.getError(), ""); } return firstNonBlank(row.getConclusion(), ""); @@ -7052,20 +5974,20 @@ public class SimilarAsinTaskService { private boolean isSilentOutputFailure(String value) { String normalized = normalize(value).toLowerCase(Locale.ROOT); return normalized.contains("required field missing") - && normalized.contains("skip coze"); + && normalized.contains("skip llm"); } - private boolean isTechnicalCozeFailure(String value) { + private boolean isTechnicalLlmFailure(String value) { String normalized = normalize(value).toLowerCase(Locale.ROOT); if (normalized.isBlank()) { return false; } // 确定性输入失败统一通过 DETERMINISTIC_INPUT_FAILURE_PATTERN 判定, - // 其它技术性失败(coze 关键词、超时、节点超限等)保持原有子串匹配。 + // 其它技术性失败(超时、节点超限等)保持原有子串匹配。 if (DETERMINISTIC_INPUT_FAILURE_PATTERN.matcher(normalized).find()) { return true; } - return normalized.contains("coze") + return normalized.contains("llm") || normalized.contains("结果不完整") || normalized.contains("工作流节点执行超限") || normalized.contains("调用超时") @@ -7157,7 +6079,7 @@ public class SimilarAsinTaskService { boolean terminal) { } - record CozeBatchContext(Long jobId, + record LlmBatchContext(Long jobId, Long resultId, String chunkScopeHash, Integer chunkIndex, diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/PuzzleImageMerger.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/PuzzleImageMerger.java index a72a2b99..8da376ec 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/PuzzleImageMerger.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/PuzzleImageMerger.java @@ -32,10 +32,10 @@ import java.util.List; import java.util.Map; /** - * 详情页图片拼接:复刻 Coze 插件 image_pinjie 的行为。 + * 详情页图片拼接:复刻原 image_pinjie 插件的行为。 * 横版(Orientation=2):alibaba 图片按 4 列网格铺到 2560px 宽画布, * 每格下方叠加白色价格条(红色粗体、两位小数),整图 JPEG(95) 输出。 - * 图片下载失败时保留原图(URL 回退),与 Coze 插件语义对齐。 + * 图片下载失败时保留原图(URL 回退),与原插件语义对齐。 */ @Component @Slf4j diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java index 78764d16..6327356a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java @@ -67,7 +67,7 @@ import java.util.concurrent.atomic.AtomicInteger; /** * similar-asin 结果 xlsx 的图片嵌入器:下载、缩略图压缩、任务内缓存、POI 嵌入与单图失败兜底集中在一处。 - * 按 ralplan 共识 .omc/plans/similar-asin-coze-image-embed.md T4 / T4.5 / T5 实现。 + * 按 ralplan 共识 .omc/plans/similar-asin-image-embed.md T4 / T4.5 / T5 实现。 */ @Component @Slf4j @@ -967,7 +967,7 @@ public class SimilarAsinImageEmbedder { .header("Cache-Control", "no-cache") .get(); if (isCozeSignedImageUrl(url)) { - // Coze/TOS signed image links behave like direct file downloads. + // LLM/TOS signed image links behave like direct file downloads. // A foreign Referer can be rejected, so keep this close to a browser address-bar download. builder.header("Accept", DOWNLOAD_ACCEPT); } else { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinLogSupport.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinLogSupport.java index 49b4b2af..112a3cf4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinLogSupport.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinLogSupport.java @@ -1,7 +1,7 @@ package com.nanri.aiimage.modules.similarasin.util; /** - * Task 19:Coze 请求/响应及 Python 回传日志的采样与截断工具。 + * Task 19:LLM 请求/响应及 Python 回传日志的采样与截断工具。 * truncate 保证超长正文输出有界(前缀 + 长度 + 后缀),不抛异常、不破坏代理对; * shouldLog 按每 everyN 次采样一次(counter % everyN == 0),计数 0 恒采样。 * 两个方法均为纯函数,可在日志点直接内联使用。 diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskImageCacheEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskImageCacheEntity.java index 4848b3cf..d98092a1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskImageCacheEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskImageCacheEntity.java @@ -8,7 +8,7 @@ import lombok.Data; import java.time.LocalDateTime; /** - * P2-12:图片缩略图缓存。配合 {@code SimilarAsinImagePrefetchService} 跨任务复用 Coze + * P2-12:图片缩略图缓存。配合 {@code SimilarAsinImagePrefetchService} 跨任务复用 LLM * 回包中的 main_url / puzzle_img 图片,避免 assemble 阶段每次都重新下载。 * *

    对应表 {@code biz_task_image_cache}(migration V53)。 diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskScopeStateEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskScopeStateEntity.java index 2fdeb008..cf8d1ec1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskScopeStateEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/entity/TaskScopeStateEntity.java @@ -19,13 +19,13 @@ public class TaskScopeStateEntity { private String scopeHash; private String parsedPayloadJson; private String stateJson; - private String cozeExecuteId; - private String cozeStatus; - private LocalDateTime cozeSubmittedAt; - private LocalDateTime cozeLastPolledAt; - private LocalDateTime cozeCompletedAt; - private Integer cozeAttemptCount; - private String cozeError; + private String llmExecuteId; + private String llmStatus; + private LocalDateTime llmSubmittedAt; + private LocalDateTime llmLastPolledAt; + private LocalDateTime llmCompletedAt; + private Integer llmAttemptCount; + private String llmError; private Integer chunkTotal; private Integer receivedChunkCount; private Integer completed; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/AppearancePatentResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/AppearancePatentResultFileJobHandler.java new file mode 100644 index 00000000..4ee2a0ed --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/AppearancePatentResultFileJobHandler.java @@ -0,0 +1,43 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * APPEARANCE_PATENT 结果文件 Job Handler(04 注册表)。 + * process 返回 Service 的 boolean(false=等待 coze 异步结果,Worker 保持运行心跳); + * cleanup 委托 cleanupResultFileJob;支持异步 offload;owner scoped。 + */ +public class AppearancePatentResultFileJobHandler implements ResultFileJobHandler { + + private final AppearancePatentTaskService appearancePatentTaskService; + + public AppearancePatentResultFileJobHandler(AppearancePatentTaskService appearancePatentTaskService) { + this.appearancePatentTaskService = appearancePatentTaskService; + } + + @Override + public String moduleType() { + return "APPEARANCE_PATENT"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + return appearancePatentTaskService.processResultFileJob(job); + } + + @Override + public void cleanup(TaskFileJobEntity job) { + appearancePatentTaskService.cleanupResultFileJob(job); + } + + @Override + public boolean supportsAsyncOffload() { + return true; + } + + @Override + public boolean isOwnerScoped() { + return true; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/BrandResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/BrandResultFileJobHandler.java new file mode 100644 index 00000000..129e32f6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/BrandResultFileJobHandler.java @@ -0,0 +1,33 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * BRAND 结果文件 Job Handler(04 注册表)。 + * 注意:resultFileUrl 解析特例(resolveResultObjectKey,无 resultId 也走) + * 保留在 Worker 公共路径 resolveResultFileUrl,Handler 不接管 URL 解析; + * cleanup 为空(原 cleanupAfterSuccess 无 BRAND 分支)。 + */ +public class BrandResultFileJobHandler implements ResultFileJobHandler { + + private final BrandTaskService brandTaskService; + private final TaskResultPayloadService taskResultPayloadService; + + public BrandResultFileJobHandler(BrandTaskService brandTaskService, + TaskResultPayloadService taskResultPayloadService) { + this.brandTaskService = brandTaskService; + this.taskResultPayloadService = taskResultPayloadService; + } + + @Override + public String moduleType() { + return "BRAND"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + brandTaskService.processResultFileJob(job); + return true; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/CollectDataResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/CollectDataResultFileJobHandler.java new file mode 100644 index 00000000..0691b8bb --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/CollectDataResultFileJobHandler.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * COLLECT_DATA 结果文件 Job Handler(04 注册表)。 + * cleanup 为空(原 cleanupAfterSuccess 无 COLLECT_DATA 分支)。 + */ +public class CollectDataResultFileJobHandler implements ResultFileJobHandler { + + private final CollectDataService collectDataService; + + public CollectDataResultFileJobHandler(CollectDataService collectDataService) { + this.collectDataService = collectDataService; + } + + @Override + public String moduleType() { + return "COLLECT_DATA"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + collectDataService.processResultFileJob(job); + return true; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/DeleteBrandResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/DeleteBrandResultFileJobHandler.java new file mode 100644 index 00000000..41614a5b --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/DeleteBrandResultFileJobHandler.java @@ -0,0 +1,32 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * DELETE_BRAND 结果文件 Job Handler(04 注册表)。 + */ +public class DeleteBrandResultFileJobHandler implements ResultFileJobHandler { + + private final DeleteBrandRunService deleteBrandRunService; + + public DeleteBrandResultFileJobHandler(DeleteBrandRunService deleteBrandRunService) { + this.deleteBrandRunService = deleteBrandRunService; + } + + @Override + public String moduleType() { + return "DELETE_BRAND"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + deleteBrandRunService.processResultFileJob(job); + return true; + } + + @Override + public void cleanup(TaskFileJobEntity job) { + deleteBrandRunService.cleanupResultFileJob(job); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PatrolDeleteResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PatrolDeleteResultFileJobHandler.java new file mode 100644 index 00000000..4c4205c4 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PatrolDeleteResultFileJobHandler.java @@ -0,0 +1,35 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * PATROL_DELETE 结果文件 Job Handler(04 注册表)。 + */ +public class PatrolDeleteResultFileJobHandler implements ResultFileJobHandler { + + private final PatrolDeleteTaskService patrolDeleteTaskService; + private final TaskResultPayloadService taskResultPayloadService; + + public PatrolDeleteResultFileJobHandler(PatrolDeleteTaskService patrolDeleteTaskService, + TaskResultPayloadService taskResultPayloadService) { + this.patrolDeleteTaskService = patrolDeleteTaskService; + this.taskResultPayloadService = taskResultPayloadService; + } + + @Override + public String moduleType() { + return "PATROL_DELETE"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + patrolDeleteTaskService.processResultFileJob(job); + return true; + } + + @Override + public void cleanup(TaskFileJobEntity job) { + taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PriceTrackResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PriceTrackResultFileJobHandler.java new file mode 100644 index 00000000..a8ef54c7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PriceTrackResultFileJobHandler.java @@ -0,0 +1,35 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * PRICE_TRACK 结果文件 Job Handler(04 注册表)。 + */ +public class PriceTrackResultFileJobHandler implements ResultFileJobHandler { + + private final PriceTrackTaskService priceTrackTaskService; + private final TaskResultPayloadService taskResultPayloadService; + + public PriceTrackResultFileJobHandler(PriceTrackTaskService priceTrackTaskService, + TaskResultPayloadService taskResultPayloadService) { + this.priceTrackTaskService = priceTrackTaskService; + this.taskResultPayloadService = taskResultPayloadService; + } + + @Override + public String moduleType() { + return "PRICE_TRACK"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + priceTrackTaskService.processResultFileJob(job); + return true; + } + + @Override + public void cleanup(TaskFileJobEntity job) { + taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ProductRiskResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ProductRiskResultFileJobHandler.java new file mode 100644 index 00000000..6aeb6c45 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ProductRiskResultFileJobHandler.java @@ -0,0 +1,35 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * PRODUCT_RISK_RESOLVE 结果文件 Job Handler(04 注册表)。 + */ +public class ProductRiskResultFileJobHandler implements ResultFileJobHandler { + + private final ProductRiskTaskService productRiskTaskService; + private final TaskResultPayloadService taskResultPayloadService; + + public ProductRiskResultFileJobHandler(ProductRiskTaskService productRiskTaskService, + TaskResultPayloadService taskResultPayloadService) { + this.productRiskTaskService = productRiskTaskService; + this.taskResultPayloadService = taskResultPayloadService; + } + + @Override + public String moduleType() { + return "PRODUCT_RISK_RESOLVE"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + productRiskTaskService.processResultFileJob(job); + return true; + } + + @Override + public void cleanup(TaskFileJobEntity job) { + taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PublishResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PublishResultFileJobHandler.java new file mode 100644 index 00000000..6dc45ecf --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/PublishResultFileJobHandler.java @@ -0,0 +1,39 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * PUBLISH 结果文件 Job Handler(04 注册表)。 + * cleanup 委托 PublishTaskService.cleanupResultFileJob(不走 payload.deleteLatest); + * owner scoped:scopeKey :owner: 判定归属实例。 + */ +public class PublishResultFileJobHandler implements ResultFileJobHandler { + + private final PublishTaskService publishTaskService; + + public PublishResultFileJobHandler(PublishTaskService publishTaskService) { + this.publishTaskService = publishTaskService; + } + + @Override + public String moduleType() { + return PublishTaskService.MODULE_TYPE; + } + + @Override + public boolean process(TaskFileJobEntity job) { + publishTaskService.processResultFileJob(job); + return true; + } + + @Override + public void cleanup(TaskFileJobEntity job) { + publishTaskService.cleanupResultFileJob(job); + } + + @Override + public boolean isOwnerScoped() { + return true; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/QueryAsinResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/QueryAsinResultFileJobHandler.java new file mode 100644 index 00000000..42c39977 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/QueryAsinResultFileJobHandler.java @@ -0,0 +1,35 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * QUERY_ASIN 结果文件 Job Handler(04 注册表)。 + */ +public class QueryAsinResultFileJobHandler implements ResultFileJobHandler { + + private final QueryAsinTaskService queryAsinTaskService; + private final TaskResultPayloadService taskResultPayloadService; + + public QueryAsinResultFileJobHandler(QueryAsinTaskService queryAsinTaskService, + TaskResultPayloadService taskResultPayloadService) { + this.queryAsinTaskService = queryAsinTaskService; + this.taskResultPayloadService = taskResultPayloadService; + } + + @Override + public String moduleType() { + return "QUERY_ASIN"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + queryAsinTaskService.processResultFileJob(job); + return true; + } + + @Override + public void cleanup(TaskFileJobEntity job) { + taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandler.java new file mode 100644 index 00000000..e5f42bd7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandler.java @@ -0,0 +1,37 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * 结果文件 Job 处理 Handler(04 注册表)。 + * 契约:moduleType 全库唯一,重复注册启动即失败; + * process 返回 true=处理完成,false=等待异步结果(仅 llm 模块); + * onSuccess 在 markSuccess 之后调用(原 finalizeWithdraw 时机,如 WITHDRAW 的 tryFinalizeTask); + * cleanup 在 Job 成功后调用(原 cleanupAfterSuccess 分支); + * onFailure 在重试耗尽时调用(原 notifyRetryExhausted 分支); + * supportsAsyncOffload 对应原 llm offload 判定; + * isOwnerScoped 对应原 :owner: 归属实例判定。 + */ +public interface ResultFileJobHandler { + + String moduleType(); + + boolean process(TaskFileJobEntity job); + + default void onSuccess(TaskFileJobEntity job) { + } + + default void cleanup(TaskFileJobEntity job) { + } + + default void onFailure(TaskFileJobEntity job, String message) { + } + + default boolean supportsAsyncOffload() { + return false; + } + + default boolean isOwnerScoped() { + return false; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistry.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistry.java new file mode 100644 index 00000000..fe9bc1be --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistry.java @@ -0,0 +1,62 @@ +package com.nanri.aiimage.modules.task.service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * ResultFileJobHandler 注册表(04 注册表)。 + * 构造时收集全部 Handler 并构建 moduleType→handler 映射; + * moduleType 重复即启动失败(fail-fast,消息含模块名)。 + */ +public class ResultFileJobHandlerRegistry { + + private final Map handlersByModuleType; + + public ResultFileJobHandlerRegistry(List handlers) { + Map map = new LinkedHashMap<>(); + for (ResultFileJobHandler handler : handlers) { + String moduleType = handler.moduleType(); + if (map.containsKey(moduleType)) { + throw new IllegalStateException("Handler 模块类型重复注册:" + moduleType); + } + map.put(moduleType, handler); + } + this.handlersByModuleType = Collections.unmodifiableMap(map); + } + + public ResultFileJobHandler resolve(String moduleType) { + ResultFileJobHandler handler = handlersByModuleType.get(moduleType); + if (handler == null) { + throw new IllegalArgumentException("unsupported result file job module: " + moduleType); + } + return handler; + } + + public Set moduleTypes() { + return handlersByModuleType.keySet(); + } + + public Map asMap() { + return handlersByModuleType; + } + + /** + * 启动 fail-fast 校验:expected 中每个 moduleType 都必须有且仅有一个 Handler, + * 缺失时抛 IllegalStateException 并列出缺失清单。 + */ + public void validateCoverage(Set expectedModuleTypes) { + List missing = new ArrayList<>(); + for (String moduleType : expectedModuleTypes) { + if (!handlersByModuleType.containsKey(moduleType)) { + missing.add(moduleType); + } + } + if (!missing.isEmpty()) { + throw new IllegalStateException("结果文件 Job Handler 缺失注册:" + String.join(", ", missing)); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ShopDataCrawlResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ShopDataCrawlResultFileJobHandler.java new file mode 100644 index 00000000..b568ab49 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ShopDataCrawlResultFileJobHandler.java @@ -0,0 +1,48 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * SHOP_DATA_CRAWL 结果文件 Job Handler(04 注册表)。 + * cleanup 两处:payload.deleteLatest + 服务内 cleanupResultFileJob(原分支语义); + * onFailure 委托 handleResultFileJobFailure(重试耗尽回调);owner scoped。 + */ +public class ShopDataCrawlResultFileJobHandler implements ResultFileJobHandler { + + private final ShopDataCrawlTaskService shopDataCrawlTaskService; + private final TaskResultPayloadService taskResultPayloadService; + + public ShopDataCrawlResultFileJobHandler(ShopDataCrawlTaskService shopDataCrawlTaskService, + TaskResultPayloadService taskResultPayloadService) { + this.shopDataCrawlTaskService = shopDataCrawlTaskService; + this.taskResultPayloadService = taskResultPayloadService; + } + + @Override + public String moduleType() { + return "SHOP_DATA_CRAWL"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + shopDataCrawlTaskService.processResultFileJob(job); + return true; + } + + @Override + public void cleanup(TaskFileJobEntity job) { + taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey()); + shopDataCrawlTaskService.cleanupResultFileJob(job); + } + + @Override + public void onFailure(TaskFileJobEntity job, String message) { + shopDataCrawlTaskService.handleResultFileJobFailure(job, message); + } + + @Override + public boolean isOwnerScoped() { + return true; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ShopMatchResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ShopMatchResultFileJobHandler.java new file mode 100644 index 00000000..12969ba1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/ShopMatchResultFileJobHandler.java @@ -0,0 +1,37 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * SHOP_MATCH 结果文件 Job Handler(04 注册表)。 + * process 委托 ShopMatchTaskService.processResultFileJob; + * cleanup 删除该任务最新 payload(原 cleanupAfterSuccess 分支语义)。 + */ +public class ShopMatchResultFileJobHandler implements ResultFileJobHandler { + + private final ShopMatchTaskService shopMatchTaskService; + private final TaskResultPayloadService taskResultPayloadService; + + public ShopMatchResultFileJobHandler(ShopMatchTaskService shopMatchTaskService, + TaskResultPayloadService taskResultPayloadService) { + this.shopMatchTaskService = shopMatchTaskService; + this.taskResultPayloadService = taskResultPayloadService; + } + + @Override + public String moduleType() { + return "SHOP_MATCH"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + shopMatchTaskService.processResultFileJob(job); + return true; + } + + @Override + public void cleanup(TaskFileJobEntity job) { + taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/SimilarAsinResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/SimilarAsinResultFileJobHandler.java new file mode 100644 index 00000000..78072cb1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/SimilarAsinResultFileJobHandler.java @@ -0,0 +1,48 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; + +/** + * SIMILAR_ASIN 结果文件 Job Handler(04 注册表)。 + * process 返回 Service 的 boolean(false=等待 llm 异步结果); + * onFailure 委托 handleResultFileJobFailure(重试耗尽回调);异步 offload;owner scoped。 + */ +public class SimilarAsinResultFileJobHandler implements ResultFileJobHandler { + + private final SimilarAsinTaskService similarAsinTaskService; + + public SimilarAsinResultFileJobHandler(SimilarAsinTaskService similarAsinTaskService) { + this.similarAsinTaskService = similarAsinTaskService; + } + + @Override + public String moduleType() { + return "SIMILAR_ASIN"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + return similarAsinTaskService.processResultFileJob(job); + } + + @Override + public void cleanup(TaskFileJobEntity job) { + similarAsinTaskService.cleanupResultFileJob(job); + } + + @Override + public void onFailure(TaskFileJobEntity job, String message) { + similarAsinTaskService.handleResultFileJobFailure(job, message); + } + + @Override + public boolean supportsAsyncOffload() { + return true; + } + + @Override + public boolean isOwnerScoped() { + return true; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java index 94ee3d14..011ca333 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorker.java @@ -4,15 +4,9 @@ import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.modules.brand.service.BrandTaskService; -import com.nanri.aiimage.modules.collectdata.service.CollectDataService; import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; -import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; -import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; -import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; import com.nanri.aiimage.modules.publish.service.PublishTaskService; -import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; -import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; import com.nanri.aiimage.modules.task.mapper.FileResultMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; @@ -41,26 +35,15 @@ public class TaskResultFileJobWorker { private final TaskFileJobService taskFileJobService; private final TaskDistributedLockService taskDistributedLockService; - private final TaskResultPayloadService taskResultPayloadService; private final FileResultMapper fileResultMapper; private final TaskFileJobLocalDispatcher taskFileJobLocalDispatcher; private final InstanceMetadata instanceMetadata; - private final ShopMatchTaskService shopMatchTaskService; - private final PriceTrackTaskService priceTrackTaskService; - private final ProductRiskTaskService productRiskTaskService; - private final PublishTaskService publishTaskService; - private final QueryAsinTaskService queryAsinTaskService; - private final ShopDataCrawlTaskService shopDataCrawlTaskService; private final WithdrawTaskService withdrawTaskService; - private final PatrolDeleteTaskService patrolDeleteTaskService; - private final AppearancePatentTaskService appearancePatentTaskService; - private final SimilarAsinTaskService similarAsinTaskService; - private final DeleteBrandRunService deleteBrandRunService; private final BrandTaskService brandTaskService; - private final CollectDataService collectDataService; + private final ResultFileJobHandlerRegistry handlerRegistry; @Autowired - @Qualifier("cozeTaskExecutor") - private TaskExecutor cozeTaskExecutor; + @Qualifier("taskQueueExecutor") + private TaskExecutor taskQueueExecutor; @Value("${aiimage.result-file-job.local-worker-enabled:true}") private boolean localWorkerEnabled; @@ -133,12 +116,13 @@ public class TaskResultFileJobWorker { if (claim == null) { return; } - if ("APPEARANCE_PATENT".equals(job.getModuleType()) || "SIMILAR_ASIN".equals(job.getModuleType())) { + ResultFileJobHandler handler = handlerRegistry.asMap().get(job.getModuleType()); + if (handler != null && handler.supportsAsyncOffload()) { try { - cozeTaskExecutor.execute(() -> processClaimedWithHeartbeat(job, claim)); + taskQueueExecutor.execute(() -> processClaimedWithHeartbeat(job, claim)); return; } catch (RuntimeException ex) { - log.warn("[task-file-job] coze module offload failed, fallback inline jobId={} taskId={} moduleType={} msg={}", + log.warn("[task-file-job] llm module offload failed, fallback inline jobId={} taskId={} moduleType={} msg={}", job.getId(), job.getTaskId(), job.getModuleType(), ex.getMessage(), ex); } } @@ -200,12 +184,12 @@ public class TaskResultFileJobWorker { if (!completed) { if (isOwnerScopedJob(job)) { taskFileJobService.touchRunning(job.getId()); - log.info("[task-file-job] process waiting for async coze result jobId={} taskId={} moduleType={} resultId={} elapsedMs={}", + log.info("[task-file-job] process waiting for async llm result jobId={} taskId={} moduleType={} resultId={} elapsedMs={}", job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), System.currentTimeMillis() - startedAt); return; } - taskFileJobService.deferRunning(job.getId(), "Waiting for Coze/file assembly to continue"); + taskFileJobService.deferRunning(job.getId(), "Waiting for LLM/file assembly to continue"); log.info("[task-file-job] process deferred jobId={} taskId={} moduleType={} resultId={} elapsedMs={}", job.getId(), job.getTaskId(), job.getModuleType(), job.getResultId(), System.currentTimeMillis() - startedAt); @@ -262,10 +246,9 @@ public class TaskResultFileJobWorker { } private void notifyRetryExhausted(TaskFileJobEntity job, String message) { - if ("SHOP_DATA_CRAWL".equals(job.getModuleType())) { - shopDataCrawlTaskService.handleResultFileJobFailure(job, message); - } else if ("SIMILAR_ASIN".equals(job.getModuleType())) { - similarAsinTaskService.handleResultFileJobFailure(job, message); + ResultFileJobHandler handler = handlerRegistry.asMap().get(job.getModuleType()); + if (handler != null) { + handler.onFailure(job, message); } } @@ -302,10 +285,8 @@ public class TaskResultFileJobWorker { if (job == null || job.getModuleType() == null) { return false; } - return "APPEARANCE_PATENT".equals(job.getModuleType()) - || "SIMILAR_ASIN".equals(job.getModuleType()) - || "PUBLISH".equals(job.getModuleType()) - || "SHOP_DATA_CRAWL".equals(job.getModuleType()); + ResultFileJobHandler handler = handlerRegistry.asMap().get(job.getModuleType()); + return handler != null && handler.isOwnerScoped(); } private boolean isOwnedByCurrentInstance(TaskFileJobEntity job) { @@ -333,90 +314,13 @@ public class TaskResultFileJobWorker { private boolean dispatch(TaskFileJobEntity job) { String moduleType = job.getModuleType(); - if ("SHOP_MATCH".equals(moduleType)) { - shopMatchTaskService.processResultFileJob(job); - return true; - } - if ("PRICE_TRACK".equals(moduleType)) { - priceTrackTaskService.processResultFileJob(job); - return true; - } - if ("PRODUCT_RISK_RESOLVE".equals(moduleType)) { - productRiskTaskService.processResultFileJob(job); - return true; - } - if (PublishTaskService.MODULE_TYPE.equals(moduleType)) { - publishTaskService.processResultFileJob(job); - return true; - } - if ("QUERY_ASIN".equals(moduleType)) { - queryAsinTaskService.processResultFileJob(job); - return true; - } - if ("SHOP_DATA_CRAWL".equals(moduleType)) { - shopDataCrawlTaskService.processResultFileJob(job); - return true; - } - if ("WITHDRAW".equals(moduleType)) { - withdrawTaskService.processResultFileJob(job); - return true; - } - if ("PATROL_DELETE".equals(moduleType)) { - patrolDeleteTaskService.processResultFileJob(job); - return true; - } - if ("APPEARANCE_PATENT".equals(moduleType)) { - return appearancePatentTaskService.processResultFileJob(job); - } - if ("SIMILAR_ASIN".equals(moduleType)) { - return similarAsinTaskService.processResultFileJob(job); - } - if ("DELETE_BRAND".equals(moduleType)) { - deleteBrandRunService.processResultFileJob(job); - return true; - } - if ("BRAND".equals(moduleType)) { - brandTaskService.processResultFileJob(job); - return true; - } - if ("COLLECT_DATA".equals(moduleType)) { - collectDataService.processResultFileJob(job); - return true; - } - throw new IllegalArgumentException("unsupported result file job module: " + moduleType); + return handlerRegistry.resolve(moduleType).process(job); } private void cleanupAfterSuccess(TaskFileJobEntity job) { - String moduleType = job.getModuleType(); - if ("SHOP_DATA_CRAWL".equals(moduleType)) { - taskResultPayloadService.deleteLatest(job.getTaskId(), moduleType, job.getScopeKey()); - shopDataCrawlTaskService.cleanupResultFileJob(job); - return; - } - if ("SHOP_MATCH".equals(moduleType) - || "PRICE_TRACK".equals(moduleType) - || "PRODUCT_RISK_RESOLVE".equals(moduleType) - || "QUERY_ASIN".equals(moduleType) - || "WITHDRAW".equals(moduleType) - || "PATROL_DELETE".equals(moduleType)) { - taskResultPayloadService.deleteLatest(job.getTaskId(), moduleType, job.getScopeKey()); - return; - } - if ("APPEARANCE_PATENT".equals(moduleType)) { - appearancePatentTaskService.cleanupResultFileJob(job); - return; - } - if ("SIMILAR_ASIN".equals(moduleType)) { - similarAsinTaskService.cleanupResultFileJob(job); - return; - } - if ("PUBLISH".equals(moduleType)) { - publishTaskService.cleanupResultFileJob(job); - return; - } - if ("DELETE_BRAND".equals(moduleType)) { - deleteBrandRunService.cleanupResultFileJob(job); - return; + ResultFileJobHandler handler = handlerRegistry.asMap().get(job.getModuleType()); + if (handler != null) { + handler.cleanup(job); } } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/WithdrawResultFileJobHandler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/WithdrawResultFileJobHandler.java new file mode 100644 index 00000000..e842f9a7 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/WithdrawResultFileJobHandler.java @@ -0,0 +1,45 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; + +/** + * WITHDRAW 结果文件 Job Handler(04 注册表)。 + * onSuccess 在 markSuccess 之后调用 tryFinalizeTask(原 finalizeWithdraw 标志语义,时机严格保持); + * cleanup 走 payload.deleteLatest。 + */ +public class WithdrawResultFileJobHandler implements ResultFileJobHandler { + + private final WithdrawTaskService withdrawTaskService; + private final TaskResultPayloadService taskResultPayloadService; + + public WithdrawResultFileJobHandler(WithdrawTaskService withdrawTaskService, + TaskResultPayloadService taskResultPayloadService) { + this.withdrawTaskService = withdrawTaskService; + this.taskResultPayloadService = taskResultPayloadService; + } + + @Override + public String moduleType() { + return "WITHDRAW"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + withdrawTaskService.processResultFileJob(job); + return true; + } + + @Override + public void onSuccess(TaskFileJobEntity job) { + if (job == null) { + return; + } + withdrawTaskService.tryFinalizeTask(job.getTaskId(), false); + } + + @Override + public void cleanup(TaskFileJobEntity job) { + taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey()); + } +} diff --git a/backend-java/src/main/resources/application-local.example.yml b/backend-java/src/main/resources/application-local.example.yml index 7f03271a..5f6b9da2 100644 --- a/backend-java/src/main/resources/application-local.example.yml +++ b/backend-java/src/main/resources/application-local.example.yml @@ -61,20 +61,17 @@ AIIMAGE_MODULE_CLEANUP_ENABLED=true AIIMAGE_MODULE_CLEANUP_CRON=0 0 0 * * * AIIMAGE_MODULE_CLEANUP_MODULE_TYPES=DEDUPE,SPLIT,CONVERT,DELETE_BRAND -AIIMAGE_APPEARANCE_PATENT_COZE_BASE_URL=https://api.coze.cn -AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_PATH=/v1/workflow/run -AIIMAGE_APPEARANCE_PATENT_COZE_WORKFLOW_ID=7632683471312355338 -AIIMAGE_APPEARANCE_PATENT_COZE_TOKEN= -AIIMAGE_APPEARANCE_PATENT_COZE_BATCH_SIZE=10 -AIIMAGE_APPEARANCE_PATENT_COZE_READ_TIMEOUT_MILLIS=60000 +AIIMAGE_APPEARANCE_PATENT_LLM_HOST=https://ai.t8star.org +AIIMAGE_APPEARANCE_PATENT_TITLE_MODEL=deepseek-v4-flash +AIIMAGE_APPEARANCE_PATENT_APPEARANCE_MODEL=gemini-3.7-flash +AIIMAGE_APPEARANCE_PATENT_LLM_MAX_TOKENS=64000 +AIIMAGE_APPEARANCE_PATENT_LLM_BATCH_SIZE=10 +AIIMAGE_APPEARANCE_PATENT_LLM_ROW_CONCURRENCY=10 +AIIMAGE_APPEARANCE_PATENT_LLM_RETRY_TIMES=3 AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES=30 -AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL=https://api.coze.cn -AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH=/v1/workflow/run -AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID=7635328462404583478 -AIIMAGE_SIMILAR_ASIN_COZE_TOKEN= -AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE=3 -AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS=60000 +AIIMAGE_SIMILAR_ASIN_LLM_BATCH_SIZE=3 +AIIMAGE_SIMILAR_ASIN_LLM_TEXT_ONLY_BATCH_SIZE=10 AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES=30 AIIMAGE_ROCKETMQ_NAME_SERVER=121.196.149.225:9876 diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 802ea09e..2667c03f 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -92,7 +92,7 @@ aiimage: server-121: ${AIIMAGE_INSTANCE_ROUTE_SERVER_121:http://192.168.0.170:18080} connect-timeout-millis: ${AIIMAGE_INSTANCE_ROUTE_CONNECT_TIMEOUT_MILLIS:3000} read-timeout-millis: ${AIIMAGE_INSTANCE_ROUTE_READ_TIMEOUT_MILLIS:300000} - request-body-cache-limit-bytes: ${AIIMAGE_INSTANCE_ROUTE_REQUEST_BODY_CACHE_LIMIT_BYTES:104857600} + request-body-cache-limit-bytes: ${AIIMAGE_INSTANCE_ROUTE_REQUEST_BODY_CACHE_LIMIT_BYTES:1048576} oss: region: ${AIIMAGE_OSS_REGION:us-east-1} endpoint: ${AIIMAGE_OSS_ENDPOINT:https://oss.aishufu.top} @@ -238,22 +238,11 @@ aiimage: stale-timeout-minutes: ${AIIMAGE_APPEARANCE_PATENT_STALE_TIMEOUT_MINUTES:30} stale-finalize-cron: ${AIIMAGE_APPEARANCE_PATENT_STALE_FINALIZE_CRON:0 */2 * * * *} similar-asin: - coze-base-url: ${AIIMAGE_SIMILAR_ASIN_COZE_BASE_URL:https://api.coze.cn} - coze-workflow-path: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_PATH:/v1/workflow/run} - coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7639708860686024756} - coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:} - coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:3} - coze-text-only-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_TEXT_ONLY_BATCH_SIZE:10} - coze-credential-stripe-size: ${AIIMAGE_SIMILAR_ASIN_COZE_CREDENTIAL_STRIPE_SIZE:0} - coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000} - coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000} - coze-poll-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_INTERVAL_MILLIS:30000} - coze-poll-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_POLL_TIMEOUT_MILLIS:1800000} + llm-batch-size: ${AIIMAGE_SIMILAR_ASIN_LLM_BATCH_SIZE:3} + llm-text-only-batch-size: ${AIIMAGE_SIMILAR_ASIN_LLM_TEXT_ONLY_BATCH_SIZE:10} db-task-touch-interval-millis: ${AIIMAGE_SIMILAR_ASIN_DB_TASK_TOUCH_INTERVAL_MILLIS:120000} db-job-touch-interval-millis: ${AIIMAGE_SIMILAR_ASIN_DB_JOB_TOUCH_INTERVAL_MILLIS:60000} - coze-submit-min-interval-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_SUBMIT_MIN_INTERVAL_MILLIS:5000} - coze-flush-pending-minutes: ${AIIMAGE_SIMILAR_ASIN_COZE_FLUSH_PENDING_MINUTES:1} - coze-submit-max-retry-count: ${AIIMAGE_SIMILAR_ASIN_COZE_SUBMIT_MAX_RETRY_COUNT:5} + llm-flush-pending-minutes: ${AIIMAGE_SIMILAR_ASIN_LLM_FLUSH_PENDING_MINUTES:1} image-download-pool-size: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_POOL_SIZE:2} image-download-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DOWNLOAD_TIMEOUT_SECONDS:5} image-prefetch-timeout-seconds: ${AIIMAGE_SIMILAR_ASIN_IMAGE_PREFETCH_TIMEOUT_SECONDS:1800} @@ -263,9 +252,7 @@ aiimage: image-db-cache-enabled: ${AIIMAGE_SIMILAR_ASIN_IMAGE_DB_CACHE_ENABLED:false} stale-timeout-minutes: ${AIIMAGE_SIMILAR_ASIN_STALE_TIMEOUT_MINUTES:30} stale-finalize-cron: ${AIIMAGE_SIMILAR_ASIN_STALE_FINALIZE_CRON:0 */2 * * * *} - 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} + llm-result-buffer-enabled: ${AIIMAGE_SIMILAR_ASIN_LLM_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:} diff --git a/backend-java/src/main/resources/db/V100__drop_coze_rename_llm_columns.sql b/backend-java/src/main/resources/db/V100__drop_coze_rename_llm_columns.sql new file mode 100644 index 00000000..d26834df --- /dev/null +++ b/backend-java/src/main/resources/db/V100__drop_coze_rename_llm_columns.sql @@ -0,0 +1,192 @@ +-- Task:移除 similar-asin / appearance-patent 模块的 Coze 命名残留。 +-- biz_task_scope_state 的 coze_* 列改名为 llm_*(Java 实体 TaskScopeStateEntity 已映射 llm_*), +-- idx_task_coze_status 索引同步改名;biz_coze_credential 表已无代码引用,直接删除。 + +SET @db_name = DATABASE(); + +-- 1. 列改名:coze_execute_id -> llm_execute_id +SET @col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'coze_execute_id' +); +SET @llm_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'llm_execute_id' +); +SET @sql := IF(@col_exists = 1 AND @llm_exists = 0, + 'ALTER TABLE biz_task_scope_state CHANGE COLUMN coze_execute_id llm_execute_id VARCHAR(128) NULL COMMENT ''LLM execute id''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2. coze_status -> llm_status +SET @col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'coze_status' +); +SET @llm_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'llm_status' +); +SET @sql := IF(@col_exists = 1 AND @llm_exists = 0, + 'ALTER TABLE biz_task_scope_state CHANGE COLUMN coze_status llm_status VARCHAR(32) NULL COMMENT ''LLM workflow status''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 3. coze_submitted_at -> llm_submitted_at +SET @col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'coze_submitted_at' +); +SET @llm_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'llm_submitted_at' +); +SET @sql := IF(@col_exists = 1 AND @llm_exists = 0, + 'ALTER TABLE biz_task_scope_state CHANGE COLUMN coze_submitted_at llm_submitted_at DATETIME NULL COMMENT ''LLM submitted time''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 4. coze_last_polled_at -> llm_last_polled_at +SET @col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'coze_last_polled_at' +); +SET @llm_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'llm_last_polled_at' +); +SET @sql := IF(@col_exists = 1 AND @llm_exists = 0, + 'ALTER TABLE biz_task_scope_state CHANGE COLUMN coze_last_polled_at llm_last_polled_at DATETIME NULL COMMENT ''LLM last polled time''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 5. coze_completed_at -> llm_completed_at +SET @col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'coze_completed_at' +); +SET @llm_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'llm_completed_at' +); +SET @sql := IF(@col_exists = 1 AND @llm_exists = 0, + 'ALTER TABLE biz_task_scope_state CHANGE COLUMN coze_completed_at llm_completed_at DATETIME NULL COMMENT ''LLM completed time''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 6. coze_attempt_count -> llm_attempt_count +SET @col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'coze_attempt_count' +); +SET @llm_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'llm_attempt_count' +); +SET @sql := IF(@col_exists = 1 AND @llm_exists = 0, + 'ALTER TABLE biz_task_scope_state CHANGE COLUMN coze_attempt_count llm_attempt_count INT NOT NULL DEFAULT 0 COMMENT ''LLM poll attempt count''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 7. coze_error -> llm_error +SET @col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'coze_error' +); +SET @llm_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND COLUMN_NAME = 'llm_error' +); +SET @sql := IF(@col_exists = 1 AND @llm_exists = 0, + 'ALTER TABLE biz_task_scope_state CHANGE COLUMN coze_error llm_error VARCHAR(1000) NULL COMMENT ''LLM error message''', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 8. 索引改名:idx_task_coze_status -> idx_task_llm_status +SET @idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND INDEX_NAME = 'idx_task_coze_status' +); +SET @llm_idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_scope_state' + AND INDEX_NAME = 'idx_task_llm_status' +); +SET @sql := IF(@idx_exists = 1 AND @llm_idx_exists = 0, + 'ALTER TABLE biz_task_scope_state DROP INDEX idx_task_coze_status, ADD INDEX idx_task_llm_status (module_type, llm_status, updated_at)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 9. 删除已无代码引用的 Coze 凭据表 +DROP TABLE IF EXISTS biz_coze_credential; diff --git a/backend-java/src/test/java/com/nanri/aiimage/common/service/TaskOwnerForwardServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/common/service/TaskOwnerForwardServiceTest.java new file mode 100644 index 00000000..1ce3d8b7 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/common/service/TaskOwnerForwardServiceTest.java @@ -0,0 +1,237 @@ +package com.nanri.aiimage.common.service; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.common.exception.TaskOwnerMismatchException; +import com.nanri.aiimage.config.InstanceRoutingProperties; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.DelegatingServletInputStream; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.web.util.ContentCachingRequestWrapper; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * TaskOwnerForwardService 转发安全测试(任务 54)。 + * 反射调用私有静态方法 requestBody / copyForwardHeaders / hasAlreadyForwarded, + * 验证 multipart 大 body 转发路径与 hop-by-hop 头剔除。 + */ +class TaskOwnerForwardServiceTest { + + private static final byte[] MULTIPART_BODY = ( + "-----b\r\nContent-Disposition: form-data; name=\"f\"; filename=\"a.bin\"\r\n\r\n" + + "x".repeat(2048) + "\r\n-----b--\r\n").getBytes(StandardCharsets.UTF_8); + + private static Object invoke(String name, Class[] paramTypes, Object... args) throws Exception { + Method method = TaskOwnerForwardService.class.getDeclaredMethod(name, paramTypes); + method.setAccessible(true); + try { + return method.invoke(null, args); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + private static byte[] requestBody(HttpServletRequest request) throws Exception { + return (byte[]) invoke("requestBody", new Class[]{HttpServletRequest.class}, request); + } + + private static HttpHeaders forwardHeaders(HttpServletRequest request, String currentInstanceId) throws Exception { + return (HttpHeaders) invoke("copyForwardHeaders", + new Class[]{HttpServletRequest.class, String.class}, request, currentInstanceId); + } + + private static boolean alreadyForwarded(HttpServletRequest request) throws Exception { + return (boolean) invoke("hasAlreadyForwarded", new Class[]{HttpServletRequest.class}, request); + } + + @Test + void nonWrapperReadsInputStream() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward"); + request.setContent(MULTIPART_BODY); + assertArrayEquals(MULTIPART_BODY, requestBody(request), "非 wrapper 走 inputStream 路径读完整 body"); + } + + @Test + void wrapperReadsCache() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward"); + request.setContent(MULTIPART_BODY); + ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request); + byte[] body = requestBody(wrapper); + assertArrayEquals(new byte[0], body, "wrapper 路径读缓存:body 未被消费时缓存为空数组"); + byte[] content = wrapper.getContentAsByteArray(); + assertTrue(content.length == 0, "未消费则缓存仍为空"); + wrapper.getInputStream().readAllBytes(); + assertArrayEquals(MULTIPART_BODY, requestBody(wrapper), "消费后缓存与原始 body 一致"); + } + + @Test + void multipartBodyForwardOk() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/upload"); + request.setContentType("multipart/form-data; boundary=----b"); + request.setContent(MULTIPART_BODY); + assertArrayEquals(MULTIPART_BODY, requestBody(request), "multipart 转发 body 与原请求字节一致"); + } + + @Test + void ioErrorHasMessage() { + // 伪造读流即抛 IOException 的 request:read() 抛 IOException,StreamUtils 原样上抛 + java.io.InputStream failing = new java.io.InputStream() { + @Override + public int read() throws java.io.IOException { + throw new java.io.IOException("stream closed"); + } + }; + MockHttpServletRequest broken = new MockHttpServletRequest("POST", "/api/x") { + @Override + public jakarta.servlet.ServletInputStream getInputStream() { + return new DelegatingServletInputStream(failing); + } + }; + broken.setContent("x".getBytes(StandardCharsets.UTF_8)); + BusinessException ex = assertThrows(BusinessException.class, + () -> invoke("requestBody", new Class[]{HttpServletRequest.class}, broken), + "IO 异常包装为 BusinessException"); + assertTrue(ex.getMessage().contains("读取转发请求体失败"), "异常消息携带上下文:" + ex.getMessage()); + } + + @Test + void hopByHopHeadersRemoved() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward"); + request.addHeader("Connection", "keep-alive"); + request.addHeader("Transfer-Encoding", "chunked"); + request.addHeader("Host", "example.com"); + request.addHeader("Content-Length", "123"); + request.addHeader("X-Custom", "keep-me"); + HttpHeaders headers = forwardHeaders(request, "server-110"); + assertFalse(headers.containsKey("Connection"), "connection 剔除"); + assertFalse(headers.containsKey("Transfer-Encoding"), "transfer-encoding 剔除"); + assertFalse(headers.containsKey("Host"), "host 剔除"); + assertFalse(headers.containsKey("Content-Length"), "content-length 剔除"); + assertEquals("keep-me", headers.getFirst("X-Custom"), "普通头保留"); + } + + @Test + void forwardedHeaderSet() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward"); + HttpHeaders headers = forwardHeaders(request, "server-121"); + assertEquals("server-121", headers.getFirst(TaskOwnerForwardService.FORWARDED_HEADER), + "转发头标记当前实例"); + } + + @Test + void contentTypeKept() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward"); + request.setContentType("multipart/form-data; boundary=----b"); + HttpHeaders headers = forwardHeaders(request, "server-110"); + assertEquals("multipart/form-data; boundary=----b", headers.getFirst(HttpHeaders.CONTENT_TYPE), + "content-type 保留"); + } + + @Test + void loopDetected() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward"); + request.addHeader(TaskOwnerForwardService.FORWARDED_HEADER, "server-110"); + assertTrue(alreadyForwarded(request), "已带转发头判定为循环"); + MockHttpServletRequest fresh = new MockHttpServletRequest("POST", "/api/forward"); + assertFalse(alreadyForwarded(fresh), "无转发头不判定循环"); + } + + @Test + void forwardExceptionCarriesLoopMessage() { + TaskOwnerMismatchException ex = new TaskOwnerMismatchException( + 1L, "op", "server-110", "server-121"); + InstanceRoutingProperties properties = new InstanceRoutingProperties(); + properties.setRoutes(Map.of("server-110", "http://10.0.0.1:18080")); + TaskOwnerForwardService service = new TaskOwnerForwardService(properties); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward"); + request.addHeader(TaskOwnerForwardService.FORWARDED_HEADER, "server-110"); + BusinessException loopEx = assertThrows(BusinessException.class, + () -> service.forwardCurrentRequest(ex, request), + "循环转发拒绝并抛 BusinessException"); + assertTrue(loopEx.getMessage().contains("循环"), "循环消息:" + loopEx.getMessage()); + } + + private static byte[] forwardBodyThroughWrapper(byte[] content, String contentType) throws Exception { + return forwardBodyThroughWrapper(content, contentType, 1024 * 1024); + } + + private static byte[] forwardBodyThroughWrapper(byte[] content, String contentType, int cacheLimitBytes) throws Exception { + // 模拟真实链路:过滤器包装 → 业务读取(填充缓存)→ 转发读缓存 + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/upload"); + request.setContentType(contentType); + request.setContent(content); + ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request, cacheLimitBytes); + wrapper.getInputStream().readAllBytes(); + return requestBody(wrapper); + } + + @Test + void forwardJsonBytesEqual() throws Exception { + byte[] body = "{\"taskId\":100,\"status\":\"RUNNING\"}".getBytes(StandardCharsets.UTF_8); + assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json"), "json 字节级一致"); + } + + @Test + void forwardMultipartBytesEqual() throws Exception { + assertArrayEquals(MULTIPART_BODY, forwardBodyThroughWrapper(MULTIPART_BODY, "multipart/form-data; boundary=----b"), + "multipart 字节级一致"); + } + + @Test + void forwardEmptyBodySafe() throws Exception { + byte[] empty = new byte[0]; + assertArrayEquals(empty, forwardBodyThroughWrapper(empty, "application/json"), "空 body 安全"); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/empty"); + assertArrayEquals(empty, requestBody(request), "非包装空 body 返回空数组"); + } + + @Test + void forwardLargeBodyEqual() throws Exception { + byte[] large = ("{\"payload\":\"" + "x".repeat(1024 * 1024 + 100) + "\"}").getBytes(StandardCharsets.UTF_8); + assertArrayEquals(large, forwardBodyThroughWrapper(large, "application/json", 2 * 1024 * 1024), ">1MB 大 body 一致"); + } + + @Test + void forwardUtf8ChineseEqual() throws Exception { + byte[] body = "{\"name\":\"任务进度查询\",\"备注\":\"成功\"}".getBytes(StandardCharsets.UTF_8); + assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json; charset=utf-8"), "中文 UTF-8 一致"); + } + + @Test + void forwardSpecialCharsEqual() throws Exception { + byte[] body = "{\"s\":\"a\\n\\t\\\"b\\\\céü中\"}".getBytes(StandardCharsets.UTF_8); + assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json"), "转义与特殊字符一致"); + } + + @Test + void forwardMultipleChunksEqual() throws Exception { + StringBuilder sb = new StringBuilder("{\"chunks\":["); + for (int i = 0; i < 50; i++) { + if (i > 0) sb.append(','); + sb.append('"').append("chunk-").append(i).append('"'); + } + sb.append("]}"); + byte[] body = sb.toString().getBytes(StandardCharsets.UTF_8); + assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json"), "多段内容一致"); + } + + @Test + void forwardIdentityRepeated() throws Exception { + byte[] body = "{\"repeat\":\"yes\"}".getBytes(StandardCharsets.UTF_8); + byte[] first = forwardBodyThroughWrapper(body, "application/json"); + byte[] second = forwardBodyThroughWrapper(body, "application/json"); + assertArrayEquals(first, second, "两次转发结果一致"); + assertArrayEquals(body, second, "与原始一致"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/common/util/CozeGroupResultPropagatorTest.java b/backend-java/src/test/java/com/nanri/aiimage/common/util/GroupResultPropagatorTest.java similarity index 92% rename from backend-java/src/test/java/com/nanri/aiimage/common/util/CozeGroupResultPropagatorTest.java rename to backend-java/src/test/java/com/nanri/aiimage/common/util/GroupResultPropagatorTest.java index 299577f2..54a6effe 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/common/util/CozeGroupResultPropagatorTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/common/util/GroupResultPropagatorTest.java @@ -6,7 +6,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; -class CozeGroupResultPropagatorTest { +class GroupResultPropagatorTest { @Test void doesNotTreatNoInfringementAsInfringementHit() { @@ -19,7 +19,7 @@ class CozeGroupResultPropagatorTest { new ResultRow("无侵权") ); - int updatedRows = CozeGroupResultPropagator.propagateByGroup( + int updatedRows = GroupResultPropagator.propagateByGroup( parsedRows, ParsedRow::displayId, row -> resultRows.get(parsedRows.indexOf(row)), @@ -44,7 +44,7 @@ class CozeGroupResultPropagatorTest { new ResultRow("侵权") ); - int updatedRows = CozeGroupResultPropagator.propagateByGroup( + int updatedRows = GroupResultPropagator.propagateByGroup( parsedRows, ParsedRow::displayId, row -> resultRows.get(parsedRows.indexOf(row)), diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/ArchitecturePlanSection6DocTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/ArchitecturePlanSection6DocTest.java new file mode 100644 index 00000000..eb0c0368 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/ArchitecturePlanSection6DocTest.java @@ -0,0 +1,121 @@ +package com.nanri.aiimage.config; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 架构文档 §6 现状描述核对(任务 56,验证型)。 + * 断言 java-architecture-optimization-plan.md §6 已反映本轮实现现状: + * multipart 直通、1MB 阈值、过滤器日志不读 body、未做项标注、spec 引用、 + * 与 00-overview 实施记录一致、无过时默认值/占位符残留。 + */ +class ArchitecturePlanSection6DocTest { + + private static final Path DOC = Paths.get("src", "main", "resources", "..", "..", "..", + "docs", "java-architecture-optimization-plan.md").normalize(); + private static final Path OVERVIEW = Paths.get("src", "main", "resources", "..", "..", "..", + "docs", "plans", "00-plan-overview.md").normalize(); + + private static String read(Path path) throws IOException { + return new String(Files.readAllBytes(path), StandardCharsets.UTF_8); + } + + private static String section6() throws IOException { + String doc = read(DOC); + int start = doc.indexOf("## 6. "); + int end = doc.indexOf("## 7. ", start); + assertTrue(start >= 0, "文档存在 §6 章节"); + return end > start ? doc.substring(start, end) : doc.substring(start); + } + + private static List section6Lines() throws IOException { + return List.of(section6().split("\r?\n")); + } + + private static int docLineNumberOf(String section, String needle) { + List lines = List.of(section.split("\r?\n")); + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).contains(needle)) { + return i + 1; + } + } + return -1; + } + + @Test + void noObsoleteDefaultRemains() throws IOException { + for (String line : section6Lines()) { + assertFalse(line.contains("104857600"), "§6 无过时默认值 104857600 残留:" + line); + } + } + + @Test + void multipartPassthroughStated() throws IOException { + String section = section6(); + assertTrue(section.contains("multipart"), "§6 记录 multipart 处理"); + assertTrue(section.contains("直通") || section.contains("跳过缓存"), + "§6 记录 multipart 直通现状"); + } + + @Test + void threshold1MbStated() throws IOException { + String section = section6(); + assertTrue(section.contains("1MB") || section.contains("1 MiB") || section.contains("1048576"), + "§6 记录 1MB 阈值现状"); + assertTrue(section.contains("1048576"), "§6 记录阈值具体值 1048576"); + } + + @Test + void notReadingBodyStated() throws IOException { + String section = section6(); + assertTrue(section.contains("不读 body") || (section.contains("日志") && section.contains("body")), + "§6 记录过滤器日志不读 body"); + } + + @Test + void deferredItemsListed() throws IOException { + String section = section6(); + assertTrue(section.contains("未做") || section.contains("不做") || section.contains("后续批次"), + "§6 标注本轮未做项"); + } + + @Test + void spec03Referenced() throws IOException { + String section = section6(); + assertTrue(section.contains("specs/03-request-trace-filter.md") + || section.contains("03-request-trace-filter.md"), + "§6 引用 03 spec"); + } + + @Test + void consistentWithOverview() throws IOException { + String section = section6(); + String overview = read(OVERVIEW); + for (String key : List.of("49", "50", "51", "52", "53", "54", "55", "56")) { + assertTrue(overview.contains("| " + key + " |"), "00-overview 实施记录含任务 " + key); + } + assertTrue(overview.contains("multipart 直通"), "00-overview 记录 multipart 直通"); + assertEquals(1, overview.split("multipart 直通", -1).length - 1, + "00-overview 实施记录恰好一条 multipart 直通条目(与 §6 现状一致)"); + } + + @Test + void noTodoLeft() throws IOException { + String section = section6(); + assertTrue(docLineNumberOf(section, "### 现状") > 0 || section.contains("已实现"), + "§6 有现状或已实现描述"); + assertFalse(section.contains("TBD"), "§6 无 TBD 残留"); + assertFalse(section.contains("TODO"), "§6 无 TODO 残留"); + assertFalse(section.contains("待确认"), "§6 无待确认残留"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java index 5bf4a3fd..3aa0c922 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/config/HttpClientConnectionReuseTest.java @@ -3,7 +3,7 @@ package com.nanri.aiimage.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.modules.brand.client.BrandCheckClient; import com.nanri.aiimage.modules.brand.client.BrandCheckClient.BrandCheckBatchResult; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; +import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient; import com.nanri.aiimage.modules.ziniao.client.ZiniaoClientImpl; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -25,9 +25,9 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Task 77:统一 Coze、品牌检查和紫鸟 HTTP 客户端的连接复用策略。 + * Task 77:统一 LLM、品牌检查和紫鸟 HTTP 客户端的连接复用策略。 * 三个外部客户端统一使用 HttpClientPool 共享的 java.net.http.HttpClient - * (内置 keep-alive 连接复用):Coze 与品牌检查从无连接池的 + * (内置 keep-alive 连接复用):LLM 与品牌检查从无连接池的 * HttpURLConnection 迁移到共享池;紫鸟从每次调用新建 RestClient 改为 * 复用共享单例。同一 HttpClient 实例即表明连接复用同一连接池。 */ @@ -98,17 +98,17 @@ class HttpClientConnectionReuseTest { void test_task_077_brand_normal_multiple_items() throws Exception { // 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient, // 但底层连接池共用同一 HttpClient 实例,不重复创建。 - SimilarAsinCozeClient coze = new SimilarAsinCozeClient(new SimilarAsinProperties(), new ObjectMapper(), null, null); + SimilarAsinLlmClient llm = new SimilarAsinLlmClient(new SimilarAsinProperties(), new ObjectMapper(), null); BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null); ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper()); - HttpClient cozeClient = clientOf(factoryOf(restClientOf(coze))); + HttpClient llmClient = clientOf(factoryOf(restClientOf(llm))); HttpClient brandClient = clientOf(factoryOf(restClientOf(brand))); HttpClient ziniaoClient = clientOf(factoryOf(restClientOf(ziniao))); - assertSame(cozeClient, brandClient, "Coze 与品牌检查共享连接池"); + assertSame(llmClient, brandClient, "LLM 与品牌检查共享连接池"); assertSame(brandClient, ziniaoClient, "品牌检查与紫鸟共享连接池"); - assertSame(HttpClientPool.sharedHttpClient(), cozeClient, "与共享单例一致"); + assertSame(HttpClientPool.sharedHttpClient(), llmClient, "与共享单例一致"); } @Test diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/RequestTraceConfigTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/RequestTraceConfigTest.java new file mode 100644 index 00000000..edbc8a7f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/RequestTraceConfigTest.java @@ -0,0 +1,101 @@ +package com.nanri.aiimage.config; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.PropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; + +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * application.yml 默认值与 env 覆盖测试(任务 53)。 + * 用 Spring 的 YamlPropertySourceLoader 真实解析 application.yml, + * 再经 StandardEnvironment 验证 ${VAR:default} 占位符的默认值与 env 覆盖语义。 + */ +class RequestTraceConfigTest { + + private static final String KEY = "aiimage.instance-routing.request-body-cache-limit-bytes"; + + private static String rawYmlValue() throws IOException { + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + List> sources = + loader.load("app.yml", new ClassPathResource("application.yml")); + for (PropertySource source : sources) { + Object value = source.getProperty(KEY); + if (value != null) { + return String.valueOf(value); + } + } + throw new AssertionError("application.yml 未包含 " + KEY); + } + + private static String resolve(String raw, String envValue) { + StandardEnvironment environment = new StandardEnvironment(); + environment.getPropertySources().addFirst(new MapPropertySource( + "test-env", envValue == null + ? Collections.emptyMap() + : Collections.singletonMap("AIIMAGE_INSTANCE_ROUTE_REQUEST_BODY_CACHE_LIMIT_BYTES", envValue))); + return environment.resolvePlaceholders(raw); + } + + @Test + void ymlDefaultIs1MiB() throws IOException { + assertTrue(rawYmlValue().contains("1048576"), "yml 默认值 1048576(1MB)"); + } + + @Test + void envOverrideTakesEffect() throws IOException { + assertEquals("2097152", resolve(rawYmlValue(), "2097152"), "env 覆盖生效"); + } + + @Test + void envOverrideLarge() throws IOException { + assertEquals("104857600", resolve(rawYmlValue(), "104857600"), "大值覆盖生效"); + } + + @Test + void envOverrideSmallIsKeptThenClampedByCode() throws IOException { + // env 注入 1024(<1MB):yml 层原样解析,钳制在代码层 Math.max(1024*1024, …) + assertEquals("1024", resolve(rawYmlValue(), "1024")); + } + + @Test + void placeholderFormat() throws IOException { + assertTrue(rawYmlValue().matches("\\$\\{AIIMAGE_INSTANCE_ROUTE_REQUEST_BODY_CACHE_LIMIT_BYTES:1048576}"), + "占位符格式 ${VAR:default}"); + } + + @Test + void defaultMatchesCodeConstant() throws IOException { + assertEquals("1048576", resolve(rawYmlValue(), null), "未设 env 时解析为 1048576,与代码 @Value 默认一致"); + } + + @Test + void envUnsetUsesDefault() throws IOException { + assertFalse(resolve(rawYmlValue(), null).startsWith("${"), "未设 env 时占位符被解析为默认值而非保留原文"); + assertEquals("1048576", resolve(rawYmlValue(), null)); + } + + @Test + void ymlLoadsUnderSpringEnv() throws IOException { + YamlPropertySourceLoader loader = new YamlPropertySourceLoader(); + List> sources = + loader.load("app.yml", new ClassPathResource("application.yml")); + assertFalse(sources.isEmpty(), "application.yml 能被 YamlPropertySourceLoader 加载"); + boolean found = false; + for (PropertySource source : sources) { + if (source.containsProperty(KEY)) { + found = true; + } + } + assertTrue(found, "instance-routing.request-body-cache-limit-bytes 存在于 yml 配置源"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/RequestTraceFilterTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/RequestTraceFilterTest.java new file mode 100644 index 00000000..c5a6fa40 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/RequestTraceFilterTest.java @@ -0,0 +1,458 @@ +package com.nanri.aiimage.config; + +import jakarta.servlet.ServletInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.util.ContentCachingRequestWrapper; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class RequestTraceFilterTest { + + private static final int DEFAULT_LIMIT = 1024 * 1024; + + private MockHttpServletRequest request(String method, String contentType) { + MockHttpServletRequest request = new MockHttpServletRequest(method, "/api/example"); + if (contentType != null) { + request.setContentType(contentType); + } + return request; + } + + /** 消费请求体触发缓存:ContentCachingRequestWrapper 仅在 getInputStream() 被读时填充缓存 */ + private static ContentCachingRequestWrapper drain(ContentCachingRequestWrapper wrapper) { + try (ServletInputStream stream = wrapper.getInputStream()) { + stream.readAllBytes(); + } catch (IOException e) { + throw new IllegalStateException("读取请求体失败", e); + } + return wrapper; + } + + private static String cachedBody(ContentCachingRequestWrapper wrapper) { + return new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8); + } + + private static String overflowAttr() { + return ContentCachingRequestWrapper.class.getName() + ".CONTENT_OVERFLOW"; + } + + @Test + void multipartPostIsNotWrapped() { + MockHttpServletRequest request = request("POST", "multipart/form-data; boundary=----x"); + assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper); + } + + @Test + void jsonPostIsWrapped() { + MockHttpServletRequest request = request("POST", "application/json"); + assertInstanceOf(ContentCachingRequestWrapper.class, RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT)); + } + + @Test + void putAndDeleteAreWrapped() { + assertInstanceOf(ContentCachingRequestWrapper.class, + RequestTraceFilter.wrapRequestIfNeeded(request("PUT", "application/json"), DEFAULT_LIMIT)); + assertInstanceOf(ContentCachingRequestWrapper.class, + RequestTraceFilter.wrapRequestIfNeeded(request("DELETE", "application/json"), DEFAULT_LIMIT)); + } + + @Test + void getIsNotWrapped() { + MockHttpServletRequest request = request("GET", null); + assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper); + } + + @Test + void alreadyWrappedRequestIsReturnedAsIs() { + ContentCachingRequestWrapper wrapped = new ContentCachingRequestWrapper(request("POST", "application/json")); + assertTrue(RequestTraceFilter.wrapRequestIfNeeded(wrapped, DEFAULT_LIMIT) == wrapped); + } + + @Test + void multipartUpperCaseBoundaryIsNotWrapped() { + MockHttpServletRequest request = request("POST", "Multipart/Form-Data; boundary=----x"); + assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper); + } + + @Test + void multipartMixedCaseIsNotWrapped() { + MockHttpServletRequest request = request("POST", "mUlTiPaRt/foRm-DaTa; boundary=----y"); + assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper); + } + + @Test + void multipartRelatedIsNotWrapped() { + MockHttpServletRequest request = request("POST", "multipart/related; boundary=----z"); + assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper); + } + + @Test + void multipartWithoutBoundaryIsNotWrapped() { + MockHttpServletRequest request = request("POST", "multipart/form-data"); + assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper); + } + + @Test + void multipartWithExtraParamsIsNotWrapped() { + MockHttpServletRequest request = request("POST", "multipart/form-data; boundary=----b; charset=utf-8"); + assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper); + } + + @Test + void multipartWithWhitespaceIsNotWrapped() { + MockHttpServletRequest request = request("POST", " multipart/form-data; boundary=----w "); + assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper); + } + + @Test + void multipartPutIsNotWrapped() { + MockHttpServletRequest request = request("PUT", "multipart/form-data; boundary=----p"); + assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper); + } + + @Test + void applicationJsonIsStillWrapped() { + MockHttpServletRequest request = request("POST", "application/json"); + assertInstanceOf(ContentCachingRequestWrapper.class, + RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT)); + } + + @Test + void jsonPostUsesProvidedLimit() { + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"payload\":\"x\"}".getBytes()); + ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper) + RequestTraceFilter.wrapRequestIfNeeded(request, 4096)); + assertEquals("{\"payload\":\"x\"}", cachedBody(wrapper), "缓存按传入阈值(4096 > body)完整读入"); + assertEquals(null, wrapper.getAttribute(overflowAttr())); + } + + @Test + void limitBelowMinimumIsClampedTo1MiB() { + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"k\":\"v\"}".getBytes()); + ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper) + RequestTraceFilter.wrapRequestIfNeeded(request, 1024 * 1024 - 1)); + // <1MB 被钳到 1MB 下限:远超 body,可完整读出(若未钳制,body 截断/溢出标记) + assertEquals("{\"k\":\"v\"}", cachedBody(wrapper)); + assertEquals(null, wrapper.getAttribute(overflowAttr())); + } + + @Test + void limitZeroIsClampedTo1MiB() { + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"k\":\"v\"}".getBytes()); + ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper) + RequestTraceFilter.wrapRequestIfNeeded(request, 0)); + assertEquals("{\"k\":\"v\"}", cachedBody(wrapper), "0 钳制到 1MB 后仍可完整缓存"); + assertEquals(null, wrapper.getAttribute(overflowAttr())); + } + + @Test + void limitNegativeIsClampedTo1MiB() { + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"k\":\"v\"}".getBytes()); + ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper) + RequestTraceFilter.wrapRequestIfNeeded(request, -5)); + assertEquals("{\"k\":\"v\"}", cachedBody(wrapper), "负数钳制到 1MB 后仍可完整缓存"); + assertEquals(null, wrapper.getAttribute(overflowAttr())); + } + + @Test + void limitExactlyMinimumIsKept() { + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"k\":\"v\"}".getBytes()); + ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper) + RequestTraceFilter.wrapRequestIfNeeded(request, 1024 * 1024)); + assertEquals("{\"k\":\"v\"}", cachedBody(wrapper), "恰好 1MB 不钳制,body 完整缓存"); + assertEquals(null, wrapper.getAttribute(overflowAttr())); + } + + @Test + void limitLargeIsKept() { + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"k\":\"v\"}".getBytes()); + ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper) + RequestTraceFilter.wrapRequestIfNeeded(request, 8 * 1024 * 1024)); + assertEquals("{\"k\":\"v\"}", cachedBody(wrapper), "大阈值保留,body 完整缓存"); + assertEquals(null, wrapper.getAttribute(overflowAttr())); + } + + @Test + void deleteRequestIsWrapped() { + MockHttpServletRequest request = request("DELETE", "application/json"); + assertInstanceOf(ContentCachingRequestWrapper.class, + RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT)); + } + + @Test + void putRequestIsWrapped() { + MockHttpServletRequest request = request("PUT", "application/json"); + assertInstanceOf(ContentCachingRequestWrapper.class, + RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT)); + } + + private static final byte[] CAPTURE_MAGIC = "SECRET-BODY-PAYLOAD-42".getBytes(StandardCharsets.UTF_8); + + /** 拦截日志输出,验证过滤器日志不包含 body 内容(AppenderBase 必须 start 后才会接收事件) */ + private static String captureLogsFor(byte[] body, String contentType) throws Exception { + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + AppenderBase appender = new AppenderBase() { + @Override + protected void append(ILoggingEvent event) { + captured.writeBytes((event.getFormattedMessage() + "\n").getBytes(StandardCharsets.UTF_8)); + } + }; + appender.start(); + ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(RequestTraceFilter.class)).addAppender(appender); + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/example"); + request.setContentType(contentType); + request.setContent(body); + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + ServletInputStream stream = req.getInputStream(); + stream.readAllBytes(); + }); + return captured.toString(StandardCharsets.UTF_8); + } + + @Test + void filterLogDoesNotReadBody() throws Exception { + String logs = captureLogsFor(CAPTURE_MAGIC, "application/json"); + assertFalse(logs.contains("SECRET-BODY-PAYLOAD-42"), "日志只消费 method/uri/status/headers,不含 body 内容"); + assertTrue(logs.contains("POST"), "日志含 method"); + assertTrue(logs.contains("/api/example"), "日志含 uri"); + } + + @Test + void filterPassesChain() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"a\":1}".getBytes()); + boolean[] invoked = {false}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> invoked[0] = true); + assertTrue(invoked[0], "过滤链正常执行"); + } + + @Test + void businessReadsJsonBodyAfterFilter() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"hello\":\"世界\"}".getBytes()); + byte[][] read = {new byte[0]}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + byte[] bytes = req.getInputStream().readAllBytes(); + read[0] = bytes; + }); + assertEquals("{\"hello\":\"世界\"}", new String(read[0], StandardCharsets.UTF_8), "业务侧仍可读完整 json body"); + } + + @Test + void businessReadsMultipartBody() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + MockHttpServletRequest request = request("POST", "multipart/form-data; boundary=----b"); + byte[] body = "-----b\r\nContent-Disposition: form-data; name=\"f\"; filename=\"a.txt\"\r\n\r\nhello\r\n-----b--\r\n".getBytes(StandardCharsets.UTF_8); + request.setContent(body); + byte[][] read = {new byte[0]}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + byte[] bytes = req.getInputStream().readAllBytes(); + read[0] = bytes; + }); + assertEquals(new String(body, StandardCharsets.UTF_8), new String(read[0], StandardCharsets.UTF_8), + "multipart 不包装,业务侧读到的就是原始流"); + } + + @Test + void inputStreamReusableAfterFilter() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"k\":1}".getBytes()); + byte[][] reads = {new byte[0], new byte[0]}; + ContentCachingRequestWrapper[] chainWrapper = {null}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + chainWrapper[0] = (ContentCachingRequestWrapper) req; + reads[0] = req.getInputStream().readAllBytes(); + }); + reads[1] = chainWrapper[0].getContentAsByteArray(); + assertEquals("{\"k\":1}", new String(reads[0], StandardCharsets.UTF_8), "业务侧读过一次"); + assertEquals("{\"k\":1}", new String(reads[1], StandardCharsets.UTF_8), "过滤后缓存仍可取(无第二次消费)"); + } + + @Test + void instanceResponseHeadersAdded() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + MockHttpServletRequest request = request("GET", null); + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request, response, (req, res) -> { + }); + assertEquals("test-instance", response.getHeader("X-AIIMAGE-Instance")); + assertNotNull(response.getHeader("X-AIIMAGE-Host"), "host 头存在"); + assertEquals("configured", response.getHeader("X-AIIMAGE-Instance-Source")); + assertEquals("true", response.getHeader("X-AIIMAGE-Instance-Stable")); + } + + @Test + void costLogged() throws Exception { + String logs = captureLogsFor(CAPTURE_MAGIC, "application/json"); + assertTrue(logs.matches("(?s).*costMs=\\d+.*"), "costMs 数字被记录"); + } + + @Test + void requestIdLogged() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + ByteArrayOutputStream captured = new ByteArrayOutputStream(); + AppenderBase appender = new AppenderBase() { + @Override + protected void append(ILoggingEvent event) { + captured.writeBytes((event.getFormattedMessage() + "\n").getBytes(StandardCharsets.UTF_8)); + } + }; + appender.start(); + ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(RequestTraceFilter.class)).addAppender(appender); + MockHttpServletRequest request = request("POST", "application/json"); + request.addHeader("X-Request-Id", "req-abc-123"); + request.setContent("{}".getBytes()); + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + }); + String logs = captured.toString(StandardCharsets.UTF_8); + assertTrue(logs.contains("requestId=req-abc-123"), "requestId 从 X-Request-Id 透传进日志"); + assertFalse(logs.contains("requestId=-"), "requestId 未缺失"); + } + + @Test + void largeJsonOver1MbSucceeds() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + byte[] large = ("{\"payload\":\"" + "x".repeat(2 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8); + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent(large); + boolean[] invoked = {false}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> invoked[0] = true); + assertTrue(invoked[0], ">1MB json 请求成功穿过过滤器"); + } + + @Test + void largeJsonCacheTruncated() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + byte[] large = ("{\"payload\":\"" + "x".repeat(2 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8); + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent(large); + ContentCachingRequestWrapper[] chainWrapper = {null}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + chainWrapper[0] = (ContentCachingRequestWrapper) req; + req.getInputStream().readAllBytes(); + }); + byte[] cached = chainWrapper[0].getContentAsByteArray(); + assertTrue(cached.length <= 1024 * 1024, ">1MB body 缓存被截断到阈值内(实际 " + cached.length + "B)"); + assertEquals(new String(large, StandardCharsets.UTF_8), + new String(large, StandardCharsets.UTF_8), "body 原值保留"); + } + + @Test + void largeMultipartUploadSucceeds() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + byte[] large = ("-----b\r\nContent-Disposition: form-data; name=\"f\"; filename=\"big.bin\"\r\n\r\n" + + "x".repeat(2 * 1024 * 1024) + "\r\n-----b--\r\n").getBytes(StandardCharsets.UTF_8); + MockHttpServletRequest request = request("POST", "multipart/form-data; boundary=----b"); + request.setContent(large); + byte[][] read = {new byte[0]}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + read[0] = req.getInputStream().readAllBytes(); + }); + assertArrayEquals(large, read[0], "multipart 不包装:>1MB 上传原样通过,业务读完整 body"); + } + + @Test + void largeBodyBusinessReadsFully() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + byte[] large = ("{\"payload\":\"" + "y".repeat(2 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8); + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent(large); + byte[][] read = {new byte[0]}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + read[0] = req.getInputStream().readAllBytes(); + }); + assertArrayEquals(large, read[0], "业务侧读到完整大 body(缓存截断不影响业务读取)"); + } + + @Test + void smallJsonCached() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent("{\"small\":1}".getBytes()); + ContentCachingRequestWrapper[] chainWrapper = {null}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + chainWrapper[0] = (ContentCachingRequestWrapper) req; + req.getInputStream().readAllBytes(); + }); + assertEquals("{\"small\":1}", new String(chainWrapper[0].getContentAsByteArray(), StandardCharsets.UTF_8), + "小 json 正常整包缓存"); + } + + @Test + void contentLengthHeaderPreserved() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + byte[] body = "{\"len\":\"ok\"}".getBytes(StandardCharsets.UTF_8); + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent(body); + request.addHeader("Content-Length", String.valueOf(body.length)); + ContentCachingRequestWrapper[] chainWrapper = {null}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + chainWrapper[0] = (ContentCachingRequestWrapper) req; + }); + assertEquals(body.length, chainWrapper[0].getContentLength(), "content-length 经 wrapper 保留"); + } + + @Test + void largeJsonForwardPathReads() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + byte[] large = ("{\"data\":\"" + "z".repeat(3 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8); + MockHttpServletRequest request = request("POST", "/api/forward"); + request.setContent(large); + byte[][] read = {new byte[0]}; + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> { + read[0] = req.getInputStream().readAllBytes(); + }); + assertArrayEquals(large, read[0], "转发路径(非 API 前缀)大 body 读取正常"); + } + + @Test + void largeJsonHandlingTimeBounded() throws Exception { + InstanceMetadata metadata = new InstanceMetadata("test-instance"); + RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024); + byte[] large = ("{\"t\":\"" + "w".repeat(2 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8); + MockHttpServletRequest request = request("POST", "application/json"); + request.setContent(large); + long start = System.nanoTime(); + filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> req.getInputStream().readAllBytes()); + long costMs = (System.nanoTime() - start) / 1_000_000; + assertTrue(costMs < 5000, "大 body 处理耗时合理(实际 " + costMs + "ms)"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/CozeTaskQueueGateTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/TaskQueueGateTest.java similarity index 91% rename from backend-java/src/test/java/com/nanri/aiimage/config/CozeTaskQueueGateTest.java rename to backend-java/src/test/java/com/nanri/aiimage/config/TaskQueueGateTest.java index 4995a55d..0247fc66 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/config/CozeTaskQueueGateTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/config/TaskQueueGateTest.java @@ -22,12 +22,12 @@ import static org.mockito.Mockito.when; /** * Task 75:为虚拟线程任务增加等待队列上限与拒绝/延迟指标。 - * CozeTaskQueueGate 在信号量限流(并发上限)之外增加排队闸门: + * TaskQueueGate 在信号量限流(并发上限)之外增加排队闸门: * 已提交未启动(含等待信号量)的任务数量达到 max-waiting 上限时 * 立即拒绝新提交并记录拒绝指标;每次提交/执行记录等待耗时与执行耗时 * 指标;任务执行完成、失败或执行器拒绝后排队名额必须释放。 */ -class CozeTaskQueueGateTest { +class TaskQueueGateTest { private SimpleMeterRegistry registry; private ObjectProvider meterRegistryProvider; @@ -44,8 +44,8 @@ class CozeTaskQueueGateTest { capturingDelegate = captured::add; } - private CozeTaskQueueGate gate(int maxWaiting) { - return new CozeTaskQueueGate(capturingDelegate, maxWaiting, meterRegistryProvider); + private TaskQueueGate gate(int maxWaiting) { + return new TaskQueueGate(capturingDelegate, maxWaiting, meterRegistryProvider); } private long rejectedCount(String reason) { @@ -67,7 +67,7 @@ class CozeTaskQueueGateTest { @Test void test_task_075_metrics_normal_default_path() { // 默认路径:任务正常受理并执行,等待/执行指标各记一次,排队名额释放。 - CozeTaskQueueGate gate = gate(4); + TaskQueueGate gate = gate(4); AtomicInteger ran = new AtomicInteger(); gate.execute(ran::incrementAndGet); @@ -84,7 +84,7 @@ class CozeTaskQueueGateTest { @Test void test_task_075_metrics_normal_multiple_items() { // 批量场景:多个任务依次受理,执行顺序稳定不丢失,指标逐条记录。 - CozeTaskQueueGate gate = gate(8); + TaskQueueGate gate = gate(8); List order = new ArrayList<>(); for (int i = 1; i <= 3; i++) { int id = i; @@ -103,7 +103,7 @@ class CozeTaskQueueGateTest { @Test void test_task_075_metrics_normal_repeated_operation_is_idempotent() { // 幂等:同一任务对象重复提交各自独立执行,不合并、不丢失。 - CozeTaskQueueGate gate = gate(4); + TaskQueueGate gate = gate(4); AtomicInteger ran = new AtomicInteger(); Runnable task = ran::incrementAndGet; gate.execute(task); @@ -117,7 +117,7 @@ class CozeTaskQueueGateTest { @Test void test_task_075_metrics_boundary_empty_input() { // 空输入:null 任务直接拒绝,不计数、不产生指标。 - CozeTaskQueueGate gate = gate(4); + TaskQueueGate gate = gate(4); IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> gate.execute(null)); assertTrue(ex.getMessage().contains("不能为 null"), "可识别错误消息"); @@ -130,7 +130,7 @@ class CozeTaskQueueGateTest { @Test void test_task_075_metrics_boundary_single_item() { // 单元素:单任务直接受理执行,不依赖批量路径。 - CozeTaskQueueGate gate = gate(1); + TaskQueueGate gate = gate(1); AtomicInteger ran = new AtomicInteger(); gate.execute(ran::incrementAndGet); captured.get(0).run(); @@ -143,7 +143,7 @@ class CozeTaskQueueGateTest { void test_task_075_metrics_boundary_limit_and_overflow() { // 上限/超限:等待队列达到上限后新提交被拒绝(记录拒绝指标), // 排队名额释放后可再次受理,等待数不发生无界增长。 - CozeTaskQueueGate gate = gate(1); + TaskQueueGate gate = gate(1); AtomicInteger ran = new AtomicInteger(); gate.execute(ran::incrementAndGet); @@ -171,13 +171,13 @@ class CozeTaskQueueGateTest { void test_task_075_metrics_invalid_input_rejected() { // 非法参数:等待上限 0/负值统一钳制到 1(首个受理、第二个拒绝), // null 任务拒绝且不产生指标。 - CozeTaskQueueGate zeroLimit = gate(0); + TaskQueueGate zeroLimit = gate(0); zeroLimit.execute(() -> { }); assertThrows(TaskRejectedException.class, () -> zeroLimit.execute(() -> { })); assertEquals(1, captured.size(), "上限 0 回退到 1"); captured.clear(); - CozeTaskQueueGate negativeLimit = gate(-5); + TaskQueueGate negativeLimit = gate(-5); negativeLimit.execute(() -> { }); assertThrows(TaskRejectedException.class, () -> negativeLimit.execute(() -> { })); assertEquals(1, captured.size(), "负值回退到 1"); @@ -190,21 +190,21 @@ class CozeTaskQueueGateTest { void test_task_075_metrics_dependency_failure_releases_resources() { // 依赖失败:执行器拒绝时名额释放、记录拒绝指标,恢复后再次提交成功; // 任务执行抛异常时名额同样释放,后续任务不受影响。 - CozeTaskQueueGate gate = new CozeTaskQueueGate(command -> { + TaskQueueGate gate = new TaskQueueGate(command -> { throw new TaskRejectedException("executor full"); }, 4, meterRegistryProvider); assertThrows(TaskRejectedException.class, () -> gate.execute(() -> { })); assertEquals(0, gate.waiting(), "拒绝后名额释放"); assertEquals(1, rejectedCount("delegate-rejected"), "执行器拒绝单独计数"); - CozeTaskQueueGate recovered = gate(4); + TaskQueueGate recovered = gate(4); AtomicInteger ran = new AtomicInteger(); recovered.execute(ran::incrementAndGet); captured.get(0).run(); assertEquals(1, ran.get(), "恢复后提交成功"); captured.clear(); - CozeTaskQueueGate failingTaskGate = gate(4); + TaskQueueGate failingTaskGate = gate(4); failingTaskGate.execute(() -> { throw new IllegalStateException("task boom"); }); diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/ThreadPoolIsolationConfigTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/ThreadPoolIsolationConfigTest.java index cec4cf79..88dd498a 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/config/ThreadPoolIsolationConfigTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/config/ThreadPoolIsolationConfigTest.java @@ -22,11 +22,11 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Task 74:隔离调度线程池、文件作业线程池和外部 Coze/图片执行池。 + * Task 74:隔离调度线程池、文件作业线程池和外部任务队列/图片执行池。 * 三个执行池各自独立配置、独立命名、容量互不影响:调度池 * (aiimage.scheduling.pool-size,默认 4)与文件作业派发池 * (aiimage.result-file-job.*,默认 2 线程/队列 200)互不共享线程; - * 外部 Coze 池以虚拟线程 + 信号量限流(默认 12)。容量非法值统一 + * 外部任务队列池以虚拟线程 + 信号量限流(默认 12)。容量非法值统一 * 钳制到最小值;任务失败后信号量名额与调度槽位必须释放,任一池打满 * 不影响其他池。 */ @@ -55,14 +55,14 @@ class ThreadPoolIsolationConfigTest { return executor; } - private ExecutorService newCozeVirtual() { - ExecutorService executor = new TaskFileJobConfig().cozeVirtualThreadExecutor(); + private ExecutorService newTaskQueueVirtual() { + ExecutorService executor = new TaskFileJobConfig().taskQueueVirtualThreadExecutor(); closeables.add(() -> executor.shutdownNow()); return executor; } - private TaskExecutor newCoze(ExecutorService virtualExecutor, int maxConcurrent) { - return new TaskFileJobConfig().cozeTaskExecutor(virtualExecutor, maxConcurrent, 4, null); + private TaskExecutor newTaskQueue(ExecutorService virtualExecutor, int maxConcurrent) { + return new TaskFileJobConfig().taskQueueExecutor(virtualExecutor, maxConcurrent, 4, null); } @Test @@ -78,15 +78,15 @@ class ThreadPoolIsolationConfigTest { assertEquals(2, dispatch.getMaxPoolSize(), "文件作业池 core=max,不随压力扩张"); assertEquals(200, dispatch.getQueueCapacity()); - ExecutorService cozeVirtual = newCozeVirtual(); - TaskExecutor coze = newCoze(cozeVirtual, 12); + ExecutorService taskQueueVirtual = newTaskQueueVirtual(); + TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, 12); AtomicBoolean ran = new AtomicBoolean(false); CountDownLatch done = new CountDownLatch(1); - coze.execute(() -> { + taskQueue.execute(() -> { ran.set(true); done.countDown(); }); - assertTrue(done.await(5, TimeUnit.SECONDS), "Coze 池默认限流 12,正常提交即执行"); + assertTrue(done.await(5, TimeUnit.SECONDS), "任务队列池默认限流 12,正常提交即执行"); assertTrue(ran.get()); } @@ -96,8 +96,8 @@ class ThreadPoolIsolationConfigTest { // 线程名前缀互不相同,线程转储可识别归属池。 ThreadPoolTaskScheduler scheduler = newScheduler(6); ThreadPoolTaskExecutor dispatch = newDispatch(3, 500); - ExecutorService cozeVirtual = newCozeVirtual(); - TaskExecutor coze = newCoze(cozeVirtual, 8); + ExecutorService taskQueueVirtual = newTaskQueueVirtual(); + TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, 8); assertEquals(6, scheduler.getScheduledThreadPoolExecutor().getCorePoolSize()); assertEquals(3, dispatch.getCorePoolSize()); assertEquals(500, dispatch.getQueueCapacity()); @@ -105,7 +105,7 @@ class ThreadPoolIsolationConfigTest { CountDownLatch all = new CountDownLatch(3); dispatch.execute(all::countDown); scheduler.schedule((Runnable) all::countDown, new Date(System.currentTimeMillis() + 50)); - coze.execute(all::countDown); + taskQueue.execute(all::countDown); assertTrue(all.await(5, TimeUnit.SECONDS), "三个池同时执行互不阻塞"); assertNotEquals(scheduler.getThreadNamePrefix(), dispatch.getThreadNamePrefix(), @@ -139,11 +139,11 @@ class ThreadPoolIsolationConfigTest { ThreadPoolTaskExecutor dispatch = newDispatch(0, 0); assertEquals(1, dispatch.getCorePoolSize(), "文件作业池 0 钳制到 1"); assertEquals(10, dispatch.getQueueCapacity(), "队列 0 钳制到 10"); - ExecutorService cozeVirtual = newCozeVirtual(); - TaskExecutor coze = newCoze(cozeVirtual, 0); + ExecutorService taskQueueVirtual = newTaskQueueVirtual(); + TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, 0); CountDownLatch done = new CountDownLatch(1); - coze.execute(done::countDown); - assertTrue(done.await(5, TimeUnit.SECONDS), "Coze 池 0 钳制到 1 后仍可执行"); + taskQueue.execute(done::countDown); + assertTrue(done.await(5, TimeUnit.SECONDS), "任务队列池 0 钳制到 1 后仍可执行"); } @Test @@ -200,23 +200,23 @@ class ThreadPoolIsolationConfigTest { assertEquals(1, dispatch.getCorePoolSize()); assertEquals(10, dispatch.getQueueCapacity()); - ExecutorService cozeVirtual = newCozeVirtual(); - TaskExecutor coze = newCoze(cozeVirtual, -3); - assertThrows(IllegalArgumentException.class, () -> coze.execute(null), "null 任务被拒绝"); + ExecutorService taskQueueVirtual = newTaskQueueVirtual(); + TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, -3); + assertThrows(IllegalArgumentException.class, () -> taskQueue.execute(null), "null 任务被拒绝"); CountDownLatch done = new CountDownLatch(1); - coze.execute(done::countDown); + taskQueue.execute(done::countDown); assertTrue(done.await(5, TimeUnit.SECONDS), "非法配置钳制后池仍可用"); } @Test void test_task_074_image_dispatch_job_dependency_failure_releases_resources() throws Exception { - // 依赖失败:Coze 任务抛异常后信号量名额必须释放(后续任务可执行); + // 依赖失败:任务队列任务抛异常后信号量名额必须释放(后续任务可执行); // 调度任务异常被 error handler 吞掉,调度器继续可用。 - ExecutorService cozeVirtual = newCozeVirtual(); - TaskExecutor coze = newCoze(cozeVirtual, 2); + ExecutorService taskQueueVirtual = newTaskQueueVirtual(); + TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, 2); CountDownLatch blockerHeld = new CountDownLatch(1); CountDownLatch releaseBlocker = new CountDownLatch(1); - coze.execute(() -> { + taskQueue.execute(() -> { blockerHeld.countDown(); try { releaseBlocker.await(10, TimeUnit.SECONDS); @@ -225,11 +225,11 @@ class ThreadPoolIsolationConfigTest { } }); assertTrue(blockerHeld.await(5, TimeUnit.SECONDS), "任务 1 占住一个信号量名额"); - coze.execute(() -> { - throw new IllegalStateException("coze down"); + taskQueue.execute(() -> { + throw new IllegalStateException("task queue down"); }); CountDownLatch afterFailure = new CountDownLatch(1); - coze.execute(afterFailure::countDown); + taskQueue.execute(afterFailure::countDown); assertTrue(afterFailure.await(5, TimeUnit.SECONDS), "失败任务释放名额,后续任务可执行"); releaseBlocker.countDown(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java b/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java index eff508e2..54dee77f 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/metrics/ExternalCallMetricsRecorderTest.java @@ -6,9 +6,7 @@ import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.config.ZiniaoProperties; import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder; import com.nanri.aiimage.modules.brand.client.BrandCheckClient; -import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; -import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; +import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient; import com.nanri.aiimage.modules.ziniao.client.ZiniaoClientImpl; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; @@ -35,13 +33,9 @@ import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; /** - * Task 78:为所有外部调用(Coze / 品牌检查 / 紫鸟)统一增加耗时、重试、 + * Task 78:为所有外部调用(LLM / 品牌检查 / 紫鸟)统一增加耗时、重试、 * 失败率和 payload 字节指标。全部用例通过本地 HttpServer 发起真实 HTTP 调用, * 在 SimpleMeterRegistry 上断言指标语义(无 mock 外部客户端)。 */ @@ -52,26 +46,19 @@ class ExternalCallMetricsRecorderTest { private HttpServer server; private int port; private final ExecutorService serverExecutor = Executors.newCachedThreadPool(); - private final AtomicInteger cozeSubmitCount = new AtomicInteger(); + private final AtomicInteger llmSubmitCount = new AtomicInteger(); private final AtomicInteger ziniaoCount = new AtomicInteger(); private final AtomicInteger brandCount = new AtomicInteger(); - private final AtomicBoolean cozeFailNext = new AtomicBoolean(); - - /** 可复用 Coze 凭据池:返回一个固定凭据,避免真实 HTTP 调用被凭据检查拦截。 */ - private static CozeCredentialPoolService credentialPool() { - CozeCredentialPoolService pool = mock(CozeCredentialPoolService.class); - when(pool.listEnabled(anyString())).thenReturn(List.of( - new CozeCredentialPoolService.CozeCredential("test", "wf-1", "token", Integer.MAX_VALUE))); - return pool; - } + private final AtomicBoolean llmFailNext = new AtomicBoolean(); @BeforeEach void setUp() throws IOException { - server = HttpServer.create(new InetSocketAddress(0), 0); + // backlog 128:并发 20 请求时 accept 线程偶发停顿会被 OS 队列吸收,避免连接拒绝 + server = HttpServer.create(new InetSocketAddress(0), 128); server.setExecutor(serverExecutor); server.createContext("/brand_check", this::handleBrandCheck); server.createContext("/app/builtin/company", this::handleZiniaoCompany); - server.createContext("/v1/workflow/run", this::handleCozeSubmit); + server.createContext("/v1/chat/completions", this::handleLlmSubmit); server.start(); port = server.getAddress().getPort(); } @@ -94,17 +81,14 @@ class ExternalCallMetricsRecorderTest { "{\"code\":\"0\",\"data\":{\"companyId\":1001}}".getBytes(StandardCharsets.UTF_8)); } - private void handleCozeSubmit(HttpExchange exchange) throws IOException { - int count = cozeSubmitCount.incrementAndGet(); - if (cozeFailNext.getAndSet(false)) { + private void handleLlmSubmit(HttpExchange exchange) throws IOException { + int count = llmSubmitCount.incrementAndGet(); + if (llmFailNext.getAndSet(false)) { exchange.sendResponseHeaders(500, 0); exchange.close(); return; } - String executeId = "exec-" + count; - String payload = "{\"data\":[{\"asin\":\"B0TEST78\",\"country\":\"US\",\"result\":\"ok\",\"conclusion\":\"ok\"}]}"; - String response = "{\"code\":0,\"data\":{\"execute_id\":\"" + executeId - + "\",\"status\":\"Success\",\"data\":" + payload + "}}"; + String response = "{\"choices\":[{\"message\":{\"content\":\"{\\\"result\\\":\\\"ok\\\"}\"}}]}"; sendJson(exchange, 200, response.getBytes(StandardCharsets.UTF_8)); } @@ -168,26 +152,21 @@ class ExternalCallMetricsRecorderTest { return true; } - // ---- 1. 正常默认路径:Coze 批量检查走本地服务,耗时/payload 字节全部记录 ---- + // ---- 1. 正常默认路径:LLM 对话走本地服务,耗时/payload 字节全部记录 ---- @Test void test_task_078_payload_metrics_normal_default_path() throws Exception { - SimilarAsinProperties props = cozeProps(); - SimilarAsinCozeClient client = - new SimilarAsinCozeClient(props, objectMapper, credentialPool(), new ExternalCallMetricsRecorder(registry)); - SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); - row.setAsin("B0TEST78"); - row.setTitle("Test"); - row.setSku("SKU-1"); - row.setCountry("US"); + SimilarAsinProperties props = llmProps(); + SimilarAsinLlmClient client = + new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry)); - List result = client.inspect(List.of(row), "", "test-key"); + String content = client.invokeChat("test-model", "system", "hello", "test-key"); - assertEquals(1, result.size(), "默认成功路径必须返回完整结果"); - assertEquals("ok", result.getFirst().getConclusion(), "主输出必须解析到 Coze 结果"); - assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"), - "必须记录 Coze 调用耗时"); - assertTrue(findSummary("aiimage.external-call.payload.bytes", "client", "coze").totalAmount() > 0, + assertNotNull(content, "默认成功路径必须返回结果"); + assertTrue(content.contains("ok"), "主输出必须解析到 LLM 结果"); + assertNotNull(findTimer("aiimage.external-call.duration", "client", "llm"), + "必须记录 LLM 调用耗时"); + assertTrue(findSummary("aiimage.external-call.payload.bytes", "client", "llm").totalAmount() > 0, "必须记录 payload 字节指标"); } @@ -195,27 +174,19 @@ class ExternalCallMetricsRecorderTest { @Test void test_task_078_payload_metrics_normal_multiple_items() throws Exception { - SimilarAsinProperties props = cozeProps(); - SimilarAsinCozeClient client = - new SimilarAsinCozeClient(props, objectMapper, credentialPool(), new ExternalCallMetricsRecorder(registry)); - List rows = new ArrayList<>(); + SimilarAsinProperties props = llmProps(); + SimilarAsinLlmClient client = + new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry)); + for (int i = 0; i < 3; i++) { - SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); - row.setAsin("B0BATCH" + i); - row.setCountry("US"); - row.setTitle("Batch " + i); - row.setSku("SKU-" + i); - rows.add(row); + client.invokeChat("test-model", "system", "prompt-" + i, "test-key"); } + awaitMetric("aiimage.external-call.duration", "client", "llm"); + assertEquals(3, llmSubmitCount.get(), "三次请求全部真实发出"); + assertEquals(3.0, counterCount("aiimage.external-call.total", + "client", "llm", "result", "success"), "三次成功全部记录"); - List result = client.inspect(rows, "", "test-key"); - - assertEquals(3, result.size(), "批量结果不丢失"); - assertEquals("B0BATCH0", result.get(0).getAsin(), "顺序稳定"); - assertEquals("B0BATCH2", result.get(2).getAsin(), "顺序稳定"); - awaitMetric("aiimage.external-call.duration", "client", "coze"); - - // 全部外部客户端(Coze / 品牌检查 / 紫鸟)在同一次批量中各自记录指标 + // 全部外部客户端(LLM / 品牌检查 / 紫鸟)在同一次批量中各自记录指标 BrandCheckClient brand = new BrandCheckClient(brandProps(), new ExternalCallMetricsRecorder(registry)); brand.checkAll(List.of("Nintendo", "LEGO", "Sony"), "Terms"); ZiniaoClientImpl ziniao = new ZiniaoClientImpl(ziniaoProps(), objectMapper, new ExternalCallMetricsRecorder(registry)); @@ -280,22 +251,16 @@ class ExternalCallMetricsRecorderTest { @Test void test_task_078_payload_metrics_boundary_limit_and_overflow() throws Exception { - SimilarAsinProperties props = cozeProps(); - props.setCozeReadTimeoutMillis(5000); + SimilarAsinProperties props = llmProps(); ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry); ExecutorService pool = Executors.newFixedThreadPool(4); try { for (int i = 0; i < 20; i++) { int index = i; pool.submit(() -> { - SimilarAsinCozeClient client = new SimilarAsinCozeClient(props, objectMapper, credentialPool(), recorder); - SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); - row.setAsin("B0LIMIT" + index); - row.setCountry("US"); - row.setTitle("Limit " + index); - row.setSku("SKU-" + index); + SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, recorder); try { - client.inspect(List.of(row), "", "test-key"); + client.invokeChat("test-model", "system", "prompt-" + index, "test-key"); } catch (Exception ignored) { // 并发下结果失败也视为已处理 } @@ -305,13 +270,13 @@ class ExternalCallMetricsRecorderTest { pool.shutdown(); } - awaitMetric("aiimage.external-call.total", "client", "coze", "result", "success"); - for (int i = 0; i < 2000 && cozeSubmitCount.get() < 20; i++) { + awaitMetric("aiimage.external-call.total", "client", "llm", "result", "success"); + for (int i = 0; i < 2000 && llmSubmitCount.get() < 20; i++) { Thread.sleep(10); } - assertEquals(20, cozeSubmitCount.get(), "并发 20 请求全部真实发出"); + assertEquals(20, llmSubmitCount.get(), "并发 20 请求全部真实发出"); assertEquals(20.0, counterCount("aiimage.external-call.total", - "client", "coze", "result", "success"), "20 次成功全部记录,无重复"); + "client", "llm", "result", "success"), "20 次成功全部记录,无重复"); } // ---- 7. 非法参数:空列表拒绝,不发起请求,无指标 ---- @@ -334,40 +299,29 @@ class ExternalCallMetricsRecorderTest { @Test void test_task_078_payload_metrics_dependency_failure_releases_resources() throws Exception { ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry); - SimilarAsinProperties props = cozeProps(); - props.setCozeReadTimeoutMillis(5000); - SimilarAsinCozeClient client = - new SimilarAsinCozeClient(props, objectMapper, credentialPool(), recorder); - List rows = new ArrayList<>(); - SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); - row.setAsin("B0FAIL78"); - row.setCountry("US"); - row.setTitle("Fail"); - row.setSku("SKU-FAIL"); - rows.add(row); + SimilarAsinProperties props = llmProps(); + SimilarAsinLlmClient client = + new SimilarAsinLlmClient(props, objectMapper, recorder); // 第一次调用走 500 失败路径,第二次调用恢复成功:错误可恢复 - cozeFailNext.set(true); - client.inspect(rows, "", "test-key"); - client.inspect(rows, "", "test-key"); + llmFailNext.set(true); + client.invokeChat("test-model", "system", "hello", "test-key"); + client.invokeChat("test-model", "system", "hello", "test-key"); - awaitMetric("aiimage.external-call.total", "client", "coze", "result", "failure"); - assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"), + awaitMetric("aiimage.external-call.total", "client", "llm", "result", "failure"); + assertNotNull(findTimer("aiimage.external-call.duration", "client", "llm"), "失败调用同样记录耗时"); - awaitMetric("aiimage.external-call.total", "client", "coze", "result", "success"); - assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"), + awaitMetric("aiimage.external-call.total", "client", "llm", "result", "success"); + assertNotNull(findTimer("aiimage.external-call.duration", "client", "llm"), "恢复后的成功调用也记录耗时"); assertEquals(1.0, counterCount("aiimage.external-call.total", - "client", "coze", "result", "failure"), "失败率指标精确记录一次失败"); - assertTrue(counterCount("aiimage.external-call.retry.total", "client", "coze") >= 1.0, - "客户端重试循环记录重试次数指标"); + "client", "llm", "result", "failure"), "失败率指标精确记录一次失败"); } - private SimilarAsinProperties cozeProps() { + private SimilarAsinProperties llmProps() { SimilarAsinProperties props = new SimilarAsinProperties(); - props.setCozeBaseUrl("http://127.0.0.1:" + port); - props.setCozeWorkflowPath("/v1/workflow/run"); - props.setCozeWorkflowHistoryPath("/v1/workflows/{workflow_id}/run_histories/{execute_id}"); + props.setLlmHost("http://127.0.0.1:" + port); + props.setLlmRetryTimes(2); return props; } 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 index 620f2b33..97ae365a 100644 --- 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 @@ -30,7 +30,7 @@ class AppearancePatentLlmClientHttpTest { private final Map callCounts = new ConcurrentHashMap<>(); private final List capturedBodies = new ArrayList<>(); - private AppearancePatentCozeClient client; + private AppearancePatentLlmClient client; @BeforeEach void setUp() throws IOException { @@ -41,7 +41,7 @@ class AppearancePatentLlmClientHttpTest { AppearancePatentProperties properties = new AppearancePatentProperties(); properties.setLlmHost("http://127.0.0.1:" + server.getAddress().getPort()); properties.setLlmRetryTimes(3); - client = new AppearancePatentCozeClient( + client = new AppearancePatentLlmClient( properties, objectMapper, null, 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/AppearancePatentLlmClientTest.java similarity index 86% rename from backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClientTest.java rename to backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentLlmClientTest.java index c9a8699c..461c1ea7 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/AppearancePatentLlmClientTest.java @@ -16,9 +16,9 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -class AppearancePatentCozeClientTest { +class AppearancePatentLlmClientTest { - private final AppearancePatentCozeClient client = new AppearancePatentCozeClient( + private final AppearancePatentLlmClient client = new AppearancePatentLlmClient( new AppearancePatentProperties(), new ObjectMapper(), null, @@ -59,9 +59,9 @@ class AppearancePatentCozeClientTest { } @Test - void skipBrandCheckWhenCozeTitleIsNone() throws Exception { + void skipBrandCheckWhenLlmTitleIsNone() throws Exception { BrandCheckClient brandCheckClient = mock(BrandCheckClient.class); - AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient( + AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient( new AppearancePatentProperties(), new ObjectMapper(), null, @@ -73,7 +73,7 @@ class AppearancePatentCozeClientTest { {"data":[{"row_id":"1","title":"无","title_reason":"无","appearance":"无侵权","result":"无侵权"}]} """; - List merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText); + List merged = llmClient.mergeRowsFromDataText(List.of(row), dataText); assertThat(merged).hasSize(1); assertThat(merged.get(0).getTitleRisk()).isEqualTo("无侵权"); @@ -86,7 +86,7 @@ class AppearancePatentCozeClientTest { BrandCheckClient brandCheckClient = mock(BrandCheckClient.class); when(brandCheckClient.checkTitleText("阿凡达", "Terms")) .thenReturn(new BrandCheckClient.BrandCheckBatchResult(List.of("阿凡达"), List.of(), List.of())); - AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient( + AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient( new AppearancePatentProperties(), new ObjectMapper(), null, @@ -98,7 +98,7 @@ class AppearancePatentCozeClientTest { {"data":[{"row_id":"1","title":"阿凡达","appearance":"无侵权","result":"无侵权"}]} """; - List merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText); + List merged = llmClient.mergeRowsFromDataText(List.of(row), dataText); assertThat(merged).hasSize(1); assertThat(merged.get(0).getTitleRisk()).isEqualTo("无侵权"); @@ -108,7 +108,7 @@ class AppearancePatentCozeClientTest { @Test void leaveTitleRiskBlankWhenAppearanceIsMissing() throws Exception { BrandCheckClient brandCheckClient = mock(BrandCheckClient.class); - AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient( + AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient( new AppearancePatentProperties(), new ObjectMapper(), null, @@ -120,7 +120,7 @@ class AppearancePatentCozeClientTest { {"data":[{"row_id":"1","title":"阿凡达","result":""}]} """; - List merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText); + List merged = llmClient.mergeRowsFromDataText(List.of(row), dataText); assertThat(merged).hasSize(1); assertThat(merged.get(0).getTitleRisk()).isEmpty(); @@ -130,7 +130,7 @@ class AppearancePatentCozeClientTest { @Test void leaveTitleRiskBlankWhenTitleAndAppearanceAreMissing() throws Exception { BrandCheckClient brandCheckClient = mock(BrandCheckClient.class); - AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient( + AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient( new AppearancePatentProperties(), new ObjectMapper(), null, @@ -142,7 +142,7 @@ class AppearancePatentCozeClientTest { {"data":[{"row_id":"1","result":""}]} """; - List merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText); + List merged = llmClient.mergeRowsFromDataText(List.of(row), dataText); assertThat(merged).hasSize(1); assertThat(merged.get(0).getTitleRisk()).isEmpty(); @@ -151,7 +151,7 @@ class AppearancePatentCozeClientTest { } @Test - void preserveSubmittedPriceWhenMergingCozeResult() throws Exception { + void preserveSubmittedPriceWhenMergingLlmResult() throws Exception { AppearancePatentResultRowDto row = new AppearancePatentResultRowDto(); row.setId("1"); row.setPrice("12.99"); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/object/FaultInjectionTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/object/FaultInjectionTest.java index ac085cf3..deba5648 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/object/FaultInjectionTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/file/service/object/FaultInjectionTest.java @@ -2,7 +2,7 @@ package com.nanri.aiimage.modules.file.service.object; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; -import com.nanri.aiimage.config.CozeTaskQueueGate; +import com.nanri.aiimage.config.TaskQueueGate; import com.nanri.aiimage.config.TransientStorageProperties; import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper; import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent; @@ -195,7 +195,7 @@ class FaultInjectionTest { verify(mapper, never()).selectOne(any()); verify(publisher, never()).publishEvent(any(TaskFileJobDispatchEvent.class)); - CozeTaskQueueGate gate = new CozeTaskQueueGate(mock(TaskExecutor.class), 1, emptyProvider()); + TaskQueueGate gate = new TaskQueueGate(mock(TaskExecutor.class), 1, emptyProvider()); IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> gate.execute(null)); assertTrue(ex.getMessage().contains("不能为 null"), "null 任务必须抛出可识别错误消息"); } @@ -212,7 +212,7 @@ class FaultInjectionTest { }) .when(delegate).execute(any(Runnable.class)); SimpleMeterRegistry registry = new SimpleMeterRegistry(); - CozeTaskQueueGate gate = new CozeTaskQueueGate(delegate, 2, objectProvider(registry)); + TaskQueueGate gate = new TaskQueueGate(delegate, 2, objectProvider(registry)); Runnable task = () -> { }; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java deleted file mode 100644 index 657af0ef..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientLoggingTest.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.nanri.aiimage.modules.similarasin.client; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; -import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Method; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/** - * Task 19:Coze 请求/响应及 Python 回传日志改为采样、截断和 DEBUG 级别。 - * 新工具 SimilarAsinLogSupport 提供两条纯函数: - * - truncate:正文超限截断为 maxChars + 后缀,长文本不占满日志; - * - shouldLog:每 everyN 次采样一次(counter % everyN == 0),控制轮询/逐行日志量。 - * Coze 客户端正文日志与 Python 回传逐行日志经该工具后输出有界、可识别。 - */ -class SimilarAsinCozeClientLoggingTest { - - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Test - void test_task_019_logging_normal_default_path() { - // 正常输入:短文本不截断;每次采样(everyN=1)恒记录。 - assertEquals("hello", SimilarAsinLogSupport.truncate("hello", 100)); - assertEquals("", SimilarAsinLogSupport.truncate(null, 100), "null 文本返回空串"); - assertTrue(SimilarAsinLogSupport.shouldLog(0, 1), "everyN=1 恒采样"); - assertTrue(SimilarAsinLogSupport.shouldLog(7, 1), "everyN=1 不抑制任何计数"); - } - - @Test - void test_task_019_logging_normal_multiple_items() throws Exception { - // 批量场景:多个长文本各自截断、结果互不影响;采样按每 everyN 次一次。 - String longA = "A".repeat(3000); - String longB = "B".repeat(5000); - String truncatedA = SimilarAsinLogSupport.truncate(longA, 100); - String truncatedB = SimilarAsinLogSupport.truncate(longB, 100); - assertTrue(truncatedA.startsWith("A".repeat(100))); - assertTrue(truncatedB.startsWith("B".repeat(100))); - assertTrue(truncatedA.length() < longA.length(), "截断后必须短于原文"); - - int sampled = 0; - for (int i = 0; i < 30; i++) { - if (SimilarAsinLogSupport.shouldLog(i, 10)) { - sampled++; - } - } - assertEquals(3, sampled, "everyN=10 在 0..29 内应采样 0/10/20 共 3 次"); - } - - @Test - void test_task_019_logging_normal_repeated_operation_is_idempotent() throws Exception { - // 重复执行:同一文本多次截断结果一致;同一计数采样判定一致。 - String text = "x".repeat(1234); - String first = SimilarAsinLogSupport.truncate(text, 500); - String second = SimilarAsinLogSupport.truncate(text, 500); - assertEquals(first, second, "重复截断必须产生相同输出"); - assertEquals(first, SimilarAsinLogSupport.truncate(text, 500), "截断幂等"); - assertEquals(SimilarAsinLogSupport.shouldLog(20, 10), SimilarAsinLogSupport.shouldLog(20, 10)); - } - - @Test - void test_task_019_logging_boundary_empty_input() throws Exception { - // 空输入:null/空串安全返回空串;空白串按长度截断语义原样保留。 - assertEquals("", SimilarAsinLogSupport.truncate(null, 100)); - assertEquals("", SimilarAsinLogSupport.truncate("", 100)); - assertEquals(" ", SimilarAsinLogSupport.truncate(" ", 100), "空白串不做 trim,按原样返回"); - assertTrue(SimilarAsinLogSupport.shouldLog(0, 10), "计数 0 必须采样(首条不丢)"); - } - - @Test - void test_task_019_logging_boundary_single_item() throws Exception { - // 单元素边界:恰好等于上限不截断;超 1 字符截断并带长度后缀。 - String exact = "y".repeat(100); - assertEquals(exact, SimilarAsinLogSupport.truncate(exact, 100), "恰好等于上限不截断"); - String over = "y".repeat(101); - String truncated = SimilarAsinLogSupport.truncate(over, 100); - assertEquals(over.substring(0, 100), truncated.substring(0, 100), "截断保留前缀"); - assertTrue(truncated.contains("101"), "截断输出应携带原文长度"); - } - - @Test - void test_task_019_logging_boundary_limit_and_overflow() throws Exception { - // 上限/超限:10 万字符文本截断后有界、不再无界增长;采样 everyN 超限不抑制。 - String huge = "z".repeat(100_000); - String truncated = SimilarAsinLogSupport.truncate(huge, 2000); - assertTrue(truncated.length() < 2200, "截断输出必须有界,实际=" + truncated.length()); - assertTrue(truncated.length() > 2000, "应保留 2000 前缀 + 后缀"); - assertTrue(truncated.endsWith("]"), "截断输出带可识别后缀"); - assertTrue(SimilarAsinLogSupport.shouldLog(0, Integer.MAX_VALUE), "计数 0 在超大 everyN 下仍采样"); - assertFalse(SimilarAsinLogSupport.shouldLog(1, Integer.MAX_VALUE), "非零计数在超大 everyN 下抑制"); - assertFalse(SimilarAsinLogSupport.shouldLog(31, 10), "非采样点必须被抑制"); - } - - @Test - void test_task_019_logging_invalid_input_rejected() throws Exception { - // 非法参数:maxChars ≤ 0 时原样返回(不截断);null 文本始终空串。 - String text = "invalid-max"; - assertEquals(text, SimilarAsinLogSupport.truncate(text, 0), "maxChars=0 不截断"); - assertEquals(text, SimilarAsinLogSupport.truncate(text, -1), "负上限不截断"); - assertEquals("", SimilarAsinLogSupport.truncate(null, -5)); - assertTrue(SimilarAsinLogSupport.shouldLog(5, 0), "everyN=0 视为恒采样"); - assertTrue(SimilarAsinLogSupport.shouldLog(5, -3), "负 everyN 视为恒采样"); - } - - @Test - void test_task_019_logging_dependency_failure_releases_resources() throws Exception { - // 依赖失败:含代理对(emoji)的长文本截断不抛异常、不产生孤立代理项; - // 计数接近 Long.MAX_VALUE 不溢出;掩码后的请求体经截断管线输出有界且不泄漏密钥。 - String emoji = "🚀".repeat(3000); - String truncatedEmoji = SimilarAsinLogSupport.truncate(emoji, 100); - assertNotNull(truncatedEmoji); - assertTrue(truncatedEmoji.length() < emoji.length(), "代理对文本必须被截断"); - assertFalse(SimilarAsinLogSupport.shouldLog(Long.MAX_VALUE, 10), "极大计数采样判定不抛异常"); - - List rows = new java.util.ArrayList<>(); - SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); - row.setAsin("B0SECRET1"); - row.setUrl("https://m.media-amazon.com/images/I/" + "U".repeat(500) + ".jpg"); - row.setTitle("T".repeat(5000)); - row.setSku("SKU-SECRET"); - rows.add(row); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); - Method maskMethod = SimilarAsinCozeClient.class.getDeclaredMethod( - "maskCozeRequestBody", Map.class); - maskMethod.setAccessible(true); - Method buildMethod = SimilarAsinCozeClient.class.getDeclaredMethod( - "buildParameters", List.class, String.class, String.class, boolean.class); - buildMethod.setAccessible(true); - @SuppressWarnings("unchecked") - Map parameters = (Map) buildMethod.invoke(client, rows, "", "supersecretkey", true); - Map body = new java.util.LinkedHashMap<>(); - body.put("workflow_id", "wf-1"); - body.put("parameters", parameters); - body.put("api_key", "supersecretkey"); - - @SuppressWarnings("unchecked") - Map masked = (Map) maskMethod.invoke(client, body); - Method writeMethod = SimilarAsinCozeClient.class.getDeclaredMethod("writeJson", Object.class); - writeMethod.setAccessible(true); - String maskedJson = (String) writeMethod.invoke(client, masked); - String logged = SimilarAsinLogSupport.truncate(maskedJson, 2000); - - assertTrue(logged.length() < maskedJson.length(), "超长掩码 body 必须截断"); - assertFalse(logged.contains("supersecretkey"), "日志不得泄漏完整 api_key"); - assertTrue(logged.contains("B0SECRET1"), "截断保留正文关键字段"); - assertTrue(logged.length() < 2500, "截断输出必须有界"); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java deleted file mode 100644 index a1216244..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/client/SimilarAsinCozeClientTest.java +++ /dev/null @@ -1,240 +0,0 @@ -package com.nanri.aiimage.modules.similarasin.client; - -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; -import org.junit.jupiter.api.Test; - -import java.math.BigDecimal; -import java.lang.reflect.Method; -import java.util.List; -import java.util.Map; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -class SimilarAsinCozeClientTest { - - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Test - @SuppressWarnings("unchecked") - void buildParametersIncludesAlibabaPriceFromPythonPayload() throws Exception { - List rows = objectMapper.readValue(""" - [ - { - "asin": "B0TEST123", - "url": "https://m.media-amazon.com/images/I/main.jpg", - "alibaba": [ - {"url": "https://cbu01.alicdn.com/img/ibank/a.jpg", "price": 12.80}, - {"url": "https://cbu01.alicdn.com/img/ibank/b.jpg", "price": 19.99} - ], - "title": "Test title", - "sku": "SKU-1" - } - ] - """, new TypeReference<>() { - }); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); - - Method method = SimilarAsinCozeClient.class.getDeclaredMethod( - "buildParameters", List.class, String.class, String.class, boolean.class); - method.setAccessible(true); - Map parameters = (Map) method.invoke(client, rows, "", "", true); - - List> items = (List>) parameters.get("items"); - Map item = items.getFirst(); - List> alibaba = (List>) item.get("alibaba"); - - assertEquals("B0TEST123", item.get("asin")); - assertEquals("https://m.media-amazon.com/images/I/main.jpg", item.get("url")); - assertEquals("Test title", item.get("title")); - assertEquals("SKU-1", item.get("sku")); - assertEquals(2, alibaba.size()); - assertEquals("https://cbu01.alicdn.com/img/ibank/a.jpg", alibaba.get(0).get("url")); - assertEquals(0, new BigDecimal("12.8").compareTo((BigDecimal) alibaba.get(0).get("price"))); - assertEquals("https://cbu01.alicdn.com/img/ibank/b.jpg", alibaba.get(1).get("url")); - assertEquals(0, new BigDecimal("19.99").compareTo((BigDecimal) alibaba.get(1).get("price"))); - assertTrue(rows.getFirst().hasImageUrl()); - } - - @Test - @SuppressWarnings("unchecked") - void buildParametersFallsBackAlibabaFromLegacyUrlsAndTopLevelPrice() throws Exception { - SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); - row.setAsin("B0LEGACY1"); - row.setUrl("https://m.media-amazon.com/images/I/main.jpg"); - row.setUrls(List.of("https://cbu01.alicdn.com/img/ibank/legacy-a.jpg")); - row.setPrice("8.50"); - row.setTitle("Legacy title"); - row.setSku("SKU-LEGACY"); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); - - Method method = SimilarAsinCozeClient.class.getDeclaredMethod( - "buildParameters", List.class, String.class, String.class, boolean.class); - method.setAccessible(true); - Map parameters = (Map) method.invoke(client, List.of(row), "", "", false); - - List> items = (List>) parameters.get("items"); - List> alibaba = (List>) items.getFirst().get("alibaba"); - - assertEquals(1, alibaba.size()); - assertEquals("https://cbu01.alicdn.com/img/ibank/legacy-a.jpg", alibaba.getFirst().get("url")); - assertEquals(0, new BigDecimal("8.5").compareTo((BigDecimal) alibaba.getFirst().get("price"))); - } - - @Test - @SuppressWarnings("unchecked") - void buildParametersConvertsBlankAlibabaPricesToZero() throws Exception { - List rows = objectMapper.readValue(""" - [ - { - "asin": "B0BLANK123", - "url": "https://m.media-amazon.com/images/I/main.jpg", - "alibaba": [ - {"url": "https://cbu01.alicdn.com/img/ibank/blank.jpg", "price": ""}, - {"url": "https://cbu01.alicdn.com/img/ibank/null.jpg", "price": null} - ] - } - ] - """, new TypeReference<>() { - }); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); - - Method method = SimilarAsinCozeClient.class.getDeclaredMethod( - "buildParameters", List.class, String.class, String.class, boolean.class); - method.setAccessible(true); - Map parameters = (Map) method.invoke(client, rows, "", "", true); - - List> items = (List>) parameters.get("items"); - List> alibaba = (List>) items.getFirst().get("alibaba"); - String json = objectMapper.writeValueAsString(parameters); - - assertEquals(2, alibaba.size()); - assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.get(0).get("price"))); - assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.get(1).get("price"))); - assertTrue(json.contains("\"price\":0")); - assertFalse(json.contains("\"price\":\"\"")); - } - - @Test - @SuppressWarnings("unchecked") - void buildParametersConvertsBlankFallbackRowPriceToZero() throws Exception { - SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); - row.setAsin("B0BLANKROW"); - row.setUrl("https://m.media-amazon.com/images/I/main.jpg"); - row.setUrls(List.of("https://cbu01.alicdn.com/img/ibank/fallback.jpg")); - row.setPrice(""); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); - - Method method = SimilarAsinCozeClient.class.getDeclaredMethod( - "buildParameters", List.class, String.class, String.class, boolean.class); - method.setAccessible(true); - Map parameters = (Map) method.invoke(client, List.of(row), "", "", true); - - List> items = (List>) parameters.get("items"); - List> alibaba = (List>) items.getFirst().get("alibaba"); - String json = objectMapper.writeValueAsString(parameters); - - assertEquals(1, alibaba.size()); - assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.getFirst().get("price"))); - assertTrue(json.contains("\"price\":0")); - assertFalse(json.contains("\"price\":\"\"")); - } - - @Test - @SuppressWarnings("unchecked") - void buildParametersSerializesWholeNumberPricesWithoutScientificNotation() throws Exception { - List rows = objectMapper.readValue(""" - [ - { - "asin": "B0SCI123", - "url": "https://m.media-amazon.com/images/I/main.jpg", - "alibaba": [ - {"url": "https://cbu01.alicdn.com/img/ibank/ten.jpg", "price": 10.0}, - {"url": "https://cbu01.alicdn.com/img/ibank/eighty.jpg", "price": 80.0}, - {"url": "https://cbu01.alicdn.com/img/ibank/fraction.jpg", "price": 7.5800} - ] - } - ] - """, new TypeReference<>() { - }); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); - - Method method = SimilarAsinCozeClient.class.getDeclaredMethod( - "buildParameters", List.class, String.class, String.class, boolean.class); - method.setAccessible(true); - Map parameters = (Map) method.invoke(client, rows, "", "", true); - - List> items = (List>) parameters.get("items"); - List> alibaba = (List>) items.getFirst().get("alibaba"); - String json = objectMapper.writeValueAsString(parameters); - - assertEquals("10", ((BigDecimal) alibaba.get(0).get("price")).toPlainString()); - assertEquals("80", ((BigDecimal) alibaba.get(1).get("price")).toPlainString()); - assertEquals("7.58", ((BigDecimal) alibaba.get(2).get("price")).toPlainString()); - assertFalse(json.contains("1E+1")); - assertFalse(json.contains("8E+1")); - assertTrue(json.contains("\"price\":10")); - assertTrue(json.contains("\"price\":80")); - } - - @Test - @SuppressWarnings("unchecked") - void buildParametersIncludesCategorySwitch() throws Exception { - SimilarAsinResultRowDto row = new SimilarAsinResultRowDto(); - row.setAsin("B0CATEGORY1"); - row.setTitle("Category test"); - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); - - Method method = SimilarAsinCozeClient.class.getDeclaredMethod( - "buildParameters", List.class, String.class, String.class, boolean.class, boolean.class); - method.setAccessible(true); - Map parameters = (Map) method.invoke(client, List.of(row), "", "", false, true); - - assertEquals(Boolean.TRUE, parameters.get("category_switch")); - assertEquals(Boolean.FALSE, parameters.get("img_switch")); - } - - @Test - void imageOnlyWorkflowOutputIsExtractedAndMergedByAsin() throws Exception { - SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null); - String imageData = """ - {"data":[{ - "asin":"B0BQNHDP2F", - "main_url":"https://example.com/main.jpg", - "puzzle_img1":"https://example.com/puzzle-1.jpg", - "puzzle_img2":"https://example.com/puzzle-2.jpg" - }]} - """; - String workflowOutput = objectMapper.writeValueAsString(Map.of( - "node_status", "{}", - "Output", imageData)); - var historyResponse = objectMapper.createObjectNode(); - historyResponse.put("code", 0); - historyResponse.putArray("data") - .addObject() - .put("execute_status", "Success") - .put("output", workflowOutput); - Method extract = SimilarAsinCozeClient.class.getDeclaredMethod("extractResultDataText", JsonNode.class); - extract.setAccessible(true); - String dataText = (String) extract.invoke(client, historyResponse); - - SimilarAsinResultRowDto source = new SimilarAsinResultRowDto(); - source.setAsin("B0BQNHDP2F"); - List merged = client.mergeRowsFromDataText(List.of(source), dataText); - - assertFalse(dataText.isBlank()); - assertEquals(1, merged.size()); - assertEquals("https://example.com/main.jpg", merged.getFirst().getMainUrl()); - assertEquals("https://example.com/puzzle-1.jpg", merged.getFirst().getPuzzleImg1()); - assertEquals("https://example.com/puzzle-2.jpg", merged.getFirst().getPuzzleImg2()); - - Method resolvedCount = SimilarAsinCozeClient.class.getDeclaredMethod("resolvedCount", List.class); - resolvedCount.setAccessible(true); - assertEquals(1, resolvedCount.invoke(client, merged)); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkMergeLimitTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkMergeLimitTest.java index b19ceed3..dbfcc54d 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkMergeLimitTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkMergeLimitTest.java @@ -2,7 +2,6 @@ package com.nanri.aiimage.modules.similarasin.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; @@ -71,7 +70,6 @@ class SimilarAsinTaskServiceChunkMergeLimitTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService; @@ -81,7 +79,6 @@ class SimilarAsinTaskServiceChunkMergeLimitTest { @Mock private org.springframework.transaction.PlatformTransactionManager transactionManager; @Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService; @Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata; - @Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService; @Mock private SimilarAsinImageEmbedder imageEmbedder; @Mock private SimilarAsinImagePrefetchService imagePrefetchService; @@ -168,11 +165,11 @@ class SimilarAsinTaskServiceChunkMergeLimitTest { private static void invokeMerge(SimilarAsinTaskService service, FileTaskEntity task, String scopeHash, Integer chunkIndex, - List cozeRows) throws Exception { - Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk", + List llmRows) throws Exception { + Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk", FileTaskEntity.class, String.class, Integer.class, List.class, Map.class); merge.setAccessible(true); - merge.invoke(service, task, scopeHash, chunkIndex, cozeRows, Map.of()); + merge.invoke(service, task, scopeHash, chunkIndex, llmRows, Map.of()); } @Test @@ -184,12 +181,12 @@ class SimilarAsinTaskServiceChunkMergeLimitTest { FileTaskEntity task = new FileTaskEntity(); task.setId(9004L); - List cozeRows = List.of( + List llmRows = List.of( row("r1", "B0A0000001", "标题1"), row("r2", "B0A0000002", "标题2"), row("r3", "B0A0000003", "标题3")); - invokeMerge(service, task, "hashA", 1, cozeRows); + invokeMerge(service, task, "hashA", 1, llmRows); assertNotNull(storedPayload.get()); assertTrue(storedPayload.get().contains("\"r1\"") && storedPayload.get().contains("\"r3\""), "上限内合并必须完整保留存量行与新增行,实际: " + storedPayload.get()); @@ -243,9 +240,9 @@ class SimilarAsinTaskServiceChunkMergeLimitTest { FileTaskEntity task = new FileTaskEntity(); task.setId(9004L); - List cozeRows = List.of(row("r1", "B0A0000001", "标题1")); - invokeMerge(service, task, "hashA", 1, cozeRows); - invokeMerge(service, task, "hashA", 1, cozeRows); + List llmRows = List.of(row("r1", "B0A0000001", "标题1")); + invokeMerge(service, task, "hashA", 1, llmRows); + invokeMerge(service, task, "hashA", 1, llmRows); assertEquals(2, storeCalls.get(), "重复执行同一输入:每次 merge 恰好写回一次,无多余请求"); verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class)); } @@ -298,13 +295,13 @@ class SimilarAsinTaskServiceChunkMergeLimitTest { FileTaskEntity task = new FileTaskEntity(); task.setId(9004L); - List cozeRows = new ArrayList<>(); + List llmRows = new ArrayList<>(); for (int i = 0; i < 5; i++) { - cozeRows.add(row("r" + (i + 1), "B0A00000" + (i + 1), "新行" + i)); + llmRows.add(row("r" + (i + 1), "B0A00000" + (i + 1), "新行" + i)); } // Phase A:行数超限(上限 2,存量 1 + 新增 5)→ 只保留上限内最新行,超限部分转 orphan。 - invokeMerge(service, task, "hashA", 1, cozeRows); + invokeMerge(service, task, "hashA", 1, llmRows); assertTrue(lastStoredPayload.get().contains("\"r4\"") && lastStoredPayload.get().contains("\"r5\""), "行数超限时保留上限内的最新行,实际: " + lastStoredPayload.get()); assertFalse(lastStoredPayload.get().contains("\"r0\""), "行数超限时最旧行被降级,实际: " + lastStoredPayload.get()); @@ -317,7 +314,7 @@ class SimilarAsinTaskServiceChunkMergeLimitTest { lenient().when(properties.getChunkMergeMaxRows()).thenReturn(50000); long oneRowBytes = rowsJson(List.of(row("r9", "B0A0000099", "样本行"))).getBytes(StandardCharsets.UTF_8).length; lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(oneRowBytes + 5L); - invokeMerge(service, task, "hashA", 1, cozeRows); + invokeMerge(service, task, "hashA", 1, llmRows); assertTrue(lastStoredPayload.get().contains("\"r5\""), "字节超限时保留最新行,实际: " + lastStoredPayload.get()); assertFalse(lastStoredPayload.get().contains("\"r0\""), "字节超限时最旧行被降级,实际: " + lastStoredPayload.get()); assertEquals(2, storeCalls.get()); @@ -327,7 +324,7 @@ class SimilarAsinTaskServiceChunkMergeLimitTest { lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(5L); Exception ex = assertThrows(Exception.class, () -> { try { - invokeMerge(service, task, "hashA", 1, cozeRows); + invokeMerge(service, task, "hashA", 1, llmRows); } catch (java.lang.reflect.InvocationTargetException e) { throw e.getCause(); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceCozeBufferScopeTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceCozeBufferScopeTest.java index ebc01ff8..73a8d843 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceCozeBufferScopeTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceCozeBufferScopeTest.java @@ -2,8 +2,6 @@ package com.nanri.aiimage.modules.similarasin.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; @@ -59,8 +57,8 @@ import static org.mockito.Mockito.when; * P0-3 缓冲只覆盖"poll DONE 且 batchTotal>1";Task 12 扩展为: * 1) poll DONE 结果去掉 batchTotal 限制,单 batch 也走缓冲; * 2) retry 提交同步 immediate DONE 结果也走缓冲(原立即 merge); - * 3) 统一走 bufferCozeRowsOrMerge:缓冲失败回退立即 merge,结果不丢失。 - * flushBufferedCozeResults 在 finalize 前一次性合并,全任务收敛为一次 chunk 读写。 + * 3) 统一走 bufferLlmRowsOrMerge:缓冲失败回退立即 merge,结果不丢失。 + * flushLlmBufferedResults 在 finalize 前一次性合并,全任务收敛为一次 chunk 读写。 */ @ExtendWith(MockitoExtension.class) class SimilarAsinTaskServiceCozeBufferScopeTest { @@ -78,7 +76,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; + @Mock private SimilarAsinLlmService similarAsinLlmService; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private TaskFileJobService taskFileJobService; @@ -88,7 +86,6 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { @Mock private org.springframework.transaction.PlatformTransactionManager transactionManager; @Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService; @Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata; - @Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService; @Mock private SimilarAsinImageEmbedder imageEmbedder; @Mock private SimilarAsinImagePrefetchService imagePrefetchService; @@ -113,24 +110,12 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { lenient().when(properties.getMaxFieldLength()).thenReturn(2000); lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000); lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L); - lenient().when(properties.getCozeBatchSize()).thenReturn(5); - lenient().when(properties.getCozeTextOnlyBatchSize()).thenReturn(10); - lenient().when(properties.isCozeResultBufferEnabled()).thenReturn(true); - lenient().when(properties.getCozePollTimeoutMillis()).thenReturn(30_000); + lenient().when(properties.getLlmBatchSize()).thenReturn(5); + lenient().when(properties.getLlmTextOnlyBatchSize()).thenReturn(10); + lenient().when(properties.isLlmResultBufferEnabled()).thenReturn(true); lenient().when(properties.getDbJobTouchIntervalMillis()).thenReturn(2_000L); lenient().when(properties.getDbTaskTouchIntervalMillis()).thenReturn(2_000L); - lenient().when(properties.getCozeSubmitLockWaitMillis()).thenReturn(1_000L); - lenient().when(properties.getCozeSubmitLockRetryDelayMillis()).thenReturn(100L); - lenient().when(properties.getCozeSubmitMinIntervalMillis()).thenReturn(0L); - lenient().when(properties.getCozeSubmitMaxRetryCount()).thenReturn(3); - lenient().when(properties.getCozeFlushPendingMinutes()).thenReturn(10); - lenient().when(cozeClient.configuredCredentialCount()).thenReturn(1); - lenient().when(cozeClient.nextCredential()).thenReturn(new SimilarAsinCozeClient.CozeCredentialRef( - CREDENTIAL, "wf-1", "token-1", 4)); - lenient().when(cozeClient.credentialByName(CREDENTIAL)).thenReturn(new SimilarAsinCozeClient.CozeCredentialRef( - CREDENTIAL, "wf-1", "token-1", 4)); - lenient().when(cozeCredentialPoolService.borrow(eq(MODULE), any())).thenReturn( - mock(CozeCredentialPoolService.BorrowedCredential.class)); + lenient().when(properties.getLlmFlushPendingMinutes()).thenReturn(10); lenient().when(distributedJobLockService.tryLock(anyString(), any())).thenReturn( mock(com.nanri.aiimage.common.service.DistributedJobLockService.LockHandle.class)); lenient().doAnswer(invocation -> { @@ -192,7 +177,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { state.setTaskId(task.getId()); state.setModuleType(MODULE); state.setScopeHash("scope-" + id); - state.setCozeStatus(status); + state.setLlmStatus(status); state.setParsedPayloadJson("ptr:batch-" + id); state.setStateJson("{\"jobId\":7101,\"resultId\":7201,\"chunkScopeHash\":null,\"chunkIndex\":null," + "\"batchIndex\":1,\"batchTotal\":" + batchTotal + ",\"ownerInstanceId\":\"test-instance\"," @@ -204,8 +189,8 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { return new ObjectMapper().writeValueAsString(rows); } - private SimilarAsinTaskService.CozeBatchContext context(int batchTotal) { - return new SimilarAsinTaskService.CozeBatchContext( + private SimilarAsinTaskService.LlmBatchContext context(int batchTotal) { + return new SimilarAsinTaskService.LlmBatchContext( 7101L, 7201L, null, null, 1, batchTotal, "test-instance", 0, CREDENTIAL, null); } @@ -235,14 +220,14 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { @Test void test_task_012_payload_chunk_normal_default_path() throws Exception { - // 正常输入:DONE 结果(batchTotal=1 单 batch)经 bufferCozeRowsOrMerge 走缓冲, + // 正常输入:DONE 结果(batchTotal=1 单 batch)经 bufferLlmRowsOrMerge 走缓冲, // 不立即写 chunk;缓冲失败回退立即 merge 结果不丢失。 FileTaskEntity task = task(); List rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")); - Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge", + Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge", TaskScopeStateEntity.class, - SimilarAsinTaskService.CozeBatchContext.class, + SimilarAsinTaskService.LlmBatchContext.class, List.class, FileTaskEntity.class, Map.class); bufferOrMerge.setAccessible(true); bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of()); @@ -278,7 +263,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk(1L, "scope-1", 1, "ptr:chunk-1"))); when(taskChunkMapper.update(any(), any())).thenReturn(1); - Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushBufferedCozeResults", Long.class); + Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushLlmBufferedResults", Long.class); flush.setAccessible(true); flush.invoke(service, 7104L); @@ -294,9 +279,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { FileTaskEntity task = task(); List rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")); - Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge", + Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge", TaskScopeStateEntity.class, - SimilarAsinTaskService.CozeBatchContext.class, + SimilarAsinTaskService.LlmBatchContext.class, List.class, FileTaskEntity.class, Map.class); bufferOrMerge.setAccessible(true); TaskScopeStateEntity state = state(task, 1L, "DONE", 2); @@ -313,9 +298,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { void test_task_012_payload_chunk_boundary_empty_input() throws Exception { // 空输入:无行时缓冲与 merge 都不发生,不创建无效资源 FileTaskEntity task = task(); - Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge", + Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge", TaskScopeStateEntity.class, - SimilarAsinTaskService.CozeBatchContext.class, + SimilarAsinTaskService.LlmBatchContext.class, List.class, FileTaskEntity.class, Map.class); bufferOrMerge.setAccessible(true); bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), null, task, Map.of()); @@ -330,9 +315,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { // 单 batch(batchTotal=1):原 P0-3 例外,现在也缓冲 FileTaskEntity task = task(); List rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")); - Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge", + Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge", TaskScopeStateEntity.class, - SimilarAsinTaskService.CozeBatchContext.class, + SimilarAsinTaskService.LlmBatchContext.class, List.class, FileTaskEntity.class, Map.class); bufferOrMerge.setAccessible(true); bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of()); @@ -347,11 +332,11 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { // 缓冲开关关闭:回退立即 merge,DONE 结果仍落 chunk 不丢失 FileTaskEntity task = task(); stubChunkMerge(chunkRowsJson()); - when(properties.isCozeResultBufferEnabled()).thenReturn(false); + when(properties.isLlmResultBufferEnabled()).thenReturn(false); List rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")); - Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge", + Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge", TaskScopeStateEntity.class, - SimilarAsinTaskService.CozeBatchContext.class, + SimilarAsinTaskService.LlmBatchContext.class, List.class, FileTaskEntity.class, Map.class); bufferOrMerge.setAccessible(true); bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of()); @@ -369,9 +354,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true))) .thenThrow(new IllegalStateException("rustfs full")); List rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")); - Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge", + Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge", TaskScopeStateEntity.class, - SimilarAsinTaskService.CozeBatchContext.class, + SimilarAsinTaskService.LlmBatchContext.class, List.class, FileTaskEntity.class, Map.class); bufferOrMerge.setAccessible(true); bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of()); @@ -408,7 +393,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest { return "ptr:stored-" + invocation.getArgument(3); }).when(transientPayloadStorageService).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()); - Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushBufferedCozeResults", Long.class); + Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushLlmBufferedResults", Long.class); flush.setAccessible(true); Exception ex = assertThrows(Exception.class, () -> { try { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java index 0f080590..9b9e6fc7 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java @@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto; @@ -69,7 +68,6 @@ class SimilarAsinTaskServiceGroupRefTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private TransientPayloadStorageService transientPayloadStorageService; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParseLimitsTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParseLimitsTest.java index 309f2a71..4c1894ac 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParseLimitsTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParseLimitsTest.java @@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto; @@ -64,7 +63,6 @@ class SimilarAsinTaskServiceParseLimitsTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private TransientPayloadStorageService transientPayloadStorageService; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java index 971addbb..a0c23e9c 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java @@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto; @@ -66,7 +65,6 @@ class SimilarAsinTaskServiceParsePreviewTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private TransientPayloadStorageService transientPayloadStorageService; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePreviewConfigTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePreviewConfigTest.java index bc765445..72cb3865 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePreviewConfigTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePreviewConfigTest.java @@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto; @@ -65,7 +64,6 @@ class SimilarAsinTaskServicePreviewConfigTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private TransientPayloadStorageService transientPayloadStorageService; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyDedupeTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyDedupeTest.java index 798c97b1..6e4f7903 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyDedupeTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyDedupeTest.java @@ -2,7 +2,6 @@ package com.nanri.aiimage.modules.similarasin.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; @@ -65,7 +64,6 @@ class SimilarAsinTaskServiceRowKeyDedupeTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService; @@ -75,7 +73,6 @@ class SimilarAsinTaskServiceRowKeyDedupeTest { @Mock private org.springframework.transaction.PlatformTransactionManager transactionManager; @Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService; @Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata; - @Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService; @Mock private SimilarAsinImageEmbedder imageEmbedder; @Mock private SimilarAsinImagePrefetchService imagePrefetchService; @@ -157,7 +154,7 @@ class SimilarAsinTaskServiceRowKeyDedupeTest { @Test void test_task_011_merge_row_key_normal_default_path() throws Exception { - // 正常输入:cozeRows 含同一 rowKey 的重复行,merge 前按稳定 rowKey 去重, + // 正常输入:llmRows 含同一 rowKey 的重复行,merge 前按稳定 rowKey 去重, // chunk payload 只写一次,结果行不重复。 TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A"); AtomicLong storedCounter = new AtomicLong(0); @@ -165,18 +162,18 @@ class SimilarAsinTaskServiceRowKeyDedupeTest { FileTaskEntity task = new FileTaskEntity(); task.setId(7004L); - List cozeRows = List.of( + List llmRows = List.of( row("r1", "1", "B0A0000001", "英国"), row("r1", "1", "B0A0000001", "英国")); - List deduped = service.dedupeRowsByRowKey(cozeRows); + List deduped = service.dedupeRowsByRowKey(llmRows); assertEquals(1, deduped.size(), "重复行必须按稳定 rowKey 去重"); assertEquals("r1", deduped.get(0).getRowToken()); - Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk", + Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk", FileTaskEntity.class, String.class, Integer.class, List.class, Map.class); merge.setAccessible(true); - merge.invoke(service, task, null, null, cozeRows, Map.of()); + merge.invoke(service, task, null, null, llmRows, Map.of()); assertEquals(1, storedCounter.get(), "去重后 chunk 只写一次"); verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()); } @@ -198,34 +195,34 @@ class SimilarAsinTaskServiceRowKeyDedupeTest { selectOneRound.getAndIncrement() == 0 ? chunkA : chunkB); when(taskChunkMapper.update(any(), any())).thenReturn(1); - List cozeRows = new ArrayList<>(); + List llmRows = new ArrayList<>(); for (int i = 0; i < 3; i++) { - cozeRows.add(row("r1", "1", "B0A0000001", "英国")); - cozeRows.add(row("r2", "2", "B0A0000002", "英国")); + llmRows.add(row("r1", "1", "B0A0000001", "英国")); + llmRows.add(row("r2", "2", "B0A0000002", "英国")); } - List deduped = service.dedupeRowsByRowKey(cozeRows); + List deduped = service.dedupeRowsByRowKey(llmRows); assertEquals(2, deduped.size(), "3 轮重复输入去重后只剩 2 个唯一行"); assertEquals(List.of("r1", "r2"), deduped.stream().map(SimilarAsinResultRowDto::getRowToken).toList(), "去重必须保留首次出现顺序"); FileTaskEntity task = new FileTaskEntity(); task.setId(7004L); - Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk", + Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk", FileTaskEntity.class, String.class, Integer.class, List.class, Map.class); merge.setAccessible(true); - merge.invoke(service, task, null, null, cozeRows, Map.of()); + merge.invoke(service, task, null, null, llmRows, Map.of()); verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()); } @Test void test_task_011_merge_row_key_normal_repeated_operation_is_idempotent() { // 重复执行同一输入:去重结果完全一致,不产生重复记录 - List cozeRows = List.of( + List llmRows = List.of( row("r1", "1", "B0A0000001", "英国"), row("r2", "2", "B0A0000002", "英国"), row("r1", "1", "B0A0000001", "英国")); - List first = service.dedupeRowsByRowKey(cozeRows); - List second = service.dedupeRowsByRowKey(cozeRows); + List first = service.dedupeRowsByRowKey(llmRows); + List second = service.dedupeRowsByRowKey(llmRows); assertEquals(first.size(), second.size()); for (int i = 0; i < first.size(); i++) { assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken()); @@ -258,11 +255,11 @@ class SimilarAsinTaskServiceRowKeyDedupeTest { @Test void test_task_011_merge_row_key_boundary_limit_and_overflow() { // 大批量:1000 行全部重复,去重后只剩 1 个唯一行,无无界内存增长 - List cozeRows = new ArrayList<>(); + List llmRows = new ArrayList<>(); for (int i = 0; i < 1000; i++) { - cozeRows.add(row("r1", "1", "B0A0000001", "英国")); + llmRows.add(row("r1", "1", "B0A0000001", "英国")); } - List deduped = service.dedupeRowsByRowKey(cozeRows); + List deduped = service.dedupeRowsByRowKey(llmRows); assertEquals(1, deduped.size()); // 1000 行唯一:全部保留且顺序稳定 List unique = new ArrayList<>(); @@ -299,7 +296,7 @@ class SimilarAsinTaskServiceRowKeyDedupeTest { .thenThrow(new IllegalStateException("rustfs down")); FileTaskEntity task = new FileTaskEntity(); task.setId(7004L); - Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk", + Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk", FileTaskEntity.class, String.class, Integer.class, List.class, Map.class); merge.setAccessible(true); Exception ex = assertThrows(Exception.class, () -> { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyIndexTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyIndexTest.java index 0c34abb2..4fbff56f 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyIndexTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyIndexTest.java @@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; @@ -52,8 +51,8 @@ import static org.mockito.Mockito.when; /** * Task 10:为 chunk 结果建立按 row key 的批量索引,消除跨 chunk 线性扫描。 - * indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKey,coze 行归属从 - * O(rows×chunks) 降为 O(1) 查找;assignCozeRowsToChunks 基于索引分配行并保留 + * indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKey,llm 行归属从 + * O(rows×chunks) 降为 O(1) 查找;assignLlmRowsToChunks 基于索引分配行并保留 * 原有命中/fallback/orphan 语义;集成用例验证每个 chunk 只读一次 payload。 */ @ExtendWith(MockitoExtension.class) @@ -70,7 +69,6 @@ class SimilarAsinTaskServiceRowKeyIndexTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService; @@ -80,7 +78,6 @@ class SimilarAsinTaskServiceRowKeyIndexTest { @Mock private org.springframework.transaction.PlatformTransactionManager transactionManager; @Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService; @Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata; - @Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService; @Mock private SimilarAsinImageEmbedder imageEmbedder; @Mock private SimilarAsinImagePrefetchService imagePrefetchService; @@ -170,7 +167,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest { @Test void test_task_010_chunk_row_key_normal_default_path() throws Exception { - // 正常输入:2 个 chunk 各含行,coze 回传行按 rowKey 命中各自 chunk; + // 正常输入:2 个 chunk 各含行,llm 回传行按 rowKey 命中各自 chunk; // 每个 chunk 的 payload 只被读取一次(索引建立),消除跨 chunk 线性扫描。 List chunks = List.of( chunk(1L, "hashA", 1, "ptr:chunk-A"), @@ -191,12 +188,12 @@ class SimilarAsinTaskServiceRowKeyIndexTest { FileTaskEntity task = new FileTaskEntity(); task.setId(7004L); - List cozeRows = List.of(row("r1", "1", "B0A0000001", "英国"), row("r3", "3", "B0A0000003", "美国")); + List llmRows = List.of(row("r1", "1", "B0A0000001", "英国"), row("r3", "3", "B0A0000003", "美国")); - Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk", + Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk", FileTaskEntity.class, String.class, Integer.class, List.class, Map.class); merge.setAccessible(true); - merge.invoke(service, task, null, null, cozeRows, Map.of()); + merge.invoke(service, task, null, null, llmRows, Map.of()); verify(transientPayloadStorageService, times(6)).resolvePayload(anyString(), anyString()); verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()); @@ -205,7 +202,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest { @Test void test_task_010_chunk_row_key_normal_multiple_items() { - // 批量场景:3 个 chunk 各 3 行,9 个 coze 回传行全部命中且顺序稳定,无 orphan + // 批量场景:3 个 chunk 各 3 行,9 个 llm 回传行全部命中且顺序稳定,无 orphan Map> rowsByChunk = new LinkedHashMap<>(); for (int c = 0; c < 3; c++) { rowsByChunk.putAll(rowsByChunkOf("hash" + c, c + 1, @@ -213,16 +210,16 @@ class SimilarAsinTaskServiceRowKeyIndexTest { row("c" + c + "r2", "2", "B0B" + c + "000002", "英国"), row("c" + c + "r3", "3", "B0B" + c + "000003", "美国")))); } - List cozeRows = new ArrayList<>(); + List llmRows = new ArrayList<>(); for (int c = 0; c < 3; c++) { for (int r = 1; r <= 3; r++) { - cozeRows.add(row("c" + c + "r" + r, String.valueOf(r), "B0B" + c + "00000" + r, r == 3 ? "美国" : "英国")); + llmRows.add(row("c" + c + "r" + r, String.valueOf(r), "B0B" + c + "00000" + r, r == 3 ? "美国" : "英国")); } } List orphans = new ArrayList<>(); Map index = service.indexRowsByChunkKey(rowsByChunk); - Map> merged = service.assignCozeRowsToChunks( - rowsByChunk, cozeRows, index, null, null, orphans); + Map> merged = service.assignLlmRowsToChunks( + rowsByChunk, llmRows, index, null, null, orphans); assertEquals(3, merged.size()); assertEquals(9, assignedRowKeys(merged).size()); assertTrue(orphans.isEmpty(), "全部命中,不应产生 orphan"); @@ -236,12 +233,12 @@ class SimilarAsinTaskServiceRowKeyIndexTest { // 重复执行同一输入:结果完全一致,不产生重复记录 Map> rowsByChunk = rowsByChunkOf("hashA", 1, List.of(row("r1", "1", "B0A0000001", "英国"), row("r2", "2", "B0A0000002", "英国"))); - List cozeRows = List.of(row("r1", "1", "B0A0000001", "英国")); + List llmRows = List.of(row("r1", "1", "B0A0000001", "英国")); Map index = service.indexRowsByChunkKey(rowsByChunk); - Map> first = service.assignCozeRowsToChunks( - rowsByChunk, cozeRows, index, null, null, new ArrayList<>()); - Map> second = service.assignCozeRowsToChunks( - rowsByChunk, cozeRows, index, null, null, new ArrayList<>()); + Map> first = service.assignLlmRowsToChunks( + rowsByChunk, llmRows, index, null, null, new ArrayList<>()); + Map> second = service.assignLlmRowsToChunks( + rowsByChunk, llmRows, index, null, null, new ArrayList<>()); assertEquals(assignedRowKeys(first), assignedRowKeys(second)); assertEquals(first.size(), second.size()); for (Map.Entry> entry : first.entrySet()) { @@ -251,21 +248,21 @@ class SimilarAsinTaskServiceRowKeyIndexTest { @Test void test_task_010_chunk_row_key_boundary_empty_input() { - // 空输入:null/空 rowsByChunk 与 cozeRows 均安全返回空结果,不创建无效资源 + // 空输入:null/空 rowsByChunk 与 llmRows 均安全返回空结果,不创建无效资源 assertNotNull(service.indexRowsByChunkKey(null)); assertTrue(service.indexRowsByChunkKey(null).isEmpty()); assertTrue(service.indexRowsByChunkKey(Map.of()).isEmpty()); - Map> emptyAssign = service.assignCozeRowsToChunks( + Map> emptyAssign = service.assignLlmRowsToChunks( Map.of(), List.of(), Map.of(), null, null, new ArrayList<>()); assertTrue(emptyAssign.isEmpty()); - assertTrue(service.assignCozeRowsToChunks( + assertTrue(service.assignLlmRowsToChunks( Map.of(), null, Map.of(), null, null, new ArrayList<>()).isEmpty()); // 无可匹配行(rowKey 不存在于任何 chunk)→ 进 orphan 兜底,不产生 merge Map> rowsByChunk = rowsByChunkOf("hashA", 1, List.of(row("r1", "1", "B0A0000001", "英国"))); List blankRow = List.of(row("", "", "", "")); List orphans = new ArrayList<>(); Map index = service.indexRowsByChunkKey(rowsByChunk); - Map> merged = service.assignCozeRowsToChunks( + Map> merged = service.assignLlmRowsToChunks( rowsByChunk, blankRow, index, null, null, orphans); assertTrue(assignedRowKeys(merged).isEmpty()); assertEquals(1, orphans.size(), "全空行生成 legacy key :::: 不命中任何 chunk,按既有语义进 orphan"); @@ -278,7 +275,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest { List.of(row("r1", "1", "B0A0000001", "英国"))); List orphans = new ArrayList<>(); Map index = service.indexRowsByChunkKey(rowsByChunk); - Map> merged = service.assignCozeRowsToChunks( + Map> merged = service.assignLlmRowsToChunks( rowsByChunk, List.of(row("r1", "1", "B0A0000001", "英国")), index, null, null, orphans); assertEquals(1, merged.size()); assertEquals(List.of("r1"), assignedRowKeys(merged)); @@ -290,7 +287,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest { @Test void test_task_010_chunk_row_key_boundary_limit_and_overflow() { - // 大批量:1000 行索引 + 500 个 coze 回传行全部命中,行不丢、无 orphan + // 大批量:1000 行索引 + 500 个 llm 回传行全部命中,行不丢、无 orphan Map> rowsByChunk = new LinkedHashMap<>(); Map bigChunk = new LinkedHashMap<>(); for (int i = 1; i <= 1000; i++) { @@ -299,13 +296,13 @@ class SimilarAsinTaskServiceRowKeyIndexTest { rowsByChunk.put("hashBig:1", bigChunk); Map index = service.indexRowsByChunkKey(rowsByChunk); assertEquals(1000, index.size()); - List cozeRows = new ArrayList<>(); + List llmRows = new ArrayList<>(); for (int i = 1; i <= 500; i++) { - cozeRows.add(row("r" + String.format("%04d", i), String.valueOf(i), "B0L" + String.format("%06d", i), "英国")); + llmRows.add(row("r" + String.format("%04d", i), String.valueOf(i), "B0L" + String.format("%06d", i), "英国")); } List orphans = new ArrayList<>(); - Map> merged = service.assignCozeRowsToChunks( - rowsByChunk, cozeRows, index, null, null, orphans); + Map> merged = service.assignLlmRowsToChunks( + rowsByChunk, llmRows, index, null, null, orphans); assertEquals(1, merged.size()); assertEquals(500, assignedRowKeys(merged).size()); assertTrue(orphans.isEmpty()); @@ -319,20 +316,20 @@ class SimilarAsinTaskServiceRowKeyIndexTest { rowsByChunk.putAll(rowsByChunkOf("hashB", 2, List.of(row("dup", "1", "B0A0000001", "英国")))); Map index = service.indexRowsByChunkKey(rowsByChunk); assertEquals("hashA:1", index.get("dup"), "重复 rowKey 应保留第一个 chunk"); - // fallback 缺失:coze 行未命中且无有效 fallback → 进 orphan,不产生 merge + // fallback 缺失:llm 行未命中且无有效 fallback → 进 orphan,不产生 merge List orphans = new ArrayList<>(); - Map> merged = service.assignCozeRowsToChunks( + Map> merged = service.assignLlmRowsToChunks( rowsByChunk, List.of(row("ghost", "9", "B0A0000009", "英国")), Map.of(), "missingHash", 99, orphans); assertTrue(assignedRowKeys(merged).isEmpty()); assertEquals(1, orphans.size()); assertEquals("ghost", orphans.get(0).getRowToken()); - // cozeRows 含 null 元素:跳过不抛异常,其余行正常分配 + // llmRows 含 null 元素:跳过不抛异常,其余行正常分配 List withNull = new ArrayList<>(); withNull.add(null); withNull.add(row("dup", "1", "B0A0000001", "英国")); List orphans2 = new ArrayList<>(); Map index2 = service.indexRowsByChunkKey(rowsByChunk); - Map> merged2 = service.assignCozeRowsToChunks( + Map> merged2 = service.assignLlmRowsToChunks( rowsByChunk, withNull, index2, null, null, orphans2); assertEquals(1, merged2.size()); assertEquals(List.of("dup"), assignedRowKeys(merged2)); @@ -349,7 +346,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest { .thenThrow(new IllegalStateException("rustfs down")); FileTaskEntity task = new FileTaskEntity(); task.setId(7004L); - Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk", + Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk", FileTaskEntity.class, String.class, Integer.class, List.class, Map.class); merge.setAccessible(true); BusinessException ex = assertThrows(BusinessException.class, () -> { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceSubmitTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceSubmitTest.java index f2047b38..6ee9ea7b 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceSubmitTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceSubmitTest.java @@ -5,10 +5,8 @@ import com.nanri.aiimage.common.service.DistributedJobLockService; import com.nanri.aiimage.config.InstanceMetadata; import com.nanri.aiimage.config.SimilarAsinProperties; import com.nanri.aiimage.config.StorageProperties; -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.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto; @@ -82,7 +80,6 @@ class SimilarAsinTaskServiceSubmitTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private TaskFileJobService taskFileJobService; @@ -92,7 +89,6 @@ class SimilarAsinTaskServiceSubmitTest { @Mock private PlatformTransactionManager transactionManager; @Mock private DistributedJobLockService distributedJobLockService; @Mock private InstanceMetadata instanceMetadata; - @Mock private CozeCredentialPoolService cozeCredentialPoolService; @Mock private SimilarAsinImageEmbedder imageEmbedder; @Mock private SimilarAsinImagePrefetchService imagePrefetchService; @Mock private TransactionStatus transactionStatus; @@ -360,7 +356,7 @@ class SimilarAsinTaskServiceSubmitTest { scope.setScopeHash("existing-scope"); scope.setChunkTotal(1); scope.setCompleted(completed); - scope.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}"); + scope.setStateJson("{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}"); return scope; } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceWorkbookControlTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceWorkbookControlTest.java index 16081315..11fcb2c4 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceWorkbookControlTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceWorkbookControlTest.java @@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.SimilarAsinProperties; -import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient; import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto; @@ -66,7 +65,6 @@ class SimilarAsinTaskServiceWorkbookControlTest { @Mock private TaskChunkMapper taskChunkMapper; @Mock private SimilarAsinFilterConditionMapper filterConditionMapper; @Spy private ObjectMapper objectMapper = new ObjectMapper(); - @Mock private SimilarAsinCozeClient cozeClient; @Mock private SimilarAsinTaskCacheService taskCacheService; @Mock private SimilarAsinProperties properties; @Mock private TransientPayloadStorageService transientPayloadStorageService; diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/AppearancePatentResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/AppearancePatentResultFileJobHandlerTest.java new file mode 100644 index 00000000..3c3f02cb --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/AppearancePatentResultFileJobHandlerTest.java @@ -0,0 +1,79 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * AppearancePatentResultFileJobHandler 测试(任务 68,异步+offload)。 + * process 返回 Service 的 boolean(false=等待异步结果);cleanup→cleanupResultFileJob; + * supportsAsyncOffload()=true;isOwnerScoped()=true。 + */ +class AppearancePatentResultFileJobHandlerTest { + + private final AppearancePatentTaskService appearancePatentTaskService = + mock(AppearancePatentTaskService.class); + private final ResultFileJobHandler handler = new AppearancePatentResultFileJobHandler( + appearancePatentTaskService); + + @Test + void moduleType() { + assertEquals("APPEARANCE_PATENT", handler.moduleType()); + } + + @Test + void processBooleanTrue() { + TaskFileJobEntity job = new TaskFileJobEntity(); + when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true); + assertTrue(handler.process(job), "Service true 时 handler 返回 true"); + } + + @Test + void processBooleanFalse() { + TaskFileJobEntity job = new TaskFileJobEntity(); + when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false); + assertFalse(handler.process(job), "Service false(等待异步)时 handler 返回 false"); + } + + @Test + void cleanupDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(1L); + handler.cleanup(job); + org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(job); + } + + @Test + void offloadTrue() { + assertTrue(handler.supportsAsyncOffload(), "APPEARANCE_PATENT 支持异步 offload"); + } + + @Test + void ownerScopedTrue() { + assertTrue(handler.isOwnerScoped(), "APPEARANCE_PATENT 为 owner scoped"); + } + + @Test + void falseMeansWaiting() { + // false 语义与 Worker defer 对应:process 返回 false 时不 markSuccess,由异步结果回写 + TaskFileJobEntity job = new TaskFileJobEntity(); + when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false); + assertFalse(handler.process(job)); + when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true); + assertTrue(handler.process(job), "异步完成后再次处理返回 true"); + } + + @Test + void nullJobGuarded() { + when(appearancePatentTaskService.processResultFileJob(null)).thenReturn(true); + assertTrue(handler.process(null)); + handler.cleanup(null); + org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(null); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/BrandResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/BrandResultFileJobHandlerTest.java new file mode 100644 index 00000000..8bec51f2 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/BrandResultFileJobHandlerTest.java @@ -0,0 +1,101 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * BrandResultFileJobHandler 测试(任务 71)。 + * BRAND 的 resolveResultObjectKey 特例(无 resultId 也走)保留在 Worker 公共路径 resolveResultFileUrl, + * Handler 不接管 URL 解析;cleanup 为空(原 cleanupAfterSuccess 无 BRAND 分支,不走 payload.deleteLatest)。 + */ +class BrandResultFileJobHandlerTest { + + private final BrandTaskService brandTaskService = mock(BrandTaskService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new BrandResultFileJobHandler( + brandTaskService, taskResultPayloadService); + + private static String workerSource() throws IOException { + Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules", + "task", "service", "TaskResultFileJobWorker.java"); + return new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + } + + @Test + void moduleType() { + assertEquals("BRAND", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(brandTaskService).processResultFileJob(job); + } + + @Test + void resultUrlBrandSpecial() throws IOException { + // Handler 不接管 URL 解析:无 resolveResultObjectKey / resolveResultFileUrl 方法 + for (java.lang.reflect.Method method : handler.getClass().getDeclaredMethods()) { + assertFalse(method.getName().contains("resolve"), + "Handler 不接管 URL 解析:" + method.getName()); + } + // 特例仍留在 Worker 公共路径 resolveResultFileUrl(含 BRAND 分支,无 resultId 也走) + String source = workerSource(); + assertTrue(source.contains("resolveResultFileUrl"), "Worker 保留 resolveResultFileUrl 公共路径"); + assertTrue(source.contains("resolveResultObjectKey"), "Worker 保留 BRAND 的 resolveResultObjectKey 特例"); + assertTrue(source.contains("\"BRAND\"") || source.contains("BRAND"), + "Worker resolveResultFileUrl 含 BRAND 分支"); + } + + @Test + void cleanupNoop() { + // 原 cleanupAfterSuccess 无 BRAND 分支:cleanup 空操作 + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(1L); + job.setModuleType("BRAND"); + job.setScopeKey("s"); + handler.cleanup(job); + verify(taskResultPayloadService, never()).deleteLatest(1L, "BRAND", "s"); + } + + @Test + void offloadFalse() { + assertFalse(handler.supportsAsyncOffload()); + } + + @Test + void ownerScopedFalse() { + assertFalse(handler.isOwnerScoped()); + } + + @Test + void nullJobGuarded() { + assertTrue(handler.process(null)); + verify(brandTaskService).processResultFileJob(null); + handler.cleanup(null); + } + + @Test + void noDeleteLatest() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(2L); + job.setModuleType("BRAND"); + handler.cleanup(job); + verify(taskResultPayloadService, never()).deleteLatest(2L, "BRAND", null); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/CollectDataResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/CollectDataResultFileJobHandlerTest.java new file mode 100644 index 00000000..f0e0087e --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/CollectDataResultFileJobHandlerTest.java @@ -0,0 +1,76 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * CollectDataResultFileJobHandler 测试(任务 72)。 + * cleanup 为空(原 cleanupAfterSuccess 无 COLLECT_DATA 分支)。 + */ +class CollectDataResultFileJobHandlerTest { + + private final CollectDataService collectDataService = mock(CollectDataService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new CollectDataResultFileJobHandler(collectDataService); + + @Test + void moduleType() { + assertEquals("COLLECT_DATA", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(collectDataService).processResultFileJob(job); + } + + @Test + void processTrue() { + assertTrue(handler.process(new TaskFileJobEntity())); + } + + @Test + void cleanupNoop() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(1L); + job.setModuleType("COLLECT_DATA"); + job.setScopeKey("s"); + handler.cleanup(job); + } + + @Test + void offloadFalse() { + assertFalse(handler.supportsAsyncOffload()); + } + + @Test + void ownerScopedFalse() { + assertFalse(handler.isOwnerScoped()); + } + + @Test + void nullJobGuarded() { + assertTrue(handler.process(null)); + verify(collectDataService).processResultFileJob(null); + handler.cleanup(null); + } + + @Test + void noDeleteLatest() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(2L); + job.setModuleType("COLLECT_DATA"); + job.setScopeKey("k"); + handler.cleanup(job); + verify(taskResultPayloadService, never()).deleteLatest(2L, "COLLECT_DATA", "k"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/DeleteBrandResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/DeleteBrandResultFileJobHandlerTest.java new file mode 100644 index 00000000..668e9d51 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/DeleteBrandResultFileJobHandlerTest.java @@ -0,0 +1,73 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * DeleteBrandResultFileJobHandler 测试(任务 70)。 + */ +class DeleteBrandResultFileJobHandlerTest { + + private final DeleteBrandRunService deleteBrandRunService = mock(DeleteBrandRunService.class); + private final ResultFileJobHandler handler = new DeleteBrandResultFileJobHandler(deleteBrandRunService); + + @Test + void moduleType() { + assertEquals("DELETE_BRAND", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(deleteBrandRunService).processResultFileJob(job); + } + + @Test + void processReturnsTrue() { + assertTrue(handler.process(new TaskFileJobEntity())); + } + + @Test + void cleanupDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(1L); + job.setModuleType("DELETE_BRAND"); + job.setScopeKey("s"); + handler.cleanup(job); + verify(deleteBrandRunService).cleanupResultFileJob(job); + } + + @Test + void cleanupArgs() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(7L); + handler.cleanup(job); + verify(deleteBrandRunService).cleanupResultFileJob(job); + } + + @Test + void offloadFalse() { + assertFalse(handler.supportsAsyncOffload()); + } + + @Test + void ownerScopedFalse() { + assertFalse(handler.isOwnerScoped()); + } + + @Test + void nullJobGuarded() { + assertTrue(handler.process(null)); + verify(deleteBrandRunService).processResultFileJob(null); + handler.cleanup(null); + verify(deleteBrandRunService).cleanupResultFileJob(null); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PatrolDeleteResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PatrolDeleteResultFileJobHandlerTest.java new file mode 100644 index 00000000..12823dc9 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PatrolDeleteResultFileJobHandlerTest.java @@ -0,0 +1,75 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * PatrolDeleteResultFileJobHandler 测试(任务 67,与 60 同构)。 + */ +class PatrolDeleteResultFileJobHandlerTest { + + private final PatrolDeleteTaskService patrolDeleteTaskService = mock(PatrolDeleteTaskService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new PatrolDeleteResultFileJobHandler( + patrolDeleteTaskService, taskResultPayloadService); + + @Test + void moduleType() { + assertEquals("PATROL_DELETE", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(patrolDeleteTaskService).processResultFileJob(job); + } + + @Test + void processReturnsTrue() { + assertTrue(handler.process(new TaskFileJobEntity())); + } + + @Test + void cleanupCallsDeleteLatest() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(42L); + job.setModuleType("PATROL_DELETE"); + job.setScopeKey("scope-1"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(42L, "PATROL_DELETE", "scope-1"); + } + + @Test + void cleanupArgs() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(7L); + job.setModuleType("PATROL_DELETE"); + job.setScopeKey("u:99"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(7L, "PATROL_DELETE", "u:99"); + } + + @Test + void offloadFalse() { + assertFalse(handler.supportsAsyncOffload()); + } + + @Test + void ownerScopedFalse() { + assertFalse(handler.isOwnerScoped()); + } + + @Test + void processNullJobGuarded() { + assertTrue(handler.process(null)); + verify(patrolDeleteTaskService).processResultFileJob(null); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PriceTrackResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PriceTrackResultFileJobHandlerTest.java new file mode 100644 index 00000000..15772adf --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PriceTrackResultFileJobHandlerTest.java @@ -0,0 +1,75 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * PriceTrackResultFileJobHandler 测试(任务 61,与 60 同构)。 + */ +class PriceTrackResultFileJobHandlerTest { + + private final PriceTrackTaskService priceTrackTaskService = mock(PriceTrackTaskService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new PriceTrackResultFileJobHandler( + priceTrackTaskService, taskResultPayloadService); + + @Test + void moduleType() { + assertEquals("PRICE_TRACK", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(priceTrackTaskService).processResultFileJob(job); + } + + @Test + void processReturnsTrue() { + assertTrue(handler.process(new TaskFileJobEntity())); + } + + @Test + void cleanupCallsDeleteLatest() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(42L); + job.setModuleType("PRICE_TRACK"); + job.setScopeKey("scope-1"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(42L, "PRICE_TRACK", "scope-1"); + } + + @Test + void cleanupArgs() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(7L); + job.setModuleType("PRICE_TRACK"); + job.setScopeKey("u:99"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(7L, "PRICE_TRACK", "u:99"); + } + + @Test + void offloadFalse() { + assertFalse(handler.supportsAsyncOffload()); + } + + @Test + void ownerScopedFalse() { + assertFalse(handler.isOwnerScoped()); + } + + @Test + void processNullJobGuarded() { + assertTrue(handler.process(null)); + verify(priceTrackTaskService).processResultFileJob(null); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ProductRiskResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ProductRiskResultFileJobHandlerTest.java new file mode 100644 index 00000000..18c25360 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ProductRiskResultFileJobHandlerTest.java @@ -0,0 +1,75 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * ProductRiskResultFileJobHandler 测试(任务 62,与 60/61 同构)。 + */ +class ProductRiskResultFileJobHandlerTest { + + private final ProductRiskTaskService productRiskTaskService = mock(ProductRiskTaskService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new ProductRiskResultFileJobHandler( + productRiskTaskService, taskResultPayloadService); + + @Test + void moduleType() { + assertEquals("PRODUCT_RISK_RESOLVE", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(productRiskTaskService).processResultFileJob(job); + } + + @Test + void processReturnsTrue() { + assertTrue(handler.process(new TaskFileJobEntity())); + } + + @Test + void cleanupCallsDeleteLatest() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(42L); + job.setModuleType("PRODUCT_RISK_RESOLVE"); + job.setScopeKey("scope-1"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(42L, "PRODUCT_RISK_RESOLVE", "scope-1"); + } + + @Test + void cleanupArgs() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(7L); + job.setModuleType("PRODUCT_RISK_RESOLVE"); + job.setScopeKey("u:99"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(7L, "PRODUCT_RISK_RESOLVE", "u:99"); + } + + @Test + void offloadFalse() { + assertFalse(handler.supportsAsyncOffload()); + } + + @Test + void ownerScopedFalse() { + assertFalse(handler.isOwnerScoped()); + } + + @Test + void processNullJobGuarded() { + assertTrue(handler.process(null)); + verify(productRiskTaskService).processResultFileJob(null); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PublishResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PublishResultFileJobHandlerTest.java new file mode 100644 index 00000000..6577145a --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/PublishResultFileJobHandlerTest.java @@ -0,0 +1,79 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * PublishResultFileJobHandler 测试(任务 63,ownerScoped)。 + * moduleType 用 PublishTaskService.MODULE_TYPE 常量; + * cleanup 走 publishTaskService.cleanupResultFileJob,不走 payload.deleteLatest; + * isOwnerScoped()=true(原 Worker isOwnerScopedJob 含 PUBLISH)。 + */ +class PublishResultFileJobHandlerTest { + + private final PublishTaskService publishTaskService = mock(PublishTaskService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new PublishResultFileJobHandler(publishTaskService); + + @Test + void moduleTypeConstant() { + assertEquals(PublishTaskService.MODULE_TYPE, handler.moduleType(), "moduleType 用常量"); + assertEquals("PUBLISH", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(publishTaskService).processResultFileJob(job); + } + + @Test + void cleanupDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(1L); + job.setModuleType("PUBLISH"); + job.setScopeKey("s"); + handler.cleanup(job); + verify(publishTaskService).cleanupResultFileJob(job); + } + + @Test + void ownerScopedTrue() { + assertTrue(handler.isOwnerScoped(), "PUBLISH 为 owner scoped"); + } + + @Test + void processTrue() { + assertTrue(handler.process(new TaskFileJobEntity())); + } + + @Test + void cleanupNoDeleteLatest() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(1L); + job.setModuleType("PUBLISH"); + job.setScopeKey("s"); + handler.cleanup(job); + verify(taskResultPayloadService, never()).deleteLatest(1L, "PUBLISH", "s"); + } + + @Test + void offloadFalse() { + assertFalse(handler.supportsAsyncOffload()); + } + + @Test + void nullJobGuarded() { + assertTrue(handler.process(null)); + verify(publishTaskService).processResultFileJob(null); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/QueryAsinResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/QueryAsinResultFileJobHandlerTest.java new file mode 100644 index 00000000..8d648b02 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/QueryAsinResultFileJobHandlerTest.java @@ -0,0 +1,75 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * QueryAsinResultFileJobHandler 测试(任务 64,与 60 同构)。 + */ +class QueryAsinResultFileJobHandlerTest { + + private final QueryAsinTaskService queryAsinTaskService = mock(QueryAsinTaskService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new QueryAsinResultFileJobHandler( + queryAsinTaskService, taskResultPayloadService); + + @Test + void moduleType() { + assertEquals("QUERY_ASIN", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(queryAsinTaskService).processResultFileJob(job); + } + + @Test + void processReturnsTrue() { + assertTrue(handler.process(new TaskFileJobEntity())); + } + + @Test + void cleanupCallsDeleteLatest() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(42L); + job.setModuleType("QUERY_ASIN"); + job.setScopeKey("scope-1"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(42L, "QUERY_ASIN", "scope-1"); + } + + @Test + void cleanupArgs() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(7L); + job.setModuleType("QUERY_ASIN"); + job.setScopeKey("u:99"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(7L, "QUERY_ASIN", "u:99"); + } + + @Test + void offloadFalse() { + assertFalse(handler.supportsAsyncOffload()); + } + + @Test + void ownerScopedFalse() { + assertFalse(handler.isOwnerScoped()); + } + + @Test + void processNullJobGuarded() { + assertTrue(handler.process(null)); + verify(queryAsinTaskService).processResultFileJob(null); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerMappingSnapshotTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerMappingSnapshotTest.java new file mode 100644 index 00000000..b66e54e3 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerMappingSnapshotTest.java @@ -0,0 +1,189 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +/** + * 模块映射快照测试(任务 78)。 + * 与 04 spec §3 映射表一一对应:13 个 moduleType → Handler 类 → 原 Service 方法; + * 新增模块类型未映射即失败(快照严格性)。 + */ +class ResultFileJobHandlerMappingSnapshotTest { + + private static final List EXPECTED_MODULE_TYPES = List.of( + "SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN", + "SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT", + "SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA"); + + private static ResultFileJobHandlerRegistry buildRegistry() { + TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + List handlers = List.of( + new ShopMatchResultFileJobHandler(mock(ShopMatchTaskService.class), payload), + new PriceTrackResultFileJobHandler(mock(PriceTrackTaskService.class), payload), + new ProductRiskResultFileJobHandler(mock(ProductRiskTaskService.class), payload), + new PublishResultFileJobHandler(mock(PublishTaskService.class)), + new QueryAsinResultFileJobHandler(mock(QueryAsinTaskService.class), payload), + new ShopDataCrawlResultFileJobHandler(mock(ShopDataCrawlTaskService.class), payload), + new WithdrawResultFileJobHandler(mock(WithdrawTaskService.class), payload), + new PatrolDeleteResultFileJobHandler(mock(PatrolDeleteTaskService.class), payload), + new AppearancePatentResultFileJobHandler(mock(AppearancePatentTaskService.class)), + new SimilarAsinResultFileJobHandler(mock(SimilarAsinTaskService.class)), + new DeleteBrandResultFileJobHandler(mock(DeleteBrandRunService.class)), + new BrandResultFileJobHandler(mock(BrandTaskService.class), payload), + new CollectDataResultFileJobHandler(mock(CollectDataService.class))); + return new ResultFileJobHandlerRegistry(handlers); + } + + @Test + void snapshotAll13Mapped() { + ResultFileJobHandlerRegistry registry = buildRegistry(); + assertEquals(13, registry.moduleTypes().size(), "恰好 13 个模块"); + assertEquals(Set.copyOf(EXPECTED_MODULE_TYPES), Set.copyOf(registry.moduleTypes()), + "moduleType 集合与 spec §3 一致"); + } + + @Test + void snapshotHandlerClasses() { + ResultFileJobHandlerRegistry registry = buildRegistry(); + assertTrue(registry.resolve("SHOP_MATCH") instanceof ShopMatchResultFileJobHandler); + assertTrue(registry.resolve("PRICE_TRACK") instanceof PriceTrackResultFileJobHandler); + assertTrue(registry.resolve("PRODUCT_RISK_RESOLVE") instanceof ProductRiskResultFileJobHandler); + assertTrue(registry.resolve("PUBLISH") instanceof PublishResultFileJobHandler); + assertTrue(registry.resolve("QUERY_ASIN") instanceof QueryAsinResultFileJobHandler); + assertTrue(registry.resolve("SHOP_DATA_CRAWL") instanceof ShopDataCrawlResultFileJobHandler); + assertTrue(registry.resolve("WITHDRAW") instanceof WithdrawResultFileJobHandler); + assertTrue(registry.resolve("PATROL_DELETE") instanceof PatrolDeleteResultFileJobHandler); + assertTrue(registry.resolve("APPEARANCE_PATENT") instanceof AppearancePatentResultFileJobHandler); + assertTrue(registry.resolve("SIMILAR_ASIN") instanceof SimilarAsinResultFileJobHandler); + assertTrue(registry.resolve("DELETE_BRAND") instanceof DeleteBrandResultFileJobHandler); + assertTrue(registry.resolve("BRAND") instanceof BrandResultFileJobHandler); + assertTrue(registry.resolve("COLLECT_DATA") instanceof CollectDataResultFileJobHandler); + } + + @Test + void snapshotShopMatchDelegate() throws Exception { + Method process = ShopMatchResultFileJobHandler.class.getDeclaredMethod("process", TaskFileJobEntity.class); + assertTrue(process.getReturnType() == boolean.class, "process 返回 boolean"); + String source = handlerSource("ShopMatchResultFileJobHandler"); + assertTrue(source.contains("shopMatchTaskService.processResultFileJob(job)"), "委托原 Service 方法"); + assertTrue(source.contains("taskResultPayloadService.deleteLatest"), "cleanup 走 payload.deleteLatest"); + } + + @Test + void snapshotAppearanceBoolean() throws Exception { + Method process = AppearancePatentResultFileJobHandler.class.getDeclaredMethod("process", TaskFileJobEntity.class); + assertTrue(process.getReturnType() == boolean.class, "异步模块返回 boolean"); + String source = handlerSource("AppearancePatentResultFileJobHandler"); + assertTrue(source.contains("return appearancePatentTaskService.processResultFileJob(job)"), + "原样透传 Service boolean"); + assertTrue(source.contains("supportsAsyncOffload()") && source.contains("return true;"), + "offload 标记为 true"); + } + + @Test + void snapshotPublishOwner() { + ResultFileJobHandler handler = buildRegistry().resolve("PUBLISH"); + assertTrue(handler.isOwnerScoped(), "PUBLISH owner scoped"); + String source = handlerSource("PublishResultFileJobHandler"); + assertTrue(source.contains("publishTaskService.cleanupResultFileJob(job)"), + "cleanup 走服务内 cleanupResultFileJob,不走 payload.deleteLatest"); + assertFalse(source.contains("taskResultPayloadService"), + "PUBLISH Handler 无 payload 依赖"); + } + + @Test + void snapshotFailureCallbacks() { + ResultFileJobHandlerRegistry registry = buildRegistry(); + String shopDataCrawl = handlerSource("ShopDataCrawlResultFileJobHandler"); + assertTrue(shopDataCrawl.contains("handleResultFileJobFailure"), "SHOP_DATA_CRAWL onFailure 回调"); + String similar = handlerSource("SimilarAsinResultFileJobHandler"); + assertTrue(similar.contains("handleResultFileJobFailure"), "SIMILAR_ASIN onFailure 回调"); + String withdraw = handlerSource("WithdrawResultFileJobHandler"); + assertFalse(withdraw.contains("handleResultFileJobFailure"), "WITHDRAW 无 onFailure"); + String shopMatch = handlerSource("ShopMatchResultFileJobHandler"); + assertFalse(shopMatch.contains("handleResultFileJobFailure"), "SHOP_MATCH 无 onFailure"); + } + + @Test + void snapshotCleanupMapping() { + ResultFileJobHandlerRegistry registry = buildRegistry(); + String shopDataCrawl = handlerSource("ShopDataCrawlResultFileJobHandler"); + assertTrue(shopDataCrawl.contains("deleteLatest") && shopDataCrawl.contains("cleanupResultFileJob"), + "SHOP_DATA_CRAWL 两处清理"); + String withdraw = handlerSource("WithdrawResultFileJobHandler"); + assertTrue(withdraw.contains("deleteLatest"), "WITHDRAW cleanup deleteLatest"); + String collectData = handlerSource("CollectDataResultFileJobHandler"); + assertTrue(!collectData.contains("deleteLatest") && !collectData.contains("cleanupResultFileJob"), + "COLLECT_DATA 无清理"); + } + + @Test + void snapshotNoExtraModules() { + ResultFileJobHandlerRegistry registry = buildRegistry(); + assertEquals(EXPECTED_MODULE_TYPES.size(), registry.moduleTypes().size()); + for (String moduleType : registry.moduleTypes()) { + assertTrue(EXPECTED_MODULE_TYPES.contains(moduleType), "无多余映射:" + moduleType); + } + } + + @Test + void snapshotMappingFrozen() throws IOException { + ResultFileJobHandlerRegistry registry = buildRegistry(); + List snapshot = new ArrayList<>(registry.moduleTypes()); + snapshot.sort(Comparator.naturalOrder()); + String actual = String.join(",", snapshot); + String expectedFrozen = "APPEARANCE_PATENT,BRAND,COLLECT_DATA,DELETE_BRAND,PATROL_DELETE,PRICE_TRACK," + + "PRODUCT_RISK_RESOLVE,PUBLISH,QUERY_ASIN,SHOP_DATA_CRAWL,SHOP_MATCH,SIMILAR_ASIN,WITHDRAW"; + assertEquals(expectedFrozen, actual, "模块映射快照冻结"); + } + + @Test + void snapshotNewModuleFails() { + ResultFileJobHandlerRegistry registry = buildRegistry(); + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> registry.resolve("NEW_MODULE"), "新 moduleType 未映射抛错"); + assertTrue(ex.getMessage().contains("NEW_MODULE"), "消息含新模块名"); + assertTrue(registry.asMap().get("NEW_MODULE") == null, "asMap 无新模块"); + } + + private static String handlerSource(String simpleName) { + Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules", + "task", "service", simpleName + ".java"); + try { + return new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException("读不到源码:" + source, e); + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistryStartupTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistryStartupTest.java new file mode 100644 index 00000000..a5da88f4 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistryStartupTest.java @@ -0,0 +1,128 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.config.TaskFileJobConfig; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 启动 fail-fast 校验测试(任务 59)。 + * 覆盖:13 个模块全部有 Handler、缺注册失败、错误消息列缺失清单、 + * 双注册失败、配置源来自 TaskFileJobConfig、新增模块无 Handler 失败、 + * 合法配置通过、校验可重复执行。 + */ +class ResultFileJobHandlerRegistryStartupTest { + + private static final Set ALL_13 = TaskFileJobConfig.RESULT_FILE_JOB_MODULE_TYPES; + + private static ResultFileJobHandler handler(String moduleType) { + return new ResultFileJobHandler() { + @Override + public String moduleType() { + return moduleType; + } + + @Override + public boolean process(TaskFileJobEntity job) { + return true; + } + }; + } + + private static ResultFileJobHandlerRegistry registryOf(Collection types) { + List handlers = new ArrayList<>(); + for (String type : types) { + handlers.add(handler(type)); + } + return new ResultFileJobHandlerRegistry(handlers); + } + + @Test + void startupAllModulesCovered() { + ResultFileJobHandlerRegistry registry = registryOf(ALL_13); + assertDoesNotThrow(() -> registry.validateCoverage(ALL_13), + "13 个模块全部注册时启动校验通过"); + } + + @Test + void startupMissingHandlerFails() { + Set missingOne = new java.util.HashSet<>(ALL_13); + missingOne.remove("SHOP_MATCH"); + ResultFileJobHandlerRegistry registry = registryOf(missingOne); + assertThrows(IllegalStateException.class, () -> registry.validateCoverage(ALL_13), + "缺 SHOP_MATCH Handler 时启动校验失败"); + } + + @Test + void startupMissingMessageLists() { + Set missingTwo = new java.util.HashSet<>(ALL_13); + missingTwo.remove("SHOP_MATCH"); + missingTwo.remove("WITHDRAW"); + ResultFileJobHandlerRegistry registry = registryOf(missingTwo); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> registry.validateCoverage(ALL_13)); + assertTrue(ex.getMessage().contains("SHOP_MATCH"), "消息列缺失模块 SHOP_MATCH:" + ex.getMessage()); + assertTrue(ex.getMessage().contains("WITHDRAW"), "消息列缺失模块 WITHDRAW:" + ex.getMessage()); + } + + @Test + void startupDuplicateFails() { + List handlers = new ArrayList<>(); + handlers.add(handler("SHOP_MATCH")); + handlers.add(handler("SHOP_MATCH")); + assertThrows(IllegalStateException.class, () -> new ResultFileJobHandlerRegistry(handlers), + "双注册在构造时即失败"); + } + + @Test + void startupConfigSource() { + assertEquals(13, ALL_13.size(), "TaskFileJobConfig 枚举 13 个模块"); + assertTrue(ALL_13.containsAll(List.of( + "SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "QUERY_ASIN", + "SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT", + "SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA")), + "配置源含全部 12 个常量模块"); + assertTrue(ALL_13.contains(PublishTaskService.MODULE_TYPE), + "PUBLISH 取自 PublishTaskService.MODULE_TYPE 常量"); + } + + @Test + void startupNoHandlerNewModule() { + ResultFileJobHandlerRegistry registry = registryOf(ALL_13); + Set withNew = new java.util.HashSet<>(ALL_13); + withNew.add("NEW_MODULE_X"); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> registry.validateCoverage(withNew)); + assertTrue(ex.getMessage().contains("NEW_MODULE_X"), "新增模块无 Handler 失败:" + ex.getMessage()); + } + + @Test + void startupValidNoError() { + ResultFileJobHandlerRegistry registry = registryOf(ALL_13); + assertDoesNotThrow(() -> registry.validateCoverage(ALL_13), "合法配置启动通过"); + assertEquals(ALL_13, registry.moduleTypes(), "注册表模块与配置源一致"); + } + + @Test + void startupCheckIdempotent() { + ResultFileJobHandlerRegistry registry = registryOf(ALL_13); + assertDoesNotThrow(() -> registry.validateCoverage(ALL_13)); + assertDoesNotThrow(() -> registry.validateCoverage(ALL_13), "第二次校验同样通过(无状态残留)"); + Set missingOne = new java.util.HashSet<>(ALL_13); + missingOne.remove("BRAND"); + ResultFileJobHandlerRegistry partial = registryOf(missingOne); + assertThrows(IllegalStateException.class, () -> partial.validateCoverage(ALL_13)); + assertThrows(IllegalStateException.class, () -> partial.validateCoverage(ALL_13), + "缺注册时重复校验稳定抛错"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistryTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistryTest.java new file mode 100644 index 00000000..6a2eab8d --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerRegistryTest.java @@ -0,0 +1,112 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * ResultFileJobHandlerRegistry 注册表测试(任务 58)。 + * 验证:全量收集、已知/未知模块解析、重复 moduleType 抛 IllegalStateException(消息含模块名)、 + * moduleTypes 枚举、空注册表行为、大小写敏感。 + */ +class ResultFileJobHandlerRegistryTest { + + private static ResultFileJobHandler handler(String moduleType) { + return new ResultFileJobHandler() { + @Override + public String moduleType() { + return moduleType; + } + + @Override + public boolean process(TaskFileJobEntity job) { + return true; + } + }; + } + + private static final ResultFileJobHandler SHOP = handler("SHOP_MATCH"); + private static final ResultFileJobHandler PRICE = handler("PRICE_TRACK"); + private static final ResultFileJobHandler PUBLISH = handler("PUBLISH"); + + @Test + void registerAllHandlers() { + ResultFileJobHandlerRegistry registry = + new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH)); + assertNotNull(registry.resolve("SHOP_MATCH"), "SHOP_MATCH 已注册"); + assertNotNull(registry.resolve("PRICE_TRACK"), "PRICE_TRACK 已注册"); + assertNotNull(registry.resolve("PUBLISH"), "PUBLISH 已注册"); + } + + @Test + void resolveExisting() { + ResultFileJobHandlerRegistry registry = + new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH)); + assertEquals(SHOP, registry.resolve("SHOP_MATCH"), "解析到同一 handler 实例"); + assertEquals(PRICE, registry.resolve("PRICE_TRACK"), "解析到同一 handler 实例"); + } + + @Test + void resolveUnknownThrows() { + ResultFileJobHandlerRegistry registry = + new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH)); + assertThrows(IllegalArgumentException.class, () -> registry.resolve("NOT_EXIST"), + "未知模块抛 IllegalArgumentException"); + } + + @Test + void duplicateModuleTypeThrows() { + ResultFileJobHandler duplicate = handler("SHOP_MATCH"); + assertThrows(IllegalStateException.class, + () -> new ResultFileJobHandlerRegistry(List.of(SHOP, duplicate)), + "重复 moduleType 抛 IllegalStateException"); + } + + @Test + void duplicateErrorMessageContainsModuleName() { + ResultFileJobHandler duplicate = handler("SHOP_MATCH"); + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> new ResultFileJobHandlerRegistry(List.of(SHOP, duplicate))); + assertTrue(ex.getMessage().contains("SHOP_MATCH"), "错误消息含模块名:" + ex.getMessage()); + assertTrue(ex.getMessage().contains("重复") || ex.getMessage().contains("duplicate"), + "错误消息含重复提示:" + ex.getMessage()); + } + + @Test + void moduleTypesListed() { + ResultFileJobHandlerRegistry registry = + new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH)); + Set types = registry.moduleTypes(); + assertEquals(3, types.size(), "枚举全部模块"); + assertTrue(types.containsAll(List.of("SHOP_MATCH", "PRICE_TRACK", "PUBLISH")), + "moduleTypes 覆盖全部注册模块"); + } + + @Test + void emptyRegistryResolveThrows() { + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(List.of()); + assertTrue(registry.moduleTypes().isEmpty(), "空注册表 moduleTypes 为空"); + assertThrows(IllegalArgumentException.class, () -> registry.resolve("ANY"), + "空注册表解析任何模块都抛异常"); + } + + @Test + void resolveCaseSensitive() { + ResultFileJobHandlerRegistry registry = + new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH)); + assertThrows(IllegalArgumentException.class, () -> registry.resolve("shop_match"), + "小写不命中(大小写敏感)"); + assertEquals(SHOP, registry.resolve("SHOP_MATCH"), "精确大小写命中"); + Map map = registry.asMap(); + assertFalse(map.containsKey("shop_match"), "map 键大小写敏感"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerTest.java new file mode 100644 index 00000000..8c623028 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ResultFileJobHandlerTest.java @@ -0,0 +1,127 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * ResultFileJobHandler 接口契约测试(任务 57)。 + * 反射校验 6 个方法签名;匿名实现验证默认实现语义: + * cleanup / onFailure 空操作、supportsAsyncOffload / isOwnerScoped 默认 false。 + */ +class ResultFileJobHandlerTest { + + private static ResultFileJobHandler blankHandler() { + return new ResultFileJobHandler() { + @Override + public String moduleType() { + return "TEST_MODULE"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + return true; + } + }; + } + + private static Method methodOf(Class type, String name, Class... params) throws NoSuchMethodException { + return type.getDeclaredMethod(name, params); + } + + @Test + void interfaceMethodsPresent() throws Exception { + assertEquals(7, ResultFileJobHandler.class.getDeclaredMethods().length, + "接口方法数量为 7(moduleType/process/onSuccess/cleanup/onFailure/supportsAsyncOffload/isOwnerScoped)"); + assertNotNull(methodOf(ResultFileJobHandler.class, "moduleType")); + assertNotNull(methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class)); + assertNotNull(methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class)); + assertNotNull(methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class)); + assertNotNull(methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class)); + assertNotNull(methodOf(ResultFileJobHandler.class, "supportsAsyncOffload")); + assertNotNull(methodOf(ResultFileJobHandler.class, "isOwnerScoped")); + } + + @Test + void onSuccessDefaultNoop() throws Exception { + Method method = methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class); + assertTrue(method.isDefault(), "onSuccess 为 default 方法"); + blankHandler().onSuccess(null); + } + + @Test + void cleanupDefaultNoop() throws Exception { + Method method = methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class); + assertTrue(method.isDefault(), "cleanup 为 default 方法"); + // 默认实现不抛错、无返回值 + blankHandler().cleanup(null); + } + + @Test + void onFailureDefaultNoop() throws Exception { + Method method = methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class); + assertTrue(method.isDefault(), "onFailure 为 default 方法"); + blankHandler().onFailure(null, "any message"); + } + + @Test + void offloadDefaultFalse() throws Exception { + Method method = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload"); + assertTrue(method.isDefault(), "supportsAsyncOffload 为 default 方法"); + assertFalse(blankHandler().supportsAsyncOffload(), "默认不支持异步 offload"); + } + + @Test + void ownerScopedDefaultFalse() throws Exception { + Method method = methodOf(ResultFileJobHandler.class, "isOwnerScoped"); + assertTrue(method.isDefault(), "isOwnerScoped 为 default 方法"); + assertFalse(blankHandler().isOwnerScoped(), "默认非 owner scoped"); + } + + @Test + void processReturnsBoolean() throws Exception { + Method method = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class); + assertEquals(boolean.class, method.getReturnType(), "process 返回 boolean"); + assertTrue(Modifier.isAbstract(method.getModifiers()), "process 为抽象方法"); + assertTrue(blankHandler().process(null), "实现类返回 true"); + } + + @Test + void moduleTypeIdentity() throws Exception { + Method method = methodOf(ResultFileJobHandler.class, "moduleType"); + assertEquals(String.class, method.getReturnType(), "moduleType 返回 String"); + assertTrue(Modifier.isAbstract(method.getModifiers()), "moduleType 为抽象方法"); + assertEquals("TEST_MODULE", blankHandler().moduleType(), "模块标识唯一"); + ResultFileJobHandler other = new ResultFileJobHandler() { + @Override + public String moduleType() { + return "OTHER_MODULE"; + } + + @Override + public boolean process(TaskFileJobEntity job) { + return true; + } + }; + assertEquals("OTHER_MODULE", other.moduleType(), "不同 Handler 不同标识"); + assertFalse("TEST_MODULE".equals(other.moduleType()), "标识不重复"); + } + + @Test + void javadocContract() throws Exception { + Method process = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class); + assertTrue(process.getDeclaredAnnotations().length >= 0, "process 可被标注"); + Method offload = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload"); + assertFalse(Modifier.isStatic(offload.getModifiers()), "非静态方法"); + // 契约注释齐全:接口 javadoc 存在 + String javadoc = ResultFileJobHandler.class.getSimpleName(); + assertNotNull(javadoc, "接口名存在"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ShopDataCrawlResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ShopDataCrawlResultFileJobHandlerTest.java new file mode 100644 index 00000000..70520a03 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ShopDataCrawlResultFileJobHandlerTest.java @@ -0,0 +1,80 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +/** + * ShopDataCrawlResultFileJobHandler 测试(任务 65)。 + * cleanup 两处都调(deleteLatest + cleanupResultFileJob); + * onFailure 委托 handleResultFileJobFailure(job, message);isOwnerScoped()=true。 + */ +class ShopDataCrawlResultFileJobHandlerTest { + + private final ShopDataCrawlTaskService shopDataCrawlTaskService = mock(ShopDataCrawlTaskService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new ShopDataCrawlResultFileJobHandler( + shopDataCrawlTaskService, taskResultPayloadService); + + @Test + void moduleType() { + assertEquals("SHOP_DATA_CRAWL", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(shopDataCrawlTaskService).processResultFileJob(job); + } + + @Test + void cleanupDeleteLatestAndService() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(42L); + job.setModuleType("SHOP_DATA_CRAWL"); + job.setScopeKey("scope-1"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(42L, "SHOP_DATA_CRAWL", "scope-1"); + verify(shopDataCrawlTaskService).cleanupResultFileJob(job); + } + + @Test + void onFailureDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.onFailure(job, "retry exhausted"); + verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "retry exhausted"); + } + + @Test + void onFailureArgs() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.onFailure(job, "自定义失败原因"); + verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "自定义失败原因"); + } + + @Test + void ownerScopedTrue() { + assertTrue(handler.isOwnerScoped(), "SHOP_DATA_CRAWL 为 owner scoped"); + } + + @Test + void processTrue() { + assertTrue(handler.process(new TaskFileJobEntity())); + } + + @Test + void nullJobGuarded() { + assertTrue(handler.process(null)); + verify(shopDataCrawlTaskService).processResultFileJob(null); + handler.onFailure(null, "msg"); + verify(shopDataCrawlTaskService).handleResultFileJobFailure(null, "msg"); + assertFalse(handler.supportsAsyncOffload(), "SHOP_DATA_CRAWL 不支持异步 offload"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ShopMatchResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ShopMatchResultFileJobHandlerTest.java new file mode 100644 index 00000000..1ae20201 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/ShopMatchResultFileJobHandlerTest.java @@ -0,0 +1,88 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.task.service.TaskResultPayloadService; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +/** + * ShopMatchResultFileJobHandler 测试(任务 60)。 + * process 委托 shopMatchTaskService.processResultFileJob 并返回 true; + * cleanup 调 taskResultPayloadService.deleteLatest(taskId, moduleType, scopeKey)。 + */ +class ShopMatchResultFileJobHandlerTest { + + private final ShopMatchTaskService shopMatchTaskService = mock(ShopMatchTaskService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new ShopMatchResultFileJobHandler( + shopMatchTaskService, taskResultPayloadService); + + @Test + void moduleType() { + assertEquals("SHOP_MATCH", handler.moduleType(), "moduleType=SHOP_MATCH"); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(shopMatchTaskService).processResultFileJob(job); + } + + @Test + void processReturnsTrue() { + assertTrue(handler.process(new TaskFileJobEntity()), "process 返回 true"); + } + + @Test + void cleanupCallsDeleteLatest() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(42L); + job.setModuleType("SHOP_MATCH"); + job.setScopeKey("scope-1"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(42L, "SHOP_MATCH", "scope-1"); + } + + @Test + void cleanupArgs() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(7L); + job.setModuleType("SHOP_MATCH"); + job.setScopeKey("u:99"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(7L, "SHOP_MATCH", "u:99"); + } + + @Test + void offloadFalse() { + assertFalse(handler.supportsAsyncOffload(), "SHOP_MATCH 不支持异步 offload"); + } + + @Test + void ownerScopedFalse() { + assertFalse(handler.isOwnerScoped(), "SHOP_MATCH 非 owner scoped"); + } + + @Test + void processNullJobGuarded() { + assertTrue(handler.process(null), "空 job 由原 Service 守卫,Handler 不抛错"); + verify(shopMatchTaskService).processResultFileJob(null); + assertNotNull(handler, "handler 可正常构建"); + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(1L); + job.setModuleType("SHOP_MATCH"); + job.setScopeKey("s"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(1L, "SHOP_MATCH", "s"); + verify(taskResultPayloadService, never()).deleteLatest(1L, "OTHER", "s"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/SimilarAsinResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/SimilarAsinResultFileJobHandlerTest.java new file mode 100644 index 00000000..0460de60 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/SimilarAsinResultFileJobHandlerTest.java @@ -0,0 +1,74 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * SimilarAsinResultFileJobHandler 测试(任务 69,异步+offload+onFailure)。 + */ +class SimilarAsinResultFileJobHandlerTest { + + private final SimilarAsinTaskService similarAsinTaskService = mock(SimilarAsinTaskService.class); + private final ResultFileJobHandler handler = new SimilarAsinResultFileJobHandler(similarAsinTaskService); + + @Test + void moduleType() { + assertEquals("SIMILAR_ASIN", handler.moduleType()); + } + + @Test + void processTrue() { + TaskFileJobEntity job = new TaskFileJobEntity(); + when(similarAsinTaskService.processResultFileJob(job)).thenReturn(true); + assertTrue(handler.process(job)); + } + + @Test + void processFalse() { + TaskFileJobEntity job = new TaskFileJobEntity(); + when(similarAsinTaskService.processResultFileJob(job)).thenReturn(false); + assertFalse(handler.process(job), "false=等待异步结果"); + } + + @Test + void cleanupDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.cleanup(job); + verify(similarAsinTaskService).cleanupResultFileJob(job); + } + + @Test + void onFailureDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.onFailure(job, "retry exhausted"); + verify(similarAsinTaskService).handleResultFileJobFailure(job, "retry exhausted"); + } + + @Test + void offloadTrue() { + assertTrue(handler.supportsAsyncOffload()); + } + + @Test + void ownerScopedTrue() { + assertTrue(handler.isOwnerScoped()); + } + + @Test + void nullJobGuarded() { + when(similarAsinTaskService.processResultFileJob(null)).thenReturn(true); + assertTrue(handler.process(null)); + handler.cleanup(null); + verify(similarAsinTaskService).cleanupResultFileJob(null); + handler.onFailure(null, "msg"); + verify(similarAsinTaskService).handleResultFileJobFailure(null, "msg"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerCleanupTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerCleanupTest.java new file mode 100644 index 00000000..476402b8 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerCleanupTest.java @@ -0,0 +1,182 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * TaskResultFileJobWorker.cleanupAfterSuccess 注册表改造测试(任务 74)。 + * 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.cleanup 委托)仍 GREEN; + * 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。 + */ +class TaskResultFileJobWorkerCleanupTest { + + private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class); + private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class); + private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class); + private final PublishTaskService publish = mock(PublishTaskService.class); + private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class); + private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class); + private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class); + private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class); + private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class); + private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class); + private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class); + private final BrandTaskService brand = mock(BrandTaskService.class); + private final CollectDataService collectData = mock(CollectDataService.class); + private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + + private TaskResultFileJobWorker buildWorker() { + List handlers = List.of( + new ShopMatchResultFileJobHandler(shopMatch, payload), + new PriceTrackResultFileJobHandler(priceTrack, payload), + new ProductRiskResultFileJobHandler(productRisk, payload), + new PublishResultFileJobHandler(publish), + new QueryAsinResultFileJobHandler(queryAsin, payload), + new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload), + new WithdrawResultFileJobHandler(withdraw, payload), + new PatrolDeleteResultFileJobHandler(patrolDelete, payload), + new AppearancePatentResultFileJobHandler(appearance), + new SimilarAsinResultFileJobHandler(similar), + new DeleteBrandResultFileJobHandler(deleteBrand), + new BrandResultFileJobHandler(brand, payload), + new CollectDataResultFileJobHandler(collectData)); + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); + return new TaskResultFileJobWorker( + mock(TaskFileJobService.class), + mock(TaskDistributedLockService.class), + mock(FileResultMapper.class), + mock(TaskFileJobLocalDispatcher.class), + mock(InstanceMetadata.class), + withdraw, brand, + registry); + } + + private static void cleanup(TaskResultFileJobWorker worker, TaskFileJobEntity job) throws Exception { + Method method = TaskResultFileJobWorker.class.getDeclaredMethod("cleanupAfterSuccess", TaskFileJobEntity.class); + method.setAccessible(true); + try { + method.invoke(worker, job); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + private static TaskFileJobEntity job(String moduleType) { + TaskFileJobEntity entity = new TaskFileJobEntity(); + entity.setModuleType(moduleType); + entity.setTaskId(1L); + entity.setScopeKey("scope:" + moduleType); + return entity; + } + + @Test + void cleanupShopDataCrawlBoth() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_DATA_CRAWL"); + cleanup(worker, job); + verify(shopDataCrawl).cleanupResultFileJob(job); + verify(payload).deleteLatest(1L, "SHOP_DATA_CRAWL", "scope:SHOP_DATA_CRAWL"); + } + + @Test + void cleanupDeleteLatestModules() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + for (String moduleType : List.of("SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", + "QUERY_ASIN", "WITHDRAW", "PATROL_DELETE")) { + TaskFileJobEntity j = job(moduleType); + cleanup(worker, j); + verify(payload).deleteLatest(1L, moduleType, "scope:" + moduleType); + } + } + + @Test + void cleanupAppearance() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("APPEARANCE_PATENT"); + cleanup(worker, job); + verify(appearance).cleanupResultFileJob(job); + } + + @Test + void cleanupSimilar() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN"); + cleanup(worker, job); + verify(similar).cleanupResultFileJob(job); + } + + @Test + void cleanupPublish() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("PUBLISH"); + cleanup(worker, job); + verify(publish).cleanupResultFileJob(job); + } + + @Test + void cleanupDeleteBrand() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("DELETE_BRAND"); + cleanup(worker, job); + verify(deleteBrand).cleanupResultFileJob(job); + } + + @Test + void cleanupCollectDataNoop() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + cleanup(worker, job("COLLECT_DATA")); + cleanup(worker, job("BRAND")); + verifyNoInteractions(payload); + } + + @Test + void cleanupUnknownIgnored() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + cleanup(worker, job("UNKNOWN_X")); + verifyNoInteractions(payload); + } + + @Test + void cleanupNoLongerBranches() throws IOException { + Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules", + "task", "service", "TaskResultFileJobWorker.java"); + String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + int cleanupStart = content.indexOf("private void cleanupAfterSuccess"); + String cleanupBody = content.substring(cleanupStart); + assertTrue(!cleanupBody.contains("\"SHOP_DATA_CRAWL\".equals(moduleType)"), + "cleanupAfterSuccess 无 if/else 分支残留"); + assertTrue(!cleanupBody.contains("taskResultPayloadService.deleteLatest"), + "cleanupAfterSuccess 不再直接操作 payload"); + assertTrue(cleanupBody.contains("handlerRegistry"), "cleanupAfterSuccess 走注册表查找"); + assertTrue(cleanupBody.contains(".cleanup(job)"), "cleanupAfterSuccess 委托 handler.cleanup"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerDispatchTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerDispatchTest.java new file mode 100644 index 00000000..d2bd9892 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerDispatchTest.java @@ -0,0 +1,202 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * TaskResultFileJobWorker.dispatch 注册表改造测试(任务 73)。 + * 行为等价基线:改造前对现有实现断言 GREEN;改造后(注册表查找)仍 GREEN; + * 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。 + */ +class TaskResultFileJobWorkerDispatchTest { + + private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class); + private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class); + private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class); + private final PublishTaskService publish = mock(PublishTaskService.class); + private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class); + private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class); + private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class); + private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class); + private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class); + private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class); + private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class); + private final BrandTaskService brand = mock(BrandTaskService.class); + private final CollectDataService collectData = mock(CollectDataService.class); + private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + + private TaskResultFileJobWorker buildWorker() { + List handlers = List.of( + new ShopMatchResultFileJobHandler(shopMatch, payload), + new PriceTrackResultFileJobHandler(priceTrack, payload), + new ProductRiskResultFileJobHandler(productRisk, payload), + new PublishResultFileJobHandler(publish), + new QueryAsinResultFileJobHandler(queryAsin, payload), + new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload), + new WithdrawResultFileJobHandler(withdraw, payload), + new PatrolDeleteResultFileJobHandler(patrolDelete, payload), + new AppearancePatentResultFileJobHandler(appearance), + new SimilarAsinResultFileJobHandler(similar), + new DeleteBrandResultFileJobHandler(deleteBrand), + new BrandResultFileJobHandler(brand, payload), + new CollectDataResultFileJobHandler(collectData)); + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); + return new TaskResultFileJobWorker( + mock(TaskFileJobService.class), + mock(TaskDistributedLockService.class), + mock(FileResultMapper.class), + mock(TaskFileJobLocalDispatcher.class), + mock(InstanceMetadata.class), + withdraw, brand, + registry); + } + + private static boolean dispatch(TaskResultFileJobWorker worker, TaskFileJobEntity job) throws Exception { + Method method = TaskResultFileJobWorker.class.getDeclaredMethod("dispatch", TaskFileJobEntity.class); + method.setAccessible(true); + try { + return (boolean) method.invoke(worker, job); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + private static TaskFileJobEntity job(String moduleType) { + TaskFileJobEntity entity = new TaskFileJobEntity(); + entity.setModuleType(moduleType); + entity.setTaskId(1L); + entity.setScopeKey("s"); + return entity; + } + + @Test + void dispatchResolvesHandler() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH"); + assertTrue(dispatch(worker, job), "dispatch 通过注册表委托"); + verify(shopMatch).processResultFileJob(job); + } + + @Test + void dispatchKnownModule() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true); + when(similar.processResultFileJob(job("SIMILAR_ASIN"))).thenReturn(true); + List known = List.of( + "SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN", + "SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT", + "SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA"); + for (String moduleType : known) { + assertTrue(dispatch(worker, job(moduleType)), moduleType + " 返回 true"); + } + } + + @Test + void dispatchUnknownThrows() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + assertThrows(IllegalArgumentException.class, () -> dispatch(worker, job("UNKNOWN_X")), + "未知模块抛 IllegalArgumentException"); + } + + @Test + void dispatchUnknownMessage() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + Exception ex = assertThrows(Exception.class, () -> dispatch(worker, job("UNKNOWN_X"))); + assertTrue(ex.getMessage() != null && ex.getMessage().contains("UNKNOWN_X"), + "消息含模块名:" + ex.getMessage()); + assertTrue(ex.getMessage().contains("unsupported result file job module"), + "原 IllegalArgumentException 文案保留:" + ex.getMessage()); + } + + @Test + void dispatchAll13ModulesDelegate() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true); + when(similar.processResultFileJob(job("SIMILAR_ASIN"))).thenReturn(true); + for (String moduleType : List.of( + "SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN", + "SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT", + "SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA")) { + TaskFileJobEntity j = job(moduleType); + assertTrue(dispatch(worker, j), moduleType + " 委托成功"); + } + verify(shopMatch).processResultFileJob(job("SHOP_MATCH")); + verify(priceTrack).processResultFileJob(job("PRICE_TRACK")); + verify(productRisk).processResultFileJob(job("PRODUCT_RISK_RESOLVE")); + verify(publish).processResultFileJob(job("PUBLISH")); + verify(queryAsin).processResultFileJob(job("QUERY_ASIN")); + verify(shopDataCrawl).processResultFileJob(job("SHOP_DATA_CRAWL")); + verify(withdraw).processResultFileJob(job("WITHDRAW")); + verify(patrolDelete).processResultFileJob(job("PATROL_DELETE")); + verify(appearance).processResultFileJob(job("APPEARANCE_PATENT")); + verify(similar).processResultFileJob(job("SIMILAR_ASIN")); + verify(deleteBrand).processResultFileJob(job("DELETE_BRAND")); + verify(brand).processResultFileJob(job("BRAND")); + verify(collectData).processResultFileJob(job("COLLECT_DATA")); + } + + @Test + void dispatchNullJobSafe() { + TaskResultFileJobWorker worker = buildWorker(); + // process(null) 有守卫(job==null 直接返回),不抛错 + worker.process(null); + } + + @Test + void dispatchNoLongerBranch() throws IOException { + Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules", + "task", "service", "TaskResultFileJobWorker.java"); + String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + int dispatchStart = content.indexOf("private boolean dispatch"); + int dispatchEnd = content.indexOf("private void cleanupAfterSuccess", dispatchStart); + String dispatchBody = content.substring(dispatchStart, dispatchEnd); + assertTrue(!dispatchBody.contains("if (\"SHOP_MATCH\".equals(moduleType)"), + "dispatch 无 SHOP_MATCH if/else 残留"); + assertTrue(dispatchBody.contains("handlerRegistry.resolve"), "dispatch 走注册表查找"); + } + + @Test + void dispatchIntegrationProcess() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + // boolean 语义全链路:true 模块完成;false 模块(异步等待)原样透传 + assertTrue(dispatch(worker, job("SHOP_MATCH"))); + when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(false); + assertEquals(false, dispatch(worker, job("APPEARANCE_PATENT")), "false 原样返回(等待异步)"); + when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true); + assertTrue(dispatch(worker, job("APPEARANCE_PATENT")), "异步完成后返回 true"); + List list = new ArrayList<>(); + assertTrue(list.isEmpty(), "辅助断言保持编译"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerEquivalenceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerEquivalenceTest.java new file mode 100644 index 00000000..a0c6fef0 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerEquivalenceTest.java @@ -0,0 +1,273 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.List; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 行为等价对比测试(任务 79)。 + * 同一 Job 序列(成功/失败/重试耗尽/异步等待/owner 跳过/孤儿失败)在注册表改造后 + * 与改造前录制(golden)的状态迁移与依赖调用序列一致。 + */ +class TaskResultFileJobWorkerEquivalenceTest { + + private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class); + private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class); + private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class); + private final PublishTaskService publish = mock(PublishTaskService.class); + private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class); + private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class); + private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class); + private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class); + private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class); + private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class); + private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class); + private final BrandTaskService brand = mock(BrandTaskService.class); + private final CollectDataService collectData = mock(CollectDataService.class); + private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class); + private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class); + private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + private final FileResultMapper fileResultMapper = mock(FileResultMapper.class); + private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class); + private final org.springframework.core.task.TaskExecutor taskQueueExecutor = + mock(org.springframework.core.task.TaskExecutor.class); + + private TaskResultFileJobWorker buildWorker() throws Exception { + List handlers = List.of( + new ShopMatchResultFileJobHandler(shopMatch, payload), + new PriceTrackResultFileJobHandler(priceTrack, payload), + new ProductRiskResultFileJobHandler(productRisk, payload), + new PublishResultFileJobHandler(publish), + new QueryAsinResultFileJobHandler(queryAsin, payload), + new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload), + new WithdrawResultFileJobHandler(withdraw, payload), + new PatrolDeleteResultFileJobHandler(patrolDelete, payload), + new AppearancePatentResultFileJobHandler(appearance), + new SimilarAsinResultFileJobHandler(similar), + new DeleteBrandResultFileJobHandler(deleteBrand), + new BrandResultFileJobHandler(brand, payload), + new CollectDataResultFileJobHandler(collectData)); + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); + TaskResultFileJobWorker worker = new TaskResultFileJobWorker( + taskFileJobService, + taskDistributedLockService, + fileResultMapper, + mock(TaskFileJobLocalDispatcher.class), + instanceMetadata, + withdraw, brand, + registry); + Field executorField = TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor"); + executorField.setAccessible(true); + executorField.set(worker, taskQueueExecutor); + org.mockito.Mockito.doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(taskQueueExecutor).execute(org.mockito.ArgumentMatchers.any(Runnable.class)); + Field heartbeatField = TaskResultFileJobWorker.class.getDeclaredField("heartbeatIntervalMillis"); + heartbeatField.setAccessible(true); + heartbeatField.set(worker, 3_600_000L); + return worker; + } + + private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, long resultId) { + TaskFileJobEntity entity = new TaskFileJobEntity(); + entity.setId(jobId); + entity.setTaskId(taskId); + entity.setResultId(resultId); + entity.setModuleType(moduleType); + entity.setScopeKey("scope:" + taskId); + return entity; + } + + private void allowClaim(TaskFileJobEntity job) { + TaskFileJobEntity claim = new TaskFileJobEntity(); + claim.setId(job.getId()); + claim.setTaskId(job.getTaskId()); + claim.setModuleType(job.getModuleType()); + claim.setStatus("RUNNING"); + claim.setUpdatedAt(LocalDateTime.now()); + when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim); + when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true); + } + + private void allowLock(String moduleType, long taskId) { + when(taskDistributedLockService.acquire(moduleType, taskId, + TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock); + } + + @Test + void equivSuccessJob() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 1L, 11L, 21L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + FileResultEntity result = new FileResultEntity(); + result.setResultFileUrl("result/shop-match/11.xlsx"); + when(fileResultMapper.selectById(21L)).thenReturn(result); + + worker.process(job); + + InOrder order = inOrder(taskFileJobService, taskDistributedLockService, shopMatch, fileResultMapper, payload, lock); + order.verify(taskFileJobService).claimRunning(1L); + order.verify(taskFileJobService).activateRunningClaim(any()); + order.verify(taskDistributedLockService).acquire("SHOP_MATCH", 11L, + TaskDistributedLockService.DEFAULT_WAIT_MILLIS); + order.verify(shopMatch).processResultFileJob(job); + order.verify(fileResultMapper).selectById(21L); + order.verify(taskFileJobService).markSuccess(job, "result/shop-match/11.xlsx"); + order.verify(payload).deleteLatest(11L, "SHOP_MATCH", "scope:11"); + order.verify(lock).close(); + verify(taskFileJobService, never()).markFailed(any(), any()); + } + + @Test + void equivFailedJob() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 2L, 12L, 22L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new IllegalStateException("boom")).when(shopMatch).processResultFileJob(job); + when(taskFileJobService.isRetryExhausted(2L)).thenReturn(false); + + worker.process(job); + + InOrder order = inOrder(taskFileJobService); + order.verify(taskFileJobService).markFailed(job, "boom"); + verify(taskFileJobService, never()).markSuccess(any(), any()); + verify(taskFileJobService, never()).markFailureFinalized(any(), any()); + verify(taskFileJobService, never()).markFailedPermanent(any(), any()); + } + + @Test + void equivRetryExhausted() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 3L, 13L, 23L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new IllegalStateException("upload failed")).when(shopDataCrawl).processResultFileJob(job); + when(taskFileJobService.isRetryExhausted(3L)).thenReturn(true); + + worker.process(job); + + InOrder order = inOrder(taskFileJobService, shopDataCrawl); + order.verify(taskFileJobService).markFailed(job, "upload failed"); + order.verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed"); + order.verify(taskFileJobService).markFailureFinalized(3L, "upload failed"); + verify(taskFileJobService, never()).markSuccess(any(), any()); + } + + @Test + void equivAsyncWait() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("APPEARANCE_PATENT", 4L, 14L, 24L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + when(appearance.processResultFileJob(job)).thenReturn(false); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + + worker.process(job); + + verify(taskFileJobService).touchRunning(4L); + verify(taskFileJobService, never()).markSuccess(any(), any()); + verify(taskFileJobService, never()).markFailed(any(), any()); + verify(taskFileJobService, never()).deferRunning(any(), any()); + verify(appearance, never()).cleanupResultFileJob(any()); + } + + @Test + void equivOwnerSkip() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("PUBLISH", 5L, 15L, 25L); + job.setScopeKey("task:15:owner:instance-b"); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + + worker.process(job); + + verify(taskFileJobService, never()).claimRunning(any()); + verify(taskDistributedLockService, never()).acquire(any(), any(), anyLong()); + verify(publish, never()).processResultFileJob(any()); + } + + @Test + void equivOrphanFailure() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 6L, 16L, 26L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new BusinessException("task not found: 16")).when(shopMatch).processResultFileJob(job); + + worker.process(job); + + verify(taskFileJobService).markFailedPermanent(job, "task not found: 16"); + verify(taskFileJobService, never()).markFailed(any(), any()); + verify(taskFileJobService, never()).markSuccess(any(), any()); + } + + @Test + void equivCallSequenceWithdraw() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("WITHDRAW", 7L, 17L, 27L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + FileResultEntity result = new FileResultEntity(); + result.setResultFileUrl("result/withdraw/17.xlsx"); + when(fileResultMapper.selectById(27L)).thenReturn(result); + + worker.process(job); + + InOrder order = inOrder(taskFileJobService, payload, withdraw, lock); + order.verify(taskFileJobService).markSuccess(job, "result/withdraw/17.xlsx"); + order.verify(payload).deleteLatest(17L, "WITHDRAW", "scope:17"); + order.verify(lock).close(); + order.verify(withdraw).tryFinalizeTask(17L, false); + verify(withdraw, times(1)).tryFinalizeTask(17L, false); + } + + @Test + void equivStateTransition() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 8L, 18L, 28L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + + worker.process(job); + + verify(taskFileJobService).claimRunning(8L); + verify(taskFileJobService).activateRunningClaim(any()); + verify(taskFileJobService).markSuccess(eq(job), eq(null)); + verify(lock).close(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerFailureNotifyTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerFailureNotifyTest.java new file mode 100644 index 00000000..0694f91d --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerFailureNotifyTest.java @@ -0,0 +1,225 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * TaskResultFileJobWorker.notifyRetryExhausted 注册表改造测试(任务 75)。 + * 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.onFailure 委托)仍 GREEN; + * 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。 + */ +class TaskResultFileJobWorkerFailureNotifyTest { + + private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class); + private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class); + private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class); + private final PublishTaskService publish = mock(PublishTaskService.class); + private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class); + private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class); + private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class); + private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class); + private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class); + private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class); + private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class); + private final BrandTaskService brand = mock(BrandTaskService.class); + private final CollectDataService collectData = mock(CollectDataService.class); + private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class); + private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class); + private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class); + + private TaskResultFileJobWorker buildWorker() { + List handlers = List.of( + new ShopMatchResultFileJobHandler(shopMatch, payload), + new PriceTrackResultFileJobHandler(priceTrack, payload), + new ProductRiskResultFileJobHandler(productRisk, payload), + new PublishResultFileJobHandler(publish), + new QueryAsinResultFileJobHandler(queryAsin, payload), + new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload), + new WithdrawResultFileJobHandler(withdraw, payload), + new PatrolDeleteResultFileJobHandler(patrolDelete, payload), + new AppearancePatentResultFileJobHandler(appearance), + new SimilarAsinResultFileJobHandler(similar), + new DeleteBrandResultFileJobHandler(deleteBrand), + new BrandResultFileJobHandler(brand, payload), + new CollectDataResultFileJobHandler(collectData)); + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); + return new TaskResultFileJobWorker( + taskFileJobService, + taskDistributedLockService, + mock(FileResultMapper.class), + mock(TaskFileJobLocalDispatcher.class), + instanceMetadata, + withdraw, brand, + registry); + } + + private static void notifyRetryExhausted(TaskResultFileJobWorker worker, TaskFileJobEntity job, String message) + throws Exception { + Method method = TaskResultFileJobWorker.class.getDeclaredMethod("notifyRetryExhausted", TaskFileJobEntity.class, String.class); + method.setAccessible(true); + try { + method.invoke(worker, job, message); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + private static TaskFileJobEntity job(String moduleType) { + TaskFileJobEntity entity = new TaskFileJobEntity(); + entity.setModuleType(moduleType); + entity.setTaskId(1L); + entity.setScopeKey("scope:" + moduleType); + return entity; + } + + @Test + void onFailureShopDataCrawl() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_DATA_CRAWL"); + notifyRetryExhausted(worker, job, "upload failed"); + verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed"); + } + + @Test + void onFailureSimilar() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN"); + notifyRetryExhausted(worker, job, "timeout"); + verify(similar).handleResultFileJobFailure(job, "timeout"); + } + + @Test + void onFailureOthersNoop() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + for (String moduleType : List.of("SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", + "QUERY_ASIN", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT", + "DELETE_BRAND", "BRAND", "COLLECT_DATA")) { + notifyRetryExhausted(worker, job(moduleType), "boom"); + } + verifyNoInteractions(shopDataCrawl, similar); + } + + @Test + void onFailureUnknownIgnored() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + notifyRetryExhausted(worker, job("UNKNOWN_X"), "boom"); + verifyNoInteractions(shopDataCrawl, similar); + } + + @Test + void onFailureIdempotent() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN"); + notifyRetryExhausted(worker, job, "timeout"); + notifyRetryExhausted(worker, job, "timeout"); + verify(similar, times(2)).handleResultFileJobFailure(job, "timeout"); + } + + @Test + void onFailureErrorSwallowed() { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN"); + job.setId(13858L); + job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT); + job.setErrorMessage("文件生成任务运行超时,已达到最大重试次数"); + TaskFileJobService.StuckJobResetResult resetResult = + new TaskFileJobService.StuckJobResetResult(0, List.of(job)); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult); + doThrow(new IllegalStateException("owner mismatch")) + .when(similar).handleResultFileJobFailure(any(), anyString()); + + worker.resetStuckJobs(); + + verify(taskFileJobService, never()) + .markFailureFinalized(job.getId(), job.getErrorMessage()); + } + + @Test + void onFailureArgsPassed() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_DATA_CRAWL"); + notifyRetryExhausted(worker, job, "specific failure message"); + verify(shopDataCrawl).handleResultFileJobFailure(job, "specific failure message"); + } + + @Test + void exhaustedFinalizeFlow() { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_DATA_CRAWL"); + job.setId(13644L); + job.setTaskId(20144L); + job.setScopeKey("task:20144:owner:instance-a"); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + TaskFileJobEntity claim = new TaskFileJobEntity(); + claim.setId(job.getId()); + claim.setTaskId(job.getTaskId()); + claim.setModuleType(job.getModuleType()); + claim.setStatus("RUNNING"); + claim.setUpdatedAt(LocalDateTime.now()); + when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim); + when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true); + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", job.getTaskId(), + TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock); + doThrow(new IllegalStateException("upload failed")) + .when(shopDataCrawl).processResultFileJob(job); + when(taskFileJobService.isRetryExhausted(job.getId())).thenReturn(true); + + worker.process(job); + + verify(taskFileJobService).markFailed(job, "upload failed"); + verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed"); + verify(taskFileJobService).markFailureFinalized(job.getId(), "upload failed"); + } + + @Test + void notifyNoLongerBranches() throws IOException { + Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules", + "task", "service", "TaskResultFileJobWorker.java"); + String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + int notifyStart = content.indexOf("private void notifyRetryExhausted"); + int notifyEnd = content.indexOf("@PreDestroy", notifyStart); + String notifyBody = content.substring(notifyStart, notifyEnd); + assertTrue(!notifyBody.contains("\"SHOP_DATA_CRAWL\".equals(moduleType)"), + "notifyRetryExhausted 无 if/else 分支残留"); + assertTrue(notifyBody.contains("handlerRegistry"), "notifyRetryExhausted 走注册表查找"); + assertTrue(notifyBody.contains(".onFailure(job, message)"), "notifyRetryExhausted 委托 handler.onFailure"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOffloadTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOffloadTest.java new file mode 100644 index 00000000..05aaef4f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOffloadTest.java @@ -0,0 +1,264 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * TaskResultFileJobWorker.process coze offload 判定注册表改造测试(任务 76)。 + * 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.supportsAsyncOffload 委托)仍 GREEN; + * 其中"无 offload 模块硬编码"为静态检查,改造前 RED、改造后 GREEN。 + */ +class TaskResultFileJobWorkerOffloadTest { + + private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class); + private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class); + private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class); + private final PublishTaskService publish = mock(PublishTaskService.class); + private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class); + private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class); + private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class); + private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class); + private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class); + private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class); + private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class); + private final BrandTaskService brand = mock(BrandTaskService.class); + private final CollectDataService collectData = mock(CollectDataService.class); + private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class); + private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class); + private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class); + private final org.springframework.core.task.TaskExecutor taskQueueExecutor = + mock(org.springframework.core.task.TaskExecutor.class); + + private TaskResultFileJobWorker buildWorker() throws Exception { + List handlers = List.of( + new ShopMatchResultFileJobHandler(shopMatch, payload), + new PriceTrackResultFileJobHandler(priceTrack, payload), + new ProductRiskResultFileJobHandler(productRisk, payload), + new PublishResultFileJobHandler(publish), + new QueryAsinResultFileJobHandler(queryAsin, payload), + new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload), + new WithdrawResultFileJobHandler(withdraw, payload), + new PatrolDeleteResultFileJobHandler(patrolDelete, payload), + new AppearancePatentResultFileJobHandler(appearance), + new SimilarAsinResultFileJobHandler(similar), + new DeleteBrandResultFileJobHandler(deleteBrand), + new BrandResultFileJobHandler(brand, payload), + new CollectDataResultFileJobHandler(collectData)); + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); + TaskResultFileJobWorker worker = new TaskResultFileJobWorker( + taskFileJobService, + taskDistributedLockService, + mock(FileResultMapper.class), + mock(TaskFileJobLocalDispatcher.class), + instanceMetadata, + withdraw, brand, + registry); + setTaskQueueExecutor(worker, taskQueueExecutor); + return worker; + } + + private static void setTaskQueueExecutor(TaskResultFileJobWorker worker, org.springframework.core.task.TaskExecutor executor) + throws Exception { + Field field = TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor"); + field.setAccessible(true); + field.set(worker, executor); + } + + private static TaskFileJobEntity job(String moduleType, long jobId, long taskId) { + TaskFileJobEntity entity = new TaskFileJobEntity(); + entity.setId(jobId); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + return entity; + } + + private TaskFileJobEntity allowClaim(long jobId, long taskId, String moduleType) { + TaskFileJobEntity claim = new TaskFileJobEntity(); + claim.setId(jobId); + claim.setTaskId(taskId); + claim.setModuleType(moduleType); + claim.setStatus("RUNNING"); + claim.setUpdatedAt(LocalDateTime.now()); + when(taskFileJobService.claimRunning(jobId)).thenReturn(claim); + when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true); + return claim; + } + + private void allowLock(String moduleType, long taskId) { + when(taskDistributedLockService.acquire(moduleType, taskId, + TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock); + } + + @Test + void offloadModulesDetected() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + allowClaim(1L, 11L, "APPEARANCE_PATENT"); + worker.process(job("APPEARANCE_PATENT", 1L, 11L)); + allowClaim(2L, 12L, "SIMILAR_ASIN"); + worker.process(job("SIMILAR_ASIN", 2L, 12L)); + verify(taskQueueExecutor, times(2)).execute(any(Runnable.class)); + } + + @Test + void offloadExecutesOnTaskQueueExecutor() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN", 3L, 13L); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + allowLock(job.getModuleType(), job.getTaskId()); + when(similar.processResultFileJob(job)).thenReturn(true); + + worker.process(job); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Runnable.class); + verify(taskQueueExecutor).execute(captor.capture()); + captor.getValue().run(); + + verify(taskFileJobService).markSuccess(job, null); + verify(similar).cleanupResultFileJob(job); + } + + @Test + void offloadFailureFallback() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + allowLock(job.getModuleType(), job.getTaskId()); + when(similar.processResultFileJob(job)).thenReturn(true); + doThrow(new RuntimeException("executor down")).when(taskQueueExecutor).execute(any(Runnable.class)); + + worker.process(job); + + verify(taskFileJobService).markSuccess(job, null); + verify(similar).cleanupResultFileJob(job); + } + + @Test + void offloadOtherModulesInline() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 5L, 15L); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + allowLock(job.getModuleType(), job.getTaskId()); + + worker.process(job); + + verify(taskQueueExecutor, never()).execute(any(Runnable.class)); + verify(taskFileJobService).markSuccess(job, null); + verify(payload).deleteLatest(15L, "SHOP_MATCH", null); + } + + @Test + void offloadOnlyAsyncModules() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + allowClaim(6L, 16L, "WITHDRAW"); + allowLock("WITHDRAW", 16L); + + worker.process(job("WITHDRAW", 6L, 16L)); + + verify(taskQueueExecutor, never()).execute(any(Runnable.class)); + } + + @Test + void offloadNoDuplicateExecution() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("APPEARANCE_PATENT", 7L, 17L); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + + worker.process(job); + + verify(taskQueueExecutor).execute(any(Runnable.class)); + verify(taskFileJobService, never()).activateRunningClaim(any()); + verify(taskFileJobService, never()).markSuccess(any(), any()); + } + + @Test + void offloadHeartbeatStarted() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + Field heartbeatField = TaskResultFileJobWorker.class.getDeclaredField("heartbeatIntervalMillis"); + heartbeatField.setAccessible(true); + heartbeatField.set(worker, 1000L); + TaskFileJobEntity job = job("SIMILAR_ASIN", 8L, 18L); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + allowLock(job.getModuleType(), job.getTaskId()); + when(similar.processResultFileJob(job)).thenAnswer(invocation -> { + Thread.sleep(2000L); + return true; + }); + + worker.process(job); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Runnable.class); + verify(taskQueueExecutor).execute(captor.capture()); + captor.getValue().run(); + + verify(taskFileJobService, atLeastOnce()).touchRunning(job.getId()); + } + + @Test + void offloadIntegrationAppearance() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("APPEARANCE_PATENT", 9L, 19L); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + allowLock(job.getModuleType(), job.getTaskId()); + when(appearance.processResultFileJob(job)).thenReturn(true); + + worker.process(job); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Runnable.class); + verify(taskQueueExecutor).execute(captor.capture()); + captor.getValue().run(); + + verify(taskFileJobService).markSuccess(job, null); + verify(appearance).cleanupResultFileJob(job); + } + + @Test + void processNoLongerHardcodesOffloadModules() throws IOException { + Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules", + "task", "service", "TaskResultFileJobWorker.java"); + String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + int processStart = content.indexOf("public void process"); + int processEnd = content.indexOf("private ScheduledFuture startJobHeartbeat", processStart); + String processBody = content.substring(processStart, processEnd); + assertTrue(!processBody.contains("\"APPEARANCE_PATENT\".equals(job.getModuleType())"), + "process 无 offload 模块硬编码"); + assertTrue(processBody.contains("supportsAsyncOffload"), "process 走 handler.supportsAsyncOffload"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOrphanTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOrphanTest.java new file mode 100644 index 00000000..71740509 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOrphanTest.java @@ -0,0 +1,303 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 孤儿失败路径不回归测试(任务 80)。 + * BusinessException 含 not found 文案 → markFailedPermanent 终态;普通失败 → markFailed + retry 判断; + * 非 BusinessException 永不判孤儿;孤儿失败不重排、不触发重试耗尽回调。 + */ +class TaskResultFileJobWorkerOrphanTest { + + private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class); + private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class); + private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class); + private final PublishTaskService publish = mock(PublishTaskService.class); + private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class); + private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class); + private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class); + private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class); + private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class); + private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class); + private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class); + private final BrandTaskService brand = mock(BrandTaskService.class); + private final CollectDataService collectData = mock(CollectDataService.class); + private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class); + private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class); + private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class); + + private TaskResultFileJobWorker buildWorker() throws Exception { + List handlers = List.of( + new ShopMatchResultFileJobHandler(shopMatch, payload), + new PriceTrackResultFileJobHandler(priceTrack, payload), + new ProductRiskResultFileJobHandler(productRisk, payload), + new PublishResultFileJobHandler(publish), + new QueryAsinResultFileJobHandler(queryAsin, payload), + new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload), + new WithdrawResultFileJobHandler(withdraw, payload), + new PatrolDeleteResultFileJobHandler(patrolDelete, payload), + new AppearancePatentResultFileJobHandler(appearance), + new SimilarAsinResultFileJobHandler(similar), + new DeleteBrandResultFileJobHandler(deleteBrand), + new BrandResultFileJobHandler(brand, payload), + new CollectDataResultFileJobHandler(collectData)); + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); + return new TaskResultFileJobWorker( + taskFileJobService, + taskDistributedLockService, + mock(FileResultMapper.class), + mock(TaskFileJobLocalDispatcher.class), + instanceMetadata, + withdraw, brand, + registry); + } + + private static TaskFileJobEntity job(String moduleType, long jobId, long taskId) { + TaskFileJobEntity entity = new TaskFileJobEntity(); + entity.setId(jobId); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + entity.setScopeKey("scope:" + taskId); + return entity; + } + + private void allowClaim(TaskFileJobEntity job) { + TaskFileJobEntity claim = new TaskFileJobEntity(); + claim.setId(job.getId()); + claim.setTaskId(job.getTaskId()); + claim.setModuleType(job.getModuleType()); + claim.setStatus("RUNNING"); + claim.setUpdatedAt(LocalDateTime.now()); + when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim); + when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true); + } + + private void allowLock(String moduleType, long taskId) { + when(taskDistributedLockService.acquire(moduleType, taskId, + TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock); + } + + @Test + void orphanTaskNotFoundEnglishMessage() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 1L, 11L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new BusinessException("task not found: 11")).when(shopMatch).processResultFileJob(job); + + worker.process(job); + + verify(taskFileJobService).markFailedPermanent(job, "task not found: 11"); + } + + @Test + void orphanResultNotFoundEnglishMessage() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 2L, 12L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new BusinessException("result record not found")).when(shopMatch).processResultFileJob(job); + + worker.process(job); + + verify(taskFileJobService).markFailedPermanent(job, "result record not found"); + } + + @Test + void orphanChineseMessages() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job1 = job("SHOP_MATCH", 3L, 13L); + allowClaim(job1); + allowLock(job1.getModuleType(), job1.getTaskId()); + doThrow(new BusinessException("任务不存在")).when(shopMatch).processResultFileJob(job1); + worker.process(job1); + verify(taskFileJobService).markFailedPermanent(job1, "任务不存在"); + + TaskFileJobEntity job2 = job("SHOP_MATCH", 4L, 14L); + allowClaim(job2); + allowLock(job2.getModuleType(), job2.getTaskId()); + doThrow(new BusinessException("记录不存在")).when(shopMatch).processResultFileJob(job2); + worker.process(job2); + verify(taskFileJobService).markFailedPermanent(job2, "记录不存在"); + } + + @Test + void orphanMarkedPermanent() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 5L, 15L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new BusinessException("task not found: 15")).when(shopMatch).processResultFileJob(job); + + worker.process(job); + + verify(taskFileJobService).markFailedPermanent(job, "task not found: 15"); + verify(taskFileJobService, never()).markFailed(any(), any()); + verify(taskFileJobService, never()).markFailureFinalized(any(), any()); + verify(taskFileJobService, never()).markSuccess(any(), any()); + verify(taskFileJobService, never()).requeue(any(), any()); + } + + @Test + void nonOrphanFailureMarkedFailed() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 6L, 16L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new IllegalStateException("boom")).when(shopMatch).processResultFileJob(job); + when(taskFileJobService.isRetryExhausted(6L)).thenReturn(false); + + worker.process(job); + + verify(taskFileJobService).markFailed(job, "boom"); + verify(taskFileJobService, never()).markFailedPermanent(any(), any()); + } + + @Test + void nonBusinessExceptionNeverOrphan() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 7L, 17L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new IllegalStateException("task not found: 17")).when(shopMatch).processResultFileJob(job); + when(taskFileJobService.isRetryExhausted(7L)).thenReturn(false); + + worker.process(job); + + verify(taskFileJobService).markFailed(job, "task not found: 17"); + verify(taskFileJobService, never()).markFailedPermanent(any(), any()); + } + + @Test + void retryExhaustedAfterOrphan() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN", 8L, 18L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new BusinessException("task not found: 18")).when(similar).processResultFileJob(job); + + worker.process(job); + + verify(taskFileJobService).markFailedPermanent(job, "task not found: 18"); + verify(similar, never()).handleResultFileJobFailure(any(), any()); + verify(taskFileJobService, never()).markFailureFinalized(any(), any()); + } + + @Test + void orphanNoRequeue() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 9L, 19L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new BusinessException("task not found: 19")).when(shopMatch).processResultFileJob(job); + + worker.process(job); + + verify(taskFileJobService, never()).requeue(any(), any()); + verify(taskFileJobService, never()).deferRunning(any(), any()); + verify(taskFileJobService, never()).touchRunning(any()); + } + + @Test + void orphanClassificationBranchesOnly() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + assertTrue(invokeIsOrphan(worker, new BusinessException("task not found"))); + assertTrue(invokeIsOrphan(worker, new BusinessException("result record not found"))); + assertTrue(invokeIsOrphan(worker, new BusinessException("任务不存在"))); + assertTrue(invokeIsOrphan(worker, new BusinessException("记录不存在"))); + assertFalse(invokeIsOrphan(worker, new BusinessException("boom"))); + assertFalse(invokeIsOrphan(worker, new IllegalStateException("task not found"))); + assertFalse(invokeIsOrphan(worker, new BusinessException(null))); + assertFalse(invokeIsOrphan(worker, null)); + } + + @Test + void orphanCheckIsStaticBranchesOnly() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + Method method = TaskResultFileJobWorker.class.getDeclaredMethod("isOrphanJobFailure", Exception.class, String.class); + assertSame(Exception.class, method.getParameterTypes()[0], "首参是 Exception(BusinessException 是其子类)"); + } + + private static boolean invokeIsOrphan(TaskResultFileJobWorker worker, Exception ex) throws Exception { + Method method = TaskResultFileJobWorker.class.getDeclaredMethod("isOrphanJobFailure", Exception.class, String.class); + method.setAccessible(true); + try { + return (boolean) method.invoke(worker, ex, ex == null ? null : ex.getMessage()); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + @Test + void orphanIntegrationShopMatch() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SHOP_MATCH", 10L, 20L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new BusinessException("task not found: 20")).when(shopMatch).processResultFileJob(job); + + worker.process(job); + + verify(taskFileJobService).markFailedPermanent(job, "task not found: 20"); + verify(taskFileJobService, never()).markFailed(any(), any()); + } + + @Test + void orphanExhaustedStillPermanent() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + doThrow(new BusinessException("task not found: 21")).when(similar).processResultFileJob(job); + when(taskFileJobService.isRetryExhausted(11L)).thenReturn(true); + + worker.process(job); + + verify(taskFileJobService).markFailedPermanent(job, "task not found: 21"); + verify(similar, never()).handleResultFileJobFailure(any(), any()); + verify(taskFileJobService, never()).markFailureFinalized(any(), any()); + } + + @Test + void orphanFieldReflectionGetters() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + Field registryField = TaskResultFileJobWorker.class.getDeclaredField("handlerRegistry"); + registryField.setAccessible(true); + assertTrue(registryField.get(worker) instanceof ResultFileJobHandlerRegistry, "注册表仍为唯一判定来源"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOwnerScopedTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOwnerScopedTest.java new file mode 100644 index 00000000..68ebfb33 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerOwnerScopedTest.java @@ -0,0 +1,234 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * TaskResultFileJobWorker owner-scoped 判定注册表改造测试(任务 77)。 + * 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.isOwnerScoped 委托)仍 GREEN; + * 其中"无 owner 模块硬编码"为静态检查,改造前 RED、改造后 GREEN。 + */ +class TaskResultFileJobWorkerOwnerScopedTest { + + private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class); + private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class); + private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class); + private final PublishTaskService publish = mock(PublishTaskService.class); + private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class); + private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class); + private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class); + private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class); + private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class); + private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class); + private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class); + private final BrandTaskService brand = mock(BrandTaskService.class); + private final CollectDataService collectData = mock(CollectDataService.class); + private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class); + private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class); + private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class); + + private TaskResultFileJobWorker buildWorker() { + List handlers = List.of( + new ShopMatchResultFileJobHandler(shopMatch, payload), + new PriceTrackResultFileJobHandler(priceTrack, payload), + new ProductRiskResultFileJobHandler(productRisk, payload), + new PublishResultFileJobHandler(publish), + new QueryAsinResultFileJobHandler(queryAsin, payload), + new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload), + new WithdrawResultFileJobHandler(withdraw, payload), + new PatrolDeleteResultFileJobHandler(patrolDelete, payload), + new AppearancePatentResultFileJobHandler(appearance), + new SimilarAsinResultFileJobHandler(similar), + new DeleteBrandResultFileJobHandler(deleteBrand), + new BrandResultFileJobHandler(brand, payload), + new CollectDataResultFileJobHandler(collectData)); + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); + return new TaskResultFileJobWorker( + taskFileJobService, + taskDistributedLockService, + mock(FileResultMapper.class), + mock(TaskFileJobLocalDispatcher.class), + instanceMetadata, + withdraw, brand, + registry); + } + + private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, String scopeKey) { + TaskFileJobEntity entity = new TaskFileJobEntity(); + entity.setId(jobId); + entity.setTaskId(taskId); + entity.setModuleType(moduleType); + entity.setScopeKey(scopeKey); + return entity; + } + + private TaskFileJobEntity allowClaim(long jobId, long taskId, String moduleType) { + TaskFileJobEntity claim = new TaskFileJobEntity(); + claim.setId(jobId); + claim.setTaskId(taskId); + claim.setModuleType(moduleType); + claim.setStatus("RUNNING"); + claim.setUpdatedAt(LocalDateTime.now()); + when(taskFileJobService.claimRunning(jobId)).thenReturn(claim); + when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true); + return claim; + } + + private void allowLock(String moduleType, long taskId) { + when(taskDistributedLockService.acquire(moduleType, taskId, + TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock); + } + + @Test + void ownerScopedModulesRecognized() { + TaskResultFileJobWorker worker = buildWorker(); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + for (String moduleType : List.of("APPEARANCE_PATENT", "SIMILAR_ASIN", "PUBLISH", "SHOP_DATA_CRAWL")) { + worker.process(job(moduleType, 1L, 11L, "task:11:owner:instance-b")); + verifyNoInteractions(taskFileJobService); + } + } + + @Test + void ownerScopedOtherOwnerSkip() { + TaskResultFileJobWorker worker = buildWorker(); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + TaskFileJobEntity job = job("PUBLISH", 2L, 12L, "task:12:owner:instance-b"); + worker.process(job); + verifyNoInteractions(taskFileJobService, taskDistributedLockService, publish); + } + + @Test + void ownerScopedSelfProcess() { + TaskResultFileJobWorker worker = buildWorker(); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + TaskFileJobEntity job = job("PUBLISH", 3L, 13L, "task:13:owner:instance-a"); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + allowLock(PublishTaskService.MODULE_TYPE, job.getTaskId()); + + worker.process(job); + + verify(publish).processResultFileJob(job); + verify(taskFileJobService).markSuccess(job, null); + verify(publish).cleanupResultFileJob(job); + } + + @Test + void ownerScopeParse() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + Method method = TaskResultFileJobWorker.class.getDeclaredMethod("ownerFromScopeKey", String.class); + method.setAccessible(true); + try { + assertEquals("instance-a", method.invoke(worker, "task:1:owner:instance-a")); + assertEquals("instance-b", method.invoke(worker, "x:y:owner:instance-b")); + assertEquals(null, method.invoke(worker, "no-owner-marker")); + assertEquals(null, method.invoke(worker, (Object) null)); + assertEquals(null, method.invoke(worker, "")); + assertEquals(null, method.invoke(worker, "owner:at-start")); + } catch (InvocationTargetException e) { + throw (Exception) e.getCause(); + } + } + + @Test + void ownerScopeBlank() { + TaskResultFileJobWorker worker = buildWorker(); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + TaskFileJobEntity job = job("PUBLISH", 4L, 14L, null); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + allowLock(PublishTaskService.MODULE_TYPE, job.getTaskId()); + + worker.process(job); + + verify(publish).processResultFileJob(job); + verify(taskFileJobService).markSuccess(job, null); + } + + @Test + void ownerNonScopedModules() { + TaskResultFileJobWorker worker = buildWorker(); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + TaskFileJobEntity job = job("WITHDRAW", 5L, 15L, "withdraw:15:owner:instance-b"); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + allowLock(job.getModuleType(), job.getTaskId()); + + worker.process(job); + + verify(withdraw).processResultFileJob(job); + verify(taskFileJobService).markSuccess(job, null); + verify(payload).deleteLatest(15L, "WITHDRAW", "withdraw:15:owner:instance-b"); + } + + @Test + void ownerUnknownInstanceId() { + TaskResultFileJobWorker worker = buildWorker(); + when(instanceMetadata.getInstanceId()).thenReturn(null); + TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 6L, 16L, "task:16:owner:instance-b"); + worker.process(job); + verifyNoInteractions(taskFileJobService); + } + + @Test + void ownerIntegrationShopDataCrawl() { + TaskResultFileJobWorker worker = buildWorker(); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 7L, 17L, "task:17:owner:instance-a"); + allowClaim(job.getId(), job.getTaskId(), job.getModuleType()); + allowLock(job.getModuleType(), job.getTaskId()); + + worker.process(job); + + verify(shopDataCrawl).processResultFileJob(job); + verify(taskFileJobService).markSuccess(job, null); + verify(payload).deleteLatest(17L, "SHOP_DATA_CRAWL", "task:17:owner:instance-a"); + verify(shopDataCrawl).cleanupResultFileJob(job); + } + + @Test + void ownerNoLongerHardcodesModules() throws IOException { + Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules", + "task", "service", "TaskResultFileJobWorker.java"); + String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8); + int ownerStart = content.indexOf("private boolean isOwnerScopedJob"); + int ownerEnd = content.indexOf("private boolean isOwnedByCurrentInstance", ownerStart); + String ownerBody = content.substring(ownerStart, ownerEnd); + assertTrue(!ownerBody.contains("\"APPEARANCE_PATENT\".equals(job.getModuleType())"), + "isOwnerScopedJob 无 owner 模块硬编码"); + assertTrue(ownerBody.contains("handlerRegistry"), "isOwnerScopedJob 走注册表查找"); + assertTrue(ownerBody.contains(".isOwnerScoped()"), "isOwnerScopedJob 委托 handler.isOwnerScoped"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerSpecialRuleTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerSpecialRuleTest.java new file mode 100644 index 00000000..f991c9ae --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerSpecialRuleTest.java @@ -0,0 +1,325 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; +import org.mockito.InOrder; + +import java.time.LocalDateTime; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 特殊规则回归测试(任务 82)。 + * WITHDRAW 成功后 tryFinalizeTask;dispatch false → owner 路径 touchRunning / 非 owner deferRunning; + * 等待不标记失败;完成后 markSuccess;finalize 只一次;全链路不回归。 + */ +class TaskResultFileJobWorkerSpecialRuleTest { + + private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class); + private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class); + private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class); + private final PublishTaskService publish = mock(PublishTaskService.class); + private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class); + private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class); + private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class); + private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class); + private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class); + private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class); + private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class); + private final BrandTaskService brand = mock(BrandTaskService.class); + private final CollectDataService collectData = mock(CollectDataService.class); + private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class); + private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class); + private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + private final FileResultMapper fileResultMapper = mock(FileResultMapper.class); + private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class); + private final ResultFileJobHandlerRegistry handlerRegistry = mock(ResultFileJobHandlerRegistry.class); + private final org.springframework.core.task.TaskExecutor taskQueueExecutor = + mock(org.springframework.core.task.TaskExecutor.class); + + private TaskResultFileJobWorker buildWorker() throws Exception { + TaskResultFileJobWorker worker = new TaskResultFileJobWorker( + taskFileJobService, + taskDistributedLockService, + fileResultMapper, + mock(TaskFileJobLocalDispatcher.class), + instanceMetadata, + withdraw, brand, + handlerRegistry); + java.lang.reflect.Field executorField = + TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor"); + executorField.setAccessible(true); + executorField.set(worker, taskQueueExecutor); + org.mockito.Mockito.doAnswer(invocation -> { + ((Runnable) invocation.getArgument(0)).run(); + return null; + }).when(taskQueueExecutor).execute(org.mockito.ArgumentMatchers.any(Runnable.class)); + return worker; + } + + private void stubHandler(ResultFileJobHandler handler, boolean ownerScoped, boolean offload) { + String moduleType = handler.moduleType(); + when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler)); + when(handlerRegistry.resolve(moduleType)).thenReturn(handler); + when(handler.isOwnerScoped()).thenReturn(ownerScoped); + when(handler.supportsAsyncOffload()).thenReturn(offload); + } + + private void stubReal(ResultFileJobHandler handler) { + String moduleType = handler.moduleType(); + when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler)); + when(handlerRegistry.resolve(moduleType)).thenReturn(handler); + } + + private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, long resultId) { + TaskFileJobEntity entity = new TaskFileJobEntity(); + entity.setId(jobId); + entity.setTaskId(taskId); + entity.setResultId(resultId); + entity.setModuleType(moduleType); + entity.setScopeKey("scope:" + taskId); + return entity; + } + + private void allowClaim(TaskFileJobEntity job) { + TaskFileJobEntity claim = new TaskFileJobEntity(); + claim.setId(job.getId()); + claim.setTaskId(job.getTaskId()); + claim.setModuleType(job.getModuleType()); + claim.setStatus("RUNNING"); + claim.setUpdatedAt(LocalDateTime.now()); + when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim); + when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true); + } + + private void allowLock(String moduleType, long taskId) { + when(taskDistributedLockService.acquire(moduleType, taskId, + TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock); + } + + @Test + void withdrawFinalizeAfterSuccess() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + stubReal(new WithdrawResultFileJobHandler(withdraw, payload)); + TaskFileJobEntity job = job("WITHDRAW", 1L, 11L, 21L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + FileResultEntity result = new FileResultEntity(); + result.setResultFileUrl("result/withdraw/11.xlsx"); + when(fileResultMapper.selectById(21L)).thenReturn(result); + + worker.process(job); + + InOrder order = inOrder(taskFileJobService, payload, lock, withdraw); + order.verify(taskFileJobService).markSuccess(job, "result/withdraw/11.xlsx"); + order.verify(payload).deleteLatest(11L, "WITHDRAW", "scope:11"); + order.verify(lock).close(); + order.verify(withdraw).tryFinalizeTask(11L, false); + } + + @Test + void withdrawNoFinalizeOnFailure() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("WITHDRAW", 2L, 12L, 22L); + ResultFileJobHandler handler = mock(ResultFileJobHandler.class); + when(handler.moduleType()).thenReturn("WITHDRAW"); + when(handler.process(job)).thenThrow(new IllegalStateException("boom")); + stubHandler(handler, false, false); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + when(taskFileJobService.isRetryExhausted(2L)).thenReturn(false); + + worker.process(job); + + verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean()); + verify(taskFileJobService).markFailed(job, "boom"); + } + + @Test + void asyncFalseOwnerTouchesRunning() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + stubReal(new AppearancePatentResultFileJobHandler(appearance)); + TaskFileJobEntity job = job("APPEARANCE_PATENT", 3L, 13L, 23L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + when(appearance.processResultFileJob(job)).thenReturn(false); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + + worker.process(job); + + verify(taskFileJobService).touchRunning(3L); + verify(taskFileJobService, never()).deferRunning(any(), any()); + verify(taskFileJobService, never()).markFailed(any(), any()); + verify(taskFileJobService, never()).markSuccess(any(), any()); + } + + @Test + void asyncFalseDeferredForNonOwnerScoped() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L, 24L); + ResultFileJobHandler handler = mock(ResultFileJobHandler.class); + when(handler.moduleType()).thenReturn("SIMILAR_ASIN"); + when(handler.process(job)).thenReturn(false); + stubHandler(handler, false, false); + + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + + worker.process(job); + + verify(taskFileJobService).deferRunning(4L, "Waiting for LLM/file assembly to continue"); + verify(taskFileJobService, never()).touchRunning(any()); + verify(taskFileJobService, never()).markFailed(any(), any()); + } + + @Test + void asyncFalseNoMarkFailed() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN", 5L, 15L, 25L); + ResultFileJobHandler handler = mock(ResultFileJobHandler.class); + when(handler.moduleType()).thenReturn("SIMILAR_ASIN"); + when(handler.process(job)).thenReturn(false); + stubHandler(handler, false, false); + + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + + worker.process(job); + + verify(taskFileJobService, never()).markFailed(any(), any()); + verify(taskFileJobService, never()).markFailedPermanent(any(), any()); + verify(taskFileJobService, never()).markSuccess(any(), any()); + } + + @Test + void asyncTrueMarksSuccess() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN", 6L, 16L, 26L); + ResultFileJobHandler handler = mock(ResultFileJobHandler.class); + when(handler.moduleType()).thenReturn("SIMILAR_ASIN"); + when(handler.process(job)).thenReturn(true); + stubHandler(handler, false, false); + + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + FileResultEntity result = new FileResultEntity(); + result.setResultFileUrl("result/similar-asin/16.xlsx"); + when(fileResultMapper.selectById(26L)).thenReturn(result); + + worker.process(job); + + verify(taskFileJobService).markSuccess(job, "result/similar-asin/16.xlsx"); + verify(handler).cleanup(job); + } + + @Test + void withdrawFinalizeExactlyOnce() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + stubReal(new WithdrawResultFileJobHandler(withdraw, payload)); + TaskFileJobEntity job = job("WITHDRAW", 7L, 17L, 27L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + FileResultEntity result = new FileResultEntity(); + result.setResultFileUrl("result/withdraw/17.xlsx"); + when(fileResultMapper.selectById(27L)).thenReturn(result); + + worker.process(job); + + verify(withdraw, times(1)).tryFinalizeTask(17L, false); + } + + @Test + void specialRulesIntegration() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + stubReal(new ShopMatchResultFileJobHandler(shopMatch, payload)); + TaskFileJobEntity job = job("SHOP_MATCH", 8L, 18L, 28L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + FileResultEntity result = new FileResultEntity(); + result.setResultFileUrl("result/shop-match/18.xlsx"); + when(fileResultMapper.selectById(28L)).thenReturn(result); + + worker.process(job); + + verify(taskFileJobService).markSuccess(job, "result/shop-match/18.xlsx"); + verify(payload).deleteLatest(18L, "SHOP_MATCH", "scope:18"); + verify(taskFileJobService, never()).deferRunning(any(), any()); + verify(taskFileJobService, never()).touchRunning(any()); + verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean()); + } + + @Test + void asyncFalseStillReleasesLock() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + stubReal(new AppearancePatentResultFileJobHandler(appearance)); + TaskFileJobEntity job = job("APPEARANCE_PATENT", 9L, 19L, 29L); + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + when(appearance.processResultFileJob(job)).thenReturn(false); + when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + + worker.process(job); + + verify(lock).close(); + verify(taskFileJobService).touchRunning(9L); + } + + @Test + void withdrawLockBusyRequeuesNoFinalize() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + stubReal(new WithdrawResultFileJobHandler(withdraw, payload)); + TaskFileJobEntity job = job("WITHDRAW", 10L, 20L, 30L); + allowClaim(job); + when(taskDistributedLockService.acquire("WITHDRAW", 20L, + TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(null); + + worker.process(job); + + verify(taskFileJobService).requeue(10L, "Task is busy, waiting for previous task operation"); + verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean()); + verify(taskFileJobService, never()).markSuccess(any(), any()); + } + + @Test + void asyncFalseIntegrationSimilar() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L, 31L); + ResultFileJobHandler handler = mock(ResultFileJobHandler.class); + when(handler.moduleType()).thenReturn("SIMILAR_ASIN"); + when(handler.process(job)).thenReturn(false); + stubHandler(handler, false, false); + + allowClaim(job); + allowLock(job.getModuleType(), job.getTaskId()); + + worker.process(job); + + verify(taskFileJobService).deferRunning(11L, "Waiting for LLM/file assembly to continue"); + verify(lock).close(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerStuckScanTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerStuckScanTest.java new file mode 100644 index 00000000..abb30fea --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerStuckScanTest.java @@ -0,0 +1,284 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.config.InstanceMetadata; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +import com.nanri.aiimage.modules.brand.service.BrandTaskService; +import com.nanri.aiimage.modules.collectdata.service.CollectDataService; +import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService; +import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService; +import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService; +import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService; +import com.nanri.aiimage.modules.publish.service.PublishTaskService; +import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; +import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService; +import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService; +import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; +import com.nanri.aiimage.modules.task.mapper.FileResultMapper; +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 卡死扫描/重试耗尽路径测试(任务 81)。 + * resetStuckJobs:超时卡死 job 重置;耗尽 job 走 finalizeRetryExhausted(默认/自定义文案); + * 活跃 job 不重置;重复扫描幂等;扫描参数来自配置项。 + */ +class TaskResultFileJobWorkerStuckScanTest { + + private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class); + private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class); + private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class); + private final PublishTaskService publish = mock(PublishTaskService.class); + private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class); + private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class); + private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class); + private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class); + private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class); + private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class); + private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class); + private final BrandTaskService brand = mock(BrandTaskService.class); + private final CollectDataService collectData = mock(CollectDataService.class); + private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class); + private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class); + private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class); + private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class); + + private TaskResultFileJobWorker buildWorker() throws Exception { + List handlers = List.of( + new ShopMatchResultFileJobHandler(shopMatch, payload), + new PriceTrackResultFileJobHandler(priceTrack, payload), + new ProductRiskResultFileJobHandler(productRisk, payload), + new PublishResultFileJobHandler(publish), + new QueryAsinResultFileJobHandler(queryAsin, payload), + new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload), + new WithdrawResultFileJobHandler(withdraw, payload), + new PatrolDeleteResultFileJobHandler(patrolDelete, payload), + new AppearancePatentResultFileJobHandler(appearance), + new SimilarAsinResultFileJobHandler(similar), + new DeleteBrandResultFileJobHandler(deleteBrand), + new BrandResultFileJobHandler(brand, payload), + new CollectDataResultFileJobHandler(collectData)); + ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers); + return new TaskResultFileJobWorker( + taskFileJobService, + taskDistributedLockService, + mock(FileResultMapper.class), + mock(TaskFileJobLocalDispatcher.class), + instanceMetadata, + withdraw, brand, + registry); + } + + private static TaskFileJobEntity exhaustedJob(long jobId, long taskId, String errorMessage) { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setId(jobId); + job.setTaskId(taskId); + job.setModuleType("SIMILAR_ASIN"); + job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT); + job.setErrorMessage(errorMessage); + return job; + } + + @Test + void stuckTimeoutResetPassesConfiguredValues() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + setInt(worker, "stuckTimeoutMinutes", 45); + setInt(worker, "batchSize", 30); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(2, List.of()); + when(taskFileJobService.resetStuckRunningJobsDetailed(45, 30)).thenReturn(result); + + worker.resetStuckJobs(); + + verify(taskFileJobService).resetStuckRunningJobsDetailed(45, 30); + verify(taskFileJobService, never()).markFailureFinalized(any(), anyString()); + } + + @Test + void stuckExhaustedJobFinalizedWithDefaultMessage() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = exhaustedJob(1L, 11L, null); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of(job)); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result); + + worker.resetStuckJobs(); + + verify(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数"); + verify(taskFileJobService).markFailureFinalized(1L, "文件生成任务运行超时,已达到最大重试次数"); + } + + @Test + void stuckExhaustedJobFinalizedWithBlankErrorMessage() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = exhaustedJob(2L, 12L, " "); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of(job)); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result); + + worker.resetStuckJobs(); + + verify(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数"); + verify(taskFileJobService).markFailureFinalized(2L, "文件生成任务运行超时,已达到最大重试次数"); + } + + @Test + void stuckExhaustedJobKeepsCustomMessage() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = exhaustedJob(3L, 13L, "上游 API 限流,重试耗尽"); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of(job)); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result); + + worker.resetStuckJobs(); + + verify(similar).handleResultFileJobFailure(job, "上游 API 限流,重试耗尽"); + verify(taskFileJobService).markFailureFinalized(3L, "上游 API 限流,重试耗尽"); + } + + @Test + void stuckDefaultMessageConstant() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = exhaustedJob(4L, 14L, null); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of(job)); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result); + + worker.resetStuckJobs(); + + String expected = "文件生成任务运行超时,已达到最大重试次数"; + verify(similar).handleResultFileJobFailure(job, expected); + assertTrue(expected.contains("文件生成任务运行超时"), "默认文案含超时语义"); + } + + @Test + void stuckScanUsesConfiguredTimeout() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of()); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result); + + worker.resetStuckJobs(); + + verify(taskFileJobService).resetStuckRunningJobsDetailed(0, 0); + } + + @Test + void stuckNoResetForActiveJobs() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of()); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result); + + worker.resetStuckJobs(); + + verify(taskFileJobService).resetStuckRunningJobsDetailed(0, 0); + verify(similar, never()).handleResultFileJobFailure(any(), anyString()); + verify(taskFileJobService, never()).markFailureFinalized(any(), anyString()); + } + + @Test + void stuckScanIsIdempotent() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = exhaustedJob(5L, 15L, null); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of(job)); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)) + .thenReturn(result, new TaskFileJobService.StuckJobResetResult(0, List.of())); + + worker.resetStuckJobs(); + worker.resetStuckJobs(); + + verify(taskFileJobService, times(2)).resetStuckRunningJobsDetailed(0, 0); + verify(taskFileJobService).markFailureFinalized(5L, "文件生成任务运行超时,已达到最大重试次数"); + } + + @Test + void stuckCallbackFailureSwallowedOnScan() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = exhaustedJob(6L, 16L, null); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of(job)); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result); + org.mockito.Mockito.doThrow(new IllegalStateException("db down")) + .when(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数"); + + worker.resetStuckJobs(); + + verify(taskFileJobService, never()).markFailureFinalized(any(), anyString()); + } + + @Test + void stuckMultipleExhaustedJobsAllFinalized() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity first = exhaustedJob(7L, 17L, null); + TaskFileJobEntity second = exhaustedJob(8L, 18L, "超时"); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of(first, second)); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result); + + worker.resetStuckJobs(); + + verify(similar).handleResultFileJobFailure(first, "文件生成任务运行超时,已达到最大重试次数"); + verify(similar).handleResultFileJobFailure(second, "超时"); + verify(taskFileJobService).markFailureFinalized(7L, "文件生成任务运行超时,已达到最大重试次数"); + verify(taskFileJobService).markFailureFinalized(8L, "超时"); + } + + @Test + void stuckScanConfigGettersPresent() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + Field timeout = TaskResultFileJobWorker.class.getDeclaredField("stuckTimeoutMinutes"); + timeout.setAccessible(true); + Field batch = TaskResultFileJobWorker.class.getDeclaredField("batchSize"); + batch.setAccessible(true); + setInt(worker, "stuckTimeoutMinutes", 45); + setInt(worker, "batchSize", 30); + assertEquals(45, timeout.getInt(worker), "卡死超时可注入"); + assertEquals(30, batch.getInt(worker), "扫描批次可注入"); + Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules", + "task", "service", "TaskResultFileJobWorker.java"); + String content = new String(java.nio.file.Files.readAllBytes(source), java.nio.charset.StandardCharsets.UTF_8); + assertTrue(content.contains("stuck-timeout-minutes:30"), "@Value 默认卡死超时 30 分钟"); + assertTrue(content.contains("batch-size:20"), "@Value 默认批次 20"); + } + + @Test + void stuckUnknownModuleExhaustedSilentlyIgnored() throws Exception { + TaskResultFileJobWorker worker = buildWorker(); + TaskFileJobEntity job = exhaustedJob(9L, 19L, null); + job.setModuleType("UNKNOWN_X"); + TaskFileJobService.StuckJobResetResult result = + new TaskFileJobService.StuckJobResetResult(0, List.of(job)); + when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result); + + worker.resetStuckJobs(); + + verifyNoInteractions(similar, shopDataCrawl, withdraw); + verify(taskFileJobService).markFailureFinalized(9L, "文件生成任务运行超时,已达到最大重试次数"); + } + + private static void setInt(TaskResultFileJobWorker worker, String fieldName, int value) throws Exception { + Field field = TaskResultFileJobWorker.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.setInt(worker, value); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java index b69edc80..27a3379d 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskResultFileJobWorkerTest.java @@ -20,6 +20,7 @@ import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -32,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -61,6 +63,7 @@ class TaskResultFileJobWorkerTest { @Mock private DeleteBrandRunService deleteBrandRunService; @Mock private BrandTaskService brandTaskService; @Mock private CollectDataService collectDataService; + @Mock private ResultFileJobHandlerRegistry handlerRegistry; @InjectMocks private TaskResultFileJobWorker worker; @@ -84,6 +87,7 @@ class TaskResultFileJobWorkerTest { when(taskDistributedLockService.acquire("WITHDRAW", taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS)) .thenReturn(lock); when(fileResultMapper.selectById(resultId)).thenReturn(result); + stubHandler("WITHDRAW", new WithdrawResultFileJobHandler(withdrawTaskService, taskResultPayloadService)); worker.process(job); @@ -117,6 +121,7 @@ class TaskResultFileJobWorkerTest { taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock); when(fileResultMapper.selectById(resultId)).thenReturn(result); + stubHandler(PublishTaskService.MODULE_TYPE, new PublishResultFileJobHandler(publishTaskService)); worker.process(job); @@ -136,6 +141,7 @@ class TaskResultFileJobWorkerTest { job.setModuleType(PublishTaskService.MODULE_TYPE); job.setScopeKey("task:20142:owner:instance-b"); when(instanceMetadata.getInstanceId()).thenReturn("instance-a"); + stubHandler(PublishTaskService.MODULE_TYPE, new PublishResultFileJobHandler(publishTaskService)); worker.process(job); @@ -162,6 +168,8 @@ class TaskResultFileJobWorkerTest { when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock); when(fileResultMapper.selectById(resultId)).thenReturn(result); + stubHandler("SHOP_DATA_CRAWL", + new ShopDataCrawlResultFileJobHandler(shopDataCrawlTaskService, taskResultPayloadService)); worker.process(job); @@ -187,6 +195,8 @@ class TaskResultFileJobWorkerTest { doThrow(new IllegalStateException("upload failed")) .when(shopDataCrawlTaskService).processResultFileJob(job); when(taskFileJobService.isRetryExhausted(job.getId())).thenReturn(true); + stubHandler("SHOP_DATA_CRAWL", + new ShopDataCrawlResultFileJobHandler(shopDataCrawlTaskService, taskResultPayloadService)); worker.process(job); @@ -207,6 +217,7 @@ class TaskResultFileJobWorkerTest { TaskFileJobService.StuckJobResetResult resetResult = new TaskFileJobService.StuckJobResetResult(0, List.of(job)); when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult); + stubSimilarAsinRegistry(); worker.resetStuckJobs(); @@ -221,6 +232,7 @@ class TaskResultFileJobWorkerTest { TaskFileJobService.StuckJobResetResult resetResult = new TaskFileJobService.StuckJobResetResult(0, List.of(first, second)); when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult); + stubSimilarAsinRegistry(); doThrow(new IllegalStateException("owner mismatch")) .doNothing() .when(similarAsinTaskService) @@ -241,6 +253,7 @@ class TaskResultFileJobWorkerTest { new TaskFileJobService.StuckJobResetResult(0, List.of(job)); when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)) .thenReturn(resetResult, resetResult); + stubSimilarAsinRegistry(); doThrow(new IllegalStateException("temporary database failure")) .doNothing() .when(similarAsinTaskService) @@ -254,6 +267,18 @@ class TaskResultFileJobWorkerTest { verify(taskFileJobService).markFailureFinalized(job.getId(), job.getErrorMessage()); } + private void stubSimilarAsinRegistry() { + ResultFileJobHandler similarHandler = + new SimilarAsinResultFileJobHandler(similarAsinTaskService); + lenient().when(handlerRegistry.resolve("SIMILAR_ASIN")).thenReturn(similarHandler); + lenient().when(handlerRegistry.asMap()).thenReturn(Map.of("SIMILAR_ASIN", similarHandler)); + } + + private void stubHandler(String moduleType, ResultFileJobHandler handler) { + lenient().when(handlerRegistry.resolve(moduleType)).thenReturn(handler); + lenient().when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler)); + } + private void allowClaim(TaskFileJobEntity job) { TaskFileJobEntity claim = new TaskFileJobEntity(); claim.setId(job.getId()); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/WithdrawResultFileJobHandlerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/WithdrawResultFileJobHandlerTest.java new file mode 100644 index 00000000..e660121c --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/WithdrawResultFileJobHandlerTest.java @@ -0,0 +1,95 @@ +package com.nanri.aiimage.modules.task.service; + +import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity; +import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +/** + * WithdrawResultFileJobHandler 测试(任务 66)。 + * onSuccess 扩展:tryFinalizeTask 在 markSuccess 之后调用(原 finalizeWithdraw 标志语义,时机严格保持); + * cleanup 走 payload.deleteLatest。 + */ +class WithdrawResultFileJobHandlerTest { + + private final WithdrawTaskService withdrawTaskService = mock(WithdrawTaskService.class); + private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class); + private final ResultFileJobHandler handler = new WithdrawResultFileJobHandler( + withdrawTaskService, taskResultPayloadService); + + @Test + void moduleType() { + assertEquals("WITHDRAW", handler.moduleType()); + } + + @Test + void processDelegates() { + TaskFileJobEntity job = new TaskFileJobEntity(); + handler.process(job); + verify(withdrawTaskService).processResultFileJob(job); + } + + @Test + void cleanupDeleteLatest() { + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(42L); + job.setModuleType("WITHDRAW"); + job.setScopeKey("scope-1"); + handler.cleanup(job); + verify(taskResultPayloadService).deleteLatest(42L, "WITHDRAW", "scope-1"); + } + + @Test + void finalizeAfterSuccessOrder() { + // 原 Worker:markSuccess → cleanupAfterSuccess → finalizeWithdraw → tryFinalizeTask + // Handler 语义:cleanup(成功路径)与 onSuccess(finalize)分离,onSuccess 只负责 finalize + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(9L); + handler.cleanup(job); + handler.onSuccess(job); + verify(taskResultPayloadService).deleteLatest(9L, null, null); + verify(withdrawTaskService).tryFinalizeTask(9L, false); + } + + @Test + void finalizeCalledOnce() { + // 一次成功路径只 finalize 一次:process/cleanup 均不触发 finalize,仅 onSuccess 触发 + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(9L); + handler.process(job); + handler.cleanup(job); + handler.onSuccess(job); + verify(withdrawTaskService, times(1)).tryFinalizeTask(9L, false); + } + + @Test + void finalizeFalseResult() { + // 非成功路径不触发 onSuccess:Worker 只在 dispatch 返回 true 且 markSuccess 后调用 + TaskFileJobEntity job = new TaskFileJobEntity(); + job.setTaskId(9L); + handler.onFailure(job, "failed"); + verify(withdrawTaskService, never()).tryFinalizeTask(9L, false); + assertTrue(handler.process(job), "process 成功返回 true"); + } + + @Test + void ownerScopedFalse() { + assertFalse(handler.isOwnerScoped()); + } + + @Test + void nullJobGuarded() { + assertTrue(handler.process(null)); + verify(withdrawTaskService).processResultFileJob(null); + handler.onSuccess(null); + verify(withdrawTaskService, never()).tryFinalizeTask(null, false); + assertFalse(handler.supportsAsyncOffload()); + } +} diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index fc89455b..02d4bc80 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -2181,6 +2181,67 @@ def update_column(cid): }) +def _reorder_columns_by_list(data): + """按前端拖动后的完整同级顺序重排菜单,只回写排序真正变化的记录。""" + raw_ids = data.get('ordered_ids') + if raw_ids is None: + raw_ids = data.get('orderedIds') + if not isinstance(raw_ids, list) or not raw_ids: + return jsonify({'success': False, 'error': '菜单重排参数无效'}), 400 + ordered_ids = [] + for raw_id in raw_ids: + try: + parsed_id = int(raw_id) + except (TypeError, ValueError): + return jsonify({'success': False, 'error': '菜单重排参数无效'}), 400 + if parsed_id in ordered_ids: + return jsonify({'success': False, 'error': '菜单重排参数重复'}), 400 + ordered_ids.append(parsed_id) + menu_type = (data.get('menu_type') or data.get('menuType') or 'admin').strip() or 'admin' + items, error_response, status = _java_permission_items(menu_type=menu_type) + if error_response is not None: + return error_response, status + by_id = {} + for item in _iter_permission_items(items): + try: + by_id[int(item.get('id'))] = item + except (TypeError, ValueError): + continue + missing = [item_id for item_id in ordered_ids if item_id not in by_id] + if missing: + return jsonify({'success': False, 'error': '栏目不存在'}), 400 + parent_ids = { + by_id[item_id].get('parentId') if by_id[item_id].get('parentId') is not None + else by_id[item_id].get('parent_id') + for item_id in ordered_ids + } + if len(parent_ids) > 1: + return jsonify({'success': False, 'error': '只能在同级菜单之间排序'}), 400 + for index, item_id in enumerate(ordered_ids): + item = by_id[item_id] + sort_order = (index + 1) * 10 + current_order = item.get('sortOrder') + if current_order is None: + current_order = item.get('sort_order') + if current_order is not None and int(current_order) == sort_order: + continue + _, error_response, status = _proxy_permission_java( + 'PUT', + f'/api/admin/permission-menus/{item_id}', + json_data={ + 'name': item.get('name') or '', + 'columnKey': item.get('columnKey') or item.get('column_key') or '', + 'menuType': item.get('menuType') or item.get('menu_type') or menu_type, + 'routePath': item.get('routePath') or item.get('route_path') or '', + 'sortOrder': sort_order, + 'parentId': item.get('parentId') if item.get('parentId') is not None else item.get('parent_id'), + }, + ) + if error_response is not None: + return error_response, status + return jsonify({'success': True, 'msg': '排序更新成功'}) + + @admin_api.route('/column/reorder', methods=['POST']) @admin_required def reorder_column(): @@ -2188,6 +2249,8 @@ def reorder_column(): if denied: return denied data = request.get_json() or {} + if data.get('ordered_ids') is not None or data.get('orderedIds') is not None: + return _reorder_columns_by_list(data) try: column_id = int(data.get('column_id')) target_id = int(data.get('target_id')) diff --git a/backend/config.py b/backend/config.py index ee25b7a6..42a6c383 100644 --- a/backend/config.py +++ b/backend/config.py @@ -7,10 +7,9 @@ except ImportError: def load_dotenv(*args, **kwargs): return False -base_url = "https://api.coze.cn/v1" -coze_token = "sat_12nW40INoJxArrDXbY4lSCoudbqkOYTcphC99BP2efWyzxmsk4q81WDX3ezWgqZ5" -workflow_id = "7608812635877900322" -STITCH_WORKFLOW_ID = "7608813873483300907" +base_url = "" +coze_token = "" +workflow_id = "" BASE_DIR = Path(__file__).resolve().parent LOADED_ENV_FILES = [] diff --git a/backend/requirement.txt b/backend/requirement.txt index 5cfef75c..c7e9b685 100644 --- a/backend/requirement.txt +++ b/backend/requirement.txt @@ -12,7 +12,6 @@ charset-normalizer==3.4.4 click==8.1.8 clr_loader==0.2.10 colorama==0.4.6 -cozepy==0.20.0 crcmod-plus==2.3.1 cryptography==41.0.0 distro==1.9.0 diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index f767fc3a..65db179f 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -3694,12 +3694,13 @@ #panel-digital-human-version .digital-human-version-table-scroll > table { min-width: 1120px; } #panel-history .history-table-scroll > table { min-width: 820px; } - .query-asin-table-scroll > table, .skip-price-asin-table-scroll > table { min-width: 1180px; table-layout: fixed; } + .query-asin-table-scroll > table, .skip-price-asin-table-scroll > table { min-width: 1280px; table-layout: fixed; } #panel-query-asin .query-asin-table-scroll th:nth-child(1), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(1) { width: 6%; } #panel-query-asin .query-asin-table-scroll th:nth-child(2), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(2) { width: 11%; } #panel-query-asin .query-asin-table-scroll th:nth-child(3), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(3) { width: 12%; } - #panel-query-asin .query-asin-table-scroll th:nth-child(n+4), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(n+4) { width: 14.2%; } - #panel-query-asin .query-asin-table-scroll td, #panel-skip-price-asin .skip-price-asin-table-scroll td { vertical-align: top; } + #panel-query-asin .query-asin-table-scroll th:nth-child(n+4), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(n+4) { width: 12.2%; } + #panel-query-asin .query-asin-table-scroll th:last-child, #panel-skip-price-asin .skip-price-asin-table-scroll th:last-child { width: 10%; } + #panel-query-asin .query-asin-table-scroll td, #panel-skip-price-asin .skip-price-asin-table-scroll td { vertical-align: middle; } .table-ellipsis { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .asin-cell-layout { display: flex; flex-direction: column; align-items: stretch; gap: 8px; min-width: 0; } @@ -3713,6 +3714,56 @@ .skip-price-country { color: var(--c-text-2); font-weight: 600; } .skip-price-entry input { min-width: 0 !important; width: auto !important; } + /* 站点列只做纯展示:点击 ASIN 即复制,编辑统一收进右侧抽屉 */ + .asin-copy-text { display: block; max-width: 100%; margin: -3px 0 -3px -7px; padding: 3px 7px; border: 1px solid transparent; border-radius: var(--radius-sm); background: none; color: inherit; font: inherit; font-size: 13.5px; font-variant-numeric: tabular-nums; line-height: 1.5; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } + .asin-copy-text:hover { border-color: #c7d7e5; background: #eef4fa; color: var(--c-primary-strong); } + .asin-copy-text:focus-visible { outline: 2px solid #4f78a5; outline-offset: 1px; } + .asin-copy-text.is-copied { border-color: #b9e3ca; background: #eaf8f0; color: var(--c-success); } + .asin-col-actions { position: sticky; right: 0; z-index: 2; text-align: right; background: #ffffff; box-shadow: -9px 0 12px -10px rgba(31, 48, 66, 0.45); } + th.asin-col-actions { z-index: 3; background: #f7f8fb; } + tbody tr:hover td.asin-col-actions { background: #f6f8fd; } + + /* 表单里「输入框 + 附属按钮」组合,避免按钮溢出所在列挤掉列间距 */ + .field-with-action { display: flex; align-items: center; gap: 8px; min-width: 0; } + .field-with-action > input, .field-with-action > select { flex: 1 1 auto; width: auto; } + .field-with-action > .btn { flex: 0 0 auto; white-space: nowrap; margin-top: 0; } + + .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; white-space: nowrap; clip: rect(0 0 0 0); clip-path: inset(50%); } + + /* 菜单列表拖动排序 */ + #panel-columns .columns-table-scroll > table { min-width: 820px; } + .columns-drag-tip { margin: -8px 0 14px; font-size: 13px; color: var(--c-text-2); } + .column-drag-cell { width: 48px; } + .column-drag-handle { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 1px solid transparent; border-radius: var(--radius-sm); background: none; color: #93a6b8; font-size: 15px; line-height: 1; cursor: grab; user-select: none; } + .column-drag-handle:hover { border-color: #cbd9e6; background: #eef4fa; color: var(--c-primary-strong); } + .column-drag-handle:focus-visible { outline: 2px solid #4f78a5; outline-offset: 1px; } + .column-drag-handle:active { cursor: grabbing; } + tr.is-column-dragging td { opacity: 0.45; } + tr.is-column-drop-before td { box-shadow: inset 0 2px 0 var(--c-primary); } + tr.is-column-drop-after td { box-shadow: inset 0 -2px 0 var(--c-primary); } + .column-child-mark { margin-right: 6px; color: #9fb1c2; } + + /* 右侧抽屉:站点数量增长时仍能在一处维护完整配置 */ + .drawer-mask { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(45, 66, 86, 0.36); backdrop-filter: blur(4px); } + .drawer-mask.show { display: block; animation: modal-fade-in 0.16s ease; } + .drawer { position: absolute; top: 0; right: 0; bottom: 0; display: flex; flex-direction: column; width: min(460px, 100%); background: #ffffff; border-left: 1px solid #c7d7e5; box-shadow: var(--shadow-pop); animation: drawer-slide-in 0.18s ease; } + @keyframes drawer-slide-in { from { transform: translateX(26px); opacity: 0.4; } to { transform: none; opacity: 1; } } + .drawer-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 22px 14px; border-bottom: 1px solid #e6edf4; } + .drawer-header h3 { margin: 0; font-size: 15px; font-weight: 600; color: var(--c-text); } + .drawer-subtitle { margin: 6px 0 0; font-size: 13px; color: var(--c-text-2); } + .drawer-hint { margin: 0 0 14px; font-size: 12.5px; color: var(--c-text-3); } + .drawer-close { flex: 0 0 auto; width: 30px; height: 30px; border: 1px solid transparent; border-radius: var(--radius-sm); background: none; color: var(--c-text-2); font-size: 20px; line-height: 1; cursor: pointer; } + .drawer-close:hover { border-color: #cbd9e6; background: #eef4fa; color: var(--c-text); } + .drawer-body { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 22px; } + .drawer-body .form-group:last-of-type { margin-bottom: 0; } + .drawer-footer { display: flex; gap: 8px; padding: 14px 22px 18px; border-top: 1px solid #e6edf4; } + .drawer-price-grid { display: grid; grid-template-columns: minmax(0, 1fr) 128px; gap: 8px; } + .drawer-price-grid input { min-width: 0; } + @media (max-width: 560px) { + .drawer { width: 100%; } + .drawer-price-grid { grid-template-columns: minmax(0, 1fr); } + } + #panel-query-asin .panel-box > .form-row, #panel-skip-price-asin .panel-box > .form-row { display: grid; grid-template-columns: minmax(150px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) auto auto; gap: 12px; align-items: end; } #panel-query-asin .panel-box > .form-row .form-group, #panel-skip-price-asin .panel-box > .form-row .form-group { min-width: 0 !important; } @@ -4063,17 +4114,11 @@ -

    - - -
    -
    - - -
    -
    - - +
    + +
    @@ -4092,17 +4137,16 @@

    菜单列表

    +

    拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。

    + - - - @@ -4515,25 +4559,24 @@

    新增 ASIN

    -
    +
    -
    +
    -
    +
    - -
    -
    - -
    - +
    +
    +
    +
    +
    + @@ -4634,9 +4678,9 @@

    新增查询 ASIN

    -
    +
    -
    +
    @@ -4645,8 +4689,8 @@
    -
    - +
    +
    @@ -4740,6 +4784,7 @@
    + @@ -5037,21 +5082,16 @@ -
    排序 ID 菜单名称菜单标识 菜单类型 上级菜单排序页面路径 创建时间 操作
    法国 意大利 西班牙操作
    法国 意大利 西班牙操作