task-98: 移除 similar-asin/appearance-patent 模块 Coze,状态机与共享组件改名 LLM
Build Backend JAR / build (push) Has been cancelled

- 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 启动校验)
This commit is contained in:
2026-09-01 02:19:45 +08:00
parent 0cc7380205
commit e2607ab723
109 changed files with 6903 additions and 4582 deletions
@@ -10,7 +10,7 @@ import java.util.function.BiConsumer;
import java.util.function.Function;
/**
* Coze 回流数据按 ID 分组传播工具
* LLM 回流数据按 ID 分组传播工具
*
* <p>业务背景
* 解析行按 Excel 行顺序排列ID 形如 "1""1_1""1_2""2""2_1"
@@ -33,7 +33,7 @@ import java.util.function.Function;
* <p>使用方式
* <pre>
* // 专利结论列组内任一行结论命中 "已侵权" "侵权"组内都改为该标准值
* CozeGroupResultPropagator.propagateByGroup(
* GroupResultPropagator.propagateByGroup(
* receivedRows,
* AppearancePatentParsedRowVo::getDisplayId,
* row -&gt; findResultRow(row, resultMap),
@@ -44,9 +44,9 @@ import java.util.function.Function;
* );
* </pre>
*/
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<String> NEGATIVE_KEYWORDS = Arrays.asList("没有", "", "", "");
private CozeGroupResultPropagator() {
private GroupResultPropagator() {
}
/**
@@ -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 连接池容量。 */
@@ -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 单例懒加载。
@@ -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;
}
@@ -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<CozeCredential> 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
* 不立即合并到 chunkfinalize 前一次性按 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。应与 cozeSubmitMinIntervalMillis5000ms)保持 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;
}
}
@@ -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<String> 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<MeterRegistry> 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<ResultFileJobHandler> 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);
}
}
@@ -11,7 +11,7 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
/**
* Task 75虚拟线程任务排队闸门Coze 执行池的信号量只限制"正在执行"
* Task 75虚拟线程任务排队闸门任务执行池的信号量只限制"正在执行"
* 并发度提交侧仍会在虚拟线程里无限排队此闸门在提交时统计"已受理未启动"
* 的等待数达到上限立即拒绝并记录指标防止等待队列无界堆积
* <ul>
@@ -21,14 +21,14 @@ import java.util.concurrent.atomic.AtomicInteger;
* </ul>
*/
@Slf4j
public class CozeTaskQueueGate implements TaskExecutor {
public class TaskQueueGate implements TaskExecutor {
private final TaskExecutor delegate;
private final int maxWaiting;
private final ObjectProvider<MeterRegistry> meterRegistryProvider;
private final AtomicInteger waiting = new AtomicInteger();
public CozeTaskQueueGate(TaskExecutor delegate, int maxWaiting,
public TaskQueueGate(TaskExecutor delegate, int maxWaiting,
ObjectProvider<MeterRegistry> 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;
}
@@ -13,13 +13,12 @@ import java.util.concurrent.TimeUnit;
/**
* Task 78:外部调用统一指标记录器。
* 所有外部 HTTP 客户端(Coze / 品牌检查 / 紫鸟)在构建 RestClient 时挂载
* 所有外部 HTTP 客户端(LLM / 品牌检查 / 紫鸟)在构建 RestClient 时挂载
* {@link #interceptor(String)} 拦截器,统一记录:
* <ul>
* <li>耗时:{@code aiimage.external-call.duration}client + result 标签);</li>
* <li>失败率:{@code aiimage.external-call.total}result=success/failure2xx 之外计失败);</li>
* <li>payload 字节:{@code aiimage.external-call.payload.bytes}(请求体字节数)</li>
* <li>重试次数:{@code aiimage.external-call.retry.total}(客户端重试循环内调用)。</li>
* <li>payload 字节:{@code aiimage.external-call.payload.bytes}(请求体字节数)</li>
* </ul>
* 指标注册表通过 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) {
@@ -30,12 +30,12 @@ import java.util.regex.Pattern;
/**
* 外观专利检测直连 LLMOpenAI 兼容 /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<CozeResult> parseResults(String raw) throws Exception {
private List<LlmResult> 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<CozeResult> results = new ArrayList<>();
List<LlmResult> 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<AppearancePatentResultRowDto> mergeRows(List<AppearancePatentResultRowDto> rows, List<CozeResult> results) {
Map<String, CozeResult> resultByGroupKey = new LinkedHashMap<>();
Map<String, CozeResult> resultByCompositeKey = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsinCountry = new LinkedHashMap<>();
Map<String, CozeResult> resultByAsin = new LinkedHashMap<>();
Map<String, CozeResult> resultByRowId = new LinkedHashMap<>();
for (CozeResult result : results) {
private List<AppearancePatentResultRowDto> mergeRows(List<AppearancePatentResultRowDto> rows, List<LlmResult> results) {
Map<String, LlmResult> resultByGroupKey = new LinkedHashMap<>();
Map<String, LlmResult> resultByCompositeKey = new LinkedHashMap<>();
Map<String, LlmResult> resultByAsinCountry = new LinkedHashMap<>();
Map<String, LlmResult> resultByAsin = new LinkedHashMap<>();
Map<String, LlmResult> 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<String> 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,
@@ -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;
/**
@@ -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")
@@ -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;
}
}
@@ -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<AppearancePatentResultRowDto> 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<TaskChunkEntity> 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<TaskScopeStateEntity> inputStates = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
.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<TaskChunkEntity>()
@@ -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<TaskScopeStateEntity>()
.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> T inNewTransaction(Supplier<T> action) {
@@ -1209,7 +1209,7 @@ public class AppearancePatentTaskService {
}
}
private List<AppearancePatentResultRowDto> pickGroupRepresentativesForCoze(java.util.Collection<AppearancePatentResultRowDto> rows) {
private List<AppearancePatentResultRowDto> pickGroupRepresentativesForLlm(java.util.Collection<AppearancePatentResultRowDto> rows) {
Map<String, List<AppearancePatentResultRowDto>> groupedRows = new LinkedHashMap<>();
if (rows == null) {
return List.of();
@@ -1223,7 +1223,7 @@ public class AppearancePatentTaskService {
}
List<AppearancePatentResultRowDto> representatives = new ArrayList<>();
for (List<AppearancePatentResultRowDto> 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<AppearancePatentResultRowDto> cozeRows,
private void mergeLlmRowsIntoSubmittedChunks(FileTaskEntity task,
List<AppearancePatentResultRowDto> llmRows,
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId) {
mergeCozeRowsIntoSubmittedChunks(task, cozeRows, allRowsByBaseId, null, null);
mergeLlmRowsIntoSubmittedChunks(task, llmRows, allRowsByBaseId, null, null);
}
private void mergeCozeRowsIntoSubmittedChunks(FileTaskEntity task,
List<AppearancePatentResultRowDto> cozeRows,
private void mergeLlmRowsIntoSubmittedChunks(FileTaskEntity task,
List<AppearancePatentResultRowDto> llmRows,
Map<String, List<AppearancePatentParsedRowVo>> allRowsByBaseId,
String fallbackScopeHash,
Integer fallbackChunkIndex) {
if (task == null || cozeRows == null || cozeRows.isEmpty()) {
if (task == null || llmRows == null || llmRows.isEmpty()) {
return;
}
List<TaskChunkEntity> chunks = loadSubmittedChunks(task.getId());
@@ -1578,7 +1578,7 @@ public class AppearancePatentTaskService {
chunkByKey.put(chunkKey, chunk);
}
Map<String, Map<String, AppearancePatentResultRowDto>> 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<TaskScopeStateEntity>()
.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<AppearancePatentParsedRowVo> receivedRows,
Map<String, AppearancePatentResultRowDto> 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<String, Integer> 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("调用超时")
@@ -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<CozeCredentialEntity> {
}
@@ -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;
}
@@ -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 = moduleTypevalue = 仅作占位,仅用 putIfAbsent 语义判断"是否已经 WARN 过"。
*/
private final ConcurrentHashMap<String, Boolean> stripeWarnedModules = new ConcurrentHashMap<>();
private final CozeCredentialMapper cozeCredentialMapper;
private final StringRedisTemplate stringRedisTemplate;
public List<CozeCredential> listEnabled(String moduleType) {
if (moduleType == null || moduleType.isBlank()) {
return List.of();
}
try {
List<CozeCredentialEntity> rows = cozeCredentialMapper.selectList(new LambdaQueryWrapper<CozeCredentialEntity>()
.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<CozeCredential> credentials) {
return chooseRoundRobin(moduleType, credentials, 1);
}
public CozeCredential chooseRoundRobin(String moduleType, List<CozeCredential> 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);
}
}
}
@@ -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);
}
@@ -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
@@ -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<Void> result(
@Parameter(description = "相似 ASIN 检测任务 ID,任务必须处于 RUNNING 状态", required = true, example = "3938")
@@ -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;
}
@@ -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 = "本次解析的源文件列表")
@@ -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<String> 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;
}
@@ -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")
@@ -35,12 +35,12 @@ import java.util.concurrent.atomic.AtomicInteger;
/**
* P2-11:相似ASIN 图片异步预热服务。
*
* <p>背景:assemble 阶段({@code assembleResultWorkbook})需要把 Coze 回包中的 main_url /
* <p>背景:assemble 阶段({@code assembleResultWorkbook})需要把 LLM 回包中的 main_url /
* puzzle_img1 / puzzle_img2 下载并 resize 后嵌入 xlsx。当任务行数到 1000+ 时,串行 +
* 短池下载会把整个 assemble 拖到 244s / 918s。改造点:
*
* <ul>
* <li>每次 {@code mergeCozeRowsIntoChunk} 拿到新 cozeRows 时,调用 {@link #enqueue}
* <li>每次 {@code mergeLlmRowsIntoChunk} 拿到新 llmRows 时,调用 {@link #enqueue}
* 立即丢入预热队列;同 task 串行排队({@link #inflight}),避免多个 batch 同时打爆图片源站;</li>
* <li>预热成功的缩略图字节落表 {@code biz_task_image_cache}(由 P2-12 提供),跨任务复用;</li>
* <li>所有路径 best-effort:预热失败、DB 写入失败都吞掉,assemble 阶段会回退到原下载链路兜底。</li>
@@ -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<String> urls) {
@@ -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. 图片准备 batch103226,无条件执行):alibaba 前 8 张拼图1、8~16 张拼图2
@@ -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
@@ -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 {
@@ -1,7 +1,7 @@
package com.nanri.aiimage.modules.similarasin.util;
/**
* Task 19Coze 请求/响应及 Python 回传日志的采样与截断工具。
* Task 19LLM 请求/响应及 Python 回传日志的采样与截断工具。
* truncate 保证超长正文输出有界(前缀 + 长度 + 后缀),不抛异常、不破坏代理对;
* shouldLog 按每 everyN 次采样一次(counter % everyN == 0),计数 0 恒采样。
* 两个方法均为纯函数,可在日志点直接内联使用。
@@ -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 阶段每次都重新下载。
*
* <p>对应表 {@code biz_task_image_cache}migration V53)。
@@ -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;
@@ -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 Handler04 注册表)。
* process 返回 Service 的 booleanfalse=等待 coze 异步结果,Worker 保持运行心跳);
* cleanup 委托 cleanupResultFileJob;支持异步 offloadowner 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;
}
}
@@ -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 Handler04 注册表)。
* 注意:resultFileUrl 解析特例(resolveResultObjectKey,无 resultId 也走)
* 保留在 Worker 公共路径 resolveResultFileUrlHandler 不接管 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;
}
}
@@ -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 Handler04 注册表)。
* 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;
}
}
@@ -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 Handler04 注册表)。
*/
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);
}
}
@@ -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 Handler04 注册表)。
*/
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());
}
}
@@ -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 Handler04 注册表)。
*/
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());
}
}
@@ -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 Handler04 注册表)。
*/
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());
}
}
@@ -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 Handler04 注册表)。
* cleanup 委托 PublishTaskService.cleanupResultFileJob(不走 payload.deleteLatest);
* owner scopedscopeKey :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;
}
}
@@ -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 Handler04 注册表)。
*/
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());
}
}
@@ -0,0 +1,37 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
/**
* 结果文件 Job 处理 Handler04 注册表)。
* 契约: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;
}
}
@@ -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<String, ResultFileJobHandler> handlersByModuleType;
public ResultFileJobHandlerRegistry(List<ResultFileJobHandler> handlers) {
Map<String, ResultFileJobHandler> 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<String> moduleTypes() {
return handlersByModuleType.keySet();
}
public Map<String, ResultFileJobHandler> asMap() {
return handlersByModuleType;
}
/**
* 启动 fail-fast 校验:expected 中每个 moduleType 都必须有且仅有一个 Handler,
* 缺失时抛 IllegalStateException 并列出缺失清单。
*/
public void validateCoverage(Set<String> expectedModuleTypes) {
List<String> 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));
}
}
}
@@ -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 Handler04 注册表)。
* 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;
}
}
@@ -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 Handler04 注册表)。
* 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());
}
}
@@ -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 Handler04 注册表)。
* process 返回 Service 的 booleanfalse=等待 llm 异步结果);
* onFailure 委托 handleResultFileJobFailure(重试耗尽回调);异步 offloadowner 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;
}
}
@@ -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);
}
}
}
@@ -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 Handler04 注册表)。
* 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());
}
}