task-98: 移除 similar-asin/appearance-patent 模块 Coze,状态机与共享组件改名 LLM
Build Backend JAR / build (push) Has been cancelled
Build Backend JAR / build (push) Has been cancelled
- similarasin/appearancepatent 模块全部 Coze 工作流调用改走 direct-LLM(已确认唯一运行路径) - 共享组件改名:CozeTaskQueueGate→TaskQueueGate、CozeGroupResultPropagator→GroupResultPropagator - 状态机改名:biz_task_scope_state 的 coze_* 列→llm_*、stateJson coze 键→llm(V100 迁移已应用生产) - 删除 biz_coze_credential 表、CozeCredential* 类、SimilarAsinCozeClient、AppearancePatentCozeClient→LlmClient - 前端 brand 页 Coze 文案→LLM;Python 后端删除 cozepy 依赖与死配置 - 修复 TaskResultFileJobWorker 启动失败:TaskFileJobConfig 注册 ResultFileJobHandlerRegistry 与 13 个 handler bean(含 validateCoverage 启动校验)
This commit is contained in:
+237
@@ -0,0 +1,237 @@
|
||||
package com.nanri.aiimage.common.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.common.exception.TaskOwnerMismatchException;
|
||||
import com.nanri.aiimage.config.InstanceRoutingProperties;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.mock.web.DelegatingServletInputStream;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.web.util.ContentCachingRequestWrapper;
|
||||
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* TaskOwnerForwardService 转发安全测试(任务 54)。
|
||||
* 反射调用私有静态方法 requestBody / copyForwardHeaders / hasAlreadyForwarded,
|
||||
* 验证 multipart 大 body 转发路径与 hop-by-hop 头剔除。
|
||||
*/
|
||||
class TaskOwnerForwardServiceTest {
|
||||
|
||||
private static final byte[] MULTIPART_BODY = (
|
||||
"-----b\r\nContent-Disposition: form-data; name=\"f\"; filename=\"a.bin\"\r\n\r\n"
|
||||
+ "x".repeat(2048) + "\r\n-----b--\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static Object invoke(String name, Class<?>[] paramTypes, Object... args) throws Exception {
|
||||
Method method = TaskOwnerForwardService.class.getDeclaredMethod(name, paramTypes);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return method.invoke(null, args);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] requestBody(HttpServletRequest request) throws Exception {
|
||||
return (byte[]) invoke("requestBody", new Class<?>[]{HttpServletRequest.class}, request);
|
||||
}
|
||||
|
||||
private static HttpHeaders forwardHeaders(HttpServletRequest request, String currentInstanceId) throws Exception {
|
||||
return (HttpHeaders) invoke("copyForwardHeaders",
|
||||
new Class<?>[]{HttpServletRequest.class, String.class}, request, currentInstanceId);
|
||||
}
|
||||
|
||||
private static boolean alreadyForwarded(HttpServletRequest request) throws Exception {
|
||||
return (boolean) invoke("hasAlreadyForwarded", new Class<?>[]{HttpServletRequest.class}, request);
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonWrapperReadsInputStream() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.setContent(MULTIPART_BODY);
|
||||
assertArrayEquals(MULTIPART_BODY, requestBody(request), "非 wrapper 走 inputStream 路径读完整 body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrapperReadsCache() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.setContent(MULTIPART_BODY);
|
||||
ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request);
|
||||
byte[] body = requestBody(wrapper);
|
||||
assertArrayEquals(new byte[0], body, "wrapper 路径读缓存:body 未被消费时缓存为空数组");
|
||||
byte[] content = wrapper.getContentAsByteArray();
|
||||
assertTrue(content.length == 0, "未消费则缓存仍为空");
|
||||
wrapper.getInputStream().readAllBytes();
|
||||
assertArrayEquals(MULTIPART_BODY, requestBody(wrapper), "消费后缓存与原始 body 一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartBodyForwardOk() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/upload");
|
||||
request.setContentType("multipart/form-data; boundary=----b");
|
||||
request.setContent(MULTIPART_BODY);
|
||||
assertArrayEquals(MULTIPART_BODY, requestBody(request), "multipart 转发 body 与原请求字节一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ioErrorHasMessage() {
|
||||
// 伪造读流即抛 IOException 的 request:read() 抛 IOException,StreamUtils 原样上抛
|
||||
java.io.InputStream failing = new java.io.InputStream() {
|
||||
@Override
|
||||
public int read() throws java.io.IOException {
|
||||
throw new java.io.IOException("stream closed");
|
||||
}
|
||||
};
|
||||
MockHttpServletRequest broken = new MockHttpServletRequest("POST", "/api/x") {
|
||||
@Override
|
||||
public jakarta.servlet.ServletInputStream getInputStream() {
|
||||
return new DelegatingServletInputStream(failing);
|
||||
}
|
||||
};
|
||||
broken.setContent("x".getBytes(StandardCharsets.UTF_8));
|
||||
BusinessException ex = assertThrows(BusinessException.class,
|
||||
() -> invoke("requestBody", new Class<?>[]{HttpServletRequest.class}, broken),
|
||||
"IO 异常包装为 BusinessException");
|
||||
assertTrue(ex.getMessage().contains("读取转发请求体失败"), "异常消息携带上下文:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void hopByHopHeadersRemoved() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.addHeader("Connection", "keep-alive");
|
||||
request.addHeader("Transfer-Encoding", "chunked");
|
||||
request.addHeader("Host", "example.com");
|
||||
request.addHeader("Content-Length", "123");
|
||||
request.addHeader("X-Custom", "keep-me");
|
||||
HttpHeaders headers = forwardHeaders(request, "server-110");
|
||||
assertFalse(headers.containsKey("Connection"), "connection 剔除");
|
||||
assertFalse(headers.containsKey("Transfer-Encoding"), "transfer-encoding 剔除");
|
||||
assertFalse(headers.containsKey("Host"), "host 剔除");
|
||||
assertFalse(headers.containsKey("Content-Length"), "content-length 剔除");
|
||||
assertEquals("keep-me", headers.getFirst("X-Custom"), "普通头保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardedHeaderSet() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
HttpHeaders headers = forwardHeaders(request, "server-121");
|
||||
assertEquals("server-121", headers.getFirst(TaskOwnerForwardService.FORWARDED_HEADER),
|
||||
"转发头标记当前实例");
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentTypeKept() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.setContentType("multipart/form-data; boundary=----b");
|
||||
HttpHeaders headers = forwardHeaders(request, "server-110");
|
||||
assertEquals("multipart/form-data; boundary=----b", headers.getFirst(HttpHeaders.CONTENT_TYPE),
|
||||
"content-type 保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void loopDetected() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.addHeader(TaskOwnerForwardService.FORWARDED_HEADER, "server-110");
|
||||
assertTrue(alreadyForwarded(request), "已带转发头判定为循环");
|
||||
MockHttpServletRequest fresh = new MockHttpServletRequest("POST", "/api/forward");
|
||||
assertFalse(alreadyForwarded(fresh), "无转发头不判定循环");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardExceptionCarriesLoopMessage() {
|
||||
TaskOwnerMismatchException ex = new TaskOwnerMismatchException(
|
||||
1L, "op", "server-110", "server-121");
|
||||
InstanceRoutingProperties properties = new InstanceRoutingProperties();
|
||||
properties.setRoutes(Map.of("server-110", "http://10.0.0.1:18080"));
|
||||
TaskOwnerForwardService service = new TaskOwnerForwardService(properties);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/forward");
|
||||
request.addHeader(TaskOwnerForwardService.FORWARDED_HEADER, "server-110");
|
||||
BusinessException loopEx = assertThrows(BusinessException.class,
|
||||
() -> service.forwardCurrentRequest(ex, request),
|
||||
"循环转发拒绝并抛 BusinessException");
|
||||
assertTrue(loopEx.getMessage().contains("循环"), "循环消息:" + loopEx.getMessage());
|
||||
}
|
||||
|
||||
private static byte[] forwardBodyThroughWrapper(byte[] content, String contentType) throws Exception {
|
||||
return forwardBodyThroughWrapper(content, contentType, 1024 * 1024);
|
||||
}
|
||||
|
||||
private static byte[] forwardBodyThroughWrapper(byte[] content, String contentType, int cacheLimitBytes) throws Exception {
|
||||
// 模拟真实链路:过滤器包装 → 业务读取(填充缓存)→ 转发读缓存
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/upload");
|
||||
request.setContentType(contentType);
|
||||
request.setContent(content);
|
||||
ContentCachingRequestWrapper wrapper = new ContentCachingRequestWrapper(request, cacheLimitBytes);
|
||||
wrapper.getInputStream().readAllBytes();
|
||||
return requestBody(wrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardJsonBytesEqual() throws Exception {
|
||||
byte[] body = "{\"taskId\":100,\"status\":\"RUNNING\"}".getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json"), "json 字节级一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardMultipartBytesEqual() throws Exception {
|
||||
assertArrayEquals(MULTIPART_BODY, forwardBodyThroughWrapper(MULTIPART_BODY, "multipart/form-data; boundary=----b"),
|
||||
"multipart 字节级一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardEmptyBodySafe() throws Exception {
|
||||
byte[] empty = new byte[0];
|
||||
assertArrayEquals(empty, forwardBodyThroughWrapper(empty, "application/json"), "空 body 安全");
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/empty");
|
||||
assertArrayEquals(empty, requestBody(request), "非包装空 body 返回空数组");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardLargeBodyEqual() throws Exception {
|
||||
byte[] large = ("{\"payload\":\"" + "x".repeat(1024 * 1024 + 100) + "\"}").getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(large, forwardBodyThroughWrapper(large, "application/json", 2 * 1024 * 1024), ">1MB 大 body 一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardUtf8ChineseEqual() throws Exception {
|
||||
byte[] body = "{\"name\":\"任务进度查询\",\"备注\":\"成功\"}".getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json; charset=utf-8"), "中文 UTF-8 一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardSpecialCharsEqual() throws Exception {
|
||||
byte[] body = "{\"s\":\"a\\n\\t\\\"b\\\\céü中\"}".getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json"), "转义与特殊字符一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardMultipleChunksEqual() throws Exception {
|
||||
StringBuilder sb = new StringBuilder("{\"chunks\":[");
|
||||
for (int i = 0; i < 50; i++) {
|
||||
if (i > 0) sb.append(',');
|
||||
sb.append('"').append("chunk-").append(i).append('"');
|
||||
}
|
||||
sb.append("]}");
|
||||
byte[] body = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
assertArrayEquals(body, forwardBodyThroughWrapper(body, "application/json"), "多段内容一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void forwardIdentityRepeated() throws Exception {
|
||||
byte[] body = "{\"repeat\":\"yes\"}".getBytes(StandardCharsets.UTF_8);
|
||||
byte[] first = forwardBodyThroughWrapper(body, "application/json");
|
||||
byte[] second = forwardBodyThroughWrapper(body, "application/json");
|
||||
assertArrayEquals(first, second, "两次转发结果一致");
|
||||
assertArrayEquals(body, second, "与原始一致");
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -6,7 +6,7 @@ import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class CozeGroupResultPropagatorTest {
|
||||
class GroupResultPropagatorTest {
|
||||
|
||||
@Test
|
||||
void doesNotTreatNoInfringementAsInfringementHit() {
|
||||
@@ -19,7 +19,7 @@ class CozeGroupResultPropagatorTest {
|
||||
new ResultRow("无侵权")
|
||||
);
|
||||
|
||||
int updatedRows = CozeGroupResultPropagator.propagateByGroup(
|
||||
int updatedRows = GroupResultPropagator.propagateByGroup(
|
||||
parsedRows,
|
||||
ParsedRow::displayId,
|
||||
row -> resultRows.get(parsedRows.indexOf(row)),
|
||||
@@ -44,7 +44,7 @@ class CozeGroupResultPropagatorTest {
|
||||
new ResultRow("侵权")
|
||||
);
|
||||
|
||||
int updatedRows = CozeGroupResultPropagator.propagateByGroup(
|
||||
int updatedRows = GroupResultPropagator.propagateByGroup(
|
||||
parsedRows,
|
||||
ParsedRow::displayId,
|
||||
row -> resultRows.get(parsedRows.indexOf(row)),
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 架构文档 §6 现状描述核对(任务 56,验证型)。
|
||||
* 断言 java-architecture-optimization-plan.md §6 已反映本轮实现现状:
|
||||
* multipart 直通、1MB 阈值、过滤器日志不读 body、未做项标注、spec 引用、
|
||||
* 与 00-overview 实施记录一致、无过时默认值/占位符残留。
|
||||
*/
|
||||
class ArchitecturePlanSection6DocTest {
|
||||
|
||||
private static final Path DOC = Paths.get("src", "main", "resources", "..", "..", "..",
|
||||
"docs", "java-architecture-optimization-plan.md").normalize();
|
||||
private static final Path OVERVIEW = Paths.get("src", "main", "resources", "..", "..", "..",
|
||||
"docs", "plans", "00-plan-overview.md").normalize();
|
||||
|
||||
private static String read(Path path) throws IOException {
|
||||
return new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String section6() throws IOException {
|
||||
String doc = read(DOC);
|
||||
int start = doc.indexOf("## 6. ");
|
||||
int end = doc.indexOf("## 7. ", start);
|
||||
assertTrue(start >= 0, "文档存在 §6 章节");
|
||||
return end > start ? doc.substring(start, end) : doc.substring(start);
|
||||
}
|
||||
|
||||
private static List<String> section6Lines() throws IOException {
|
||||
return List.of(section6().split("\r?\n"));
|
||||
}
|
||||
|
||||
private static int docLineNumberOf(String section, String needle) {
|
||||
List<String> lines = List.of(section.split("\r?\n"));
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
if (lines.get(i).contains(needle)) {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Test
|
||||
void noObsoleteDefaultRemains() throws IOException {
|
||||
for (String line : section6Lines()) {
|
||||
assertFalse(line.contains("104857600"), "§6 无过时默认值 104857600 残留:" + line);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartPassthroughStated() throws IOException {
|
||||
String section = section6();
|
||||
assertTrue(section.contains("multipart"), "§6 记录 multipart 处理");
|
||||
assertTrue(section.contains("直通") || section.contains("跳过缓存"),
|
||||
"§6 记录 multipart 直通现状");
|
||||
}
|
||||
|
||||
@Test
|
||||
void threshold1MbStated() throws IOException {
|
||||
String section = section6();
|
||||
assertTrue(section.contains("1MB") || section.contains("1 MiB") || section.contains("1048576"),
|
||||
"§6 记录 1MB 阈值现状");
|
||||
assertTrue(section.contains("1048576"), "§6 记录阈值具体值 1048576");
|
||||
}
|
||||
|
||||
@Test
|
||||
void notReadingBodyStated() throws IOException {
|
||||
String section = section6();
|
||||
assertTrue(section.contains("不读 body") || (section.contains("日志") && section.contains("body")),
|
||||
"§6 记录过滤器日志不读 body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deferredItemsListed() throws IOException {
|
||||
String section = section6();
|
||||
assertTrue(section.contains("未做") || section.contains("不做") || section.contains("后续批次"),
|
||||
"§6 标注本轮未做项");
|
||||
}
|
||||
|
||||
@Test
|
||||
void spec03Referenced() throws IOException {
|
||||
String section = section6();
|
||||
assertTrue(section.contains("specs/03-request-trace-filter.md")
|
||||
|| section.contains("03-request-trace-filter.md"),
|
||||
"§6 引用 03 spec");
|
||||
}
|
||||
|
||||
@Test
|
||||
void consistentWithOverview() throws IOException {
|
||||
String section = section6();
|
||||
String overview = read(OVERVIEW);
|
||||
for (String key : List.of("49", "50", "51", "52", "53", "54", "55", "56")) {
|
||||
assertTrue(overview.contains("| " + key + " |"), "00-overview 实施记录含任务 " + key);
|
||||
}
|
||||
assertTrue(overview.contains("multipart 直通"), "00-overview 记录 multipart 直通");
|
||||
assertEquals(1, overview.split("multipart 直通", -1).length - 1,
|
||||
"00-overview 实施记录恰好一条 multipart 直通条目(与 §6 现状一致)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void noTodoLeft() throws IOException {
|
||||
String section = section6();
|
||||
assertTrue(docLineNumberOf(section, "### 现状") > 0 || section.contains("已实现"),
|
||||
"§6 有现状或已实现描述");
|
||||
assertFalse(section.contains("TBD"), "§6 无 TBD 残留");
|
||||
assertFalse(section.contains("TODO"), "§6 无 TODO 残留");
|
||||
assertFalse(section.contains("待确认"), "§6 无待确认残留");
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -3,7 +3,7 @@ package com.nanri.aiimage.config;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
|
||||
import com.nanri.aiimage.modules.brand.client.BrandCheckClient.BrandCheckBatchResult;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
|
||||
import com.nanri.aiimage.modules.ziniao.client.ZiniaoClientImpl;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -25,9 +25,9 @@ import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Task 77:统一 Coze、品牌检查和紫鸟 HTTP 客户端的连接复用策略。
|
||||
* Task 77:统一 LLM、品牌检查和紫鸟 HTTP 客户端的连接复用策略。
|
||||
* 三个外部客户端统一使用 HttpClientPool 共享的 java.net.http.HttpClient
|
||||
* (内置 keep-alive 连接复用):Coze 与品牌检查从无连接池的
|
||||
* (内置 keep-alive 连接复用):LLM 与品牌检查从无连接池的
|
||||
* HttpURLConnection 迁移到共享池;紫鸟从每次调用新建 RestClient 改为
|
||||
* 复用共享单例。同一 HttpClient 实例即表明连接复用同一连接池。
|
||||
*/
|
||||
@@ -98,17 +98,17 @@ class HttpClientConnectionReuseTest {
|
||||
void test_task_077_brand_normal_multiple_items() throws Exception {
|
||||
// 批量场景:Coze/品牌/紫鸟三个客户端各自持有独立 RestClient,
|
||||
// 但底层连接池共用同一 HttpClient 实例,不重复创建。
|
||||
SimilarAsinCozeClient coze = new SimilarAsinCozeClient(new SimilarAsinProperties(), new ObjectMapper(), null, null);
|
||||
SimilarAsinLlmClient llm = new SimilarAsinLlmClient(new SimilarAsinProperties(), new ObjectMapper(), null);
|
||||
BrandCheckClient brand = new BrandCheckClient(new BrandCheckProperties(), null);
|
||||
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(new ZiniaoProperties(), new ObjectMapper());
|
||||
|
||||
HttpClient cozeClient = clientOf(factoryOf(restClientOf(coze)));
|
||||
HttpClient llmClient = clientOf(factoryOf(restClientOf(llm)));
|
||||
HttpClient brandClient = clientOf(factoryOf(restClientOf(brand)));
|
||||
HttpClient ziniaoClient = clientOf(factoryOf(restClientOf(ziniao)));
|
||||
|
||||
assertSame(cozeClient, brandClient, "Coze 与品牌检查共享连接池");
|
||||
assertSame(llmClient, brandClient, "LLM 与品牌检查共享连接池");
|
||||
assertSame(brandClient, ziniaoClient, "品牌检查与紫鸟共享连接池");
|
||||
assertSame(HttpClientPool.sharedHttpClient(), cozeClient, "与共享单例一致");
|
||||
assertSame(HttpClientPool.sharedHttpClient(), llmClient, "与共享单例一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.env.YamlPropertySourceLoader;
|
||||
import org.springframework.core.env.MapPropertySource;
|
||||
import org.springframework.core.env.PropertySource;
|
||||
import org.springframework.core.env.StandardEnvironment;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* application.yml 默认值与 env 覆盖测试(任务 53)。
|
||||
* 用 Spring 的 YamlPropertySourceLoader 真实解析 application.yml,
|
||||
* 再经 StandardEnvironment 验证 ${VAR:default} 占位符的默认值与 env 覆盖语义。
|
||||
*/
|
||||
class RequestTraceConfigTest {
|
||||
|
||||
private static final String KEY = "aiimage.instance-routing.request-body-cache-limit-bytes";
|
||||
|
||||
private static String rawYmlValue() throws IOException {
|
||||
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
List<PropertySource<?>> sources =
|
||||
loader.load("app.yml", new ClassPathResource("application.yml"));
|
||||
for (PropertySource<?> source : sources) {
|
||||
Object value = source.getProperty(KEY);
|
||||
if (value != null) {
|
||||
return String.valueOf(value);
|
||||
}
|
||||
}
|
||||
throw new AssertionError("application.yml 未包含 " + KEY);
|
||||
}
|
||||
|
||||
private static String resolve(String raw, String envValue) {
|
||||
StandardEnvironment environment = new StandardEnvironment();
|
||||
environment.getPropertySources().addFirst(new MapPropertySource(
|
||||
"test-env", envValue == null
|
||||
? Collections.emptyMap()
|
||||
: Collections.singletonMap("AIIMAGE_INSTANCE_ROUTE_REQUEST_BODY_CACHE_LIMIT_BYTES", envValue)));
|
||||
return environment.resolvePlaceholders(raw);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ymlDefaultIs1MiB() throws IOException {
|
||||
assertTrue(rawYmlValue().contains("1048576"), "yml 默认值 1048576(1MB)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void envOverrideTakesEffect() throws IOException {
|
||||
assertEquals("2097152", resolve(rawYmlValue(), "2097152"), "env 覆盖生效");
|
||||
}
|
||||
|
||||
@Test
|
||||
void envOverrideLarge() throws IOException {
|
||||
assertEquals("104857600", resolve(rawYmlValue(), "104857600"), "大值覆盖生效");
|
||||
}
|
||||
|
||||
@Test
|
||||
void envOverrideSmallIsKeptThenClampedByCode() throws IOException {
|
||||
// env 注入 1024(<1MB):yml 层原样解析,钳制在代码层 Math.max(1024*1024, …)
|
||||
assertEquals("1024", resolve(rawYmlValue(), "1024"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void placeholderFormat() throws IOException {
|
||||
assertTrue(rawYmlValue().matches("\\$\\{AIIMAGE_INSTANCE_ROUTE_REQUEST_BODY_CACHE_LIMIT_BYTES:1048576}"),
|
||||
"占位符格式 ${VAR:default}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultMatchesCodeConstant() throws IOException {
|
||||
assertEquals("1048576", resolve(rawYmlValue(), null), "未设 env 时解析为 1048576,与代码 @Value 默认一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void envUnsetUsesDefault() throws IOException {
|
||||
assertFalse(resolve(rawYmlValue(), null).startsWith("${"), "未设 env 时占位符被解析为默认值而非保留原文");
|
||||
assertEquals("1048576", resolve(rawYmlValue(), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void ymlLoadsUnderSpringEnv() throws IOException {
|
||||
YamlPropertySourceLoader loader = new YamlPropertySourceLoader();
|
||||
List<PropertySource<?>> sources =
|
||||
loader.load("app.yml", new ClassPathResource("application.yml"));
|
||||
assertFalse(sources.isEmpty(), "application.yml 能被 YamlPropertySourceLoader 加载");
|
||||
boolean found = false;
|
||||
for (PropertySource<?> source : sources) {
|
||||
if (source.containsProperty(KEY)) {
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
assertTrue(found, "instance-routing.request-body-cache-limit-bytes 存在于 yml 配置源");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
package com.nanri.aiimage.config;
|
||||
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
import org.springframework.web.util.ContentCachingRequestWrapper;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.AppenderBase;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class RequestTraceFilterTest {
|
||||
|
||||
private static final int DEFAULT_LIMIT = 1024 * 1024;
|
||||
|
||||
private MockHttpServletRequest request(String method, String contentType) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest(method, "/api/example");
|
||||
if (contentType != null) {
|
||||
request.setContentType(contentType);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
/** 消费请求体触发缓存:ContentCachingRequestWrapper 仅在 getInputStream() 被读时填充缓存 */
|
||||
private static ContentCachingRequestWrapper drain(ContentCachingRequestWrapper wrapper) {
|
||||
try (ServletInputStream stream = wrapper.getInputStream()) {
|
||||
stream.readAllBytes();
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("读取请求体失败", e);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
private static String cachedBody(ContentCachingRequestWrapper wrapper) {
|
||||
return new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String overflowAttr() {
|
||||
return ContentCachingRequestWrapper.class.getName() + ".CONTENT_OVERFLOW";
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartPostIsNotWrapped() {
|
||||
MockHttpServletRequest request = request("POST", "multipart/form-data; boundary=----x");
|
||||
assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonPostIsWrapped() {
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
assertInstanceOf(ContentCachingRequestWrapper.class, RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putAndDeleteAreWrapped() {
|
||||
assertInstanceOf(ContentCachingRequestWrapper.class,
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request("PUT", "application/json"), DEFAULT_LIMIT));
|
||||
assertInstanceOf(ContentCachingRequestWrapper.class,
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request("DELETE", "application/json"), DEFAULT_LIMIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getIsNotWrapped() {
|
||||
MockHttpServletRequest request = request("GET", null);
|
||||
assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void alreadyWrappedRequestIsReturnedAsIs() {
|
||||
ContentCachingRequestWrapper wrapped = new ContentCachingRequestWrapper(request("POST", "application/json"));
|
||||
assertTrue(RequestTraceFilter.wrapRequestIfNeeded(wrapped, DEFAULT_LIMIT) == wrapped);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartUpperCaseBoundaryIsNotWrapped() {
|
||||
MockHttpServletRequest request = request("POST", "Multipart/Form-Data; boundary=----x");
|
||||
assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartMixedCaseIsNotWrapped() {
|
||||
MockHttpServletRequest request = request("POST", "mUlTiPaRt/foRm-DaTa; boundary=----y");
|
||||
assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartRelatedIsNotWrapped() {
|
||||
MockHttpServletRequest request = request("POST", "multipart/related; boundary=----z");
|
||||
assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartWithoutBoundaryIsNotWrapped() {
|
||||
MockHttpServletRequest request = request("POST", "multipart/form-data");
|
||||
assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartWithExtraParamsIsNotWrapped() {
|
||||
MockHttpServletRequest request = request("POST", "multipart/form-data; boundary=----b; charset=utf-8");
|
||||
assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartWithWhitespaceIsNotWrapped() {
|
||||
MockHttpServletRequest request = request("POST", " multipart/form-data; boundary=----w ");
|
||||
assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void multipartPutIsNotWrapped() {
|
||||
MockHttpServletRequest request = request("PUT", "multipart/form-data; boundary=----p");
|
||||
assertFalse(RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT) instanceof ContentCachingRequestWrapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void applicationJsonIsStillWrapped() {
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
assertInstanceOf(ContentCachingRequestWrapper.class,
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void jsonPostUsesProvidedLimit() {
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"payload\":\"x\"}".getBytes());
|
||||
ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper)
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request, 4096));
|
||||
assertEquals("{\"payload\":\"x\"}", cachedBody(wrapper), "缓存按传入阈值(4096 > body)完整读入");
|
||||
assertEquals(null, wrapper.getAttribute(overflowAttr()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitBelowMinimumIsClampedTo1MiB() {
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"k\":\"v\"}".getBytes());
|
||||
ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper)
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request, 1024 * 1024 - 1));
|
||||
// <1MB 被钳到 1MB 下限:远超 body,可完整读出(若未钳制,body 截断/溢出标记)
|
||||
assertEquals("{\"k\":\"v\"}", cachedBody(wrapper));
|
||||
assertEquals(null, wrapper.getAttribute(overflowAttr()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitZeroIsClampedTo1MiB() {
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"k\":\"v\"}".getBytes());
|
||||
ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper)
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request, 0));
|
||||
assertEquals("{\"k\":\"v\"}", cachedBody(wrapper), "0 钳制到 1MB 后仍可完整缓存");
|
||||
assertEquals(null, wrapper.getAttribute(overflowAttr()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitNegativeIsClampedTo1MiB() {
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"k\":\"v\"}".getBytes());
|
||||
ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper)
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request, -5));
|
||||
assertEquals("{\"k\":\"v\"}", cachedBody(wrapper), "负数钳制到 1MB 后仍可完整缓存");
|
||||
assertEquals(null, wrapper.getAttribute(overflowAttr()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitExactlyMinimumIsKept() {
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"k\":\"v\"}".getBytes());
|
||||
ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper)
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request, 1024 * 1024));
|
||||
assertEquals("{\"k\":\"v\"}", cachedBody(wrapper), "恰好 1MB 不钳制,body 完整缓存");
|
||||
assertEquals(null, wrapper.getAttribute(overflowAttr()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitLargeIsKept() {
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"k\":\"v\"}".getBytes());
|
||||
ContentCachingRequestWrapper wrapper = drain((ContentCachingRequestWrapper)
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request, 8 * 1024 * 1024));
|
||||
assertEquals("{\"k\":\"v\"}", cachedBody(wrapper), "大阈值保留,body 完整缓存");
|
||||
assertEquals(null, wrapper.getAttribute(overflowAttr()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void deleteRequestIsWrapped() {
|
||||
MockHttpServletRequest request = request("DELETE", "application/json");
|
||||
assertInstanceOf(ContentCachingRequestWrapper.class,
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT));
|
||||
}
|
||||
|
||||
@Test
|
||||
void putRequestIsWrapped() {
|
||||
MockHttpServletRequest request = request("PUT", "application/json");
|
||||
assertInstanceOf(ContentCachingRequestWrapper.class,
|
||||
RequestTraceFilter.wrapRequestIfNeeded(request, DEFAULT_LIMIT));
|
||||
}
|
||||
|
||||
private static final byte[] CAPTURE_MAGIC = "SECRET-BODY-PAYLOAD-42".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
/** 拦截日志输出,验证过滤器日志不包含 body 内容(AppenderBase 必须 start 后才会接收事件) */
|
||||
private static String captureLogsFor(byte[] body, String contentType) throws Exception {
|
||||
ByteArrayOutputStream captured = new ByteArrayOutputStream();
|
||||
AppenderBase<ILoggingEvent> appender = new AppenderBase<ILoggingEvent>() {
|
||||
@Override
|
||||
protected void append(ILoggingEvent event) {
|
||||
captured.writeBytes((event.getFormattedMessage() + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
};
|
||||
appender.start();
|
||||
((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(RequestTraceFilter.class)).addAppender(appender);
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/example");
|
||||
request.setContentType(contentType);
|
||||
request.setContent(body);
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
ServletInputStream stream = req.getInputStream();
|
||||
stream.readAllBytes();
|
||||
});
|
||||
return captured.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterLogDoesNotReadBody() throws Exception {
|
||||
String logs = captureLogsFor(CAPTURE_MAGIC, "application/json");
|
||||
assertFalse(logs.contains("SECRET-BODY-PAYLOAD-42"), "日志只消费 method/uri/status/headers,不含 body 内容");
|
||||
assertTrue(logs.contains("POST"), "日志含 method");
|
||||
assertTrue(logs.contains("/api/example"), "日志含 uri");
|
||||
}
|
||||
|
||||
@Test
|
||||
void filterPassesChain() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"a\":1}".getBytes());
|
||||
boolean[] invoked = {false};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> invoked[0] = true);
|
||||
assertTrue(invoked[0], "过滤链正常执行");
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessReadsJsonBodyAfterFilter() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"hello\":\"世界\"}".getBytes());
|
||||
byte[][] read = {new byte[0]};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
byte[] bytes = req.getInputStream().readAllBytes();
|
||||
read[0] = bytes;
|
||||
});
|
||||
assertEquals("{\"hello\":\"世界\"}", new String(read[0], StandardCharsets.UTF_8), "业务侧仍可读完整 json body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessReadsMultipartBody() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
MockHttpServletRequest request = request("POST", "multipart/form-data; boundary=----b");
|
||||
byte[] body = "-----b\r\nContent-Disposition: form-data; name=\"f\"; filename=\"a.txt\"\r\n\r\nhello\r\n-----b--\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
request.setContent(body);
|
||||
byte[][] read = {new byte[0]};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
byte[] bytes = req.getInputStream().readAllBytes();
|
||||
read[0] = bytes;
|
||||
});
|
||||
assertEquals(new String(body, StandardCharsets.UTF_8), new String(read[0], StandardCharsets.UTF_8),
|
||||
"multipart 不包装,业务侧读到的就是原始流");
|
||||
}
|
||||
|
||||
@Test
|
||||
void inputStreamReusableAfterFilter() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"k\":1}".getBytes());
|
||||
byte[][] reads = {new byte[0], new byte[0]};
|
||||
ContentCachingRequestWrapper[] chainWrapper = {null};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
chainWrapper[0] = (ContentCachingRequestWrapper) req;
|
||||
reads[0] = req.getInputStream().readAllBytes();
|
||||
});
|
||||
reads[1] = chainWrapper[0].getContentAsByteArray();
|
||||
assertEquals("{\"k\":1}", new String(reads[0], StandardCharsets.UTF_8), "业务侧读过一次");
|
||||
assertEquals("{\"k\":1}", new String(reads[1], StandardCharsets.UTF_8), "过滤后缓存仍可取(无第二次消费)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void instanceResponseHeadersAdded() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
MockHttpServletRequest request = request("GET", null);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
filter.doFilter(request, response, (req, res) -> {
|
||||
});
|
||||
assertEquals("test-instance", response.getHeader("X-AIIMAGE-Instance"));
|
||||
assertNotNull(response.getHeader("X-AIIMAGE-Host"), "host 头存在");
|
||||
assertEquals("configured", response.getHeader("X-AIIMAGE-Instance-Source"));
|
||||
assertEquals("true", response.getHeader("X-AIIMAGE-Instance-Stable"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void costLogged() throws Exception {
|
||||
String logs = captureLogsFor(CAPTURE_MAGIC, "application/json");
|
||||
assertTrue(logs.matches("(?s).*costMs=\\d+.*"), "costMs 数字被记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestIdLogged() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
ByteArrayOutputStream captured = new ByteArrayOutputStream();
|
||||
AppenderBase<ILoggingEvent> appender = new AppenderBase<ILoggingEvent>() {
|
||||
@Override
|
||||
protected void append(ILoggingEvent event) {
|
||||
captured.writeBytes((event.getFormattedMessage() + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
};
|
||||
appender.start();
|
||||
((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(RequestTraceFilter.class)).addAppender(appender);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.addHeader("X-Request-Id", "req-abc-123");
|
||||
request.setContent("{}".getBytes());
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
});
|
||||
String logs = captured.toString(StandardCharsets.UTF_8);
|
||||
assertTrue(logs.contains("requestId=req-abc-123"), "requestId 从 X-Request-Id 透传进日志");
|
||||
assertFalse(logs.contains("requestId=-"), "requestId 未缺失");
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeJsonOver1MbSucceeds() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
byte[] large = ("{\"payload\":\"" + "x".repeat(2 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent(large);
|
||||
boolean[] invoked = {false};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> invoked[0] = true);
|
||||
assertTrue(invoked[0], ">1MB json 请求成功穿过过滤器");
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeJsonCacheTruncated() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
byte[] large = ("{\"payload\":\"" + "x".repeat(2 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent(large);
|
||||
ContentCachingRequestWrapper[] chainWrapper = {null};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
chainWrapper[0] = (ContentCachingRequestWrapper) req;
|
||||
req.getInputStream().readAllBytes();
|
||||
});
|
||||
byte[] cached = chainWrapper[0].getContentAsByteArray();
|
||||
assertTrue(cached.length <= 1024 * 1024, ">1MB body 缓存被截断到阈值内(实际 " + cached.length + "B)");
|
||||
assertEquals(new String(large, StandardCharsets.UTF_8),
|
||||
new String(large, StandardCharsets.UTF_8), "body 原值保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeMultipartUploadSucceeds() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
byte[] large = ("-----b\r\nContent-Disposition: form-data; name=\"f\"; filename=\"big.bin\"\r\n\r\n"
|
||||
+ "x".repeat(2 * 1024 * 1024) + "\r\n-----b--\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
MockHttpServletRequest request = request("POST", "multipart/form-data; boundary=----b");
|
||||
request.setContent(large);
|
||||
byte[][] read = {new byte[0]};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
read[0] = req.getInputStream().readAllBytes();
|
||||
});
|
||||
assertArrayEquals(large, read[0], "multipart 不包装:>1MB 上传原样通过,业务读完整 body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeBodyBusinessReadsFully() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
byte[] large = ("{\"payload\":\"" + "y".repeat(2 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent(large);
|
||||
byte[][] read = {new byte[0]};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
read[0] = req.getInputStream().readAllBytes();
|
||||
});
|
||||
assertArrayEquals(large, read[0], "业务侧读到完整大 body(缓存截断不影响业务读取)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void smallJsonCached() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent("{\"small\":1}".getBytes());
|
||||
ContentCachingRequestWrapper[] chainWrapper = {null};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
chainWrapper[0] = (ContentCachingRequestWrapper) req;
|
||||
req.getInputStream().readAllBytes();
|
||||
});
|
||||
assertEquals("{\"small\":1}", new String(chainWrapper[0].getContentAsByteArray(), StandardCharsets.UTF_8),
|
||||
"小 json 正常整包缓存");
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentLengthHeaderPreserved() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
byte[] body = "{\"len\":\"ok\"}".getBytes(StandardCharsets.UTF_8);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent(body);
|
||||
request.addHeader("Content-Length", String.valueOf(body.length));
|
||||
ContentCachingRequestWrapper[] chainWrapper = {null};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
chainWrapper[0] = (ContentCachingRequestWrapper) req;
|
||||
});
|
||||
assertEquals(body.length, chainWrapper[0].getContentLength(), "content-length 经 wrapper 保留");
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeJsonForwardPathReads() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
byte[] large = ("{\"data\":\"" + "z".repeat(3 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8);
|
||||
MockHttpServletRequest request = request("POST", "/api/forward");
|
||||
request.setContent(large);
|
||||
byte[][] read = {new byte[0]};
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> {
|
||||
read[0] = req.getInputStream().readAllBytes();
|
||||
});
|
||||
assertArrayEquals(large, read[0], "转发路径(非 API 前缀)大 body 读取正常");
|
||||
}
|
||||
|
||||
@Test
|
||||
void largeJsonHandlingTimeBounded() throws Exception {
|
||||
InstanceMetadata metadata = new InstanceMetadata("test-instance");
|
||||
RequestTraceFilter filter = new RequestTraceFilter(metadata, 1024 * 1024);
|
||||
byte[] large = ("{\"t\":\"" + "w".repeat(2 * 1024 * 1024) + "\"}").getBytes(StandardCharsets.UTF_8);
|
||||
MockHttpServletRequest request = request("POST", "application/json");
|
||||
request.setContent(large);
|
||||
long start = System.nanoTime();
|
||||
filter.doFilter(request, new MockHttpServletResponse(), (req, res) -> req.getInputStream().readAllBytes());
|
||||
long costMs = (System.nanoTime() - start) / 1_000_000;
|
||||
assertTrue(costMs < 5000, "大 body 处理耗时合理(实际 " + costMs + "ms)");
|
||||
}
|
||||
}
|
||||
+15
-15
@@ -22,12 +22,12 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 75:为虚拟线程任务增加等待队列上限与拒绝/延迟指标。
|
||||
* CozeTaskQueueGate 在信号量限流(并发上限)之外增加排队闸门:
|
||||
* TaskQueueGate 在信号量限流(并发上限)之外增加排队闸门:
|
||||
* 已提交未启动(含等待信号量)的任务数量达到 max-waiting 上限时
|
||||
* 立即拒绝新提交并记录拒绝指标;每次提交/执行记录等待耗时与执行耗时
|
||||
* 指标;任务执行完成、失败或执行器拒绝后排队名额必须释放。
|
||||
*/
|
||||
class CozeTaskQueueGateTest {
|
||||
class TaskQueueGateTest {
|
||||
|
||||
private SimpleMeterRegistry registry;
|
||||
private ObjectProvider<MeterRegistry> meterRegistryProvider;
|
||||
@@ -44,8 +44,8 @@ class CozeTaskQueueGateTest {
|
||||
capturingDelegate = captured::add;
|
||||
}
|
||||
|
||||
private CozeTaskQueueGate gate(int maxWaiting) {
|
||||
return new CozeTaskQueueGate(capturingDelegate, maxWaiting, meterRegistryProvider);
|
||||
private TaskQueueGate gate(int maxWaiting) {
|
||||
return new TaskQueueGate(capturingDelegate, maxWaiting, meterRegistryProvider);
|
||||
}
|
||||
|
||||
private long rejectedCount(String reason) {
|
||||
@@ -67,7 +67,7 @@ class CozeTaskQueueGateTest {
|
||||
@Test
|
||||
void test_task_075_metrics_normal_default_path() {
|
||||
// 默认路径:任务正常受理并执行,等待/执行指标各记一次,排队名额释放。
|
||||
CozeTaskQueueGate gate = gate(4);
|
||||
TaskQueueGate gate = gate(4);
|
||||
AtomicInteger ran = new AtomicInteger();
|
||||
gate.execute(ran::incrementAndGet);
|
||||
|
||||
@@ -84,7 +84,7 @@ class CozeTaskQueueGateTest {
|
||||
@Test
|
||||
void test_task_075_metrics_normal_multiple_items() {
|
||||
// 批量场景:多个任务依次受理,执行顺序稳定不丢失,指标逐条记录。
|
||||
CozeTaskQueueGate gate = gate(8);
|
||||
TaskQueueGate gate = gate(8);
|
||||
List<Integer> order = new ArrayList<>();
|
||||
for (int i = 1; i <= 3; i++) {
|
||||
int id = i;
|
||||
@@ -103,7 +103,7 @@ class CozeTaskQueueGateTest {
|
||||
@Test
|
||||
void test_task_075_metrics_normal_repeated_operation_is_idempotent() {
|
||||
// 幂等:同一任务对象重复提交各自独立执行,不合并、不丢失。
|
||||
CozeTaskQueueGate gate = gate(4);
|
||||
TaskQueueGate gate = gate(4);
|
||||
AtomicInteger ran = new AtomicInteger();
|
||||
Runnable task = ran::incrementAndGet;
|
||||
gate.execute(task);
|
||||
@@ -117,7 +117,7 @@ class CozeTaskQueueGateTest {
|
||||
@Test
|
||||
void test_task_075_metrics_boundary_empty_input() {
|
||||
// 空输入:null 任务直接拒绝,不计数、不产生指标。
|
||||
CozeTaskQueueGate gate = gate(4);
|
||||
TaskQueueGate gate = gate(4);
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> gate.execute(null));
|
||||
assertTrue(ex.getMessage().contains("不能为 null"), "可识别错误消息");
|
||||
@@ -130,7 +130,7 @@ class CozeTaskQueueGateTest {
|
||||
@Test
|
||||
void test_task_075_metrics_boundary_single_item() {
|
||||
// 单元素:单任务直接受理执行,不依赖批量路径。
|
||||
CozeTaskQueueGate gate = gate(1);
|
||||
TaskQueueGate gate = gate(1);
|
||||
AtomicInteger ran = new AtomicInteger();
|
||||
gate.execute(ran::incrementAndGet);
|
||||
captured.get(0).run();
|
||||
@@ -143,7 +143,7 @@ class CozeTaskQueueGateTest {
|
||||
void test_task_075_metrics_boundary_limit_and_overflow() {
|
||||
// 上限/超限:等待队列达到上限后新提交被拒绝(记录拒绝指标),
|
||||
// 排队名额释放后可再次受理,等待数不发生无界增长。
|
||||
CozeTaskQueueGate gate = gate(1);
|
||||
TaskQueueGate gate = gate(1);
|
||||
AtomicInteger ran = new AtomicInteger();
|
||||
|
||||
gate.execute(ran::incrementAndGet);
|
||||
@@ -171,13 +171,13 @@ class CozeTaskQueueGateTest {
|
||||
void test_task_075_metrics_invalid_input_rejected() {
|
||||
// 非法参数:等待上限 0/负值统一钳制到 1(首个受理、第二个拒绝),
|
||||
// null 任务拒绝且不产生指标。
|
||||
CozeTaskQueueGate zeroLimit = gate(0);
|
||||
TaskQueueGate zeroLimit = gate(0);
|
||||
zeroLimit.execute(() -> { });
|
||||
assertThrows(TaskRejectedException.class, () -> zeroLimit.execute(() -> { }));
|
||||
assertEquals(1, captured.size(), "上限 0 回退到 1");
|
||||
|
||||
captured.clear();
|
||||
CozeTaskQueueGate negativeLimit = gate(-5);
|
||||
TaskQueueGate negativeLimit = gate(-5);
|
||||
negativeLimit.execute(() -> { });
|
||||
assertThrows(TaskRejectedException.class, () -> negativeLimit.execute(() -> { }));
|
||||
assertEquals(1, captured.size(), "负值回退到 1");
|
||||
@@ -190,21 +190,21 @@ class CozeTaskQueueGateTest {
|
||||
void test_task_075_metrics_dependency_failure_releases_resources() {
|
||||
// 依赖失败:执行器拒绝时名额释放、记录拒绝指标,恢复后再次提交成功;
|
||||
// 任务执行抛异常时名额同样释放,后续任务不受影响。
|
||||
CozeTaskQueueGate gate = new CozeTaskQueueGate(command -> {
|
||||
TaskQueueGate gate = new TaskQueueGate(command -> {
|
||||
throw new TaskRejectedException("executor full");
|
||||
}, 4, meterRegistryProvider);
|
||||
assertThrows(TaskRejectedException.class, () -> gate.execute(() -> { }));
|
||||
assertEquals(0, gate.waiting(), "拒绝后名额释放");
|
||||
assertEquals(1, rejectedCount("delegate-rejected"), "执行器拒绝单独计数");
|
||||
|
||||
CozeTaskQueueGate recovered = gate(4);
|
||||
TaskQueueGate recovered = gate(4);
|
||||
AtomicInteger ran = new AtomicInteger();
|
||||
recovered.execute(ran::incrementAndGet);
|
||||
captured.get(0).run();
|
||||
assertEquals(1, ran.get(), "恢复后提交成功");
|
||||
|
||||
captured.clear();
|
||||
CozeTaskQueueGate failingTaskGate = gate(4);
|
||||
TaskQueueGate failingTaskGate = gate(4);
|
||||
failingTaskGate.execute(() -> {
|
||||
throw new IllegalStateException("task boom");
|
||||
});
|
||||
+28
-28
@@ -22,11 +22,11 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Task 74:隔离调度线程池、文件作业线程池和外部 Coze/图片执行池。
|
||||
* Task 74:隔离调度线程池、文件作业线程池和外部任务队列/图片执行池。
|
||||
* 三个执行池各自独立配置、独立命名、容量互不影响:调度池
|
||||
* (aiimage.scheduling.pool-size,默认 4)与文件作业派发池
|
||||
* (aiimage.result-file-job.*,默认 2 线程/队列 200)互不共享线程;
|
||||
* 外部 Coze 池以虚拟线程 + 信号量限流(默认 12)。容量非法值统一
|
||||
* 外部任务队列池以虚拟线程 + 信号量限流(默认 12)。容量非法值统一
|
||||
* 钳制到最小值;任务失败后信号量名额与调度槽位必须释放,任一池打满
|
||||
* 不影响其他池。
|
||||
*/
|
||||
@@ -55,14 +55,14 @@ class ThreadPoolIsolationConfigTest {
|
||||
return executor;
|
||||
}
|
||||
|
||||
private ExecutorService newCozeVirtual() {
|
||||
ExecutorService executor = new TaskFileJobConfig().cozeVirtualThreadExecutor();
|
||||
private ExecutorService newTaskQueueVirtual() {
|
||||
ExecutorService executor = new TaskFileJobConfig().taskQueueVirtualThreadExecutor();
|
||||
closeables.add(() -> executor.shutdownNow());
|
||||
return executor;
|
||||
}
|
||||
|
||||
private TaskExecutor newCoze(ExecutorService virtualExecutor, int maxConcurrent) {
|
||||
return new TaskFileJobConfig().cozeTaskExecutor(virtualExecutor, maxConcurrent, 4, null);
|
||||
private TaskExecutor newTaskQueue(ExecutorService virtualExecutor, int maxConcurrent) {
|
||||
return new TaskFileJobConfig().taskQueueExecutor(virtualExecutor, maxConcurrent, 4, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -78,15 +78,15 @@ class ThreadPoolIsolationConfigTest {
|
||||
assertEquals(2, dispatch.getMaxPoolSize(), "文件作业池 core=max,不随压力扩张");
|
||||
assertEquals(200, dispatch.getQueueCapacity());
|
||||
|
||||
ExecutorService cozeVirtual = newCozeVirtual();
|
||||
TaskExecutor coze = newCoze(cozeVirtual, 12);
|
||||
ExecutorService taskQueueVirtual = newTaskQueueVirtual();
|
||||
TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, 12);
|
||||
AtomicBoolean ran = new AtomicBoolean(false);
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
coze.execute(() -> {
|
||||
taskQueue.execute(() -> {
|
||||
ran.set(true);
|
||||
done.countDown();
|
||||
});
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "Coze 池默认限流 12,正常提交即执行");
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "任务队列池默认限流 12,正常提交即执行");
|
||||
assertTrue(ran.get());
|
||||
}
|
||||
|
||||
@@ -96,8 +96,8 @@ class ThreadPoolIsolationConfigTest {
|
||||
// 线程名前缀互不相同,线程转储可识别归属池。
|
||||
ThreadPoolTaskScheduler scheduler = newScheduler(6);
|
||||
ThreadPoolTaskExecutor dispatch = newDispatch(3, 500);
|
||||
ExecutorService cozeVirtual = newCozeVirtual();
|
||||
TaskExecutor coze = newCoze(cozeVirtual, 8);
|
||||
ExecutorService taskQueueVirtual = newTaskQueueVirtual();
|
||||
TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, 8);
|
||||
assertEquals(6, scheduler.getScheduledThreadPoolExecutor().getCorePoolSize());
|
||||
assertEquals(3, dispatch.getCorePoolSize());
|
||||
assertEquals(500, dispatch.getQueueCapacity());
|
||||
@@ -105,7 +105,7 @@ class ThreadPoolIsolationConfigTest {
|
||||
CountDownLatch all = new CountDownLatch(3);
|
||||
dispatch.execute(all::countDown);
|
||||
scheduler.schedule((Runnable) all::countDown, new Date(System.currentTimeMillis() + 50));
|
||||
coze.execute(all::countDown);
|
||||
taskQueue.execute(all::countDown);
|
||||
assertTrue(all.await(5, TimeUnit.SECONDS), "三个池同时执行互不阻塞");
|
||||
|
||||
assertNotEquals(scheduler.getThreadNamePrefix(), dispatch.getThreadNamePrefix(),
|
||||
@@ -139,11 +139,11 @@ class ThreadPoolIsolationConfigTest {
|
||||
ThreadPoolTaskExecutor dispatch = newDispatch(0, 0);
|
||||
assertEquals(1, dispatch.getCorePoolSize(), "文件作业池 0 钳制到 1");
|
||||
assertEquals(10, dispatch.getQueueCapacity(), "队列 0 钳制到 10");
|
||||
ExecutorService cozeVirtual = newCozeVirtual();
|
||||
TaskExecutor coze = newCoze(cozeVirtual, 0);
|
||||
ExecutorService taskQueueVirtual = newTaskQueueVirtual();
|
||||
TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, 0);
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
coze.execute(done::countDown);
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "Coze 池 0 钳制到 1 后仍可执行");
|
||||
taskQueue.execute(done::countDown);
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "任务队列池 0 钳制到 1 后仍可执行");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -200,23 +200,23 @@ class ThreadPoolIsolationConfigTest {
|
||||
assertEquals(1, dispatch.getCorePoolSize());
|
||||
assertEquals(10, dispatch.getQueueCapacity());
|
||||
|
||||
ExecutorService cozeVirtual = newCozeVirtual();
|
||||
TaskExecutor coze = newCoze(cozeVirtual, -3);
|
||||
assertThrows(IllegalArgumentException.class, () -> coze.execute(null), "null 任务被拒绝");
|
||||
ExecutorService taskQueueVirtual = newTaskQueueVirtual();
|
||||
TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, -3);
|
||||
assertThrows(IllegalArgumentException.class, () -> taskQueue.execute(null), "null 任务被拒绝");
|
||||
CountDownLatch done = new CountDownLatch(1);
|
||||
coze.execute(done::countDown);
|
||||
taskQueue.execute(done::countDown);
|
||||
assertTrue(done.await(5, TimeUnit.SECONDS), "非法配置钳制后池仍可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_074_image_dispatch_job_dependency_failure_releases_resources() throws Exception {
|
||||
// 依赖失败:Coze 任务抛异常后信号量名额必须释放(后续任务可执行);
|
||||
// 依赖失败:任务队列任务抛异常后信号量名额必须释放(后续任务可执行);
|
||||
// 调度任务异常被 error handler 吞掉,调度器继续可用。
|
||||
ExecutorService cozeVirtual = newCozeVirtual();
|
||||
TaskExecutor coze = newCoze(cozeVirtual, 2);
|
||||
ExecutorService taskQueueVirtual = newTaskQueueVirtual();
|
||||
TaskExecutor taskQueue = newTaskQueue(taskQueueVirtual, 2);
|
||||
CountDownLatch blockerHeld = new CountDownLatch(1);
|
||||
CountDownLatch releaseBlocker = new CountDownLatch(1);
|
||||
coze.execute(() -> {
|
||||
taskQueue.execute(() -> {
|
||||
blockerHeld.countDown();
|
||||
try {
|
||||
releaseBlocker.await(10, TimeUnit.SECONDS);
|
||||
@@ -225,11 +225,11 @@ class ThreadPoolIsolationConfigTest {
|
||||
}
|
||||
});
|
||||
assertTrue(blockerHeld.await(5, TimeUnit.SECONDS), "任务 1 占住一个信号量名额");
|
||||
coze.execute(() -> {
|
||||
throw new IllegalStateException("coze down");
|
||||
taskQueue.execute(() -> {
|
||||
throw new IllegalStateException("task queue down");
|
||||
});
|
||||
CountDownLatch afterFailure = new CountDownLatch(1);
|
||||
coze.execute(afterFailure::countDown);
|
||||
taskQueue.execute(afterFailure::countDown);
|
||||
assertTrue(afterFailure.await(5, TimeUnit.SECONDS), "失败任务释放名额,后续任务可执行");
|
||||
releaseBlocker.countDown();
|
||||
|
||||
|
||||
+52
-98
@@ -6,9 +6,7 @@ import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.config.ZiniaoProperties;
|
||||
import com.nanri.aiimage.metrics.ExternalCallMetricsRecorder;
|
||||
import com.nanri.aiimage.modules.brand.client.BrandCheckClient;
|
||||
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
|
||||
import com.nanri.aiimage.modules.ziniao.client.ZiniaoClientImpl;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
@@ -35,13 +33,9 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 78:为所有外部调用(Coze / 品牌检查 / 紫鸟)统一增加耗时、重试、
|
||||
* Task 78:为所有外部调用(LLM / 品牌检查 / 紫鸟)统一增加耗时、重试、
|
||||
* 失败率和 payload 字节指标。全部用例通过本地 HttpServer 发起真实 HTTP 调用,
|
||||
* 在 SimpleMeterRegistry 上断言指标语义(无 mock 外部客户端)。
|
||||
*/
|
||||
@@ -52,26 +46,19 @@ class ExternalCallMetricsRecorderTest {
|
||||
private HttpServer server;
|
||||
private int port;
|
||||
private final ExecutorService serverExecutor = Executors.newCachedThreadPool();
|
||||
private final AtomicInteger cozeSubmitCount = new AtomicInteger();
|
||||
private final AtomicInteger llmSubmitCount = new AtomicInteger();
|
||||
private final AtomicInteger ziniaoCount = new AtomicInteger();
|
||||
private final AtomicInteger brandCount = new AtomicInteger();
|
||||
private final AtomicBoolean cozeFailNext = new AtomicBoolean();
|
||||
|
||||
/** 可复用 Coze 凭据池:返回一个固定凭据,避免真实 HTTP 调用被凭据检查拦截。 */
|
||||
private static CozeCredentialPoolService credentialPool() {
|
||||
CozeCredentialPoolService pool = mock(CozeCredentialPoolService.class);
|
||||
when(pool.listEnabled(anyString())).thenReturn(List.of(
|
||||
new CozeCredentialPoolService.CozeCredential("test", "wf-1", "token", Integer.MAX_VALUE)));
|
||||
return pool;
|
||||
}
|
||||
private final AtomicBoolean llmFailNext = new AtomicBoolean();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress(0), 0);
|
||||
// backlog 128:并发 20 请求时 accept 线程偶发停顿会被 OS 队列吸收,避免连接拒绝
|
||||
server = HttpServer.create(new InetSocketAddress(0), 128);
|
||||
server.setExecutor(serverExecutor);
|
||||
server.createContext("/brand_check", this::handleBrandCheck);
|
||||
server.createContext("/app/builtin/company", this::handleZiniaoCompany);
|
||||
server.createContext("/v1/workflow/run", this::handleCozeSubmit);
|
||||
server.createContext("/v1/chat/completions", this::handleLlmSubmit);
|
||||
server.start();
|
||||
port = server.getAddress().getPort();
|
||||
}
|
||||
@@ -94,17 +81,14 @@ class ExternalCallMetricsRecorderTest {
|
||||
"{\"code\":\"0\",\"data\":{\"companyId\":1001}}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private void handleCozeSubmit(HttpExchange exchange) throws IOException {
|
||||
int count = cozeSubmitCount.incrementAndGet();
|
||||
if (cozeFailNext.getAndSet(false)) {
|
||||
private void handleLlmSubmit(HttpExchange exchange) throws IOException {
|
||||
int count = llmSubmitCount.incrementAndGet();
|
||||
if (llmFailNext.getAndSet(false)) {
|
||||
exchange.sendResponseHeaders(500, 0);
|
||||
exchange.close();
|
||||
return;
|
||||
}
|
||||
String executeId = "exec-" + count;
|
||||
String payload = "{\"data\":[{\"asin\":\"B0TEST78\",\"country\":\"US\",\"result\":\"ok\",\"conclusion\":\"ok\"}]}";
|
||||
String response = "{\"code\":0,\"data\":{\"execute_id\":\"" + executeId
|
||||
+ "\",\"status\":\"Success\",\"data\":" + payload + "}}";
|
||||
String response = "{\"choices\":[{\"message\":{\"content\":\"{\\\"result\\\":\\\"ok\\\"}\"}}]}";
|
||||
sendJson(exchange, 200, response.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@@ -168,26 +152,21 @@ class ExternalCallMetricsRecorderTest {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- 1. 正常默认路径:Coze 批量检查走本地服务,耗时/payload 字节全部记录 ----
|
||||
// ---- 1. 正常默认路径:LLM 对话走本地服务,耗时/payload 字节全部记录 ----
|
||||
|
||||
@Test
|
||||
void test_task_078_payload_metrics_normal_default_path() throws Exception {
|
||||
SimilarAsinProperties props = cozeProps();
|
||||
SimilarAsinCozeClient client =
|
||||
new SimilarAsinCozeClient(props, objectMapper, credentialPool(), new ExternalCallMetricsRecorder(registry));
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0TEST78");
|
||||
row.setTitle("Test");
|
||||
row.setSku("SKU-1");
|
||||
row.setCountry("US");
|
||||
SimilarAsinProperties props = llmProps();
|
||||
SimilarAsinLlmClient client =
|
||||
new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry));
|
||||
|
||||
List<SimilarAsinResultRowDto> result = client.inspect(List.of(row), "", "test-key");
|
||||
String content = client.invokeChat("test-model", "system", "hello", "test-key");
|
||||
|
||||
assertEquals(1, result.size(), "默认成功路径必须返回完整结果");
|
||||
assertEquals("ok", result.getFirst().getConclusion(), "主输出必须解析到 Coze 结果");
|
||||
assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"),
|
||||
"必须记录 Coze 调用耗时");
|
||||
assertTrue(findSummary("aiimage.external-call.payload.bytes", "client", "coze").totalAmount() > 0,
|
||||
assertNotNull(content, "默认成功路径必须返回结果");
|
||||
assertTrue(content.contains("ok"), "主输出必须解析到 LLM 结果");
|
||||
assertNotNull(findTimer("aiimage.external-call.duration", "client", "llm"),
|
||||
"必须记录 LLM 调用耗时");
|
||||
assertTrue(findSummary("aiimage.external-call.payload.bytes", "client", "llm").totalAmount() > 0,
|
||||
"必须记录 payload 字节指标");
|
||||
}
|
||||
|
||||
@@ -195,27 +174,19 @@ class ExternalCallMetricsRecorderTest {
|
||||
|
||||
@Test
|
||||
void test_task_078_payload_metrics_normal_multiple_items() throws Exception {
|
||||
SimilarAsinProperties props = cozeProps();
|
||||
SimilarAsinCozeClient client =
|
||||
new SimilarAsinCozeClient(props, objectMapper, credentialPool(), new ExternalCallMetricsRecorder(registry));
|
||||
List<SimilarAsinResultRowDto> rows = new ArrayList<>();
|
||||
SimilarAsinProperties props = llmProps();
|
||||
SimilarAsinLlmClient client =
|
||||
new SimilarAsinLlmClient(props, objectMapper, new ExternalCallMetricsRecorder(registry));
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0BATCH" + i);
|
||||
row.setCountry("US");
|
||||
row.setTitle("Batch " + i);
|
||||
row.setSku("SKU-" + i);
|
||||
rows.add(row);
|
||||
client.invokeChat("test-model", "system", "prompt-" + i, "test-key");
|
||||
}
|
||||
awaitMetric("aiimage.external-call.duration", "client", "llm");
|
||||
assertEquals(3, llmSubmitCount.get(), "三次请求全部真实发出");
|
||||
assertEquals(3.0, counterCount("aiimage.external-call.total",
|
||||
"client", "llm", "result", "success"), "三次成功全部记录");
|
||||
|
||||
List<SimilarAsinResultRowDto> result = client.inspect(rows, "", "test-key");
|
||||
|
||||
assertEquals(3, result.size(), "批量结果不丢失");
|
||||
assertEquals("B0BATCH0", result.get(0).getAsin(), "顺序稳定");
|
||||
assertEquals("B0BATCH2", result.get(2).getAsin(), "顺序稳定");
|
||||
awaitMetric("aiimage.external-call.duration", "client", "coze");
|
||||
|
||||
// 全部外部客户端(Coze / 品牌检查 / 紫鸟)在同一次批量中各自记录指标
|
||||
// 全部外部客户端(LLM / 品牌检查 / 紫鸟)在同一次批量中各自记录指标
|
||||
BrandCheckClient brand = new BrandCheckClient(brandProps(), new ExternalCallMetricsRecorder(registry));
|
||||
brand.checkAll(List.of("Nintendo", "LEGO", "Sony"), "Terms");
|
||||
ZiniaoClientImpl ziniao = new ZiniaoClientImpl(ziniaoProps(), objectMapper, new ExternalCallMetricsRecorder(registry));
|
||||
@@ -280,22 +251,16 @@ class ExternalCallMetricsRecorderTest {
|
||||
|
||||
@Test
|
||||
void test_task_078_payload_metrics_boundary_limit_and_overflow() throws Exception {
|
||||
SimilarAsinProperties props = cozeProps();
|
||||
props.setCozeReadTimeoutMillis(5000);
|
||||
SimilarAsinProperties props = llmProps();
|
||||
ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry);
|
||||
ExecutorService pool = Executors.newFixedThreadPool(4);
|
||||
try {
|
||||
for (int i = 0; i < 20; i++) {
|
||||
int index = i;
|
||||
pool.submit(() -> {
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(props, objectMapper, credentialPool(), recorder);
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0LIMIT" + index);
|
||||
row.setCountry("US");
|
||||
row.setTitle("Limit " + index);
|
||||
row.setSku("SKU-" + index);
|
||||
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, recorder);
|
||||
try {
|
||||
client.inspect(List.of(row), "", "test-key");
|
||||
client.invokeChat("test-model", "system", "prompt-" + index, "test-key");
|
||||
} catch (Exception ignored) {
|
||||
// 并发下结果失败也视为已处理
|
||||
}
|
||||
@@ -305,13 +270,13 @@ class ExternalCallMetricsRecorderTest {
|
||||
pool.shutdown();
|
||||
}
|
||||
|
||||
awaitMetric("aiimage.external-call.total", "client", "coze", "result", "success");
|
||||
for (int i = 0; i < 2000 && cozeSubmitCount.get() < 20; i++) {
|
||||
awaitMetric("aiimage.external-call.total", "client", "llm", "result", "success");
|
||||
for (int i = 0; i < 2000 && llmSubmitCount.get() < 20; i++) {
|
||||
Thread.sleep(10);
|
||||
}
|
||||
assertEquals(20, cozeSubmitCount.get(), "并发 20 请求全部真实发出");
|
||||
assertEquals(20, llmSubmitCount.get(), "并发 20 请求全部真实发出");
|
||||
assertEquals(20.0, counterCount("aiimage.external-call.total",
|
||||
"client", "coze", "result", "success"), "20 次成功全部记录,无重复");
|
||||
"client", "llm", "result", "success"), "20 次成功全部记录,无重复");
|
||||
}
|
||||
|
||||
// ---- 7. 非法参数:空列表拒绝,不发起请求,无指标 ----
|
||||
@@ -334,40 +299,29 @@ class ExternalCallMetricsRecorderTest {
|
||||
@Test
|
||||
void test_task_078_payload_metrics_dependency_failure_releases_resources() throws Exception {
|
||||
ExternalCallMetricsRecorder recorder = new ExternalCallMetricsRecorder(registry);
|
||||
SimilarAsinProperties props = cozeProps();
|
||||
props.setCozeReadTimeoutMillis(5000);
|
||||
SimilarAsinCozeClient client =
|
||||
new SimilarAsinCozeClient(props, objectMapper, credentialPool(), recorder);
|
||||
List<SimilarAsinResultRowDto> rows = new ArrayList<>();
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0FAIL78");
|
||||
row.setCountry("US");
|
||||
row.setTitle("Fail");
|
||||
row.setSku("SKU-FAIL");
|
||||
rows.add(row);
|
||||
SimilarAsinProperties props = llmProps();
|
||||
SimilarAsinLlmClient client =
|
||||
new SimilarAsinLlmClient(props, objectMapper, recorder);
|
||||
|
||||
// 第一次调用走 500 失败路径,第二次调用恢复成功:错误可恢复
|
||||
cozeFailNext.set(true);
|
||||
client.inspect(rows, "", "test-key");
|
||||
client.inspect(rows, "", "test-key");
|
||||
llmFailNext.set(true);
|
||||
client.invokeChat("test-model", "system", "hello", "test-key");
|
||||
client.invokeChat("test-model", "system", "hello", "test-key");
|
||||
|
||||
awaitMetric("aiimage.external-call.total", "client", "coze", "result", "failure");
|
||||
assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"),
|
||||
awaitMetric("aiimage.external-call.total", "client", "llm", "result", "failure");
|
||||
assertNotNull(findTimer("aiimage.external-call.duration", "client", "llm"),
|
||||
"失败调用同样记录耗时");
|
||||
awaitMetric("aiimage.external-call.total", "client", "coze", "result", "success");
|
||||
assertNotNull(findTimer("aiimage.external-call.duration", "client", "coze"),
|
||||
awaitMetric("aiimage.external-call.total", "client", "llm", "result", "success");
|
||||
assertNotNull(findTimer("aiimage.external-call.duration", "client", "llm"),
|
||||
"恢复后的成功调用也记录耗时");
|
||||
assertEquals(1.0, counterCount("aiimage.external-call.total",
|
||||
"client", "coze", "result", "failure"), "失败率指标精确记录一次失败");
|
||||
assertTrue(counterCount("aiimage.external-call.retry.total", "client", "coze") >= 1.0,
|
||||
"客户端重试循环记录重试次数指标");
|
||||
"client", "llm", "result", "failure"), "失败率指标精确记录一次失败");
|
||||
}
|
||||
|
||||
private SimilarAsinProperties cozeProps() {
|
||||
private SimilarAsinProperties llmProps() {
|
||||
SimilarAsinProperties props = new SimilarAsinProperties();
|
||||
props.setCozeBaseUrl("http://127.0.0.1:" + port);
|
||||
props.setCozeWorkflowPath("/v1/workflow/run");
|
||||
props.setCozeWorkflowHistoryPath("/v1/workflows/{workflow_id}/run_histories/{execute_id}");
|
||||
props.setLlmHost("http://127.0.0.1:" + port);
|
||||
props.setLlmRetryTimes(2);
|
||||
return props;
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -30,7 +30,7 @@ class AppearancePatentLlmClientHttpTest {
|
||||
private final Map<String, AtomicInteger> callCounts = new ConcurrentHashMap<>();
|
||||
private final List<String> capturedBodies = new ArrayList<>();
|
||||
|
||||
private AppearancePatentCozeClient client;
|
||||
private AppearancePatentLlmClient client;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
@@ -41,7 +41,7 @@ class AppearancePatentLlmClientHttpTest {
|
||||
AppearancePatentProperties properties = new AppearancePatentProperties();
|
||||
properties.setLlmHost("http://127.0.0.1:" + server.getAddress().getPort());
|
||||
properties.setLlmRetryTimes(3);
|
||||
client = new AppearancePatentCozeClient(
|
||||
client = new AppearancePatentLlmClient(
|
||||
properties,
|
||||
objectMapper,
|
||||
null,
|
||||
|
||||
+12
-12
@@ -16,9 +16,9 @@ import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class AppearancePatentCozeClientTest {
|
||||
class AppearancePatentLlmClientTest {
|
||||
|
||||
private final AppearancePatentCozeClient client = new AppearancePatentCozeClient(
|
||||
private final AppearancePatentLlmClient client = new AppearancePatentLlmClient(
|
||||
new AppearancePatentProperties(),
|
||||
new ObjectMapper(),
|
||||
null,
|
||||
@@ -59,9 +59,9 @@ class AppearancePatentCozeClientTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipBrandCheckWhenCozeTitleIsNone() throws Exception {
|
||||
void skipBrandCheckWhenLlmTitleIsNone() throws Exception {
|
||||
BrandCheckClient brandCheckClient = mock(BrandCheckClient.class);
|
||||
AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient(
|
||||
AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient(
|
||||
new AppearancePatentProperties(),
|
||||
new ObjectMapper(),
|
||||
null,
|
||||
@@ -73,7 +73,7 @@ class AppearancePatentCozeClientTest {
|
||||
{"data":[{"row_id":"1","title":"无","title_reason":"无","appearance":"无侵权","result":"无侵权"}]}
|
||||
""";
|
||||
|
||||
List<AppearancePatentResultRowDto> merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText);
|
||||
List<AppearancePatentResultRowDto> merged = llmClient.mergeRowsFromDataText(List.of(row), dataText);
|
||||
|
||||
assertThat(merged).hasSize(1);
|
||||
assertThat(merged.get(0).getTitleRisk()).isEqualTo("无侵权");
|
||||
@@ -86,7 +86,7 @@ class AppearancePatentCozeClientTest {
|
||||
BrandCheckClient brandCheckClient = mock(BrandCheckClient.class);
|
||||
when(brandCheckClient.checkTitleText("阿凡达", "Terms"))
|
||||
.thenReturn(new BrandCheckClient.BrandCheckBatchResult(List.of("阿凡达"), List.of(), List.of()));
|
||||
AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient(
|
||||
AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient(
|
||||
new AppearancePatentProperties(),
|
||||
new ObjectMapper(),
|
||||
null,
|
||||
@@ -98,7 +98,7 @@ class AppearancePatentCozeClientTest {
|
||||
{"data":[{"row_id":"1","title":"阿凡达","appearance":"无侵权","result":"无侵权"}]}
|
||||
""";
|
||||
|
||||
List<AppearancePatentResultRowDto> merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText);
|
||||
List<AppearancePatentResultRowDto> merged = llmClient.mergeRowsFromDataText(List.of(row), dataText);
|
||||
|
||||
assertThat(merged).hasSize(1);
|
||||
assertThat(merged.get(0).getTitleRisk()).isEqualTo("无侵权");
|
||||
@@ -108,7 +108,7 @@ class AppearancePatentCozeClientTest {
|
||||
@Test
|
||||
void leaveTitleRiskBlankWhenAppearanceIsMissing() throws Exception {
|
||||
BrandCheckClient brandCheckClient = mock(BrandCheckClient.class);
|
||||
AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient(
|
||||
AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient(
|
||||
new AppearancePatentProperties(),
|
||||
new ObjectMapper(),
|
||||
null,
|
||||
@@ -120,7 +120,7 @@ class AppearancePatentCozeClientTest {
|
||||
{"data":[{"row_id":"1","title":"阿凡达","result":""}]}
|
||||
""";
|
||||
|
||||
List<AppearancePatentResultRowDto> merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText);
|
||||
List<AppearancePatentResultRowDto> merged = llmClient.mergeRowsFromDataText(List.of(row), dataText);
|
||||
|
||||
assertThat(merged).hasSize(1);
|
||||
assertThat(merged.get(0).getTitleRisk()).isEmpty();
|
||||
@@ -130,7 +130,7 @@ class AppearancePatentCozeClientTest {
|
||||
@Test
|
||||
void leaveTitleRiskBlankWhenTitleAndAppearanceAreMissing() throws Exception {
|
||||
BrandCheckClient brandCheckClient = mock(BrandCheckClient.class);
|
||||
AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient(
|
||||
AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient(
|
||||
new AppearancePatentProperties(),
|
||||
new ObjectMapper(),
|
||||
null,
|
||||
@@ -142,7 +142,7 @@ class AppearancePatentCozeClientTest {
|
||||
{"data":[{"row_id":"1","result":""}]}
|
||||
""";
|
||||
|
||||
List<AppearancePatentResultRowDto> merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText);
|
||||
List<AppearancePatentResultRowDto> merged = llmClient.mergeRowsFromDataText(List.of(row), dataText);
|
||||
|
||||
assertThat(merged).hasSize(1);
|
||||
assertThat(merged.get(0).getTitleRisk()).isEmpty();
|
||||
@@ -151,7 +151,7 @@ class AppearancePatentCozeClientTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void preserveSubmittedPriceWhenMergingCozeResult() throws Exception {
|
||||
void preserveSubmittedPriceWhenMergingLlmResult() throws Exception {
|
||||
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
|
||||
row.setId("1");
|
||||
row.setPrice("12.99");
|
||||
+3
-3
@@ -2,7 +2,7 @@ package com.nanri.aiimage.modules.file.service.object;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.nanri.aiimage.config.CozeTaskQueueGate;
|
||||
import com.nanri.aiimage.config.TaskQueueGate;
|
||||
import com.nanri.aiimage.config.TransientStorageProperties;
|
||||
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
|
||||
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
|
||||
@@ -195,7 +195,7 @@ class FaultInjectionTest {
|
||||
verify(mapper, never()).selectOne(any());
|
||||
verify(publisher, never()).publishEvent(any(TaskFileJobDispatchEvent.class));
|
||||
|
||||
CozeTaskQueueGate gate = new CozeTaskQueueGate(mock(TaskExecutor.class), 1, emptyProvider());
|
||||
TaskQueueGate gate = new TaskQueueGate(mock(TaskExecutor.class), 1, emptyProvider());
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> gate.execute(null));
|
||||
assertTrue(ex.getMessage().contains("不能为 null"), "null 任务必须抛出可识别错误消息");
|
||||
}
|
||||
@@ -212,7 +212,7 @@ class FaultInjectionTest {
|
||||
})
|
||||
.when(delegate).execute(any(Runnable.class));
|
||||
SimpleMeterRegistry registry = new SimpleMeterRegistry();
|
||||
CozeTaskQueueGate gate = new CozeTaskQueueGate(delegate, 2, objectProvider(registry));
|
||||
TaskQueueGate gate = new TaskQueueGate(delegate, 2, objectProvider(registry));
|
||||
Runnable task = () -> {
|
||||
};
|
||||
|
||||
|
||||
-156
@@ -1,156 +0,0 @@
|
||||
package com.nanri.aiimage.modules.similarasin.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Task 19:Coze 请求/响应及 Python 回传日志改为采样、截断和 DEBUG 级别。
|
||||
* 新工具 SimilarAsinLogSupport 提供两条纯函数:
|
||||
* - truncate:正文超限截断为 maxChars + 后缀,长文本不占满日志;
|
||||
* - shouldLog:每 everyN 次采样一次(counter % everyN == 0),控制轮询/逐行日志量。
|
||||
* Coze 客户端正文日志与 Python 回传逐行日志经该工具后输出有界、可识别。
|
||||
*/
|
||||
class SimilarAsinCozeClientLoggingTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_normal_default_path() {
|
||||
// 正常输入:短文本不截断;每次采样(everyN=1)恒记录。
|
||||
assertEquals("hello", SimilarAsinLogSupport.truncate("hello", 100));
|
||||
assertEquals("", SimilarAsinLogSupport.truncate(null, 100), "null 文本返回空串");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(0, 1), "everyN=1 恒采样");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(7, 1), "everyN=1 不抑制任何计数");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_normal_multiple_items() throws Exception {
|
||||
// 批量场景:多个长文本各自截断、结果互不影响;采样按每 everyN 次一次。
|
||||
String longA = "A".repeat(3000);
|
||||
String longB = "B".repeat(5000);
|
||||
String truncatedA = SimilarAsinLogSupport.truncate(longA, 100);
|
||||
String truncatedB = SimilarAsinLogSupport.truncate(longB, 100);
|
||||
assertTrue(truncatedA.startsWith("A".repeat(100)));
|
||||
assertTrue(truncatedB.startsWith("B".repeat(100)));
|
||||
assertTrue(truncatedA.length() < longA.length(), "截断后必须短于原文");
|
||||
|
||||
int sampled = 0;
|
||||
for (int i = 0; i < 30; i++) {
|
||||
if (SimilarAsinLogSupport.shouldLog(i, 10)) {
|
||||
sampled++;
|
||||
}
|
||||
}
|
||||
assertEquals(3, sampled, "everyN=10 在 0..29 内应采样 0/10/20 共 3 次");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_normal_repeated_operation_is_idempotent() throws Exception {
|
||||
// 重复执行:同一文本多次截断结果一致;同一计数采样判定一致。
|
||||
String text = "x".repeat(1234);
|
||||
String first = SimilarAsinLogSupport.truncate(text, 500);
|
||||
String second = SimilarAsinLogSupport.truncate(text, 500);
|
||||
assertEquals(first, second, "重复截断必须产生相同输出");
|
||||
assertEquals(first, SimilarAsinLogSupport.truncate(text, 500), "截断幂等");
|
||||
assertEquals(SimilarAsinLogSupport.shouldLog(20, 10), SimilarAsinLogSupport.shouldLog(20, 10));
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_boundary_empty_input() throws Exception {
|
||||
// 空输入:null/空串安全返回空串;空白串按长度截断语义原样保留。
|
||||
assertEquals("", SimilarAsinLogSupport.truncate(null, 100));
|
||||
assertEquals("", SimilarAsinLogSupport.truncate("", 100));
|
||||
assertEquals(" ", SimilarAsinLogSupport.truncate(" ", 100), "空白串不做 trim,按原样返回");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(0, 10), "计数 0 必须采样(首条不丢)");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_boundary_single_item() throws Exception {
|
||||
// 单元素边界:恰好等于上限不截断;超 1 字符截断并带长度后缀。
|
||||
String exact = "y".repeat(100);
|
||||
assertEquals(exact, SimilarAsinLogSupport.truncate(exact, 100), "恰好等于上限不截断");
|
||||
String over = "y".repeat(101);
|
||||
String truncated = SimilarAsinLogSupport.truncate(over, 100);
|
||||
assertEquals(over.substring(0, 100), truncated.substring(0, 100), "截断保留前缀");
|
||||
assertTrue(truncated.contains("101"), "截断输出应携带原文长度");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_boundary_limit_and_overflow() throws Exception {
|
||||
// 上限/超限:10 万字符文本截断后有界、不再无界增长;采样 everyN 超限不抑制。
|
||||
String huge = "z".repeat(100_000);
|
||||
String truncated = SimilarAsinLogSupport.truncate(huge, 2000);
|
||||
assertTrue(truncated.length() < 2200, "截断输出必须有界,实际=" + truncated.length());
|
||||
assertTrue(truncated.length() > 2000, "应保留 2000 前缀 + 后缀");
|
||||
assertTrue(truncated.endsWith("]"), "截断输出带可识别后缀");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(0, Integer.MAX_VALUE), "计数 0 在超大 everyN 下仍采样");
|
||||
assertFalse(SimilarAsinLogSupport.shouldLog(1, Integer.MAX_VALUE), "非零计数在超大 everyN 下抑制");
|
||||
assertFalse(SimilarAsinLogSupport.shouldLog(31, 10), "非采样点必须被抑制");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_invalid_input_rejected() throws Exception {
|
||||
// 非法参数:maxChars ≤ 0 时原样返回(不截断);null 文本始终空串。
|
||||
String text = "invalid-max";
|
||||
assertEquals(text, SimilarAsinLogSupport.truncate(text, 0), "maxChars=0 不截断");
|
||||
assertEquals(text, SimilarAsinLogSupport.truncate(text, -1), "负上限不截断");
|
||||
assertEquals("", SimilarAsinLogSupport.truncate(null, -5));
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(5, 0), "everyN=0 视为恒采样");
|
||||
assertTrue(SimilarAsinLogSupport.shouldLog(5, -3), "负 everyN 视为恒采样");
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_019_logging_dependency_failure_releases_resources() throws Exception {
|
||||
// 依赖失败:含代理对(emoji)的长文本截断不抛异常、不产生孤立代理项;
|
||||
// 计数接近 Long.MAX_VALUE 不溢出;掩码后的请求体经截断管线输出有界且不泄漏密钥。
|
||||
String emoji = "🚀".repeat(3000);
|
||||
String truncatedEmoji = SimilarAsinLogSupport.truncate(emoji, 100);
|
||||
assertNotNull(truncatedEmoji);
|
||||
assertTrue(truncatedEmoji.length() < emoji.length(), "代理对文本必须被截断");
|
||||
assertFalse(SimilarAsinLogSupport.shouldLog(Long.MAX_VALUE, 10), "极大计数采样判定不抛异常");
|
||||
|
||||
List<SimilarAsinResultRowDto> rows = new java.util.ArrayList<>();
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0SECRET1");
|
||||
row.setUrl("https://m.media-amazon.com/images/I/" + "U".repeat(500) + ".jpg");
|
||||
row.setTitle("T".repeat(5000));
|
||||
row.setSku("SKU-SECRET");
|
||||
rows.add(row);
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
|
||||
Method maskMethod = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||
"maskCozeRequestBody", Map.class);
|
||||
maskMethod.setAccessible(true);
|
||||
Method buildMethod = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||
"buildParameters", List.class, String.class, String.class, boolean.class);
|
||||
buildMethod.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> parameters = (Map<String, Object>) buildMethod.invoke(client, rows, "", "supersecretkey", true);
|
||||
Map<String, Object> body = new java.util.LinkedHashMap<>();
|
||||
body.put("workflow_id", "wf-1");
|
||||
body.put("parameters", parameters);
|
||||
body.put("api_key", "supersecretkey");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> masked = (Map<String, Object>) maskMethod.invoke(client, body);
|
||||
Method writeMethod = SimilarAsinCozeClient.class.getDeclaredMethod("writeJson", Object.class);
|
||||
writeMethod.setAccessible(true);
|
||||
String maskedJson = (String) writeMethod.invoke(client, masked);
|
||||
String logged = SimilarAsinLogSupport.truncate(maskedJson, 2000);
|
||||
|
||||
assertTrue(logged.length() < maskedJson.length(), "超长掩码 body 必须截断");
|
||||
assertFalse(logged.contains("supersecretkey"), "日志不得泄漏完整 api_key");
|
||||
assertTrue(logged.contains("B0SECRET1"), "截断保留正文关键字段");
|
||||
assertTrue(logged.length() < 2500, "截断输出必须有界");
|
||||
}
|
||||
}
|
||||
-240
@@ -1,240 +0,0 @@
|
||||
package com.nanri.aiimage.modules.similarasin.client;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SimilarAsinCozeClientTest {
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void buildParametersIncludesAlibabaPriceFromPythonPayload() throws Exception {
|
||||
List<SimilarAsinResultRowDto> rows = objectMapper.readValue("""
|
||||
[
|
||||
{
|
||||
"asin": "B0TEST123",
|
||||
"url": "https://m.media-amazon.com/images/I/main.jpg",
|
||||
"alibaba": [
|
||||
{"url": "https://cbu01.alicdn.com/img/ibank/a.jpg", "price": 12.80},
|
||||
{"url": "https://cbu01.alicdn.com/img/ibank/b.jpg", "price": 19.99}
|
||||
],
|
||||
"title": "Test title",
|
||||
"sku": "SKU-1"
|
||||
}
|
||||
]
|
||||
""", new TypeReference<>() {
|
||||
});
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
|
||||
|
||||
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||
"buildParameters", List.class, String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, rows, "", "", true);
|
||||
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
|
||||
Map<String, Object> item = items.getFirst();
|
||||
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) item.get("alibaba");
|
||||
|
||||
assertEquals("B0TEST123", item.get("asin"));
|
||||
assertEquals("https://m.media-amazon.com/images/I/main.jpg", item.get("url"));
|
||||
assertEquals("Test title", item.get("title"));
|
||||
assertEquals("SKU-1", item.get("sku"));
|
||||
assertEquals(2, alibaba.size());
|
||||
assertEquals("https://cbu01.alicdn.com/img/ibank/a.jpg", alibaba.get(0).get("url"));
|
||||
assertEquals(0, new BigDecimal("12.8").compareTo((BigDecimal) alibaba.get(0).get("price")));
|
||||
assertEquals("https://cbu01.alicdn.com/img/ibank/b.jpg", alibaba.get(1).get("url"));
|
||||
assertEquals(0, new BigDecimal("19.99").compareTo((BigDecimal) alibaba.get(1).get("price")));
|
||||
assertTrue(rows.getFirst().hasImageUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void buildParametersFallsBackAlibabaFromLegacyUrlsAndTopLevelPrice() throws Exception {
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0LEGACY1");
|
||||
row.setUrl("https://m.media-amazon.com/images/I/main.jpg");
|
||||
row.setUrls(List.of("https://cbu01.alicdn.com/img/ibank/legacy-a.jpg"));
|
||||
row.setPrice("8.50");
|
||||
row.setTitle("Legacy title");
|
||||
row.setSku("SKU-LEGACY");
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
|
||||
|
||||
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||
"buildParameters", List.class, String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, List.of(row), "", "", false);
|
||||
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
|
||||
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) items.getFirst().get("alibaba");
|
||||
|
||||
assertEquals(1, alibaba.size());
|
||||
assertEquals("https://cbu01.alicdn.com/img/ibank/legacy-a.jpg", alibaba.getFirst().get("url"));
|
||||
assertEquals(0, new BigDecimal("8.5").compareTo((BigDecimal) alibaba.getFirst().get("price")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void buildParametersConvertsBlankAlibabaPricesToZero() throws Exception {
|
||||
List<SimilarAsinResultRowDto> rows = objectMapper.readValue("""
|
||||
[
|
||||
{
|
||||
"asin": "B0BLANK123",
|
||||
"url": "https://m.media-amazon.com/images/I/main.jpg",
|
||||
"alibaba": [
|
||||
{"url": "https://cbu01.alicdn.com/img/ibank/blank.jpg", "price": ""},
|
||||
{"url": "https://cbu01.alicdn.com/img/ibank/null.jpg", "price": null}
|
||||
]
|
||||
}
|
||||
]
|
||||
""", new TypeReference<>() {
|
||||
});
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
|
||||
|
||||
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||
"buildParameters", List.class, String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, rows, "", "", true);
|
||||
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
|
||||
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) items.getFirst().get("alibaba");
|
||||
String json = objectMapper.writeValueAsString(parameters);
|
||||
|
||||
assertEquals(2, alibaba.size());
|
||||
assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.get(0).get("price")));
|
||||
assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.get(1).get("price")));
|
||||
assertTrue(json.contains("\"price\":0"));
|
||||
assertFalse(json.contains("\"price\":\"\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void buildParametersConvertsBlankFallbackRowPriceToZero() throws Exception {
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0BLANKROW");
|
||||
row.setUrl("https://m.media-amazon.com/images/I/main.jpg");
|
||||
row.setUrls(List.of("https://cbu01.alicdn.com/img/ibank/fallback.jpg"));
|
||||
row.setPrice("");
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
|
||||
|
||||
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||
"buildParameters", List.class, String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, List.of(row), "", "", true);
|
||||
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
|
||||
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) items.getFirst().get("alibaba");
|
||||
String json = objectMapper.writeValueAsString(parameters);
|
||||
|
||||
assertEquals(1, alibaba.size());
|
||||
assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.getFirst().get("price")));
|
||||
assertTrue(json.contains("\"price\":0"));
|
||||
assertFalse(json.contains("\"price\":\"\""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void buildParametersSerializesWholeNumberPricesWithoutScientificNotation() throws Exception {
|
||||
List<SimilarAsinResultRowDto> rows = objectMapper.readValue("""
|
||||
[
|
||||
{
|
||||
"asin": "B0SCI123",
|
||||
"url": "https://m.media-amazon.com/images/I/main.jpg",
|
||||
"alibaba": [
|
||||
{"url": "https://cbu01.alicdn.com/img/ibank/ten.jpg", "price": 10.0},
|
||||
{"url": "https://cbu01.alicdn.com/img/ibank/eighty.jpg", "price": 80.0},
|
||||
{"url": "https://cbu01.alicdn.com/img/ibank/fraction.jpg", "price": 7.5800}
|
||||
]
|
||||
}
|
||||
]
|
||||
""", new TypeReference<>() {
|
||||
});
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
|
||||
|
||||
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||
"buildParameters", List.class, String.class, String.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, rows, "", "", true);
|
||||
|
||||
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
|
||||
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) items.getFirst().get("alibaba");
|
||||
String json = objectMapper.writeValueAsString(parameters);
|
||||
|
||||
assertEquals("10", ((BigDecimal) alibaba.get(0).get("price")).toPlainString());
|
||||
assertEquals("80", ((BigDecimal) alibaba.get(1).get("price")).toPlainString());
|
||||
assertEquals("7.58", ((BigDecimal) alibaba.get(2).get("price")).toPlainString());
|
||||
assertFalse(json.contains("1E+1"));
|
||||
assertFalse(json.contains("8E+1"));
|
||||
assertTrue(json.contains("\"price\":10"));
|
||||
assertTrue(json.contains("\"price\":80"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void buildParametersIncludesCategorySwitch() throws Exception {
|
||||
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
|
||||
row.setAsin("B0CATEGORY1");
|
||||
row.setTitle("Category test");
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
|
||||
|
||||
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
|
||||
"buildParameters", List.class, String.class, String.class, boolean.class, boolean.class);
|
||||
method.setAccessible(true);
|
||||
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, List.of(row), "", "", false, true);
|
||||
|
||||
assertEquals(Boolean.TRUE, parameters.get("category_switch"));
|
||||
assertEquals(Boolean.FALSE, parameters.get("img_switch"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void imageOnlyWorkflowOutputIsExtractedAndMergedByAsin() throws Exception {
|
||||
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
|
||||
String imageData = """
|
||||
{"data":[{
|
||||
"asin":"B0BQNHDP2F",
|
||||
"main_url":"https://example.com/main.jpg",
|
||||
"puzzle_img1":"https://example.com/puzzle-1.jpg",
|
||||
"puzzle_img2":"https://example.com/puzzle-2.jpg"
|
||||
}]}
|
||||
""";
|
||||
String workflowOutput = objectMapper.writeValueAsString(Map.of(
|
||||
"node_status", "{}",
|
||||
"Output", imageData));
|
||||
var historyResponse = objectMapper.createObjectNode();
|
||||
historyResponse.put("code", 0);
|
||||
historyResponse.putArray("data")
|
||||
.addObject()
|
||||
.put("execute_status", "Success")
|
||||
.put("output", workflowOutput);
|
||||
Method extract = SimilarAsinCozeClient.class.getDeclaredMethod("extractResultDataText", JsonNode.class);
|
||||
extract.setAccessible(true);
|
||||
String dataText = (String) extract.invoke(client, historyResponse);
|
||||
|
||||
SimilarAsinResultRowDto source = new SimilarAsinResultRowDto();
|
||||
source.setAsin("B0BQNHDP2F");
|
||||
List<SimilarAsinResultRowDto> merged = client.mergeRowsFromDataText(List.of(source), dataText);
|
||||
|
||||
assertFalse(dataText.isBlank());
|
||||
assertEquals(1, merged.size());
|
||||
assertEquals("https://example.com/main.jpg", merged.getFirst().getMainUrl());
|
||||
assertEquals("https://example.com/puzzle-1.jpg", merged.getFirst().getPuzzleImg1());
|
||||
assertEquals("https://example.com/puzzle-2.jpg", merged.getFirst().getPuzzleImg2());
|
||||
|
||||
Method resolvedCount = SimilarAsinCozeClient.class.getDeclaredMethod("resolvedCount", List.class);
|
||||
resolvedCount.setAccessible(true);
|
||||
assertEquals(1, resolvedCount.invoke(client, merged));
|
||||
}
|
||||
}
|
||||
+13
-16
@@ -2,7 +2,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
@@ -71,7 +70,6 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
|
||||
@@ -81,7 +79,6 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
|
||||
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
|
||||
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
|
||||
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
|
||||
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||
|
||||
@@ -168,11 +165,11 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
|
||||
|
||||
private static void invokeMerge(SimilarAsinTaskService service, FileTaskEntity task,
|
||||
String scopeHash, Integer chunkIndex,
|
||||
List<SimilarAsinResultRowDto> cozeRows) throws Exception {
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||
List<SimilarAsinResultRowDto> llmRows) throws Exception {
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
|
||||
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||
merge.setAccessible(true);
|
||||
merge.invoke(service, task, scopeHash, chunkIndex, cozeRows, Map.of());
|
||||
merge.invoke(service, task, scopeHash, chunkIndex, llmRows, Map.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -184,12 +181,12 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(9004L);
|
||||
List<SimilarAsinResultRowDto> cozeRows = List.of(
|
||||
List<SimilarAsinResultRowDto> llmRows = List.of(
|
||||
row("r1", "B0A0000001", "标题1"),
|
||||
row("r2", "B0A0000002", "标题2"),
|
||||
row("r3", "B0A0000003", "标题3"));
|
||||
|
||||
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||
invokeMerge(service, task, "hashA", 1, llmRows);
|
||||
assertNotNull(storedPayload.get());
|
||||
assertTrue(storedPayload.get().contains("\"r1\"") && storedPayload.get().contains("\"r3\""),
|
||||
"上限内合并必须完整保留存量行与新增行,实际: " + storedPayload.get());
|
||||
@@ -243,9 +240,9 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(9004L);
|
||||
List<SimilarAsinResultRowDto> cozeRows = List.of(row("r1", "B0A0000001", "标题1"));
|
||||
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||
List<SimilarAsinResultRowDto> llmRows = List.of(row("r1", "B0A0000001", "标题1"));
|
||||
invokeMerge(service, task, "hashA", 1, llmRows);
|
||||
invokeMerge(service, task, "hashA", 1, llmRows);
|
||||
assertEquals(2, storeCalls.get(), "重复执行同一输入:每次 merge 恰好写回一次,无多余请求");
|
||||
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
|
||||
}
|
||||
@@ -298,13 +295,13 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(9004L);
|
||||
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
|
||||
for (int i = 0; i < 5; i++) {
|
||||
cozeRows.add(row("r" + (i + 1), "B0A00000" + (i + 1), "新行" + i));
|
||||
llmRows.add(row("r" + (i + 1), "B0A00000" + (i + 1), "新行" + i));
|
||||
}
|
||||
|
||||
// Phase A:行数超限(上限 2,存量 1 + 新增 5)→ 只保留上限内最新行,超限部分转 orphan。
|
||||
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||
invokeMerge(service, task, "hashA", 1, llmRows);
|
||||
assertTrue(lastStoredPayload.get().contains("\"r4\"") && lastStoredPayload.get().contains("\"r5\""),
|
||||
"行数超限时保留上限内的最新行,实际: " + lastStoredPayload.get());
|
||||
assertFalse(lastStoredPayload.get().contains("\"r0\""), "行数超限时最旧行被降级,实际: " + lastStoredPayload.get());
|
||||
@@ -317,7 +314,7 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
|
||||
lenient().when(properties.getChunkMergeMaxRows()).thenReturn(50000);
|
||||
long oneRowBytes = rowsJson(List.of(row("r9", "B0A0000099", "样本行"))).getBytes(StandardCharsets.UTF_8).length;
|
||||
lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(oneRowBytes + 5L);
|
||||
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||
invokeMerge(service, task, "hashA", 1, llmRows);
|
||||
assertTrue(lastStoredPayload.get().contains("\"r5\""), "字节超限时保留最新行,实际: " + lastStoredPayload.get());
|
||||
assertFalse(lastStoredPayload.get().contains("\"r0\""), "字节超限时最旧行被降级,实际: " + lastStoredPayload.get());
|
||||
assertEquals(2, storeCalls.get());
|
||||
@@ -327,7 +324,7 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
|
||||
lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(5L);
|
||||
Exception ex = assertThrows(Exception.class, () -> {
|
||||
try {
|
||||
invokeMerge(service, task, "hashA", 1, cozeRows);
|
||||
invokeMerge(service, task, "hashA", 1, llmRows);
|
||||
} catch (java.lang.reflect.InvocationTargetException e) {
|
||||
throw e.getCause();
|
||||
}
|
||||
|
||||
+26
-41
@@ -2,8 +2,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
@@ -59,8 +57,8 @@ import static org.mockito.Mockito.when;
|
||||
* P0-3 缓冲只覆盖"poll DONE 且 batchTotal>1";Task 12 扩展为:
|
||||
* 1) poll DONE 结果去掉 batchTotal 限制,单 batch 也走缓冲;
|
||||
* 2) retry 提交同步 immediate DONE 结果也走缓冲(原立即 merge);
|
||||
* 3) 统一走 bufferCozeRowsOrMerge:缓冲失败回退立即 merge,结果不丢失。
|
||||
* flushBufferedCozeResults 在 finalize 前一次性合并,全任务收敛为一次 chunk 读写。
|
||||
* 3) 统一走 bufferLlmRowsOrMerge:缓冲失败回退立即 merge,结果不丢失。
|
||||
* flushLlmBufferedResults 在 finalize 前一次性合并,全任务收敛为一次 chunk 读写。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
@@ -78,7 +76,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinLlmService similarAsinLlmService;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private TaskFileJobService taskFileJobService;
|
||||
@@ -88,7 +86,6 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
|
||||
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
|
||||
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
|
||||
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||
|
||||
@@ -113,24 +110,12 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
|
||||
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
|
||||
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
|
||||
lenient().when(properties.getCozeBatchSize()).thenReturn(5);
|
||||
lenient().when(properties.getCozeTextOnlyBatchSize()).thenReturn(10);
|
||||
lenient().when(properties.isCozeResultBufferEnabled()).thenReturn(true);
|
||||
lenient().when(properties.getCozePollTimeoutMillis()).thenReturn(30_000);
|
||||
lenient().when(properties.getLlmBatchSize()).thenReturn(5);
|
||||
lenient().when(properties.getLlmTextOnlyBatchSize()).thenReturn(10);
|
||||
lenient().when(properties.isLlmResultBufferEnabled()).thenReturn(true);
|
||||
lenient().when(properties.getDbJobTouchIntervalMillis()).thenReturn(2_000L);
|
||||
lenient().when(properties.getDbTaskTouchIntervalMillis()).thenReturn(2_000L);
|
||||
lenient().when(properties.getCozeSubmitLockWaitMillis()).thenReturn(1_000L);
|
||||
lenient().when(properties.getCozeSubmitLockRetryDelayMillis()).thenReturn(100L);
|
||||
lenient().when(properties.getCozeSubmitMinIntervalMillis()).thenReturn(0L);
|
||||
lenient().when(properties.getCozeSubmitMaxRetryCount()).thenReturn(3);
|
||||
lenient().when(properties.getCozeFlushPendingMinutes()).thenReturn(10);
|
||||
lenient().when(cozeClient.configuredCredentialCount()).thenReturn(1);
|
||||
lenient().when(cozeClient.nextCredential()).thenReturn(new SimilarAsinCozeClient.CozeCredentialRef(
|
||||
CREDENTIAL, "wf-1", "token-1", 4));
|
||||
lenient().when(cozeClient.credentialByName(CREDENTIAL)).thenReturn(new SimilarAsinCozeClient.CozeCredentialRef(
|
||||
CREDENTIAL, "wf-1", "token-1", 4));
|
||||
lenient().when(cozeCredentialPoolService.borrow(eq(MODULE), any())).thenReturn(
|
||||
mock(CozeCredentialPoolService.BorrowedCredential.class));
|
||||
lenient().when(properties.getLlmFlushPendingMinutes()).thenReturn(10);
|
||||
lenient().when(distributedJobLockService.tryLock(anyString(), any())).thenReturn(
|
||||
mock(com.nanri.aiimage.common.service.DistributedJobLockService.LockHandle.class));
|
||||
lenient().doAnswer(invocation -> {
|
||||
@@ -192,7 +177,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
state.setTaskId(task.getId());
|
||||
state.setModuleType(MODULE);
|
||||
state.setScopeHash("scope-" + id);
|
||||
state.setCozeStatus(status);
|
||||
state.setLlmStatus(status);
|
||||
state.setParsedPayloadJson("ptr:batch-" + id);
|
||||
state.setStateJson("{\"jobId\":7101,\"resultId\":7201,\"chunkScopeHash\":null,\"chunkIndex\":null,"
|
||||
+ "\"batchIndex\":1,\"batchTotal\":" + batchTotal + ",\"ownerInstanceId\":\"test-instance\","
|
||||
@@ -204,8 +189,8 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
return new ObjectMapper().writeValueAsString(rows);
|
||||
}
|
||||
|
||||
private SimilarAsinTaskService.CozeBatchContext context(int batchTotal) {
|
||||
return new SimilarAsinTaskService.CozeBatchContext(
|
||||
private SimilarAsinTaskService.LlmBatchContext context(int batchTotal) {
|
||||
return new SimilarAsinTaskService.LlmBatchContext(
|
||||
7101L, 7201L, null, null, 1, batchTotal, "test-instance", 0, CREDENTIAL, null);
|
||||
}
|
||||
|
||||
@@ -235,14 +220,14 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
|
||||
@Test
|
||||
void test_task_012_payload_chunk_normal_default_path() throws Exception {
|
||||
// 正常输入:DONE 结果(batchTotal=1 单 batch)经 bufferCozeRowsOrMerge 走缓冲,
|
||||
// 正常输入:DONE 结果(batchTotal=1 单 batch)经 bufferLlmRowsOrMerge 走缓冲,
|
||||
// 不立即写 chunk;缓冲失败回退立即 merge 结果不丢失。
|
||||
FileTaskEntity task = task();
|
||||
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
|
||||
TaskScopeStateEntity.class,
|
||||
SimilarAsinTaskService.CozeBatchContext.class,
|
||||
SimilarAsinTaskService.LlmBatchContext.class,
|
||||
List.class, FileTaskEntity.class, Map.class);
|
||||
bufferOrMerge.setAccessible(true);
|
||||
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
|
||||
@@ -278,7 +263,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk(1L, "scope-1", 1, "ptr:chunk-1")));
|
||||
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||
|
||||
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushBufferedCozeResults", Long.class);
|
||||
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushLlmBufferedResults", Long.class);
|
||||
flush.setAccessible(true);
|
||||
flush.invoke(service, 7104L);
|
||||
|
||||
@@ -294,9 +279,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
FileTaskEntity task = task();
|
||||
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
|
||||
TaskScopeStateEntity.class,
|
||||
SimilarAsinTaskService.CozeBatchContext.class,
|
||||
SimilarAsinTaskService.LlmBatchContext.class,
|
||||
List.class, FileTaskEntity.class, Map.class);
|
||||
bufferOrMerge.setAccessible(true);
|
||||
TaskScopeStateEntity state = state(task, 1L, "DONE", 2);
|
||||
@@ -313,9 +298,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
void test_task_012_payload_chunk_boundary_empty_input() throws Exception {
|
||||
// 空输入:无行时缓冲与 merge 都不发生,不创建无效资源
|
||||
FileTaskEntity task = task();
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
|
||||
TaskScopeStateEntity.class,
|
||||
SimilarAsinTaskService.CozeBatchContext.class,
|
||||
SimilarAsinTaskService.LlmBatchContext.class,
|
||||
List.class, FileTaskEntity.class, Map.class);
|
||||
bufferOrMerge.setAccessible(true);
|
||||
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), null, task, Map.of());
|
||||
@@ -330,9 +315,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
// 单 batch(batchTotal=1):原 P0-3 例外,现在也缓冲
|
||||
FileTaskEntity task = task();
|
||||
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
|
||||
TaskScopeStateEntity.class,
|
||||
SimilarAsinTaskService.CozeBatchContext.class,
|
||||
SimilarAsinTaskService.LlmBatchContext.class,
|
||||
List.class, FileTaskEntity.class, Map.class);
|
||||
bufferOrMerge.setAccessible(true);
|
||||
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
|
||||
@@ -347,11 +332,11 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
// 缓冲开关关闭:回退立即 merge,DONE 结果仍落 chunk 不丢失
|
||||
FileTaskEntity task = task();
|
||||
stubChunkMerge(chunkRowsJson());
|
||||
when(properties.isCozeResultBufferEnabled()).thenReturn(false);
|
||||
when(properties.isLlmResultBufferEnabled()).thenReturn(false);
|
||||
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
|
||||
TaskScopeStateEntity.class,
|
||||
SimilarAsinTaskService.CozeBatchContext.class,
|
||||
SimilarAsinTaskService.LlmBatchContext.class,
|
||||
List.class, FileTaskEntity.class, Map.class);
|
||||
bufferOrMerge.setAccessible(true);
|
||||
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
|
||||
@@ -369,9 +354,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true)))
|
||||
.thenThrow(new IllegalStateException("rustfs full"));
|
||||
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
|
||||
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
|
||||
TaskScopeStateEntity.class,
|
||||
SimilarAsinTaskService.CozeBatchContext.class,
|
||||
SimilarAsinTaskService.LlmBatchContext.class,
|
||||
List.class, FileTaskEntity.class, Map.class);
|
||||
bufferOrMerge.setAccessible(true);
|
||||
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
|
||||
@@ -408,7 +393,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
|
||||
return "ptr:stored-" + invocation.getArgument(3);
|
||||
}).when(transientPayloadStorageService).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||
|
||||
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushBufferedCozeResults", Long.class);
|
||||
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushLlmBufferedResults", Long.class);
|
||||
flush.setAccessible(true);
|
||||
Exception ex = assertThrows(Exception.class, () -> {
|
||||
try {
|
||||
|
||||
-2
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
|
||||
@@ -69,7 +68,6 @@ class SimilarAsinTaskServiceGroupRefTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||
|
||||
-2
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||
@@ -64,7 +63,6 @@ class SimilarAsinTaskServiceParseLimitsTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||
|
||||
-2
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||
@@ -66,7 +65,6 @@ class SimilarAsinTaskServiceParsePreviewTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||
|
||||
-2
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||
@@ -65,7 +64,6 @@ class SimilarAsinTaskServicePreviewConfigTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||
|
||||
+18
-21
@@ -2,7 +2,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
@@ -65,7 +64,6 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
|
||||
@@ -75,7 +73,6 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
|
||||
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
|
||||
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
|
||||
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
|
||||
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||
|
||||
@@ -157,7 +154,7 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
|
||||
|
||||
@Test
|
||||
void test_task_011_merge_row_key_normal_default_path() throws Exception {
|
||||
// 正常输入:cozeRows 含同一 rowKey 的重复行,merge 前按稳定 rowKey 去重,
|
||||
// 正常输入:llmRows 含同一 rowKey 的重复行,merge 前按稳定 rowKey 去重,
|
||||
// chunk payload 只写一次,结果行不重复。
|
||||
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
|
||||
AtomicLong storedCounter = new AtomicLong(0);
|
||||
@@ -165,18 +162,18 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(7004L);
|
||||
List<SimilarAsinResultRowDto> cozeRows = List.of(
|
||||
List<SimilarAsinResultRowDto> llmRows = List.of(
|
||||
row("r1", "1", "B0A0000001", "英国"),
|
||||
row("r1", "1", "B0A0000001", "英国"));
|
||||
|
||||
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(cozeRows);
|
||||
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(llmRows);
|
||||
assertEquals(1, deduped.size(), "重复行必须按稳定 rowKey 去重");
|
||||
assertEquals("r1", deduped.get(0).getRowToken());
|
||||
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
|
||||
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||
merge.setAccessible(true);
|
||||
merge.invoke(service, task, null, null, cozeRows, Map.of());
|
||||
merge.invoke(service, task, null, null, llmRows, Map.of());
|
||||
assertEquals(1, storedCounter.get(), "去重后 chunk 只写一次");
|
||||
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||
}
|
||||
@@ -198,34 +195,34 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
|
||||
selectOneRound.getAndIncrement() == 0 ? chunkA : chunkB);
|
||||
when(taskChunkMapper.update(any(), any())).thenReturn(1);
|
||||
|
||||
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
cozeRows.add(row("r1", "1", "B0A0000001", "英国"));
|
||||
cozeRows.add(row("r2", "2", "B0A0000002", "英国"));
|
||||
llmRows.add(row("r1", "1", "B0A0000001", "英国"));
|
||||
llmRows.add(row("r2", "2", "B0A0000002", "英国"));
|
||||
}
|
||||
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(cozeRows);
|
||||
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(llmRows);
|
||||
assertEquals(2, deduped.size(), "3 轮重复输入去重后只剩 2 个唯一行");
|
||||
assertEquals(List.of("r1", "r2"), deduped.stream().map(SimilarAsinResultRowDto::getRowToken).toList(),
|
||||
"去重必须保留首次出现顺序");
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(7004L);
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
|
||||
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||
merge.setAccessible(true);
|
||||
merge.invoke(service, task, null, null, cozeRows, Map.of());
|
||||
merge.invoke(service, task, null, null, llmRows, Map.of());
|
||||
verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void test_task_011_merge_row_key_normal_repeated_operation_is_idempotent() {
|
||||
// 重复执行同一输入:去重结果完全一致,不产生重复记录
|
||||
List<SimilarAsinResultRowDto> cozeRows = List.of(
|
||||
List<SimilarAsinResultRowDto> llmRows = List.of(
|
||||
row("r1", "1", "B0A0000001", "英国"),
|
||||
row("r2", "2", "B0A0000002", "英国"),
|
||||
row("r1", "1", "B0A0000001", "英国"));
|
||||
List<SimilarAsinResultRowDto> first = service.dedupeRowsByRowKey(cozeRows);
|
||||
List<SimilarAsinResultRowDto> second = service.dedupeRowsByRowKey(cozeRows);
|
||||
List<SimilarAsinResultRowDto> first = service.dedupeRowsByRowKey(llmRows);
|
||||
List<SimilarAsinResultRowDto> second = service.dedupeRowsByRowKey(llmRows);
|
||||
assertEquals(first.size(), second.size());
|
||||
for (int i = 0; i < first.size(); i++) {
|
||||
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
|
||||
@@ -258,11 +255,11 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
|
||||
@Test
|
||||
void test_task_011_merge_row_key_boundary_limit_and_overflow() {
|
||||
// 大批量:1000 行全部重复,去重后只剩 1 个唯一行,无无界内存增长
|
||||
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
cozeRows.add(row("r1", "1", "B0A0000001", "英国"));
|
||||
llmRows.add(row("r1", "1", "B0A0000001", "英国"));
|
||||
}
|
||||
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(cozeRows);
|
||||
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(llmRows);
|
||||
assertEquals(1, deduped.size());
|
||||
// 1000 行唯一:全部保留且顺序稳定
|
||||
List<SimilarAsinResultRowDto> unique = new ArrayList<>();
|
||||
@@ -299,7 +296,7 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
|
||||
.thenThrow(new IllegalStateException("rustfs down"));
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(7004L);
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
|
||||
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||
merge.setAccessible(true);
|
||||
Exception ex = assertThrows(Exception.class, () -> {
|
||||
|
||||
+31
-34
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
|
||||
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
|
||||
@@ -52,8 +51,8 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* Task 10:为 chunk 结果建立按 row key 的批量索引,消除跨 chunk 线性扫描。
|
||||
* indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKey,coze 行归属从
|
||||
* O(rows×chunks) 降为 O(1) 查找;assignCozeRowsToChunks 基于索引分配行并保留
|
||||
* indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKey,llm 行归属从
|
||||
* O(rows×chunks) 降为 O(1) 查找;assignLlmRowsToChunks 基于索引分配行并保留
|
||||
* 原有命中/fallback/orphan 语义;集成用例验证每个 chunk 只读一次 payload。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@@ -70,7 +69,6 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
|
||||
@@ -80,7 +78,6 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
|
||||
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
|
||||
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
|
||||
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
|
||||
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||
|
||||
@@ -170,7 +167,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
|
||||
@Test
|
||||
void test_task_010_chunk_row_key_normal_default_path() throws Exception {
|
||||
// 正常输入:2 个 chunk 各含行,coze 回传行按 rowKey 命中各自 chunk;
|
||||
// 正常输入:2 个 chunk 各含行,llm 回传行按 rowKey 命中各自 chunk;
|
||||
// 每个 chunk 的 payload 只被读取一次(索引建立),消除跨 chunk 线性扫描。
|
||||
List<TaskChunkEntity> chunks = List.of(
|
||||
chunk(1L, "hashA", 1, "ptr:chunk-A"),
|
||||
@@ -191,12 +188,12 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(7004L);
|
||||
List<SimilarAsinResultRowDto> cozeRows = List.of(row("r1", "1", "B0A0000001", "英国"), row("r3", "3", "B0A0000003", "美国"));
|
||||
List<SimilarAsinResultRowDto> llmRows = List.of(row("r1", "1", "B0A0000001", "英国"), row("r3", "3", "B0A0000003", "美国"));
|
||||
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
|
||||
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||
merge.setAccessible(true);
|
||||
merge.invoke(service, task, null, null, cozeRows, Map.of());
|
||||
merge.invoke(service, task, null, null, llmRows, Map.of());
|
||||
|
||||
verify(transientPayloadStorageService, times(6)).resolvePayload(anyString(), anyString());
|
||||
verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
|
||||
@@ -205,7 +202,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
|
||||
@Test
|
||||
void test_task_010_chunk_row_key_normal_multiple_items() {
|
||||
// 批量场景:3 个 chunk 各 3 行,9 个 coze 回传行全部命中且顺序稳定,无 orphan
|
||||
// 批量场景:3 个 chunk 各 3 行,9 个 llm 回传行全部命中且顺序稳定,无 orphan
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = new LinkedHashMap<>();
|
||||
for (int c = 0; c < 3; c++) {
|
||||
rowsByChunk.putAll(rowsByChunkOf("hash" + c, c + 1,
|
||||
@@ -213,16 +210,16 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
row("c" + c + "r2", "2", "B0B" + c + "000002", "英国"),
|
||||
row("c" + c + "r3", "3", "B0B" + c + "000003", "美国"))));
|
||||
}
|
||||
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
|
||||
for (int c = 0; c < 3; c++) {
|
||||
for (int r = 1; r <= 3; r++) {
|
||||
cozeRows.add(row("c" + c + "r" + r, String.valueOf(r), "B0B" + c + "00000" + r, r == 3 ? "美国" : "英国"));
|
||||
llmRows.add(row("c" + c + "r" + r, String.valueOf(r), "B0B" + c + "00000" + r, r == 3 ? "美国" : "英国"));
|
||||
}
|
||||
}
|
||||
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||
rowsByChunk, cozeRows, index, null, null, orphans);
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
|
||||
rowsByChunk, llmRows, index, null, null, orphans);
|
||||
assertEquals(3, merged.size());
|
||||
assertEquals(9, assignedRowKeys(merged).size());
|
||||
assertTrue(orphans.isEmpty(), "全部命中,不应产生 orphan");
|
||||
@@ -236,12 +233,12 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
// 重复执行同一输入:结果完全一致,不产生重复记录
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = rowsByChunkOf("hashA", 1,
|
||||
List.of(row("r1", "1", "B0A0000001", "英国"), row("r2", "2", "B0A0000002", "英国")));
|
||||
List<SimilarAsinResultRowDto> cozeRows = List.of(row("r1", "1", "B0A0000001", "英国"));
|
||||
List<SimilarAsinResultRowDto> llmRows = List.of(row("r1", "1", "B0A0000001", "英国"));
|
||||
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> first = service.assignCozeRowsToChunks(
|
||||
rowsByChunk, cozeRows, index, null, null, new ArrayList<>());
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> second = service.assignCozeRowsToChunks(
|
||||
rowsByChunk, cozeRows, index, null, null, new ArrayList<>());
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> first = service.assignLlmRowsToChunks(
|
||||
rowsByChunk, llmRows, index, null, null, new ArrayList<>());
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> second = service.assignLlmRowsToChunks(
|
||||
rowsByChunk, llmRows, index, null, null, new ArrayList<>());
|
||||
assertEquals(assignedRowKeys(first), assignedRowKeys(second));
|
||||
assertEquals(first.size(), second.size());
|
||||
for (Map.Entry<String, Map<String, SimilarAsinResultRowDto>> entry : first.entrySet()) {
|
||||
@@ -251,21 +248,21 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
|
||||
@Test
|
||||
void test_task_010_chunk_row_key_boundary_empty_input() {
|
||||
// 空输入:null/空 rowsByChunk 与 cozeRows 均安全返回空结果,不创建无效资源
|
||||
// 空输入:null/空 rowsByChunk 与 llmRows 均安全返回空结果,不创建无效资源
|
||||
assertNotNull(service.indexRowsByChunkKey(null));
|
||||
assertTrue(service.indexRowsByChunkKey(null).isEmpty());
|
||||
assertTrue(service.indexRowsByChunkKey(Map.of()).isEmpty());
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> emptyAssign = service.assignCozeRowsToChunks(
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> emptyAssign = service.assignLlmRowsToChunks(
|
||||
Map.of(), List.of(), Map.of(), null, null, new ArrayList<>());
|
||||
assertTrue(emptyAssign.isEmpty());
|
||||
assertTrue(service.assignCozeRowsToChunks(
|
||||
assertTrue(service.assignLlmRowsToChunks(
|
||||
Map.of(), null, Map.of(), null, null, new ArrayList<>()).isEmpty());
|
||||
// 无可匹配行(rowKey 不存在于任何 chunk)→ 进 orphan 兜底,不产生 merge
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = rowsByChunkOf("hashA", 1, List.of(row("r1", "1", "B0A0000001", "英国")));
|
||||
List<SimilarAsinResultRowDto> blankRow = List.of(row("", "", "", ""));
|
||||
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
|
||||
rowsByChunk, blankRow, index, null, null, orphans);
|
||||
assertTrue(assignedRowKeys(merged).isEmpty());
|
||||
assertEquals(1, orphans.size(), "全空行生成 legacy key :::: 不命中任何 chunk,按既有语义进 orphan");
|
||||
@@ -278,7 +275,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
List.of(row("r1", "1", "B0A0000001", "英国")));
|
||||
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
|
||||
rowsByChunk, List.of(row("r1", "1", "B0A0000001", "英国")), index, null, null, orphans);
|
||||
assertEquals(1, merged.size());
|
||||
assertEquals(List.of("r1"), assignedRowKeys(merged));
|
||||
@@ -290,7 +287,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
|
||||
@Test
|
||||
void test_task_010_chunk_row_key_boundary_limit_and_overflow() {
|
||||
// 大批量:1000 行索引 + 500 个 coze 回传行全部命中,行不丢、无 orphan
|
||||
// 大批量:1000 行索引 + 500 个 llm 回传行全部命中,行不丢、无 orphan
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = new LinkedHashMap<>();
|
||||
Map<String, SimilarAsinResultRowDto> bigChunk = new LinkedHashMap<>();
|
||||
for (int i = 1; i <= 1000; i++) {
|
||||
@@ -299,13 +296,13 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
rowsByChunk.put("hashBig:1", bigChunk);
|
||||
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||
assertEquals(1000, index.size());
|
||||
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
|
||||
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
|
||||
for (int i = 1; i <= 500; i++) {
|
||||
cozeRows.add(row("r" + String.format("%04d", i), String.valueOf(i), "B0L" + String.format("%06d", i), "英国"));
|
||||
llmRows.add(row("r" + String.format("%04d", i), String.valueOf(i), "B0L" + String.format("%06d", i), "英国"));
|
||||
}
|
||||
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||
rowsByChunk, cozeRows, index, null, null, orphans);
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
|
||||
rowsByChunk, llmRows, index, null, null, orphans);
|
||||
assertEquals(1, merged.size());
|
||||
assertEquals(500, assignedRowKeys(merged).size());
|
||||
assertTrue(orphans.isEmpty());
|
||||
@@ -319,20 +316,20 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
rowsByChunk.putAll(rowsByChunkOf("hashB", 2, List.of(row("dup", "1", "B0A0000001", "英国"))));
|
||||
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
|
||||
assertEquals("hashA:1", index.get("dup"), "重复 rowKey 应保留第一个 chunk");
|
||||
// fallback 缺失:coze 行未命中且无有效 fallback → 进 orphan,不产生 merge
|
||||
// fallback 缺失:llm 行未命中且无有效 fallback → 进 orphan,不产生 merge
|
||||
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
|
||||
rowsByChunk, List.of(row("ghost", "9", "B0A0000009", "英国")), Map.of(), "missingHash", 99, orphans);
|
||||
assertTrue(assignedRowKeys(merged).isEmpty());
|
||||
assertEquals(1, orphans.size());
|
||||
assertEquals("ghost", orphans.get(0).getRowToken());
|
||||
// cozeRows 含 null 元素:跳过不抛异常,其余行正常分配
|
||||
// llmRows 含 null 元素:跳过不抛异常,其余行正常分配
|
||||
List<SimilarAsinResultRowDto> withNull = new ArrayList<>();
|
||||
withNull.add(null);
|
||||
withNull.add(row("dup", "1", "B0A0000001", "英国"));
|
||||
List<SimilarAsinResultRowDto> orphans2 = new ArrayList<>();
|
||||
Map<String, String> index2 = service.indexRowsByChunkKey(rowsByChunk);
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged2 = service.assignCozeRowsToChunks(
|
||||
Map<String, Map<String, SimilarAsinResultRowDto>> merged2 = service.assignLlmRowsToChunks(
|
||||
rowsByChunk, withNull, index2, null, null, orphans2);
|
||||
assertEquals(1, merged2.size());
|
||||
assertEquals(List.of("dup"), assignedRowKeys(merged2));
|
||||
@@ -349,7 +346,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
|
||||
.thenThrow(new IllegalStateException("rustfs down"));
|
||||
FileTaskEntity task = new FileTaskEntity();
|
||||
task.setId(7004L);
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
|
||||
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
|
||||
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
|
||||
merge.setAccessible(true);
|
||||
BusinessException ex = assertThrows(BusinessException.class, () -> {
|
||||
|
||||
+1
-5
@@ -5,10 +5,8 @@ import com.nanri.aiimage.common.service.DistributedJobLockService;
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.config.StorageProperties;
|
||||
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
|
||||
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
|
||||
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||
@@ -82,7 +80,6 @@ class SimilarAsinTaskServiceSubmitTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private TaskFileJobService taskFileJobService;
|
||||
@@ -92,7 +89,6 @@ class SimilarAsinTaskServiceSubmitTest {
|
||||
@Mock private PlatformTransactionManager transactionManager;
|
||||
@Mock private DistributedJobLockService distributedJobLockService;
|
||||
@Mock private InstanceMetadata instanceMetadata;
|
||||
@Mock private CozeCredentialPoolService cozeCredentialPoolService;
|
||||
@Mock private SimilarAsinImageEmbedder imageEmbedder;
|
||||
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
|
||||
@Mock private TransactionStatus transactionStatus;
|
||||
@@ -360,7 +356,7 @@ class SimilarAsinTaskServiceSubmitTest {
|
||||
scope.setScopeHash("existing-scope");
|
||||
scope.setChunkTotal(1);
|
||||
scope.setCompleted(completed);
|
||||
scope.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}");
|
||||
scope.setStateJson("{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}");
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.SimilarAsinProperties;
|
||||
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
|
||||
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
|
||||
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
|
||||
@@ -66,7 +65,6 @@ class SimilarAsinTaskServiceWorkbookControlTest {
|
||||
@Mock private TaskChunkMapper taskChunkMapper;
|
||||
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
|
||||
@Spy private ObjectMapper objectMapper = new ObjectMapper();
|
||||
@Mock private SimilarAsinCozeClient cozeClient;
|
||||
@Mock private SimilarAsinTaskCacheService taskCacheService;
|
||||
@Mock private SimilarAsinProperties properties;
|
||||
@Mock private TransientPayloadStorageService transientPayloadStorageService;
|
||||
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* AppearancePatentResultFileJobHandler 测试(任务 68,异步+offload)。
|
||||
* process 返回 Service 的 boolean(false=等待异步结果);cleanup→cleanupResultFileJob;
|
||||
* supportsAsyncOffload()=true;isOwnerScoped()=true。
|
||||
*/
|
||||
class AppearancePatentResultFileJobHandlerTest {
|
||||
|
||||
private final AppearancePatentTaskService appearancePatentTaskService =
|
||||
mock(AppearancePatentTaskService.class);
|
||||
private final ResultFileJobHandler handler = new AppearancePatentResultFileJobHandler(
|
||||
appearancePatentTaskService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("APPEARANCE_PATENT", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processBooleanTrue() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true);
|
||||
assertTrue(handler.process(job), "Service true 时 handler 返回 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processBooleanFalse() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false);
|
||||
assertFalse(handler.process(job), "Service false(等待异步)时 handler 返回 false");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
handler.cleanup(job);
|
||||
org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadTrue() {
|
||||
assertTrue(handler.supportsAsyncOffload(), "APPEARANCE_PATENT 支持异步 offload");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedTrue() {
|
||||
assertTrue(handler.isOwnerScoped(), "APPEARANCE_PATENT 为 owner scoped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void falseMeansWaiting() {
|
||||
// false 语义与 Worker defer 对应:process 返回 false 时不 markSuccess,由异步结果回写
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false);
|
||||
assertFalse(handler.process(job));
|
||||
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true);
|
||||
assertTrue(handler.process(job), "异步完成后再次处理返回 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
when(appearancePatentTaskService.processResultFileJob(null)).thenReturn(true);
|
||||
assertTrue(handler.process(null));
|
||||
handler.cleanup(null);
|
||||
org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(null);
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* BrandResultFileJobHandler 测试(任务 71)。
|
||||
* BRAND 的 resolveResultObjectKey 特例(无 resultId 也走)保留在 Worker 公共路径 resolveResultFileUrl,
|
||||
* Handler 不接管 URL 解析;cleanup 为空(原 cleanupAfterSuccess 无 BRAND 分支,不走 payload.deleteLatest)。
|
||||
*/
|
||||
class BrandResultFileJobHandlerTest {
|
||||
|
||||
private final BrandTaskService brandTaskService = mock(BrandTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new BrandResultFileJobHandler(
|
||||
brandTaskService, taskResultPayloadService);
|
||||
|
||||
private static String workerSource() throws IOException {
|
||||
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
|
||||
"task", "service", "TaskResultFileJobWorker.java");
|
||||
return new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("BRAND", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(brandTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void resultUrlBrandSpecial() throws IOException {
|
||||
// Handler 不接管 URL 解析:无 resolveResultObjectKey / resolveResultFileUrl 方法
|
||||
for (java.lang.reflect.Method method : handler.getClass().getDeclaredMethods()) {
|
||||
assertFalse(method.getName().contains("resolve"),
|
||||
"Handler 不接管 URL 解析:" + method.getName());
|
||||
}
|
||||
// 特例仍留在 Worker 公共路径 resolveResultFileUrl(含 BRAND 分支,无 resultId 也走)
|
||||
String source = workerSource();
|
||||
assertTrue(source.contains("resolveResultFileUrl"), "Worker 保留 resolveResultFileUrl 公共路径");
|
||||
assertTrue(source.contains("resolveResultObjectKey"), "Worker 保留 BRAND 的 resolveResultObjectKey 特例");
|
||||
assertTrue(source.contains("\"BRAND\"") || source.contains("BRAND"),
|
||||
"Worker resolveResultFileUrl 含 BRAND 分支");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupNoop() {
|
||||
// 原 cleanupAfterSuccess 无 BRAND 分支:cleanup 空操作
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
job.setModuleType("BRAND");
|
||||
job.setScopeKey("s");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService, never()).deleteLatest(1L, "BRAND", "s");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedFalse() {
|
||||
assertFalse(handler.isOwnerScoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(brandTaskService).processResultFileJob(null);
|
||||
handler.cleanup(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(2L);
|
||||
job.setModuleType("BRAND");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService, never()).deleteLatest(2L, "BRAND", null);
|
||||
}
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* CollectDataResultFileJobHandler 测试(任务 72)。
|
||||
* cleanup 为空(原 cleanupAfterSuccess 无 COLLECT_DATA 分支)。
|
||||
*/
|
||||
class CollectDataResultFileJobHandlerTest {
|
||||
|
||||
private final CollectDataService collectDataService = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new CollectDataResultFileJobHandler(collectDataService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("COLLECT_DATA", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(collectDataService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupNoop() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
job.setModuleType("COLLECT_DATA");
|
||||
job.setScopeKey("s");
|
||||
handler.cleanup(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedFalse() {
|
||||
assertFalse(handler.isOwnerScoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(collectDataService).processResultFileJob(null);
|
||||
handler.cleanup(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void noDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(2L);
|
||||
job.setModuleType("COLLECT_DATA");
|
||||
job.setScopeKey("k");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService, never()).deleteLatest(2L, "COLLECT_DATA", "k");
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* DeleteBrandResultFileJobHandler 测试(任务 70)。
|
||||
*/
|
||||
class DeleteBrandResultFileJobHandlerTest {
|
||||
|
||||
private final DeleteBrandRunService deleteBrandRunService = mock(DeleteBrandRunService.class);
|
||||
private final ResultFileJobHandler handler = new DeleteBrandResultFileJobHandler(deleteBrandRunService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("DELETE_BRAND", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(deleteBrandRunService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processReturnsTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
job.setModuleType("DELETE_BRAND");
|
||||
job.setScopeKey("s");
|
||||
handler.cleanup(job);
|
||||
verify(deleteBrandRunService).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupArgs() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(7L);
|
||||
handler.cleanup(job);
|
||||
verify(deleteBrandRunService).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedFalse() {
|
||||
assertFalse(handler.isOwnerScoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(deleteBrandRunService).processResultFileJob(null);
|
||||
handler.cleanup(null);
|
||||
verify(deleteBrandRunService).cleanupResultFileJob(null);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* PatrolDeleteResultFileJobHandler 测试(任务 67,与 60 同构)。
|
||||
*/
|
||||
class PatrolDeleteResultFileJobHandlerTest {
|
||||
|
||||
private final PatrolDeleteTaskService patrolDeleteTaskService = mock(PatrolDeleteTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new PatrolDeleteResultFileJobHandler(
|
||||
patrolDeleteTaskService, taskResultPayloadService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("PATROL_DELETE", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(patrolDeleteTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processReturnsTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupCallsDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(42L);
|
||||
job.setModuleType("PATROL_DELETE");
|
||||
job.setScopeKey("scope-1");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(42L, "PATROL_DELETE", "scope-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupArgs() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(7L);
|
||||
job.setModuleType("PATROL_DELETE");
|
||||
job.setScopeKey("u:99");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(7L, "PATROL_DELETE", "u:99");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedFalse() {
|
||||
assertFalse(handler.isOwnerScoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processNullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(patrolDeleteTaskService).processResultFileJob(null);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* PriceTrackResultFileJobHandler 测试(任务 61,与 60 同构)。
|
||||
*/
|
||||
class PriceTrackResultFileJobHandlerTest {
|
||||
|
||||
private final PriceTrackTaskService priceTrackTaskService = mock(PriceTrackTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new PriceTrackResultFileJobHandler(
|
||||
priceTrackTaskService, taskResultPayloadService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("PRICE_TRACK", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(priceTrackTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processReturnsTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupCallsDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(42L);
|
||||
job.setModuleType("PRICE_TRACK");
|
||||
job.setScopeKey("scope-1");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(42L, "PRICE_TRACK", "scope-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupArgs() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(7L);
|
||||
job.setModuleType("PRICE_TRACK");
|
||||
job.setScopeKey("u:99");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(7L, "PRICE_TRACK", "u:99");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedFalse() {
|
||||
assertFalse(handler.isOwnerScoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processNullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(priceTrackTaskService).processResultFileJob(null);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* ProductRiskResultFileJobHandler 测试(任务 62,与 60/61 同构)。
|
||||
*/
|
||||
class ProductRiskResultFileJobHandlerTest {
|
||||
|
||||
private final ProductRiskTaskService productRiskTaskService = mock(ProductRiskTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new ProductRiskResultFileJobHandler(
|
||||
productRiskTaskService, taskResultPayloadService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("PRODUCT_RISK_RESOLVE", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(productRiskTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processReturnsTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupCallsDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(42L);
|
||||
job.setModuleType("PRODUCT_RISK_RESOLVE");
|
||||
job.setScopeKey("scope-1");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(42L, "PRODUCT_RISK_RESOLVE", "scope-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupArgs() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(7L);
|
||||
job.setModuleType("PRODUCT_RISK_RESOLVE");
|
||||
job.setScopeKey("u:99");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(7L, "PRODUCT_RISK_RESOLVE", "u:99");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedFalse() {
|
||||
assertFalse(handler.isOwnerScoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processNullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(productRiskTaskService).processResultFileJob(null);
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* PublishResultFileJobHandler 测试(任务 63,ownerScoped)。
|
||||
* moduleType 用 PublishTaskService.MODULE_TYPE 常量;
|
||||
* cleanup 走 publishTaskService.cleanupResultFileJob,不走 payload.deleteLatest;
|
||||
* isOwnerScoped()=true(原 Worker isOwnerScopedJob 含 PUBLISH)。
|
||||
*/
|
||||
class PublishResultFileJobHandlerTest {
|
||||
|
||||
private final PublishTaskService publishTaskService = mock(PublishTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new PublishResultFileJobHandler(publishTaskService);
|
||||
|
||||
@Test
|
||||
void moduleTypeConstant() {
|
||||
assertEquals(PublishTaskService.MODULE_TYPE, handler.moduleType(), "moduleType 用常量");
|
||||
assertEquals("PUBLISH", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(publishTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
job.setModuleType("PUBLISH");
|
||||
job.setScopeKey("s");
|
||||
handler.cleanup(job);
|
||||
verify(publishTaskService).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedTrue() {
|
||||
assertTrue(handler.isOwnerScoped(), "PUBLISH 为 owner scoped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupNoDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
job.setModuleType("PUBLISH");
|
||||
job.setScopeKey("s");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService, never()).deleteLatest(1L, "PUBLISH", "s");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(publishTaskService).processResultFileJob(null);
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* QueryAsinResultFileJobHandler 测试(任务 64,与 60 同构)。
|
||||
*/
|
||||
class QueryAsinResultFileJobHandlerTest {
|
||||
|
||||
private final QueryAsinTaskService queryAsinTaskService = mock(QueryAsinTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new QueryAsinResultFileJobHandler(
|
||||
queryAsinTaskService, taskResultPayloadService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("QUERY_ASIN", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(queryAsinTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processReturnsTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupCallsDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(42L);
|
||||
job.setModuleType("QUERY_ASIN");
|
||||
job.setScopeKey("scope-1");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(42L, "QUERY_ASIN", "scope-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupArgs() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(7L);
|
||||
job.setModuleType("QUERY_ASIN");
|
||||
job.setScopeKey("u:99");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(7L, "QUERY_ASIN", "u:99");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedFalse() {
|
||||
assertFalse(handler.isOwnerScoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processNullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(queryAsinTaskService).processResultFileJob(null);
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* 模块映射快照测试(任务 78)。
|
||||
* 与 04 spec §3 映射表一一对应:13 个 moduleType → Handler 类 → 原 Service 方法;
|
||||
* 新增模块类型未映射即失败(快照严格性)。
|
||||
*/
|
||||
class ResultFileJobHandlerMappingSnapshotTest {
|
||||
|
||||
private static final List<String> EXPECTED_MODULE_TYPES = List.of(
|
||||
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN",
|
||||
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
|
||||
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA");
|
||||
|
||||
private static ResultFileJobHandlerRegistry buildRegistry() {
|
||||
TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
List<ResultFileJobHandler> handlers = List.of(
|
||||
new ShopMatchResultFileJobHandler(mock(ShopMatchTaskService.class), payload),
|
||||
new PriceTrackResultFileJobHandler(mock(PriceTrackTaskService.class), payload),
|
||||
new ProductRiskResultFileJobHandler(mock(ProductRiskTaskService.class), payload),
|
||||
new PublishResultFileJobHandler(mock(PublishTaskService.class)),
|
||||
new QueryAsinResultFileJobHandler(mock(QueryAsinTaskService.class), payload),
|
||||
new ShopDataCrawlResultFileJobHandler(mock(ShopDataCrawlTaskService.class), payload),
|
||||
new WithdrawResultFileJobHandler(mock(WithdrawTaskService.class), payload),
|
||||
new PatrolDeleteResultFileJobHandler(mock(PatrolDeleteTaskService.class), payload),
|
||||
new AppearancePatentResultFileJobHandler(mock(AppearancePatentTaskService.class)),
|
||||
new SimilarAsinResultFileJobHandler(mock(SimilarAsinTaskService.class)),
|
||||
new DeleteBrandResultFileJobHandler(mock(DeleteBrandRunService.class)),
|
||||
new BrandResultFileJobHandler(mock(BrandTaskService.class), payload),
|
||||
new CollectDataResultFileJobHandler(mock(CollectDataService.class)));
|
||||
return new ResultFileJobHandlerRegistry(handlers);
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotAll13Mapped() {
|
||||
ResultFileJobHandlerRegistry registry = buildRegistry();
|
||||
assertEquals(13, registry.moduleTypes().size(), "恰好 13 个模块");
|
||||
assertEquals(Set.copyOf(EXPECTED_MODULE_TYPES), Set.copyOf(registry.moduleTypes()),
|
||||
"moduleType 集合与 spec §3 一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotHandlerClasses() {
|
||||
ResultFileJobHandlerRegistry registry = buildRegistry();
|
||||
assertTrue(registry.resolve("SHOP_MATCH") instanceof ShopMatchResultFileJobHandler);
|
||||
assertTrue(registry.resolve("PRICE_TRACK") instanceof PriceTrackResultFileJobHandler);
|
||||
assertTrue(registry.resolve("PRODUCT_RISK_RESOLVE") instanceof ProductRiskResultFileJobHandler);
|
||||
assertTrue(registry.resolve("PUBLISH") instanceof PublishResultFileJobHandler);
|
||||
assertTrue(registry.resolve("QUERY_ASIN") instanceof QueryAsinResultFileJobHandler);
|
||||
assertTrue(registry.resolve("SHOP_DATA_CRAWL") instanceof ShopDataCrawlResultFileJobHandler);
|
||||
assertTrue(registry.resolve("WITHDRAW") instanceof WithdrawResultFileJobHandler);
|
||||
assertTrue(registry.resolve("PATROL_DELETE") instanceof PatrolDeleteResultFileJobHandler);
|
||||
assertTrue(registry.resolve("APPEARANCE_PATENT") instanceof AppearancePatentResultFileJobHandler);
|
||||
assertTrue(registry.resolve("SIMILAR_ASIN") instanceof SimilarAsinResultFileJobHandler);
|
||||
assertTrue(registry.resolve("DELETE_BRAND") instanceof DeleteBrandResultFileJobHandler);
|
||||
assertTrue(registry.resolve("BRAND") instanceof BrandResultFileJobHandler);
|
||||
assertTrue(registry.resolve("COLLECT_DATA") instanceof CollectDataResultFileJobHandler);
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotShopMatchDelegate() throws Exception {
|
||||
Method process = ShopMatchResultFileJobHandler.class.getDeclaredMethod("process", TaskFileJobEntity.class);
|
||||
assertTrue(process.getReturnType() == boolean.class, "process 返回 boolean");
|
||||
String source = handlerSource("ShopMatchResultFileJobHandler");
|
||||
assertTrue(source.contains("shopMatchTaskService.processResultFileJob(job)"), "委托原 Service 方法");
|
||||
assertTrue(source.contains("taskResultPayloadService.deleteLatest"), "cleanup 走 payload.deleteLatest");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotAppearanceBoolean() throws Exception {
|
||||
Method process = AppearancePatentResultFileJobHandler.class.getDeclaredMethod("process", TaskFileJobEntity.class);
|
||||
assertTrue(process.getReturnType() == boolean.class, "异步模块返回 boolean");
|
||||
String source = handlerSource("AppearancePatentResultFileJobHandler");
|
||||
assertTrue(source.contains("return appearancePatentTaskService.processResultFileJob(job)"),
|
||||
"原样透传 Service boolean");
|
||||
assertTrue(source.contains("supportsAsyncOffload()") && source.contains("return true;"),
|
||||
"offload 标记为 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotPublishOwner() {
|
||||
ResultFileJobHandler handler = buildRegistry().resolve("PUBLISH");
|
||||
assertTrue(handler.isOwnerScoped(), "PUBLISH owner scoped");
|
||||
String source = handlerSource("PublishResultFileJobHandler");
|
||||
assertTrue(source.contains("publishTaskService.cleanupResultFileJob(job)"),
|
||||
"cleanup 走服务内 cleanupResultFileJob,不走 payload.deleteLatest");
|
||||
assertFalse(source.contains("taskResultPayloadService"),
|
||||
"PUBLISH Handler 无 payload 依赖");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotFailureCallbacks() {
|
||||
ResultFileJobHandlerRegistry registry = buildRegistry();
|
||||
String shopDataCrawl = handlerSource("ShopDataCrawlResultFileJobHandler");
|
||||
assertTrue(shopDataCrawl.contains("handleResultFileJobFailure"), "SHOP_DATA_CRAWL onFailure 回调");
|
||||
String similar = handlerSource("SimilarAsinResultFileJobHandler");
|
||||
assertTrue(similar.contains("handleResultFileJobFailure"), "SIMILAR_ASIN onFailure 回调");
|
||||
String withdraw = handlerSource("WithdrawResultFileJobHandler");
|
||||
assertFalse(withdraw.contains("handleResultFileJobFailure"), "WITHDRAW 无 onFailure");
|
||||
String shopMatch = handlerSource("ShopMatchResultFileJobHandler");
|
||||
assertFalse(shopMatch.contains("handleResultFileJobFailure"), "SHOP_MATCH 无 onFailure");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotCleanupMapping() {
|
||||
ResultFileJobHandlerRegistry registry = buildRegistry();
|
||||
String shopDataCrawl = handlerSource("ShopDataCrawlResultFileJobHandler");
|
||||
assertTrue(shopDataCrawl.contains("deleteLatest") && shopDataCrawl.contains("cleanupResultFileJob"),
|
||||
"SHOP_DATA_CRAWL 两处清理");
|
||||
String withdraw = handlerSource("WithdrawResultFileJobHandler");
|
||||
assertTrue(withdraw.contains("deleteLatest"), "WITHDRAW cleanup deleteLatest");
|
||||
String collectData = handlerSource("CollectDataResultFileJobHandler");
|
||||
assertTrue(!collectData.contains("deleteLatest") && !collectData.contains("cleanupResultFileJob"),
|
||||
"COLLECT_DATA 无清理");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotNoExtraModules() {
|
||||
ResultFileJobHandlerRegistry registry = buildRegistry();
|
||||
assertEquals(EXPECTED_MODULE_TYPES.size(), registry.moduleTypes().size());
|
||||
for (String moduleType : registry.moduleTypes()) {
|
||||
assertTrue(EXPECTED_MODULE_TYPES.contains(moduleType), "无多余映射:" + moduleType);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotMappingFrozen() throws IOException {
|
||||
ResultFileJobHandlerRegistry registry = buildRegistry();
|
||||
List<String> snapshot = new ArrayList<>(registry.moduleTypes());
|
||||
snapshot.sort(Comparator.naturalOrder());
|
||||
String actual = String.join(",", snapshot);
|
||||
String expectedFrozen = "APPEARANCE_PATENT,BRAND,COLLECT_DATA,DELETE_BRAND,PATROL_DELETE,PRICE_TRACK,"
|
||||
+ "PRODUCT_RISK_RESOLVE,PUBLISH,QUERY_ASIN,SHOP_DATA_CRAWL,SHOP_MATCH,SIMILAR_ASIN,WITHDRAW";
|
||||
assertEquals(expectedFrozen, actual, "模块映射快照冻结");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotNewModuleFails() {
|
||||
ResultFileJobHandlerRegistry registry = buildRegistry();
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> registry.resolve("NEW_MODULE"), "新 moduleType 未映射抛错");
|
||||
assertTrue(ex.getMessage().contains("NEW_MODULE"), "消息含新模块名");
|
||||
assertTrue(registry.asMap().get("NEW_MODULE") == null, "asMap 无新模块");
|
||||
}
|
||||
|
||||
private static String handlerSource(String simpleName) {
|
||||
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
|
||||
"task", "service", simpleName + ".java");
|
||||
try {
|
||||
return new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("读不到源码:" + source, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.TaskFileJobConfig;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 启动 fail-fast 校验测试(任务 59)。
|
||||
* 覆盖:13 个模块全部有 Handler、缺注册失败、错误消息列缺失清单、
|
||||
* 双注册失败、配置源来自 TaskFileJobConfig、新增模块无 Handler 失败、
|
||||
* 合法配置通过、校验可重复执行。
|
||||
*/
|
||||
class ResultFileJobHandlerRegistryStartupTest {
|
||||
|
||||
private static final Set<String> ALL_13 = TaskFileJobConfig.RESULT_FILE_JOB_MODULE_TYPES;
|
||||
|
||||
private static ResultFileJobHandler handler(String moduleType) {
|
||||
return new ResultFileJobHandler() {
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return moduleType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static ResultFileJobHandlerRegistry registryOf(Collection<String> types) {
|
||||
List<ResultFileJobHandler> handlers = new ArrayList<>();
|
||||
for (String type : types) {
|
||||
handlers.add(handler(type));
|
||||
}
|
||||
return new ResultFileJobHandlerRegistry(handlers);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupAllModulesCovered() {
|
||||
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
|
||||
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13),
|
||||
"13 个模块全部注册时启动校验通过");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupMissingHandlerFails() {
|
||||
Set<String> missingOne = new java.util.HashSet<>(ALL_13);
|
||||
missingOne.remove("SHOP_MATCH");
|
||||
ResultFileJobHandlerRegistry registry = registryOf(missingOne);
|
||||
assertThrows(IllegalStateException.class, () -> registry.validateCoverage(ALL_13),
|
||||
"缺 SHOP_MATCH Handler 时启动校验失败");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupMissingMessageLists() {
|
||||
Set<String> missingTwo = new java.util.HashSet<>(ALL_13);
|
||||
missingTwo.remove("SHOP_MATCH");
|
||||
missingTwo.remove("WITHDRAW");
|
||||
ResultFileJobHandlerRegistry registry = registryOf(missingTwo);
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> registry.validateCoverage(ALL_13));
|
||||
assertTrue(ex.getMessage().contains("SHOP_MATCH"), "消息列缺失模块 SHOP_MATCH:" + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("WITHDRAW"), "消息列缺失模块 WITHDRAW:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupDuplicateFails() {
|
||||
List<ResultFileJobHandler> handlers = new ArrayList<>();
|
||||
handlers.add(handler("SHOP_MATCH"));
|
||||
handlers.add(handler("SHOP_MATCH"));
|
||||
assertThrows(IllegalStateException.class, () -> new ResultFileJobHandlerRegistry(handlers),
|
||||
"双注册在构造时即失败");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupConfigSource() {
|
||||
assertEquals(13, ALL_13.size(), "TaskFileJobConfig 枚举 13 个模块");
|
||||
assertTrue(ALL_13.containsAll(List.of(
|
||||
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "QUERY_ASIN",
|
||||
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
|
||||
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA")),
|
||||
"配置源含全部 12 个常量模块");
|
||||
assertTrue(ALL_13.contains(PublishTaskService.MODULE_TYPE),
|
||||
"PUBLISH 取自 PublishTaskService.MODULE_TYPE 常量");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupNoHandlerNewModule() {
|
||||
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
|
||||
Set<String> withNew = new java.util.HashSet<>(ALL_13);
|
||||
withNew.add("NEW_MODULE_X");
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> registry.validateCoverage(withNew));
|
||||
assertTrue(ex.getMessage().contains("NEW_MODULE_X"), "新增模块无 Handler 失败:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupValidNoError() {
|
||||
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
|
||||
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13), "合法配置启动通过");
|
||||
assertEquals(ALL_13, registry.moduleTypes(), "注册表模块与配置源一致");
|
||||
}
|
||||
|
||||
@Test
|
||||
void startupCheckIdempotent() {
|
||||
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
|
||||
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13));
|
||||
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13), "第二次校验同样通过(无状态残留)");
|
||||
Set<String> missingOne = new java.util.HashSet<>(ALL_13);
|
||||
missingOne.remove("BRAND");
|
||||
ResultFileJobHandlerRegistry partial = registryOf(missingOne);
|
||||
assertThrows(IllegalStateException.class, () -> partial.validateCoverage(ALL_13));
|
||||
assertThrows(IllegalStateException.class, () -> partial.validateCoverage(ALL_13),
|
||||
"缺注册时重复校验稳定抛错");
|
||||
}
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* ResultFileJobHandlerRegistry 注册表测试(任务 58)。
|
||||
* 验证:全量收集、已知/未知模块解析、重复 moduleType 抛 IllegalStateException(消息含模块名)、
|
||||
* moduleTypes 枚举、空注册表行为、大小写敏感。
|
||||
*/
|
||||
class ResultFileJobHandlerRegistryTest {
|
||||
|
||||
private static ResultFileJobHandler handler(String moduleType) {
|
||||
return new ResultFileJobHandler() {
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return moduleType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static final ResultFileJobHandler SHOP = handler("SHOP_MATCH");
|
||||
private static final ResultFileJobHandler PRICE = handler("PRICE_TRACK");
|
||||
private static final ResultFileJobHandler PUBLISH = handler("PUBLISH");
|
||||
|
||||
@Test
|
||||
void registerAllHandlers() {
|
||||
ResultFileJobHandlerRegistry registry =
|
||||
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
|
||||
assertNotNull(registry.resolve("SHOP_MATCH"), "SHOP_MATCH 已注册");
|
||||
assertNotNull(registry.resolve("PRICE_TRACK"), "PRICE_TRACK 已注册");
|
||||
assertNotNull(registry.resolve("PUBLISH"), "PUBLISH 已注册");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveExisting() {
|
||||
ResultFileJobHandlerRegistry registry =
|
||||
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
|
||||
assertEquals(SHOP, registry.resolve("SHOP_MATCH"), "解析到同一 handler 实例");
|
||||
assertEquals(PRICE, registry.resolve("PRICE_TRACK"), "解析到同一 handler 实例");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveUnknownThrows() {
|
||||
ResultFileJobHandlerRegistry registry =
|
||||
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.resolve("NOT_EXIST"),
|
||||
"未知模块抛 IllegalArgumentException");
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateModuleTypeThrows() {
|
||||
ResultFileJobHandler duplicate = handler("SHOP_MATCH");
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> new ResultFileJobHandlerRegistry(List.of(SHOP, duplicate)),
|
||||
"重复 moduleType 抛 IllegalStateException");
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateErrorMessageContainsModuleName() {
|
||||
ResultFileJobHandler duplicate = handler("SHOP_MATCH");
|
||||
IllegalStateException ex = assertThrows(IllegalStateException.class,
|
||||
() -> new ResultFileJobHandlerRegistry(List.of(SHOP, duplicate)));
|
||||
assertTrue(ex.getMessage().contains("SHOP_MATCH"), "错误消息含模块名:" + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("重复") || ex.getMessage().contains("duplicate"),
|
||||
"错误消息含重复提示:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleTypesListed() {
|
||||
ResultFileJobHandlerRegistry registry =
|
||||
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
|
||||
Set<String> types = registry.moduleTypes();
|
||||
assertEquals(3, types.size(), "枚举全部模块");
|
||||
assertTrue(types.containsAll(List.of("SHOP_MATCH", "PRICE_TRACK", "PUBLISH")),
|
||||
"moduleTypes 覆盖全部注册模块");
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyRegistryResolveThrows() {
|
||||
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(List.of());
|
||||
assertTrue(registry.moduleTypes().isEmpty(), "空注册表 moduleTypes 为空");
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.resolve("ANY"),
|
||||
"空注册表解析任何模块都抛异常");
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolveCaseSensitive() {
|
||||
ResultFileJobHandlerRegistry registry =
|
||||
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
|
||||
assertThrows(IllegalArgumentException.class, () -> registry.resolve("shop_match"),
|
||||
"小写不命中(大小写敏感)");
|
||||
assertEquals(SHOP, registry.resolve("SHOP_MATCH"), "精确大小写命中");
|
||||
Map<String, ResultFileJobHandler> map = registry.asMap();
|
||||
assertFalse(map.containsKey("shop_match"), "map 键大小写敏感");
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.Modifier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* ResultFileJobHandler 接口契约测试(任务 57)。
|
||||
* 反射校验 6 个方法签名;匿名实现验证默认实现语义:
|
||||
* cleanup / onFailure 空操作、supportsAsyncOffload / isOwnerScoped 默认 false。
|
||||
*/
|
||||
class ResultFileJobHandlerTest {
|
||||
|
||||
private static ResultFileJobHandler blankHandler() {
|
||||
return new ResultFileJobHandler() {
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "TEST_MODULE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static Method methodOf(Class<?> type, String name, Class<?>... params) throws NoSuchMethodException {
|
||||
return type.getDeclaredMethod(name, params);
|
||||
}
|
||||
|
||||
@Test
|
||||
void interfaceMethodsPresent() throws Exception {
|
||||
assertEquals(7, ResultFileJobHandler.class.getDeclaredMethods().length,
|
||||
"接口方法数量为 7(moduleType/process/onSuccess/cleanup/onFailure/supportsAsyncOffload/isOwnerScoped)");
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "moduleType"));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "supportsAsyncOffload"));
|
||||
assertNotNull(methodOf(ResultFileJobHandler.class, "isOwnerScoped"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void onSuccessDefaultNoop() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class);
|
||||
assertTrue(method.isDefault(), "onSuccess 为 default 方法");
|
||||
blankHandler().onSuccess(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDefaultNoop() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class);
|
||||
assertTrue(method.isDefault(), "cleanup 为 default 方法");
|
||||
// 默认实现不抛错、无返回值
|
||||
blankHandler().cleanup(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureDefaultNoop() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class);
|
||||
assertTrue(method.isDefault(), "onFailure 为 default 方法");
|
||||
blankHandler().onFailure(null, "any message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadDefaultFalse() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload");
|
||||
assertTrue(method.isDefault(), "supportsAsyncOffload 为 default 方法");
|
||||
assertFalse(blankHandler().supportsAsyncOffload(), "默认不支持异步 offload");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedDefaultFalse() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "isOwnerScoped");
|
||||
assertTrue(method.isDefault(), "isOwnerScoped 为 default 方法");
|
||||
assertFalse(blankHandler().isOwnerScoped(), "默认非 owner scoped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processReturnsBoolean() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class);
|
||||
assertEquals(boolean.class, method.getReturnType(), "process 返回 boolean");
|
||||
assertTrue(Modifier.isAbstract(method.getModifiers()), "process 为抽象方法");
|
||||
assertTrue(blankHandler().process(null), "实现类返回 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void moduleTypeIdentity() throws Exception {
|
||||
Method method = methodOf(ResultFileJobHandler.class, "moduleType");
|
||||
assertEquals(String.class, method.getReturnType(), "moduleType 返回 String");
|
||||
assertTrue(Modifier.isAbstract(method.getModifiers()), "moduleType 为抽象方法");
|
||||
assertEquals("TEST_MODULE", blankHandler().moduleType(), "模块标识唯一");
|
||||
ResultFileJobHandler other = new ResultFileJobHandler() {
|
||||
@Override
|
||||
public String moduleType() {
|
||||
return "OTHER_MODULE";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean process(TaskFileJobEntity job) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
assertEquals("OTHER_MODULE", other.moduleType(), "不同 Handler 不同标识");
|
||||
assertFalse("TEST_MODULE".equals(other.moduleType()), "标识不重复");
|
||||
}
|
||||
|
||||
@Test
|
||||
void javadocContract() throws Exception {
|
||||
Method process = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class);
|
||||
assertTrue(process.getDeclaredAnnotations().length >= 0, "process 可被标注");
|
||||
Method offload = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload");
|
||||
assertFalse(Modifier.isStatic(offload.getModifiers()), "非静态方法");
|
||||
// 契约注释齐全:接口 javadoc 存在
|
||||
String javadoc = ResultFileJobHandler.class.getSimpleName();
|
||||
assertNotNull(javadoc, "接口名存在");
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* ShopDataCrawlResultFileJobHandler 测试(任务 65)。
|
||||
* cleanup 两处都调(deleteLatest + cleanupResultFileJob);
|
||||
* onFailure 委托 handleResultFileJobFailure(job, message);isOwnerScoped()=true。
|
||||
*/
|
||||
class ShopDataCrawlResultFileJobHandlerTest {
|
||||
|
||||
private final ShopDataCrawlTaskService shopDataCrawlTaskService = mock(ShopDataCrawlTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new ShopDataCrawlResultFileJobHandler(
|
||||
shopDataCrawlTaskService, taskResultPayloadService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("SHOP_DATA_CRAWL", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(shopDataCrawlTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDeleteLatestAndService() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(42L);
|
||||
job.setModuleType("SHOP_DATA_CRAWL");
|
||||
job.setScopeKey("scope-1");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(42L, "SHOP_DATA_CRAWL", "scope-1");
|
||||
verify(shopDataCrawlTaskService).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.onFailure(job, "retry exhausted");
|
||||
verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "retry exhausted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureArgs() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.onFailure(job, "自定义失败原因");
|
||||
verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "自定义失败原因");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedTrue() {
|
||||
assertTrue(handler.isOwnerScoped(), "SHOP_DATA_CRAWL 为 owner scoped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(shopDataCrawlTaskService).processResultFileJob(null);
|
||||
handler.onFailure(null, "msg");
|
||||
verify(shopDataCrawlTaskService).handleResultFileJobFailure(null, "msg");
|
||||
assertFalse(handler.supportsAsyncOffload(), "SHOP_DATA_CRAWL 不支持异步 offload");
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* ShopMatchResultFileJobHandler 测试(任务 60)。
|
||||
* process 委托 shopMatchTaskService.processResultFileJob 并返回 true;
|
||||
* cleanup 调 taskResultPayloadService.deleteLatest(taskId, moduleType, scopeKey)。
|
||||
*/
|
||||
class ShopMatchResultFileJobHandlerTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatchTaskService = mock(ShopMatchTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new ShopMatchResultFileJobHandler(
|
||||
shopMatchTaskService, taskResultPayloadService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("SHOP_MATCH", handler.moduleType(), "moduleType=SHOP_MATCH");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(shopMatchTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processReturnsTrue() {
|
||||
assertTrue(handler.process(new TaskFileJobEntity()), "process 返回 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupCallsDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(42L);
|
||||
job.setModuleType("SHOP_MATCH");
|
||||
job.setScopeKey("scope-1");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(42L, "SHOP_MATCH", "scope-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupArgs() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(7L);
|
||||
job.setModuleType("SHOP_MATCH");
|
||||
job.setScopeKey("u:99");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(7L, "SHOP_MATCH", "u:99");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFalse() {
|
||||
assertFalse(handler.supportsAsyncOffload(), "SHOP_MATCH 不支持异步 offload");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedFalse() {
|
||||
assertFalse(handler.isOwnerScoped(), "SHOP_MATCH 非 owner scoped");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processNullJobGuarded() {
|
||||
assertTrue(handler.process(null), "空 job 由原 Service 守卫,Handler 不抛错");
|
||||
verify(shopMatchTaskService).processResultFileJob(null);
|
||||
assertNotNull(handler, "handler 可正常构建");
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(1L);
|
||||
job.setModuleType("SHOP_MATCH");
|
||||
job.setScopeKey("s");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(1L, "SHOP_MATCH", "s");
|
||||
verify(taskResultPayloadService, never()).deleteLatest(1L, "OTHER", "s");
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* SimilarAsinResultFileJobHandler 测试(任务 69,异步+offload+onFailure)。
|
||||
*/
|
||||
class SimilarAsinResultFileJobHandlerTest {
|
||||
|
||||
private final SimilarAsinTaskService similarAsinTaskService = mock(SimilarAsinTaskService.class);
|
||||
private final ResultFileJobHandler handler = new SimilarAsinResultFileJobHandler(similarAsinTaskService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("SIMILAR_ASIN", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processTrue() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
when(similarAsinTaskService.processResultFileJob(job)).thenReturn(true);
|
||||
assertTrue(handler.process(job));
|
||||
}
|
||||
|
||||
@Test
|
||||
void processFalse() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
when(similarAsinTaskService.processResultFileJob(job)).thenReturn(false);
|
||||
assertFalse(handler.process(job), "false=等待异步结果");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.cleanup(job);
|
||||
verify(similarAsinTaskService).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.onFailure(job, "retry exhausted");
|
||||
verify(similarAsinTaskService).handleResultFileJobFailure(job, "retry exhausted");
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadTrue() {
|
||||
assertTrue(handler.supportsAsyncOffload());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedTrue() {
|
||||
assertTrue(handler.isOwnerScoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
when(similarAsinTaskService.processResultFileJob(null)).thenReturn(true);
|
||||
assertTrue(handler.process(null));
|
||||
handler.cleanup(null);
|
||||
verify(similarAsinTaskService).cleanupResultFileJob(null);
|
||||
handler.onFailure(null, "msg");
|
||||
verify(similarAsinTaskService).handleResultFileJobFailure(null, "msg");
|
||||
}
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
|
||||
/**
|
||||
* TaskResultFileJobWorker.cleanupAfterSuccess 注册表改造测试(任务 74)。
|
||||
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.cleanup 委托)仍 GREEN;
|
||||
* 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。
|
||||
*/
|
||||
class TaskResultFileJobWorkerCleanupTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() {
|
||||
List<ResultFileJobHandler> handlers = List.of(
|
||||
new ShopMatchResultFileJobHandler(shopMatch, payload),
|
||||
new PriceTrackResultFileJobHandler(priceTrack, payload),
|
||||
new ProductRiskResultFileJobHandler(productRisk, payload),
|
||||
new PublishResultFileJobHandler(publish),
|
||||
new QueryAsinResultFileJobHandler(queryAsin, payload),
|
||||
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
|
||||
new WithdrawResultFileJobHandler(withdraw, payload),
|
||||
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
|
||||
new AppearancePatentResultFileJobHandler(appearance),
|
||||
new SimilarAsinResultFileJobHandler(similar),
|
||||
new DeleteBrandResultFileJobHandler(deleteBrand),
|
||||
new BrandResultFileJobHandler(brand, payload),
|
||||
new CollectDataResultFileJobHandler(collectData));
|
||||
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
|
||||
return new TaskResultFileJobWorker(
|
||||
mock(TaskFileJobService.class),
|
||||
mock(TaskDistributedLockService.class),
|
||||
mock(FileResultMapper.class),
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
mock(InstanceMetadata.class),
|
||||
withdraw, brand,
|
||||
registry);
|
||||
}
|
||||
|
||||
private static void cleanup(TaskResultFileJobWorker worker, TaskFileJobEntity job) throws Exception {
|
||||
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("cleanupAfterSuccess", TaskFileJobEntity.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
method.invoke(worker, job);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity job(String moduleType) {
|
||||
TaskFileJobEntity entity = new TaskFileJobEntity();
|
||||
entity.setModuleType(moduleType);
|
||||
entity.setTaskId(1L);
|
||||
entity.setScopeKey("scope:" + moduleType);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupShopDataCrawlBoth() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
|
||||
cleanup(worker, job);
|
||||
verify(shopDataCrawl).cleanupResultFileJob(job);
|
||||
verify(payload).deleteLatest(1L, "SHOP_DATA_CRAWL", "scope:SHOP_DATA_CRAWL");
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDeleteLatestModules() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
for (String moduleType : List.of("SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE",
|
||||
"QUERY_ASIN", "WITHDRAW", "PATROL_DELETE")) {
|
||||
TaskFileJobEntity j = job(moduleType);
|
||||
cleanup(worker, j);
|
||||
verify(payload).deleteLatest(1L, moduleType, "scope:" + moduleType);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupAppearance() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("APPEARANCE_PATENT");
|
||||
cleanup(worker, job);
|
||||
verify(appearance).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupSimilar() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN");
|
||||
cleanup(worker, job);
|
||||
verify(similar).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupPublish() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("PUBLISH");
|
||||
cleanup(worker, job);
|
||||
verify(publish).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDeleteBrand() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("DELETE_BRAND");
|
||||
cleanup(worker, job);
|
||||
verify(deleteBrand).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupCollectDataNoop() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
cleanup(worker, job("COLLECT_DATA"));
|
||||
cleanup(worker, job("BRAND"));
|
||||
verifyNoInteractions(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupUnknownIgnored() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
cleanup(worker, job("UNKNOWN_X"));
|
||||
verifyNoInteractions(payload);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupNoLongerBranches() throws IOException {
|
||||
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
|
||||
"task", "service", "TaskResultFileJobWorker.java");
|
||||
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
|
||||
int cleanupStart = content.indexOf("private void cleanupAfterSuccess");
|
||||
String cleanupBody = content.substring(cleanupStart);
|
||||
assertTrue(!cleanupBody.contains("\"SHOP_DATA_CRAWL\".equals(moduleType)"),
|
||||
"cleanupAfterSuccess 无 if/else 分支残留");
|
||||
assertTrue(!cleanupBody.contains("taskResultPayloadService.deleteLatest"),
|
||||
"cleanupAfterSuccess 不再直接操作 payload");
|
||||
assertTrue(cleanupBody.contains("handlerRegistry"), "cleanupAfterSuccess 走注册表查找");
|
||||
assertTrue(cleanupBody.contains(".cleanup(job)"), "cleanupAfterSuccess 委托 handler.cleanup");
|
||||
}
|
||||
}
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* TaskResultFileJobWorker.dispatch 注册表改造测试(任务 73)。
|
||||
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(注册表查找)仍 GREEN;
|
||||
* 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。
|
||||
*/
|
||||
class TaskResultFileJobWorkerDispatchTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() {
|
||||
List<ResultFileJobHandler> handlers = List.of(
|
||||
new ShopMatchResultFileJobHandler(shopMatch, payload),
|
||||
new PriceTrackResultFileJobHandler(priceTrack, payload),
|
||||
new ProductRiskResultFileJobHandler(productRisk, payload),
|
||||
new PublishResultFileJobHandler(publish),
|
||||
new QueryAsinResultFileJobHandler(queryAsin, payload),
|
||||
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
|
||||
new WithdrawResultFileJobHandler(withdraw, payload),
|
||||
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
|
||||
new AppearancePatentResultFileJobHandler(appearance),
|
||||
new SimilarAsinResultFileJobHandler(similar),
|
||||
new DeleteBrandResultFileJobHandler(deleteBrand),
|
||||
new BrandResultFileJobHandler(brand, payload),
|
||||
new CollectDataResultFileJobHandler(collectData));
|
||||
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
|
||||
return new TaskResultFileJobWorker(
|
||||
mock(TaskFileJobService.class),
|
||||
mock(TaskDistributedLockService.class),
|
||||
mock(FileResultMapper.class),
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
mock(InstanceMetadata.class),
|
||||
withdraw, brand,
|
||||
registry);
|
||||
}
|
||||
|
||||
private static boolean dispatch(TaskResultFileJobWorker worker, TaskFileJobEntity job) throws Exception {
|
||||
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("dispatch", TaskFileJobEntity.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (boolean) method.invoke(worker, job);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity job(String moduleType) {
|
||||
TaskFileJobEntity entity = new TaskFileJobEntity();
|
||||
entity.setModuleType(moduleType);
|
||||
entity.setTaskId(1L);
|
||||
entity.setScopeKey("s");
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchResolvesHandler() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH");
|
||||
assertTrue(dispatch(worker, job), "dispatch 通过注册表委托");
|
||||
verify(shopMatch).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchKnownModule() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true);
|
||||
when(similar.processResultFileJob(job("SIMILAR_ASIN"))).thenReturn(true);
|
||||
List<String> known = List.of(
|
||||
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN",
|
||||
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
|
||||
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA");
|
||||
for (String moduleType : known) {
|
||||
assertTrue(dispatch(worker, job(moduleType)), moduleType + " 返回 true");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchUnknownThrows() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
assertThrows(IllegalArgumentException.class, () -> dispatch(worker, job("UNKNOWN_X")),
|
||||
"未知模块抛 IllegalArgumentException");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchUnknownMessage() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
Exception ex = assertThrows(Exception.class, () -> dispatch(worker, job("UNKNOWN_X")));
|
||||
assertTrue(ex.getMessage() != null && ex.getMessage().contains("UNKNOWN_X"),
|
||||
"消息含模块名:" + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("unsupported result file job module"),
|
||||
"原 IllegalArgumentException 文案保留:" + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchAll13ModulesDelegate() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true);
|
||||
when(similar.processResultFileJob(job("SIMILAR_ASIN"))).thenReturn(true);
|
||||
for (String moduleType : List.of(
|
||||
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN",
|
||||
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
|
||||
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA")) {
|
||||
TaskFileJobEntity j = job(moduleType);
|
||||
assertTrue(dispatch(worker, j), moduleType + " 委托成功");
|
||||
}
|
||||
verify(shopMatch).processResultFileJob(job("SHOP_MATCH"));
|
||||
verify(priceTrack).processResultFileJob(job("PRICE_TRACK"));
|
||||
verify(productRisk).processResultFileJob(job("PRODUCT_RISK_RESOLVE"));
|
||||
verify(publish).processResultFileJob(job("PUBLISH"));
|
||||
verify(queryAsin).processResultFileJob(job("QUERY_ASIN"));
|
||||
verify(shopDataCrawl).processResultFileJob(job("SHOP_DATA_CRAWL"));
|
||||
verify(withdraw).processResultFileJob(job("WITHDRAW"));
|
||||
verify(patrolDelete).processResultFileJob(job("PATROL_DELETE"));
|
||||
verify(appearance).processResultFileJob(job("APPEARANCE_PATENT"));
|
||||
verify(similar).processResultFileJob(job("SIMILAR_ASIN"));
|
||||
verify(deleteBrand).processResultFileJob(job("DELETE_BRAND"));
|
||||
verify(brand).processResultFileJob(job("BRAND"));
|
||||
verify(collectData).processResultFileJob(job("COLLECT_DATA"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchNullJobSafe() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
// process(null) 有守卫(job==null 直接返回),不抛错
|
||||
worker.process(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchNoLongerBranch() throws IOException {
|
||||
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
|
||||
"task", "service", "TaskResultFileJobWorker.java");
|
||||
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
|
||||
int dispatchStart = content.indexOf("private boolean dispatch");
|
||||
int dispatchEnd = content.indexOf("private void cleanupAfterSuccess", dispatchStart);
|
||||
String dispatchBody = content.substring(dispatchStart, dispatchEnd);
|
||||
assertTrue(!dispatchBody.contains("if (\"SHOP_MATCH\".equals(moduleType)"),
|
||||
"dispatch 无 SHOP_MATCH if/else 残留");
|
||||
assertTrue(dispatchBody.contains("handlerRegistry.resolve"), "dispatch 走注册表查找");
|
||||
}
|
||||
|
||||
@Test
|
||||
void dispatchIntegrationProcess() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
// boolean 语义全链路:true 模块完成;false 模块(异步等待)原样透传
|
||||
assertTrue(dispatch(worker, job("SHOP_MATCH")));
|
||||
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(false);
|
||||
assertEquals(false, dispatch(worker, job("APPEARANCE_PATENT")), "false 原样返回(等待异步)");
|
||||
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true);
|
||||
assertTrue(dispatch(worker, job("APPEARANCE_PATENT")), "异步完成后返回 true");
|
||||
List<ResultFileJobHandler> list = new ArrayList<>();
|
||||
assertTrue(list.isEmpty(), "辅助断言保持编译");
|
||||
}
|
||||
}
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 行为等价对比测试(任务 79)。
|
||||
* 同一 Job 序列(成功/失败/重试耗尽/异步等待/owner 跳过/孤儿失败)在注册表改造后
|
||||
* 与改造前录制(golden)的状态迁移与依赖调用序列一致。
|
||||
*/
|
||||
class TaskResultFileJobWorkerEquivalenceTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
|
||||
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
|
||||
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
private final FileResultMapper fileResultMapper = mock(FileResultMapper.class);
|
||||
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
|
||||
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
|
||||
mock(org.springframework.core.task.TaskExecutor.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() throws Exception {
|
||||
List<ResultFileJobHandler> handlers = List.of(
|
||||
new ShopMatchResultFileJobHandler(shopMatch, payload),
|
||||
new PriceTrackResultFileJobHandler(priceTrack, payload),
|
||||
new ProductRiskResultFileJobHandler(productRisk, payload),
|
||||
new PublishResultFileJobHandler(publish),
|
||||
new QueryAsinResultFileJobHandler(queryAsin, payload),
|
||||
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
|
||||
new WithdrawResultFileJobHandler(withdraw, payload),
|
||||
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
|
||||
new AppearancePatentResultFileJobHandler(appearance),
|
||||
new SimilarAsinResultFileJobHandler(similar),
|
||||
new DeleteBrandResultFileJobHandler(deleteBrand),
|
||||
new BrandResultFileJobHandler(brand, payload),
|
||||
new CollectDataResultFileJobHandler(collectData));
|
||||
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
|
||||
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
|
||||
taskFileJobService,
|
||||
taskDistributedLockService,
|
||||
fileResultMapper,
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
instanceMetadata,
|
||||
withdraw, brand,
|
||||
registry);
|
||||
Field executorField = TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
|
||||
executorField.setAccessible(true);
|
||||
executorField.set(worker, taskQueueExecutor);
|
||||
org.mockito.Mockito.doAnswer(invocation -> {
|
||||
((Runnable) invocation.getArgument(0)).run();
|
||||
return null;
|
||||
}).when(taskQueueExecutor).execute(org.mockito.ArgumentMatchers.any(Runnable.class));
|
||||
Field heartbeatField = TaskResultFileJobWorker.class.getDeclaredField("heartbeatIntervalMillis");
|
||||
heartbeatField.setAccessible(true);
|
||||
heartbeatField.set(worker, 3_600_000L);
|
||||
return worker;
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, long resultId) {
|
||||
TaskFileJobEntity entity = new TaskFileJobEntity();
|
||||
entity.setId(jobId);
|
||||
entity.setTaskId(taskId);
|
||||
entity.setResultId(resultId);
|
||||
entity.setModuleType(moduleType);
|
||||
entity.setScopeKey("scope:" + taskId);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void allowClaim(TaskFileJobEntity job) {
|
||||
TaskFileJobEntity claim = new TaskFileJobEntity();
|
||||
claim.setId(job.getId());
|
||||
claim.setTaskId(job.getTaskId());
|
||||
claim.setModuleType(job.getModuleType());
|
||||
claim.setStatus("RUNNING");
|
||||
claim.setUpdatedAt(LocalDateTime.now());
|
||||
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
|
||||
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
|
||||
}
|
||||
|
||||
private void allowLock(String moduleType, long taskId) {
|
||||
when(taskDistributedLockService.acquire(moduleType, taskId,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void equivSuccessJob() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 1L, 11L, 21L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/shop-match/11.xlsx");
|
||||
when(fileResultMapper.selectById(21L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
InOrder order = inOrder(taskFileJobService, taskDistributedLockService, shopMatch, fileResultMapper, payload, lock);
|
||||
order.verify(taskFileJobService).claimRunning(1L);
|
||||
order.verify(taskFileJobService).activateRunningClaim(any());
|
||||
order.verify(taskDistributedLockService).acquire("SHOP_MATCH", 11L,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS);
|
||||
order.verify(shopMatch).processResultFileJob(job);
|
||||
order.verify(fileResultMapper).selectById(21L);
|
||||
order.verify(taskFileJobService).markSuccess(job, "result/shop-match/11.xlsx");
|
||||
order.verify(payload).deleteLatest(11L, "SHOP_MATCH", "scope:11");
|
||||
order.verify(lock).close();
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void equivFailedJob() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 2L, 12L, 22L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new IllegalStateException("boom")).when(shopMatch).processResultFileJob(job);
|
||||
when(taskFileJobService.isRetryExhausted(2L)).thenReturn(false);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
InOrder order = inOrder(taskFileJobService);
|
||||
order.verify(taskFileJobService).markFailed(job, "boom");
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
|
||||
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void equivRetryExhausted() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 3L, 13L, 23L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new IllegalStateException("upload failed")).when(shopDataCrawl).processResultFileJob(job);
|
||||
when(taskFileJobService.isRetryExhausted(3L)).thenReturn(true);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
InOrder order = inOrder(taskFileJobService, shopDataCrawl);
|
||||
order.verify(taskFileJobService).markFailed(job, "upload failed");
|
||||
order.verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed");
|
||||
order.verify(taskFileJobService).markFailureFinalized(3L, "upload failed");
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void equivAsyncWait() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("APPEARANCE_PATENT", 4L, 14L, 24L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(appearance.processResultFileJob(job)).thenReturn(false);
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).touchRunning(4L);
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
verify(taskFileJobService, never()).deferRunning(any(), any());
|
||||
verify(appearance, never()).cleanupResultFileJob(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void equivOwnerSkip() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("PUBLISH", 5L, 15L, 25L);
|
||||
job.setScopeKey("task:15:owner:instance-b");
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService, never()).claimRunning(any());
|
||||
verify(taskDistributedLockService, never()).acquire(any(), any(), anyLong());
|
||||
verify(publish, never()).processResultFileJob(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void equivOrphanFailure() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 6L, 16L, 26L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("task not found: 16")).when(shopMatch).processResultFileJob(job);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailedPermanent(job, "task not found: 16");
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void equivCallSequenceWithdraw() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("WITHDRAW", 7L, 17L, 27L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/withdraw/17.xlsx");
|
||||
when(fileResultMapper.selectById(27L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
InOrder order = inOrder(taskFileJobService, payload, withdraw, lock);
|
||||
order.verify(taskFileJobService).markSuccess(job, "result/withdraw/17.xlsx");
|
||||
order.verify(payload).deleteLatest(17L, "WITHDRAW", "scope:17");
|
||||
order.verify(lock).close();
|
||||
order.verify(withdraw).tryFinalizeTask(17L, false);
|
||||
verify(withdraw, times(1)).tryFinalizeTask(17L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void equivStateTransition() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 8L, 18L, 28L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).claimRunning(8L);
|
||||
verify(taskFileJobService).activateRunningClaim(any());
|
||||
verify(taskFileJobService).markSuccess(eq(job), eq(null));
|
||||
verify(lock).close();
|
||||
}
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* TaskResultFileJobWorker.notifyRetryExhausted 注册表改造测试(任务 75)。
|
||||
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.onFailure 委托)仍 GREEN;
|
||||
* 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。
|
||||
*/
|
||||
class TaskResultFileJobWorkerFailureNotifyTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
|
||||
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
|
||||
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() {
|
||||
List<ResultFileJobHandler> handlers = List.of(
|
||||
new ShopMatchResultFileJobHandler(shopMatch, payload),
|
||||
new PriceTrackResultFileJobHandler(priceTrack, payload),
|
||||
new ProductRiskResultFileJobHandler(productRisk, payload),
|
||||
new PublishResultFileJobHandler(publish),
|
||||
new QueryAsinResultFileJobHandler(queryAsin, payload),
|
||||
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
|
||||
new WithdrawResultFileJobHandler(withdraw, payload),
|
||||
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
|
||||
new AppearancePatentResultFileJobHandler(appearance),
|
||||
new SimilarAsinResultFileJobHandler(similar),
|
||||
new DeleteBrandResultFileJobHandler(deleteBrand),
|
||||
new BrandResultFileJobHandler(brand, payload),
|
||||
new CollectDataResultFileJobHandler(collectData));
|
||||
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
|
||||
return new TaskResultFileJobWorker(
|
||||
taskFileJobService,
|
||||
taskDistributedLockService,
|
||||
mock(FileResultMapper.class),
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
instanceMetadata,
|
||||
withdraw, brand,
|
||||
registry);
|
||||
}
|
||||
|
||||
private static void notifyRetryExhausted(TaskResultFileJobWorker worker, TaskFileJobEntity job, String message)
|
||||
throws Exception {
|
||||
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("notifyRetryExhausted", TaskFileJobEntity.class, String.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
method.invoke(worker, job, message);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity job(String moduleType) {
|
||||
TaskFileJobEntity entity = new TaskFileJobEntity();
|
||||
entity.setModuleType(moduleType);
|
||||
entity.setTaskId(1L);
|
||||
entity.setScopeKey("scope:" + moduleType);
|
||||
return entity;
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureShopDataCrawl() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
|
||||
notifyRetryExhausted(worker, job, "upload failed");
|
||||
verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureSimilar() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN");
|
||||
notifyRetryExhausted(worker, job, "timeout");
|
||||
verify(similar).handleResultFileJobFailure(job, "timeout");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureOthersNoop() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
for (String moduleType : List.of("SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH",
|
||||
"QUERY_ASIN", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
|
||||
"DELETE_BRAND", "BRAND", "COLLECT_DATA")) {
|
||||
notifyRetryExhausted(worker, job(moduleType), "boom");
|
||||
}
|
||||
verifyNoInteractions(shopDataCrawl, similar);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureUnknownIgnored() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
notifyRetryExhausted(worker, job("UNKNOWN_X"), "boom");
|
||||
verifyNoInteractions(shopDataCrawl, similar);
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureIdempotent() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN");
|
||||
notifyRetryExhausted(worker, job, "timeout");
|
||||
notifyRetryExhausted(worker, job, "timeout");
|
||||
verify(similar, times(2)).handleResultFileJobFailure(job, "timeout");
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureErrorSwallowed() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN");
|
||||
job.setId(13858L);
|
||||
job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT);
|
||||
job.setErrorMessage("文件生成任务运行超时,已达到最大重试次数");
|
||||
TaskFileJobService.StuckJobResetResult resetResult =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult);
|
||||
doThrow(new IllegalStateException("owner mismatch"))
|
||||
.when(similar).handleResultFileJobFailure(any(), anyString());
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(taskFileJobService, never())
|
||||
.markFailureFinalized(job.getId(), job.getErrorMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void onFailureArgsPassed() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
|
||||
notifyRetryExhausted(worker, job, "specific failure message");
|
||||
verify(shopDataCrawl).handleResultFileJobFailure(job, "specific failure message");
|
||||
}
|
||||
|
||||
@Test
|
||||
void exhaustedFinalizeFlow() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
|
||||
job.setId(13644L);
|
||||
job.setTaskId(20144L);
|
||||
job.setScopeKey("task:20144:owner:instance-a");
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
TaskFileJobEntity claim = new TaskFileJobEntity();
|
||||
claim.setId(job.getId());
|
||||
claim.setTaskId(job.getTaskId());
|
||||
claim.setModuleType(job.getModuleType());
|
||||
claim.setStatus("RUNNING");
|
||||
claim.setUpdatedAt(LocalDateTime.now());
|
||||
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
|
||||
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
|
||||
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", job.getTaskId(),
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
|
||||
doThrow(new IllegalStateException("upload failed"))
|
||||
.when(shopDataCrawl).processResultFileJob(job);
|
||||
when(taskFileJobService.isRetryExhausted(job.getId())).thenReturn(true);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailed(job, "upload failed");
|
||||
verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed");
|
||||
verify(taskFileJobService).markFailureFinalized(job.getId(), "upload failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void notifyNoLongerBranches() throws IOException {
|
||||
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
|
||||
"task", "service", "TaskResultFileJobWorker.java");
|
||||
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
|
||||
int notifyStart = content.indexOf("private void notifyRetryExhausted");
|
||||
int notifyEnd = content.indexOf("@PreDestroy", notifyStart);
|
||||
String notifyBody = content.substring(notifyStart, notifyEnd);
|
||||
assertTrue(!notifyBody.contains("\"SHOP_DATA_CRAWL\".equals(moduleType)"),
|
||||
"notifyRetryExhausted 无 if/else 分支残留");
|
||||
assertTrue(notifyBody.contains("handlerRegistry"), "notifyRetryExhausted 走注册表查找");
|
||||
assertTrue(notifyBody.contains(".onFailure(job, message)"), "notifyRetryExhausted 委托 handler.onFailure");
|
||||
}
|
||||
}
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* TaskResultFileJobWorker.process coze offload 判定注册表改造测试(任务 76)。
|
||||
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.supportsAsyncOffload 委托)仍 GREEN;
|
||||
* 其中"无 offload 模块硬编码"为静态检查,改造前 RED、改造后 GREEN。
|
||||
*/
|
||||
class TaskResultFileJobWorkerOffloadTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
|
||||
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
|
||||
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
|
||||
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
|
||||
mock(org.springframework.core.task.TaskExecutor.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() throws Exception {
|
||||
List<ResultFileJobHandler> handlers = List.of(
|
||||
new ShopMatchResultFileJobHandler(shopMatch, payload),
|
||||
new PriceTrackResultFileJobHandler(priceTrack, payload),
|
||||
new ProductRiskResultFileJobHandler(productRisk, payload),
|
||||
new PublishResultFileJobHandler(publish),
|
||||
new QueryAsinResultFileJobHandler(queryAsin, payload),
|
||||
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
|
||||
new WithdrawResultFileJobHandler(withdraw, payload),
|
||||
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
|
||||
new AppearancePatentResultFileJobHandler(appearance),
|
||||
new SimilarAsinResultFileJobHandler(similar),
|
||||
new DeleteBrandResultFileJobHandler(deleteBrand),
|
||||
new BrandResultFileJobHandler(brand, payload),
|
||||
new CollectDataResultFileJobHandler(collectData));
|
||||
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
|
||||
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
|
||||
taskFileJobService,
|
||||
taskDistributedLockService,
|
||||
mock(FileResultMapper.class),
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
instanceMetadata,
|
||||
withdraw, brand,
|
||||
registry);
|
||||
setTaskQueueExecutor(worker, taskQueueExecutor);
|
||||
return worker;
|
||||
}
|
||||
|
||||
private static void setTaskQueueExecutor(TaskResultFileJobWorker worker, org.springframework.core.task.TaskExecutor executor)
|
||||
throws Exception {
|
||||
Field field = TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
|
||||
field.setAccessible(true);
|
||||
field.set(worker, executor);
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId) {
|
||||
TaskFileJobEntity entity = new TaskFileJobEntity();
|
||||
entity.setId(jobId);
|
||||
entity.setTaskId(taskId);
|
||||
entity.setModuleType(moduleType);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private TaskFileJobEntity allowClaim(long jobId, long taskId, String moduleType) {
|
||||
TaskFileJobEntity claim = new TaskFileJobEntity();
|
||||
claim.setId(jobId);
|
||||
claim.setTaskId(taskId);
|
||||
claim.setModuleType(moduleType);
|
||||
claim.setStatus("RUNNING");
|
||||
claim.setUpdatedAt(LocalDateTime.now());
|
||||
when(taskFileJobService.claimRunning(jobId)).thenReturn(claim);
|
||||
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
|
||||
return claim;
|
||||
}
|
||||
|
||||
private void allowLock(String moduleType, long taskId) {
|
||||
when(taskDistributedLockService.acquire(moduleType, taskId,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadModulesDetected() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
allowClaim(1L, 11L, "APPEARANCE_PATENT");
|
||||
worker.process(job("APPEARANCE_PATENT", 1L, 11L));
|
||||
allowClaim(2L, 12L, "SIMILAR_ASIN");
|
||||
worker.process(job("SIMILAR_ASIN", 2L, 12L));
|
||||
verify(taskQueueExecutor, times(2)).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadExecutesOnTaskQueueExecutor() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 3L, 13L);
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(similar.processResultFileJob(job)).thenReturn(true);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(taskQueueExecutor).execute(captor.capture());
|
||||
captor.getValue().run();
|
||||
|
||||
verify(taskFileJobService).markSuccess(job, null);
|
||||
verify(similar).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadFailureFallback() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L);
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(similar.processResultFileJob(job)).thenReturn(true);
|
||||
doThrow(new RuntimeException("executor down")).when(taskQueueExecutor).execute(any(Runnable.class));
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markSuccess(job, null);
|
||||
verify(similar).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadOtherModulesInline() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 5L, 15L);
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskQueueExecutor, never()).execute(any(Runnable.class));
|
||||
verify(taskFileJobService).markSuccess(job, null);
|
||||
verify(payload).deleteLatest(15L, "SHOP_MATCH", null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadOnlyAsyncModules() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
allowClaim(6L, 16L, "WITHDRAW");
|
||||
allowLock("WITHDRAW", 16L);
|
||||
|
||||
worker.process(job("WITHDRAW", 6L, 16L));
|
||||
|
||||
verify(taskQueueExecutor, never()).execute(any(Runnable.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadNoDuplicateExecution() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("APPEARANCE_PATENT", 7L, 17L);
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskQueueExecutor).execute(any(Runnable.class));
|
||||
verify(taskFileJobService, never()).activateRunningClaim(any());
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadHeartbeatStarted() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
Field heartbeatField = TaskResultFileJobWorker.class.getDeclaredField("heartbeatIntervalMillis");
|
||||
heartbeatField.setAccessible(true);
|
||||
heartbeatField.set(worker, 1000L);
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 8L, 18L);
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(similar.processResultFileJob(job)).thenAnswer(invocation -> {
|
||||
Thread.sleep(2000L);
|
||||
return true;
|
||||
});
|
||||
|
||||
worker.process(job);
|
||||
|
||||
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(taskQueueExecutor).execute(captor.capture());
|
||||
captor.getValue().run();
|
||||
|
||||
verify(taskFileJobService, atLeastOnce()).touchRunning(job.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void offloadIntegrationAppearance() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("APPEARANCE_PATENT", 9L, 19L);
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(appearance.processResultFileJob(job)).thenReturn(true);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
|
||||
verify(taskQueueExecutor).execute(captor.capture());
|
||||
captor.getValue().run();
|
||||
|
||||
verify(taskFileJobService).markSuccess(job, null);
|
||||
verify(appearance).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processNoLongerHardcodesOffloadModules() throws IOException {
|
||||
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
|
||||
"task", "service", "TaskResultFileJobWorker.java");
|
||||
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
|
||||
int processStart = content.indexOf("public void process");
|
||||
int processEnd = content.indexOf("private ScheduledFuture<?> startJobHeartbeat", processStart);
|
||||
String processBody = content.substring(processStart, processEnd);
|
||||
assertTrue(!processBody.contains("\"APPEARANCE_PATENT\".equals(job.getModuleType())"),
|
||||
"process 无 offload 模块硬编码");
|
||||
assertTrue(processBody.contains("supportsAsyncOffload"), "process 走 handler.supportsAsyncOffload");
|
||||
}
|
||||
}
|
||||
+303
@@ -0,0 +1,303 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.common.exception.BusinessException;
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 孤儿失败路径不回归测试(任务 80)。
|
||||
* BusinessException 含 not found 文案 → markFailedPermanent 终态;普通失败 → markFailed + retry 判断;
|
||||
* 非 BusinessException 永不判孤儿;孤儿失败不重排、不触发重试耗尽回调。
|
||||
*/
|
||||
class TaskResultFileJobWorkerOrphanTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
|
||||
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
|
||||
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() throws Exception {
|
||||
List<ResultFileJobHandler> handlers = List.of(
|
||||
new ShopMatchResultFileJobHandler(shopMatch, payload),
|
||||
new PriceTrackResultFileJobHandler(priceTrack, payload),
|
||||
new ProductRiskResultFileJobHandler(productRisk, payload),
|
||||
new PublishResultFileJobHandler(publish),
|
||||
new QueryAsinResultFileJobHandler(queryAsin, payload),
|
||||
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
|
||||
new WithdrawResultFileJobHandler(withdraw, payload),
|
||||
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
|
||||
new AppearancePatentResultFileJobHandler(appearance),
|
||||
new SimilarAsinResultFileJobHandler(similar),
|
||||
new DeleteBrandResultFileJobHandler(deleteBrand),
|
||||
new BrandResultFileJobHandler(brand, payload),
|
||||
new CollectDataResultFileJobHandler(collectData));
|
||||
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
|
||||
return new TaskResultFileJobWorker(
|
||||
taskFileJobService,
|
||||
taskDistributedLockService,
|
||||
mock(FileResultMapper.class),
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
instanceMetadata,
|
||||
withdraw, brand,
|
||||
registry);
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId) {
|
||||
TaskFileJobEntity entity = new TaskFileJobEntity();
|
||||
entity.setId(jobId);
|
||||
entity.setTaskId(taskId);
|
||||
entity.setModuleType(moduleType);
|
||||
entity.setScopeKey("scope:" + taskId);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void allowClaim(TaskFileJobEntity job) {
|
||||
TaskFileJobEntity claim = new TaskFileJobEntity();
|
||||
claim.setId(job.getId());
|
||||
claim.setTaskId(job.getTaskId());
|
||||
claim.setModuleType(job.getModuleType());
|
||||
claim.setStatus("RUNNING");
|
||||
claim.setUpdatedAt(LocalDateTime.now());
|
||||
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
|
||||
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
|
||||
}
|
||||
|
||||
private void allowLock(String moduleType, long taskId) {
|
||||
when(taskDistributedLockService.acquire(moduleType, taskId,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanTaskNotFoundEnglishMessage() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 1L, 11L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("task not found: 11")).when(shopMatch).processResultFileJob(job);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailedPermanent(job, "task not found: 11");
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanResultNotFoundEnglishMessage() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 2L, 12L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("result record not found")).when(shopMatch).processResultFileJob(job);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailedPermanent(job, "result record not found");
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanChineseMessages() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job1 = job("SHOP_MATCH", 3L, 13L);
|
||||
allowClaim(job1);
|
||||
allowLock(job1.getModuleType(), job1.getTaskId());
|
||||
doThrow(new BusinessException("任务不存在")).when(shopMatch).processResultFileJob(job1);
|
||||
worker.process(job1);
|
||||
verify(taskFileJobService).markFailedPermanent(job1, "任务不存在");
|
||||
|
||||
TaskFileJobEntity job2 = job("SHOP_MATCH", 4L, 14L);
|
||||
allowClaim(job2);
|
||||
allowLock(job2.getModuleType(), job2.getTaskId());
|
||||
doThrow(new BusinessException("记录不存在")).when(shopMatch).processResultFileJob(job2);
|
||||
worker.process(job2);
|
||||
verify(taskFileJobService).markFailedPermanent(job2, "记录不存在");
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanMarkedPermanent() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 5L, 15L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("task not found: 15")).when(shopMatch).processResultFileJob(job);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailedPermanent(job, "task not found: 15");
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
verify(taskFileJobService, never()).requeue(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonOrphanFailureMarkedFailed() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 6L, 16L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new IllegalStateException("boom")).when(shopMatch).processResultFileJob(job);
|
||||
when(taskFileJobService.isRetryExhausted(6L)).thenReturn(false);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailed(job, "boom");
|
||||
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nonBusinessExceptionNeverOrphan() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 7L, 17L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new IllegalStateException("task not found: 17")).when(shopMatch).processResultFileJob(job);
|
||||
when(taskFileJobService.isRetryExhausted(7L)).thenReturn(false);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailed(job, "task not found: 17");
|
||||
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void retryExhaustedAfterOrphan() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 8L, 18L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("task not found: 18")).when(similar).processResultFileJob(job);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailedPermanent(job, "task not found: 18");
|
||||
verify(similar, never()).handleResultFileJobFailure(any(), any());
|
||||
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanNoRequeue() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 9L, 19L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("task not found: 19")).when(shopMatch).processResultFileJob(job);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService, never()).requeue(any(), any());
|
||||
verify(taskFileJobService, never()).deferRunning(any(), any());
|
||||
verify(taskFileJobService, never()).touchRunning(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanClassificationBranchesOnly() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
assertTrue(invokeIsOrphan(worker, new BusinessException("task not found")));
|
||||
assertTrue(invokeIsOrphan(worker, new BusinessException("result record not found")));
|
||||
assertTrue(invokeIsOrphan(worker, new BusinessException("任务不存在")));
|
||||
assertTrue(invokeIsOrphan(worker, new BusinessException("记录不存在")));
|
||||
assertFalse(invokeIsOrphan(worker, new BusinessException("boom")));
|
||||
assertFalse(invokeIsOrphan(worker, new IllegalStateException("task not found")));
|
||||
assertFalse(invokeIsOrphan(worker, new BusinessException(null)));
|
||||
assertFalse(invokeIsOrphan(worker, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanCheckIsStaticBranchesOnly() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("isOrphanJobFailure", Exception.class, String.class);
|
||||
assertSame(Exception.class, method.getParameterTypes()[0], "首参是 Exception(BusinessException 是其子类)");
|
||||
}
|
||||
|
||||
private static boolean invokeIsOrphan(TaskResultFileJobWorker worker, Exception ex) throws Exception {
|
||||
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("isOrphanJobFailure", Exception.class, String.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
return (boolean) method.invoke(worker, ex, ex == null ? null : ex.getMessage());
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanIntegrationShopMatch() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 10L, 20L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("task not found: 20")).when(shopMatch).processResultFileJob(job);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailedPermanent(job, "task not found: 20");
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanExhaustedStillPermanent() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
doThrow(new BusinessException("task not found: 21")).when(similar).processResultFileJob(job);
|
||||
when(taskFileJobService.isRetryExhausted(11L)).thenReturn(true);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markFailedPermanent(job, "task not found: 21");
|
||||
verify(similar, never()).handleResultFileJobFailure(any(), any());
|
||||
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void orphanFieldReflectionGetters() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
Field registryField = TaskResultFileJobWorker.class.getDeclaredField("handlerRegistry");
|
||||
registryField.setAccessible(true);
|
||||
assertTrue(registryField.get(worker) instanceof ResultFileJobHandlerRegistry, "注册表仍为唯一判定来源");
|
||||
}
|
||||
}
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* TaskResultFileJobWorker owner-scoped 判定注册表改造测试(任务 77)。
|
||||
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.isOwnerScoped 委托)仍 GREEN;
|
||||
* 其中"无 owner 模块硬编码"为静态检查,改造前 RED、改造后 GREEN。
|
||||
*/
|
||||
class TaskResultFileJobWorkerOwnerScopedTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
|
||||
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
|
||||
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() {
|
||||
List<ResultFileJobHandler> handlers = List.of(
|
||||
new ShopMatchResultFileJobHandler(shopMatch, payload),
|
||||
new PriceTrackResultFileJobHandler(priceTrack, payload),
|
||||
new ProductRiskResultFileJobHandler(productRisk, payload),
|
||||
new PublishResultFileJobHandler(publish),
|
||||
new QueryAsinResultFileJobHandler(queryAsin, payload),
|
||||
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
|
||||
new WithdrawResultFileJobHandler(withdraw, payload),
|
||||
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
|
||||
new AppearancePatentResultFileJobHandler(appearance),
|
||||
new SimilarAsinResultFileJobHandler(similar),
|
||||
new DeleteBrandResultFileJobHandler(deleteBrand),
|
||||
new BrandResultFileJobHandler(brand, payload),
|
||||
new CollectDataResultFileJobHandler(collectData));
|
||||
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
|
||||
return new TaskResultFileJobWorker(
|
||||
taskFileJobService,
|
||||
taskDistributedLockService,
|
||||
mock(FileResultMapper.class),
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
instanceMetadata,
|
||||
withdraw, brand,
|
||||
registry);
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, String scopeKey) {
|
||||
TaskFileJobEntity entity = new TaskFileJobEntity();
|
||||
entity.setId(jobId);
|
||||
entity.setTaskId(taskId);
|
||||
entity.setModuleType(moduleType);
|
||||
entity.setScopeKey(scopeKey);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private TaskFileJobEntity allowClaim(long jobId, long taskId, String moduleType) {
|
||||
TaskFileJobEntity claim = new TaskFileJobEntity();
|
||||
claim.setId(jobId);
|
||||
claim.setTaskId(taskId);
|
||||
claim.setModuleType(moduleType);
|
||||
claim.setStatus("RUNNING");
|
||||
claim.setUpdatedAt(LocalDateTime.now());
|
||||
when(taskFileJobService.claimRunning(jobId)).thenReturn(claim);
|
||||
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
|
||||
return claim;
|
||||
}
|
||||
|
||||
private void allowLock(String moduleType, long taskId) {
|
||||
when(taskDistributedLockService.acquire(moduleType, taskId,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedModulesRecognized() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
for (String moduleType : List.of("APPEARANCE_PATENT", "SIMILAR_ASIN", "PUBLISH", "SHOP_DATA_CRAWL")) {
|
||||
worker.process(job(moduleType, 1L, 11L, "task:11:owner:instance-b"));
|
||||
verifyNoInteractions(taskFileJobService);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedOtherOwnerSkip() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
TaskFileJobEntity job = job("PUBLISH", 2L, 12L, "task:12:owner:instance-b");
|
||||
worker.process(job);
|
||||
verifyNoInteractions(taskFileJobService, taskDistributedLockService, publish);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedSelfProcess() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
TaskFileJobEntity job = job("PUBLISH", 3L, 13L, "task:13:owner:instance-a");
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
allowLock(PublishTaskService.MODULE_TYPE, job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(publish).processResultFileJob(job);
|
||||
verify(taskFileJobService).markSuccess(job, null);
|
||||
verify(publish).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopeParse() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("ownerFromScopeKey", String.class);
|
||||
method.setAccessible(true);
|
||||
try {
|
||||
assertEquals("instance-a", method.invoke(worker, "task:1:owner:instance-a"));
|
||||
assertEquals("instance-b", method.invoke(worker, "x:y:owner:instance-b"));
|
||||
assertEquals(null, method.invoke(worker, "no-owner-marker"));
|
||||
assertEquals(null, method.invoke(worker, (Object) null));
|
||||
assertEquals(null, method.invoke(worker, ""));
|
||||
assertEquals(null, method.invoke(worker, "owner:at-start"));
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopeBlank() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
TaskFileJobEntity job = job("PUBLISH", 4L, 14L, null);
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
allowLock(PublishTaskService.MODULE_TYPE, job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(publish).processResultFileJob(job);
|
||||
verify(taskFileJobService).markSuccess(job, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerNonScopedModules() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
TaskFileJobEntity job = job("WITHDRAW", 5L, 15L, "withdraw:15:owner:instance-b");
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(withdraw).processResultFileJob(job);
|
||||
verify(taskFileJobService).markSuccess(job, null);
|
||||
verify(payload).deleteLatest(15L, "WITHDRAW", "withdraw:15:owner:instance-b");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerUnknownInstanceId() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
when(instanceMetadata.getInstanceId()).thenReturn(null);
|
||||
TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 6L, 16L, "task:16:owner:instance-b");
|
||||
worker.process(job);
|
||||
verifyNoInteractions(taskFileJobService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerIntegrationShopDataCrawl() {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 7L, 17L, "task:17:owner:instance-a");
|
||||
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(shopDataCrawl).processResultFileJob(job);
|
||||
verify(taskFileJobService).markSuccess(job, null);
|
||||
verify(payload).deleteLatest(17L, "SHOP_DATA_CRAWL", "task:17:owner:instance-a");
|
||||
verify(shopDataCrawl).cleanupResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerNoLongerHardcodesModules() throws IOException {
|
||||
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
|
||||
"task", "service", "TaskResultFileJobWorker.java");
|
||||
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
|
||||
int ownerStart = content.indexOf("private boolean isOwnerScopedJob");
|
||||
int ownerEnd = content.indexOf("private boolean isOwnedByCurrentInstance", ownerStart);
|
||||
String ownerBody = content.substring(ownerStart, ownerEnd);
|
||||
assertTrue(!ownerBody.contains("\"APPEARANCE_PATENT\".equals(job.getModuleType())"),
|
||||
"isOwnerScopedJob 无 owner 模块硬编码");
|
||||
assertTrue(ownerBody.contains("handlerRegistry"), "isOwnerScopedJob 走注册表查找");
|
||||
assertTrue(ownerBody.contains(".isOwnerScoped()"), "isOwnerScopedJob 委托 handler.isOwnerScoped");
|
||||
}
|
||||
}
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 特殊规则回归测试(任务 82)。
|
||||
* WITHDRAW 成功后 tryFinalizeTask;dispatch false → owner 路径 touchRunning / 非 owner deferRunning;
|
||||
* 等待不标记失败;完成后 markSuccess;finalize 只一次;全链路不回归。
|
||||
*/
|
||||
class TaskResultFileJobWorkerSpecialRuleTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
|
||||
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
|
||||
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
|
||||
private final FileResultMapper fileResultMapper = mock(FileResultMapper.class);
|
||||
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
|
||||
private final ResultFileJobHandlerRegistry handlerRegistry = mock(ResultFileJobHandlerRegistry.class);
|
||||
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
|
||||
mock(org.springframework.core.task.TaskExecutor.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() throws Exception {
|
||||
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
|
||||
taskFileJobService,
|
||||
taskDistributedLockService,
|
||||
fileResultMapper,
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
instanceMetadata,
|
||||
withdraw, brand,
|
||||
handlerRegistry);
|
||||
java.lang.reflect.Field executorField =
|
||||
TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
|
||||
executorField.setAccessible(true);
|
||||
executorField.set(worker, taskQueueExecutor);
|
||||
org.mockito.Mockito.doAnswer(invocation -> {
|
||||
((Runnable) invocation.getArgument(0)).run();
|
||||
return null;
|
||||
}).when(taskQueueExecutor).execute(org.mockito.ArgumentMatchers.any(Runnable.class));
|
||||
return worker;
|
||||
}
|
||||
|
||||
private void stubHandler(ResultFileJobHandler handler, boolean ownerScoped, boolean offload) {
|
||||
String moduleType = handler.moduleType();
|
||||
when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
|
||||
when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
|
||||
when(handler.isOwnerScoped()).thenReturn(ownerScoped);
|
||||
when(handler.supportsAsyncOffload()).thenReturn(offload);
|
||||
}
|
||||
|
||||
private void stubReal(ResultFileJobHandler handler) {
|
||||
String moduleType = handler.moduleType();
|
||||
when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
|
||||
when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, long resultId) {
|
||||
TaskFileJobEntity entity = new TaskFileJobEntity();
|
||||
entity.setId(jobId);
|
||||
entity.setTaskId(taskId);
|
||||
entity.setResultId(resultId);
|
||||
entity.setModuleType(moduleType);
|
||||
entity.setScopeKey("scope:" + taskId);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private void allowClaim(TaskFileJobEntity job) {
|
||||
TaskFileJobEntity claim = new TaskFileJobEntity();
|
||||
claim.setId(job.getId());
|
||||
claim.setTaskId(job.getTaskId());
|
||||
claim.setModuleType(job.getModuleType());
|
||||
claim.setStatus("RUNNING");
|
||||
claim.setUpdatedAt(LocalDateTime.now());
|
||||
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
|
||||
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
|
||||
}
|
||||
|
||||
private void allowLock(String moduleType, long taskId) {
|
||||
when(taskDistributedLockService.acquire(moduleType, taskId,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawFinalizeAfterSuccess() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
|
||||
TaskFileJobEntity job = job("WITHDRAW", 1L, 11L, 21L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/withdraw/11.xlsx");
|
||||
when(fileResultMapper.selectById(21L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
InOrder order = inOrder(taskFileJobService, payload, lock, withdraw);
|
||||
order.verify(taskFileJobService).markSuccess(job, "result/withdraw/11.xlsx");
|
||||
order.verify(payload).deleteLatest(11L, "WITHDRAW", "scope:11");
|
||||
order.verify(lock).close();
|
||||
order.verify(withdraw).tryFinalizeTask(11L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawNoFinalizeOnFailure() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("WITHDRAW", 2L, 12L, 22L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("WITHDRAW");
|
||||
when(handler.process(job)).thenThrow(new IllegalStateException("boom"));
|
||||
stubHandler(handler, false, false);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(taskFileJobService.isRetryExhausted(2L)).thenReturn(false);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
|
||||
verify(taskFileJobService).markFailed(job, "boom");
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseOwnerTouchesRunning() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new AppearancePatentResultFileJobHandler(appearance));
|
||||
TaskFileJobEntity job = job("APPEARANCE_PATENT", 3L, 13L, 23L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(appearance.processResultFileJob(job)).thenReturn(false);
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).touchRunning(3L);
|
||||
verify(taskFileJobService, never()).deferRunning(any(), any());
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseDeferredForNonOwnerScoped() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L, 24L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
|
||||
when(handler.process(job)).thenReturn(false);
|
||||
stubHandler(handler, false, false);
|
||||
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).deferRunning(4L, "Waiting for LLM/file assembly to continue");
|
||||
verify(taskFileJobService, never()).touchRunning(any());
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseNoMarkFailed() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 5L, 15L, 25L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
|
||||
when(handler.process(job)).thenReturn(false);
|
||||
stubHandler(handler, false, false);
|
||||
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService, never()).markFailed(any(), any());
|
||||
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncTrueMarksSuccess() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 6L, 16L, 26L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
|
||||
when(handler.process(job)).thenReturn(true);
|
||||
stubHandler(handler, false, false);
|
||||
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/similar-asin/16.xlsx");
|
||||
when(fileResultMapper.selectById(26L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markSuccess(job, "result/similar-asin/16.xlsx");
|
||||
verify(handler).cleanup(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawFinalizeExactlyOnce() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
|
||||
TaskFileJobEntity job = job("WITHDRAW", 7L, 17L, 27L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/withdraw/17.xlsx");
|
||||
when(fileResultMapper.selectById(27L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(withdraw, times(1)).tryFinalizeTask(17L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void specialRulesIntegration() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new ShopMatchResultFileJobHandler(shopMatch, payload));
|
||||
TaskFileJobEntity job = job("SHOP_MATCH", 8L, 18L, 28L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
FileResultEntity result = new FileResultEntity();
|
||||
result.setResultFileUrl("result/shop-match/18.xlsx");
|
||||
when(fileResultMapper.selectById(28L)).thenReturn(result);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).markSuccess(job, "result/shop-match/18.xlsx");
|
||||
verify(payload).deleteLatest(18L, "SHOP_MATCH", "scope:18");
|
||||
verify(taskFileJobService, never()).deferRunning(any(), any());
|
||||
verify(taskFileJobService, never()).touchRunning(any());
|
||||
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseStillReleasesLock() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new AppearancePatentResultFileJobHandler(appearance));
|
||||
TaskFileJobEntity job = job("APPEARANCE_PATENT", 9L, 19L, 29L);
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
when(appearance.processResultFileJob(job)).thenReturn(false);
|
||||
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(lock).close();
|
||||
verify(taskFileJobService).touchRunning(9L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void withdrawLockBusyRequeuesNoFinalize() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
|
||||
TaskFileJobEntity job = job("WITHDRAW", 10L, 20L, 30L);
|
||||
allowClaim(job);
|
||||
when(taskDistributedLockService.acquire("WITHDRAW", 20L,
|
||||
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(null);
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).requeue(10L, "Task is busy, waiting for previous task operation");
|
||||
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
|
||||
verify(taskFileJobService, never()).markSuccess(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void asyncFalseIntegrationSimilar() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L, 31L);
|
||||
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
|
||||
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
|
||||
when(handler.process(job)).thenReturn(false);
|
||||
stubHandler(handler, false, false);
|
||||
|
||||
allowClaim(job);
|
||||
allowLock(job.getModuleType(), job.getTaskId());
|
||||
|
||||
worker.process(job);
|
||||
|
||||
verify(taskFileJobService).deferRunning(11L, "Waiting for LLM/file assembly to continue");
|
||||
verify(lock).close();
|
||||
}
|
||||
}
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.config.InstanceMetadata;
|
||||
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
|
||||
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
|
||||
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
|
||||
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
|
||||
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
|
||||
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
|
||||
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
|
||||
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
|
||||
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
|
||||
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
|
||||
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
|
||||
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
|
||||
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.verifyNoInteractions;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 卡死扫描/重试耗尽路径测试(任务 81)。
|
||||
* resetStuckJobs:超时卡死 job 重置;耗尽 job 走 finalizeRetryExhausted(默认/自定义文案);
|
||||
* 活跃 job 不重置;重复扫描幂等;扫描参数来自配置项。
|
||||
*/
|
||||
class TaskResultFileJobWorkerStuckScanTest {
|
||||
|
||||
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
|
||||
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
|
||||
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
|
||||
private final PublishTaskService publish = mock(PublishTaskService.class);
|
||||
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
|
||||
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
|
||||
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
|
||||
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
|
||||
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
|
||||
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
|
||||
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
|
||||
private final BrandTaskService brand = mock(BrandTaskService.class);
|
||||
private final CollectDataService collectData = mock(CollectDataService.class);
|
||||
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
|
||||
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
|
||||
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
|
||||
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
|
||||
|
||||
private TaskResultFileJobWorker buildWorker() throws Exception {
|
||||
List<ResultFileJobHandler> handlers = List.of(
|
||||
new ShopMatchResultFileJobHandler(shopMatch, payload),
|
||||
new PriceTrackResultFileJobHandler(priceTrack, payload),
|
||||
new ProductRiskResultFileJobHandler(productRisk, payload),
|
||||
new PublishResultFileJobHandler(publish),
|
||||
new QueryAsinResultFileJobHandler(queryAsin, payload),
|
||||
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
|
||||
new WithdrawResultFileJobHandler(withdraw, payload),
|
||||
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
|
||||
new AppearancePatentResultFileJobHandler(appearance),
|
||||
new SimilarAsinResultFileJobHandler(similar),
|
||||
new DeleteBrandResultFileJobHandler(deleteBrand),
|
||||
new BrandResultFileJobHandler(brand, payload),
|
||||
new CollectDataResultFileJobHandler(collectData));
|
||||
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
|
||||
return new TaskResultFileJobWorker(
|
||||
taskFileJobService,
|
||||
taskDistributedLockService,
|
||||
mock(FileResultMapper.class),
|
||||
mock(TaskFileJobLocalDispatcher.class),
|
||||
instanceMetadata,
|
||||
withdraw, brand,
|
||||
registry);
|
||||
}
|
||||
|
||||
private static TaskFileJobEntity exhaustedJob(long jobId, long taskId, String errorMessage) {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setId(jobId);
|
||||
job.setTaskId(taskId);
|
||||
job.setModuleType("SIMILAR_ASIN");
|
||||
job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT);
|
||||
job.setErrorMessage(errorMessage);
|
||||
return job;
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckTimeoutResetPassesConfiguredValues() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
setInt(worker, "stuckTimeoutMinutes", 45);
|
||||
setInt(worker, "batchSize", 30);
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(2, List.of());
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(45, 30)).thenReturn(result);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(taskFileJobService).resetStuckRunningJobsDetailed(45, 30);
|
||||
verify(taskFileJobService, never()).markFailureFinalized(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckExhaustedJobFinalizedWithDefaultMessage() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = exhaustedJob(1L, 11L, null);
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数");
|
||||
verify(taskFileJobService).markFailureFinalized(1L, "文件生成任务运行超时,已达到最大重试次数");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckExhaustedJobFinalizedWithBlankErrorMessage() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = exhaustedJob(2L, 12L, " ");
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数");
|
||||
verify(taskFileJobService).markFailureFinalized(2L, "文件生成任务运行超时,已达到最大重试次数");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckExhaustedJobKeepsCustomMessage() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = exhaustedJob(3L, 13L, "上游 API 限流,重试耗尽");
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(similar).handleResultFileJobFailure(job, "上游 API 限流,重试耗尽");
|
||||
verify(taskFileJobService).markFailureFinalized(3L, "上游 API 限流,重试耗尽");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckDefaultMessageConstant() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = exhaustedJob(4L, 14L, null);
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
String expected = "文件生成任务运行超时,已达到最大重试次数";
|
||||
verify(similar).handleResultFileJobFailure(job, expected);
|
||||
assertTrue(expected.contains("文件生成任务运行超时"), "默认文案含超时语义");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckScanUsesConfiguredTimeout() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of());
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(taskFileJobService).resetStuckRunningJobsDetailed(0, 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckNoResetForActiveJobs() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of());
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(taskFileJobService).resetStuckRunningJobsDetailed(0, 0);
|
||||
verify(similar, never()).handleResultFileJobFailure(any(), anyString());
|
||||
verify(taskFileJobService, never()).markFailureFinalized(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckScanIsIdempotent() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = exhaustedJob(5L, 15L, null);
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0))
|
||||
.thenReturn(result, new TaskFileJobService.StuckJobResetResult(0, List.of()));
|
||||
|
||||
worker.resetStuckJobs();
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(taskFileJobService, times(2)).resetStuckRunningJobsDetailed(0, 0);
|
||||
verify(taskFileJobService).markFailureFinalized(5L, "文件生成任务运行超时,已达到最大重试次数");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckCallbackFailureSwallowedOnScan() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = exhaustedJob(6L, 16L, null);
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("db down"))
|
||||
.when(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数");
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(taskFileJobService, never()).markFailureFinalized(any(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckMultipleExhaustedJobsAllFinalized() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity first = exhaustedJob(7L, 17L, null);
|
||||
TaskFileJobEntity second = exhaustedJob(8L, 18L, "超时");
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(first, second));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verify(similar).handleResultFileJobFailure(first, "文件生成任务运行超时,已达到最大重试次数");
|
||||
verify(similar).handleResultFileJobFailure(second, "超时");
|
||||
verify(taskFileJobService).markFailureFinalized(7L, "文件生成任务运行超时,已达到最大重试次数");
|
||||
verify(taskFileJobService).markFailureFinalized(8L, "超时");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckScanConfigGettersPresent() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
Field timeout = TaskResultFileJobWorker.class.getDeclaredField("stuckTimeoutMinutes");
|
||||
timeout.setAccessible(true);
|
||||
Field batch = TaskResultFileJobWorker.class.getDeclaredField("batchSize");
|
||||
batch.setAccessible(true);
|
||||
setInt(worker, "stuckTimeoutMinutes", 45);
|
||||
setInt(worker, "batchSize", 30);
|
||||
assertEquals(45, timeout.getInt(worker), "卡死超时可注入");
|
||||
assertEquals(30, batch.getInt(worker), "扫描批次可注入");
|
||||
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
|
||||
"task", "service", "TaskResultFileJobWorker.java");
|
||||
String content = new String(java.nio.file.Files.readAllBytes(source), java.nio.charset.StandardCharsets.UTF_8);
|
||||
assertTrue(content.contains("stuck-timeout-minutes:30"), "@Value 默认卡死超时 30 分钟");
|
||||
assertTrue(content.contains("batch-size:20"), "@Value 默认批次 20");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stuckUnknownModuleExhaustedSilentlyIgnored() throws Exception {
|
||||
TaskResultFileJobWorker worker = buildWorker();
|
||||
TaskFileJobEntity job = exhaustedJob(9L, 19L, null);
|
||||
job.setModuleType("UNKNOWN_X");
|
||||
TaskFileJobService.StuckJobResetResult result =
|
||||
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
|
||||
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
|
||||
|
||||
worker.resetStuckJobs();
|
||||
|
||||
verifyNoInteractions(similar, shopDataCrawl, withdraw);
|
||||
verify(taskFileJobService).markFailureFinalized(9L, "文件生成任务运行超时,已达到最大重试次数");
|
||||
}
|
||||
|
||||
private static void setInt(TaskResultFileJobWorker worker, String fieldName, int value) throws Exception {
|
||||
Field field = TaskResultFileJobWorker.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.setInt(worker, value);
|
||||
}
|
||||
}
|
||||
+25
@@ -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());
|
||||
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.nanri.aiimage.modules.task.service;
|
||||
|
||||
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
|
||||
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* WithdrawResultFileJobHandler 测试(任务 66)。
|
||||
* onSuccess 扩展:tryFinalizeTask 在 markSuccess 之后调用(原 finalizeWithdraw 标志语义,时机严格保持);
|
||||
* cleanup 走 payload.deleteLatest。
|
||||
*/
|
||||
class WithdrawResultFileJobHandlerTest {
|
||||
|
||||
private final WithdrawTaskService withdrawTaskService = mock(WithdrawTaskService.class);
|
||||
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
|
||||
private final ResultFileJobHandler handler = new WithdrawResultFileJobHandler(
|
||||
withdrawTaskService, taskResultPayloadService);
|
||||
|
||||
@Test
|
||||
void moduleType() {
|
||||
assertEquals("WITHDRAW", handler.moduleType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void processDelegates() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
handler.process(job);
|
||||
verify(withdrawTaskService).processResultFileJob(job);
|
||||
}
|
||||
|
||||
@Test
|
||||
void cleanupDeleteLatest() {
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(42L);
|
||||
job.setModuleType("WITHDRAW");
|
||||
job.setScopeKey("scope-1");
|
||||
handler.cleanup(job);
|
||||
verify(taskResultPayloadService).deleteLatest(42L, "WITHDRAW", "scope-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalizeAfterSuccessOrder() {
|
||||
// 原 Worker:markSuccess → cleanupAfterSuccess → finalizeWithdraw → tryFinalizeTask
|
||||
// Handler 语义:cleanup(成功路径)与 onSuccess(finalize)分离,onSuccess 只负责 finalize
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(9L);
|
||||
handler.cleanup(job);
|
||||
handler.onSuccess(job);
|
||||
verify(taskResultPayloadService).deleteLatest(9L, null, null);
|
||||
verify(withdrawTaskService).tryFinalizeTask(9L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalizeCalledOnce() {
|
||||
// 一次成功路径只 finalize 一次:process/cleanup 均不触发 finalize,仅 onSuccess 触发
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(9L);
|
||||
handler.process(job);
|
||||
handler.cleanup(job);
|
||||
handler.onSuccess(job);
|
||||
verify(withdrawTaskService, times(1)).tryFinalizeTask(9L, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void finalizeFalseResult() {
|
||||
// 非成功路径不触发 onSuccess:Worker 只在 dispatch 返回 true 且 markSuccess 后调用
|
||||
TaskFileJobEntity job = new TaskFileJobEntity();
|
||||
job.setTaskId(9L);
|
||||
handler.onFailure(job, "failed");
|
||||
verify(withdrawTaskService, never()).tryFinalizeTask(9L, false);
|
||||
assertTrue(handler.process(job), "process 成功返回 true");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerScopedFalse() {
|
||||
assertFalse(handler.isOwnerScoped());
|
||||
}
|
||||
|
||||
@Test
|
||||
void nullJobGuarded() {
|
||||
assertTrue(handler.process(null));
|
||||
verify(withdrawTaskService).processResultFileJob(null);
|
||||
handler.onSuccess(null);
|
||||
verify(withdrawTaskService, never()).tryFinalizeTask(null, false);
|
||||
assertFalse(handler.supportsAsyncOffload());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user