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

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