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

- similarasin/appearancepatent 模块全部 Coze 工作流调用改走 direct-LLM(已确认唯一运行路径)
- 共享组件改名:CozeTaskQueueGate→TaskQueueGate、CozeGroupResultPropagator→GroupResultPropagator
- 状态机改名:biz_task_scope_state 的 coze_* 列→llm_*、stateJson coze 键→llm(V100 迁移已应用生产)
- 删除 biz_coze_credential 表、CozeCredential* 类、SimilarAsinCozeClient、AppearancePatentCozeClient→LlmClient
- 前端 brand 页 Coze 文案→LLM;Python 后端删除 cozepy 依赖与死配置
- 修复 TaskResultFileJobWorker 启动失败:TaskFileJobConfig 注册 ResultFileJobHandlerRegistry 与 13 个 handler bean(含 validateCoverage 启动校验)
This commit is contained in:
2026-09-01 02:19:45 +08:00
parent 0cc7380205
commit e2607ab723
109 changed files with 6903 additions and 4582 deletions
@@ -30,7 +30,7 @@ class AppearancePatentLlmClientHttpTest {
private final Map<String, AtomicInteger> callCounts = new ConcurrentHashMap<>();
private final List<String> capturedBodies = new ArrayList<>();
private AppearancePatentCozeClient client;
private AppearancePatentLlmClient client;
@BeforeEach
void setUp() throws IOException {
@@ -41,7 +41,7 @@ class AppearancePatentLlmClientHttpTest {
AppearancePatentProperties properties = new AppearancePatentProperties();
properties.setLlmHost("http://127.0.0.1:" + server.getAddress().getPort());
properties.setLlmRetryTimes(3);
client = new AppearancePatentCozeClient(
client = new AppearancePatentLlmClient(
properties,
objectMapper,
null,
@@ -16,9 +16,9 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class AppearancePatentCozeClientTest {
class AppearancePatentLlmClientTest {
private final AppearancePatentCozeClient client = new AppearancePatentCozeClient(
private final AppearancePatentLlmClient client = new AppearancePatentLlmClient(
new AppearancePatentProperties(),
new ObjectMapper(),
null,
@@ -59,9 +59,9 @@ class AppearancePatentCozeClientTest {
}
@Test
void skipBrandCheckWhenCozeTitleIsNone() throws Exception {
void skipBrandCheckWhenLlmTitleIsNone() throws Exception {
BrandCheckClient brandCheckClient = mock(BrandCheckClient.class);
AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient(
AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient(
new AppearancePatentProperties(),
new ObjectMapper(),
null,
@@ -73,7 +73,7 @@ class AppearancePatentCozeClientTest {
{"data":[{"row_id":"1","title":"","title_reason":"","appearance":"无侵权","result":"无侵权"}]}
""";
List<AppearancePatentResultRowDto> merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText);
List<AppearancePatentResultRowDto> merged = llmClient.mergeRowsFromDataText(List.of(row), dataText);
assertThat(merged).hasSize(1);
assertThat(merged.get(0).getTitleRisk()).isEqualTo("无侵权");
@@ -86,7 +86,7 @@ class AppearancePatentCozeClientTest {
BrandCheckClient brandCheckClient = mock(BrandCheckClient.class);
when(brandCheckClient.checkTitleText("阿凡达", "Terms"))
.thenReturn(new BrandCheckClient.BrandCheckBatchResult(List.of("阿凡达"), List.of(), List.of()));
AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient(
AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient(
new AppearancePatentProperties(),
new ObjectMapper(),
null,
@@ -98,7 +98,7 @@ class AppearancePatentCozeClientTest {
{"data":[{"row_id":"1","title":"阿凡达","appearance":"无侵权","result":"无侵权"}]}
""";
List<AppearancePatentResultRowDto> merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText);
List<AppearancePatentResultRowDto> merged = llmClient.mergeRowsFromDataText(List.of(row), dataText);
assertThat(merged).hasSize(1);
assertThat(merged.get(0).getTitleRisk()).isEqualTo("无侵权");
@@ -108,7 +108,7 @@ class AppearancePatentCozeClientTest {
@Test
void leaveTitleRiskBlankWhenAppearanceIsMissing() throws Exception {
BrandCheckClient brandCheckClient = mock(BrandCheckClient.class);
AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient(
AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient(
new AppearancePatentProperties(),
new ObjectMapper(),
null,
@@ -120,7 +120,7 @@ class AppearancePatentCozeClientTest {
{"data":[{"row_id":"1","title":"阿凡达","result":""}]}
""";
List<AppearancePatentResultRowDto> merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText);
List<AppearancePatentResultRowDto> merged = llmClient.mergeRowsFromDataText(List.of(row), dataText);
assertThat(merged).hasSize(1);
assertThat(merged.get(0).getTitleRisk()).isEmpty();
@@ -130,7 +130,7 @@ class AppearancePatentCozeClientTest {
@Test
void leaveTitleRiskBlankWhenTitleAndAppearanceAreMissing() throws Exception {
BrandCheckClient brandCheckClient = mock(BrandCheckClient.class);
AppearancePatentCozeClient cozeClient = new AppearancePatentCozeClient(
AppearancePatentLlmClient llmClient = new AppearancePatentLlmClient(
new AppearancePatentProperties(),
new ObjectMapper(),
null,
@@ -142,7 +142,7 @@ class AppearancePatentCozeClientTest {
{"data":[{"row_id":"1","result":""}]}
""";
List<AppearancePatentResultRowDto> merged = cozeClient.mergeRowsFromDataText(List.of(row), dataText);
List<AppearancePatentResultRowDto> merged = llmClient.mergeRowsFromDataText(List.of(row), dataText);
assertThat(merged).hasSize(1);
assertThat(merged.get(0).getTitleRisk()).isEmpty();
@@ -151,7 +151,7 @@ class AppearancePatentCozeClientTest {
}
@Test
void preserveSubmittedPriceWhenMergingCozeResult() throws Exception {
void preserveSubmittedPriceWhenMergingLlmResult() throws Exception {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId("1");
row.setPrice("12.99");
@@ -2,7 +2,7 @@ package com.nanri.aiimage.modules.file.service.object;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.config.CozeTaskQueueGate;
import com.nanri.aiimage.config.TaskQueueGate;
import com.nanri.aiimage.config.TransientStorageProperties;
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.model.dto.TaskFileJobDispatchEvent;
@@ -195,7 +195,7 @@ class FaultInjectionTest {
verify(mapper, never()).selectOne(any());
verify(publisher, never()).publishEvent(any(TaskFileJobDispatchEvent.class));
CozeTaskQueueGate gate = new CozeTaskQueueGate(mock(TaskExecutor.class), 1, emptyProvider());
TaskQueueGate gate = new TaskQueueGate(mock(TaskExecutor.class), 1, emptyProvider());
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> gate.execute(null));
assertTrue(ex.getMessage().contains("不能为 null"), "null 任务必须抛出可识别错误消息");
}
@@ -212,7 +212,7 @@ class FaultInjectionTest {
})
.when(delegate).execute(any(Runnable.class));
SimpleMeterRegistry registry = new SimpleMeterRegistry();
CozeTaskQueueGate gate = new CozeTaskQueueGate(delegate, 2, objectProvider(registry));
TaskQueueGate gate = new TaskQueueGate(delegate, 2, objectProvider(registry));
Runnable task = () -> {
};
@@ -1,156 +0,0 @@
package com.nanri.aiimage.modules.similarasin.client;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 19Coze 请求/响应及 Python 回传日志改为采样、截断和 DEBUG 级别。
* 新工具 SimilarAsinLogSupport 提供两条纯函数:
* - truncate:正文超限截断为 maxChars + 后缀,长文本不占满日志;
* - shouldLog:每 everyN 次采样一次(counter % everyN == 0),控制轮询/逐行日志量。
* Coze 客户端正文日志与 Python 回传逐行日志经该工具后输出有界、可识别。
*/
class SimilarAsinCozeClientLoggingTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
void test_task_019_logging_normal_default_path() {
// 正常输入:短文本不截断;每次采样(everyN=1)恒记录。
assertEquals("hello", SimilarAsinLogSupport.truncate("hello", 100));
assertEquals("", SimilarAsinLogSupport.truncate(null, 100), "null 文本返回空串");
assertTrue(SimilarAsinLogSupport.shouldLog(0, 1), "everyN=1 恒采样");
assertTrue(SimilarAsinLogSupport.shouldLog(7, 1), "everyN=1 不抑制任何计数");
}
@Test
void test_task_019_logging_normal_multiple_items() throws Exception {
// 批量场景:多个长文本各自截断、结果互不影响;采样按每 everyN 次一次。
String longA = "A".repeat(3000);
String longB = "B".repeat(5000);
String truncatedA = SimilarAsinLogSupport.truncate(longA, 100);
String truncatedB = SimilarAsinLogSupport.truncate(longB, 100);
assertTrue(truncatedA.startsWith("A".repeat(100)));
assertTrue(truncatedB.startsWith("B".repeat(100)));
assertTrue(truncatedA.length() < longA.length(), "截断后必须短于原文");
int sampled = 0;
for (int i = 0; i < 30; i++) {
if (SimilarAsinLogSupport.shouldLog(i, 10)) {
sampled++;
}
}
assertEquals(3, sampled, "everyN=10 在 0..29 内应采样 0/10/20 共 3 次");
}
@Test
void test_task_019_logging_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行:同一文本多次截断结果一致;同一计数采样判定一致。
String text = "x".repeat(1234);
String first = SimilarAsinLogSupport.truncate(text, 500);
String second = SimilarAsinLogSupport.truncate(text, 500);
assertEquals(first, second, "重复截断必须产生相同输出");
assertEquals(first, SimilarAsinLogSupport.truncate(text, 500), "截断幂等");
assertEquals(SimilarAsinLogSupport.shouldLog(20, 10), SimilarAsinLogSupport.shouldLog(20, 10));
}
@Test
void test_task_019_logging_boundary_empty_input() throws Exception {
// 空输入:null/空串安全返回空串;空白串按长度截断语义原样保留。
assertEquals("", SimilarAsinLogSupport.truncate(null, 100));
assertEquals("", SimilarAsinLogSupport.truncate("", 100));
assertEquals(" ", SimilarAsinLogSupport.truncate(" ", 100), "空白串不做 trim,按原样返回");
assertTrue(SimilarAsinLogSupport.shouldLog(0, 10), "计数 0 必须采样(首条不丢)");
}
@Test
void test_task_019_logging_boundary_single_item() throws Exception {
// 单元素边界:恰好等于上限不截断;超 1 字符截断并带长度后缀。
String exact = "y".repeat(100);
assertEquals(exact, SimilarAsinLogSupport.truncate(exact, 100), "恰好等于上限不截断");
String over = "y".repeat(101);
String truncated = SimilarAsinLogSupport.truncate(over, 100);
assertEquals(over.substring(0, 100), truncated.substring(0, 100), "截断保留前缀");
assertTrue(truncated.contains("101"), "截断输出应携带原文长度");
}
@Test
void test_task_019_logging_boundary_limit_and_overflow() throws Exception {
// 上限/超限:10 万字符文本截断后有界、不再无界增长;采样 everyN 超限不抑制。
String huge = "z".repeat(100_000);
String truncated = SimilarAsinLogSupport.truncate(huge, 2000);
assertTrue(truncated.length() < 2200, "截断输出必须有界,实际=" + truncated.length());
assertTrue(truncated.length() > 2000, "应保留 2000 前缀 + 后缀");
assertTrue(truncated.endsWith("]"), "截断输出带可识别后缀");
assertTrue(SimilarAsinLogSupport.shouldLog(0, Integer.MAX_VALUE), "计数 0 在超大 everyN 下仍采样");
assertFalse(SimilarAsinLogSupport.shouldLog(1, Integer.MAX_VALUE), "非零计数在超大 everyN 下抑制");
assertFalse(SimilarAsinLogSupport.shouldLog(31, 10), "非采样点必须被抑制");
}
@Test
void test_task_019_logging_invalid_input_rejected() throws Exception {
// 非法参数:maxChars ≤ 0 时原样返回(不截断);null 文本始终空串。
String text = "invalid-max";
assertEquals(text, SimilarAsinLogSupport.truncate(text, 0), "maxChars=0 不截断");
assertEquals(text, SimilarAsinLogSupport.truncate(text, -1), "负上限不截断");
assertEquals("", SimilarAsinLogSupport.truncate(null, -5));
assertTrue(SimilarAsinLogSupport.shouldLog(5, 0), "everyN=0 视为恒采样");
assertTrue(SimilarAsinLogSupport.shouldLog(5, -3), "负 everyN 视为恒采样");
}
@Test
void test_task_019_logging_dependency_failure_releases_resources() throws Exception {
// 依赖失败:含代理对(emoji)的长文本截断不抛异常、不产生孤立代理项;
// 计数接近 Long.MAX_VALUE 不溢出;掩码后的请求体经截断管线输出有界且不泄漏密钥。
String emoji = "🚀".repeat(3000);
String truncatedEmoji = SimilarAsinLogSupport.truncate(emoji, 100);
assertNotNull(truncatedEmoji);
assertTrue(truncatedEmoji.length() < emoji.length(), "代理对文本必须被截断");
assertFalse(SimilarAsinLogSupport.shouldLog(Long.MAX_VALUE, 10), "极大计数采样判定不抛异常");
List<SimilarAsinResultRowDto> rows = new java.util.ArrayList<>();
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0SECRET1");
row.setUrl("https://m.media-amazon.com/images/I/" + "U".repeat(500) + ".jpg");
row.setTitle("T".repeat(5000));
row.setSku("SKU-SECRET");
rows.add(row);
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method maskMethod = SimilarAsinCozeClient.class.getDeclaredMethod(
"maskCozeRequestBody", Map.class);
maskMethod.setAccessible(true);
Method buildMethod = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
buildMethod.setAccessible(true);
@SuppressWarnings("unchecked")
Map<String, Object> parameters = (Map<String, Object>) buildMethod.invoke(client, rows, "", "supersecretkey", true);
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("workflow_id", "wf-1");
body.put("parameters", parameters);
body.put("api_key", "supersecretkey");
@SuppressWarnings("unchecked")
Map<String, Object> masked = (Map<String, Object>) maskMethod.invoke(client, body);
Method writeMethod = SimilarAsinCozeClient.class.getDeclaredMethod("writeJson", Object.class);
writeMethod.setAccessible(true);
String maskedJson = (String) writeMethod.invoke(client, masked);
String logged = SimilarAsinLogSupport.truncate(maskedJson, 2000);
assertTrue(logged.length() < maskedJson.length(), "超长掩码 body 必须截断");
assertFalse(logged.contains("supersecretkey"), "日志不得泄漏完整 api_key");
assertTrue(logged.contains("B0SECRET1"), "截断保留正文关键字段");
assertTrue(logged.length() < 2500, "截断输出必须有界");
}
}
@@ -1,240 +0,0 @@
package com.nanri.aiimage.modules.similarasin.client;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.lang.reflect.Method;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SimilarAsinCozeClientTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Test
@SuppressWarnings("unchecked")
void buildParametersIncludesAlibabaPriceFromPythonPayload() throws Exception {
List<SimilarAsinResultRowDto> rows = objectMapper.readValue("""
[
{
"asin": "B0TEST123",
"url": "https://m.media-amazon.com/images/I/main.jpg",
"alibaba": [
{"url": "https://cbu01.alicdn.com/img/ibank/a.jpg", "price": 12.80},
{"url": "https://cbu01.alicdn.com/img/ibank/b.jpg", "price": 19.99}
],
"title": "Test title",
"sku": "SKU-1"
}
]
""", new TypeReference<>() {
});
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
method.setAccessible(true);
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, rows, "", "", true);
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
Map<String, Object> item = items.getFirst();
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) item.get("alibaba");
assertEquals("B0TEST123", item.get("asin"));
assertEquals("https://m.media-amazon.com/images/I/main.jpg", item.get("url"));
assertEquals("Test title", item.get("title"));
assertEquals("SKU-1", item.get("sku"));
assertEquals(2, alibaba.size());
assertEquals("https://cbu01.alicdn.com/img/ibank/a.jpg", alibaba.get(0).get("url"));
assertEquals(0, new BigDecimal("12.8").compareTo((BigDecimal) alibaba.get(0).get("price")));
assertEquals("https://cbu01.alicdn.com/img/ibank/b.jpg", alibaba.get(1).get("url"));
assertEquals(0, new BigDecimal("19.99").compareTo((BigDecimal) alibaba.get(1).get("price")));
assertTrue(rows.getFirst().hasImageUrl());
}
@Test
@SuppressWarnings("unchecked")
void buildParametersFallsBackAlibabaFromLegacyUrlsAndTopLevelPrice() throws Exception {
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0LEGACY1");
row.setUrl("https://m.media-amazon.com/images/I/main.jpg");
row.setUrls(List.of("https://cbu01.alicdn.com/img/ibank/legacy-a.jpg"));
row.setPrice("8.50");
row.setTitle("Legacy title");
row.setSku("SKU-LEGACY");
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
method.setAccessible(true);
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, List.of(row), "", "", false);
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) items.getFirst().get("alibaba");
assertEquals(1, alibaba.size());
assertEquals("https://cbu01.alicdn.com/img/ibank/legacy-a.jpg", alibaba.getFirst().get("url"));
assertEquals(0, new BigDecimal("8.5").compareTo((BigDecimal) alibaba.getFirst().get("price")));
}
@Test
@SuppressWarnings("unchecked")
void buildParametersConvertsBlankAlibabaPricesToZero() throws Exception {
List<SimilarAsinResultRowDto> rows = objectMapper.readValue("""
[
{
"asin": "B0BLANK123",
"url": "https://m.media-amazon.com/images/I/main.jpg",
"alibaba": [
{"url": "https://cbu01.alicdn.com/img/ibank/blank.jpg", "price": ""},
{"url": "https://cbu01.alicdn.com/img/ibank/null.jpg", "price": null}
]
}
]
""", new TypeReference<>() {
});
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
method.setAccessible(true);
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, rows, "", "", true);
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) items.getFirst().get("alibaba");
String json = objectMapper.writeValueAsString(parameters);
assertEquals(2, alibaba.size());
assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.get(0).get("price")));
assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.get(1).get("price")));
assertTrue(json.contains("\"price\":0"));
assertFalse(json.contains("\"price\":\"\""));
}
@Test
@SuppressWarnings("unchecked")
void buildParametersConvertsBlankFallbackRowPriceToZero() throws Exception {
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0BLANKROW");
row.setUrl("https://m.media-amazon.com/images/I/main.jpg");
row.setUrls(List.of("https://cbu01.alicdn.com/img/ibank/fallback.jpg"));
row.setPrice("");
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
method.setAccessible(true);
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, List.of(row), "", "", true);
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) items.getFirst().get("alibaba");
String json = objectMapper.writeValueAsString(parameters);
assertEquals(1, alibaba.size());
assertEquals(0, BigDecimal.ZERO.compareTo((BigDecimal) alibaba.getFirst().get("price")));
assertTrue(json.contains("\"price\":0"));
assertFalse(json.contains("\"price\":\"\""));
}
@Test
@SuppressWarnings("unchecked")
void buildParametersSerializesWholeNumberPricesWithoutScientificNotation() throws Exception {
List<SimilarAsinResultRowDto> rows = objectMapper.readValue("""
[
{
"asin": "B0SCI123",
"url": "https://m.media-amazon.com/images/I/main.jpg",
"alibaba": [
{"url": "https://cbu01.alicdn.com/img/ibank/ten.jpg", "price": 10.0},
{"url": "https://cbu01.alicdn.com/img/ibank/eighty.jpg", "price": 80.0},
{"url": "https://cbu01.alicdn.com/img/ibank/fraction.jpg", "price": 7.5800}
]
}
]
""", new TypeReference<>() {
});
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class);
method.setAccessible(true);
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, rows, "", "", true);
List<Map<String, Object>> items = (List<Map<String, Object>>) parameters.get("items");
List<Map<String, Object>> alibaba = (List<Map<String, Object>>) items.getFirst().get("alibaba");
String json = objectMapper.writeValueAsString(parameters);
assertEquals("10", ((BigDecimal) alibaba.get(0).get("price")).toPlainString());
assertEquals("80", ((BigDecimal) alibaba.get(1).get("price")).toPlainString());
assertEquals("7.58", ((BigDecimal) alibaba.get(2).get("price")).toPlainString());
assertFalse(json.contains("1E+1"));
assertFalse(json.contains("8E+1"));
assertTrue(json.contains("\"price\":10"));
assertTrue(json.contains("\"price\":80"));
}
@Test
@SuppressWarnings("unchecked")
void buildParametersIncludesCategorySwitch() throws Exception {
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0CATEGORY1");
row.setTitle("Category test");
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
Method method = SimilarAsinCozeClient.class.getDeclaredMethod(
"buildParameters", List.class, String.class, String.class, boolean.class, boolean.class);
method.setAccessible(true);
Map<String, Object> parameters = (Map<String, Object>) method.invoke(client, List.of(row), "", "", false, true);
assertEquals(Boolean.TRUE, parameters.get("category_switch"));
assertEquals(Boolean.FALSE, parameters.get("img_switch"));
}
@Test
void imageOnlyWorkflowOutputIsExtractedAndMergedByAsin() throws Exception {
SimilarAsinCozeClient client = new SimilarAsinCozeClient(new SimilarAsinProperties(), objectMapper, null, null);
String imageData = """
{"data":[{
"asin":"B0BQNHDP2F",
"main_url":"https://example.com/main.jpg",
"puzzle_img1":"https://example.com/puzzle-1.jpg",
"puzzle_img2":"https://example.com/puzzle-2.jpg"
}]}
""";
String workflowOutput = objectMapper.writeValueAsString(Map.of(
"node_status", "{}",
"Output", imageData));
var historyResponse = objectMapper.createObjectNode();
historyResponse.put("code", 0);
historyResponse.putArray("data")
.addObject()
.put("execute_status", "Success")
.put("output", workflowOutput);
Method extract = SimilarAsinCozeClient.class.getDeclaredMethod("extractResultDataText", JsonNode.class);
extract.setAccessible(true);
String dataText = (String) extract.invoke(client, historyResponse);
SimilarAsinResultRowDto source = new SimilarAsinResultRowDto();
source.setAsin("B0BQNHDP2F");
List<SimilarAsinResultRowDto> merged = client.mergeRowsFromDataText(List.of(source), dataText);
assertFalse(dataText.isBlank());
assertEquals(1, merged.size());
assertEquals("https://example.com/main.jpg", merged.getFirst().getMainUrl());
assertEquals("https://example.com/puzzle-1.jpg", merged.getFirst().getPuzzleImg1());
assertEquals("https://example.com/puzzle-2.jpg", merged.getFirst().getPuzzleImg2());
Method resolvedCount = SimilarAsinCozeClient.class.getDeclaredMethod("resolvedCount", List.class);
resolvedCount.setAccessible(true);
assertEquals(1, resolvedCount.invoke(client, merged));
}
}
@@ -2,7 +2,6 @@ package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
@@ -71,7 +70,6 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
@@ -81,7 +79,6 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@@ -168,11 +165,11 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
private static void invokeMerge(SimilarAsinTaskService service, FileTaskEntity task,
String scopeHash, Integer chunkIndex,
List<SimilarAsinResultRowDto> cozeRows) throws Exception {
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
List<SimilarAsinResultRowDto> llmRows) throws Exception {
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
merge.setAccessible(true);
merge.invoke(service, task, scopeHash, chunkIndex, cozeRows, Map.of());
merge.invoke(service, task, scopeHash, chunkIndex, llmRows, Map.of());
}
@Test
@@ -184,12 +181,12 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
List<SimilarAsinResultRowDto> cozeRows = List.of(
List<SimilarAsinResultRowDto> llmRows = List.of(
row("r1", "B0A0000001", "标题1"),
row("r2", "B0A0000002", "标题2"),
row("r3", "B0A0000003", "标题3"));
invokeMerge(service, task, "hashA", 1, cozeRows);
invokeMerge(service, task, "hashA", 1, llmRows);
assertNotNull(storedPayload.get());
assertTrue(storedPayload.get().contains("\"r1\"") && storedPayload.get().contains("\"r3\""),
"上限内合并必须完整保留存量行与新增行,实际: " + storedPayload.get());
@@ -243,9 +240,9 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
List<SimilarAsinResultRowDto> cozeRows = List.of(row("r1", "B0A0000001", "标题1"));
invokeMerge(service, task, "hashA", 1, cozeRows);
invokeMerge(service, task, "hashA", 1, cozeRows);
List<SimilarAsinResultRowDto> llmRows = List.of(row("r1", "B0A0000001", "标题1"));
invokeMerge(service, task, "hashA", 1, llmRows);
invokeMerge(service, task, "hashA", 1, llmRows);
assertEquals(2, storeCalls.get(), "重复执行同一输入:每次 merge 恰好写回一次,无多余请求");
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
}
@@ -298,13 +295,13 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int i = 0; i < 5; i++) {
cozeRows.add(row("r" + (i + 1), "B0A00000" + (i + 1), "新行" + i));
llmRows.add(row("r" + (i + 1), "B0A00000" + (i + 1), "新行" + i));
}
// Phase A:行数超限(上限 2,存量 1 + 新增 5)→ 只保留上限内最新行,超限部分转 orphan。
invokeMerge(service, task, "hashA", 1, cozeRows);
invokeMerge(service, task, "hashA", 1, llmRows);
assertTrue(lastStoredPayload.get().contains("\"r4\"") && lastStoredPayload.get().contains("\"r5\""),
"行数超限时保留上限内的最新行,实际: " + lastStoredPayload.get());
assertFalse(lastStoredPayload.get().contains("\"r0\""), "行数超限时最旧行被降级,实际: " + lastStoredPayload.get());
@@ -317,7 +314,7 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
lenient().when(properties.getChunkMergeMaxRows()).thenReturn(50000);
long oneRowBytes = rowsJson(List.of(row("r9", "B0A0000099", "样本行"))).getBytes(StandardCharsets.UTF_8).length;
lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(oneRowBytes + 5L);
invokeMerge(service, task, "hashA", 1, cozeRows);
invokeMerge(service, task, "hashA", 1, llmRows);
assertTrue(lastStoredPayload.get().contains("\"r5\""), "字节超限时保留最新行,实际: " + lastStoredPayload.get());
assertFalse(lastStoredPayload.get().contains("\"r0\""), "字节超限时最旧行被降级,实际: " + lastStoredPayload.get());
assertEquals(2, storeCalls.get());
@@ -327,7 +324,7 @@ class SimilarAsinTaskServiceChunkMergeLimitTest {
lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(5L);
Exception ex = assertThrows(Exception.class, () -> {
try {
invokeMerge(service, task, "hashA", 1, cozeRows);
invokeMerge(service, task, "hashA", 1, llmRows);
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
@@ -2,8 +2,6 @@ package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
@@ -59,8 +57,8 @@ import static org.mockito.Mockito.when;
* P0-3 缓冲只覆盖"poll DONE 且 batchTotal>1"Task 12 扩展为:
* 1) poll DONE 结果去掉 batchTotal 限制,单 batch 也走缓冲;
* 2) retry 提交同步 immediate DONE 结果也走缓冲(原立即 merge);
* 3) 统一走 bufferCozeRowsOrMerge:缓冲失败回退立即 merge,结果不丢失。
* flushBufferedCozeResults 在 finalize 前一次性合并,全任务收敛为一次 chunk 读写。
* 3) 统一走 bufferLlmRowsOrMerge:缓冲失败回退立即 merge,结果不丢失。
* flushLlmBufferedResults 在 finalize 前一次性合并,全任务收敛为一次 chunk 读写。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceCozeBufferScopeTest {
@@ -78,7 +76,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinLlmService similarAsinLlmService;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TaskFileJobService taskFileJobService;
@@ -88,7 +86,6 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@@ -113,24 +110,12 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
lenient().when(properties.getCozeBatchSize()).thenReturn(5);
lenient().when(properties.getCozeTextOnlyBatchSize()).thenReturn(10);
lenient().when(properties.isCozeResultBufferEnabled()).thenReturn(true);
lenient().when(properties.getCozePollTimeoutMillis()).thenReturn(30_000);
lenient().when(properties.getLlmBatchSize()).thenReturn(5);
lenient().when(properties.getLlmTextOnlyBatchSize()).thenReturn(10);
lenient().when(properties.isLlmResultBufferEnabled()).thenReturn(true);
lenient().when(properties.getDbJobTouchIntervalMillis()).thenReturn(2_000L);
lenient().when(properties.getDbTaskTouchIntervalMillis()).thenReturn(2_000L);
lenient().when(properties.getCozeSubmitLockWaitMillis()).thenReturn(1_000L);
lenient().when(properties.getCozeSubmitLockRetryDelayMillis()).thenReturn(100L);
lenient().when(properties.getCozeSubmitMinIntervalMillis()).thenReturn(0L);
lenient().when(properties.getCozeSubmitMaxRetryCount()).thenReturn(3);
lenient().when(properties.getCozeFlushPendingMinutes()).thenReturn(10);
lenient().when(cozeClient.configuredCredentialCount()).thenReturn(1);
lenient().when(cozeClient.nextCredential()).thenReturn(new SimilarAsinCozeClient.CozeCredentialRef(
CREDENTIAL, "wf-1", "token-1", 4));
lenient().when(cozeClient.credentialByName(CREDENTIAL)).thenReturn(new SimilarAsinCozeClient.CozeCredentialRef(
CREDENTIAL, "wf-1", "token-1", 4));
lenient().when(cozeCredentialPoolService.borrow(eq(MODULE), any())).thenReturn(
mock(CozeCredentialPoolService.BorrowedCredential.class));
lenient().when(properties.getLlmFlushPendingMinutes()).thenReturn(10);
lenient().when(distributedJobLockService.tryLock(anyString(), any())).thenReturn(
mock(com.nanri.aiimage.common.service.DistributedJobLockService.LockHandle.class));
lenient().doAnswer(invocation -> {
@@ -192,7 +177,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
state.setTaskId(task.getId());
state.setModuleType(MODULE);
state.setScopeHash("scope-" + id);
state.setCozeStatus(status);
state.setLlmStatus(status);
state.setParsedPayloadJson("ptr:batch-" + id);
state.setStateJson("{\"jobId\":7101,\"resultId\":7201,\"chunkScopeHash\":null,\"chunkIndex\":null,"
+ "\"batchIndex\":1,\"batchTotal\":" + batchTotal + ",\"ownerInstanceId\":\"test-instance\","
@@ -204,8 +189,8 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
return new ObjectMapper().writeValueAsString(rows);
}
private SimilarAsinTaskService.CozeBatchContext context(int batchTotal) {
return new SimilarAsinTaskService.CozeBatchContext(
private SimilarAsinTaskService.LlmBatchContext context(int batchTotal) {
return new SimilarAsinTaskService.LlmBatchContext(
7101L, 7201L, null, null, 1, batchTotal, "test-instance", 0, CREDENTIAL, null);
}
@@ -235,14 +220,14 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
@Test
void test_task_012_payload_chunk_normal_default_path() throws Exception {
// 正常输入:DONE 结果(batchTotal=1 单 batch)经 bufferCozeRowsOrMerge 走缓冲,
// 正常输入:DONE 结果(batchTotal=1 单 batch)经 bufferLlmRowsOrMerge 走缓冲,
// 不立即写 chunk;缓冲失败回退立即 merge 结果不丢失。
FileTaskEntity task = task();
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.class,
SimilarAsinTaskService.CozeBatchContext.class,
SimilarAsinTaskService.LlmBatchContext.class,
List.class, FileTaskEntity.class, Map.class);
bufferOrMerge.setAccessible(true);
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
@@ -278,7 +263,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk(1L, "scope-1", 1, "ptr:chunk-1")));
when(taskChunkMapper.update(any(), any())).thenReturn(1);
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushBufferedCozeResults", Long.class);
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushLlmBufferedResults", Long.class);
flush.setAccessible(true);
flush.invoke(service, 7104L);
@@ -294,9 +279,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
FileTaskEntity task = task();
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.class,
SimilarAsinTaskService.CozeBatchContext.class,
SimilarAsinTaskService.LlmBatchContext.class,
List.class, FileTaskEntity.class, Map.class);
bufferOrMerge.setAccessible(true);
TaskScopeStateEntity state = state(task, 1L, "DONE", 2);
@@ -313,9 +298,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
void test_task_012_payload_chunk_boundary_empty_input() throws Exception {
// 空输入:无行时缓冲与 merge 都不发生,不创建无效资源
FileTaskEntity task = task();
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.class,
SimilarAsinTaskService.CozeBatchContext.class,
SimilarAsinTaskService.LlmBatchContext.class,
List.class, FileTaskEntity.class, Map.class);
bufferOrMerge.setAccessible(true);
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), null, task, Map.of());
@@ -330,9 +315,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
// 单 batchbatchTotal=1):原 P0-3 例外,现在也缓冲
FileTaskEntity task = task();
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.class,
SimilarAsinTaskService.CozeBatchContext.class,
SimilarAsinTaskService.LlmBatchContext.class,
List.class, FileTaskEntity.class, Map.class);
bufferOrMerge.setAccessible(true);
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
@@ -347,11 +332,11 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
// 缓冲开关关闭:回退立即 merge,DONE 结果仍落 chunk 不丢失
FileTaskEntity task = task();
stubChunkMerge(chunkRowsJson());
when(properties.isCozeResultBufferEnabled()).thenReturn(false);
when(properties.isLlmResultBufferEnabled()).thenReturn(false);
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.class,
SimilarAsinTaskService.CozeBatchContext.class,
SimilarAsinTaskService.LlmBatchContext.class,
List.class, FileTaskEntity.class, Map.class);
bufferOrMerge.setAccessible(true);
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
@@ -369,9 +354,9 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true)))
.thenThrow(new IllegalStateException("rustfs full"));
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferCozeRowsOrMerge",
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.class,
SimilarAsinTaskService.CozeBatchContext.class,
SimilarAsinTaskService.LlmBatchContext.class,
List.class, FileTaskEntity.class, Map.class);
bufferOrMerge.setAccessible(true);
bufferOrMerge.invoke(service, state(task, 1L, "DONE", 1), context(1), rows, task, Map.of());
@@ -408,7 +393,7 @@ class SimilarAsinTaskServiceCozeBufferScopeTest {
return "ptr:stored-" + invocation.getArgument(3);
}).when(transientPayloadStorageService).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushBufferedCozeResults", Long.class);
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushLlmBufferedResults", Long.class);
flush.setAccessible(true);
Exception ex = assertThrows(Exception.class, () -> {
try {
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
@@ -69,7 +68,6 @@ class SimilarAsinTaskServiceGroupRefTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
@@ -64,7 +63,6 @@ class SimilarAsinTaskServiceParseLimitsTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
@@ -66,7 +65,6 @@ class SimilarAsinTaskServiceParsePreviewTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
@@ -65,7 +64,6 @@ class SimilarAsinTaskServicePreviewConfigTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@@ -2,7 +2,6 @@ package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
@@ -65,7 +64,6 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
@@ -75,7 +73,6 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@@ -157,7 +154,7 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
@Test
void test_task_011_merge_row_key_normal_default_path() throws Exception {
// 正常输入:cozeRows 含同一 rowKey 的重复行,merge 前按稳定 rowKey 去重,
// 正常输入:llmRows 含同一 rowKey 的重复行,merge 前按稳定 rowKey 去重,
// chunk payload 只写一次,结果行不重复。
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
AtomicLong storedCounter = new AtomicLong(0);
@@ -165,18 +162,18 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
FileTaskEntity task = new FileTaskEntity();
task.setId(7004L);
List<SimilarAsinResultRowDto> cozeRows = List.of(
List<SimilarAsinResultRowDto> llmRows = List.of(
row("r1", "1", "B0A0000001", "英国"),
row("r1", "1", "B0A0000001", "英国"));
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(cozeRows);
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(llmRows);
assertEquals(1, deduped.size(), "重复行必须按稳定 rowKey 去重");
assertEquals("r1", deduped.get(0).getRowToken());
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
merge.setAccessible(true);
merge.invoke(service, task, null, null, cozeRows, Map.of());
merge.invoke(service, task, null, null, llmRows, Map.of());
assertEquals(1, storedCounter.get(), "去重后 chunk 只写一次");
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
}
@@ -198,34 +195,34 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
selectOneRound.getAndIncrement() == 0 ? chunkA : chunkB);
when(taskChunkMapper.update(any(), any())).thenReturn(1);
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int i = 0; i < 3; i++) {
cozeRows.add(row("r1", "1", "B0A0000001", "英国"));
cozeRows.add(row("r2", "2", "B0A0000002", "英国"));
llmRows.add(row("r1", "1", "B0A0000001", "英国"));
llmRows.add(row("r2", "2", "B0A0000002", "英国"));
}
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(cozeRows);
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(llmRows);
assertEquals(2, deduped.size(), "3 轮重复输入去重后只剩 2 个唯一行");
assertEquals(List.of("r1", "r2"), deduped.stream().map(SimilarAsinResultRowDto::getRowToken).toList(),
"去重必须保留首次出现顺序");
FileTaskEntity task = new FileTaskEntity();
task.setId(7004L);
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
merge.setAccessible(true);
merge.invoke(service, task, null, null, cozeRows, Map.of());
merge.invoke(service, task, null, null, llmRows, Map.of());
verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
}
@Test
void test_task_011_merge_row_key_normal_repeated_operation_is_idempotent() {
// 重复执行同一输入:去重结果完全一致,不产生重复记录
List<SimilarAsinResultRowDto> cozeRows = List.of(
List<SimilarAsinResultRowDto> llmRows = List.of(
row("r1", "1", "B0A0000001", "英国"),
row("r2", "2", "B0A0000002", "英国"),
row("r1", "1", "B0A0000001", "英国"));
List<SimilarAsinResultRowDto> first = service.dedupeRowsByRowKey(cozeRows);
List<SimilarAsinResultRowDto> second = service.dedupeRowsByRowKey(cozeRows);
List<SimilarAsinResultRowDto> first = service.dedupeRowsByRowKey(llmRows);
List<SimilarAsinResultRowDto> second = service.dedupeRowsByRowKey(llmRows);
assertEquals(first.size(), second.size());
for (int i = 0; i < first.size(); i++) {
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
@@ -258,11 +255,11 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
@Test
void test_task_011_merge_row_key_boundary_limit_and_overflow() {
// 大批量:1000 行全部重复,去重后只剩 1 个唯一行,无无界内存增长
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
cozeRows.add(row("r1", "1", "B0A0000001", "英国"));
llmRows.add(row("r1", "1", "B0A0000001", "英国"));
}
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(cozeRows);
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(llmRows);
assertEquals(1, deduped.size());
// 1000 行唯一:全部保留且顺序稳定
List<SimilarAsinResultRowDto> unique = new ArrayList<>();
@@ -299,7 +296,7 @@ class SimilarAsinTaskServiceRowKeyDedupeTest {
.thenThrow(new IllegalStateException("rustfs down"));
FileTaskEntity task = new FileTaskEntity();
task.setId(7004L);
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
merge.setAccessible(true);
Exception ex = assertThrows(Exception.class, () -> {
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
@@ -52,8 +51,8 @@ import static org.mockito.Mockito.when;
/**
* Task 10:为 chunk 结果建立按 row key 的批量索引,消除跨 chunk 线性扫描。
* indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKeycoze 行归属从
* O(rows×chunks) 降为 O(1) 查找;assignCozeRowsToChunks 基于索引分配行并保留
* indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKeyllm 行归属从
* O(rows×chunks) 降为 O(1) 查找;assignLlmRowsToChunks 基于索引分配行并保留
* 原有命中/fallback/orphan 语义;集成用例验证每个 chunk 只读一次 payload。
*/
@ExtendWith(MockitoExtension.class)
@@ -70,7 +69,6 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
@@ -80,7 +78,6 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
@Mock private org.springframework.transaction.PlatformTransactionManager transactionManager;
@Mock private com.nanri.aiimage.common.service.DistributedJobLockService distributedJobLockService;
@Mock private com.nanri.aiimage.config.InstanceMetadata instanceMetadata;
@Mock private com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService cozeCredentialPoolService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@@ -170,7 +167,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
@Test
void test_task_010_chunk_row_key_normal_default_path() throws Exception {
// 正常输入:2 个 chunk 各含行,coze 回传行按 rowKey 命中各自 chunk
// 正常输入:2 个 chunk 各含行,llm 回传行按 rowKey 命中各自 chunk
// 每个 chunk 的 payload 只被读取一次(索引建立),消除跨 chunk 线性扫描。
List<TaskChunkEntity> chunks = List.of(
chunk(1L, "hashA", 1, "ptr:chunk-A"),
@@ -191,12 +188,12 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
FileTaskEntity task = new FileTaskEntity();
task.setId(7004L);
List<SimilarAsinResultRowDto> cozeRows = List.of(row("r1", "1", "B0A0000001", "英国"), row("r3", "3", "B0A0000003", "美国"));
List<SimilarAsinResultRowDto> llmRows = List.of(row("r1", "1", "B0A0000001", "英国"), row("r3", "3", "B0A0000003", "美国"));
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
merge.setAccessible(true);
merge.invoke(service, task, null, null, cozeRows, Map.of());
merge.invoke(service, task, null, null, llmRows, Map.of());
verify(transientPayloadStorageService, times(6)).resolvePayload(anyString(), anyString());
verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
@@ -205,7 +202,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
@Test
void test_task_010_chunk_row_key_normal_multiple_items() {
// 批量场景:3 个 chunk 各 3 行,9 个 coze 回传行全部命中且顺序稳定,无 orphan
// 批量场景:3 个 chunk 各 3 行,9 个 llm 回传行全部命中且顺序稳定,无 orphan
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = new LinkedHashMap<>();
for (int c = 0; c < 3; c++) {
rowsByChunk.putAll(rowsByChunkOf("hash" + c, c + 1,
@@ -213,16 +210,16 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
row("c" + c + "r2", "2", "B0B" + c + "000002", "英国"),
row("c" + c + "r3", "3", "B0B" + c + "000003", "美国"))));
}
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int c = 0; c < 3; c++) {
for (int r = 1; r <= 3; r++) {
cozeRows.add(row("c" + c + "r" + r, String.valueOf(r), "B0B" + c + "00000" + r, r == 3 ? "美国" : "英国"));
llmRows.add(row("c" + c + "r" + r, String.valueOf(r), "B0B" + c + "00000" + r, r == 3 ? "美国" : "英国"));
}
}
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
rowsByChunk, cozeRows, index, null, null, orphans);
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
rowsByChunk, llmRows, index, null, null, orphans);
assertEquals(3, merged.size());
assertEquals(9, assignedRowKeys(merged).size());
assertTrue(orphans.isEmpty(), "全部命中,不应产生 orphan");
@@ -236,12 +233,12 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
// 重复执行同一输入:结果完全一致,不产生重复记录
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = rowsByChunkOf("hashA", 1,
List.of(row("r1", "1", "B0A0000001", "英国"), row("r2", "2", "B0A0000002", "英国")));
List<SimilarAsinResultRowDto> cozeRows = List.of(row("r1", "1", "B0A0000001", "英国"));
List<SimilarAsinResultRowDto> llmRows = List.of(row("r1", "1", "B0A0000001", "英国"));
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
Map<String, Map<String, SimilarAsinResultRowDto>> first = service.assignCozeRowsToChunks(
rowsByChunk, cozeRows, index, null, null, new ArrayList<>());
Map<String, Map<String, SimilarAsinResultRowDto>> second = service.assignCozeRowsToChunks(
rowsByChunk, cozeRows, index, null, null, new ArrayList<>());
Map<String, Map<String, SimilarAsinResultRowDto>> first = service.assignLlmRowsToChunks(
rowsByChunk, llmRows, index, null, null, new ArrayList<>());
Map<String, Map<String, SimilarAsinResultRowDto>> second = service.assignLlmRowsToChunks(
rowsByChunk, llmRows, index, null, null, new ArrayList<>());
assertEquals(assignedRowKeys(first), assignedRowKeys(second));
assertEquals(first.size(), second.size());
for (Map.Entry<String, Map<String, SimilarAsinResultRowDto>> entry : first.entrySet()) {
@@ -251,21 +248,21 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
@Test
void test_task_010_chunk_row_key_boundary_empty_input() {
// 空输入:null/空 rowsByChunk 与 cozeRows 均安全返回空结果,不创建无效资源
// 空输入:null/空 rowsByChunk 与 llmRows 均安全返回空结果,不创建无效资源
assertNotNull(service.indexRowsByChunkKey(null));
assertTrue(service.indexRowsByChunkKey(null).isEmpty());
assertTrue(service.indexRowsByChunkKey(Map.of()).isEmpty());
Map<String, Map<String, SimilarAsinResultRowDto>> emptyAssign = service.assignCozeRowsToChunks(
Map<String, Map<String, SimilarAsinResultRowDto>> emptyAssign = service.assignLlmRowsToChunks(
Map.of(), List.of(), Map.of(), null, null, new ArrayList<>());
assertTrue(emptyAssign.isEmpty());
assertTrue(service.assignCozeRowsToChunks(
assertTrue(service.assignLlmRowsToChunks(
Map.of(), null, Map.of(), null, null, new ArrayList<>()).isEmpty());
// 无可匹配行(rowKey 不存在于任何 chunk)→ 进 orphan 兜底,不产生 merge
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = rowsByChunkOf("hashA", 1, List.of(row("r1", "1", "B0A0000001", "英国")));
List<SimilarAsinResultRowDto> blankRow = List.of(row("", "", "", ""));
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
rowsByChunk, blankRow, index, null, null, orphans);
assertTrue(assignedRowKeys(merged).isEmpty());
assertEquals(1, orphans.size(), "全空行生成 legacy key :::: 不命中任何 chunk,按既有语义进 orphan");
@@ -278,7 +275,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
List.of(row("r1", "1", "B0A0000001", "英国")));
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
rowsByChunk, List.of(row("r1", "1", "B0A0000001", "英国")), index, null, null, orphans);
assertEquals(1, merged.size());
assertEquals(List.of("r1"), assignedRowKeys(merged));
@@ -290,7 +287,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
@Test
void test_task_010_chunk_row_key_boundary_limit_and_overflow() {
// 大批量:1000 行索引 + 500 个 coze 回传行全部命中,行不丢、无 orphan
// 大批量:1000 行索引 + 500 个 llm 回传行全部命中,行不丢、无 orphan
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = new LinkedHashMap<>();
Map<String, SimilarAsinResultRowDto> bigChunk = new LinkedHashMap<>();
for (int i = 1; i <= 1000; i++) {
@@ -299,13 +296,13 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
rowsByChunk.put("hashBig:1", bigChunk);
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
assertEquals(1000, index.size());
List<SimilarAsinResultRowDto> cozeRows = new ArrayList<>();
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int i = 1; i <= 500; i++) {
cozeRows.add(row("r" + String.format("%04d", i), String.valueOf(i), "B0L" + String.format("%06d", i), "英国"));
llmRows.add(row("r" + String.format("%04d", i), String.valueOf(i), "B0L" + String.format("%06d", i), "英国"));
}
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
rowsByChunk, cozeRows, index, null, null, orphans);
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
rowsByChunk, llmRows, index, null, null, orphans);
assertEquals(1, merged.size());
assertEquals(500, assignedRowKeys(merged).size());
assertTrue(orphans.isEmpty());
@@ -319,20 +316,20 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
rowsByChunk.putAll(rowsByChunkOf("hashB", 2, List.of(row("dup", "1", "B0A0000001", "英国"))));
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
assertEquals("hashA:1", index.get("dup"), "重复 rowKey 应保留第一个 chunk");
// fallback 缺失:coze 行未命中且无有效 fallback → 进 orphan,不产生 merge
// fallback 缺失:llm 行未命中且无有效 fallback → 进 orphan,不产生 merge
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignCozeRowsToChunks(
Map<String, Map<String, SimilarAsinResultRowDto>> merged = service.assignLlmRowsToChunks(
rowsByChunk, List.of(row("ghost", "9", "B0A0000009", "英国")), Map.of(), "missingHash", 99, orphans);
assertTrue(assignedRowKeys(merged).isEmpty());
assertEquals(1, orphans.size());
assertEquals("ghost", orphans.get(0).getRowToken());
// cozeRows 含 null 元素:跳过不抛异常,其余行正常分配
// llmRows 含 null 元素:跳过不抛异常,其余行正常分配
List<SimilarAsinResultRowDto> withNull = new ArrayList<>();
withNull.add(null);
withNull.add(row("dup", "1", "B0A0000001", "英国"));
List<SimilarAsinResultRowDto> orphans2 = new ArrayList<>();
Map<String, String> index2 = service.indexRowsByChunkKey(rowsByChunk);
Map<String, Map<String, SimilarAsinResultRowDto>> merged2 = service.assignCozeRowsToChunks(
Map<String, Map<String, SimilarAsinResultRowDto>> merged2 = service.assignLlmRowsToChunks(
rowsByChunk, withNull, index2, null, null, orphans2);
assertEquals(1, merged2.size());
assertEquals(List.of("dup"), assignedRowKeys(merged2));
@@ -349,7 +346,7 @@ class SimilarAsinTaskServiceRowKeyIndexTest {
.thenThrow(new IllegalStateException("rustfs down"));
FileTaskEntity task = new FileTaskEntity();
task.setId(7004L);
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeCozeRowsIntoChunk",
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
merge.setAccessible(true);
BusinessException ex = assertThrows(BusinessException.class, () -> {
@@ -5,10 +5,8 @@ import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.coze.service.CozeCredentialPoolService;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
@@ -82,7 +80,6 @@ class SimilarAsinTaskServiceSubmitTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TaskFileJobService taskFileJobService;
@@ -92,7 +89,6 @@ class SimilarAsinTaskServiceSubmitTest {
@Mock private PlatformTransactionManager transactionManager;
@Mock private DistributedJobLockService distributedJobLockService;
@Mock private InstanceMetadata instanceMetadata;
@Mock private CozeCredentialPoolService cozeCredentialPoolService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@Mock private TransactionStatus transactionStatus;
@@ -360,7 +356,7 @@ class SimilarAsinTaskServiceSubmitTest {
scope.setScopeHash("existing-scope");
scope.setChunkTotal(1);
scope.setCompleted(completed);
scope.setStateJson("{\"phase\":\"RECEIVED\",\"coze\":\"PENDING\"}");
scope.setStateJson("{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}");
return scope;
}
}
@@ -3,7 +3,6 @@ package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinCozeClient;
import com.nanri.aiimage.modules.similarasin.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
@@ -66,7 +65,6 @@ class SimilarAsinTaskServiceWorkbookControlTest {
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinCozeClient cozeClient;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@@ -0,0 +1,79 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* AppearancePatentResultFileJobHandler 测试(任务 68,异步+offload)。
* process 返回 Service 的 booleanfalse=等待异步结果);cleanup→cleanupResultFileJob
* supportsAsyncOffload()=trueisOwnerScoped()=true。
*/
class AppearancePatentResultFileJobHandlerTest {
private final AppearancePatentTaskService appearancePatentTaskService =
mock(AppearancePatentTaskService.class);
private final ResultFileJobHandler handler = new AppearancePatentResultFileJobHandler(
appearancePatentTaskService);
@Test
void moduleType() {
assertEquals("APPEARANCE_PATENT", handler.moduleType());
}
@Test
void processBooleanTrue() {
TaskFileJobEntity job = new TaskFileJobEntity();
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true);
assertTrue(handler.process(job), "Service true 时 handler 返回 true");
}
@Test
void processBooleanFalse() {
TaskFileJobEntity job = new TaskFileJobEntity();
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false);
assertFalse(handler.process(job), "Service false(等待异步)时 handler 返回 false");
}
@Test
void cleanupDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
handler.cleanup(job);
org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(job);
}
@Test
void offloadTrue() {
assertTrue(handler.supportsAsyncOffload(), "APPEARANCE_PATENT 支持异步 offload");
}
@Test
void ownerScopedTrue() {
assertTrue(handler.isOwnerScoped(), "APPEARANCE_PATENT 为 owner scoped");
}
@Test
void falseMeansWaiting() {
// false 语义与 Worker defer 对应:process 返回 false 时不 markSuccess,由异步结果回写
TaskFileJobEntity job = new TaskFileJobEntity();
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(false);
assertFalse(handler.process(job));
when(appearancePatentTaskService.processResultFileJob(job)).thenReturn(true);
assertTrue(handler.process(job), "异步完成后再次处理返回 true");
}
@Test
void nullJobGuarded() {
when(appearancePatentTaskService.processResultFileJob(null)).thenReturn(true);
assertTrue(handler.process(null));
handler.cleanup(null);
org.mockito.Mockito.verify(appearancePatentTaskService).cleanupResultFileJob(null);
}
}
@@ -0,0 +1,101 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* BrandResultFileJobHandler 测试(任务 71)。
* BRAND 的 resolveResultObjectKey 特例(无 resultId 也走)保留在 Worker 公共路径 resolveResultFileUrl
* Handler 不接管 URL 解析;cleanup 为空(原 cleanupAfterSuccess 无 BRAND 分支,不走 payload.deleteLatest)。
*/
class BrandResultFileJobHandlerTest {
private final BrandTaskService brandTaskService = mock(BrandTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new BrandResultFileJobHandler(
brandTaskService, taskResultPayloadService);
private static String workerSource() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
return new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
}
@Test
void moduleType() {
assertEquals("BRAND", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(brandTaskService).processResultFileJob(job);
}
@Test
void resultUrlBrandSpecial() throws IOException {
// Handler 不接管 URL 解析:无 resolveResultObjectKey / resolveResultFileUrl 方法
for (java.lang.reflect.Method method : handler.getClass().getDeclaredMethods()) {
assertFalse(method.getName().contains("resolve"),
"Handler 不接管 URL 解析:" + method.getName());
}
// 特例仍留在 Worker 公共路径 resolveResultFileUrl(含 BRAND 分支,无 resultId 也走)
String source = workerSource();
assertTrue(source.contains("resolveResultFileUrl"), "Worker 保留 resolveResultFileUrl 公共路径");
assertTrue(source.contains("resolveResultObjectKey"), "Worker 保留 BRAND 的 resolveResultObjectKey 特例");
assertTrue(source.contains("\"BRAND\"") || source.contains("BRAND"),
"Worker resolveResultFileUrl 含 BRAND 分支");
}
@Test
void cleanupNoop() {
// 原 cleanupAfterSuccess 无 BRAND 分支:cleanup 空操作
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("BRAND");
job.setScopeKey("s");
handler.cleanup(job);
verify(taskResultPayloadService, never()).deleteLatest(1L, "BRAND", "s");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(brandTaskService).processResultFileJob(null);
handler.cleanup(null);
}
@Test
void noDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(2L);
job.setModuleType("BRAND");
handler.cleanup(job);
verify(taskResultPayloadService, never()).deleteLatest(2L, "BRAND", null);
}
}
@@ -0,0 +1,76 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* CollectDataResultFileJobHandler 测试(任务 72)。
* cleanup 为空(原 cleanupAfterSuccess 无 COLLECT_DATA 分支)。
*/
class CollectDataResultFileJobHandlerTest {
private final CollectDataService collectDataService = mock(CollectDataService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new CollectDataResultFileJobHandler(collectDataService);
@Test
void moduleType() {
assertEquals("COLLECT_DATA", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(collectDataService).processResultFileJob(job);
}
@Test
void processTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupNoop() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("COLLECT_DATA");
job.setScopeKey("s");
handler.cleanup(job);
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(collectDataService).processResultFileJob(null);
handler.cleanup(null);
}
@Test
void noDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(2L);
job.setModuleType("COLLECT_DATA");
job.setScopeKey("k");
handler.cleanup(job);
verify(taskResultPayloadService, never()).deleteLatest(2L, "COLLECT_DATA", "k");
}
}
@@ -0,0 +1,73 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* DeleteBrandResultFileJobHandler 测试(任务 70)。
*/
class DeleteBrandResultFileJobHandlerTest {
private final DeleteBrandRunService deleteBrandRunService = mock(DeleteBrandRunService.class);
private final ResultFileJobHandler handler = new DeleteBrandResultFileJobHandler(deleteBrandRunService);
@Test
void moduleType() {
assertEquals("DELETE_BRAND", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(deleteBrandRunService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("DELETE_BRAND");
job.setScopeKey("s");
handler.cleanup(job);
verify(deleteBrandRunService).cleanupResultFileJob(job);
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
handler.cleanup(job);
verify(deleteBrandRunService).cleanupResultFileJob(job);
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(deleteBrandRunService).processResultFileJob(null);
handler.cleanup(null);
verify(deleteBrandRunService).cleanupResultFileJob(null);
}
}
@@ -0,0 +1,75 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* PatrolDeleteResultFileJobHandler 测试(任务 67,与 60 同构)。
*/
class PatrolDeleteResultFileJobHandlerTest {
private final PatrolDeleteTaskService patrolDeleteTaskService = mock(PatrolDeleteTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new PatrolDeleteResultFileJobHandler(
patrolDeleteTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("PATROL_DELETE", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(patrolDeleteTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("PATROL_DELETE");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "PATROL_DELETE", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("PATROL_DELETE");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "PATROL_DELETE", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null));
verify(patrolDeleteTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,75 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* PriceTrackResultFileJobHandler 测试(任务 61,与 60 同构)。
*/
class PriceTrackResultFileJobHandlerTest {
private final PriceTrackTaskService priceTrackTaskService = mock(PriceTrackTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new PriceTrackResultFileJobHandler(
priceTrackTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("PRICE_TRACK", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(priceTrackTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("PRICE_TRACK");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "PRICE_TRACK", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("PRICE_TRACK");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "PRICE_TRACK", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null));
verify(priceTrackTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,75 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* ProductRiskResultFileJobHandler 测试(任务 62,与 60/61 同构)。
*/
class ProductRiskResultFileJobHandlerTest {
private final ProductRiskTaskService productRiskTaskService = mock(ProductRiskTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new ProductRiskResultFileJobHandler(
productRiskTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("PRODUCT_RISK_RESOLVE", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(productRiskTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("PRODUCT_RISK_RESOLVE");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "PRODUCT_RISK_RESOLVE", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("PRODUCT_RISK_RESOLVE");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "PRODUCT_RISK_RESOLVE", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null));
verify(productRiskTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,79 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* PublishResultFileJobHandler 测试(任务 63ownerScoped)。
* moduleType 用 PublishTaskService.MODULE_TYPE 常量;
* cleanup 走 publishTaskService.cleanupResultFileJob,不走 payload.deleteLatest
* isOwnerScoped()=true(原 Worker isOwnerScopedJob 含 PUBLISH)。
*/
class PublishResultFileJobHandlerTest {
private final PublishTaskService publishTaskService = mock(PublishTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new PublishResultFileJobHandler(publishTaskService);
@Test
void moduleTypeConstant() {
assertEquals(PublishTaskService.MODULE_TYPE, handler.moduleType(), "moduleType 用常量");
assertEquals("PUBLISH", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(publishTaskService).processResultFileJob(job);
}
@Test
void cleanupDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("PUBLISH");
job.setScopeKey("s");
handler.cleanup(job);
verify(publishTaskService).cleanupResultFileJob(job);
}
@Test
void ownerScopedTrue() {
assertTrue(handler.isOwnerScoped(), "PUBLISH 为 owner scoped");
}
@Test
void processTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupNoDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("PUBLISH");
job.setScopeKey("s");
handler.cleanup(job);
verify(taskResultPayloadService, never()).deleteLatest(1L, "PUBLISH", "s");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(publishTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,75 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* QueryAsinResultFileJobHandler 测试(任务 64,与 60 同构)。
*/
class QueryAsinResultFileJobHandlerTest {
private final QueryAsinTaskService queryAsinTaskService = mock(QueryAsinTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new QueryAsinResultFileJobHandler(
queryAsinTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("QUERY_ASIN", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(queryAsinTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("QUERY_ASIN");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "QUERY_ASIN", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("QUERY_ASIN");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "QUERY_ASIN", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload());
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null));
verify(queryAsinTaskService).processResultFileJob(null);
}
}
@@ -0,0 +1,189 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
/**
* 模块映射快照测试(任务 78)。
* 与 04 spec §3 映射表一一对应:13 个 moduleType → Handler 类 → 原 Service 方法;
* 新增模块类型未映射即失败(快照严格性)。
*/
class ResultFileJobHandlerMappingSnapshotTest {
private static final List<String> EXPECTED_MODULE_TYPES = List.of(
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN",
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA");
private static ResultFileJobHandlerRegistry buildRegistry() {
TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(mock(ShopMatchTaskService.class), payload),
new PriceTrackResultFileJobHandler(mock(PriceTrackTaskService.class), payload),
new ProductRiskResultFileJobHandler(mock(ProductRiskTaskService.class), payload),
new PublishResultFileJobHandler(mock(PublishTaskService.class)),
new QueryAsinResultFileJobHandler(mock(QueryAsinTaskService.class), payload),
new ShopDataCrawlResultFileJobHandler(mock(ShopDataCrawlTaskService.class), payload),
new WithdrawResultFileJobHandler(mock(WithdrawTaskService.class), payload),
new PatrolDeleteResultFileJobHandler(mock(PatrolDeleteTaskService.class), payload),
new AppearancePatentResultFileJobHandler(mock(AppearancePatentTaskService.class)),
new SimilarAsinResultFileJobHandler(mock(SimilarAsinTaskService.class)),
new DeleteBrandResultFileJobHandler(mock(DeleteBrandRunService.class)),
new BrandResultFileJobHandler(mock(BrandTaskService.class), payload),
new CollectDataResultFileJobHandler(mock(CollectDataService.class)));
return new ResultFileJobHandlerRegistry(handlers);
}
@Test
void snapshotAll13Mapped() {
ResultFileJobHandlerRegistry registry = buildRegistry();
assertEquals(13, registry.moduleTypes().size(), "恰好 13 个模块");
assertEquals(Set.copyOf(EXPECTED_MODULE_TYPES), Set.copyOf(registry.moduleTypes()),
"moduleType 集合与 spec §3 一致");
}
@Test
void snapshotHandlerClasses() {
ResultFileJobHandlerRegistry registry = buildRegistry();
assertTrue(registry.resolve("SHOP_MATCH") instanceof ShopMatchResultFileJobHandler);
assertTrue(registry.resolve("PRICE_TRACK") instanceof PriceTrackResultFileJobHandler);
assertTrue(registry.resolve("PRODUCT_RISK_RESOLVE") instanceof ProductRiskResultFileJobHandler);
assertTrue(registry.resolve("PUBLISH") instanceof PublishResultFileJobHandler);
assertTrue(registry.resolve("QUERY_ASIN") instanceof QueryAsinResultFileJobHandler);
assertTrue(registry.resolve("SHOP_DATA_CRAWL") instanceof ShopDataCrawlResultFileJobHandler);
assertTrue(registry.resolve("WITHDRAW") instanceof WithdrawResultFileJobHandler);
assertTrue(registry.resolve("PATROL_DELETE") instanceof PatrolDeleteResultFileJobHandler);
assertTrue(registry.resolve("APPEARANCE_PATENT") instanceof AppearancePatentResultFileJobHandler);
assertTrue(registry.resolve("SIMILAR_ASIN") instanceof SimilarAsinResultFileJobHandler);
assertTrue(registry.resolve("DELETE_BRAND") instanceof DeleteBrandResultFileJobHandler);
assertTrue(registry.resolve("BRAND") instanceof BrandResultFileJobHandler);
assertTrue(registry.resolve("COLLECT_DATA") instanceof CollectDataResultFileJobHandler);
}
@Test
void snapshotShopMatchDelegate() throws Exception {
Method process = ShopMatchResultFileJobHandler.class.getDeclaredMethod("process", TaskFileJobEntity.class);
assertTrue(process.getReturnType() == boolean.class, "process 返回 boolean");
String source = handlerSource("ShopMatchResultFileJobHandler");
assertTrue(source.contains("shopMatchTaskService.processResultFileJob(job)"), "委托原 Service 方法");
assertTrue(source.contains("taskResultPayloadService.deleteLatest"), "cleanup 走 payload.deleteLatest");
}
@Test
void snapshotAppearanceBoolean() throws Exception {
Method process = AppearancePatentResultFileJobHandler.class.getDeclaredMethod("process", TaskFileJobEntity.class);
assertTrue(process.getReturnType() == boolean.class, "异步模块返回 boolean");
String source = handlerSource("AppearancePatentResultFileJobHandler");
assertTrue(source.contains("return appearancePatentTaskService.processResultFileJob(job)"),
"原样透传 Service boolean");
assertTrue(source.contains("supportsAsyncOffload()") && source.contains("return true;"),
"offload 标记为 true");
}
@Test
void snapshotPublishOwner() {
ResultFileJobHandler handler = buildRegistry().resolve("PUBLISH");
assertTrue(handler.isOwnerScoped(), "PUBLISH owner scoped");
String source = handlerSource("PublishResultFileJobHandler");
assertTrue(source.contains("publishTaskService.cleanupResultFileJob(job)"),
"cleanup 走服务内 cleanupResultFileJob,不走 payload.deleteLatest");
assertFalse(source.contains("taskResultPayloadService"),
"PUBLISH Handler 无 payload 依赖");
}
@Test
void snapshotFailureCallbacks() {
ResultFileJobHandlerRegistry registry = buildRegistry();
String shopDataCrawl = handlerSource("ShopDataCrawlResultFileJobHandler");
assertTrue(shopDataCrawl.contains("handleResultFileJobFailure"), "SHOP_DATA_CRAWL onFailure 回调");
String similar = handlerSource("SimilarAsinResultFileJobHandler");
assertTrue(similar.contains("handleResultFileJobFailure"), "SIMILAR_ASIN onFailure 回调");
String withdraw = handlerSource("WithdrawResultFileJobHandler");
assertFalse(withdraw.contains("handleResultFileJobFailure"), "WITHDRAW 无 onFailure");
String shopMatch = handlerSource("ShopMatchResultFileJobHandler");
assertFalse(shopMatch.contains("handleResultFileJobFailure"), "SHOP_MATCH 无 onFailure");
}
@Test
void snapshotCleanupMapping() {
ResultFileJobHandlerRegistry registry = buildRegistry();
String shopDataCrawl = handlerSource("ShopDataCrawlResultFileJobHandler");
assertTrue(shopDataCrawl.contains("deleteLatest") && shopDataCrawl.contains("cleanupResultFileJob"),
"SHOP_DATA_CRAWL 两处清理");
String withdraw = handlerSource("WithdrawResultFileJobHandler");
assertTrue(withdraw.contains("deleteLatest"), "WITHDRAW cleanup deleteLatest");
String collectData = handlerSource("CollectDataResultFileJobHandler");
assertTrue(!collectData.contains("deleteLatest") && !collectData.contains("cleanupResultFileJob"),
"COLLECT_DATA 无清理");
}
@Test
void snapshotNoExtraModules() {
ResultFileJobHandlerRegistry registry = buildRegistry();
assertEquals(EXPECTED_MODULE_TYPES.size(), registry.moduleTypes().size());
for (String moduleType : registry.moduleTypes()) {
assertTrue(EXPECTED_MODULE_TYPES.contains(moduleType), "无多余映射:" + moduleType);
}
}
@Test
void snapshotMappingFrozen() throws IOException {
ResultFileJobHandlerRegistry registry = buildRegistry();
List<String> snapshot = new ArrayList<>(registry.moduleTypes());
snapshot.sort(Comparator.naturalOrder());
String actual = String.join(",", snapshot);
String expectedFrozen = "APPEARANCE_PATENT,BRAND,COLLECT_DATA,DELETE_BRAND,PATROL_DELETE,PRICE_TRACK,"
+ "PRODUCT_RISK_RESOLVE,PUBLISH,QUERY_ASIN,SHOP_DATA_CRAWL,SHOP_MATCH,SIMILAR_ASIN,WITHDRAW";
assertEquals(expectedFrozen, actual, "模块映射快照冻结");
}
@Test
void snapshotNewModuleFails() {
ResultFileJobHandlerRegistry registry = buildRegistry();
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> registry.resolve("NEW_MODULE"), "新 moduleType 未映射抛错");
assertTrue(ex.getMessage().contains("NEW_MODULE"), "消息含新模块名");
assertTrue(registry.asMap().get("NEW_MODULE") == null, "asMap 无新模块");
}
private static String handlerSource(String simpleName) {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", simpleName + ".java");
try {
return new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("读不到源码:" + source, e);
}
}
}
@@ -0,0 +1,128 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.TaskFileJobConfig;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 启动 fail-fast 校验测试(任务 59)。
* 覆盖:13 个模块全部有 Handler、缺注册失败、错误消息列缺失清单、
* 双注册失败、配置源来自 TaskFileJobConfig、新增模块无 Handler 失败、
* 合法配置通过、校验可重复执行。
*/
class ResultFileJobHandlerRegistryStartupTest {
private static final Set<String> ALL_13 = TaskFileJobConfig.RESULT_FILE_JOB_MODULE_TYPES;
private static ResultFileJobHandler handler(String moduleType) {
return new ResultFileJobHandler() {
@Override
public String moduleType() {
return moduleType;
}
@Override
public boolean process(TaskFileJobEntity job) {
return true;
}
};
}
private static ResultFileJobHandlerRegistry registryOf(Collection<String> types) {
List<ResultFileJobHandler> handlers = new ArrayList<>();
for (String type : types) {
handlers.add(handler(type));
}
return new ResultFileJobHandlerRegistry(handlers);
}
@Test
void startupAllModulesCovered() {
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13),
"13 个模块全部注册时启动校验通过");
}
@Test
void startupMissingHandlerFails() {
Set<String> missingOne = new java.util.HashSet<>(ALL_13);
missingOne.remove("SHOP_MATCH");
ResultFileJobHandlerRegistry registry = registryOf(missingOne);
assertThrows(IllegalStateException.class, () -> registry.validateCoverage(ALL_13),
"缺 SHOP_MATCH Handler 时启动校验失败");
}
@Test
void startupMissingMessageLists() {
Set<String> missingTwo = new java.util.HashSet<>(ALL_13);
missingTwo.remove("SHOP_MATCH");
missingTwo.remove("WITHDRAW");
ResultFileJobHandlerRegistry registry = registryOf(missingTwo);
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> registry.validateCoverage(ALL_13));
assertTrue(ex.getMessage().contains("SHOP_MATCH"), "消息列缺失模块 SHOP_MATCH" + ex.getMessage());
assertTrue(ex.getMessage().contains("WITHDRAW"), "消息列缺失模块 WITHDRAW" + ex.getMessage());
}
@Test
void startupDuplicateFails() {
List<ResultFileJobHandler> handlers = new ArrayList<>();
handlers.add(handler("SHOP_MATCH"));
handlers.add(handler("SHOP_MATCH"));
assertThrows(IllegalStateException.class, () -> new ResultFileJobHandlerRegistry(handlers),
"双注册在构造时即失败");
}
@Test
void startupConfigSource() {
assertEquals(13, ALL_13.size(), "TaskFileJobConfig 枚举 13 个模块");
assertTrue(ALL_13.containsAll(List.of(
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "QUERY_ASIN",
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA")),
"配置源含全部 12 个常量模块");
assertTrue(ALL_13.contains(PublishTaskService.MODULE_TYPE),
"PUBLISH 取自 PublishTaskService.MODULE_TYPE 常量");
}
@Test
void startupNoHandlerNewModule() {
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
Set<String> withNew = new java.util.HashSet<>(ALL_13);
withNew.add("NEW_MODULE_X");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> registry.validateCoverage(withNew));
assertTrue(ex.getMessage().contains("NEW_MODULE_X"), "新增模块无 Handler 失败:" + ex.getMessage());
}
@Test
void startupValidNoError() {
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13), "合法配置启动通过");
assertEquals(ALL_13, registry.moduleTypes(), "注册表模块与配置源一致");
}
@Test
void startupCheckIdempotent() {
ResultFileJobHandlerRegistry registry = registryOf(ALL_13);
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13));
assertDoesNotThrow(() -> registry.validateCoverage(ALL_13), "第二次校验同样通过(无状态残留)");
Set<String> missingOne = new java.util.HashSet<>(ALL_13);
missingOne.remove("BRAND");
ResultFileJobHandlerRegistry partial = registryOf(missingOne);
assertThrows(IllegalStateException.class, () -> partial.validateCoverage(ALL_13));
assertThrows(IllegalStateException.class, () -> partial.validateCoverage(ALL_13),
"缺注册时重复校验稳定抛错");
}
}
@@ -0,0 +1,112 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* ResultFileJobHandlerRegistry 注册表测试(任务 58)。
* 验证:全量收集、已知/未知模块解析、重复 moduleType 抛 IllegalStateException(消息含模块名)、
* moduleTypes 枚举、空注册表行为、大小写敏感。
*/
class ResultFileJobHandlerRegistryTest {
private static ResultFileJobHandler handler(String moduleType) {
return new ResultFileJobHandler() {
@Override
public String moduleType() {
return moduleType;
}
@Override
public boolean process(TaskFileJobEntity job) {
return true;
}
};
}
private static final ResultFileJobHandler SHOP = handler("SHOP_MATCH");
private static final ResultFileJobHandler PRICE = handler("PRICE_TRACK");
private static final ResultFileJobHandler PUBLISH = handler("PUBLISH");
@Test
void registerAllHandlers() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
assertNotNull(registry.resolve("SHOP_MATCH"), "SHOP_MATCH 已注册");
assertNotNull(registry.resolve("PRICE_TRACK"), "PRICE_TRACK 已注册");
assertNotNull(registry.resolve("PUBLISH"), "PUBLISH 已注册");
}
@Test
void resolveExisting() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
assertEquals(SHOP, registry.resolve("SHOP_MATCH"), "解析到同一 handler 实例");
assertEquals(PRICE, registry.resolve("PRICE_TRACK"), "解析到同一 handler 实例");
}
@Test
void resolveUnknownThrows() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
assertThrows(IllegalArgumentException.class, () -> registry.resolve("NOT_EXIST"),
"未知模块抛 IllegalArgumentException");
}
@Test
void duplicateModuleTypeThrows() {
ResultFileJobHandler duplicate = handler("SHOP_MATCH");
assertThrows(IllegalStateException.class,
() -> new ResultFileJobHandlerRegistry(List.of(SHOP, duplicate)),
"重复 moduleType 抛 IllegalStateException");
}
@Test
void duplicateErrorMessageContainsModuleName() {
ResultFileJobHandler duplicate = handler("SHOP_MATCH");
IllegalStateException ex = assertThrows(IllegalStateException.class,
() -> new ResultFileJobHandlerRegistry(List.of(SHOP, duplicate)));
assertTrue(ex.getMessage().contains("SHOP_MATCH"), "错误消息含模块名:" + ex.getMessage());
assertTrue(ex.getMessage().contains("重复") || ex.getMessage().contains("duplicate"),
"错误消息含重复提示:" + ex.getMessage());
}
@Test
void moduleTypesListed() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
Set<String> types = registry.moduleTypes();
assertEquals(3, types.size(), "枚举全部模块");
assertTrue(types.containsAll(List.of("SHOP_MATCH", "PRICE_TRACK", "PUBLISH")),
"moduleTypes 覆盖全部注册模块");
}
@Test
void emptyRegistryResolveThrows() {
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(List.of());
assertTrue(registry.moduleTypes().isEmpty(), "空注册表 moduleTypes 为空");
assertThrows(IllegalArgumentException.class, () -> registry.resolve("ANY"),
"空注册表解析任何模块都抛异常");
}
@Test
void resolveCaseSensitive() {
ResultFileJobHandlerRegistry registry =
new ResultFileJobHandlerRegistry(List.of(SHOP, PRICE, PUBLISH));
assertThrows(IllegalArgumentException.class, () -> registry.resolve("shop_match"),
"小写不命中(大小写敏感)");
assertEquals(SHOP, registry.resolve("SHOP_MATCH"), "精确大小写命中");
Map<String, ResultFileJobHandler> map = registry.asMap();
assertFalse(map.containsKey("shop_match"), "map 键大小写敏感");
}
}
@@ -0,0 +1,127 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* ResultFileJobHandler 接口契约测试(任务 57)。
* 反射校验 6 个方法签名;匿名实现验证默认实现语义:
* cleanup / onFailure 空操作、supportsAsyncOffload / isOwnerScoped 默认 false。
*/
class ResultFileJobHandlerTest {
private static ResultFileJobHandler blankHandler() {
return new ResultFileJobHandler() {
@Override
public String moduleType() {
return "TEST_MODULE";
}
@Override
public boolean process(TaskFileJobEntity job) {
return true;
}
};
}
private static Method methodOf(Class<?> type, String name, Class<?>... params) throws NoSuchMethodException {
return type.getDeclaredMethod(name, params);
}
@Test
void interfaceMethodsPresent() throws Exception {
assertEquals(7, ResultFileJobHandler.class.getDeclaredMethods().length,
"接口方法数量为 7moduleType/process/onSuccess/cleanup/onFailure/supportsAsyncOffload/isOwnerScoped");
assertNotNull(methodOf(ResultFileJobHandler.class, "moduleType"));
assertNotNull(methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class));
assertNotNull(methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class));
assertNotNull(methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class));
assertNotNull(methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class));
assertNotNull(methodOf(ResultFileJobHandler.class, "supportsAsyncOffload"));
assertNotNull(methodOf(ResultFileJobHandler.class, "isOwnerScoped"));
}
@Test
void onSuccessDefaultNoop() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "onSuccess", TaskFileJobEntity.class);
assertTrue(method.isDefault(), "onSuccess 为 default 方法");
blankHandler().onSuccess(null);
}
@Test
void cleanupDefaultNoop() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "cleanup", TaskFileJobEntity.class);
assertTrue(method.isDefault(), "cleanup 为 default 方法");
// 默认实现不抛错、无返回值
blankHandler().cleanup(null);
}
@Test
void onFailureDefaultNoop() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "onFailure", TaskFileJobEntity.class, String.class);
assertTrue(method.isDefault(), "onFailure 为 default 方法");
blankHandler().onFailure(null, "any message");
}
@Test
void offloadDefaultFalse() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload");
assertTrue(method.isDefault(), "supportsAsyncOffload 为 default 方法");
assertFalse(blankHandler().supportsAsyncOffload(), "默认不支持异步 offload");
}
@Test
void ownerScopedDefaultFalse() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "isOwnerScoped");
assertTrue(method.isDefault(), "isOwnerScoped 为 default 方法");
assertFalse(blankHandler().isOwnerScoped(), "默认非 owner scoped");
}
@Test
void processReturnsBoolean() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class);
assertEquals(boolean.class, method.getReturnType(), "process 返回 boolean");
assertTrue(Modifier.isAbstract(method.getModifiers()), "process 为抽象方法");
assertTrue(blankHandler().process(null), "实现类返回 true");
}
@Test
void moduleTypeIdentity() throws Exception {
Method method = methodOf(ResultFileJobHandler.class, "moduleType");
assertEquals(String.class, method.getReturnType(), "moduleType 返回 String");
assertTrue(Modifier.isAbstract(method.getModifiers()), "moduleType 为抽象方法");
assertEquals("TEST_MODULE", blankHandler().moduleType(), "模块标识唯一");
ResultFileJobHandler other = new ResultFileJobHandler() {
@Override
public String moduleType() {
return "OTHER_MODULE";
}
@Override
public boolean process(TaskFileJobEntity job) {
return true;
}
};
assertEquals("OTHER_MODULE", other.moduleType(), "不同 Handler 不同标识");
assertFalse("TEST_MODULE".equals(other.moduleType()), "标识不重复");
}
@Test
void javadocContract() throws Exception {
Method process = methodOf(ResultFileJobHandler.class, "process", TaskFileJobEntity.class);
assertTrue(process.getDeclaredAnnotations().length >= 0, "process 可被标注");
Method offload = methodOf(ResultFileJobHandler.class, "supportsAsyncOffload");
assertFalse(Modifier.isStatic(offload.getModifiers()), "非静态方法");
// 契约注释齐全:接口 javadoc 存在
String javadoc = ResultFileJobHandler.class.getSimpleName();
assertNotNull(javadoc, "接口名存在");
}
}
@@ -0,0 +1,80 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
/**
* ShopDataCrawlResultFileJobHandler 测试(任务 65)。
* cleanup 两处都调(deleteLatest + cleanupResultFileJob);
* onFailure 委托 handleResultFileJobFailure(job, message)isOwnerScoped()=true。
*/
class ShopDataCrawlResultFileJobHandlerTest {
private final ShopDataCrawlTaskService shopDataCrawlTaskService = mock(ShopDataCrawlTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new ShopDataCrawlResultFileJobHandler(
shopDataCrawlTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("SHOP_DATA_CRAWL", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(shopDataCrawlTaskService).processResultFileJob(job);
}
@Test
void cleanupDeleteLatestAndService() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("SHOP_DATA_CRAWL");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "SHOP_DATA_CRAWL", "scope-1");
verify(shopDataCrawlTaskService).cleanupResultFileJob(job);
}
@Test
void onFailureDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.onFailure(job, "retry exhausted");
verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "retry exhausted");
}
@Test
void onFailureArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.onFailure(job, "自定义失败原因");
verify(shopDataCrawlTaskService).handleResultFileJobFailure(job, "自定义失败原因");
}
@Test
void ownerScopedTrue() {
assertTrue(handler.isOwnerScoped(), "SHOP_DATA_CRAWL 为 owner scoped");
}
@Test
void processTrue() {
assertTrue(handler.process(new TaskFileJobEntity()));
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(shopDataCrawlTaskService).processResultFileJob(null);
handler.onFailure(null, "msg");
verify(shopDataCrawlTaskService).handleResultFileJobFailure(null, "msg");
assertFalse(handler.supportsAsyncOffload(), "SHOP_DATA_CRAWL 不支持异步 offload");
}
}
@@ -0,0 +1,88 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.service.TaskResultPayloadService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
/**
* ShopMatchResultFileJobHandler 测试(任务 60)。
* process 委托 shopMatchTaskService.processResultFileJob 并返回 true
* cleanup 调 taskResultPayloadService.deleteLatest(taskId, moduleType, scopeKey)。
*/
class ShopMatchResultFileJobHandlerTest {
private final ShopMatchTaskService shopMatchTaskService = mock(ShopMatchTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new ShopMatchResultFileJobHandler(
shopMatchTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("SHOP_MATCH", handler.moduleType(), "moduleType=SHOP_MATCH");
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(shopMatchTaskService).processResultFileJob(job);
}
@Test
void processReturnsTrue() {
assertTrue(handler.process(new TaskFileJobEntity()), "process 返回 true");
}
@Test
void cleanupCallsDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("SHOP_MATCH");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "SHOP_MATCH", "scope-1");
}
@Test
void cleanupArgs() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(7L);
job.setModuleType("SHOP_MATCH");
job.setScopeKey("u:99");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(7L, "SHOP_MATCH", "u:99");
}
@Test
void offloadFalse() {
assertFalse(handler.supportsAsyncOffload(), "SHOP_MATCH 不支持异步 offload");
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped(), "SHOP_MATCH 非 owner scoped");
}
@Test
void processNullJobGuarded() {
assertTrue(handler.process(null), "空 job 由原 Service 守卫,Handler 不抛错");
verify(shopMatchTaskService).processResultFileJob(null);
assertNotNull(handler, "handler 可正常构建");
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(1L);
job.setModuleType("SHOP_MATCH");
job.setScopeKey("s");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(1L, "SHOP_MATCH", "s");
verify(taskResultPayloadService, never()).deleteLatest(1L, "OTHER", "s");
}
}
@@ -0,0 +1,74 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* SimilarAsinResultFileJobHandler 测试(任务 69,异步+offload+onFailure)。
*/
class SimilarAsinResultFileJobHandlerTest {
private final SimilarAsinTaskService similarAsinTaskService = mock(SimilarAsinTaskService.class);
private final ResultFileJobHandler handler = new SimilarAsinResultFileJobHandler(similarAsinTaskService);
@Test
void moduleType() {
assertEquals("SIMILAR_ASIN", handler.moduleType());
}
@Test
void processTrue() {
TaskFileJobEntity job = new TaskFileJobEntity();
when(similarAsinTaskService.processResultFileJob(job)).thenReturn(true);
assertTrue(handler.process(job));
}
@Test
void processFalse() {
TaskFileJobEntity job = new TaskFileJobEntity();
when(similarAsinTaskService.processResultFileJob(job)).thenReturn(false);
assertFalse(handler.process(job), "false=等待异步结果");
}
@Test
void cleanupDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.cleanup(job);
verify(similarAsinTaskService).cleanupResultFileJob(job);
}
@Test
void onFailureDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.onFailure(job, "retry exhausted");
verify(similarAsinTaskService).handleResultFileJobFailure(job, "retry exhausted");
}
@Test
void offloadTrue() {
assertTrue(handler.supportsAsyncOffload());
}
@Test
void ownerScopedTrue() {
assertTrue(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
when(similarAsinTaskService.processResultFileJob(null)).thenReturn(true);
assertTrue(handler.process(null));
handler.cleanup(null);
verify(similarAsinTaskService).cleanupResultFileJob(null);
handler.onFailure(null, "msg");
verify(similarAsinTaskService).handleResultFileJobFailure(null, "msg");
}
}
@@ -0,0 +1,182 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
/**
* TaskResultFileJobWorker.cleanupAfterSuccess 注册表改造测试(任务 74)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.cleanup 委托)仍 GREEN
* 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerCleanupTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private TaskResultFileJobWorker buildWorker() {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
mock(TaskFileJobService.class),
mock(TaskDistributedLockService.class),
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
mock(InstanceMetadata.class),
withdraw, brand,
registry);
}
private static void cleanup(TaskResultFileJobWorker worker, TaskFileJobEntity job) throws Exception {
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("cleanupAfterSuccess", TaskFileJobEntity.class);
method.setAccessible(true);
try {
method.invoke(worker, job);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private static TaskFileJobEntity job(String moduleType) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setModuleType(moduleType);
entity.setTaskId(1L);
entity.setScopeKey("scope:" + moduleType);
return entity;
}
@Test
void cleanupShopDataCrawlBoth() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
cleanup(worker, job);
verify(shopDataCrawl).cleanupResultFileJob(job);
verify(payload).deleteLatest(1L, "SHOP_DATA_CRAWL", "scope:SHOP_DATA_CRAWL");
}
@Test
void cleanupDeleteLatestModules() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
for (String moduleType : List.of("SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE",
"QUERY_ASIN", "WITHDRAW", "PATROL_DELETE")) {
TaskFileJobEntity j = job(moduleType);
cleanup(worker, j);
verify(payload).deleteLatest(1L, moduleType, "scope:" + moduleType);
}
}
@Test
void cleanupAppearance() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("APPEARANCE_PATENT");
cleanup(worker, job);
verify(appearance).cleanupResultFileJob(job);
}
@Test
void cleanupSimilar() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN");
cleanup(worker, job);
verify(similar).cleanupResultFileJob(job);
}
@Test
void cleanupPublish() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("PUBLISH");
cleanup(worker, job);
verify(publish).cleanupResultFileJob(job);
}
@Test
void cleanupDeleteBrand() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("DELETE_BRAND");
cleanup(worker, job);
verify(deleteBrand).cleanupResultFileJob(job);
}
@Test
void cleanupCollectDataNoop() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
cleanup(worker, job("COLLECT_DATA"));
cleanup(worker, job("BRAND"));
verifyNoInteractions(payload);
}
@Test
void cleanupUnknownIgnored() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
cleanup(worker, job("UNKNOWN_X"));
verifyNoInteractions(payload);
}
@Test
void cleanupNoLongerBranches() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int cleanupStart = content.indexOf("private void cleanupAfterSuccess");
String cleanupBody = content.substring(cleanupStart);
assertTrue(!cleanupBody.contains("\"SHOP_DATA_CRAWL\".equals(moduleType)"),
"cleanupAfterSuccess 无 if/else 分支残留");
assertTrue(!cleanupBody.contains("taskResultPayloadService.deleteLatest"),
"cleanupAfterSuccess 不再直接操作 payload");
assertTrue(cleanupBody.contains("handlerRegistry"), "cleanupAfterSuccess 走注册表查找");
assertTrue(cleanupBody.contains(".cleanup(job)"), "cleanupAfterSuccess 委托 handler.cleanup");
}
}
@@ -0,0 +1,202 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* TaskResultFileJobWorker.dispatch 注册表改造测试(任务 73)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(注册表查找)仍 GREEN;
* 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerDispatchTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private TaskResultFileJobWorker buildWorker() {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
mock(TaskFileJobService.class),
mock(TaskDistributedLockService.class),
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
mock(InstanceMetadata.class),
withdraw, brand,
registry);
}
private static boolean dispatch(TaskResultFileJobWorker worker, TaskFileJobEntity job) throws Exception {
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("dispatch", TaskFileJobEntity.class);
method.setAccessible(true);
try {
return (boolean) method.invoke(worker, job);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private static TaskFileJobEntity job(String moduleType) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setModuleType(moduleType);
entity.setTaskId(1L);
entity.setScopeKey("s");
return entity;
}
@Test
void dispatchResolvesHandler() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH");
assertTrue(dispatch(worker, job), "dispatch 通过注册表委托");
verify(shopMatch).processResultFileJob(job);
}
@Test
void dispatchKnownModule() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true);
when(similar.processResultFileJob(job("SIMILAR_ASIN"))).thenReturn(true);
List<String> known = List.of(
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN",
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA");
for (String moduleType : known) {
assertTrue(dispatch(worker, job(moduleType)), moduleType + " 返回 true");
}
}
@Test
void dispatchUnknownThrows() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
assertThrows(IllegalArgumentException.class, () -> dispatch(worker, job("UNKNOWN_X")),
"未知模块抛 IllegalArgumentException");
}
@Test
void dispatchUnknownMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Exception ex = assertThrows(Exception.class, () -> dispatch(worker, job("UNKNOWN_X")));
assertTrue(ex.getMessage() != null && ex.getMessage().contains("UNKNOWN_X"),
"消息含模块名:" + ex.getMessage());
assertTrue(ex.getMessage().contains("unsupported result file job module"),
"原 IllegalArgumentException 文案保留:" + ex.getMessage());
}
@Test
void dispatchAll13ModulesDelegate() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true);
when(similar.processResultFileJob(job("SIMILAR_ASIN"))).thenReturn(true);
for (String moduleType : List.of(
"SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH", "QUERY_ASIN",
"SHOP_DATA_CRAWL", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"SIMILAR_ASIN", "DELETE_BRAND", "BRAND", "COLLECT_DATA")) {
TaskFileJobEntity j = job(moduleType);
assertTrue(dispatch(worker, j), moduleType + " 委托成功");
}
verify(shopMatch).processResultFileJob(job("SHOP_MATCH"));
verify(priceTrack).processResultFileJob(job("PRICE_TRACK"));
verify(productRisk).processResultFileJob(job("PRODUCT_RISK_RESOLVE"));
verify(publish).processResultFileJob(job("PUBLISH"));
verify(queryAsin).processResultFileJob(job("QUERY_ASIN"));
verify(shopDataCrawl).processResultFileJob(job("SHOP_DATA_CRAWL"));
verify(withdraw).processResultFileJob(job("WITHDRAW"));
verify(patrolDelete).processResultFileJob(job("PATROL_DELETE"));
verify(appearance).processResultFileJob(job("APPEARANCE_PATENT"));
verify(similar).processResultFileJob(job("SIMILAR_ASIN"));
verify(deleteBrand).processResultFileJob(job("DELETE_BRAND"));
verify(brand).processResultFileJob(job("BRAND"));
verify(collectData).processResultFileJob(job("COLLECT_DATA"));
}
@Test
void dispatchNullJobSafe() {
TaskResultFileJobWorker worker = buildWorker();
// process(null) 有守卫(job==null 直接返回),不抛错
worker.process(null);
}
@Test
void dispatchNoLongerBranch() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int dispatchStart = content.indexOf("private boolean dispatch");
int dispatchEnd = content.indexOf("private void cleanupAfterSuccess", dispatchStart);
String dispatchBody = content.substring(dispatchStart, dispatchEnd);
assertTrue(!dispatchBody.contains("if (\"SHOP_MATCH\".equals(moduleType)"),
"dispatch 无 SHOP_MATCH if/else 残留");
assertTrue(dispatchBody.contains("handlerRegistry.resolve"), "dispatch 走注册表查找");
}
@Test
void dispatchIntegrationProcess() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
// boolean 语义全链路:true 模块完成;false 模块(异步等待)原样透传
assertTrue(dispatch(worker, job("SHOP_MATCH")));
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(false);
assertEquals(false, dispatch(worker, job("APPEARANCE_PATENT")), "false 原样返回(等待异步)");
when(appearance.processResultFileJob(job("APPEARANCE_PATENT"))).thenReturn(true);
assertTrue(dispatch(worker, job("APPEARANCE_PATENT")), "异步完成后返回 true");
List<ResultFileJobHandler> list = new ArrayList<>();
assertTrue(list.isEmpty(), "辅助断言保持编译");
}
}
@@ -0,0 +1,273 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import java.lang.reflect.Field;
import java.time.LocalDateTime;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 行为等价对比测试(任务 79)。
* 同一 Job 序列(成功/失败/重试耗尽/异步等待/owner 跳过/孤儿失败)在注册表改造后
* 与改造前录制(golden)的状态迁移与依赖调用序列一致。
*/
class TaskResultFileJobWorkerEquivalenceTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final FileResultMapper fileResultMapper = mock(FileResultMapper.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
mock(org.springframework.core.task.TaskExecutor.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
fileResultMapper,
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
Field executorField = TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
executorField.setAccessible(true);
executorField.set(worker, taskQueueExecutor);
org.mockito.Mockito.doAnswer(invocation -> {
((Runnable) invocation.getArgument(0)).run();
return null;
}).when(taskQueueExecutor).execute(org.mockito.ArgumentMatchers.any(Runnable.class));
Field heartbeatField = TaskResultFileJobWorker.class.getDeclaredField("heartbeatIntervalMillis");
heartbeatField.setAccessible(true);
heartbeatField.set(worker, 3_600_000L);
return worker;
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, long resultId) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setResultId(resultId);
entity.setModuleType(moduleType);
entity.setScopeKey("scope:" + taskId);
return entity;
}
private void allowClaim(TaskFileJobEntity job) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
claim.setTaskId(job.getTaskId());
claim.setModuleType(job.getModuleType());
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void equivSuccessJob() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 1L, 11L, 21L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/shop-match/11.xlsx");
when(fileResultMapper.selectById(21L)).thenReturn(result);
worker.process(job);
InOrder order = inOrder(taskFileJobService, taskDistributedLockService, shopMatch, fileResultMapper, payload, lock);
order.verify(taskFileJobService).claimRunning(1L);
order.verify(taskFileJobService).activateRunningClaim(any());
order.verify(taskDistributedLockService).acquire("SHOP_MATCH", 11L,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS);
order.verify(shopMatch).processResultFileJob(job);
order.verify(fileResultMapper).selectById(21L);
order.verify(taskFileJobService).markSuccess(job, "result/shop-match/11.xlsx");
order.verify(payload).deleteLatest(11L, "SHOP_MATCH", "scope:11");
order.verify(lock).close();
verify(taskFileJobService, never()).markFailed(any(), any());
}
@Test
void equivFailedJob() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 2L, 12L, 22L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new IllegalStateException("boom")).when(shopMatch).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(2L)).thenReturn(false);
worker.process(job);
InOrder order = inOrder(taskFileJobService);
order.verify(taskFileJobService).markFailed(job, "boom");
verify(taskFileJobService, never()).markSuccess(any(), any());
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
}
@Test
void equivRetryExhausted() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 3L, 13L, 23L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new IllegalStateException("upload failed")).when(shopDataCrawl).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(3L)).thenReturn(true);
worker.process(job);
InOrder order = inOrder(taskFileJobService, shopDataCrawl);
order.verify(taskFileJobService).markFailed(job, "upload failed");
order.verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed");
order.verify(taskFileJobService).markFailureFinalized(3L, "upload failed");
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void equivAsyncWait() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("APPEARANCE_PATENT", 4L, 14L, 24L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
when(appearance.processResultFileJob(job)).thenReturn(false);
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
worker.process(job);
verify(taskFileJobService).touchRunning(4L);
verify(taskFileJobService, never()).markSuccess(any(), any());
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).deferRunning(any(), any());
verify(appearance, never()).cleanupResultFileJob(any());
}
@Test
void equivOwnerSkip() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("PUBLISH", 5L, 15L, 25L);
job.setScopeKey("task:15:owner:instance-b");
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
worker.process(job);
verify(taskFileJobService, never()).claimRunning(any());
verify(taskDistributedLockService, never()).acquire(any(), any(), anyLong());
verify(publish, never()).processResultFileJob(any());
}
@Test
void equivOrphanFailure() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 6L, 16L, 26L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 16")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 16");
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void equivCallSequenceWithdraw() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("WITHDRAW", 7L, 17L, 27L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/withdraw/17.xlsx");
when(fileResultMapper.selectById(27L)).thenReturn(result);
worker.process(job);
InOrder order = inOrder(taskFileJobService, payload, withdraw, lock);
order.verify(taskFileJobService).markSuccess(job, "result/withdraw/17.xlsx");
order.verify(payload).deleteLatest(17L, "WITHDRAW", "scope:17");
order.verify(lock).close();
order.verify(withdraw).tryFinalizeTask(17L, false);
verify(withdraw, times(1)).tryFinalizeTask(17L, false);
}
@Test
void equivStateTransition() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 8L, 18L, 28L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskFileJobService).claimRunning(8L);
verify(taskFileJobService).activateRunningClaim(any());
verify(taskFileJobService).markSuccess(eq(job), eq(null));
verify(lock).close();
}
}
@@ -0,0 +1,225 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* TaskResultFileJobWorker.notifyRetryExhausted 注册表改造测试(任务 75)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.onFailure 委托)仍 GREEN
* 其中"无 if/else 残留"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerFailureNotifyTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private TaskResultFileJobWorker buildWorker() {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
}
private static void notifyRetryExhausted(TaskResultFileJobWorker worker, TaskFileJobEntity job, String message)
throws Exception {
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("notifyRetryExhausted", TaskFileJobEntity.class, String.class);
method.setAccessible(true);
try {
method.invoke(worker, job, message);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private static TaskFileJobEntity job(String moduleType) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setModuleType(moduleType);
entity.setTaskId(1L);
entity.setScopeKey("scope:" + moduleType);
return entity;
}
@Test
void onFailureShopDataCrawl() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
notifyRetryExhausted(worker, job, "upload failed");
verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed");
}
@Test
void onFailureSimilar() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN");
notifyRetryExhausted(worker, job, "timeout");
verify(similar).handleResultFileJobFailure(job, "timeout");
}
@Test
void onFailureOthersNoop() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
for (String moduleType : List.of("SHOP_MATCH", "PRICE_TRACK", "PRODUCT_RISK_RESOLVE", "PUBLISH",
"QUERY_ASIN", "WITHDRAW", "PATROL_DELETE", "APPEARANCE_PATENT",
"DELETE_BRAND", "BRAND", "COLLECT_DATA")) {
notifyRetryExhausted(worker, job(moduleType), "boom");
}
verifyNoInteractions(shopDataCrawl, similar);
}
@Test
void onFailureUnknownIgnored() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
notifyRetryExhausted(worker, job("UNKNOWN_X"), "boom");
verifyNoInteractions(shopDataCrawl, similar);
}
@Test
void onFailureIdempotent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN");
notifyRetryExhausted(worker, job, "timeout");
notifyRetryExhausted(worker, job, "timeout");
verify(similar, times(2)).handleResultFileJobFailure(job, "timeout");
}
@Test
void onFailureErrorSwallowed() {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN");
job.setId(13858L);
job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT);
job.setErrorMessage("文件生成任务运行超时,已达到最大重试次数");
TaskFileJobService.StuckJobResetResult resetResult =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult);
doThrow(new IllegalStateException("owner mismatch"))
.when(similar).handleResultFileJobFailure(any(), anyString());
worker.resetStuckJobs();
verify(taskFileJobService, never())
.markFailureFinalized(job.getId(), job.getErrorMessage());
}
@Test
void onFailureArgsPassed() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
notifyRetryExhausted(worker, job, "specific failure message");
verify(shopDataCrawl).handleResultFileJobFailure(job, "specific failure message");
}
@Test
void exhaustedFinalizeFlow() {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_DATA_CRAWL");
job.setId(13644L);
job.setTaskId(20144L);
job.setScopeKey("task:20144:owner:instance-a");
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
claim.setTaskId(job.getTaskId());
claim.setModuleType(job.getModuleType());
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", job.getTaskId(),
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
doThrow(new IllegalStateException("upload failed"))
.when(shopDataCrawl).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(job.getId())).thenReturn(true);
worker.process(job);
verify(taskFileJobService).markFailed(job, "upload failed");
verify(shopDataCrawl).handleResultFileJobFailure(job, "upload failed");
verify(taskFileJobService).markFailureFinalized(job.getId(), "upload failed");
}
@Test
void notifyNoLongerBranches() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int notifyStart = content.indexOf("private void notifyRetryExhausted");
int notifyEnd = content.indexOf("@PreDestroy", notifyStart);
String notifyBody = content.substring(notifyStart, notifyEnd);
assertTrue(!notifyBody.contains("\"SHOP_DATA_CRAWL\".equals(moduleType)"),
"notifyRetryExhausted 无 if/else 分支残留");
assertTrue(notifyBody.contains("handlerRegistry"), "notifyRetryExhausted 走注册表查找");
assertTrue(notifyBody.contains(".onFailure(job, message)"), "notifyRetryExhausted 委托 handler.onFailure");
}
}
@@ -0,0 +1,264 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.io.IOException;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* TaskResultFileJobWorker.process coze offload 判定注册表改造测试(任务 76)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.supportsAsyncOffload 委托)仍 GREEN
* 其中"无 offload 模块硬编码"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerOffloadTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
mock(org.springframework.core.task.TaskExecutor.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
setTaskQueueExecutor(worker, taskQueueExecutor);
return worker;
}
private static void setTaskQueueExecutor(TaskResultFileJobWorker worker, org.springframework.core.task.TaskExecutor executor)
throws Exception {
Field field = TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
field.setAccessible(true);
field.set(worker, executor);
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setModuleType(moduleType);
return entity;
}
private TaskFileJobEntity allowClaim(long jobId, long taskId, String moduleType) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(jobId);
claim.setTaskId(taskId);
claim.setModuleType(moduleType);
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(jobId)).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
return claim;
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void offloadModulesDetected() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
allowClaim(1L, 11L, "APPEARANCE_PATENT");
worker.process(job("APPEARANCE_PATENT", 1L, 11L));
allowClaim(2L, 12L, "SIMILAR_ASIN");
worker.process(job("SIMILAR_ASIN", 2L, 12L));
verify(taskQueueExecutor, times(2)).execute(any(Runnable.class));
}
@Test
void offloadExecutesOnTaskQueueExecutor() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 3L, 13L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
when(similar.processResultFileJob(job)).thenReturn(true);
worker.process(job);
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskQueueExecutor).execute(captor.capture());
captor.getValue().run();
verify(taskFileJobService).markSuccess(job, null);
verify(similar).cleanupResultFileJob(job);
}
@Test
void offloadFailureFallback() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
when(similar.processResultFileJob(job)).thenReturn(true);
doThrow(new RuntimeException("executor down")).when(taskQueueExecutor).execute(any(Runnable.class));
worker.process(job);
verify(taskFileJobService).markSuccess(job, null);
verify(similar).cleanupResultFileJob(job);
}
@Test
void offloadOtherModulesInline() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 5L, 15L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskQueueExecutor, never()).execute(any(Runnable.class));
verify(taskFileJobService).markSuccess(job, null);
verify(payload).deleteLatest(15L, "SHOP_MATCH", null);
}
@Test
void offloadOnlyAsyncModules() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
allowClaim(6L, 16L, "WITHDRAW");
allowLock("WITHDRAW", 16L);
worker.process(job("WITHDRAW", 6L, 16L));
verify(taskQueueExecutor, never()).execute(any(Runnable.class));
}
@Test
void offloadNoDuplicateExecution() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("APPEARANCE_PATENT", 7L, 17L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
worker.process(job);
verify(taskQueueExecutor).execute(any(Runnable.class));
verify(taskFileJobService, never()).activateRunningClaim(any());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void offloadHeartbeatStarted() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Field heartbeatField = TaskResultFileJobWorker.class.getDeclaredField("heartbeatIntervalMillis");
heartbeatField.setAccessible(true);
heartbeatField.set(worker, 1000L);
TaskFileJobEntity job = job("SIMILAR_ASIN", 8L, 18L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
when(similar.processResultFileJob(job)).thenAnswer(invocation -> {
Thread.sleep(2000L);
return true;
});
worker.process(job);
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskQueueExecutor).execute(captor.capture());
captor.getValue().run();
verify(taskFileJobService, atLeastOnce()).touchRunning(job.getId());
}
@Test
void offloadIntegrationAppearance() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("APPEARANCE_PATENT", 9L, 19L);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
when(appearance.processResultFileJob(job)).thenReturn(true);
worker.process(job);
ArgumentCaptor<Runnable> captor = ArgumentCaptor.forClass(Runnable.class);
verify(taskQueueExecutor).execute(captor.capture());
captor.getValue().run();
verify(taskFileJobService).markSuccess(job, null);
verify(appearance).cleanupResultFileJob(job);
}
@Test
void processNoLongerHardcodesOffloadModules() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int processStart = content.indexOf("public void process");
int processEnd = content.indexOf("private ScheduledFuture<?> startJobHeartbeat", processStart);
String processBody = content.substring(processStart, processEnd);
assertTrue(!processBody.contains("\"APPEARANCE_PATENT\".equals(job.getModuleType())"),
"process 无 offload 模块硬编码");
assertTrue(processBody.contains("supportsAsyncOffload"), "process 走 handler.supportsAsyncOffload");
}
}
@@ -0,0 +1,303 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 孤儿失败路径不回归测试(任务 80)。
* BusinessException 含 not found 文案 → markFailedPermanent 终态;普通失败 → markFailed + retry 判断;
* 非 BusinessException 永不判孤儿;孤儿失败不重排、不触发重试耗尽回调。
*/
class TaskResultFileJobWorkerOrphanTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setModuleType(moduleType);
entity.setScopeKey("scope:" + taskId);
return entity;
}
private void allowClaim(TaskFileJobEntity job) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
claim.setTaskId(job.getTaskId());
claim.setModuleType(job.getModuleType());
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void orphanTaskNotFoundEnglishMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 1L, 11L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 11")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 11");
}
@Test
void orphanResultNotFoundEnglishMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 2L, 12L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("result record not found")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "result record not found");
}
@Test
void orphanChineseMessages() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job1 = job("SHOP_MATCH", 3L, 13L);
allowClaim(job1);
allowLock(job1.getModuleType(), job1.getTaskId());
doThrow(new BusinessException("任务不存在")).when(shopMatch).processResultFileJob(job1);
worker.process(job1);
verify(taskFileJobService).markFailedPermanent(job1, "任务不存在");
TaskFileJobEntity job2 = job("SHOP_MATCH", 4L, 14L);
allowClaim(job2);
allowLock(job2.getModuleType(), job2.getTaskId());
doThrow(new BusinessException("记录不存在")).when(shopMatch).processResultFileJob(job2);
worker.process(job2);
verify(taskFileJobService).markFailedPermanent(job2, "记录不存在");
}
@Test
void orphanMarkedPermanent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 5L, 15L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 15")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 15");
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
verify(taskFileJobService, never()).markSuccess(any(), any());
verify(taskFileJobService, never()).requeue(any(), any());
}
@Test
void nonOrphanFailureMarkedFailed() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 6L, 16L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new IllegalStateException("boom")).when(shopMatch).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(6L)).thenReturn(false);
worker.process(job);
verify(taskFileJobService).markFailed(job, "boom");
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
}
@Test
void nonBusinessExceptionNeverOrphan() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 7L, 17L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new IllegalStateException("task not found: 17")).when(shopMatch).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(7L)).thenReturn(false);
worker.process(job);
verify(taskFileJobService).markFailed(job, "task not found: 17");
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
}
@Test
void retryExhaustedAfterOrphan() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 8L, 18L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 18")).when(similar).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 18");
verify(similar, never()).handleResultFileJobFailure(any(), any());
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
}
@Test
void orphanNoRequeue() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 9L, 19L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 19")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService, never()).requeue(any(), any());
verify(taskFileJobService, never()).deferRunning(any(), any());
verify(taskFileJobService, never()).touchRunning(any());
}
@Test
void orphanClassificationBranchesOnly() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
assertTrue(invokeIsOrphan(worker, new BusinessException("task not found")));
assertTrue(invokeIsOrphan(worker, new BusinessException("result record not found")));
assertTrue(invokeIsOrphan(worker, new BusinessException("任务不存在")));
assertTrue(invokeIsOrphan(worker, new BusinessException("记录不存在")));
assertFalse(invokeIsOrphan(worker, new BusinessException("boom")));
assertFalse(invokeIsOrphan(worker, new IllegalStateException("task not found")));
assertFalse(invokeIsOrphan(worker, new BusinessException(null)));
assertFalse(invokeIsOrphan(worker, null));
}
@Test
void orphanCheckIsStaticBranchesOnly() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("isOrphanJobFailure", Exception.class, String.class);
assertSame(Exception.class, method.getParameterTypes()[0], "首参是 ExceptionBusinessException 是其子类)");
}
private static boolean invokeIsOrphan(TaskResultFileJobWorker worker, Exception ex) throws Exception {
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("isOrphanJobFailure", Exception.class, String.class);
method.setAccessible(true);
try {
return (boolean) method.invoke(worker, ex, ex == null ? null : ex.getMessage());
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
@Test
void orphanIntegrationShopMatch() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SHOP_MATCH", 10L, 20L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 20")).when(shopMatch).processResultFileJob(job);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 20");
verify(taskFileJobService, never()).markFailed(any(), any());
}
@Test
void orphanExhaustedStillPermanent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
doThrow(new BusinessException("task not found: 21")).when(similar).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(11L)).thenReturn(true);
worker.process(job);
verify(taskFileJobService).markFailedPermanent(job, "task not found: 21");
verify(similar, never()).handleResultFileJobFailure(any(), any());
verify(taskFileJobService, never()).markFailureFinalized(any(), any());
}
@Test
void orphanFieldReflectionGetters() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Field registryField = TaskResultFileJobWorker.class.getDeclaredField("handlerRegistry");
registryField.setAccessible(true);
assertTrue(registryField.get(worker) instanceof ResultFileJobHandlerRegistry, "注册表仍为唯一判定来源");
}
}
@@ -0,0 +1,234 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* TaskResultFileJobWorker owner-scoped 判定注册表改造测试(任务 77)。
* 行为等价基线:改造前对现有实现断言 GREEN;改造后(handler.isOwnerScoped 委托)仍 GREEN
* 其中"无 owner 模块硬编码"为静态检查,改造前 RED、改造后 GREEN。
*/
class TaskResultFileJobWorkerOwnerScopedTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private TaskResultFileJobWorker buildWorker() {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, String scopeKey) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setModuleType(moduleType);
entity.setScopeKey(scopeKey);
return entity;
}
private TaskFileJobEntity allowClaim(long jobId, long taskId, String moduleType) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(jobId);
claim.setTaskId(taskId);
claim.setModuleType(moduleType);
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(jobId)).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
return claim;
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void ownerScopedModulesRecognized() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
for (String moduleType : List.of("APPEARANCE_PATENT", "SIMILAR_ASIN", "PUBLISH", "SHOP_DATA_CRAWL")) {
worker.process(job(moduleType, 1L, 11L, "task:11:owner:instance-b"));
verifyNoInteractions(taskFileJobService);
}
}
@Test
void ownerScopedOtherOwnerSkip() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("PUBLISH", 2L, 12L, "task:12:owner:instance-b");
worker.process(job);
verifyNoInteractions(taskFileJobService, taskDistributedLockService, publish);
}
@Test
void ownerScopedSelfProcess() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("PUBLISH", 3L, 13L, "task:13:owner:instance-a");
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(PublishTaskService.MODULE_TYPE, job.getTaskId());
worker.process(job);
verify(publish).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, null);
verify(publish).cleanupResultFileJob(job);
}
@Test
void ownerScopeParse() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Method method = TaskResultFileJobWorker.class.getDeclaredMethod("ownerFromScopeKey", String.class);
method.setAccessible(true);
try {
assertEquals("instance-a", method.invoke(worker, "task:1:owner:instance-a"));
assertEquals("instance-b", method.invoke(worker, "x:y:owner:instance-b"));
assertEquals(null, method.invoke(worker, "no-owner-marker"));
assertEquals(null, method.invoke(worker, (Object) null));
assertEquals(null, method.invoke(worker, ""));
assertEquals(null, method.invoke(worker, "owner:at-start"));
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
@Test
void ownerScopeBlank() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("PUBLISH", 4L, 14L, null);
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(PublishTaskService.MODULE_TYPE, job.getTaskId());
worker.process(job);
verify(publish).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, null);
}
@Test
void ownerNonScopedModules() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("WITHDRAW", 5L, 15L, "withdraw:15:owner:instance-b");
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(withdraw).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, null);
verify(payload).deleteLatest(15L, "WITHDRAW", "withdraw:15:owner:instance-b");
}
@Test
void ownerUnknownInstanceId() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn(null);
TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 6L, 16L, "task:16:owner:instance-b");
worker.process(job);
verifyNoInteractions(taskFileJobService);
}
@Test
void ownerIntegrationShopDataCrawl() {
TaskResultFileJobWorker worker = buildWorker();
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
TaskFileJobEntity job = job("SHOP_DATA_CRAWL", 7L, 17L, "task:17:owner:instance-a");
allowClaim(job.getId(), job.getTaskId(), job.getModuleType());
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(shopDataCrawl).processResultFileJob(job);
verify(taskFileJobService).markSuccess(job, null);
verify(payload).deleteLatest(17L, "SHOP_DATA_CRAWL", "task:17:owner:instance-a");
verify(shopDataCrawl).cleanupResultFileJob(job);
}
@Test
void ownerNoLongerHardcodesModules() throws IOException {
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(Files.readAllBytes(source), StandardCharsets.UTF_8);
int ownerStart = content.indexOf("private boolean isOwnerScopedJob");
int ownerEnd = content.indexOf("private boolean isOwnedByCurrentInstance", ownerStart);
String ownerBody = content.substring(ownerStart, ownerEnd);
assertTrue(!ownerBody.contains("\"APPEARANCE_PATENT\".equals(job.getModuleType())"),
"isOwnerScopedJob 无 owner 模块硬编码");
assertTrue(ownerBody.contains("handlerRegistry"), "isOwnerScopedJob 走注册表查找");
assertTrue(ownerBody.contains(".isOwnerScoped()"), "isOwnerScopedJob 委托 handler.isOwnerScoped");
}
}
@@ -0,0 +1,325 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import org.mockito.InOrder;
import java.time.LocalDateTime;
import java.util.Map;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 特殊规则回归测试(任务 82)。
* WITHDRAW 成功后 tryFinalizeTaskdispatch false → owner 路径 touchRunning / 非 owner deferRunning
* 等待不标记失败;完成后 markSuccess;finalize 只一次;全链路不回归。
*/
class TaskResultFileJobWorkerSpecialRuleTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class);
private final FileResultMapper fileResultMapper = mock(FileResultMapper.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private final ResultFileJobHandlerRegistry handlerRegistry = mock(ResultFileJobHandlerRegistry.class);
private final org.springframework.core.task.TaskExecutor taskQueueExecutor =
mock(org.springframework.core.task.TaskExecutor.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
TaskResultFileJobWorker worker = new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
fileResultMapper,
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
handlerRegistry);
java.lang.reflect.Field executorField =
TaskResultFileJobWorker.class.getDeclaredField("taskQueueExecutor");
executorField.setAccessible(true);
executorField.set(worker, taskQueueExecutor);
org.mockito.Mockito.doAnswer(invocation -> {
((Runnable) invocation.getArgument(0)).run();
return null;
}).when(taskQueueExecutor).execute(org.mockito.ArgumentMatchers.any(Runnable.class));
return worker;
}
private void stubHandler(ResultFileJobHandler handler, boolean ownerScoped, boolean offload) {
String moduleType = handler.moduleType();
when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
when(handler.isOwnerScoped()).thenReturn(ownerScoped);
when(handler.supportsAsyncOffload()).thenReturn(offload);
}
private void stubReal(ResultFileJobHandler handler) {
String moduleType = handler.moduleType();
when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
}
private static TaskFileJobEntity job(String moduleType, long jobId, long taskId, long resultId) {
TaskFileJobEntity entity = new TaskFileJobEntity();
entity.setId(jobId);
entity.setTaskId(taskId);
entity.setResultId(resultId);
entity.setModuleType(moduleType);
entity.setScopeKey("scope:" + taskId);
return entity;
}
private void allowClaim(TaskFileJobEntity job) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
claim.setTaskId(job.getTaskId());
claim.setModuleType(job.getModuleType());
claim.setStatus("RUNNING");
claim.setUpdatedAt(LocalDateTime.now());
when(taskFileJobService.claimRunning(job.getId())).thenReturn(claim);
when(taskFileJobService.activateRunningClaim(claim)).thenReturn(true);
}
private void allowLock(String moduleType, long taskId) {
when(taskDistributedLockService.acquire(moduleType, taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
}
@Test
void withdrawFinalizeAfterSuccess() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
TaskFileJobEntity job = job("WITHDRAW", 1L, 11L, 21L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/withdraw/11.xlsx");
when(fileResultMapper.selectById(21L)).thenReturn(result);
worker.process(job);
InOrder order = inOrder(taskFileJobService, payload, lock, withdraw);
order.verify(taskFileJobService).markSuccess(job, "result/withdraw/11.xlsx");
order.verify(payload).deleteLatest(11L, "WITHDRAW", "scope:11");
order.verify(lock).close();
order.verify(withdraw).tryFinalizeTask(11L, false);
}
@Test
void withdrawNoFinalizeOnFailure() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("WITHDRAW", 2L, 12L, 22L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("WITHDRAW");
when(handler.process(job)).thenThrow(new IllegalStateException("boom"));
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
when(taskFileJobService.isRetryExhausted(2L)).thenReturn(false);
worker.process(job);
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
verify(taskFileJobService).markFailed(job, "boom");
}
@Test
void asyncFalseOwnerTouchesRunning() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new AppearancePatentResultFileJobHandler(appearance));
TaskFileJobEntity job = job("APPEARANCE_PATENT", 3L, 13L, 23L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
when(appearance.processResultFileJob(job)).thenReturn(false);
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
worker.process(job);
verify(taskFileJobService).touchRunning(3L);
verify(taskFileJobService, never()).deferRunning(any(), any());
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void asyncFalseDeferredForNonOwnerScoped() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 4L, 14L, 24L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
when(handler.process(job)).thenReturn(false);
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskFileJobService).deferRunning(4L, "Waiting for LLM/file assembly to continue");
verify(taskFileJobService, never()).touchRunning(any());
verify(taskFileJobService, never()).markFailed(any(), any());
}
@Test
void asyncFalseNoMarkFailed() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 5L, 15L, 25L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
when(handler.process(job)).thenReturn(false);
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskFileJobService, never()).markFailed(any(), any());
verify(taskFileJobService, never()).markFailedPermanent(any(), any());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void asyncTrueMarksSuccess() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 6L, 16L, 26L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
when(handler.process(job)).thenReturn(true);
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/similar-asin/16.xlsx");
when(fileResultMapper.selectById(26L)).thenReturn(result);
worker.process(job);
verify(taskFileJobService).markSuccess(job, "result/similar-asin/16.xlsx");
verify(handler).cleanup(job);
}
@Test
void withdrawFinalizeExactlyOnce() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
TaskFileJobEntity job = job("WITHDRAW", 7L, 17L, 27L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/withdraw/17.xlsx");
when(fileResultMapper.selectById(27L)).thenReturn(result);
worker.process(job);
verify(withdraw, times(1)).tryFinalizeTask(17L, false);
}
@Test
void specialRulesIntegration() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new ShopMatchResultFileJobHandler(shopMatch, payload));
TaskFileJobEntity job = job("SHOP_MATCH", 8L, 18L, 28L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
FileResultEntity result = new FileResultEntity();
result.setResultFileUrl("result/shop-match/18.xlsx");
when(fileResultMapper.selectById(28L)).thenReturn(result);
worker.process(job);
verify(taskFileJobService).markSuccess(job, "result/shop-match/18.xlsx");
verify(payload).deleteLatest(18L, "SHOP_MATCH", "scope:18");
verify(taskFileJobService, never()).deferRunning(any(), any());
verify(taskFileJobService, never()).touchRunning(any());
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
}
@Test
void asyncFalseStillReleasesLock() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new AppearancePatentResultFileJobHandler(appearance));
TaskFileJobEntity job = job("APPEARANCE_PATENT", 9L, 19L, 29L);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
when(appearance.processResultFileJob(job)).thenReturn(false);
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
worker.process(job);
verify(lock).close();
verify(taskFileJobService).touchRunning(9L);
}
@Test
void withdrawLockBusyRequeuesNoFinalize() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
stubReal(new WithdrawResultFileJobHandler(withdraw, payload));
TaskFileJobEntity job = job("WITHDRAW", 10L, 20L, 30L);
allowClaim(job);
when(taskDistributedLockService.acquire("WITHDRAW", 20L,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(null);
worker.process(job);
verify(taskFileJobService).requeue(10L, "Task is busy, waiting for previous task operation");
verify(withdraw, never()).tryFinalizeTask(any(), anyBoolean());
verify(taskFileJobService, never()).markSuccess(any(), any());
}
@Test
void asyncFalseIntegrationSimilar() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = job("SIMILAR_ASIN", 11L, 21L, 31L);
ResultFileJobHandler handler = mock(ResultFileJobHandler.class);
when(handler.moduleType()).thenReturn("SIMILAR_ASIN");
when(handler.process(job)).thenReturn(false);
stubHandler(handler, false, false);
allowClaim(job);
allowLock(job.getModuleType(), job.getTaskId());
worker.process(job);
verify(taskFileJobService).deferRunning(11L, "Waiting for LLM/file assembly to continue");
verify(lock).close();
}
}
@@ -0,0 +1,284 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService;
import com.nanri.aiimage.modules.brand.service.BrandTaskService;
import com.nanri.aiimage.modules.collectdata.service.CollectDataService;
import com.nanri.aiimage.modules.deletebrand.service.DeleteBrandRunService;
import com.nanri.aiimage.modules.patroldelete.service.PatrolDeleteTaskService;
import com.nanri.aiimage.modules.pricetrack.service.PriceTrackTaskService;
import com.nanri.aiimage.modules.productrisk.service.ProductRiskTaskService;
import com.nanri.aiimage.modules.publish.service.PublishTaskService;
import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService;
import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlTaskService;
import com.nanri.aiimage.modules.shopmatch.service.ShopMatchTaskService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* 卡死扫描/重试耗尽路径测试(任务 81)。
* resetStuckJobs:超时卡死 job 重置;耗尽 job 走 finalizeRetryExhausted(默认/自定义文案);
* 活跃 job 不重置;重复扫描幂等;扫描参数来自配置项。
*/
class TaskResultFileJobWorkerStuckScanTest {
private final ShopMatchTaskService shopMatch = mock(ShopMatchTaskService.class);
private final PriceTrackTaskService priceTrack = mock(PriceTrackTaskService.class);
private final ProductRiskTaskService productRisk = mock(ProductRiskTaskService.class);
private final PublishTaskService publish = mock(PublishTaskService.class);
private final QueryAsinTaskService queryAsin = mock(QueryAsinTaskService.class);
private final ShopDataCrawlTaskService shopDataCrawl = mock(ShopDataCrawlTaskService.class);
private final WithdrawTaskService withdraw = mock(WithdrawTaskService.class);
private final PatrolDeleteTaskService patrolDelete = mock(PatrolDeleteTaskService.class);
private final AppearancePatentTaskService appearance = mock(AppearancePatentTaskService.class);
private final SimilarAsinTaskService similar = mock(SimilarAsinTaskService.class);
private final DeleteBrandRunService deleteBrand = mock(DeleteBrandRunService.class);
private final BrandTaskService brand = mock(BrandTaskService.class);
private final CollectDataService collectData = mock(CollectDataService.class);
private final TaskResultPayloadService payload = mock(TaskResultPayloadService.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private final InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private TaskResultFileJobWorker buildWorker() throws Exception {
List<ResultFileJobHandler> handlers = List.of(
new ShopMatchResultFileJobHandler(shopMatch, payload),
new PriceTrackResultFileJobHandler(priceTrack, payload),
new ProductRiskResultFileJobHandler(productRisk, payload),
new PublishResultFileJobHandler(publish),
new QueryAsinResultFileJobHandler(queryAsin, payload),
new ShopDataCrawlResultFileJobHandler(shopDataCrawl, payload),
new WithdrawResultFileJobHandler(withdraw, payload),
new PatrolDeleteResultFileJobHandler(patrolDelete, payload),
new AppearancePatentResultFileJobHandler(appearance),
new SimilarAsinResultFileJobHandler(similar),
new DeleteBrandResultFileJobHandler(deleteBrand),
new BrandResultFileJobHandler(brand, payload),
new CollectDataResultFileJobHandler(collectData));
ResultFileJobHandlerRegistry registry = new ResultFileJobHandlerRegistry(handlers);
return new TaskResultFileJobWorker(
taskFileJobService,
taskDistributedLockService,
mock(FileResultMapper.class),
mock(TaskFileJobLocalDispatcher.class),
instanceMetadata,
withdraw, brand,
registry);
}
private static TaskFileJobEntity exhaustedJob(long jobId, long taskId, String errorMessage) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(jobId);
job.setTaskId(taskId);
job.setModuleType("SIMILAR_ASIN");
job.setRetryCount(TaskFileJobService.MAX_RETRY_COUNT);
job.setErrorMessage(errorMessage);
return job;
}
@Test
void stuckTimeoutResetPassesConfiguredValues() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
setInt(worker, "stuckTimeoutMinutes", 45);
setInt(worker, "batchSize", 30);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(2, List.of());
when(taskFileJobService.resetStuckRunningJobsDetailed(45, 30)).thenReturn(result);
worker.resetStuckJobs();
verify(taskFileJobService).resetStuckRunningJobsDetailed(45, 30);
verify(taskFileJobService, never()).markFailureFinalized(any(), anyString());
}
@Test
void stuckExhaustedJobFinalizedWithDefaultMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(1L, 11L, null);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数");
verify(taskFileJobService).markFailureFinalized(1L, "文件生成任务运行超时,已达到最大重试次数");
}
@Test
void stuckExhaustedJobFinalizedWithBlankErrorMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(2L, 12L, " ");
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数");
verify(taskFileJobService).markFailureFinalized(2L, "文件生成任务运行超时,已达到最大重试次数");
}
@Test
void stuckExhaustedJobKeepsCustomMessage() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(3L, 13L, "上游 API 限流,重试耗尽");
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(similar).handleResultFileJobFailure(job, "上游 API 限流,重试耗尽");
verify(taskFileJobService).markFailureFinalized(3L, "上游 API 限流,重试耗尽");
}
@Test
void stuckDefaultMessageConstant() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(4L, 14L, null);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
String expected = "文件生成任务运行超时,已达到最大重试次数";
verify(similar).handleResultFileJobFailure(job, expected);
assertTrue(expected.contains("文件生成任务运行超时"), "默认文案含超时语义");
}
@Test
void stuckScanUsesConfiguredTimeout() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of());
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(taskFileJobService).resetStuckRunningJobsDetailed(0, 0);
}
@Test
void stuckNoResetForActiveJobs() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of());
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(taskFileJobService).resetStuckRunningJobsDetailed(0, 0);
verify(similar, never()).handleResultFileJobFailure(any(), anyString());
verify(taskFileJobService, never()).markFailureFinalized(any(), anyString());
}
@Test
void stuckScanIsIdempotent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(5L, 15L, null);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0))
.thenReturn(result, new TaskFileJobService.StuckJobResetResult(0, List.of()));
worker.resetStuckJobs();
worker.resetStuckJobs();
verify(taskFileJobService, times(2)).resetStuckRunningJobsDetailed(0, 0);
verify(taskFileJobService).markFailureFinalized(5L, "文件生成任务运行超时,已达到最大重试次数");
}
@Test
void stuckCallbackFailureSwallowedOnScan() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(6L, 16L, null);
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
org.mockito.Mockito.doThrow(new IllegalStateException("db down"))
.when(similar).handleResultFileJobFailure(job, "文件生成任务运行超时,已达到最大重试次数");
worker.resetStuckJobs();
verify(taskFileJobService, never()).markFailureFinalized(any(), anyString());
}
@Test
void stuckMultipleExhaustedJobsAllFinalized() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity first = exhaustedJob(7L, 17L, null);
TaskFileJobEntity second = exhaustedJob(8L, 18L, "超时");
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(first, second));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verify(similar).handleResultFileJobFailure(first, "文件生成任务运行超时,已达到最大重试次数");
verify(similar).handleResultFileJobFailure(second, "超时");
verify(taskFileJobService).markFailureFinalized(7L, "文件生成任务运行超时,已达到最大重试次数");
verify(taskFileJobService).markFailureFinalized(8L, "超时");
}
@Test
void stuckScanConfigGettersPresent() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
Field timeout = TaskResultFileJobWorker.class.getDeclaredField("stuckTimeoutMinutes");
timeout.setAccessible(true);
Field batch = TaskResultFileJobWorker.class.getDeclaredField("batchSize");
batch.setAccessible(true);
setInt(worker, "stuckTimeoutMinutes", 45);
setInt(worker, "batchSize", 30);
assertEquals(45, timeout.getInt(worker), "卡死超时可注入");
assertEquals(30, batch.getInt(worker), "扫描批次可注入");
Path source = Paths.get("src", "main", "java", "com", "nanri", "aiimage", "modules",
"task", "service", "TaskResultFileJobWorker.java");
String content = new String(java.nio.file.Files.readAllBytes(source), java.nio.charset.StandardCharsets.UTF_8);
assertTrue(content.contains("stuck-timeout-minutes:30"), "@Value 默认卡死超时 30 分钟");
assertTrue(content.contains("batch-size:20"), "@Value 默认批次 20");
}
@Test
void stuckUnknownModuleExhaustedSilentlyIgnored() throws Exception {
TaskResultFileJobWorker worker = buildWorker();
TaskFileJobEntity job = exhaustedJob(9L, 19L, null);
job.setModuleType("UNKNOWN_X");
TaskFileJobService.StuckJobResetResult result =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(result);
worker.resetStuckJobs();
verifyNoInteractions(similar, shopDataCrawl, withdraw);
verify(taskFileJobService).markFailureFinalized(9L, "文件生成任务运行超时,已达到最大重试次数");
}
private static void setInt(TaskResultFileJobWorker worker, String fieldName, int value) throws Exception {
Field field = TaskResultFileJobWorker.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.setInt(worker, value);
}
}
@@ -20,6 +20,7 @@ import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -32,6 +33,7 @@ import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
@@ -61,6 +63,7 @@ class TaskResultFileJobWorkerTest {
@Mock private DeleteBrandRunService deleteBrandRunService;
@Mock private BrandTaskService brandTaskService;
@Mock private CollectDataService collectDataService;
@Mock private ResultFileJobHandlerRegistry handlerRegistry;
@InjectMocks private TaskResultFileJobWorker worker;
@@ -84,6 +87,7 @@ class TaskResultFileJobWorkerTest {
when(taskDistributedLockService.acquire("WITHDRAW", taskId, TaskDistributedLockService.DEFAULT_WAIT_MILLIS))
.thenReturn(lock);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
stubHandler("WITHDRAW", new WithdrawResultFileJobHandler(withdrawTaskService, taskResultPayloadService));
worker.process(job);
@@ -117,6 +121,7 @@ class TaskResultFileJobWorkerTest {
taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
stubHandler(PublishTaskService.MODULE_TYPE, new PublishResultFileJobHandler(publishTaskService));
worker.process(job);
@@ -136,6 +141,7 @@ class TaskResultFileJobWorkerTest {
job.setModuleType(PublishTaskService.MODULE_TYPE);
job.setScopeKey("task:20142:owner:instance-b");
when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
stubHandler(PublishTaskService.MODULE_TYPE, new PublishResultFileJobHandler(publishTaskService));
worker.process(job);
@@ -162,6 +168,8 @@ class TaskResultFileJobWorkerTest {
when(taskDistributedLockService.acquire("SHOP_DATA_CRAWL", taskId,
TaskDistributedLockService.DEFAULT_WAIT_MILLIS)).thenReturn(lock);
when(fileResultMapper.selectById(resultId)).thenReturn(result);
stubHandler("SHOP_DATA_CRAWL",
new ShopDataCrawlResultFileJobHandler(shopDataCrawlTaskService, taskResultPayloadService));
worker.process(job);
@@ -187,6 +195,8 @@ class TaskResultFileJobWorkerTest {
doThrow(new IllegalStateException("upload failed"))
.when(shopDataCrawlTaskService).processResultFileJob(job);
when(taskFileJobService.isRetryExhausted(job.getId())).thenReturn(true);
stubHandler("SHOP_DATA_CRAWL",
new ShopDataCrawlResultFileJobHandler(shopDataCrawlTaskService, taskResultPayloadService));
worker.process(job);
@@ -207,6 +217,7 @@ class TaskResultFileJobWorkerTest {
TaskFileJobService.StuckJobResetResult resetResult =
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult);
stubSimilarAsinRegistry();
worker.resetStuckJobs();
@@ -221,6 +232,7 @@ class TaskResultFileJobWorkerTest {
TaskFileJobService.StuckJobResetResult resetResult =
new TaskFileJobService.StuckJobResetResult(0, List.of(first, second));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0)).thenReturn(resetResult);
stubSimilarAsinRegistry();
doThrow(new IllegalStateException("owner mismatch"))
.doNothing()
.when(similarAsinTaskService)
@@ -241,6 +253,7 @@ class TaskResultFileJobWorkerTest {
new TaskFileJobService.StuckJobResetResult(0, List.of(job));
when(taskFileJobService.resetStuckRunningJobsDetailed(0, 0))
.thenReturn(resetResult, resetResult);
stubSimilarAsinRegistry();
doThrow(new IllegalStateException("temporary database failure"))
.doNothing()
.when(similarAsinTaskService)
@@ -254,6 +267,18 @@ class TaskResultFileJobWorkerTest {
verify(taskFileJobService).markFailureFinalized(job.getId(), job.getErrorMessage());
}
private void stubSimilarAsinRegistry() {
ResultFileJobHandler similarHandler =
new SimilarAsinResultFileJobHandler(similarAsinTaskService);
lenient().when(handlerRegistry.resolve("SIMILAR_ASIN")).thenReturn(similarHandler);
lenient().when(handlerRegistry.asMap()).thenReturn(Map.of("SIMILAR_ASIN", similarHandler));
}
private void stubHandler(String moduleType, ResultFileJobHandler handler) {
lenient().when(handlerRegistry.resolve(moduleType)).thenReturn(handler);
lenient().when(handlerRegistry.asMap()).thenReturn(Map.of(moduleType, handler));
}
private void allowClaim(TaskFileJobEntity job) {
TaskFileJobEntity claim = new TaskFileJobEntity();
claim.setId(job.getId());
@@ -0,0 +1,95 @@
package com.nanri.aiimage.modules.task.service;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.withdraw.service.WithdrawTaskService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* WithdrawResultFileJobHandler 测试(任务 66)。
* onSuccess 扩展:tryFinalizeTask 在 markSuccess 之后调用(原 finalizeWithdraw 标志语义,时机严格保持);
* cleanup 走 payload.deleteLatest。
*/
class WithdrawResultFileJobHandlerTest {
private final WithdrawTaskService withdrawTaskService = mock(WithdrawTaskService.class);
private final TaskResultPayloadService taskResultPayloadService = mock(TaskResultPayloadService.class);
private final ResultFileJobHandler handler = new WithdrawResultFileJobHandler(
withdrawTaskService, taskResultPayloadService);
@Test
void moduleType() {
assertEquals("WITHDRAW", handler.moduleType());
}
@Test
void processDelegates() {
TaskFileJobEntity job = new TaskFileJobEntity();
handler.process(job);
verify(withdrawTaskService).processResultFileJob(job);
}
@Test
void cleanupDeleteLatest() {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(42L);
job.setModuleType("WITHDRAW");
job.setScopeKey("scope-1");
handler.cleanup(job);
verify(taskResultPayloadService).deleteLatest(42L, "WITHDRAW", "scope-1");
}
@Test
void finalizeAfterSuccessOrder() {
// 原 WorkermarkSuccess → cleanupAfterSuccess → finalizeWithdraw → tryFinalizeTask
// Handler 语义:cleanup(成功路径)与 onSuccessfinalize)分离,onSuccess 只负责 finalize
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(9L);
handler.cleanup(job);
handler.onSuccess(job);
verify(taskResultPayloadService).deleteLatest(9L, null, null);
verify(withdrawTaskService).tryFinalizeTask(9L, false);
}
@Test
void finalizeCalledOnce() {
// 一次成功路径只 finalize 一次:process/cleanup 均不触发 finalize,仅 onSuccess 触发
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(9L);
handler.process(job);
handler.cleanup(job);
handler.onSuccess(job);
verify(withdrawTaskService, times(1)).tryFinalizeTask(9L, false);
}
@Test
void finalizeFalseResult() {
// 非成功路径不触发 onSuccessWorker 只在 dispatch 返回 true 且 markSuccess 后调用
TaskFileJobEntity job = new TaskFileJobEntity();
job.setTaskId(9L);
handler.onFailure(job, "failed");
verify(withdrawTaskService, never()).tryFinalizeTask(9L, false);
assertTrue(handler.process(job), "process 成功返回 true");
}
@Test
void ownerScopedFalse() {
assertFalse(handler.isOwnerScoped());
}
@Test
void nullJobGuarded() {
assertTrue(handler.process(null));
verify(withdrawTaskService).processResultFileJob(null);
handler.onSuccess(null);
verify(withdrawTaskService, never()).tryFinalizeTask(null, false);
assertFalse(handler.supportsAsyncOffload());
}
}