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

Some files were not shown because too many files have changed in this diff Show More