task-98: 移除 similar-asin/appearance-patent 模块 Coze,状态机与共享组件改名 LLM
Build Backend JAR / build (push) Has been cancelled
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:
+5
-5
@@ -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 -> 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,
|
||||
* 不立即合并到 chunk;finalize 前一次性按 chunkScopeHash 分组合并,
|
||||
* 把 OSS chunk 读写从 1000+ 次降到 chunk 数量级。
|
||||
* 仅作用于"正常 poll DONE"路径;失败 batch / 单 batch 任务 / 其他
|
||||
* 11 个 mergeCozeRowsIntoChunk 调用点保留原立即 merge 行为。
|
||||
* 出现问题时可通过 AIIMAGE_SIMILAR_ASIN_COZE_RESULT_BUFFER_ENABLED=false
|
||||
* 一键回滚到老路径。
|
||||
*/
|
||||
private boolean cozeResultBufferEnabled = true;
|
||||
|
||||
/**
|
||||
* P0-4:单 credential 抢 Coze 提交锁的最长等待时间(毫秒)。
|
||||
* 原硬编码 1000ms,在高并发 split retry 时大量抛 "Coze submit throttle lock timeout"
|
||||
* 并把整批行 markFailed。应与 cozeSubmitMinIntervalMillis(5000ms)保持 1.5-2 倍关系,
|
||||
* 默认 10000ms 给抢锁更多时间。
|
||||
*/
|
||||
private long cozeSubmitLockWaitMillis = 10000L;
|
||||
private boolean llmResultBufferEnabled = true;
|
||||
|
||||
/**
|
||||
* 解析接口返回的预览行/预览组数量上限。
|
||||
@@ -193,15 +137,9 @@ public class SimilarAsinProperties {
|
||||
*/
|
||||
private int imagePrefetchBudgetSeconds = 60;
|
||||
|
||||
/**
|
||||
* P0-4:抢 Coze 提交锁失败后下次重试间隔(毫秒)。
|
||||
* 原硬编码 500ms,会在指数退避算法中作为基础值(500/1000/2000/4000ms 上限 4000)。
|
||||
*/
|
||||
private long cozeSubmitLockRetryDelayMillis = 500L;
|
||||
|
||||
/**
|
||||
* 货源查询直连 LLM 模式开关(默认 true:新任务与存量 PENDING 批次都走直连 LLM,
|
||||
* 不再经过 Coze)。false 时回退到原 Coze 工作流链路(轮询/重试状态机保留)。
|
||||
* 不再经过工作流中转)。
|
||||
*/
|
||||
private boolean directLlmEnabled = true;
|
||||
|
||||
@@ -230,11 +168,4 @@ public class SimilarAsinProperties {
|
||||
|
||||
/** 拼接图/主图下载超时(秒),慢源图片较多时放大该值。 */
|
||||
private int llmImageDownloadTimeoutSeconds = 10;
|
||||
|
||||
@Data
|
||||
public static class CozeCredential {
|
||||
private String name;
|
||||
private String workflowId;
|
||||
private String token;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -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;
|
||||
}
|
||||
+2
-11
@@ -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/failure,2xx 之外计失败);</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) {
|
||||
|
||||
+25
-25
@@ -30,12 +30,12 @@ import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 外观专利检测:直连 LLM(OpenAI 兼容 /v1/chat/completions),
|
||||
* 每行并发跑"商标关键词提取"与"外观侵权检测"两个请求,结果语义与原 Coze 工作流对齐。
|
||||
* 每行并发跑"商标关键词提取"与"外观侵权检测"两个请求,结果语义与原 LLM 工作流对齐。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class AppearancePatentCozeClient {
|
||||
public class AppearancePatentLlmClient {
|
||||
|
||||
private static final MediaType APPLICATION_JSON_UTF8 = new MediaType("application", "json", StandardCharsets.UTF_8);
|
||||
private static final String INFRINGEMENT = "侵权";
|
||||
@@ -44,7 +44,7 @@ public class AppearancePatentCozeClient {
|
||||
private static final String APPEARANCE_ANOMALY = "外观识别异常";
|
||||
private static final String MISSING_ROW_DATA = "爬虫数据缺失";
|
||||
|
||||
/** 商标关键词提取系统提示词(与原 Coze 工作流一致) */
|
||||
/** 商标关键词提取系统提示词(与原 LLM 工作流一致) */
|
||||
private static final String TITLE_SYSTEM_PROMPT =
|
||||
"你是品牌词提取工具。从用户输入的商品标题中,提取实际出现的品牌、商标、企业、平台名称及违规关键词。\n\n"
|
||||
+ "硬性约束:\n"
|
||||
@@ -57,7 +57,7 @@ public class AppearancePatentCozeClient {
|
||||
+ "* **兜底输出**:如果文本中确实没有任何商标、品牌或违规词,直接输出 `\"无\"`。\n"
|
||||
+ "* **零干扰输出**:**绝对不要**包含任何引言、解释、前缀、多余的空格或标点符号。";
|
||||
|
||||
/** 外观专利检测系统提示词(与原 Coze 工作流一致) */
|
||||
/** 外观专利检测系统提示词(与原 LLM 工作流一致) */
|
||||
private static final String APPEARANCE_SYSTEM_PROMPT =
|
||||
"# 角色定义\n"
|
||||
+ "你是一位极其严谨的跨境电商知识产权(IP)律师兼视觉侵权鉴定专家。你的任务是基于用户提供的产品图片和描述,评估该产品在亚马逊等平台销售时的**外观设计(Design Patent)侵权风险**。\n\n"
|
||||
@@ -158,7 +158,7 @@ public class AppearancePatentCozeClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单行检测:与原 Coze 工作流语义对齐。
|
||||
* 单行检测:与原 LLM 工作流语义对齐。
|
||||
* 任一 LLM 失败或数据缺失时走工作流"默认值"分支:appearance=外观识别异常、
|
||||
* title 保留原始标题、title_reason/appearance_reason 填充对应错误信息。
|
||||
*/
|
||||
@@ -197,7 +197,7 @@ public class AppearancePatentCozeClient {
|
||||
String titleReason = titleFailed ? titleError : firstNonBlank(rawTitle, MISSING_ROW_DATA);
|
||||
return applyRowFallback(resultRow, rawTitle, titleReason, appearanceFailed ? appearanceReason : "");
|
||||
}
|
||||
CozeResult result = new CozeResult(
|
||||
LlmResult result = new LlmResult(
|
||||
row.getGroupKey(),
|
||||
row.getId(),
|
||||
row.getAsin(),
|
||||
@@ -219,7 +219,7 @@ public class AppearancePatentCozeClient {
|
||||
String title,
|
||||
String titleReason,
|
||||
String appearanceReason) {
|
||||
CozeResult result = new CozeResult(
|
||||
LlmResult result = new LlmResult(
|
||||
row.getGroupKey(),
|
||||
row.getId(),
|
||||
row.getAsin(),
|
||||
@@ -475,7 +475,7 @@ public class AppearancePatentCozeClient {
|
||||
}
|
||||
}
|
||||
|
||||
private List<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,
|
||||
+7
-7
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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")
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+77
-77
@@ -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("调用超时")
|
||||
|
||||
-9
@@ -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> {
|
||||
}
|
||||
-25
@@ -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;
|
||||
}
|
||||
-200
@@ -1,200 +0,0 @@
|
||||
package com.nanri.aiimage.modules.coze.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.nanri.aiimage.modules.coze.mapper.CozeCredentialMapper;
|
||||
import com.nanri.aiimage.modules.coze.model.entity.CozeCredentialEntity;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CozeCredentialPoolService {
|
||||
|
||||
private static final Duration INFLIGHT_TTL = Duration.ofMinutes(30);
|
||||
|
||||
/**
|
||||
* 每个 moduleType 只 WARN 一次,避免高频日志噪音。
|
||||
* key = moduleType,value = 仅作占位,仅用 putIfAbsent 语义判断"是否已经 WARN 过"。
|
||||
*/
|
||||
private final ConcurrentHashMap<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
-1536
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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")
|
||||
|
||||
+4
-4
@@ -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;
|
||||
}
|
||||
|
||||
+3
-3
@@ -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 = "本次解析的源文件列表")
|
||||
|
||||
+20
-20
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+3
-3
@@ -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) {
|
||||
|
||||
+2
-2
@@ -28,8 +28,8 @@ import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
/**
|
||||
* 货源查询直连 LLM 编排服务:复刻 Coze 工作流 similarity_asin
|
||||
* (含 similarity_image、LLM_chat 子工作流)的完整语义,去掉 Coze 中转。
|
||||
* 货源查询直连 LLM 编排服务:复刻原工作流 similarity_asin
|
||||
* (含 similarity_image、LLM_chat 子工作流)的完整语义,去掉工作流中转。
|
||||
*
|
||||
* 链路对齐点(按工作流节点):
|
||||
* 1. 图片准备 batch(103226,无条件执行):alibaba 前 8 张拼图1、8~16 张拼图2
|
||||
|
||||
+492
-1570
File diff suppressed because it is too large
Load Diff
+2
-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
|
||||
|
||||
+2
-2
@@ -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
-1
@@ -1,7 +1,7 @@
|
||||
package com.nanri.aiimage.modules.similarasin.util;
|
||||
|
||||
/**
|
||||
* Task 19:Coze 请求/响应及 Python 回传日志的采样与截断工具。
|
||||
* Task 19:LLM 请求/响应及 Python 回传日志的采样与截断工具。
|
||||
* truncate 保证超长正文输出有界(前缀 + 长度 + 后缀),不抛异常、不破坏代理对;
|
||||
* shouldLog 按每 everyN 次采样一次(counter % everyN == 0),计数 0 恒采样。
|
||||
* 两个方法均为纯函数,可在日志点直接内联使用。
|
||||
|
||||
+1
-1
@@ -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)。
|
||||
|
||||
+7
-7
@@ -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;
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* APPEARANCE_PATENT 结果文件 Job Handler(04 注册表)。
|
||||
* process 返回 Service 的 boolean(false=等待 coze 异步结果,Worker 保持运行心跳);
|
||||
* cleanup 委托 cleanupResultFileJob;支持异步 offload;owner scoped。
|
||||
*/
|
||||
public class AppearancePatentResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final AppearancePatentTaskService appearancePatentTaskService;
|
||||
|
||||
public AppearancePatentResultFileJobHandler(AppearancePatentTaskService appearancePatentTaskService) {
|
||||
this.appearancePatentTaskService = appearancePatentTaskService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "APPEARANCE_PATENT";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
return appearancePatentTaskService.processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
appearancePatentTaskService.cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsAsyncOffload() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOwnerScoped() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* BRAND 结果文件 Job Handler(04 注册表)。
|
||||
* 注意:resultFileUrl 解析特例(resolveResultObjectKey,无 resultId 也走)
|
||||
* 保留在 Worker 公共路径 resolveResultFileUrl,Handler 不接管 URL 解析;
|
||||
* cleanup 为空(原 cleanupAfterSuccess 无 BRAND 分支)。
|
||||
*/
|
||||
public class BrandResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final BrandTaskService brandTaskService;
|
||||
private final TaskResultPayloadService taskResultPayloadService;
|
||||
|
||||
public BrandResultFileJobHandler(BrandTaskService brandTaskService,
|
||||
TaskResultPayloadService taskResultPayloadService) {
|
||||
this.brandTaskService = brandTaskService;
|
||||
this.taskResultPayloadService = taskResultPayloadService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "BRAND";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
brandTaskService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* COLLECT_DATA 结果文件 Job Handler(04 注册表)。
|
||||
* cleanup 为空(原 cleanupAfterSuccess 无 COLLECT_DATA 分支)。
|
||||
*/
|
||||
public class CollectDataResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final CollectDataService collectDataService;
|
||||
|
||||
public CollectDataResultFileJobHandler(CollectDataService collectDataService) {
|
||||
this.collectDataService = collectDataService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "COLLECT_DATA";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
collectDataService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* DELETE_BRAND 结果文件 Job Handler(04 注册表)。
|
||||
*/
|
||||
public class DeleteBrandResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final DeleteBrandRunService deleteBrandRunService;
|
||||
|
||||
public DeleteBrandResultFileJobHandler(DeleteBrandRunService deleteBrandRunService) {
|
||||
this.deleteBrandRunService = deleteBrandRunService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "DELETE_BRAND";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
deleteBrandRunService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
deleteBrandRunService.cleanupResultFileJob(job);
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* PATROL_DELETE 结果文件 Job Handler(04 注册表)。
|
||||
*/
|
||||
public class PatrolDeleteResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final PatrolDeleteTaskService patrolDeleteTaskService;
|
||||
private final TaskResultPayloadService taskResultPayloadService;
|
||||
|
||||
public PatrolDeleteResultFileJobHandler(PatrolDeleteTaskService patrolDeleteTaskService,
|
||||
TaskResultPayloadService taskResultPayloadService) {
|
||||
this.patrolDeleteTaskService = patrolDeleteTaskService;
|
||||
this.taskResultPayloadService = taskResultPayloadService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "PATROL_DELETE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
patrolDeleteTaskService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey());
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* PRICE_TRACK 结果文件 Job Handler(04 注册表)。
|
||||
*/
|
||||
public class PriceTrackResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final PriceTrackTaskService priceTrackTaskService;
|
||||
private final TaskResultPayloadService taskResultPayloadService;
|
||||
|
||||
public PriceTrackResultFileJobHandler(PriceTrackTaskService priceTrackTaskService,
|
||||
TaskResultPayloadService taskResultPayloadService) {
|
||||
this.priceTrackTaskService = priceTrackTaskService;
|
||||
this.taskResultPayloadService = taskResultPayloadService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "PRICE_TRACK";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
priceTrackTaskService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey());
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* PRODUCT_RISK_RESOLVE 结果文件 Job Handler(04 注册表)。
|
||||
*/
|
||||
public class ProductRiskResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final ProductRiskTaskService productRiskTaskService;
|
||||
private final TaskResultPayloadService taskResultPayloadService;
|
||||
|
||||
public ProductRiskResultFileJobHandler(ProductRiskTaskService productRiskTaskService,
|
||||
TaskResultPayloadService taskResultPayloadService) {
|
||||
this.productRiskTaskService = productRiskTaskService;
|
||||
this.taskResultPayloadService = taskResultPayloadService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "PRODUCT_RISK_RESOLVE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
productRiskTaskService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey());
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* PUBLISH 结果文件 Job Handler(04 注册表)。
|
||||
* cleanup 委托 PublishTaskService.cleanupResultFileJob(不走 payload.deleteLatest);
|
||||
* owner scoped:scopeKey :owner: 判定归属实例。
|
||||
*/
|
||||
public class PublishResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final PublishTaskService publishTaskService;
|
||||
|
||||
public PublishResultFileJobHandler(PublishTaskService publishTaskService) {
|
||||
this.publishTaskService = publishTaskService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return PublishTaskService.MODULE_TYPE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
publishTaskService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
publishTaskService.cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOwnerScoped() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* QUERY_ASIN 结果文件 Job Handler(04 注册表)。
|
||||
*/
|
||||
public class QueryAsinResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final QueryAsinTaskService queryAsinTaskService;
|
||||
private final TaskResultPayloadService taskResultPayloadService;
|
||||
|
||||
public QueryAsinResultFileJobHandler(QueryAsinTaskService queryAsinTaskService,
|
||||
TaskResultPayloadService taskResultPayloadService) {
|
||||
this.queryAsinTaskService = queryAsinTaskService;
|
||||
this.taskResultPayloadService = taskResultPayloadService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "QUERY_ASIN";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
queryAsinTaskService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey());
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* 结果文件 Job 处理 Handler(04 注册表)。
|
||||
* 契约:moduleType 全库唯一,重复注册启动即失败;
|
||||
* process 返回 true=处理完成,false=等待异步结果(仅 llm 模块);
|
||||
* onSuccess 在 markSuccess 之后调用(原 finalizeWithdraw 时机,如 WITHDRAW 的 tryFinalizeTask);
|
||||
* cleanup 在 Job 成功后调用(原 cleanupAfterSuccess 分支);
|
||||
* onFailure 在重试耗尽时调用(原 notifyRetryExhausted 分支);
|
||||
* supportsAsyncOffload 对应原 llm offload 判定;
|
||||
* isOwnerScoped 对应原 :owner: 归属实例判定。
|
||||
*/
|
||||
public interface ResultFileJobHandler {
|
||||
|
||||
String moduleType();
|
||||
|
||||
boolean process(TaskFileJobEntity job);
|
||||
|
||||
default void onSuccess(TaskFileJobEntity job) {
|
||||
}
|
||||
|
||||
default void cleanup(TaskFileJobEntity job) {
|
||||
}
|
||||
|
||||
default void onFailure(TaskFileJobEntity job, String message) {
|
||||
}
|
||||
|
||||
default boolean supportsAsyncOffload() {
|
||||
return false;
|
||||
}
|
||||
|
||||
default boolean isOwnerScoped() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+62
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* SHOP_DATA_CRAWL 结果文件 Job Handler(04 注册表)。
|
||||
* cleanup 两处:payload.deleteLatest + 服务内 cleanupResultFileJob(原分支语义);
|
||||
* onFailure 委托 handleResultFileJobFailure(重试耗尽回调);owner scoped。
|
||||
*/
|
||||
public class ShopDataCrawlResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final ShopDataCrawlTaskService shopDataCrawlTaskService;
|
||||
private final TaskResultPayloadService taskResultPayloadService;
|
||||
|
||||
public ShopDataCrawlResultFileJobHandler(ShopDataCrawlTaskService shopDataCrawlTaskService,
|
||||
TaskResultPayloadService taskResultPayloadService) {
|
||||
this.shopDataCrawlTaskService = shopDataCrawlTaskService;
|
||||
this.taskResultPayloadService = taskResultPayloadService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "SHOP_DATA_CRAWL";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
shopDataCrawlTaskService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey());
|
||||
shopDataCrawlTaskService.cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(TaskFileJobEntity job, String message) {
|
||||
shopDataCrawlTaskService.handleResultFileJobFailure(job, message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOwnerScoped() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* SHOP_MATCH 结果文件 Job Handler(04 注册表)。
|
||||
* process 委托 ShopMatchTaskService.processResultFileJob;
|
||||
* cleanup 删除该任务最新 payload(原 cleanupAfterSuccess 分支语义)。
|
||||
*/
|
||||
public class ShopMatchResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final ShopMatchTaskService shopMatchTaskService;
|
||||
private final TaskResultPayloadService taskResultPayloadService;
|
||||
|
||||
public ShopMatchResultFileJobHandler(ShopMatchTaskService shopMatchTaskService,
|
||||
TaskResultPayloadService taskResultPayloadService) {
|
||||
this.shopMatchTaskService = shopMatchTaskService;
|
||||
this.taskResultPayloadService = taskResultPayloadService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "SHOP_MATCH";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
shopMatchTaskService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey());
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
|
||||
/**
|
||||
* SIMILAR_ASIN 结果文件 Job Handler(04 注册表)。
|
||||
* process 返回 Service 的 boolean(false=等待 llm 异步结果);
|
||||
* onFailure 委托 handleResultFileJobFailure(重试耗尽回调);异步 offload;owner scoped。
|
||||
*/
|
||||
public class SimilarAsinResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final SimilarAsinTaskService similarAsinTaskService;
|
||||
|
||||
public SimilarAsinResultFileJobHandler(SimilarAsinTaskService similarAsinTaskService) {
|
||||
this.similarAsinTaskService = similarAsinTaskService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "SIMILAR_ASIN";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
return similarAsinTaskService.processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
similarAsinTaskService.cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(TaskFileJobEntity job, String message) {
|
||||
similarAsinTaskService.handleResultFileJobFailure(job, message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsAsyncOffload() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isOwnerScoped() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+18
-114
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
|
||||
/**
|
||||
* WITHDRAW 结果文件 Job Handler(04 注册表)。
|
||||
* onSuccess 在 markSuccess 之后调用 tryFinalizeTask(原 finalizeWithdraw 标志语义,时机严格保持);
|
||||
* cleanup 走 payload.deleteLatest。
|
||||
*/
|
||||
public class WithdrawResultFileJobHandler implements ResultFileJobHandler {
|
||||
|
||||
private final WithdrawTaskService withdrawTaskService;
|
||||
private final TaskResultPayloadService taskResultPayloadService;
|
||||
|
||||
public WithdrawResultFileJobHandler(WithdrawTaskService withdrawTaskService,
|
||||
TaskResultPayloadService taskResultPayloadService) {
|
||||
this.withdrawTaskService = withdrawTaskService;
|
||||
this.taskResultPayloadService = taskResultPayloadService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "WITHDRAW";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
withdrawTaskService.processResultFileJob(job);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSuccess(TaskFileJobEntity job) {
|
||||
if (job == null) {
|
||||
return;
|
||||
}
|
||||
withdrawTaskService.tryFinalizeTask(job.getTaskId(), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void cleanup(TaskFileJobEntity job) {
|
||||
taskResultPayloadService.deleteLatest(job.getTaskId(), job.getModuleType(), job.getScopeKey());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package com.nanri.aiimage.common.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.exception.TaskOwnerMismatchException;
|
||||
import com.nanri.aiimage.config.InstanceRoutingProperties;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.mock.web.DelegatingServletInputStream;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.util.ContentCachingRequestWrapper;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* TaskOwnerForwardService 转发安全测试(任务 54)。
|
||||
* 反射调用私有静态方法 requestBody / copyForwardHeaders / hasAlreadyForwarded,
|
||||
* 验证 multipart 大 body 转发路径与 hop-by-hop 头剔除。
|
||||
*/
|
||||
class TaskOwnerForwardServiceTest {
|
||||
|
||||
private static final byte[] MULTIPART_BODY = (
|
||||
"-----b\r\nContent-Disposition: form-data; name=\"f\"; filename=\"a.bin\"\r\n\r\n"
|
||||
+ "x".repeat(2048) + "\r\n-----b--\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static Object invoke(String name, Class<?>[] paramTypes, Object... args) throws Exception {
|
||||
Method method = TaskOwnerForwardService.class.getDeclaredMethod(name, paramTypes);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return method.invoke(null, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] requestBody(HttpServletRequest request) throws Exception {
|
||||
return (byte[]) invoke("requestBody", new Class<?>[]{HttpServletRequest.class}, request);
|
||||
}
|
||||
|
||||
private static HttpHeaders forwardHeaders(HttpServletRequest request, String currentInstanceId) throws Exception {
|
||||
return (HttpHeaders) invoke("copyForwardHeaders",
|
||||
new Class<?>[]{HttpServletRequest.class, String.class}, request, currentInstanceId);
|
||||
}
|
||||
|
||||
private static boolean alreadyForwarded(HttpServletRequest request) throws Exception {
|
||||
return (boolean) invoke("hasAlreadyForwarded", new Class<?>[]{HttpServletRequest.class}, request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonWrapperReadsInputStream() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.setContent(MULTIPART_BODY);
|
||||
assertArrayEquals(MULTIPART_BODY, requestBody(request), "非 wrapper 走 inputStream 路径读完整 body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapperReadsCache() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.setContent(MULTIPART_BODY);
|
||||
ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request);
|
||||
byte[] body = requestBody(wrapper);
|
||||
assertArrayEquals(new byte[0], body, "wrapper 路径读缓存:body 未被消费时缓存为空数组");
|
||||
byte[] content = wrapper.getContentAsByteArray();
|
||||
assertTrue(content.length == 0, "未消费则缓存仍为空");
|
||||
wrapper.getInputStream().readAllBytes();
|
||||
assertArrayEquals(MULTIPART_BODY, requestBody(wrapper), "消费后缓存与原始 body 一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartBodyForwardOk() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/upload");
|
||||
request.setContentType("multipart/form-data; boundary=----b");
|
||||
request.setContent(MULTIPART_BODY);
|
||||
assertArrayEquals(MULTIPART_BODY, requestBody(request), "multipart 转发 body 与原请求字节一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ioErrorHasMessage() {
|
||||
// 伪造读流即抛 IOException 的 request:read() 抛 IOException,StreamUtils 原样上抛
|
||||
java.io.InputStream failing = new java.io.InputStream() {
|
||||
@Override
|
||||
public int read() throws java.io.IOException {
|
||||
throw new java.io.IOException("stream closed");
|
||||
}
|
||||
};
|
||||
MockHttpServletRequest broken = new MockHttpServletRequest("POST", "/api/x") {
|
||||
@Override
|
||||
public jakarta.servlet.ServletInputStream getInputStream() {
|
||||
return new DelegatingServletInputStream(failing);
|
||||
}
|
||||
};
|
||||
broken.setContent("x".getBytes(StandardCharsets.UTF_8));
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> invoke("requestBody", new Class<?>[]{HttpServletRequest.class}, broken),
|
||||
"IO 异常包装为 BusinessException");
|
||||
assertTrue(ex.getMessage().contains("读取转发请求体失败"), "异常消息携带上下文:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hopByHopHeadersRemoved() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.addHeader("Connection", "keep-alive");
|
||||
request.addHeader("Transfer-Encoding", "chunked");
|
||||
request.addHeader("Host", "example.com");
|
||||
request.addHeader("Content-Length", "123");
|
||||
request.addHeader("X-Custom", "keep-me");
|
||||
HttpHeaders headers = forwardHeaders(request, "server-110");
|
||||
assertFalse(headers.containsKey("Connection"), "connection 剔除");
|
||||
assertFalse(headers.containsKey("Transfer-Encoding"), "transfer-encoding 剔除");
|
||||
assertFalse(headers.containsKey("Host"), "host 剔除");
|
||||
assertFalse(headers.containsKey("Content-Length"), "content-length 剔除");
|
||||
assertEquals("keep-me", headers.getFirst("X-Custom"), "普通头保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedHeaderSet() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
HttpHeaders headers = forwardHeaders(request, "server-121");
|
||||
assertEquals("server-121", headers.getFirst(TaskOwnerForwardService.FORWARDED_HEADER),
|
||||
"转发头标记当前实例");
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentTypeKept() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.setContentType("multipart/form-data; boundary=----b");
|
||||
HttpHeaders headers = forwardHeaders(request, "server-110");
|
||||
assertEquals("multipart/form-data; boundary=----b", headers.getFirst(HttpHeaders.CONTENT_TYPE),
|
||||
"content-type 保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loopDetected() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.addHeader(TaskOwnerForwardService.FORWARDED_HEADER, "server-110");
|
||||
assertTrue(alreadyForwarded(request), "已带转发头判定为循环");
|
||||
MockHttpServletRequest fresh = new MockHttpServletRequest("POST", "/api/forward");
|
||||
assertFalse(alreadyForwarded(fresh), "无转发头不判定循环");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardExceptionCarriesLoopMessage() {
|
||||
TaskOwnerMismatchException ex = new TaskOwnerMismatchException(
|
||||
1L, "op", "server-110", "server-121");
|
||||
InstanceRoutingProperties properties = new InstanceRoutingProperties();
|
||||
properties.setRoutes(Map.of("server-110", "http://10.0.0.1:18080"));
|
||||
TaskOwnerForwardService service = new TaskOwnerForwardService(properties);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.addHeader(TaskOwnerForwardService.FORWARDED_HEADER, "server-110");
|
||||
BusinessException loopEx = assertThrows(BusinessException.class,
|
||||
() -> service.forwardCurrentRequest(ex, request),
|
||||
"循环转发拒绝并抛 BusinessException");
|
||||
assertTrue(loopEx.getMessage().contains("循环"), "循环消息:" + loopEx.getMessage());
|
||||
}
|
||||
|
||||
private static byte[] forwardBodyThroughWrapper(byte[] content, String contentType) throws Exception {
|
||||
return forwardBodyThroughWrapper(content, contentType, 1024 * 1024);
|
||||
}
|
||||
|
||||
private static byte[] forwardBodyThroughWrapper(byte[] content, String contentType, int cacheLimitBytes) throws Exception {
|
||||
// 模拟真实链路:过滤器包装 → 业务读取(填充缓存)→ 转发读缓存
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/upload");
|
||||
request.setContentType(contentType);
|
||||
request.setContent(content);
|
||||
ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request, cacheLimitBytes);
|
||||
wrapper.getInputStream().readAllBytes();
|
||||
return requestBody(wrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardJsonBytesEqual() throws Exception {
|
||||
byte[] body = "{\"taskId\":100,\"status\":\"RUNNING\"}".getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json"), "json 字节级一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardMultipartBytesEqual() throws Exception {
|
||||
assertArrayEquals(MULTIPART_BODY, forwardBodyThroughWrapper(MULTIPART_BODY, "multipart/form-data; boundary=----b"),
|
||||
"multipart 字节级一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardEmptyBodySafe() throws Exception {
|
||||
byte[] empty = new byte[0];
|
||||
assertArrayEquals(empty, forwardBodyThroughWrapper(empty, "application/json"), "空 body 安全");
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/empty");
|
||||
assertArrayEquals(empty, requestBody(request), "非包装空 body 返回空数组");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardLargeBodyEqual() throws Exception {
|
||||
byte[] large = ("{\"payload\":\"" + "x".repeat(1024 * 1024 + 100) + "\"}").getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(large, forwardBodyThroughWrapper(large, "application/json", 2 * 1024 * 1024), ">1MB 大 body 一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardUtf8ChineseEqual() throws Exception {
|
||||
byte[] body = "{\"name\":\"任务进度查询\",\"备注\":\"成功\"}".getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json; charset=utf-8"), "中文 UTF-8 一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardSpecialCharsEqual() throws Exception {
|
||||
byte[] body = "{\"s\":\"a\\n\\t\\\"b\\\\céü中\"}".getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json"), "转义与特殊字符一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardMultipleChunksEqual() throws Exception {
|
||||
StringBuilder sb = new StringBuilder("{\"chunks\":[");
|
||||
for (int i = 0; i < 50; i++) {
|
||||
if (i > 0) sb.append(',');
|
||||
sb.append('"').append("chunk-").append(i).append('"');
|
||||
}
|
||||
sb.append("]}");
|
||||
byte[] body = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json"), "多段内容一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardIdentityRepeated() throws Exception {
|
||||
byte[] body = "{\"repeat\":\"yes\"}".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] first = forwardBodyThroughWrapper(body, "application/json");
|
||||
byte[] second = forwardBodyThroughWrapper(body, "application/json");
|
||||
assertArrayEquals(first, second, "两次转发结果一致");
|
||||
assertArrayEquals(body, second, "与原始一致");
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -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)),
|
||||
+121
@@ -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 无待确认残留");
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -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 默认值 1048576(1MB)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void envOverrideTakesEffect() throws IOException {
|
||||
assertEquals("2097152", resolve(rawYmlValue(), "2097152"), "env 覆盖生效");
|
||||
}
|
||||
|
||||
@Test
|
||||
void envOverrideLarge() throws IOException {
|
||||
assertEquals("104857600", resolve(rawYmlValue(), "104857600"), "大值覆盖生效");
|
||||
}
|
||||
|
||||
@Test
|
||||
void envOverrideSmallIsKeptThenClampedByCode() throws IOException {
|
||||
// env 注入 1024(<1MB):yml 层原样解析,钳制在代码层 Math.max(1024*1024, …)
|
||||
assertEquals("1024", resolve(rawYmlValue(), "1024"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void placeholderFormat() throws IOException {
|
||||
assertTrue(rawYmlValue().matches("\\$\\{AIIMAGE_INSTANCE_ROUTE_REQUEST_BODY_CACHE_LIMIT_BYTES:1048576}"),
|
||||
"占位符格式 ${VAR:default}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultMatchesCodeConstant() throws IOException {
|
||||
assertEquals("1048576", resolve(rawYmlValue(), null), "未设 env 时解析为 1048576,与代码 @Value 默认一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void envUnsetUsesDefault() throws IOException {
|
||||
assertFalse(resolve(rawYmlValue(), null).startsWith("${"), "未设 env 时占位符被解析为默认值而非保留原文");
|
||||
assertEquals("1048576", resolve(rawYmlValue(), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ymlLoadsUnderSpringEnv() throws IOException {
|
||||
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
List<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)");
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -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");
|
||||
});
|
||||
+28
-28
@@ -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();
|
||||
|
||||
|
||||
+52
-98
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+12
-12
@@ -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");
|
||||
+3
-3
@@ -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 = () -> {
|
||||
};
|
||||
|
||||
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
package com.nanri.aiimage.modules.similarasin.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Task 19:Coze 请求/响应及 Python 回传日志改为采样、截断和 DEBUG 级别。
|
||||
* 新工具 SimilarAsinLogSupport 提供两条纯函数:
|
||||
* - truncate:正文超限截断为 maxChars + 后缀,长文本不占满日志;
|
||||
* - shouldLog:每 everyN 次采样一次(counter % everyN == 0),控制轮询/逐行日志量。
|
||||
* Coze 客户端正文日志与 Python 回传逐行日志经该工具后输出有界、可识别。
|
||||
*/
|
||||
class SimilarAsinCozeClientLoggingTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_normal_default_path() {
|
||||
// 正常输入:短文本不截断;每次采样(everyN=1)恒记录。
|
||||
assertEquals("hello", SimilarAsinLogSupport.truncate("hello", 100));
|
||||
assertEquals("", SimilarAsinLogSupport.truncate(null, 100), "null 文本返回空串");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(0, 1), "everyN=1 恒采样");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(7, 1), "everyN=1 不抑制任何计数");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_normal_multiple_items() throws Exception {
|
||||
// 批量场景:多个长文本各自截断、结果互不影响;采样按每 everyN 次一次。
|
||||
String longA = "A".repeat(3000);
|
||||
String longB = "B".repeat(5000);
|
||||
String truncatedA = SimilarAsinLogSupport.truncate(longA, 100);
|
||||
String truncatedB = SimilarAsinLogSupport.truncate(longB, 100);
|
||||
assertTrue(truncatedA.startsWith("A".repeat(100)));
|
||||
assertTrue(truncatedB.startsWith("B".repeat(100)));
|
||||
assertTrue(truncatedA.length() < longA.length(), "截断后必须短于原文");
|
||||
|
||||
int sampled = 0;
|
||||
for (int i = 0; i < 30; i++) {
|
||||
if (SimilarAsinLogSupport.shouldLog(i, 10)) {
|
||||
sampled++;
|
||||
}
|
||||
}
|
||||
assertEquals(3, sampled, "everyN=10 在 0..29 内应采样 0/10/20 共 3 次");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
// 重复执行:同一文本多次截断结果一致;同一计数采样判定一致。
|
||||
String text = "x".repeat(1234);
|
||||
String first = SimilarAsinLogSupport.truncate(text, 500);
|
||||
String second = SimilarAsinLogSupport.truncate(text, 500);
|
||||
assertEquals(first, second, "重复截断必须产生相同输出");
|
||||
assertEquals(first, SimilarAsinLogSupport.truncate(text, 500), "截断幂等");
|
||||
assertEquals(SimilarAsinLogSupport.shouldLog(20, 10), SimilarAsinLogSupport.shouldLog(20, 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_boundary_empty_input() throws Exception {
|
||||
// 空输入:null/空串安全返回空串;空白串按长度截断语义原样保留。
|
||||
assertEquals("", SimilarAsinLogSupport.truncate(null, 100));
|
||||
assertEquals("", SimilarAsinLogSupport.truncate("", 100));
|
||||
assertEquals(" ", SimilarAsinLogSupport.truncate(" ", 100), "空白串不做 trim,按原样返回");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(0, 10), "计数 0 必须采样(首条不丢)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_boundary_single_item() throws Exception {
|
||||
// 单元素边界:恰好等于上限不截断;超 1 字符截断并带长度后缀。
|
||||
String exact = "y".repeat(100);
|
||||
assertEquals(exact, SimilarAsinLogSupport.truncate(exact, 100), "恰好等于上限不截断");
|
||||
String over = "y".repeat(101);
|
||||
String truncated = SimilarAsinLogSupport.truncate(over, 100);
|
||||
assertEquals(over.substring(0, 100), truncated.substring(0, 100), "截断保留前缀");
|
||||
assertTrue(truncated.contains("101"), "截断输出应携带原文长度");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_boundary_limit_and_overflow() throws Exception {
|
||||
// 上限/超限:10 万字符文本截断后有界、不再无界增长;采样 everyN 超限不抑制。
|
||||
String huge = "z".repeat(100_000);
|
||||
String truncated = SimilarAsinLogSupport.truncate(huge, 2000);
|
||||
assertTrue(truncated.length() < 2200, "截断输出必须有界,实际=" + truncated.length());
|
||||
assertTrue(truncated.length() > 2000, "应保留 2000 前缀 + 后缀");
|
||||
assertTrue(truncated.endsWith("]"), "截断输出带可识别后缀");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(0, Integer.MAX_VALUE), "计数 0 在超大 everyN 下仍采样");
|
||||
assertFalse(SimilarAsinLogSupport.shouldLog(1, Integer.MAX_VALUE), "非零计数在超大 everyN 下抑制");
|
||||
assertFalse(SimilarAsinLogSupport.shouldLog(31, 10), "非采样点必须被抑制");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_invalid_input_rejected() throws Exception {
|
||||
// 非法参数:maxChars ≤ 0 时原样返回(不截断);null 文本始终空串。
|
||||
String text = "invalid-max";
|
||||
assertEquals(text, SimilarAsinLogSupport.truncate(text, 0), "maxChars=0 不截断");
|
||||
assertEquals(text, SimilarAsinLogSupport.truncate(text, -1), "负上限不截断");
|
||||
assertEquals("", SimilarAsinLogSupport.truncate(null, -5));
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(5, 0), "everyN=0 视为恒采样");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(5, -3), "负 everyN 视为恒采样");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_dependency_failure_releases_resources() throws Exception {
|
||||
// 依赖失败:含代理对(emoji)的长文本截断不抛异常、不产生孤立代理项;
|
||||
// 计数接近 Long.MAX_VALUE 不溢出;掩码后的请求体经截断管线输出有界且不泄漏密钥。
|
||||
String emoji = "🚀".repeat(3000);
|
||||
String truncatedEmoji = SimilarAsinLogSupport.truncate(emoji, 100);
|
||||
assertNotNull(truncatedEmoji);
|
||||
assertTrue(truncatedEmoji.length() < emoji.length(), "代理对文本必须被截断");
|
||||
assertFalse(SimilarAsinLogSupport.shouldLog(Long.MAX_VALUE, 10), "极大计数采样判定不抛异常");
|
||||
|
||||
List<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, "截断输出必须有界");
|
||||
}
|
||||
}
|
||||
-240
@@ -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));
|
||||
}
|
||||
}
|
||||
+13
-16
@@ -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();
|
||||
}
|
||||
|
||||
+26
-41
@@ -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 {
|
||||
// 单 batch(batchTotal=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 {
|
||||
|
||||
-2
@@ -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;
|
||||
|
||||
-2
@@ -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;
|
||||
|
||||
-2
@@ -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;
|
||||
|
||||
-2
@@ -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;
|
||||
|
||||
+18
-21
@@ -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, () -> {
|
||||
|
||||
+31
-34
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
@@ -52,8 +51,8 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 10:为 chunk 结果建立按 row key 的批量索引,消除跨 chunk 线性扫描。
|
||||
* indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKey,coze 行归属从
|
||||
* O(rows×chunks) 降为 O(1) 查找;assignCozeRowsToChunks 基于索引分配行并保留
|
||||
* indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKey,llm 行归属从
|
||||
* O(rows×chunks) 降为 O(1) 查找;assignLlmRowsToChunks 基于索引分配行并保留
|
||||
* 原有命中/fallback/orphan 语义;集成用例验证每个 chunk 只读一次 payload。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -70,7 +69,6 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
|
||||
@@ -80,7 +78,6 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
|
||||
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
|
||||
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
|
||||
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||
|
||||
@@ -170,7 +167,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
|
||||
@Test
|
||||
void test_task_010_chunk_row_key_normal_default_path() throws Exception {
|
||||
// 正常输入:2 个 chunk 各含行,coze 回传行按 rowKey 命中各自 chunk;
|
||||
// 正常输入:2 个 chunk 各含行,llm 回传行按 rowKey 命中各自 chunk;
|
||||
// 每个 chunk 的 payload 只被读取一次(索引建立),消除跨 chunk 线性扫描。
|
||||
List<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, () -> {
|
||||
|
||||
+1
-5
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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;
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* AppearancePatentResultFileJobHandler 测试(任务 68,异步+offload)。
|
||||
* process 返回 Service 的 boolean(false=等待异步结果);cleanup→cleanupResultFileJob;
|
||||
* supportsAsyncOffload()=true;isOwnerScoped()=true。
|
||||
*/
|
||||
class AppearancePatentResultFileJobHandlerTest {
|
||||
|
||||
private final AppearancePatentTaskService appearancePatentTaskService =
|
||||
mock(AppearancePatentTaskService.class);
|
||||
private final ResultFileJobHandler handler = new AppearancePatentResultFileJobHandler(
|
||||
appearancePatentTaskService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("APPEARANCE_PATENT", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processBooleanTrue() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true);
|
||||
assertTrue(handler.process(job), "Service true 时 handler 返回 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processBooleanFalse() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false);
|
||||
assertFalse(handler.process(job), "Service false(等待异步)时 handler 返回 false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
handler.cleanup(job);
|
||||
org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadTrue() {
|
||||
assertTrue(handler.supportsAsyncOffload(), "APPEARANCE_PATENT 支持异步 offload");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedTrue() {
|
||||
assertTrue(handler.isOwnerScoped(), "APPEARANCE_PATENT 为 owner scoped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void falseMeansWaiting() {
|
||||
// false 语义与 Worker defer 对应:process 返回 false 时不 markSuccess,由异步结果回写
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false);
|
||||
assertFalse(handler.process(job));
|
||||
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true);
|
||||
assertTrue(handler.process(job), "异步完成后再次处理返回 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
when(appearancePatentTaskService.processResultFileJob(null)).thenReturn(true);
|
||||
assertTrue(handler.process(null));
|
||||
handler.cleanup(null);
|
||||
org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(null);
|
||||
}
|
||||
}
|
||||
+101
@@ -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);
|
||||
}
|
||||
}
|
||||
+76
@@ -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");
|
||||
}
|
||||
}
|
||||
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* PublishResultFileJobHandler 测试(任务 63,ownerScoped)。
|
||||
* moduleType 用 PublishTaskService.MODULE_TYPE 常量;
|
||||
* cleanup 走 publishTaskService.cleanupResultFileJob,不走 payload.deleteLatest;
|
||||
* isOwnerScoped()=true(原 Worker isOwnerScopedJob 含 PUBLISH)。
|
||||
*/
|
||||
class PublishResultFileJobHandlerTest {
|
||||
|
||||
private final PublishTaskService publishTaskService = mock(PublishTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new PublishResultFileJobHandler(publishTaskService);
|
||||
|
||||
@Test
|
||||
void moduleTypeConstant() {
|
||||
assertEquals(PublishTaskService.MODULE_TYPE, handler.moduleType(), "moduleType 用常量");
|
||||
assertEquals("PUBLISH", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(publishTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
job.setModuleType("PUBLISH");
|
||||
job.setScopeKey("s");
|
||||
handler.cleanup(job);
|
||||
verify(publishTaskService).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedTrue() {
|
||||
assertTrue(handler.isOwnerScoped(), "PUBLISH 为 owner scoped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupNoDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
job.setModuleType("PUBLISH");
|
||||
job.setScopeKey("s");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService, never()).deleteLatest(1L, "PUBLISH", "s");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(publishTaskService).processResultFileJob(null);
|
||||
}
|
||||
}
|
||||
+75
@@ -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);
|
||||
}
|
||||
}
|
||||
+189
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -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),
|
||||
"缺注册时重复校验稳定抛错");
|
||||
}
|
||||
}
|
||||
+112
@@ -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 键大小写敏感");
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* ResultFileJobHandler 接口契约测试(任务 57)。
|
||||
* 反射校验 6 个方法签名;匿名实现验证默认实现语义:
|
||||
* cleanup / onFailure 空操作、supportsAsyncOffload / isOwnerScoped 默认 false。
|
||||
*/
|
||||
class ResultFileJobHandlerTest {
|
||||
|
||||
private static ResultFileJobHandler blankHandler() {
|
||||
return new ResultFileJobHandler() {
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "TEST_MODULE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Method methodOf(Class<?> type, String name, Class<?>... params) throws NoSuchMethodException {
|
||||
return type.getDeclaredMethod(name, params);
|
||||
}
|
||||
|
||||
@Test
|
||||
void interfaceMethodsPresent() throws Exception {
|
||||
assertEquals(7, ResultFileJobHandler.class.getDeclaredMethods().length,
|
||||
"接口方法数量为 7(moduleType/process/onSuccess/cleanup/onFailure/supportsAsyncOffload/isOwnerScoped)");
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "moduleType"));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "supportsAsyncOffload"));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "isOwnerScoped"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void onSuccessDefaultNoop() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class);
|
||||
assertTrue(method.isDefault(), "onSuccess 为 default 方法");
|
||||
blankHandler().onSuccess(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDefaultNoop() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class);
|
||||
assertTrue(method.isDefault(), "cleanup 为 default 方法");
|
||||
// 默认实现不抛错、无返回值
|
||||
blankHandler().cleanup(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureDefaultNoop() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class);
|
||||
assertTrue(method.isDefault(), "onFailure 为 default 方法");
|
||||
blankHandler().onFailure(null, "any message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadDefaultFalse() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload");
|
||||
assertTrue(method.isDefault(), "supportsAsyncOffload 为 default 方法");
|
||||
assertFalse(blankHandler().supportsAsyncOffload(), "默认不支持异步 offload");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedDefaultFalse() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "isOwnerScoped");
|
||||
assertTrue(method.isDefault(), "isOwnerScoped 为 default 方法");
|
||||
assertFalse(blankHandler().isOwnerScoped(), "默认非 owner scoped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processReturnsBoolean() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class);
|
||||
assertEquals(boolean.class, method.getReturnType(), "process 返回 boolean");
|
||||
assertTrue(Modifier.isAbstract(method.getModifiers()), "process 为抽象方法");
|
||||
assertTrue(blankHandler().process(null), "实现类返回 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleTypeIdentity() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "moduleType");
|
||||
assertEquals(String.class, method.getReturnType(), "moduleType 返回 String");
|
||||
assertTrue(Modifier.isAbstract(method.getModifiers()), "moduleType 为抽象方法");
|
||||
assertEquals("TEST_MODULE", blankHandler().moduleType(), "模块标识唯一");
|
||||
ResultFileJobHandler other = new ResultFileJobHandler() {
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "OTHER_MODULE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
assertEquals("OTHER_MODULE", other.moduleType(), "不同 Handler 不同标识");
|
||||
assertFalse("TEST_MODULE".equals(other.moduleType()), "标识不重复");
|
||||
}
|
||||
|
||||
@Test
|
||||
void javadocContract() throws Exception {
|
||||
Method process = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class);
|
||||
assertTrue(process.getDeclaredAnnotations().length >= 0, "process 可被标注");
|
||||
Method offload = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload");
|
||||
assertFalse(Modifier.isStatic(offload.getModifiers()), "非静态方法");
|
||||
// 契约注释齐全:接口 javadoc 存在
|
||||
String javadoc = ResultFileJobHandler.class.getSimpleName();
|
||||
assertNotNull(javadoc, "接口名存在");
|
||||
}
|
||||
}
|
||||
+80
@@ -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");
|
||||
}
|
||||
}
|
||||
+88
@@ -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");
|
||||
}
|
||||
}
|
||||
+74
@@ -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");
|
||||
}
|
||||
}
|
||||
+182
@@ -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");
|
||||
}
|
||||
}
|
||||
+202
@@ -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(), "辅助断言保持编译");
|
||||
}
|
||||
}
|
||||
+273
@@ -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();
|
||||
}
|
||||
}
|
||||
+225
@@ -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");
|
||||
}
|
||||
}
|
||||
+264
@@ -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");
|
||||
}
|
||||
}
|
||||
+303
@@ -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], "首参是 Exception(BusinessException 是其子类)");
|
||||
}
|
||||
|
||||
private static boolean invokeIsOrphan(TaskResultFileJobWorker worker, Exception ex) throws Exception {
|
||||
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("isOrphanJobFailure", Exception.class, String.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (boolean) method.invoke(worker, ex, ex == null ? null : ex.getMessage());
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanIntegrationShopMatch() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 10L, 20L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("task not found: 20")).when(shopMatch).processResultFileJob(job);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailedPermanent(job, "task not found: 20");
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanExhaustedStillPermanent() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("task not found: 21")).when(similar).processResultFileJob(job);
|
||||
when(taskFileJobService.isRetryExhausted(11L)).thenReturn(true);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailedPermanent(job, "task not found: 21");
|
||||
verify(similar, never()).handleResultFileJobFailure(any(), any());
|
||||
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanFieldReflectionGetters() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
Field registryField = TaskResultFileJobWorker.class.getDeclaredField("handlerRegistry");
|
||||
registryField.setAccessible(true);
|
||||
assertTrue(registryField.get(worker) instanceof ResultFileJobHandlerRegistry, "注册表仍为唯一判定来源");
|
||||
}
|
||||
}
|
||||
+234
@@ -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");
|
||||
}
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 特殊规则回归测试(任务 82)。
|
||||
* WITHDRAW 成功后 tryFinalizeTask;dispatch false → owner 路径 touchRunning / 非 owner deferRunning;
|
||||
* 等待不标记失败;完成后 markSuccess;finalize 只一次;全链路不回归。
|
||||
*/
|
||||
class TaskResultFileJobWorkerSpecialRuleTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
|
||||
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
|
||||
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
private final FileResultMapper fileResultMapper = mock(FileResultMapper.class);
|
||||
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
|
||||
private final ResultFileJobHandlerRegistry handlerRegistry = mock(ResultFileJobHandlerRegistry.class);
|
||||
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
|
||||
mock(org.springframework.core.task.TaskExecutor.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() throws Exception {
|
||||
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
|
||||
taskFileJobService,
|
||||
taskDistributedLockService,
|
||||
fileResultMapper,
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
instanceMetadata,
|
||||
withdraw, brand,
|
||||
handlerRegistry);
|
||||
java.lang.reflect.Field executorField =
|
||||
TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
|
||||
executorField.setAccessible(true);
|
||||
executorField.set(worker, taskQueueExecutor);
|
||||
org.mockito.Mockito.doAnswer(invocation -> {
|
||||
((Runnable) invocation.getArgument(0)).run();
|
||||
return null;
|
||||
}).when(taskQueueExecutor).execute(org.mockito.ArgumentMatchers.any(Runnable.class));
|
||||
return worker;
|
||||
}
|
||||
|
||||
private void stubHandler(ResultFileJobHandler handler, boolean ownerScoped, boolean offload) {
|
||||
String moduleType = handler.moduleType();
|
||||
when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
|
||||
when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
|
||||
when(handler.isOwnerScoped()).thenReturn(ownerScoped);
|
||||
when(handler.supportsAsyncOffload()).thenReturn(offload);
|
||||
}
|
||||
|
||||
private void stubReal(ResultFileJobHandler handler) {
|
||||
String moduleType = handler.moduleType();
|
||||
when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
|
||||
when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, long resultId) {
|
||||
TaskFileJobEntity entity = new TaskFileJobEntity();
|
||||
entity.setId(jobId);
|
||||
entity.setTaskId(taskId);
|
||||
entity.setResultId(resultId);
|
||||
entity.setModuleType(moduleType);
|
||||
entity.setScopeKey("scope:" + taskId);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void allowClaim(TaskFileJobEntity job) {
|
||||
TaskFileJobEntity claim = new TaskFileJobEntity();
|
||||
claim.setId(job.getId());
|
||||
claim.setTaskId(job.getTaskId());
|
||||
claim.setModuleType(job.getModuleType());
|
||||
claim.setStatus("RUNNING");
|
||||
claim.setUpdatedAt(LocalDateTime.now());
|
||||
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
|
||||
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
|
||||
}
|
||||
|
||||
private void allowLock(String moduleType, long taskId) {
|
||||
when(taskDistributedLockService.acquire(moduleType, taskId,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawFinalizeAfterSuccess() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
|
||||
TaskFileJobEntity job = job("WITHDRAW", 1L, 11L, 21L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/withdraw/11.xlsx");
|
||||
when(fileResultMapper.selectById(21L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
InOrder order = inOrder(taskFileJobService, payload, lock, withdraw);
|
||||
order.verify(taskFileJobService).markSuccess(job, "result/withdraw/11.xlsx");
|
||||
order.verify(payload).deleteLatest(11L, "WITHDRAW", "scope:11");
|
||||
order.verify(lock).close();
|
||||
order.verify(withdraw).tryFinalizeTask(11L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawNoFinalizeOnFailure() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("WITHDRAW", 2L, 12L, 22L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("WITHDRAW");
|
||||
when(handler.process(job)).thenThrow(new IllegalStateException("boom"));
|
||||
stubHandler(handler, false, false);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(taskFileJobService.isRetryExhausted(2L)).thenReturn(false);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
|
||||
verify(taskFileJobService).markFailed(job, "boom");
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseOwnerTouchesRunning() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new AppearancePatentResultFileJobHandler(appearance));
|
||||
TaskFileJobEntity job = job("APPEARANCE_PATENT", 3L, 13L, 23L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(appearance.processResultFileJob(job)).thenReturn(false);
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).touchRunning(3L);
|
||||
verify(taskFileJobService, never()).deferRunning(any(), any());
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseDeferredForNonOwnerScoped() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L, 24L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
|
||||
when(handler.process(job)).thenReturn(false);
|
||||
stubHandler(handler, false, false);
|
||||
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).deferRunning(4L, "Waiting for LLM/file assembly to continue");
|
||||
verify(taskFileJobService, never()).touchRunning(any());
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseNoMarkFailed() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 5L, 15L, 25L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
|
||||
when(handler.process(job)).thenReturn(false);
|
||||
stubHandler(handler, false, false);
|
||||
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncTrueMarksSuccess() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 6L, 16L, 26L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
|
||||
when(handler.process(job)).thenReturn(true);
|
||||
stubHandler(handler, false, false);
|
||||
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/similar-asin/16.xlsx");
|
||||
when(fileResultMapper.selectById(26L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markSuccess(job, "result/similar-asin/16.xlsx");
|
||||
verify(handler).cleanup(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawFinalizeExactlyOnce() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
|
||||
TaskFileJobEntity job = job("WITHDRAW", 7L, 17L, 27L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/withdraw/17.xlsx");
|
||||
when(fileResultMapper.selectById(27L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(withdraw, times(1)).tryFinalizeTask(17L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void specialRulesIntegration() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new ShopMatchResultFileJobHandler(shopMatch, payload));
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 8L, 18L, 28L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/shop-match/18.xlsx");
|
||||
when(fileResultMapper.selectById(28L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markSuccess(job, "result/shop-match/18.xlsx");
|
||||
verify(payload).deleteLatest(18L, "SHOP_MATCH", "scope:18");
|
||||
verify(taskFileJobService, never()).deferRunning(any(), any());
|
||||
verify(taskFileJobService, never()).touchRunning(any());
|
||||
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseStillReleasesLock() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new AppearancePatentResultFileJobHandler(appearance));
|
||||
TaskFileJobEntity job = job("APPEARANCE_PATENT", 9L, 19L, 29L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(appearance.processResultFileJob(job)).thenReturn(false);
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(lock).close();
|
||||
verify(taskFileJobService).touchRunning(9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawLockBusyRequeuesNoFinalize() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
|
||||
TaskFileJobEntity job = job("WITHDRAW", 10L, 20L, 30L);
|
||||
allowClaim(job);
|
||||
when(taskDistributedLockService.acquire("WITHDRAW", 20L,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(null);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).requeue(10L, "Task is busy, waiting for previous task operation");
|
||||
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseIntegrationSimilar() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L, 31L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
|
||||
when(handler.process(job)).thenReturn(false);
|
||||
stubHandler(handler, false, false);
|
||||
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).deferRunning(11L, "Waiting for LLM/file assembly to continue");
|
||||
verify(lock).close();
|
||||
}
|
||||
}
|
||||
+284
@@ -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
Reference in New Issue
Block a user