task-116: 后台管理新增/导入表单弹窗化 + 分组管理独立菜单与UI优化 + 轻量进度端点
Build Backend JAR / build (push) Has been cancelled

- 后台管理页(admin)所有面板的新增/导入表单改为弹窗操作,原有字段 ID 全部保留、提交逻辑不变;导入删除入口不再触发二次确认拦截
- 分组管理升级为独立菜单(V102 + schema initializer),移除 5 个面板内的管理分组按钮;分组列表改为蓝白主题、增加权限分组横幅
- Python 侧 group-manage 权限守卫(_ensure_backend_menu_access 补充 group-manage)
- 引入 V101(biz_task_file_job 复合索引)+ 新增 TaskProgressLight/TaskFileJob 轻量端点与进度聚合作
- 前端 progress-light / page-separated-loads / dispatch-guard 共享模块及单元测试
This commit is contained in:
2026-09-01 12:54:13 +08:00
parent 759f0b15d8
commit fa5a59e5cd
139 changed files with 10540 additions and 8834 deletions
@@ -0,0 +1,122 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 115:高频查询 EXPLAIN 索引审计(文档产出验证)。
* 审计文档必须覆盖 history/progress/dashboard 三类高频查询的执行计划结论、
* 候选索引清单(表/列/类型/收益/风险)、全表扫描标记、可重复执行步骤,
* 且本任务明确不做 DDL(只审计,不加索引)。
*/
class ExplainIndexAuditDocTest {
private static final Path AUDIT = Paths.get("src", "main", "resources", "..", "..", "..",
"docs", "explain-index-audit.md").normalize();
private static final Path FLYWAY_SPEC = Paths.get("src", "main", "resources", "..", "..", "..",
"docs", "specs", "12-flyway-and-inspection.md").normalize();
private static String read(Path path) throws IOException {
return new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
}
private static List<String> linesOf(Path path) throws IOException {
return List.of(read(path).split("\r?\n"));
}
private static String lineContaining(String content, String needle) {
for (String line : content.split("\r?\n")) {
if (line.contains(needle)) {
return line;
}
}
return null;
}
@Test
void test_explain_history_plan() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("history"), "审计文档覆盖 history 查询");
assertTrue(doc.contains("EXPLAIN"), "审计文档包含 EXPLAIN 执行计划记录");
}
@Test
void test_explain_progress_plan() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("progress"), "审计文档覆盖 progress 查询");
}
@Test
void test_explain_dashboard_plan() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("dashboard"), "审计文档覆盖 dashboard 查询");
assertTrue(doc.contains("GROUP BY"), "dashboard 聚合查询计划已记录");
}
@Test
void test_full_scan_detected() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("type=ALL") || doc.contains("全表扫描") || doc.contains("ALL")
|| doc.contains("possible_keys") || doc.contains("扫描"),
"审计文档记录执行计划扫描类型(full scan 标记)");
String line = lineContaining(doc, "idx_file_job_task");
assertTrue(line != null && line.contains("task_id"),
"审计文档指出 task_id 前缀索引对 (module_type, job_type, task_id IN) 的局限");
}
@Test
void test_index_candidates_listed() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("idx_file_job_module_type_task")
|| doc.contains("module_type") && doc.contains("job_type") && doc.contains("task_id"),
"候选索引清单含 (module_type, job_type, task_id) 组合");
assertTrue(doc.contains("收益") && doc.contains("风险"),
"候选索引清单含收益与风险说明");
}
@Test
void test_plan_documented() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("biz_task_file_job"), "审计文档引用目标表");
assertTrue(doc.contains("biz_file_task") || doc.contains("biz_file_result"),
"审计文档引用业务任务/结果表");
}
@Test
void test_plan_repeatable() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("EXPLAIN SELECT") || doc.contains("EXPLAIN")
&& doc.contains("手工执行") || doc.contains("执行"),
"审计文档包含可重复执行的 EXPLAIN SQL 步骤");
}
@Test
void test_no_ddl_this_task() throws Exception {
String audit = read(AUDIT);
String spec = read(FLYWAY_SPEC);
assertTrue(audit.contains("不做 DDL") || audit.contains("本任务无 DDL")
|| audit.contains("不新增索引") || audit.contains("只审计"),
"审计文档明确本任务不做 DDL");
assertFalse(audit.contains("ALTER TABLE"),
"审计文档不包含 ALTER TABLEDDL 属于后续任务 116");
assertTrue(spec.contains("只读") || spec.contains("巡检"),
"12 spec 巡检报表模式与审计文档对应");
}
@Test
void test_audit_reflects_batch_loading_queries() throws Exception {
String doc = read(AUDIT);
assertTrue(doc.contains("findAssembleJobsByTaskIds"), "审计覆盖轻量进度 Job 批量查询");
assertTrue(doc.contains("findAssembleJobsByResultIds"), "审计覆盖结果列表 Job 批量查询");
assertTrue(doc.contains("selectMaps") || doc.contains("GROUP BY"), "审计覆盖 dashboard 聚合查询");
}
}
@@ -0,0 +1,132 @@
package com.nanri.aiimage.config;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 116:索引迁移 V{N+1}(审计清单落地)。
* 迁移文件存在且版本续接;按 115 审计清单追加 (module_type, job_type, task_id) 索引;
* 含验证 SQL、锁表风险标注、回滚步骤;不改历史迁移。
*/
class IndexMigrationV101Test {
private static final Path DB_DIR = Paths.get("src", "main", "resources", "db");
private static final Path MIGRATION = DB_DIR.resolve("V101__task_file_job_module_type_task_index.sql");
private static final Path AUDIT = Paths.get("src", "main", "resources", "..", "..", "..",
"docs", "explain-index-audit.md").normalize();
private static String read(Path path) throws IOException {
return new String(Files.readAllBytes(path), StandardCharsets.UTF_8);
}
private static List<Path> allMigrations() throws IOException {
try (Stream<Path> stream = Files.list(DB_DIR)) {
return stream.filter(p -> p.getFileName().toString().matches("V\\d+__.*\\.sql"))
.sorted()
.toList();
}
}
private static int versionOf(Path path) {
String name = path.getFileName().toString();
return Integer.parseInt(name.substring(1, name.indexOf('_')));
}
@Test
void test_migration_file_exists() {
assertTrue(Files.isRegularFile(MIGRATION), "迁移文件必须存在: " + MIGRATION);
}
@Test
void test_migration_version_sequential() throws Exception {
List<Path> all = allMigrations();
assertTrue(!all.isEmpty(), "存在迁移文件");
int maxVersion = all.stream().mapToInt(IndexMigrationV101Test::versionOf).max().orElse(0);
assertTrue(maxVersion >= 100, "现有迁移最大版本 >= V100: " + maxVersion);
assertTrue(versionOf(MIGRATION) == 101, "新迁移版本号 V101 续接 V100 之后");
}
@Test
void test_migration_index_created() throws Exception {
String sql = read(MIGRATION);
assertTrue(sql.contains("biz_task_file_job"), "迁移作用于 biz_task_file_job");
assertTrue(sql.contains("idx_file_job_module_type_task"), "索引名与审计清单一致");
assertTrue(sql.contains("module_type") && sql.contains("job_type") && sql.contains("task_id"),
"索引列为 (module_type, job_type, task_id)");
assertTrue(sql.contains("ADD INDEX"), "使用 ADD INDEX");
}
@Test
void test_migration_conditional_repeatable() throws Exception {
String sql = read(MIGRATION);
assertTrue(sql.contains("information_schema.STATISTICS"), "通过 information_schema 判存在");
assertTrue(sql.contains("INDEX_NAME"), "按索引名判断");
assertTrue(sql.contains("PREPARE") && sql.contains("EXECUTE"), "条件执行(幂等,可重复运行)");
}
@Test
void test_migration_validated() throws Exception {
String sql = read(MIGRATION);
assertTrue(sql.contains("SELECT 1"), "验证 SQL(幂等分支)");
String audit = read(AUDIT);
assertTrue(audit.contains("idx_file_job_module_type_task"), "审计清单已列出该索引");
assertTrue(audit.contains("收益") && audit.contains("风险"), "审计清单含收益/风险");
}
@Test
void test_explain_improved() throws Exception {
String audit = read(AUDIT);
assertTrue(audit.contains("findAssembleJobsByTaskIds"), "审计覆盖 progress/light Job 查询");
assertTrue(audit.contains("执行后重跑") || audit.contains("前后计划")
|| audit.contains("对比"), "审计含迁移前后 EXPLAIN 对比步骤");
}
@Test
void test_migration_no_alter_legacy() throws Exception {
List<Path> all = allMigrations();
List<Path> others = all.stream()
.filter(p -> versionOf(p) != 101)
.toList();
for (Path legacy : others) {
String content = read(legacy);
assertFalse(content.contains("idx_file_job_module_type_task"),
"历史迁移不得包含新索引: " + legacy.getFileName());
}
}
@Test
void test_migration_rollback_doc() throws Exception {
String sql = read(MIGRATION);
assertTrue(sql.contains("DROP INDEX") || sql.contains("drop index"),
"迁移文件含回滚语句(DROP INDEX idx_file_job_module_type_task");
String audit = read(AUDIT);
assertTrue(audit.contains("回滚") || audit.contains("DROP"),
"审计文档含回滚步骤说明");
}
@Test
void test_migration_lock_window() throws Exception {
String sql = read(MIGRATION);
assertTrue(sql.contains("LOCK") || sql.contains("低峰") || sql.contains("lock")
|| sql.contains("窗口"), "迁移标注锁表风险/窗口");
}
@Test
void test_migration_backward_safe() throws Exception {
String sql = read(MIGRATION);
assertTrue(sql.contains("ALTER TABLE"), "普通 BTREE 索引(不重建表、不删列)");
assertFalse(sql.contains("DROP COLUMN"), "不删列");
assertFalse(sql.contains("RENAME"), "不重命名");
assertTrue(sql.contains("ADD INDEX"), "仅追加索引,向后兼容");
}
}
@@ -0,0 +1,449 @@
package com.nanri.aiimage.modules.appearancepatent.service;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.AppearancePatentProperties;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.appearancepatent.client.AppearancePatentLlmClient;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentParsedPayloadDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSubmitResultRequest;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskCacheService;
import com.nanri.aiimage.modules.appearancepatent.service.support.AppearancePatentSheetBuilder;
import com.nanri.aiimage.modules.appearancepatent.service.support.AppearancePatentRowNormalizer;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.transaction.PlatformTransactionManager;
import java.io.File;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 任务 95AppearancePatent 门面改委托。
* writeResultWorkbook/writeReasonSheet 改为委托 AppearancePatentSheetBuilder.buildResultSheet
* (门面保留 SXSSF 写出、异常包装与 rowToken 优先的 findResultRow 语义);
* submitResult 双事务路径与 owner 检查不动;Sheet 链专属私有方法从门面移除。
* 私有门面方法经反射直接验证,不经过完整 job 链路。
*/
class AppearancePatentTaskServiceDelegationTest {
private FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class);
private FileResultMapper fileResultMapper = mock(FileResultMapper.class);
private TaskScopeStateMapper taskScopeStateMapper = mock(TaskScopeStateMapper.class);
private TaskChunkMapper taskChunkMapper = mock(TaskChunkMapper.class);
private LocalFileStorageService localFileStorageService = mock(LocalFileStorageService.class);
private AppearancePatentLlmClient llmClient = mock(AppearancePatentLlmClient.class);
private AppearancePatentTaskCacheService taskCacheService = mock(AppearancePatentTaskCacheService.class); private AppearancePatentProperties properties = mock(AppearancePatentProperties.class);
private StorageProperties storageProperties = mock(StorageProperties.class);
private TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private TaskProgressSnapshotService taskProgressSnapshotService = mock(TaskProgressSnapshotService.class);
private TransientPayloadStorageService transientPayloadStorageService = mock(TransientPayloadStorageService.class);
private PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
private DistributedJobLockService distributedJobLockService = mock(DistributedJobLockService.class);
private TaskDistributedLockService taskDistributedLockService = mock(TaskDistributedLockService.class);
private InstanceMetadata instanceMetadata = mock(InstanceMetadata.class);
private AppearancePatentTaskService service;
private final AtomicInteger txCount = new AtomicInteger();
@BeforeEach
void setUp() throws Exception {
lenient().when(properties.getLlmBatchSize()).thenReturn(50);
// TransactionTemplate.execute 需要 getTransaction 返回非 null statuscommit/rollback 默认为 no-op
lenient().when(transactionManager.getTransaction(any()))
.thenReturn(mock(org.springframework.transaction.TransactionStatus.class));
lenient().when(transientPayloadStorageService.isSharedWriteEnabled()).thenReturn(true);
service = newService();
}
private AppearancePatentTaskService newService() {
return new AppearancePatentTaskService(
localFileStorageService, null, storageProperties, fileTaskMapper, fileResultMapper,
taskScopeStateMapper, taskChunkMapper, new ObjectMapper(), llmClient, taskCacheService,
properties, taskFileJobService, taskProgressSnapshotService,
transientPayloadStorageService, transactionManager, distributedJobLockService,
taskDistributedLockService, instanceMetadata,
mock(TaskProgressLightAssembler.class));
}
private AppearancePatentTaskService serviceWithoutTransactionManager() {
return new AppearancePatentTaskService(
localFileStorageService, null, storageProperties, fileTaskMapper, fileResultMapper,
taskScopeStateMapper, taskChunkMapper, new ObjectMapper(), llmClient, taskCacheService,
properties, taskFileJobService, taskProgressSnapshotService,
transientPayloadStorageService, null, distributedJobLockService,
taskDistributedLockService, instanceMetadata,
mock(TaskProgressLightAssembler.class));
}
// ---------- 1 签名不变 ----------
@Test
void test_write_result_workbook_signature_unchanged() throws Exception {
Method method = AppearancePatentTaskService.class.getDeclaredMethod(
"writeResultWorkbook",
File.class,
AppearancePatentParsedPayloadDto.class,
List.class,
Map.class);
assertEquals(void.class, method.getReturnType(), "返回类型不变");
assertEquals(4, method.getParameterCount(), "参数个数不变");
}
// ---------- 2 委托各组件 ----------
@Test
void test_write_result_workbook_delegates_sheet_builder() throws Exception {
AppearancePatentParsedRowVo row = parsedRow("2_1", "B01A", "US", "f1.xlsx");
AppearancePatentResultRowDto resultRow = resultRow("2_1", "B01A", "US", "f1.xlsx::row::2");
File out = new File("target/appearance-patent-delegation-tmp", "delegate.xlsx");
if (out.exists()) {
out.delete();
}
invokeWriteResultWorkbook(out, row, resultRow);
assertTrue(out.exists(), "委托后结果文件已生成");
try (XSSFWorkbook wb = new XSSFWorkbook(out)) {
Sheet main = wb.getSheet("外观专利检测结果");
assertNotNull(main, "主 sheet 由 SheetBuilder 产出");
Row data = main.getRow(1);
assertEquals("2_1", data.getCell(0).getStringCellValue());
assertEquals("B01A", data.getCell(1).getStringCellValue());
assertEquals("US", data.getCell(2).getStringCellValue());
assertEquals("brand-A", data.getCell(4).getStringCellValue(), "品牌经委托解析");
assertEquals("19.90", data.getCell(5).getStringCellValue(), "价格经委托解析");
assertEquals("无风险", data.getCell(9).getStringCellValue(), "标题维度经委托");
assertEquals("无风险", data.getCell(10).getStringCellValue(), "外观维度经委托");
assertEquals("已侵权", data.getCell(11).getStringCellValue());
assertEquals("成功", data.getCell(12).getStringCellValue(), "状态经委托 resolveResultStatus");
assertNotNull(wb.getSheet("原因"), "原因 sheet 由 SheetBuilder 产出");
}
}
@Test
void test_row_token_matching_preserved_in_delegated_builder() throws Exception {
// 关键行为:resultMap 键为 rowTokenservice rowKey(row) 优先 token),
// 委托查找函数必须保持 rowToken 优先匹配(SheetBuilder 默认 legacy-only,门面必须传入自己的 findResultRow
AppearancePatentParsedRowVo row = parsedRow("2_1", "B01A", "US", "f1.xlsx");
AppearancePatentResultRowDto resultRow = resultRow("2_1", "B01A", "US", "f1.xlsx::row::2");
// 注意:displayId/asin/country 与 resultRow 相同,若用 legacy key 也能匹配;
// 构造一个 legacy 不同但 rowToken 相同的用例验证 token 优先
row.setDisplayId("x_other");
row.setSourceId("x_other");
row.setAsin("B999");
row.setCountry("FR");
File out = new File("target/appearance-patent-delegation-tmp", "token.xlsx");
if (out.exists()) {
out.delete();
}
invokeWriteResultWorkbook(out, row, resultRow);
try (XSSFWorkbook wb = new XSSFWorkbook(out)) {
Row data = wb.getSheet("外观专利检测结果").getRow(1);
assertEquals("已侵权", data.getCell(11).getStringCellValue(),
"rowToken 命中 resultRowlegacy 键不匹配仍能解析结论)");
assertEquals("成功", data.getCell(12).getStringCellValue());
}
}
@Test
void test_delegation_without_result_rows_uses_parsed_fallback() throws Exception {
AppearancePatentParsedRowVo row = parsedRow("2_1", "B01A", "US", "f1.xlsx");
File out = new File("target/appearance-patent-delegation-tmp", "nofallback.xlsx");
if (out.exists()) {
out.delete();
}
invokeWriteResultWorkbook(out, row, null);
try (XSSFWorkbook wb = new XSSFWorkbook(out)) {
Row data = wb.getSheet("外观专利检测结果").getRow(1);
assertEquals("", data.getCell(9).getStringCellValue(), "无结果行 LLM 列留空");
assertEquals("", data.getCell(12).getStringCellValue(), "无结果行状态留空");
assertEquals("brand-A", data.getCell(4).getStringCellValue(), "品牌仍从解析行 values 回退");
assertEquals("", data.getCell(5).getStringCellValue(), "无结果行价格留空(resolvePrice 仅读结果行,与现状一致)");
}
}
// ---------- 3 结果一致(多行 + 原因 sheet 去重) ----------
@Test
void test_multiple_rows_and_reason_dedup() throws Exception {
AppearancePatentParsedRowVo p1 = parsedRow("2_1", "B01A", "US", "f1.xlsx");
AppearancePatentParsedRowVo p2 = parsedRow("2_2", "B01A", "DE", "f1.xlsx");
p2.setRowToken("f1.xlsx::row::3");
p2.setRowIndex(3);
AppearancePatentResultRowDto r1 = resultRow("2_1", "B01A", "US", "f1.xlsx::row::2");
r1.setAppearanceReason("外观理由");
r1.setPatentReason("专利理由");
r1.setTitleReason("标题理由");
File out = new File("target/appearance-patent-delegation-tmp", "multi.xlsx");
if (out.exists()) {
out.delete();
}
invokeWriteResultWorkbook(out, List.of(p1, p2), Map.of(tokenKey(r1), r1));
try (XSSFWorkbook wb = new XSSFWorkbook(out)) {
Sheet main = wb.getSheet("外观专利检测结果");
assertEquals(2, main.getLastRowNum(), "两行数据");
Sheet reason = wb.getSheet("原因");
assertEquals("B01A", reason.getRow(1).getCell(0).getStringCellValue());
assertEquals("外观理由", reason.getRow(1).getCell(1).getStringCellValue());
assertEquals("专利理由", reason.getRow(1).getCell(2).getStringCellValue());
assertEquals("标题理由", reason.getRow(1).getCell(3).getStringCellValue());
assertNull(reason.getRow(2), "同 ASIN 去重");
}
}
// ---------- 4 异常一致 ----------
@Test
void test_write_exception_wrapped_with_original_message() throws Exception {
// 写出目标为已存在目录 → FileOutputStream 抛异常 → 门面包成"生成外观专利检测结果失败"
File badDir = new File("target/appearance-patent-delegation-tmp", "bad-dir");
if (!badDir.exists()) {
badDir.mkdirs();
}
AppearancePatentParsedRowVo row = parsedRow("2_1", "B01A", "US", "f1.xlsx");
Throwable thrown = assertThrows(Throwable.class,
() -> invokeWriteResultWorkbook(badDir, row, null));
Throwable cause = thrown instanceof java.lang.reflect.InvocationTargetException
? ((java.lang.reflect.InvocationTargetException) thrown).getCause()
: thrown;
assertTrue(cause instanceof BusinessException, "实际异常类型: " + cause.getClass());
assertEquals("生成外观专利检测结果失败", cause.getMessage(), "异常包装一致");
}
// ---------- 5 双事务路径不动 ----------
@Test
void test_submit_result_dual_transaction_kept() throws Exception {
// transactionManager 非空 → submitResultLocked 走 REQUIRES_NEW 双事务(persist + complete
FileTaskEntity t = runningTask();
when(fileTaskMapper.selectById(90001L)).thenReturn(t);
when(taskChunkMapper.selectOne(any())).thenReturn(null);
when(taskScopeStateMapper.selectOne(any())).thenReturn(null);
when(taskChunkMapper.selectCount(any())).thenReturn(1L);
when(instanceMetadata.getInstanceId()).thenReturn("server-110");
when(transientPayloadStorageService.storeChunkPayload(anyString(), any(), any(), any(), anyString()))
.thenReturn("transient:stored");
// 提交完成后的调度查询链
when(taskChunkMapper.selectList(any())).thenReturn(List.of());
when(fileResultMapper.selectList(any())).thenReturn(List.of());
service.submitResult(90001L, submitRequest("submission-x"));
verify(taskChunkMapper, Mockito.atLeast(1)).insert(any(TaskChunkEntity.class));
verify(transactionManager, Mockito.atLeast(2)).getTransaction(any());
}
@Test
void test_submit_result_keeps_task_status_check() throws Exception {
// 非 RUNNING 任务 → 双事务路径内 persist 仍抛"任务不是运行中状态"
FileTaskEntity t = task("{\"allItems\":[]}");
t.setStatus("SUCCESS");
when(fileTaskMapper.selectById(90001L)).thenReturn(t);
BusinessException ex = assertThrows(BusinessException.class,
() -> service.submitResult(90001L, submitRequest("submission-status")));
assertTrue(ex.getMessage().contains("任务不是运行中状态"), "实际: " + ex.getMessage());
}
// ---------- 6 owner 检查不动 ----------
@Test
void test_owner_check_rejects_foreign_owner() throws Exception {
service = serviceWithoutTransactionManager();
FileTaskEntity t = task("{\"allItems\":[],\"ownerInstanceId\":\"server-121\"}");
t.setStatus("RUNNING");
when(fileTaskMapper.selectById(90001L)).thenReturn(t);
when(instanceMetadata.getInstanceId()).thenReturn("server-110");
assertThrows(com.nanri.aiimage.common.exception.TaskOwnerMismatchException.class,
() -> service.submitResult(90001L, submitRequest("submission-owner")), "owner 检查仍在手动路径生效");
}
@Test
void test_owner_check_passes_for_matching_owner() throws Exception {
service = serviceWithoutTransactionManager();
FileTaskEntity t = task("{\"allItems\":[],\"ownerInstanceId\":\"server-110\"}");
t.setStatus("RUNNING");
when(fileTaskMapper.selectById(90001L)).thenReturn(t);
when(instanceMetadata.getInstanceId()).thenReturn("server-110");
when(taskChunkMapper.selectOne(any())).thenReturn(null);
when(taskScopeStateMapper.selectOne(any())).thenReturn(null);
when(taskChunkMapper.selectCount(any())).thenReturn(1L);
when(transientPayloadStorageService.storeChunkPayload(anyString(), any(), any(), any(), anyString()))
.thenReturn("transient:stored");
service.submitResult(90001L, submitRequest("submission-owner-ok"));
verify(taskChunkMapper, Mockito.atLeast(1)).insert(any(TaskChunkEntity.class));
verify(fileTaskMapper, Mockito.atLeast(1)).updateById(any(FileTaskEntity.class));
}
// ---------- 7 行值解析语义由 SheetBuilder 承接 ----------
@Test
void test_resolve_helpers_semantics_kept_in_sheet_builder() {
AppearancePatentParsedRowVo parsedRow = new AppearancePatentParsedRowVo();
Map<String, String> values = new LinkedHashMap<>();
values.put("品牌", "Source Brand");
parsedRow.setValues(values);
AppearancePatentResultRowDto resultRow = new AppearancePatentResultRowDto();
resultRow.setBrand("Python Brand");
assertEquals("Python Brand", AppearancePatentSheetBuilder.resolveBrand(resultRow, parsedRow));
resultRow.setBrand(" ");
assertEquals("Source Brand", AppearancePatentSheetBuilder.resolveBrand(resultRow, parsedRow));
assertEquals("", AppearancePatentSheetBuilder.resolvePrice(null));
assertEquals("失败", AppearancePatentSheetBuilder.resolveResultStatus(" "));
assertEquals("成功", AppearancePatentSheetBuilder.resolveResultStatus("已侵权"));
}
@Test
void test_llm_failure_semantics_kept_in_sheet_builder() {
assertTrue(AppearancePatentSheetBuilder.isTechnicalLlmFailure("coze 工作流节点执行超限"));
assertFalse(AppearancePatentSheetBuilder.isTechnicalLlmFailure("无风险"));
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setError("coze 调用超时");
row.setTitleRisk("coze 调用超时");
row.setConclusion("");
assertEquals("coze 调用超时", AppearancePatentSheetBuilder.userFacingLlmCellValue(row, row.getTitleRisk()));
assertEquals("成功", AppearancePatentSheetBuilder.userFacingStatus(row));
}
@Test
void test_normalizer_semantics_unchanged() {
assertEquals("", AppearancePatentRowNormalizer.normalize(null));
assertEquals("b01x", AppearancePatentRowNormalizer.normalize("b01x  "));
assertEquals("A", AppearancePatentRowNormalizer.firstNonBlank(null, "A"));
}
// ---------- 私有门面反射调用 ----------
private void invokeWriteResultWorkbook(File out, AppearancePatentParsedRowVo row,
AppearancePatentResultRowDto resultRow) throws Exception {
invokeWriteResultWorkbook(out, row == null ? List.of() : List.of(row),
resultRow == null ? Map.of() : Map.of(tokenKey(resultRow), resultRow));
}
private void invokeWriteResultWorkbook(File out, List<AppearancePatentParsedRowVo> rows,
Map<String, AppearancePatentResultRowDto> resultMap) throws Exception {
File parent = out.getParentFile();
if (parent != null && !parent.exists()) {
parent.mkdirs();
}
AppearancePatentParsedPayloadDto payload = new AppearancePatentParsedPayloadDto();
payload.setAllItems(rows);
Method method = AppearancePatentTaskService.class.getDeclaredMethod(
"writeResultWorkbook", File.class, AppearancePatentParsedPayloadDto.class, List.class, Map.class);
method.setAccessible(true);
method.invoke(service, out, payload, rows, resultMap);
}
private static String tokenKey(AppearancePatentResultRowDto row) {
return AppearancePatentRowNormalizer.normalize(row.getRowToken());
}
private static FileTaskEntity runningTask() {
FileTaskEntity t = task("{\"allItems\":[]}");
t.setStatus("RUNNING");
return t;
}
private static FileTaskEntity task(String resultJson) {
FileTaskEntity t = new FileTaskEntity();
t.setId(90001L);
t.setModuleType("APPEARANCE_PATENT");
t.setResultJson(resultJson);
t.setUserId(7L);
t.setUpdatedAt(java.time.LocalDateTime.now());
return t;
}
private static AppearancePatentSubmitResultRequest submitRequest(String submissionId) {
AppearancePatentSubmitResultRequest request = new AppearancePatentSubmitResultRequest();
request.setChunkIndex(0);
request.setChunkTotal(1);
request.setSubmissionId(submissionId);
request.setItems(List.of());
return request;
}
private static AppearancePatentParsedRowVo parsedRow(String id, String asin, String country, String fileKey) {
AppearancePatentParsedRowVo row = new AppearancePatentParsedRowVo();
row.setDisplayId(id);
row.setSourceId(id);
row.setAsin(asin);
row.setCountry(country);
row.setSourceFileKey(fileKey);
row.setRowToken(fileKey + "::row::" + 2);
row.setRowIndex(2);
row.setSourceFilename("delegation-source.xlsx");
Map<String, String> values = new LinkedHashMap<>();
values.put("卖家名称", "seller-A");
values.put("品牌", "brand-A");
values.put("价格", "19.90");
row.setValues(values);
return row;
}
private static AppearancePatentResultRowDto resultRow(String id, String asin, String country, String rowToken) {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId(id);
row.setAsin(asin);
row.setCountry(country);
row.setRowToken(rowToken);
row.setTitleRisk("无风险");
row.setAppearanceRisk("无风险");
row.setConclusion("已侵权");
row.setStatus("成功");
row.setBrand("brand-A");
row.setPrice("19.90");
return row;
}
}
@@ -0,0 +1,370 @@
package com.nanri.aiimage.modules.appearancepatent.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.common.service.DistributedJobLockService;
import com.nanri.aiimage.config.AppearancePatentProperties;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.config.StorageProperties;
import com.nanri.aiimage.modules.appearancepatent.client.AppearancePatentLlmClient;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryItemVo;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryVo;
import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskCacheService;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.PlatformTransactionManager;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
/**
* 任务 106appearancepatent 历史列表批量加载(同 105 模式)。
* history() 的任务/结果/Job 关联数据走 IN 批量查询 + 按 ID Map 装配,
* 不允许逐条 N+1:结果 1 次、任务 1 次、Job 1 次(恒定 3 次查询,与结果行数无关);
* 排序(priority → activityTime → createdAt → id)、分页、过滤、输出与现状一致。
*/
class AppearancePatentTaskServiceHistoryBatchTest {
private static final String MODULE = "APPEARANCE_PATENT";
private final List<FileResultEntity> resultDb = new ArrayList<>();
private final List<FileTaskEntity> taskDb = new ArrayList<>();
private final List<TaskFileJobEntity> jobDb = new ArrayList<>();
private final AtomicInteger resultSelectCount = new AtomicInteger();
private final AtomicInteger taskSelectCount = new AtomicInteger();
private final AtomicInteger jobSelectCount = new AtomicInteger();
private final List<Long> lastResultTaskIds = new ArrayList<>();
private FileTaskMapper fileTaskMapper;
private FileResultMapper fileResultMapper;
private TaskScopeStateMapper taskScopeStateMapper;
private TaskChunkMapper taskChunkMapper;
private LocalFileStorageService localFileStorageService;
private AppearancePatentLlmClient llmClient;
private AppearancePatentTaskCacheService taskCacheService;
private AppearancePatentProperties properties;
private StorageProperties storageProperties;
private TaskFileJobService taskFileJobService;
private TaskProgressSnapshotService taskProgressSnapshotService;
private TransientPayloadStorageService transientPayloadStorageService;
private PlatformTransactionManager transactionManager;
private DistributedJobLockService distributedJobLockService;
private TaskDistributedLockService taskDistributedLockService;
private InstanceMetadata instanceMetadata;
private AppearancePatentTaskService service;
/** 从 wrapper SQL 片段解析所有 #{ew.paramNameValuePairs.<key>} 引用的参数值。 */
private static List<Object> paramValuesOf(LambdaQueryWrapper<?> q, String segment) {
List<Object> values = new ArrayList<>();
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("#\\{ew\\.paramNameValuePairs\\.(\\w+)}").matcher(segment);
Map<String, Object> params = q.getParamNameValuePairs();
while (m.find()) {
Object value = params.get(m.group(1));
if (value != null) {
values.add(value);
}
}
return values;
}
@BeforeEach
void setUp() throws Exception {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileResultEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileTaskEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TaskScopeStateEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TaskFileJobEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TaskChunkEntity.class);
fileTaskMapper = mock(FileTaskMapper.class);
fileResultMapper = mock(FileResultMapper.class);
taskScopeStateMapper = mock(TaskScopeStateMapper.class);
taskChunkMapper = mock(TaskChunkMapper.class);
localFileStorageService = mock(LocalFileStorageService.class);
llmClient = mock(AppearancePatentLlmClient.class);
taskCacheService = mock(AppearancePatentTaskCacheService.class);
properties = mock(AppearancePatentProperties.class);
storageProperties = mock(StorageProperties.class);
taskFileJobService = mock(TaskFileJobService.class);
taskProgressSnapshotService = mock(TaskProgressSnapshotService.class);
transientPayloadStorageService = mock(TransientPayloadStorageService.class);
transactionManager = mock(PlatformTransactionManager.class);
distributedJobLockService = mock(DistributedJobLockService.class);
taskDistributedLockService = mock(TaskDistributedLockService.class);
instanceMetadata = mock(InstanceMetadata.class);
// 结果表:按 wrapper 的 user/module/limit 过滤并保持 createdAt 倒序
doAnswer(invocation -> {
resultSelectCount.incrementAndGet();
@SuppressWarnings("unchecked")
LambdaQueryWrapper<FileResultEntity> q = invocation.getArgument(0);
String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment();
List<Object> params = paramValuesOf(q, segment);
List<FileResultEntity> filtered = new ArrayList<>(resultDb);
Long userId = params.stream().filter(Long.class::isInstance).map(Long.class::cast).findFirst().orElse(null);
if (userId != null) {
filtered.removeIf(r -> !userId.equals(r.getUserId()));
}
if (segment.contains("module_type")) {
filtered.removeIf(r -> !MODULE.equals(r.getModuleType()));
}
filtered.sort((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt()));
lastResultTaskIds.clear();
lastResultTaskIds.addAll(filtered.stream().map(FileResultEntity::getTaskId).toList());
if (segment.contains("limit")) {
int cap = 50;
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("limit\\s+(\\d+)", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(segment);
if (m.find()) {
cap = Integer.parseInt(m.group(1));
}
return filtered.subList(0, Math.min(cap, filtered.size()));
}
return filtered;
}).when(fileResultMapper).selectList(any());
// 任务表:IN 查询返回 id 命中集合
doAnswer(invocation -> {
taskSelectCount.incrementAndGet();
@SuppressWarnings("unchecked")
LambdaQueryWrapper<FileTaskEntity> q = invocation.getArgument(0);
String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment();
List<Object> params = paramValuesOf(q, segment);
List<Long> wanted = new ArrayList<>();
for (Object value : params) {
if (value instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Long id) {
wanted.add(id);
}
}
}
}
if (wanted.isEmpty()) {
wanted.addAll(lastResultTaskIds);
}
if (wanted.isEmpty()) {
return List.of();
}
return taskDb.stream().filter(t -> wanted.contains(t.getId())).toList();
}).when(fileTaskMapper).selectList(any());
lenient().when(taskFileJobService.findAssembleJobsByResultIds(eq(MODULE), anyList())).thenAnswer(invocation -> {
jobSelectCount.incrementAndGet();
List<Long> resultIds = invocation.getArgument(1);
Map<Long, TaskFileJobEntity> map = new HashMap<>();
if (resultIds != null) {
for (TaskFileJobEntity job : jobDb) {
if (job.getResultId() != null && resultIds.contains(job.getResultId())) {
map.put(job.getResultId(), job);
}
}
}
return map;
});
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(taskScopeStateMapper.selectList(any())).thenReturn(List.of());
lenient().when(taskScopeStateMapper.selectCount(any())).thenReturn(0L);
lenient().when(taskProgressSnapshotService.find(any(), any())).thenReturn(null);
service = new AppearancePatentTaskService(
localFileStorageService, null, storageProperties, fileTaskMapper, fileResultMapper,
taskScopeStateMapper, taskChunkMapper, new ObjectMapper(), llmClient, taskCacheService,
properties, taskFileJobService, taskProgressSnapshotService,
transientPayloadStorageService, transactionManager, distributedJobLockService,
taskDistributedLockService, instanceMetadata,
mock(TaskProgressLightAssembler.class));
}
private static FileResultEntity result(Long id, Long taskId, Long userId, LocalDateTime createdAt) {
FileResultEntity row = new FileResultEntity();
row.setId(id);
row.setTaskId(taskId);
row.setUserId(userId);
row.setModuleType(MODULE);
row.setSourceFilename("s" + id + ".xlsx");
row.setResultFilename("s" + id + "-result.xlsx");
row.setResultFileUrl("result/appearance-patent/" + id + "/out.xlsx");
row.setSuccess(1);
row.setRowCount(3);
row.setCreatedAt(createdAt);
return row;
}
private static FileTaskEntity task(Long id, String status, LocalDateTime createdAt) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setModuleType(MODULE);
task.setStatus(status);
task.setCreatedAt(createdAt);
task.setUpdatedAt(createdAt);
task.setFinishedAt(createdAt.plusMinutes(5));
return task;
}
private static TaskFileJobEntity job(Long id, Long resultId, String status) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(id);
job.setResultId(resultId);
job.setModuleType(MODULE);
job.setStatus(status);
return job;
}
private void seed(int rows, int tasks, int jobs) {
for (int i = 1; i <= tasks; i++) {
taskDb.add(task(100L + i, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, i)));
}
for (int i = 1; i <= rows; i++) {
resultDb.add(result(200L + i, 100L + (i % tasks) + 1, 1L, LocalDateTime.of(2026, 8, 1, 10, i)));
}
for (int i = 1; i <= jobs; i++) {
jobDb.add(job(300L + i, 200L + i, "SUCCESS"));
}
}
@Test
void historyBatchLoads() throws Exception {
seed(5, 3, 3);
AppearancePatentHistoryVo vo = service.history(1L, 50);
assertEquals(5, vo.getItems().size(), "5 条结果全部装配");
for (AppearancePatentHistoryItemVo item : vo.getItems()) {
assertNotNull(item.getTaskStatus(), "task 状态从批量任务 Map 装配");
assertEquals("SUCCESS", item.getFileStatus(), "job 状态从批量 Job Map 装配");
assertTrue(item.getFileReady(), "结果文件就绪");
}
}
@Test
void historySingleRow() throws Exception {
seed(1, 1, 1);
AppearancePatentHistoryVo vo = service.history(1L, 50);
assertEquals(1, vo.getItems().size());
assertEquals(201L, vo.getItems().getFirst().getResultId());
assertEquals("SUCCESS", vo.getItems().getFirst().getTaskStatus());
}
@Test
void historyMultipleTasks() throws Exception {
seed(4, 4, 4);
AppearancePatentHistoryVo vo = service.history(1L, 50);
assertEquals(4, vo.getItems().size());
long distinctTasks = vo.getItems().stream().map(AppearancePatentHistoryItemVo::getTaskId).distinct().count();
assertEquals(4, distinctTasks, "多个任务分别按 ID Map 装配");
}
@Test
void historyNoJobs() throws Exception {
seed(3, 3, 0);
AppearancePatentHistoryVo vo = service.history(1L, 50);
assertEquals(3, vo.getItems().size());
for (AppearancePatentHistoryItemVo item : vo.getItems()) {
assertNull(item.getFileJobId(), "无 Job 时不附加 jobId");
assertEquals("SUCCESS", item.getFileStatus(), "文件就绪无 Job 状态为 SUCCESS");
}
}
@Test
void historyOrderUnchanged() throws Exception {
seed(3, 1, 0);
AppearancePatentHistoryVo vo = service.history(1L, 50);
assertEquals(List.of(203L, 202L, 201L),
vo.getItems().stream().map(AppearancePatentHistoryItemVo::getResultId).toList(),
"createdAt 倒序保持");
}
@Test
void historyPagination() throws Exception {
seed(12, 1, 0);
AppearancePatentHistoryVo vo = service.history(1L, 5);
assertEquals(5, vo.getItems().size(), "limit 5 只取前 5");
assertEquals(List.of(212L, 211L, 210L, 209L, 208L),
vo.getItems().stream().map(AppearancePatentHistoryItemVo::getResultId).toList());
}
@Test
void historyMissingRelated() throws Exception {
resultDb.add(result(201L, 9999L, 1L, LocalDateTime.of(2026, 8, 1, 10, 1)));
resultDb.add(result(202L, 1001L, 1L, LocalDateTime.of(2026, 8, 1, 10, 0)));
taskDb.add(task(1001L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 0)));
AppearancePatentHistoryVo vo = service.history(1L, 50);
assertEquals(2, vo.getItems().size());
assertNull(vo.getItems().getFirst().getTaskStatus(), "关联任务缺失时状态为 null");
assertEquals("SUCCESS", vo.getItems().get(1).getTaskStatus(), "有关联任务的正常装配");
}
@Test
void historyActiveRunningPrioritized() throws Exception {
// 结果 201 关联 RUNNING 任务(活跃优先),202 关联 SUCCESS 任务(已完成)
resultDb.add(result(201L, 1001L, 1L, LocalDateTime.of(2026, 8, 1, 10, 0)));
resultDb.add(result(202L, 1002L, 1L, LocalDateTime.of(2026, 8, 1, 10, 1)));
taskDb.add(task(1001L, "RUNNING", LocalDateTime.of(2026, 8, 1, 9, 0)));
taskDb.add(task(1002L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 1)));
AppearancePatentHistoryVo vo = service.history(1L, 50);
assertEquals(2, vo.getItems().size());
assertEquals(201L, vo.getItems().getFirst().getResultId(), "RUNNING 任务记录排前");
assertEquals(202L, vo.getItems().get(1).getResultId());
}
@Test
void historyUserFiltered() throws Exception {
resultDb.add(result(201L, 1001L, 1L, LocalDateTime.of(2026, 8, 1, 10, 0)));
resultDb.add(result(202L, 1002L, 2L, LocalDateTime.of(2026, 8, 1, 10, 1)));
taskDb.add(task(1001L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 0)));
taskDb.add(task(1002L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 1)));
AppearancePatentHistoryVo vo = service.history(2L, 50);
assertEquals(1, vo.getItems().size(), "只返回当前用户结果");
assertEquals(202L, vo.getItems().getFirst().getResultId());
}
@Test
void historySqlCountConstant() throws Exception {
seed(20, 4, 20);
service.history(1L, 50);
verify(fileResultMapper, times(1)).selectList(any());
verify(fileTaskMapper, times(1)).selectList(any());
verify(taskFileJobService, times(1)).findAssembleJobsByResultIds(eq(MODULE), anyList());
assertEquals(1, resultSelectCount.get(), "结果 1 次 IN 查询");
assertEquals(1, taskSelectCount.get(), "任务 1 次 IN 查询");
assertEquals(1, jobSelectCount.get(), "Job 1 次 IN 查询");
assertEquals(20, resultSelectCount.get() * 20, "查询次数与结果行数无关(无 N+1");
}
}
@@ -1,11 +1,7 @@
package com.nanri.aiimage.modules.appearancepatent.service;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
class AppearancePatentTaskServiceTest {
@@ -16,41 +12,4 @@ class AppearancePatentTaskServiceTest {
assertEquals("SUCCESS", AppearancePatentTaskService.resolveTaskExecutionStatus(false, false));
assertEquals("FAILED", AppearancePatentTaskService.resolveTaskExecutionStatus(false, true));
}
@Test
void resultStatusFailsOnlyWhenConclusionIsEmpty() {
assertEquals("\u5931\u8d25", AppearancePatentTaskService.resolveResultStatus(null));
assertEquals("\u5931\u8d25", AppearancePatentTaskService.resolveResultStatus(" "));
assertEquals("\u6210\u529f", AppearancePatentTaskService.resolveResultStatus("\u4fb5\u6743"));
assertEquals("\u6210\u529f", AppearancePatentTaskService.resolveResultStatus("\u65e0\u4fb5\u6743"));
}
@Test
void resultBrandPrefersPythonThenFallsBackToSourceFile() {
AppearancePatentParsedRowVo parsedRow = new AppearancePatentParsedRowVo();
parsedRow.setValues(new LinkedHashMap<>());
parsedRow.getValues().put("品牌", "Source Brand");
AppearancePatentResultRowDto resultRow = new AppearancePatentResultRowDto();
resultRow.setBrand("Python Brand");
assertEquals("Python Brand", AppearancePatentTaskService.resolveBrand(resultRow, parsedRow));
resultRow.setBrand(" ");
assertEquals("Source Brand", AppearancePatentTaskService.resolveBrand(resultRow, parsedRow));
parsedRow.getValues().clear();
assertEquals("", AppearancePatentTaskService.resolveBrand(resultRow, parsedRow));
}
@Test
void resultPriceUsesOnlyPythonSubmittedValue() {
assertEquals("", AppearancePatentTaskService.resolvePrice(null));
AppearancePatentResultRowDto resultRow = new AppearancePatentResultRowDto();
resultRow.setPrice(" 12.99 ");
assertEquals("12.99", AppearancePatentTaskService.resolvePrice(resultRow));
resultRow.setPrice(null);
assertEquals("", AppearancePatentTaskService.resolvePrice(resultRow));
}
}
@@ -0,0 +1,167 @@
package com.nanri.aiimage.modules.appearancepatent.service.support;
import com.nanri.aiimage.common.exception.BusinessException;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.FileOutputStream;
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;
/**
* 任务 93AppearancePatentExcelParser 行解析器。
* POI 读 Excel 行 → 中间行对象(原始单元格值)。语义与 AppearancePatentTaskService.parseWorkbook
* 对应段落一致:cell 归一化(BOM/全角空格/trim/连续空白折叠)、表头别名匹配、空行跳过、
* 必填表头缺失抛错。注意:appearancepatent 无 2000 截断、无错误值转空(与 similarasin 不同)。
*/
class AppearancePatentExcelParserTest {
private static final String[] REQUIRED = {"id", "asin", "国家"};
private static File workbook(String[] headers, List<String[]> rows) throws Exception {
File file = File.createTempFile("appearance-patent-parse-", ".xlsx");
try (XSSFWorkbook wb = new XSSFWorkbook(); FileOutputStream out = new FileOutputStream(file)) {
Sheet sheet = wb.createSheet("Sheet1");
Row header = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
for (int r = 0; r < rows.size(); r++) {
Row row = sheet.createRow(r + 1);
for (int c = 0; c < rows.get(r).length; c++) {
row.createCell(c).setCellValue(rows.get(r)[c]);
}
}
wb.write(out);
}
return file;
}
private static final String[] FULL_HEADERS = {"id", "asin", "国家", "价格", "seller sku", "图片链接", "标题"};
@Test
void test_parser_normal_rows() throws Exception {
File file = workbook(FULL_HEADERS, List.of(
new String[]{"2_1", "b01a", "US", "19.90", "SKU-1", "http://img/a.jpg", "title a"},
new String[]{"2_2", "b01b", "DE", "29.90", "SKU-2", "http://img/b.jpg", "title b"}));
AppearancePatentExcelParser.ParsedSheet parsed = new AppearancePatentExcelParser().parse(file);
assertEquals(7, parsed.headers().size(), "表头 7 列");
assertEquals(2, parsed.rows().size());
AppearancePatentExcelParser.AppearanceExcelRow first = parsed.rows().get(0);
assertEquals(2, first.rowIndex(), "行号从 2 开始(表头占 1");
assertEquals("2_1", first.id());
assertEquals("B01A", first.asin(), "asin 归一化后大写");
assertEquals("US", first.country());
assertEquals("19.90", first.price());
assertEquals("SKU-1", first.sku());
assertEquals("http://img/a.jpg", first.url());
assertEquals("title a", first.title());
assertEquals("19.90", first.values().get("价格"), "values 按表头键取值");
}
@Test
void test_parser_blank_row_skipped() throws Exception {
File file = workbook(FULL_HEADERS, List.of(
new String[]{"", "", ""},
new String[]{"1", "B01X", "US"},
new String[]{"", "", ""}));
AppearancePatentExcelParser.ParsedSheet parsed = new AppearancePatentExcelParser().parse(file);
assertEquals(1, parsed.rows().size(), "全空行跳过");
assertEquals("1", parsed.rows().get(0).id());
}
@Test
void test_parser_empty_sheet() throws Exception {
File file = workbook(REQUIRED, List.of());
AppearancePatentExcelParser.ParsedSheet parsed = new AppearancePatentExcelParser().parse(file);
assertTrue(parsed.rows().isEmpty(), "无数据行返回空");
assertEquals(3, parsed.headers().size());
}
@Test
void test_parser_header_aliases() throws Exception {
// 国家用 country、sku 用 merchant_sku、url 用 主图链接 均可识别
File file = workbook(new String[]{"id", "asin", "country", "merchant_sku", "主图链接"},
List.<String[]>of(new String[]{"1", "B01X", "FR", "MS-1", "http://img/x.jpg"}));
AppearancePatentExcelParser.ParsedSheet parsed = new AppearancePatentExcelParser().parse(file);
assertEquals(1, parsed.rows().size());
AppearancePatentExcelParser.AppearanceExcelRow row = parsed.rows().get(0);
assertEquals("FR", row.country());
assertEquals("MS-1", row.sku());
assertEquals("http://img/x.jpg", row.url());
}
@Test
void test_parser_missing_required_header_throws() throws Exception {
File file = workbook(new String[]{"id", "asin"}, List.<String[]>of(new String[]{"1", "B01X"}));
BusinessException ex = assertThrows(BusinessException.class,
() -> new AppearancePatentExcelParser().parse(file));
assertTrue(ex.getMessage().contains("缺少必要表头"), "必填表头缺失抛错,实际: " + ex.getMessage());
}
@Test
void test_parser_empty_header_row_throws() throws Exception {
File file = workbook(new String[]{}, List.of());
BusinessException ex = assertThrows(BusinessException.class,
() -> new AppearancePatentExcelParser().parse(file));
assertTrue(ex.getMessage().contains("表头为空") || ex.getMessage().contains("缺少必要表头"),
"表头缺失抛错,实际: " + ex.getMessage());
}
@Test
void test_parser_cell_normalization() throws Exception {
File file = workbook(FULL_HEADERS, List.<String[]>of(
new String[]{"2", "b01x", " US ", " 19.90 ", " SKU 1 ", " http://img/x.jpg ", " t a "}));
AppearancePatentExcelParser.ParsedSheet parsed = new AppearancePatentExcelParser().parse(file);
AppearancePatentExcelParser.AppearanceExcelRow row = parsed.rows().get(0);
assertEquals("2", row.id(), "BOM 剥离");
assertEquals("B01X", row.asin(), "大写 + trim");
assertEquals("US", row.country(), "trim");
assertEquals("SKU 1", row.sku(), "全角空格转半角 + 折叠");
assertEquals("http://img/x.jpg", row.url(), "url trim");
assertEquals("t a", row.title(), "连续空白折叠");
}
@Test
void test_parser_no_2000_truncation() throws Exception {
// appearancepatent 无字段截断(与 similarasin 不同):超长字段原样保留
String longSku = "S".repeat(5000);
File file = workbook(FULL_HEADERS, List.<String[]>of(new String[]{"1", "B01X", "US", "", longSku, "", ""}));
AppearancePatentExcelParser.ParsedSheet parsed = new AppearancePatentExcelParser().parse(file);
assertEquals(longSku, parsed.rows().get(0).sku(), "超长字段不截断");
assertEquals(5000, parsed.rows().get(0).sku().length());
}
@Test
void test_parser_header_fallback_names() throws Exception {
// 空表头列名回退为 "列N";重复列取首列
File file = workbook(new String[]{"id", "", "asin", "asin", "国家"}, List.<String[]>of(new String[]{"1", "x", "B01A", "B01B", "US"}));
AppearancePatentExcelParser.ParsedSheet parsed = new AppearancePatentExcelParser().parse(file);
assertEquals("列2", parsed.headers().get(1), "空表头回退列N");
assertEquals("asin", parsed.headers().get(2));
assertEquals("asin", parsed.headers().get(3), "重复表头保留");
assertEquals("B01A", parsed.rows().get(0).asin(), "重复列取首个匹配列");
}
}
@@ -0,0 +1,221 @@
package com.nanri.aiimage.modules.appearancepatent.service.support;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryItemVo;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskProgressSnapshotEntity;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 任务 97AppearancePatentHistoryAssembler 历史查询组装器。
* 历史列表 VO 拼装(toHistoryItem + 进度链)抽到独立组件;只读不落库;输出与现状一致。
*/
@ExtendWith(MockitoExtension.class)
class AppearancePatentHistoryAssemblerTest {
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
private AppearancePatentHistoryAssembler assembler;
@BeforeEach
void setUp() {
assembler = new AppearancePatentHistoryAssembler(
taskProgressSnapshotService,
(current, total, job, baseTime) -> current <= 0 ? 1 : Math.min(99, current * 100 / 2),
snapshot -> 42);
lenient().when(taskProgressSnapshotService.find(anyLong(), anyString())).thenReturn(null);
}
private static FileResultEntity result(Long id, Long taskId, String source, String resultFileUrl, Integer success) {
FileResultEntity row = new FileResultEntity();
row.setId(id);
row.setTaskId(taskId);
row.setSourceFilename(source);
row.setResultFilename(source == null ? null : source.replace(".xlsx", "-result.xlsx"));
row.setResultFileUrl(resultFileUrl);
row.setSuccess(success);
row.setErrorMessage(null);
row.setRowCount(12);
row.setCreatedAt(LocalDateTime.of(2026, 8, 1, 10, 0));
return row;
}
private static FileTaskEntity task(Long id, String status, LocalDateTime createdAt) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setStatus(status);
task.setCreatedAt(createdAt);
task.setUpdatedAt(createdAt);
task.setFinishedAt(createdAt == null ? null : createdAt.plusMinutes(5));
return task;
}
private static TaskFileJobEntity job(Long id, String status) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(id);
job.setStatus(status);
job.setErrorMessage(null);
job.setUpdatedAt(LocalDateTime.of(2026, 8, 1, 9, 30));
return job;
}
@Test
void test_assembler_history_items() {
AppearancePatentHistoryItemVo item = assembler.toHistoryItem(
result(100L, 10L, "a.xlsx", "result/10/a.xlsx", 1),
task(10L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 0)),
null);
assertEquals(100L, item.getResultId());
assertEquals(10L, item.getTaskId());
assertEquals("a.xlsx", item.getSourceFilename());
assertEquals("a-result.xlsx", item.getResultFilename());
assertNull(item.getDownloadUrl(), "appearancepatent 无下载 URL 生成");
assertEquals("SUCCESS", item.getTaskStatus());
assertEquals(Boolean.TRUE, item.getSuccess(), "文件 URL 就绪即成功");
assertEquals(12, item.getRowCount());
assertEquals("2026-08-01T10:00", item.getCreatedAt());
assertEquals("2026-08-01T09:00", item.getStartedAt(), "任务开始时间复用 task.createdAt");
assertEquals("2026-08-01T09:05", item.getFinishedAt(), "任务结束时间取 task.finishedAt");
}
@Test
void test_assembler_row_with_null_fields() {
FileResultEntity row = result(100L, 10L, null, null, null);
row.setCreatedAt(null);
AppearancePatentHistoryItemVo item = assembler.toHistoryItem(row, null, null);
assertNull(item.getSourceFilename(), "空文件名保留");
assertEquals(Boolean.FALSE, item.getSuccess(), "无文件 URL 且 success 为空视为失败");
assertNull(item.getStartedAt(), "task 与 createdAt 均缺时无开始时间");
assertFalse(Boolean.TRUE.equals(item.getFileReady()), "文件未就绪");
}
@Test
void test_assembler_file_state() {
FileResultEntity row = result(100L, 10L, "a.xlsx", "result/10/a.xlsx", 1);
AppearancePatentHistoryItemVo item = assembler.toHistoryItem(row, task(10L, "SUCCESS", null), job(7L, "RUNNING"));
assertTrue(item.getFileReady(), "结果文件就绪");
assertEquals(7L, item.getFileJobId());
assertEquals("RUNNING", item.getFileStatus());
assertEquals(Integer.valueOf(100), item.getFileProgressPercent(), "就绪即 100%");
assertEquals("结果文件已生成", item.getFileProgressMessage());
}
@Test
void test_assembler_order_priority() {
// RUNNING 任务优先(priority 0)于 SUCCESS 且文件就绪(priority 1
FileResultEntity pending = result(200L, 2L, "b.xlsx", null, 0);
FileResultEntity done = result(100L, 1L, "a.xlsx", "result/1/a.xlsx", 1);
assertEquals(0, assembler.historyPriority(pending, task(2L, "RUNNING", null), null));
assertEquals(1, assembler.historyPriority(done, task(1L, "SUCCESS", null), null));
// SUCCESS 但文件未生成 → 构建中 → priority 0
FileResultEntity building = result(300L, 3L, "c.xlsx", null, 0);
assertEquals(0, assembler.historyPriority(building, task(3L, "SUCCESS", null), job(5L, "RUNNING")));
// 活动时间取 latestTimerow.createdAt 10:00 最晚)
assertEquals(LocalDateTime.of(2026, 8, 1, 10, 0),
assembler.historyActivityTime(done, task(1L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 0)), job(3L, "RUNNING")));
}
@Test
void test_assembler_file_building() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, 0);
assertTrue(assembler.isHistoryFileBuilding(row, "SUCCESS", job(3L, "RUNNING")), "SUCCESS 任务文件未生成视为构建中");
assertFalse(assembler.isHistoryFileBuilding(row, "SUCCESS", job(4L, "FAILED")), "job 失败不算构建中");
assertFalse(assembler.isHistoryFileBuilding(row, "FAILED", null), "非 SUCCESS 任务不算构建中");
row.setResultFileUrl("result/10/a.xlsx");
assertFalse(assembler.isHistoryFileBuilding(row, "SUCCESS", null), "文件就绪不算构建中");
}
@Test
void test_assembler_null_task() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, 1);
row.setCreatedAt(LocalDateTime.of(2026, 8, 1, 11, 0));
AppearancePatentHistoryItemVo item = assembler.toHistoryItem(row, null, null);
assertNull(item.getTaskStatus(), "缺 task 状态为空");
assertEquals("2026-08-01T11:00", item.getStartedAt(), "缺 task 回退 result.createdAt");
assertNull(item.getFinishedAt(), "缺 task 无结束时间");
assertNull(item.getFileStatus(), "无 job 且文件未就绪时状态为空");
}
@Test
void test_assembler_file_error_attached() {
TaskFileJobEntity failedJob = job(9L, "FAILED");
failedJob.setErrorMessage("assemble boom");
FileResultEntity row = result(100L, 10L, "a.xlsx", null, 0);
AppearancePatentHistoryItemVo item = assembler.toHistoryItem(row, task(10L, "RUNNING", null), failedJob);
assertEquals("FAILED", item.getFileStatus(), "job 失败状态附带");
assertEquals("assemble boom", item.getFileError(), "job 错误信息附带");
}
@Test
void test_assembler_snapshot_progress() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, 0);
TaskProgressSnapshotEntity snapshot = new TaskProgressSnapshotEntity();
snapshot.setTotalCount(10);
snapshot.setSuccessCount(5);
snapshot.setMessage("LLM 处理中");
when(taskProgressSnapshotService.find(10L, "APPEARANCE_PATENT")).thenReturn(snapshot);
AppearancePatentHistoryItemVo item = assembler.toHistoryItem(row, task(10L, "RUNNING", null), null);
assertEquals(Integer.valueOf(5), item.getFileProgressCurrent(), "快照 current 附带");
assertEquals(Integer.valueOf(10), item.getFileProgressTotal(), "快照 total 附带");
assertEquals(Integer.valueOf(99), item.getFileProgressPercent(), "百分比 = max(注入计算值 99, 注入提取值 42)");
assertEquals("LLM 处理中", item.getFileProgressMessage());
verify(taskProgressSnapshotService).find(10L, "APPEARANCE_PATENT");
}
@Test
void test_assembler_immutable_input() {
FileResultEntity row = result(100L, 10L, "a.xlsx", "result/10/a.xlsx", 1);
FileTaskEntity t = task(10L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 0));
assembler.toHistoryItem(row, t, null);
assertEquals("a.xlsx", row.getSourceFilename(), "result 不被修改");
assertEquals("SUCCESS", t.getStatus(), "task 不被修改");
}
@Test
void test_assembler_consistency() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, 0);
row.setErrorMessage("python timeout");
row.setCreatedAt(LocalDateTime.of(2026, 8, 1, 10, 0));
AppearancePatentHistoryItemVo item = assembler.toHistoryItem(row, task(10L, "FAILED", LocalDateTime.of(2026, 8, 1, 9, 30)), null);
assertEquals(Boolean.FALSE, item.getSuccess(), "无文件 URL 且 success=0 为失败");
assertEquals("python timeout", item.getError());
assertEquals("FAILED", item.getTaskStatus());
assertEquals(12, item.getRowCount());
}
}
@@ -0,0 +1,74 @@
package com.nanri.aiimage.modules.appearancepatent.service.support;
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.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 93AppearancePatentRowNormalizer 字段归一化器。
* 规则与 AppearancePatentTaskService.normalize / firstNonBlank / baseId / normalizeDisplayId
* 现状逐字节一致:BOM 剥离、全角空格转半角、trim、连续空白折叠、firstNonBlank 取首非空。
*/
class AppearancePatentRowNormalizerTest {
@Test
void test_normalize_null_returns_empty() {
assertEquals("", AppearancePatentRowNormalizer.normalize(null), "null 归一化为空串");
}
@Test
void test_normalize_bom_stripped() {
assertEquals("B01X", AppearancePatentRowNormalizer.normalize("B01X"), "BOM 剥离");
}
@Test
void test_normalize_fullwidth_space_to_halfwidth() {
assertEquals("SKU 1", AppearancePatentRowNormalizer.normalize("SKU 1"), "全角空格转半角");
}
@Test
void test_normalize_trim_and_collapse_whitespace() {
assertEquals("a b c", AppearancePatentRowNormalizer.normalize(" a\t b \nc "), "trim + 连续空白折叠为单空格");
}
@Test
void test_first_non_blank_prefers_first() {
assertEquals("preferred", AppearancePatentRowNormalizer.firstNonBlank("preferred", "fallback"));
assertEquals("fallback", AppearancePatentRowNormalizer.firstNonBlank(" ", "fallback"), "首选空白回退");
assertEquals("fallback", AppearancePatentRowNormalizer.firstNonBlank(null, "fallback"), "首选 null 回退");
assertEquals("kept", AppearancePatentRowNormalizer.firstNonBlank(" kept ", "fallback"), "结果去首尾空白");
}
@Test
void test_base_id_splits_underscore_block() {
assertEquals("2", AppearancePatentRowNormalizer.baseId("2_1"), "块基 id 取下划线前");
assertEquals("3", AppearancePatentRowNormalizer.baseId("3"), "无下划线原样");
assertEquals("2", AppearancePatentRowNormalizer.baseId(" 2_1 "), "归一化后再取基 id");
}
@Test
void test_normalize_display_id_trims_only() {
assertEquals("2_1", AppearancePatentRowNormalizer.normalizeDisplayId(" 2_1 "), "displayId 只 trim 不折叠内部");
assertEquals("", AppearancePatentRowNormalizer.normalizeDisplayId(null));
assertEquals("", AppearancePatentRowNormalizer.normalizeDisplayId(" "));
}
@Test
void test_normalize_keeps_other_fullwidth_punct() {
assertEquals("(外观)", AppearancePatentRowNormalizer.normalize(" (外观) "), "非空格全角字符保留");
}
@Test
void test_normalize_not_mutating_shared_rules() {
String sample = " X Y ";
String once = AppearancePatentRowNormalizer.normalize(sample);
assertEquals(once, AppearancePatentRowNormalizer.normalize(sample), "幂等");
assertNotEquals(sample, once);
assertFalse(once.contains(""), "BOM 不残留");
assertFalse(once.contains(" "), "全角空格不残留");
assertTrue(once.equals("X Y"));
}
}
@@ -0,0 +1,227 @@
package com.nanri.aiimage.modules.appearancepatent.service.support;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo;
import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 94AppearancePatentSheetBuilder Sheet 构造器。
* 结果 Workbook/Sheet 构造辅助(表头、列顺序、样式、行值派生)。
* 与现状 writeResultWorkbook / writeReasonSheet 一致:主 sheet 名"外观专利检测结果"、
* 13 列表头(RESULT_HEADERS 10 列 + 第 6/7/8 位插入标题/图片链接/sku)、加粗表头、
* 数据行从第 1 行;"原因"sheet 4 列按 ASIN 去重。不落库、无 IO 依赖。
*/
class AppearancePatentSheetBuilderTest {
private static final String[] RESULT_HEADERS_13 = {
"id", "asin", "国家", "卖家名称", "品牌", "价格",
"标题", "图片链接", "sku",
"标题维度(商标)", "外观维度(外观设计专利)", "结论", "状态"
};
private static AppearancePatentParsedRowVo parsedRow(String id, String asin, String country,
String title, String url, String sku) {
AppearancePatentParsedRowVo row = new AppearancePatentParsedRowVo();
row.setDisplayId(id);
row.setSourceId(id);
row.setAsin(asin);
row.setCountry(country);
row.setTitle(title);
row.setUrl(url);
row.setSku(sku);
Map<String, String> values = new LinkedHashMap<>();
values.put("卖家名称", "seller-A");
values.put("品牌", "brand-A");
values.put("价格", "19.90");
row.setValues(values);
return row;
}
private static AppearancePatentResultRowDto resultRow(String id, String asin, String country) {
AppearancePatentResultRowDto row = new AppearancePatentResultRowDto();
row.setId(id);
row.setAsin(asin);
row.setCountry(country);
row.setTitleRisk("无风险");
row.setAppearanceRisk("无风险");
row.setConclusion("已侵权");
row.setStatus("成功");
row.setError("");
row.setPrice("19.90");
return row;
}
@Test
void test_sheet_builder_headers_and_sheet_names() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
AppearancePatentSheetBuilder.buildResultSheet(wb, List.of(), Map.of());
Sheet main = wb.getSheet("外观专利检测结果");
Sheet reason = wb.getSheet("原因");
assertTrue(main != null, "主 sheet 存在");
assertTrue(reason != null, "原因 sheet 存在");
Row header = main.getRow(0);
assertEquals(13, header.getLastCellNum(), "主 sheet 13 列");
for (int i = 0; i < RESULT_HEADERS_13.length; i++) {
assertEquals(RESULT_HEADERS_13[i], header.getCell(i).getStringCellValue(),
"" + i + " 列表头");
}
Row reasonHeader = reason.getRow(0);
assertEquals("ASIN", reasonHeader.getCell(0).getStringCellValue());
assertEquals("外观原因", reasonHeader.getCell(1).getStringCellValue());
assertEquals("专利原因", reasonHeader.getCell(2).getStringCellValue());
assertEquals("标题原因", reasonHeader.getCell(3).getStringCellValue());
}
}
@Test
void test_sheet_builder_header_bold_style() throws Exception {
try (XSSFWorkbook wb = new XSSFWorkbook()) {
AppearancePatentSheetBuilder.buildResultSheet(wb, List.of(), Map.of());
Cell headerCell = wb.getSheet("外观专利检测结果").getRow(0).getCell(0);
org.apache.poi.xssf.usermodel.XSSFCellStyle style =
(org.apache.poi.xssf.usermodel.XSSFCellStyle) headerCell.getCellStyle();
assertTrue(style.getFont().getBold(), "表头加粗");
}
}
@Test
void test_sheet_builder_data_rows() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
AppearancePatentParsedRowVo p = parsedRow("2_1", "B01A", "US", "title-a", "http://img/a.jpg", "SKU-1");
AppearancePatentResultRowDto r = resultRow("2_1", "B01A", "US");
AppearancePatentSheetBuilder.buildResultSheet(wb, List.of(p), Map.of(
AppearancePatentSheetBuilder.rowKey(r), r));
Sheet main = wb.getSheet("外观专利检测结果");
Row data = main.getRow(1);
assertEquals("2_1", data.getCell(0).getStringCellValue(), "id 列");
assertEquals("B01A", data.getCell(1).getStringCellValue(), "asin 列");
assertEquals("US", data.getCell(2).getStringCellValue(), "国家列");
assertEquals("seller-A", data.getCell(3).getStringCellValue(), "卖家名称列");
assertEquals("brand-A", data.getCell(4).getStringCellValue(), "品牌列");
assertEquals("19.90", data.getCell(5).getStringCellValue(), "价格列");
assertEquals("title-a", data.getCell(6).getStringCellValue(), "标题列");
assertEquals("http://img/a.jpg", data.getCell(7).getStringCellValue(), "图片链接列");
assertEquals("SKU-1", data.getCell(8).getStringCellValue(), "sku 列");
assertEquals("无风险", data.getCell(9).getStringCellValue(), "标题维度列");
assertEquals("无风险", data.getCell(10).getStringCellValue(), "外观维度列");
assertEquals("已侵权", data.getCell(11).getStringCellValue(), "结论列");
assertEquals("成功", data.getCell(12).getStringCellValue(), "状态列");
}
}
@Test
void test_sheet_builder_empty_data_rows() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
AppearancePatentSheetBuilder.buildResultSheet(wb, List.of(), Map.of());
Sheet main = wb.getSheet("外观专利检测结果");
assertNull(main.getRow(1), "无数据行时只有表头");
}
}
@Test
void test_sheet_builder_no_result_row_uses_parsed_fallback() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
AppearancePatentParsedRowVo p = parsedRow("2_1", "B01A", "US", "title-a", "http://img/a.jpg", "SKU-1");
AppearancePatentSheetBuilder.buildResultSheet(wb, List.of(p), Map.of());
Row data = wb.getSheet("外观专利检测结果").getRow(1);
assertEquals("title-a", data.getCell(6).getStringCellValue(), "无结果行回退解析行标题");
assertEquals("http://img/a.jpg", data.getCell(7).getStringCellValue(), "回退解析行 URL");
assertEquals("SKU-1", data.getCell(8).getStringCellValue(), "回退解析行 sku");
assertEquals("", data.getCell(9).getStringCellValue(), "无结果行 LLM 列留空");
assertEquals("", data.getCell(12).getStringCellValue(), "无结果行状态留空");
}
}
@Test
void test_sheet_builder_reason_sheet_dedup_by_asin() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
AppearancePatentParsedRowVo p1 = parsedRow("2_1", "B01A", "US", "t", "", "");
AppearancePatentParsedRowVo p2 = parsedRow("2_2", "B01A", "DE", "t", "", "");
AppearancePatentResultRowDto r = resultRow("2_1", "B01A", "US");
r.setAppearanceReason("外观理由");
r.setPatentReason("专利理由");
r.setTitleReason("标题理由");
AppearancePatentSheetBuilder.buildResultSheet(wb, List.of(p1, p2), Map.of(
AppearancePatentSheetBuilder.rowKey(r), r));
Sheet reason = wb.getSheet("原因");
Row row1 = reason.getRow(1);
assertEquals("B01A", row1.getCell(0).getStringCellValue());
assertEquals("外观理由", row1.getCell(1).getStringCellValue());
assertEquals("专利理由", row1.getCell(2).getStringCellValue());
assertEquals("标题理由", row1.getCell(3).getStringCellValue());
assertNull(reason.getRow(2), "同 ASIN 第二行去重");
}
}
@Test
void test_sheet_builder_writable_output_stream() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
AppearancePatentSheetBuilder.buildResultSheet(wb,
List.of(parsedRow("1", "B01X", "US", "t", "u", "s")), Map.of());
ByteArrayOutputStream out = new ByteArrayOutputStream();
wb.write(out);
assertTrue(out.size() > 0, "workbook 可写出");
}
}
@Test
void test_sheet_builder_row_key_helpers() {
AppearancePatentResultRowDto r = resultRow("2_1", "b01a", "US");
assertEquals("2_1::B01A::US", AppearancePatentSheetBuilder.rowKey(r), "rowKey 归一化 + 大写 asin");
AppearancePatentParsedRowVo p = new AppearancePatentParsedRowVo();
p.setDisplayId("2_1");
p.setAsin("b01a");
p.setCountry("US");
assertEquals("2_1::B01A::US", AppearancePatentSheetBuilder.rowKey(p), "解析行 rowKey 同构");
}
@Test
void test_sheet_builder_llm_failure_user_facing_cells() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
AppearancePatentParsedRowVo p = parsedRow("2_1", "B01A", "US", "t", "", "");
AppearancePatentResultRowDto r = resultRow("2_1", "B01A", "US");
r.setTitleRisk("coze 调用超时");
r.setError("coze 工作流节点执行超限");
r.setConclusion("coze 调用超时");
AppearancePatentSheetBuilder.buildResultSheet(wb, List.of(p), Map.of(
AppearancePatentSheetBuilder.rowKey(r), r));
Row data = wb.getSheet("外观专利检测结果").getRow(1);
assertEquals("coze 工作流节点执行超限", data.getCell(9).getStringCellValue(),
"LLM 技术失败展示错误信息");
assertEquals("成功", data.getCell(12).getStringCellValue(),
"结论回退错误信息后非空即成功(与现状一致)");
}
}
@Test
void test_sheet_builder_blank_asin_reason_row_skipped() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
AppearancePatentParsedRowVo p = parsedRow("2_1", " ", "US", "t", "", "");
AppearancePatentSheetBuilder.buildResultSheet(wb, List.of(p), Map.of());
Sheet reason = wb.getSheet("原因");
assertNull(reason.getRow(1), "空白 ASIN 不入原因 sheet");
}
}
}
@@ -0,0 +1,323 @@
package com.nanri.aiimage.modules.appearancepatent.service.support;
import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 96AppearancePatent 快照对比测试。
* 夹具 Excelappearancepatent 实际结构)→ 解析结果快照(golden 文件);
* 同一夹具输出完全一致;快照变更即失败(防行为漂移)。
* 管线与服务侧 parseWorkbook 对应段落一致:parser.parse → VO 组装(displayId/baseId/
* groupKey/rowToken)→ hydratePromptFields(组内 title/url/sku 回填);夹具无"状态"列,
* 不走 FailedStatusRowFilter。
* golden 文件:src/test/resources/appearancepatent/golden/parse-snapshot.txt
*/
class AppearancePatentSnapshotTest {
private static final File GOLDEN_PARSE =
new File("src/test/resources/appearancepatent/golden/parse-snapshot.txt");
private static final String[] HEADERS = {"id", "asin", "国家", "价格", "seller sku", "图片链接", "标题", "卖家名称", "品牌"};
// ---- 夹具 ----
private static File workbook(String[] headers, List<String[]> rows) throws Exception {
File file = File.createTempFile("appearance-patent-snapshot-", ".xlsx");
try (XSSFWorkbook wb = new XSSFWorkbook(); FileOutputStream out = new FileOutputStream(file)) {
Sheet sheet = wb.createSheet("Sheet1");
Row header = sheet.createRow(0);
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
for (int r = 0; r < rows.size(); r++) {
Row row = sheet.createRow(r + 1);
for (int c = 0; c < rows.get(r).length; c++) {
row.createCell(c).setCellValue(rows.get(r)[c]);
}
}
wb.write(out);
}
return file;
}
/** 5 有效行:2_1/2_2/2_3 同块(2_2/2_3 标题空缺待组内回填)、单块 5、单块 6。 */
private static File fixtureMain() throws Exception {
return workbook(HEADERS, List.of(
new String[]{"2_1", "B01A", "US", "19.90", "SKU-1", "http://img/a.jpg", "title a", "seller-A", "brand-A"},
new String[]{"2_2", "B01B", "DE", "29.90", "SKU-2", "http://img/b.jpg", "", "seller-B", "brand-B"},
new String[]{"2_3", "B01C", "FR", "", "", "http://img/c.jpg", "", "seller-C", "brand-C"},
new String[]{"5", "B01D", "UK", "9.90", "SKU-4", "http://img/d.jpg", "title d", "seller-D", "brand-D"},
new String[]{"6", "B01E", "US", "1.00", "SKU-5", "http://img/e.jpg", "title e", "seller-E", "brand-E"}));
}
/** 缺 id/asin/国家 各 1 行 + 1 全空行;解析后剩 3 有效行。 */
private static File fixtureError() throws Exception {
return workbook(HEADERS, List.of(
new String[]{"", "B02A", "US", "", "", "", "", "seller", "brand"},
new String[]{"9", "", "DE", "", "", "", "", "seller", "brand"},
new String[]{"10", "B02B", "", "", "", "", "", "seller", "brand"},
new String[]{"11", "B02C", "FR", "", "", "", "", "seller", "brand"},
new String[]{"12", "B02D", "FR", "", "", "", "", "seller", "brand"},
new String[]{"13", "B02E", "FR", "", "", "", "", "seller", "brand"},
new String[]{"", "", ""}));
}
// ---- 解析管线(与服务侧 parseWorkbook 对应段落语义一致) ----
private static List<AppearancePatentParsedRowVo> toRows(File file, String fileKey, String filename) throws Exception {
AppearancePatentExcelParser.ParsedSheet parsed = new AppearancePatentExcelParser().parse(file);
List<AppearancePatentParsedRowVo> rows = new ArrayList<>();
String currentBlockBaseId = "";
String currentGroupKey = "";
for (AppearancePatentExcelParser.AppearanceExcelRow parsedRow : parsed.rows()) {
String id = parsedRow.id();
String asin = parsedRow.asin();
String country = parsedRow.country();
if (id.isBlank() && asin.isBlank() && country.isBlank()) {
continue;
}
if (id.isBlank() || asin.isBlank() || country.isBlank()) {
continue;
}
AppearancePatentParsedRowVo vo = new AppearancePatentParsedRowVo();
vo.setSourceFileKey(fileKey);
vo.setSourceFilename(filename);
vo.setRowIndex(parsedRow.rowIndex());
vo.setSourceId(id);
vo.setDisplayId(normalizeDisplayId(id));
String rowBaseId = baseId(vo.getDisplayId());
if (!Objects.equals(currentBlockBaseId, rowBaseId)) {
currentBlockBaseId = rowBaseId;
currentGroupKey = buildGroupKey(fileKey, rowBaseId, vo.getRowIndex());
}
vo.setGroupKey(currentGroupKey);
vo.setRowToken(buildRowToken(fileKey, vo.getRowIndex()));
vo.setAsin(asin);
vo.setCountry(country);
vo.setPrice(parsedRow.price());
vo.setSku(parsedRow.sku());
vo.setUrl(parsedRow.url());
vo.setTitle(parsedRow.title());
vo.setValues(parsedRow.values());
rows.add(vo);
}
hydratePromptFields(rows);
return rows;
}
private static void hydratePromptFields(List<AppearancePatentParsedRowVo> rows) {
if (rows == null || rows.isEmpty()) {
return;
}
Map<String, List<AppearancePatentParsedRowVo>> rowsByBaseId = new LinkedHashMap<>();
for (AppearancePatentParsedRowVo row : rows) {
String key = firstNonBlank(normalize(row.getGroupKey()), baseId(row.getDisplayId()));
rowsByBaseId.computeIfAbsent(key, ignored -> new ArrayList<>()).add(row);
}
for (List<AppearancePatentParsedRowVo> siblings : rowsByBaseId.values()) {
String title = "";
String url = "";
String sku = "";
for (AppearancePatentParsedRowVo sibling : siblings) {
title = firstNonBlank(title, sibling.getTitle());
url = firstNonBlank(url, sibling.getUrl());
sku = firstNonBlank(sku, sibling.getSku());
}
for (AppearancePatentParsedRowVo sibling : siblings) {
if (normalize(sibling.getTitle()).isBlank()) {
sibling.setTitle(title);
}
if (normalize(sibling.getUrl()).isBlank()) {
sibling.setUrl(url);
}
if (normalize(sibling.getSku()).isBlank()) {
sibling.setSku(sku);
}
}
}
}
private static String normalize(String val) {
return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " ");
}
private static String baseId(String id) {
String s = normalize(id);
int idx = s.indexOf('_');
return idx > 0 ? s.substring(0, idx) : s;
}
private static String normalizeDisplayId(String id) {
return id == null ? "" : id.trim();
}
private static String buildRowToken(String sourceFileKey, Integer rowIndex) {
return normalize(sourceFileKey) + "::row::" + (rowIndex == null ? 0 : rowIndex);
}
private static String buildGroupKey(String sourceFileKey, String rowBaseId, Integer rowIndex) {
return normalize(sourceFileKey) + "::" + normalize(rowBaseId) + "@" + (rowIndex == null ? 0 : rowIndex);
}
private static String firstNonBlank(String preferred, String fallback) {
return preferred == null || preferred.isBlank() ? fallback : preferred.trim();
}
// ---- 快照渲染 ----
private static String renderRow(AppearancePatentParsedRowVo row) {
return " row: idx=" + row.getRowIndex()
+ " sourceId=" + row.getSourceId()
+ " displayId=" + row.getDisplayId()
+ " asin=" + row.getAsin()
+ " country=" + row.getCountry()
+ " price=" + row.getPrice()
+ " sku=" + row.getSku()
+ " url=" + row.getUrl()
+ " title=" + row.getTitle()
+ " groupKey=" + row.getGroupKey()
+ " rowToken=" + row.getRowToken()
+ " values.size=" + (row.getValues() == null ? 0 : row.getValues().size());
}
private static String renderRows(List<AppearancePatentParsedRowVo> rows) {
StringBuilder sb = new StringBuilder();
sb.append("rows=").append(rows.size()).append('\n');
for (AppearancePatentParsedRowVo row : rows) {
sb.append(renderRow(row)).append('\n');
}
return sb.toString();
}
private static List<AppearancePatentParsedRowVo> mergeRows(List<List<AppearancePatentParsedRowVo>> all) {
List<AppearancePatentParsedRowVo> merged = new ArrayList<>();
for (List<AppearancePatentParsedRowVo> list : all) {
merged.addAll(list);
}
return merged;
}
public static String runAllParse() throws Exception {
return renderRows(mergeRows(List.of(
toRows(fixtureMain(), "uploads/main.xlsx", "main.xlsx"),
toRows(fixtureError(), "uploads/error.xlsx", "error.xlsx"))));
}
private static String read(File file) throws Exception {
return Files.readString(file.toPath(), StandardCharsets.UTF_8);
}
// ---- 用例 ----
@Test
void test_snapshot_parse_output() throws Exception {
assertEquals(read(GOLDEN_PARSE), runAllParse(), "解析输出快照与 golden 一致");
}
@Test
void test_snapshot_rows() throws Exception {
List<AppearancePatentParsedRowVo> rows = mergeRows(List.of(
toRows(fixtureMain(), "uploads/main.xlsx", "main.xlsx"),
toRows(fixtureError(), "uploads/error.xlsx", "error.xlsx")));
assertEquals(8, rows.size(), "有效行 5 + 3");
assertEquals("uploads/main.xlsx::2@2", rows.get(0).getGroupKey(), "首块 groupKey = fileKey::baseId@rowIndex");
assertEquals("uploads/main.xlsx::row::2", rows.get(0).getRowToken(), "rowToken = fileKey::row::rowIndex");
assertEquals("2_1", rows.get(0).getDisplayId(), "displayId 原样保留");
assertEquals("B01A", rows.get(0).getAsin(), "asin 大写");
assertEquals(9, rows.get(0).getValues().size(), "values 按 9 列表头键取值");
}
@Test
void test_snapshot_block_grouping() throws Exception {
List<AppearancePatentParsedRowVo> rows = toRows(fixtureMain(), "uploads/main.xlsx", "main.xlsx");
assertEquals(5, rows.size());
assertEquals(rows.get(0).getGroupKey(), rows.get(1).getGroupKey(), "2_1/2_2 同块");
assertEquals(rows.get(0).getGroupKey(), rows.get(2).getGroupKey(), "2_1/2_3 同块");
assertFalse(rows.get(0).getGroupKey().equals(rows.get(3).getGroupKey()), "2_1 与 5 不同块");
assertEquals("uploads/main.xlsx::5@5", rows.get(3).getGroupKey(), "新块 baseId=5");
assertEquals("uploads/main.xlsx::6@6", rows.get(4).getGroupKey(), "新块 baseId=6");
}
@Test
void test_snapshot_hydration() throws Exception {
List<AppearancePatentParsedRowVo> rows = toRows(fixtureMain(), "uploads/main.xlsx", "main.xlsx");
assertEquals("title a", rows.get(1).getTitle(), "2_2 标题从组内回填");
assertEquals("http://img/b.jpg", rows.get(1).getUrl(), "2_2 自身 url 保留");
assertEquals("SKU-1", rows.get(2).getSku(), "2_3 sku 从组内回填");
assertEquals("title a", rows.get(2).getTitle(), "2_3 标题从组内回填");
assertEquals("http://img/c.jpg", rows.get(2).getUrl(), "2_3 自身 url 保留");
assertEquals("", rows.get(2).getPrice(), "价格不回填(仅 title/url/sku");
}
@Test
void test_snapshot_error_cases() throws Exception {
// 缺必填字段行与全空行均不进入结果;不抛异常
List<AppearancePatentParsedRowVo> rows = toRows(fixtureError(), "uploads/error.xlsx", "error.xlsx");
assertEquals(3, rows.size(), "错误夹具剩 3 有效行");
assertEquals("11", rows.get(0).getSourceId());
assertEquals("12", rows.get(1).getSourceId());
assertEquals("13", rows.get(2).getSourceId());
}
@Test
void test_snapshot_reproducible() throws Exception {
assertEquals(runAllParse(), runAllParse(), "同一夹具跑两次结果一致");
}
@Test
void test_snapshot_golden_committed() {
assertTrue(GOLDEN_PARSE.isFile(), "golden 文件必须存在并入库: " + GOLDEN_PARSE.getAbsolutePath());
assertTrue(GOLDEN_PARSE.length() > 0, "golden 文件非空");
}
@Test
void test_snapshot_diff_detected() throws Exception {
String original = read(GOLDEN_PARSE);
assertTrue(original.contains("rows="), "golden 内容合法");
try {
Files.writeString(GOLDEN_PARSE.toPath(), original + "\n# tampered", StandardCharsets.UTF_8);
AssertionError failure = null;
try {
assertEquals(read(GOLDEN_PARSE), runAllParse(), "篡改后应与 golden 不一致");
} catch (AssertionError ex) {
failure = ex;
}
assertTrue(failure != null, "篡改 golden 后断言应失败");
} finally {
Files.writeString(GOLDEN_PARSE.toPath(), original, StandardCharsets.UTF_8);
}
assertEquals(original, read(GOLDEN_PARSE), "恢复原始 golden");
}
@Test
void test_snapshot_regression_all_parse() throws Exception {
assertEquals(read(GOLDEN_PARSE), runAllParse(), "全 parse 路径与 golden 一致");
List<AppearancePatentParsedRowVo> rows = mergeRows(List.of(
toRows(fixtureMain(), "uploads/main.xlsx", "main.xlsx"),
toRows(fixtureError(), "uploads/error.xlsx", "error.xlsx")));
assertEquals(8, rows.size(), "总行数 5 + 3");
assertEquals("uploads/error.xlsx::row::5", rows.get(5).getRowToken(), "错误夹具首行 rowToken");
assertEquals(9, rows.get(5).getValues().size(), "错误夹具 values 仍有 9 列");
}
}
@@ -12,6 +12,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -112,7 +113,8 @@ class CollectDataDeleteConsistencyTest {
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter.class),
resultDetailCodec,
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultItemBatchWriter.class),
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailReader.class));;
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailReader.class),
mock(TaskProgressLightAssembler.class));
}
private FileTaskEntity task(long id, long userId) {
@@ -15,6 +15,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskResultItemEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -111,7 +112,8 @@ class CollectDataStorageCallCountTest {
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataInvalidAsinBatchWriter.class),
resultDetailCodec,
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultItemBatchWriter.class),
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailReader.class));
mock(com.nanri.aiimage.modules.collectdata.util.CollectDataResultDetailReader.class),
mock(TaskProgressLightAssembler.class));
}
private FileResultEntity result(long id, long taskId, long userId) {
@@ -55,12 +55,12 @@ class InvalidAsinDataControllerTest {
when(adminAuthSupport.currentRole(operator)).thenReturn(null);
when(permissionMenuService.getUserColumnPermissions(7L, "admin"))
.thenReturn(List.of(invalidAsinDataPermission()));
when(invalidAsinDataService.page(1L, 15L, "", 3L, 7L, false))
when(invalidAsinDataService.page(1L, 15L, "", "", "", 3L, 7L, false))
.thenReturn(new InvalidAsinDataPageVo());
controller.page(1L, 15L, "", 3L, request);
controller.page(1L, 15L, "", "", "", 3L, request);
verify(invalidAsinDataService).page(1L, 15L, "", 3L, 7L, false);
verify(invalidAsinDataService).page(1L, 15L, "", "", "", 3L, 7L, false);
}
@Test
@@ -70,7 +70,7 @@ class InvalidAsinDataControllerTest {
request.addParameter("superAdmin", "true");
when(adminAuthSupport.requireUser(request)).thenThrow(new BusinessException(401, "未登录"));
assertThrows(BusinessException.class, () -> controller.page(1L, 15L, "", 3L, request));
assertThrows(BusinessException.class, () -> controller.page(1L, 15L, "", "", "", 3L, request));
verifyNoInteractions(invalidAsinDataService);
verify(permissionMenuService, never()).requireUserOperator(any());
@@ -83,12 +83,12 @@ class InvalidAsinDataControllerTest {
AdminUserEntity operator = user(1L, "super_admin");
when(adminAuthSupport.requireUser(request)).thenReturn(operator);
when(adminAuthSupport.currentRole(operator)).thenReturn("super_admin");
when(invalidAsinDataService.page(1L, 15L, "", 3L, 1L, true))
when(invalidAsinDataService.page(1L, 15L, "", "", "", 3L, 1L, true))
.thenReturn(new InvalidAsinDataPageVo());
controller.page(1L, 15L, "", 3L, request);
controller.page(1L, 15L, "", "", "", 3L, request);
verify(invalidAsinDataService).page(1L, 15L, "", 3L, 1L, true);
verify(invalidAsinDataService).page(1L, 15L, "", "", "", 3L, 1L, true);
verify(permissionMenuService, never()).getUserColumnPermissions(eq(1L), eq("admin"));
}
@@ -85,7 +85,7 @@ class InvalidAsinDataServiceTest {
when(invalidAsinDataMapper.selectList(any())).thenReturn(List.of(manual));
when(shopManageGroupService.buildGroupNameMap(any())).thenReturn(Map.of(9L, "group-a"));
InvalidAsinDataPageVo page = service.page(1, 15, "", 10L, 7L, false);
InvalidAsinDataPageVo page = service.page(1, 15, "", "", "", 10L, 7L, false);
ArgumentCaptor<LambdaQueryWrapper<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
verify(invalidAsinDataMapper).selectCount(captor.capture());
@@ -109,7 +109,7 @@ class InvalidAsinDataServiceTest {
when(invalidAsinDataMapper.selectList(any())).thenReturn(List.of(automatic, orphan));
when(shopManageGroupService.buildGroupNameMap(any())).thenReturn(Map.of());
InvalidAsinDataPageVo page = service.page(1, 15, "", null, 1L, true);
InvalidAsinDataPageVo page = service.page(1, 15, "", "", "", null, 1L, true);
assertEquals(2, page.getItems().size());
assertEquals("AUTO", page.getItems().getFirst().getRecordSource());
@@ -123,7 +123,7 @@ class InvalidAsinDataServiceTest {
when(invalidAsinDataMapper.selectCount(any())).thenReturn(0L);
when(invalidAsinDataMapper.selectList(any())).thenReturn(List.of());
service.page(1, 15, "", 9L, 1L, true);
service.page(1, 15, "", "", "", 9L, 1L, true);
ArgumentCaptor<LambdaQueryWrapper<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
verify(invalidAsinDataMapper).selectCount(captor.capture());
@@ -134,6 +134,26 @@ class InvalidAsinDataServiceTest {
assertTrue(captor.getValue().getParamNameValuePairs().containsValue(9L));
}
@Test
@SuppressWarnings({"rawtypes", "unchecked"})
void pageFiltersByDataValueAndBrandIndependently() {
when(invalidAsinDataMapper.selectCount(any())).thenReturn(0L);
when(invalidAsinDataMapper.selectList(any())).thenReturn(List.of());
service.page(1, 15, "", "B01", "acme", null, 1L, true);
ArgumentCaptor<LambdaQueryWrapper<InvalidAsinDataEntity>> captor = ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class);
verify(invalidAsinDataMapper).selectCount(captor.capture());
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
InvalidAsinDataEntity.class);
String sql = captor.getValue().getSqlSegment();
assertTrue(sql.contains("data_value LIKE"));
assertTrue(sql.contains("brand LIKE"));
assertTrue(captor.getValue().getParamNameValuePairs().containsValue("%B01%"));
assertTrue(captor.getValue().getParamNameValuePairs().containsValue("%acme%"));
}
@Test
void normalUserCannotDeleteAutoRecord() {
when(invalidAsinDataMapper.selectById(91L)).thenReturn(data(91L, "AUTO", null));
@@ -0,0 +1,552 @@
package com.nanri.aiimage.modules.publish.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.publish.mapper.PublishFileMapper;
import com.nanri.aiimage.modules.publish.mapper.PublishItemMapper;
import com.nanri.aiimage.modules.publish.model.entity.PublishFileEntity;
import com.nanri.aiimage.modules.publish.model.entity.PublishItemEntity;
import com.nanri.aiimage.modules.publish.model.vo.PublishDashboardVo;
import com.nanri.aiimage.modules.publish.model.vo.PublishFileVo;
import com.nanri.aiimage.modules.publish.model.vo.PublishHistoryVo;
import com.nanri.aiimage.modules.publish.model.vo.PublishResultVo;
import com.nanri.aiimage.modules.publish.model.vo.PublishTaskDetailVo;
import com.nanri.aiimage.modules.publish.model.vo.PublishTaskVo;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 任务 114:批量加载等价快照测试。
* 同一夹具下,批量路径(history 批量 IN / dashboard 聚合 / progress 批量)与逐条基准
* getTaskDetail 的 selectById + 按 taskId 过滤装配)输出完全一致。
* 快照可重复;篡改基准即失败;覆盖历史/结果/dashboard 三页;全量回归门禁。
*/
class PublishBatchLoadingSnapshotEquivTest {
private static final String MODULE = "PUBLISH";
private static final java.io.File GOLDEN =
new java.io.File("src/test/resources/publish/golden/batch-loading-snapshot.txt");
private final List<FileTaskEntity> taskDb = new ArrayList<>();
private final List<PublishFileEntity> fileDb = new ArrayList<>();
private final List<FileResultEntity> resultDb = new ArrayList<>();
private final List<TaskFileJobEntity> jobDb = new ArrayList<>();
private FileTaskMapper fileTaskMapper;
private PublishFileMapper publishFileMapper;
private FileResultMapper fileResultMapper;
private TaskFileJobService taskFileJobService;
private PublishTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
TableInfoHelper.initTableInfo(assistant, PublishFileEntity.class);
TableInfoHelper.initTableInfo(assistant, PublishItemEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskFileJobEntity.class);
}
private static String renderDashboard(PublishDashboardVo vo) {
StringBuilder sb = new StringBuilder();
sb.append("pending=").append(vo.getPendingCount())
.append(" running=").append(vo.getRunningCount())
.append(" success=").append(vo.getSuccessCount())
.append(" failed=").append(vo.getFailedCount()).append('\n');
for (PublishTaskDetailVo item : vo.getRecent()) {
sb.append(renderTaskDetail(item));
}
return sb.toString();
}
private static String renderTaskDetail(PublishTaskDetailVo detail) {
PublishTaskVo task = detail.getTask();
StringBuilder sb = new StringBuilder();
sb.append("task id=").append(task.getId())
.append(" no=").append(task.getTaskNo())
.append(" status=").append(task.getStatus())
.append(" src=").append(task.getSourceFileCount())
.append(" ok=").append(task.getSuccessFileCount())
.append(" fail=").append(task.getFailedFileCount())
.append(" comp=").append(task.getCompletedFileCount())
.append(" rows=").append(task.getTotalRows())
.append("/").append(task.getProcessedRows())
.append(" pct=").append(task.getPercent())
.append(" err=").append(task.getErrorMessage())
.append('\n');
for (PublishFileVo file : detail.getFiles()) {
sb.append(" file id=").append(file.getFileId())
.append(" key=").append(file.getFileKey())
.append(" shop=").append(file.getShopName())
.append("/").append(file.getShopId())
.append(" matched=").append(file.isMatched())
.append(" status=").append(file.getStatus())
.append(" rows=").append(file.getTotalRows())
.append("/").append(file.getProcessedRows())
.append(" pct=").append(file.getPercent())
.append('\n');
}
PublishResultVo result = detail.getResult();
if (result != null) {
sb.append(" result id=").append(result.getResultId())
.append(" ready=").append(result.getFileReady())
.append(" url=").append(result.getDownloadUrl())
.append(" job=").append(result.getFileJobId())
.append("/").append(result.getFileJobStatus())
.append(" retry=").append(result.getFileJobRetryCount())
.append(" joberr=").append(result.getFileJobError())
.append(" err=").append(result.getErrorMessage())
.append('\n');
}
return sb.toString();
}
private static String renderHistory(PublishHistoryVo vo) {
StringBuilder sb = new StringBuilder();
sb.append("total=").append(vo.getTotal()).append('\n');
for (PublishTaskDetailVo item : vo.getItems()) {
sb.append(renderTaskDetail(item));
}
return sb.toString();
}
private static String renderBatch(List<PublishTaskDetailVo> items) {
StringBuilder sb = new StringBuilder();
for (PublishTaskDetailVo item : items) {
sb.append(renderTaskDetail(item));
}
return sb.toString();
}
/** 从渲染后的 wrapper 提取 IN 查询引用的全部 id.in() 每个元素一个 #{ew.paramNameValuePairs.x} 参数)。 */
private static List<Long> inParamIds(LambdaQueryWrapper<?> q) {
String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment();
List<Long> ids = new ArrayList<>();
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("#\\{ew\\.paramNameValuePairs\\.(\\w+)}").matcher(segment);
Map<String, Object> params = q.getParamNameValuePairs();
while (m.find()) {
Object value = params.get(m.group(1));
if (value instanceof Number n) {
ids.add(n.longValue());
} else if (value instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Number n) {
ids.add(n.longValue());
}
}
}
}
return ids;
}
@BeforeEach
void setUp() {
fileTaskMapper = mock(FileTaskMapper.class);
publishFileMapper = mock(PublishFileMapper.class);
fileResultMapper = mock(FileResultMapper.class);
taskFileJobService = mock(TaskFileJobService.class);
OssStorageService ossStorageService = mock(OssStorageService.class);
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
LambdaQueryWrapper<FileTaskEntity> q = invocation.getArgument(0);
String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment();
List<Object> params = new ArrayList<>(q.getParamNameValuePairs().values());
List<Long> wanted = new ArrayList<>();
for (Object value : params) {
if (value instanceof List<?> list) {
for (Object item : list) {
if (item instanceof Number n) {
wanted.add(n.longValue());
}
}
}
}
List<FileTaskEntity> filtered = new ArrayList<>();
for (FileTaskEntity task : taskDb) {
if (!MODULE.equals(task.getModuleType())) {
continue;
}
if (wanted.isEmpty()) {
filtered.add(task);
} else if (wanted.contains(task.getId())) {
filtered.add(task);
}
}
filtered.sort((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt()));
if (segment.toLowerCase().contains("limit")) {
int cap = 50;
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("limit\\s+(\\d+)", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(segment);
if (m.find()) {
cap = Integer.parseInt(m.group(1));
}
return filtered.subList(0, Math.min(cap, filtered.size()));
}
return filtered;
}).when(fileTaskMapper).selectList(any());
doAnswer(invocation -> {
Long id = invocation.getArgument(0);
return taskDb.stream().filter(t -> t.getId().equals(id)).findFirst().orElse(null);
}).when(fileTaskMapper).selectById(any(Long.class));
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
LambdaQueryWrapper<PublishFileEntity> q = invocation.getArgument(0);
List<Long> wanted = inParamIds(q);
return fileDb.stream()
.filter(f -> wanted.contains(f.getTaskId()))
.sorted((a, b) -> a.getId().compareTo(b.getId()))
.toList();
}).when(publishFileMapper).selectList(any());
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
LambdaQueryWrapper<FileResultEntity> q = invocation.getArgument(0);
List<Long> wanted = inParamIds(q);
return resultDb.stream()
.filter(r -> wanted.contains(r.getTaskId()))
.sorted((a, b) -> a.getId().compareTo(b.getId()))
.toList();
}).when(fileResultMapper).selectList(any());
lenient().when(taskFileJobService.findAssembleJobsByResultIds(any(), any())).thenAnswer(invocation -> {
List<?> ids = invocation.getArgument(1);
Map<Long, TaskFileJobEntity> map = new HashMap<>();
if (ids != null) {
for (TaskFileJobEntity job : jobDb) {
if (job.getResultId() != null && ids.contains(job.getResultId())) {
map.put(job.getResultId(), job);
}
}
}
return map;
});
when(fileTaskMapper.selectCount(any())).thenAnswer(invocation -> (long) taskDb.stream()
.filter(t -> MODULE.equals(t.getModuleType()) && Long.valueOf(1L).equals(t.getUserId())).count());
when(fileTaskMapper.selectMaps(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
com.baomidou.mybatisplus.core.conditions.Wrapper<FileTaskEntity> wrapper = invocation.getArgument(0);
String segment = wrapper.getSqlSegment() == null ? "" : wrapper.getSqlSegment();
assertTrue(segment.toLowerCase().contains("group by"), "聚合查询必须带 GROUP BY: " + segment);
Map<String, Long> counts = new HashMap<>();
for (FileTaskEntity task : taskDb) {
if (!MODULE.equals(task.getModuleType()) || task.getUserId() == null || task.getStatus() == null) {
continue;
}
counts.merge(task.getStatus(), 1L, Long::sum);
}
List<Map<String, Object>> rows = new ArrayList<>();
for (Map.Entry<String, Long> entry : counts.entrySet()) {
Map<String, Object> row = new HashMap<>();
row.put("status", entry.getKey());
row.put("cnt", entry.getValue());
rows.add(row);
}
return rows;
});
when(ossStorageService.generateFreshDownloadUrl(any())).thenAnswer(
invocation -> "https://oss" + invocation.getArgument(0));
service = new PublishTaskService(
mock(LocalFileStorageService.class), mock(ZiniaoShopSwitchService.class),
mock(PublishWorkbookService.class), publishFileMapper, mock(PublishItemMapper.class),
fileTaskMapper, fileResultMapper, mock(TaskChunkMapper.class),
mock(TaskScopeStateMapper.class), taskFileJobService,
mock(TaskDistributedLockService.class), mock(TransientPayloadStorageService.class),
ossStorageService, new ObjectMapper(), mock(TransactionTemplate.class),
mock(InstanceMetadata.class),
mock(TaskProgressLightAssembler.class));
}
private static FileTaskEntity task(long id, String status, LocalDateTime createdAt, Integer src, Integer ok, Integer fail) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setModuleType(MODULE);
task.setUserId(1L);
task.setStatus(status);
task.setCreatedAt(createdAt);
task.setUpdatedAt(createdAt);
task.setFinishedAt(status.equals("SUCCESS") || status.equals("FAILED") ? createdAt.plusMinutes(5) : null);
task.setTaskNo("T" + id);
task.setSourceFileCount(src);
task.setSuccessFileCount(ok);
task.setFailedFileCount(fail);
return task;
}
private static PublishFileEntity file(Long id, Long taskId, String status, Integer total, Integer processed) {
PublishFileEntity file = new PublishFileEntity();
file.setId(id);
file.setTaskId(taskId);
file.setFileKey("key" + id);
file.setSourceFilename("s" + id + ".xlsx");
file.setShopName("Shop " + (id % 3));
file.setShopId("SHOP" + (id % 3));
file.setMatched(1);
file.setStatus(status);
file.setTotalRows(total);
file.setProcessedRows(processed);
file.setCreatedAt(LocalDateTime.of(2026, 8, 1, 10, 0));
return file;
}
private static FileResultEntity result(Long id, Long taskId, String error) {
FileResultEntity result = new FileResultEntity();
result.setId(id);
result.setTaskId(taskId);
result.setModuleType(MODULE);
result.setResultFilename("r" + id + ".xlsx");
result.setResultFileUrl("result/publish/" + id + "/out.xlsx");
result.setSuccess(1);
result.setErrorMessage(error);
result.setCreatedAt(LocalDateTime.of(2026, 8, 1, 11, 0));
return result;
}
private static TaskFileJobEntity job(Long id, Long resultId, String status, Integer retry, String error) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(id);
job.setResultId(resultId);
job.setModuleType(MODULE);
job.setStatus(status);
job.setRetryCount(retry);
job.setErrorMessage(error);
return job;
}
/** 固定夹具:5 任务 ×(成功多文件/运行中+失败文件/待命无结果/失败+Job 错误/成功单文件)+ 结果与 Job 关联。 */
private void seed() {
taskDb.add(task(1L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 1), 2, 2, 0));
taskDb.add(task(2L, "RUNNING", LocalDateTime.of(2026, 8, 1, 9, 2), 2, 0, 1));
taskDb.add(task(3L, "PENDING", LocalDateTime.of(2026, 8, 1, 9, 3), 1, 0, 0));
taskDb.add(task(4L, "FAILED", LocalDateTime.of(2026, 8, 1, 9, 4), 1, 0, 1));
taskDb.add(task(5L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 5), 1, 1, 0));
fileDb.add(file(101L, 1L, "SUCCESS", 10, 10));
fileDb.add(file(102L, 1L, "SUCCESS", 20, 20));
fileDb.add(file(201L, 2L, "RUNNING", 30, 12));
fileDb.add(file(202L, 2L, "FAILED", 5, 3));
fileDb.add(file(301L, 3L, "PENDING", 8, 0));
fileDb.add(file(401L, 4L, "FAILED", 9, 4));
fileDb.add(file(501L, 5L, "SUCCESS", 15, 15));
resultDb.add(result(901L, 1L, null));
resultDb.add(result(902L, 2L, "still running"));
resultDb.add(result(904L, 4L, "boom"));
jobDb.add(job(801L, 901L, "SUCCESS", 0, null));
jobDb.add(job(802L, 902L, "RUNNING", 1, null));
jobDb.add(job(804L, 904L, "FAILED", 2, "assemble failed"));
}
private List<FileTaskEntity> orderedTasks() {
return taskDb.stream()
.filter(t -> MODULE.equals(t.getModuleType()) && Long.valueOf(1L).equals(t.getUserId()))
.sorted((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt()))
.toList();
}
/** 逐条基准:与批量路径相同的装配语义,但每任务一次 getTaskDetailselectById 单条路径)。 */
private String baselineDetails() {
StringBuilder sb = new StringBuilder();
for (FileTaskEntity task : orderedTasks()) {
sb.append(renderTaskDetail(service.getTaskDetail(task.getId(), 1L)));
}
return sb.toString();
}
private String baselineHistoryText() {
return "total=" + taskDb.stream()
.filter(t -> MODULE.equals(t.getModuleType()) && Long.valueOf(1L).equals(t.getUserId())).count()
+ "\n" + baselineDetails();
}
private String historySnapshot() {
return renderHistory(service.history(1L, 50));
}
private String dashboardSnapshot() {
return renderDashboard(service.dashboard(1L));
}
private String resultSnapshot() {
StringBuilder sb = new StringBuilder();
for (FileTaskEntity task : orderedTasks()) {
sb.append(renderTaskDetail(service.getTaskDetail(task.getId(), 1L)));
}
return sb.toString();
}
/** progress 批量:请求顺序按 createdAt 倒序(与 history 语义一致),输出明细。 */
private String batchSnapshot() {
List<Long> ids = orderedTasks().stream().map(FileTaskEntity::getId).toList();
return renderBatch(service.getTaskProgress(1L, ids).getItems());
}
private static String readGolden() throws Exception {
return java.nio.file.Files.readString(GOLDEN.toPath(), java.nio.charset.StandardCharsets.UTF_8);
}
@Test
void test_history_snapshot_equiv() throws Exception {
seed();
assertEquals(baselineHistoryText(), historySnapshot(),
"history 批量装配输出与逐条基准完全一致");
}
@Test
void test_dashboard_snapshot_equiv() throws Exception {
seed();
PublishDashboardVo vo = service.dashboard(1L);
assertEquals(1L, vo.getPendingCount());
assertEquals(1L, vo.getRunningCount());
assertEquals(2L, vo.getSuccessCount());
assertEquals(1L, vo.getFailedCount());
String recent = "";
for (PublishTaskDetailVo item : vo.getRecent()) {
recent += renderTaskDetail(item);
}
assertEquals(baselineDetails(), recent.toString(),
"dashboard recent 明细与逐条基准完全一致");
}
@Test
void test_result_snapshot_equiv() throws Exception {
seed();
assertEquals(baselineDetails(), resultSnapshot(),
"结果页逐条装配与逐条基准完全一致");
}
@Test
void test_pagination_equiv() throws Exception {
seed();
PublishHistoryVo limited = service.history(1L, 3);
assertEquals(3, limited.getItems().size(), "limit 3 只取最近 3 条");
StringBuilder expected = new StringBuilder("total=5\n");
for (FileTaskEntity task : orderedTasks().subList(0, 3)) {
expected.append(renderTaskDetail(service.getTaskDetail(task.getId(), 1L)));
}
assertEquals(expected.toString(), renderHistory(limited), "分页输出与基准最近 3 条一致");
}
@Test
void test_batch_progress_equiv() throws Exception {
seed();
assertEquals(baselineDetails(), batchSnapshot(),
"progress 批量明细与逐条基准完全一致");
}
@Test
void test_snapshot_reproducible() throws Exception {
seed();
assertEquals(baselineDetails(), baselineDetails(), "基准可重复");
assertEquals(historySnapshot(), historySnapshot(), "批量快照可重复");
assertEquals(dashboardSnapshot(), dashboardSnapshot(), "dashboard 快照可重复");
assertEquals(resultSnapshot(), resultSnapshot(), "结果快照可重复");
}
@Test
void test_snapshot_golden_committed() {
assertTrue(GOLDEN.isFile(), "golden 文件必须存在并入库: " + GOLDEN.getAbsolutePath());
assertTrue(GOLDEN.length() > 0, "golden 非空");
}
@Test
void test_snapshot_covers_all() throws Exception {
seed();
String golden = readGolden();
assertTrue(golden.contains("task id=1"), "golden 覆盖任务 1(成功多文件)");
assertTrue(golden.contains("task id=2"), "golden 覆盖任务 2(运行中+失败文件)");
assertTrue(golden.contains("task id=3"), "golden 覆盖任务 3(无结果)");
assertTrue(golden.contains("task id=4"), "golden 覆盖任务 4(失败+Job 错误)");
assertTrue(golden.contains("task id=5"), "golden 覆盖任务 5(成功单文件)");
assertTrue(golden.contains("result id=901"), "golden 覆盖成功结果");
assertTrue(golden.contains("result id=902"), "golden 覆盖运行中结果");
assertTrue(golden.contains("result id=904"), "golden 覆盖失败结果");
assertTrue(golden.contains("joberr=assemble failed"), "golden 覆盖 Job 错误信息");
}
@Test
void test_snapshot_diff_fails() throws Exception {
seed();
String original = readGolden();
assertTrue(original.contains("task id=1"), "golden 内容合法");
try {
java.nio.file.Files.writeString(GOLDEN.toPath(), original + "\n# tampered",
java.nio.charset.StandardCharsets.UTF_8);
AssertionError failure = null;
try {
assertEquals(original, readGolden(), "篡改后 golden 与原始不一致");
} catch (AssertionError ex) {
failure = ex;
}
assertTrue(failure != null, "篡改 golden 后断言应失败");
} finally {
java.nio.file.Files.writeString(GOLDEN.toPath(), original,
java.nio.charset.StandardCharsets.UTF_8);
}
assertEquals(original, readGolden(), "恢复原始 golden");
}
@Test
void test_snapshot_regression_guard() throws Exception {
seed();
assertEquals(baselineHistoryText(), historySnapshot(), "历史快照与基准一致");
assertEquals(baselineDetails(), resultSnapshot(), "结果快照与基准一致");
assertEquals(baselineDetails(), batchSnapshot(), "progress 快照与基准一致");
assertEquals(readGolden(), historySnapshot(), "整体输出与 golden 快照一致");
}
@Test
void test_snapshot_equiv_fails_on_mutation() throws Exception {
seed();
String before = historySnapshot();
assertEquals(before, historySnapshot());
taskDb.getFirst().setStatus("RUNNING");
assertNotEquals(before, historySnapshot(), "数据变化后快照必须不同(等价门禁可感知改动)");
}
}
@@ -0,0 +1,321 @@
package com.nanri.aiimage.modules.publish.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.InstanceMetadata;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.publish.mapper.PublishFileMapper;
import com.nanri.aiimage.modules.publish.mapper.PublishItemMapper;
import com.nanri.aiimage.modules.publish.model.entity.PublishFileEntity;
import com.nanri.aiimage.modules.publish.model.entity.PublishItemEntity;
import com.nanri.aiimage.modules.publish.model.vo.PublishDashboardVo;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
/**
* 任务 108dashboard 聚合查询。
* dashboard 状态统计由 4 次逐条 selectCount 改为 1 次 GROUP BY 聚合(selectMaps status→count);
* 输出结构(pendingCount/runningCount/successCount/failedCount/recent)与逐条统计完全一致;
* 聚合结果为空时各计数为 0;recent 列表语义不变。
*/
class PublishDashboardAggregateTest {
private static final String MODULE = "PUBLISH";
private final List<FileTaskEntity> taskDb = new ArrayList<>();
private final AtomicInteger aggregateCallCount = new AtomicInteger();
private final AtomicInteger recentListCallCount = new AtomicInteger();
private FileTaskMapper fileTaskMapper;
private PublishItemMapper publishItemMapper;
private PublishFileMapper publishFileMapper;
private FileResultMapper fileResultMapper;
private TaskFileJobService taskFileJobService;
private OssStorageService ossStorageService;
private PublishTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
TableInfoHelper.initTableInfo(assistant, PublishFileEntity.class);
TableInfoHelper.initTableInfo(assistant, PublishItemEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
}
/** 从渲染后的 wrapper 参数中提取 userId(先 getSqlSegment 强制渲染,再读 paramNameValuePairs)。 */
private static Long userIdOf(Wrapper<FileTaskEntity> wrapper, String segment) {
AbstractWrapper<FileTaskEntity, ?, ?> q = (AbstractWrapper<FileTaskEntity, ?, ?>) wrapper;
List<Long> numbers = new ArrayList<>();
for (Object value : q.getParamNameValuePairs().values()) {
if (value instanceof Number n) {
numbers.add(n.longValue());
} else if (value instanceof Iterable<?> iterable) {
for (Object item : iterable) {
if (item instanceof Number n) {
numbers.add(n.longValue());
}
}
}
}
return numbers.stream().filter(Long.class::isInstance).map(Long.class::cast).findFirst().orElse(null);
}
@BeforeEach
void setUp() {
fileTaskMapper = mock(FileTaskMapper.class);
publishItemMapper = mock(PublishItemMapper.class);
publishFileMapper = mock(PublishFileMapper.class);
fileResultMapper = mock(FileResultMapper.class);
taskFileJobService = mock(TaskFileJobService.class);
ossStorageService = mock(OssStorageService.class);
// 聚合查询:一次 selectMaps,按模块+用户过滤后 status→count
doAnswer(invocation -> {
aggregateCallCount.incrementAndGet();
Wrapper<FileTaskEntity> wrapper = invocation.getArgument(0);
String segment = wrapper.getSqlSegment() == null ? "" : wrapper.getSqlSegment();
assertTrue(segment.toLowerCase().contains("group by"), "聚合查询必须带 GROUP BY: " + segment);
Long userId = userIdOf(wrapper, segment);
Map<String, Long> counts = new HashMap<>();
for (FileTaskEntity task : taskDb) {
if (!MODULE.equals(task.getModuleType()) || task.getUserId() == null || task.getStatus() == null) {
continue;
}
if (userId != null && !userId.equals(task.getUserId())) {
continue;
}
counts.merge(task.getStatus(), 1L, Long::sum);
}
List<Map<String, Object>> rows = new ArrayList<>();
for (Map.Entry<String, Long> entry : counts.entrySet()) {
Map<String, Object> row = new HashMap<>();
row.put("status", entry.getKey());
row.put("cnt", entry.getValue());
rows.add(row);
}
return rows;
}).when(fileTaskMapper).selectMaps(any());
// recent 列表:history() 依赖的明细查询
doAnswer(invocation -> {
recentListCallCount.incrementAndGet();
LambdaQueryWrapper<FileTaskEntity> q = invocation.getArgument(0);
String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment();
Long userId = userIdOf(q, segment);
List<FileTaskEntity> filtered = new ArrayList<>();
for (FileTaskEntity task : taskDb) {
if (!MODULE.equals(task.getModuleType()) || task.getUserId() == null) {
continue;
}
if (userId != null && !userId.equals(task.getUserId())) {
continue;
}
filtered.add(task);
}
filtered.sort((a, b) -> b.getCreatedAt().compareTo(a.getCreatedAt()));
if (segment.toLowerCase().contains("limit")) {
int cap = 10;
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("limit\\s+(\\d+)", java.util.regex.Pattern.CASE_INSENSITIVE).matcher(segment);
if (m.find()) {
cap = Integer.parseInt(m.group(1));
}
return filtered.subList(0, Math.min(cap, filtered.size()));
}
return filtered;
}).when(fileTaskMapper).selectList(any());
lenient().when(publishFileMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
lenient().when(taskFileJobService.findAssembleJobsByResultIds(any(), any())).thenReturn(Map.of());
lenient().when(ossStorageService.generateFreshDownloadUrl(any())).thenReturn("https://oss/x");
service = new PublishTaskService(
mock(LocalFileStorageService.class), mock(ZiniaoShopSwitchService.class),
mock(PublishWorkbookService.class), publishFileMapper, publishItemMapper,
fileTaskMapper, fileResultMapper, mock(TaskChunkMapper.class),
mock(TaskScopeStateMapper.class), taskFileJobService,
mock(TaskDistributedLockService.class), mock(TransientPayloadStorageService.class),
ossStorageService, new ObjectMapper(), mock(TransactionTemplate.class),
mock(InstanceMetadata.class),
mock(TaskProgressLightAssembler.class));
}
private static FileTaskEntity task(long id, long userId, String status, LocalDateTime createdAt) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setUserId(userId);
task.setModuleType(MODULE);
task.setStatus(status);
task.setCreatedAt(createdAt);
return task;
}
private void seed() {
taskDb.add(task(1L, 7L, "PENDING", LocalDateTime.of(2026, 8, 1, 10, 1)));
taskDb.add(task(2L, 7L, "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 2)));
taskDb.add(task(3L, 7L, "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 3)));
taskDb.add(task(4L, 7L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 4)));
taskDb.add(task(5L, 7L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 5)));
taskDb.add(task(6L, 7L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 6)));
taskDb.add(task(7L, 7L, "FAILED", LocalDateTime.of(2026, 8, 1, 10, 7)));
taskDb.add(task(8L, 7L, "FAILED", LocalDateTime.of(2026, 8, 1, 10, 8)));
}
@Test
void dashboardCounts() {
seed();
PublishDashboardVo vo = service.dashboard(7L);
assertEquals(1L, vo.getPendingCount());
assertEquals(2L, vo.getRunningCount());
assertEquals(3L, vo.getSuccessCount());
assertEquals(2L, vo.getFailedCount());
}
@Test
void dashboardEmptyUser() {
PublishDashboardVo vo = service.dashboard(7L);
assertEquals(0L, vo.getPendingCount());
assertEquals(0L, vo.getRunningCount());
assertEquals(0L, vo.getSuccessCount());
assertEquals(0L, vo.getFailedCount());
assertTrue(vo.getRecent().isEmpty());
}
@Test
void dashboardAggregateSql() {
seed();
service.dashboard(7L);
assertEquals(1, aggregateCallCount.get(), "状态统计一次 GROUP BY 聚合,不逐条 COUNT");
verify(fileTaskMapper, times(1)).selectMaps(any());
}
@Test
void dashboardRecentTasks() {
seed();
taskDb.add(task(9L, 7L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 9)));
taskDb.add(task(10L, 7L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 10)));
taskDb.add(task(11L, 7L, "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 11)));
taskDb.add(task(12L, 7L, "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 12)));
PublishDashboardVo vo = service.dashboard(7L);
assertEquals(10, vo.getRecent().size(), "recent 固定最多 10 条");
assertEquals(12L, vo.getRecent().getFirst().getTask().getId(), "最近任务按 createdAt 倒序");
assertEquals(3L, vo.getRecent().getLast().getTask().getId(), "超出 10 条的最旧任务被截断");
}
@Test
void dashboardConsistency() {
seed();
PublishDashboardVo vo = service.dashboard(7L);
Map<String, Long> perStatus = new HashMap<>();
for (FileTaskEntity task : taskDb) {
perStatus.merge(task.getStatus(), 1L, Long::sum);
}
assertEquals(perStatus.getOrDefault("PENDING", 0L).longValue(), vo.getPendingCount(), "与逐条统计一致");
assertEquals(perStatus.getOrDefault("RUNNING", 0L).longValue(), vo.getRunningCount());
assertEquals(perStatus.getOrDefault("SUCCESS", 0L).longValue(), vo.getSuccessCount());
assertEquals(perStatus.getOrDefault("FAILED", 0L).longValue(), vo.getFailedCount());
}
@Test
void dashboardMixedStatus() {
taskDb.add(task(1L, 7L, "PENDING", LocalDateTime.of(2026, 8, 1, 9, 0)));
taskDb.add(task(2L, 7L, "RUNNING", LocalDateTime.of(2026, 8, 1, 9, 1)));
taskDb.add(task(3L, 7L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 2)));
taskDb.add(task(4L, 7L, "FAILED", LocalDateTime.of(2026, 8, 1, 9, 3)));
PublishDashboardVo vo = service.dashboard(7L);
assertEquals(1L, vo.getPendingCount());
assertEquals(1L, vo.getRunningCount());
assertEquals(1L, vo.getSuccessCount());
assertEquals(1L, vo.getFailedCount());
assertEquals(4, vo.getRecent().size());
}
@Test
void dashboardUserFiltered() {
seed();
taskDb.add(task(9L, 8L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 11, 0)));
taskDb.add(task(10L, 8L, "FAILED", LocalDateTime.of(2026, 8, 1, 11, 1)));
PublishDashboardVo vo = service.dashboard(7L);
assertEquals(3L, vo.getSuccessCount(), "只统计当前用户任务");
assertEquals(2L, vo.getFailedCount());
assertEquals(8, vo.getRecent().size(), "recent 只含当前用户任务");
}
@Test
void dashboardBigData() {
for (int i = 1; i <= 500; i++) {
String status = i % 4 == 0 ? "SUCCESS" : (i % 4 == 1 ? "FAILED" : (i % 4 == 2 ? "RUNNING" : "PENDING"));
taskDb.add(task(1000L + i, 7L, status, LocalDateTime.of(2026, 8, 1, 0, 0).plusMinutes(i)));
}
PublishDashboardVo vo = service.dashboard(7L);
assertEquals(125L, vo.getPendingCount());
assertEquals(125L, vo.getRunningCount());
assertEquals(125L, vo.getSuccessCount());
assertEquals(125L, vo.getFailedCount());
assertEquals(1, aggregateCallCount.get(), "大数据量仍一次聚合");
}
@Test
void dashboardOtherModuleExcluded() {
seed();
taskDb.add(task(99L, 7L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 12, 0)));
taskDb.getLast().setModuleType("SIMILAR_ASIN");
PublishDashboardVo vo = service.dashboard(7L);
assertEquals(3L, vo.getSuccessCount(), "其他模块任务不计入");
assertEquals(8, vo.getRecent().size());
}
@Test
void dashboardRecentDetailAssembled() {
seed();
PublishDashboardVo vo = service.dashboard(7L);
assertNotNull(vo.getRecent().getFirst().getTask(), "recent 明细含 task 信息");
assertEquals("FAILED", vo.getRecent().getFirst().getTask().getStatus());
assertEquals(8L, vo.getRecent().getFirst().getTask().getId());
assertEquals(1, aggregateCallCount.get());
assertEquals(1, recentListCallCount.get(), "recent 列表一次明细查询");
}
}
@@ -22,6 +22,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -135,7 +136,8 @@ class ShopDataCrawlChunkUpsertTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
storedChunks.clear();
storedScopes.clear();
@@ -28,6 +28,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -170,7 +171,8 @@ class ShopDataCrawlCleanupTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
ReflectionTestUtils.setField(service, "staleTimeoutMinutes", 30L);
dbResultRows.clear();
@@ -23,6 +23,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -144,7 +145,8 @@ class ShopDataCrawlDailyFileIncrementalTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
dbResultRows.clear();
dbDailyFiles.clear();
@@ -23,6 +23,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -150,7 +151,8 @@ class ShopDataCrawlDailyFileJobSplitTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
dbResultRows.clear();
dbDailyFiles.clear();
@@ -23,6 +23,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -150,7 +151,8 @@ class ShopDataCrawlDailyFileLockTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
dbResultRows.clear();
dbDailyFiles.clear();
@@ -0,0 +1,240 @@
package com.nanri.aiimage.modules.shopdatacrawl.service;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.shopdatacrawl.service.support.ShopDataCrawlRowNormalizer;
import com.nanri.aiimage.modules.shopdatacrawl.service.support.ShopDataCrawlSheetBuilder;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.File;
import java.io.FileInputStream;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
/**
* 任务 101ShopDataCrawl 门面改委托。
* 门面(ShopDataCrawlExcelAssemblyService)签名与行为不变:writeWorkbook / writeWorkbookStreaming /
* replaceCountriesWorkbook / countRows 仍产出与抽取前一致的 workbook;Sheet 构造与行分组
* 全部委托 ShopDataCrawlSheetBuilder(私有方法经反射直接验证,不经过完整任务链路)。
*/
class ShopDataCrawlExcelAssemblyServiceDelegationTest {
@TempDir Path tempDir;
// ---------- 1 签名不变 ----------
@Test
void test_write_workbook_signature_unchanged() throws Exception {
Method method = ShopDataCrawlExcelAssemblyService.class.getDeclaredMethod(
"writeWorkbook", File.class, List.class);
assertEquals(int.class, method.getReturnType(), "返回类型不变");
assertEquals(2, method.getParameterCount(), "参数个数不变");
}
@Test
void test_write_workbook_streaming_signature_unchanged() throws Exception {
Method method = ShopDataCrawlExcelAssemblyService.class.getDeclaredMethod(
"writeWorkbookStreaming", File.class, List.class, int.class);
assertEquals(int.class, method.getReturnType(), "返回类型不变");
assertEquals(3, method.getParameterCount(), "参数个数不变");
}
// ---------- 2 委托各组件 ----------
@Test
void test_streaming_sheet_headers_delegated_to_builder() throws Exception {
ShopDataCrawlResultItemVo item = item("UK", row("2026-07-25", "B012345678"));
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
File output = tempDir.resolve("stream.xlsx").toFile();
int count = new ShopDataCrawlExcelAssemblyService(imageEmbedder)
.writeWorkbookStreaming(output, List.of(item), 100);
assertEquals(1, count, "数据行数委托 rowsByCountry");
try (XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(output))) {
assertEquals(ShopDataCrawlSheetBuilder.SHEETS, sheetNames(wb), "sheet 名来自 SheetBuilder");
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
Row header = wb.getSheetAt(i).getRow(0);
for (int column = 0; column < ShopDataCrawlSheetBuilder.HEADERS.size(); column++) {
assertEquals(ShopDataCrawlSheetBuilder.HEADERS.get(column),
header.getCell(column).getStringCellValue(), "表头来自 SheetBuilder");
}
}
Row data = wb.getSheet("英国").getRow(1);
assertEquals("2026-07-25", data.getCell(0).getStringCellValue());
assertEquals("B012345678", data.getCell(1).getStringCellValue());
assertEquals("Example Brand", data.getCell(9).getStringCellValue(), "品牌末列");
}
}
@Test
void test_rows_by_country_delegates_builder_grouping() throws Exception {
ShopDataCrawlResultItemVo item = item("uk", row("2026-07-25", "B012345678"));
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
File output = tempDir.resolve("group.xlsx").toFile();
new ShopDataCrawlExcelAssemblyService(imageEmbedder).writeWorkbookStreaming(output, List.of(item), 100);
try (XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(output))) {
assertEquals("B012345678", wb.getSheetAt(0).getRow(1).getCell(1).getStringCellValue(),
"小写国家码归一化后归入英国 sheet");
assertEquals(0, wb.getSheetAt(1).getLastRowNum(), "德国 sheet 无数据");
}
}
@Test
void test_write_workbook_template_path_kept_in_facade() throws Exception {
// 模板路径仍由门面加载:写出的 workbook 保留模板样式路径(图片列宽 18*256)
ShopDataCrawlResultItemVo item = item("DE", row("2026-07-25", "B012345678"));
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
File output = tempDir.resolve("tpl.xlsx").toFile();
int count = new ShopDataCrawlExcelAssemblyService(imageEmbedder).writeWorkbook(output, List.of(item));
assertEquals(1, count);
try (XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(output))) {
assertEquals(18 * 256, wb.getSheetAt(1).getColumnWidth(2), "模板路径保留图片列宽");
assertEquals("B012345678", wb.getSheetAt(1).getRow(1).getCell(1).getStringCellValue());
}
}
@Test
void test_replace_countries_delegates_sheet_builder() throws Exception {
ShopDataCrawlRowDto ukRow = row("2026-07-25", "B012345678");
ShopDataCrawlRowDto deRow = row("2026-07-26", "B099999999");
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
ShopDataCrawlExcelAssemblyService service = new ShopDataCrawlExcelAssemblyService(imageEmbedder);
File base = tempDir.resolve("base.xlsx").toFile();
File output = tempDir.resolve("daily.xlsx").toFile();
service.writeWorkbook(base, List.of(item("UK", ukRow), item("DE", deRow)));
int total = service.replaceCountriesWorkbook(base, output, List.of(item("DE", row("2026-07-27", "B099999998"))));
assertEquals(2, total, "替换后总行数(UK 1 + DE 1");
try (XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(output))) {
assertEquals("B012345678", wb.getSheetAt(0).getRow(1).getCell(1).getStringCellValue(), "未替换国家保留");
assertEquals("B099999998", wb.getSheetAt(1).getRow(1).getCell(1).getStringCellValue(), "替换国家新行");
assertEquals(0, wb.getSheetAt(2).getLastRowNum());
}
}
// ---------- 3 结果一致 ----------
@Test
void test_write_and_streaming_produce_same_row_count() throws Exception {
ShopDataCrawlResultItemVo item = item("UK", row("2026-07-25", "B012345678"));
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
ShopDataCrawlExcelAssemblyService service = new ShopDataCrawlExcelAssemblyService(imageEmbedder);
File tplOutput = tempDir.resolve("tpl.xlsx").toFile();
File streamOutput = tempDir.resolve("stream.xlsx").toFile();
int tplCount = service.writeWorkbook(tplOutput, List.of(item));
int streamCount = service.writeWorkbookStreaming(streamOutput, List.of(item), 100);
assertEquals(tplCount, streamCount, "模板路径与流式路径行数一致");
try (XSSFWorkbook tpl = new XSSFWorkbook(new FileInputStream(tplOutput));
XSSFWorkbook stream = new XSSFWorkbook(new FileInputStream(streamOutput))) {
assertEquals(tpl.getSheet("英国").getRow(1).getCell(1).getStringCellValue(),
stream.getSheet("英国").getRow(1).getCell(1).getStringCellValue(), "数据内容一致");
}
}
@Test
void test_count_rows_matches_write_row_count() throws Exception {
ShopDataCrawlResultItemVo uk = item("UK", row("2026-07-25", "B01"));
ShopDataCrawlResultItemVo de = item("DE", row("2026-07-25", "B02"));
SimilarAsinImageEmbedder imageEmbedder = mock(SimilarAsinImageEmbedder.class);
ShopDataCrawlExcelAssemblyService service = new ShopDataCrawlExcelAssemblyService(imageEmbedder);
assertEquals(2, service.countRows(List.of(uk, de)), "countRows 委托分组计数");
}
// ---------- 4 异常一致 ----------
@Test
void test_streaming_null_output_throws_business_exception() {
ShopDataCrawlExcelAssemblyService service = new ShopDataCrawlExcelAssemblyService(mock(SimilarAsinImageEmbedder.class));
com.nanri.aiimage.common.exception.BusinessException ex =
org.junit.jupiter.api.Assertions.assertThrows(com.nanri.aiimage.common.exception.BusinessException.class,
() -> service.writeWorkbookStreaming(null, List.of(), 100));
assertTrue(ex.getMessage().contains("输出文件路径不能为空"), "实际: " + ex.getMessage());
}
@Test
void test_streaming_invalid_window_throws_illegal_argument() {
ShopDataCrawlExcelAssemblyService service = new ShopDataCrawlExcelAssemblyService(mock(SimilarAsinImageEmbedder.class));
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
() -> service.writeWorkbookStreaming(tempDir.resolve("w.xlsx").toFile(), List.of(), 0),
"rowAccessWindow 校验不变");
}
// ---------- 5 行分组/归一化语义由组件承接 ----------
@Test
void test_grouping_semantics_kept_in_sheet_builder() {
ShopDataCrawlRowDto row = row("2026-07-25", "B012345678");
ShopDataCrawlResultItemVo item = item("DE", row);
Map<String, List<ShopDataCrawlRowDto>> grouped = ShopDataCrawlSheetBuilder.rowsByCountry(List.of(item));
assertEquals(List.of("UK", "DE", "FR", "ES", "IT"), grouped.keySet().stream().toList(), "5 国固定顺序");
assertEquals(1, grouped.get("DE").size());
assertEquals(0, grouped.get("UK").size());
assertEquals(0, ShopDataCrawlSheetBuilder.rowsByCountry(null).get("UK").size(), "null 条目返回空分组");
assertEquals(0, ShopDataCrawlSheetBuilder.rowsByCountry(List.of(item("US", row))).get("UK").size(),
"未命中 5 国列表的国家被丢弃");
assertEquals(0, ShopDataCrawlSheetBuilder.rowsByCountry(List.of(item("DE", row))).get("FR").size());
}
@Test
void test_normalizer_semantics_unchanged() {
assertEquals("", ShopDataCrawlRowNormalizer.normalizeCountry(null));
assertEquals("DE", ShopDataCrawlRowNormalizer.normalizeCountry(" 德国 "));
assertEquals("B01", ShopDataCrawlRowNormalizer.trim(" B01 "));
ShopDataCrawlRowDto a = row("2026-07-25", "B01");
ShopDataCrawlRowDto b = row(" 2026-07-25 ", " B01 ");
assertTrue(ShopDataCrawlRowNormalizer.sameRow(a, b), "sameRow trim 语义");
}
// ---------- 辅助 ----------
private static ShopDataCrawlResultItemVo item(String countryCode, ShopDataCrawlRowDto row) {
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
country.setCountry(countryCode);
country.setItems(List.of(row));
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
item.setSuccess(true);
item.setCountryResults(List.of(country));
return item;
}
private static ShopDataCrawlRowDto row(String date, String asin) {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setDate(date);
row.setAsin(asin);
row.setBrand("Example Brand");
row.setInventorySales("11");
row.setSalesRank("22");
row.setPageViews("33");
row.setUnitsSold("44");
row.setPrice("12.50");
row.setRecommendedOffer("12.00");
return row;
}
private static List<String> sheetNames(XSSFWorkbook wb) {
return java.util.stream.IntStream.range(0, wb.getNumberOfSheets())
.mapToObj(i -> wb.getSheetAt(i).getSheetName()).toList();
}
}
@@ -23,6 +23,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -132,7 +133,8 @@ class ShopDataCrawlLightweightProgressTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
storedChunks.clear();
storedScopes.clear();
@@ -23,6 +23,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.model.vo.ZiniaoShopMatchResultVo;
@@ -131,7 +132,8 @@ class ShopDataCrawlOwnerColumnTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
dbTasks.clear();
lastScan.clear();
@@ -23,6 +23,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -144,7 +145,8 @@ class ShopDataCrawlProgressQueryTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
dbResultRows.clear();
dbTasks.clear();
@@ -23,6 +23,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -131,7 +132,8 @@ class ShopDataCrawlRowDedupKeyTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
storedChunks.clear();
storedScopes.clear();
@@ -22,6 +22,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -132,7 +133,8 @@ class ShopDataCrawlScopeCounterTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
storedChunks.clear();
storedScopes.clear();
@@ -22,6 +22,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -133,7 +134,8 @@ class ShopDataCrawlScopeMergeTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
storedChunks.clear();
storedScopes.clear();
@@ -23,6 +23,7 @@ import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler;
import com.nanri.aiimage.modules.task.service.TaskResultItemService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService;
@@ -127,7 +128,8 @@ class ShopDataCrawlTaskServiceChunkTest {
transientPayloadStorageService,
instanceMetadata,
dailyFileService,
null);
null,
mock(TaskProgressLightAssembler.class));
storedChunks.clear();
storedScopes.clear();
@@ -0,0 +1,216 @@
package com.nanri.aiimage.modules.shopdatacrawl.service.support;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 103ShopDataCrawlHistoryAssembler 历史查询组装器。
* 历史列表 VO 拼装(toHistoryItem + 文件任务状态链)从 ShopDataCrawlTaskService 原样搬移;
* 只读不落库;输出与现状逐字段一致(快照优先,实体字段兜底,task/job 附加)。
*/
class ShopDataCrawlHistoryAssemblerTest {
private final ShopDataCrawlHistoryAssembler assembler = new ShopDataCrawlHistoryAssembler();
private static FileResultEntity result(Long id, Long taskId, String source, String sourceFileUrl, String resultFilename,
String resultFileUrl, Integer success, String errorMessage, LocalDateTime createdAt) {
FileResultEntity row = new FileResultEntity();
row.setId(id);
row.setTaskId(taskId);
row.setSourceFilename(source);
row.setSourceFileUrl(sourceFileUrl);
row.setResultFilename(resultFilename);
row.setResultFileUrl(resultFileUrl);
row.setSuccess(success);
row.setErrorMessage(errorMessage);
row.setCreatedAt(createdAt);
return row;
}
private static FileTaskEntity task(Long id, String status, LocalDateTime finishedAt) {
FileTaskEntity task = new FileTaskEntity();
task.setId(id);
task.setStatus(status);
task.setFinishedAt(finishedAt);
return task;
}
private static TaskFileJobEntity job(Long id, String status, String errorMessage) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(id);
job.setStatus(status);
job.setErrorMessage(errorMessage);
return job;
}
private static ShopDataCrawlResultItemVo snapshot(String shopName, String shopId, String outputFilename,
String error, String taskStatus) {
ShopDataCrawlResultItemVo snapshot = new ShopDataCrawlResultItemVo();
snapshot.setShopName(shopName);
snapshot.setShopId(shopId);
snapshot.setOutputFilename(outputFilename);
snapshot.setError(error);
snapshot.setTaskStatus(taskStatus);
return snapshot;
}
// ---------- toHistoryItem 字段拼装 ----------
@Test
void test_assembler_history_items() {
FileResultEntity row = result(100L, 10L, "a.xlsx", "https://src/10/a.xlsx", "a-result.xlsx",
"result/10/a.xlsx", 1, null, LocalDateTime.of(2026, 8, 1, 10, 0));
ShopDataCrawlResultItemVo item = assembler.toHistoryItem(row, task(10L, "SUCCESS",
LocalDateTime.of(2026, 8, 1, 9, 5)), snapshot("快照店", "S1", "snap.xlsx", null, null), null);
assertEquals(100L, item.getResultId());
assertEquals(10L, item.getTaskId());
assertEquals("快照店", item.getShopName(), "快照 shopName 优先");
assertEquals("S1", item.getShopId(), "快照 shopId 优先");
assertEquals("SUCCESS", item.getTaskStatus(), "task 状态覆盖快照");
assertEquals(Boolean.TRUE, item.getSuccess(), "success=1 即成功");
assertEquals(LocalDateTime.of(2026, 8, 1, 10, 0), item.getCreatedAt(), "createdAt 取实体");
assertEquals(LocalDateTime.of(2026, 8, 1, 9, 5), item.getFinishedAt(), "finishedAt 取 task");
assertEquals("snap.xlsx", item.getOutputFilename(), "快照 outputFilename 优先");
assertNull(item.getDownloadUrl(), "downloadUrl 恒为空");
assertTrue(item.getFileReady(), "文件 URL 就绪");
}
@Test
void test_assembler_snapshot_fallback_fields() {
FileResultEntity row = result(100L, 10L, "a.xlsx", "https://src/10/a.xlsx", "a-result.xlsx",
null, 0, "python timeout", LocalDateTime.of(2026, 8, 1, 10, 0));
ShopDataCrawlResultItemVo item = assembler.toHistoryItem(row, null, snapshot(null, null, null, "old err", null), null);
assertEquals("a.xlsx", item.getShopName(), "快照缺 shopName 回退实体 sourceFilename");
assertEquals("https://src/10/a.xlsx", item.getShopId(), "快照缺 shopId 回退实体 sourceFileUrl");
assertEquals("python timeout", item.getError(), "实体 errorMessage 优先");
assertEquals("a-result.xlsx", item.getOutputFilename(), "快照缺 outputFilename 回退实体 resultFilename");
assertNull(item.getTaskStatus(), "缺 task 快照 taskStatus 为 null 时保留 null");
assertEquals(Boolean.FALSE, item.getSuccess(), "success=0 为失败");
assertNull(item.getFinishedAt(), "缺 task 无结束时间");
}
@Test
void test_assembler_null_snapshot_and_null_task() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, null, null, null, null,
LocalDateTime.of(2026, 8, 1, 10, 0));
ShopDataCrawlResultItemVo item = assembler.toHistoryItem(row, null, null, null);
assertEquals(100L, item.getResultId());
assertEquals("a.xlsx", item.getShopName(), "无快照回退实体");
assertNull(item.getShopId(), "无快照且实体缺 shopId 为空");
assertNull(item.getSuccess(), "success 缺省且快照缺省为 null");
assertNull(item.getError());
assertFalse(Boolean.TRUE.equals(item.getFileReady()), "无文件 URL 未就绪");
assertNull(item.getFileStatus(), "无 job 且文件未就绪状态为空");
assertTrue(item.getCountryResults() != null && item.getCountryResults().isEmpty(), "countryResults 非 null 空列表");
assertTrue(item.getCountryCodes() != null && item.getCountryCodes().isEmpty(), "countryCodes 非 null 空列表");
}
@Test
void test_assembler_snapshot_mutation_keeps_existing_values() {
// 快照实体字段存在时保留(countryResults 原样,不被清空)
ShopDataCrawlResultItemVo snapshot = snapshot("店A", "S1", "out.xlsx", null, "RUNNING");
snapshot.setCountryResults(new ArrayList<>(List.of()));
FileResultEntity row = result(100L, 10L, "a.xlsx", null, null, null, 1, null,
LocalDateTime.of(2026, 8, 1, 10, 0));
ShopDataCrawlResultItemVo item = assembler.toHistoryItem(row, null, snapshot, null);
assertSame(snapshot, item, "快照非 null 时返回同一实例(原地填充)");
assertEquals("店A", item.getShopName());
assertEquals("RUNNING", item.getTaskStatus(), "快照 taskStatus 保留");
}
// ---------- 文件任务状态链 ----------
@Test
void test_assembler_file_state_job_attached() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, null, null, 0, null,
LocalDateTime.of(2026, 8, 1, 10, 0));
ShopDataCrawlResultItemVo item = assembler.toHistoryItem(row, null, null, job(7L, "RUNNING", null));
assertEquals(7L, item.getFileJobId());
assertEquals("RUNNING", item.getFileStatus());
assertNull(item.getFileError(), "非 FAILED job 不附错误");
}
@Test
void test_assembler_file_state_failed_job_error_attached() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, null, null, 0, null,
LocalDateTime.of(2026, 8, 1, 10, 0));
ShopDataCrawlResultItemVo item = assembler.toHistoryItem(row, null, null, job(9L, "FAILED", "assemble boom"));
assertEquals("FAILED", item.getFileStatus());
assertEquals("assemble boom", item.getFileError(), "job 错误信息附带");
}
@Test
void test_assembler_file_state_no_job_file_ready_success() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, "a-result.xlsx", "result/10/a.xlsx", 1, null,
LocalDateTime.of(2026, 8, 1, 10, 0));
ShopDataCrawlResultItemVo item = assembler.toHistoryItem(row, null, null, null);
assertTrue(item.getFileReady(), "文件 URL 就绪");
assertEquals("SUCCESS", item.getFileStatus(), "无 job 且文件就绪状态为 SUCCESS");
}
@Test
void test_assembler_country_lists_filled_when_snapshot_missing() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, null, null, 0, null,
LocalDateTime.of(2026, 8, 1, 10, 0));
ShopDataCrawlResultItemVo item = assembler.toHistoryItem(row, null, null, null);
assertTrue(item.getCountryResults() != null && item.getCountryResults().isEmpty(), "countryResults 空列表");
assertTrue(item.getCountryCodes() != null && item.getCountryCodes().isEmpty(), "countryCodes 空列表");
}
@Test
void test_assembler_immutable_input() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, null, null, 1, null,
LocalDateTime.of(2026, 8, 1, 10, 0));
FileTaskEntity t = task(10L, "SUCCESS", LocalDateTime.of(2026, 8, 1, 9, 5));
assembler.toHistoryItem(row, t, null, null);
assertEquals("a.xlsx", row.getSourceFilename(), "result 不被修改");
assertEquals("SUCCESS", t.getStatus(), "task 不被修改");
assertEquals(LocalDateTime.of(2026, 8, 1, 9, 5), t.getFinishedAt(), "task 不被修改");
}
@Test
void test_assembler_consistency_with_current_output() {
FileResultEntity row = result(100L, 10L, "a.xlsx", null, null, "result/10/a.xlsx", 1, null,
LocalDateTime.of(2026, 8, 1, 10, 0));
ShopDataCrawlResultItemVo item = assembler.toHistoryItem(row, task(10L, "FAILED",
LocalDateTime.of(2026, 8, 1, 9, 30)), null, null);
assertEquals("a.xlsx", item.getShopName(), "无快照时 shopName 回退 sourceFilename");
assertEquals(Boolean.TRUE, item.getSuccess(), "文件 URL 就绪即成功");
assertEquals("FAILED", item.getTaskStatus());
assertEquals(LocalDateTime.of(2026, 8, 1, 9, 30), item.getFinishedAt());
assertTrue(item.getFileReady(), "文件 URL 就绪");
}
}
@@ -0,0 +1,182 @@
package com.nanri.aiimage.modules.shopdatacrawl.service.support;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
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.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 99ShopDataCrawlRowNormalizer 行解析/归一化器。
* 规则与 ShopDataCrawlTaskService.trim / blank / blankToNull / trimToNull /
* normalizeCountry / copyRow / rowEmpty / sameRow / rowDedupKey 现状逐字节一致。
* 纯函数无状态;rowDedupKey 与 sameRow 的 10 字段 trim 比较语义等价。
*/
class ShopDataCrawlRowNormalizerTest {
// ---- trim ----
@Test
void test_trim_null_returns_empty() {
assertEquals("", ShopDataCrawlRowNormalizer.trim(null), "null 归一化为空串");
}
@Test
void test_trim_plain_trimming() {
assertEquals("B001", ShopDataCrawlRowNormalizer.trim(" B001 "), "trim 去首尾空白");
assertEquals("", ShopDataCrawlRowNormalizer.trim(" "), "全空白归空串");
}
// ---- blank ----
@Test
void test_blank_detection() {
assertTrue(ShopDataCrawlRowNormalizer.blank(null), "null 视为空白");
assertTrue(ShopDataCrawlRowNormalizer.blank(""), "空串视为空白");
assertTrue(ShopDataCrawlRowNormalizer.blank(" "), "全空白视为空白");
assertFalse(ShopDataCrawlRowNormalizer.blank("DE"), "非空白不视为空白");
}
// ---- blankToNull / trimToNull ----
@Test
void test_blank_to_null() {
assertNull(ShopDataCrawlRowNormalizer.blankToNull(null));
assertNull(ShopDataCrawlRowNormalizer.blankToNull(" "));
assertEquals("DE", ShopDataCrawlRowNormalizer.blankToNull(" DE "), "非空白 trim 返回");
}
@Test
void test_trim_to_null() {
assertNull(ShopDataCrawlRowNormalizer.trimToNull(null));
assertNull(ShopDataCrawlRowNormalizer.trimToNull(" "), "trim 后为空返回 null");
assertEquals("B001", ShopDataCrawlRowNormalizer.trimToNull(" B001 "));
}
// ---- normalizeCountry ----
@Test
void test_normalize_country_chinese_aliases() {
assertEquals("DE", ShopDataCrawlRowNormalizer.normalizeCountry("德国"));
assertEquals("UK", ShopDataCrawlRowNormalizer.normalizeCountry("英国"));
assertEquals("FR", ShopDataCrawlRowNormalizer.normalizeCountry("法国"));
assertEquals("IT", ShopDataCrawlRowNormalizer.normalizeCountry("意大利"));
assertEquals("ES", ShopDataCrawlRowNormalizer.normalizeCountry("西班牙"));
}
@Test
void test_normalize_country_uppercase_and_fallback() {
assertEquals("DE", ShopDataCrawlRowNormalizer.normalizeCountry(" de "), "trim + 大写");
assertEquals("US", ShopDataCrawlRowNormalizer.normalizeCountry("us"), "未知国家原样大写");
assertEquals("", ShopDataCrawlRowNormalizer.normalizeCountry(null), "null 归空串");
}
// ---- copyRow ----
@Test
void test_copy_row_ten_fields_trimmed() {
ShopDataCrawlRowDto source = row(" 2026-07-25 ", " B001 ", " Brand ", " img ",
" 10 ", " 20 ", " 30 ", " 40 ", " 50 ", " 60 ");
ShopDataCrawlRowDto copy = ShopDataCrawlRowNormalizer.copyRow(source);
assertEquals("2026-07-25", copy.getDate());
assertEquals("B001", copy.getAsin());
assertEquals("Brand", copy.getBrand());
assertEquals("img", copy.getCommodityImage());
assertEquals("10", copy.getInventorySales());
assertEquals("20", copy.getSalesRank());
assertEquals("30", copy.getPageViews());
assertEquals("40", copy.getUnitsSold());
assertEquals("50", copy.getPrice());
assertEquals("60", copy.getRecommendedOffer());
}
@Test
void test_copy_row_does_not_mutate_source() {
ShopDataCrawlRowDto source = row(" 2026-07-25 ", " B001 ", " B ", " i ",
" 1 ", " 2 ", " 3 ", " 4 ", " 5 ", " 6 ");
ShopDataCrawlRowNormalizer.copyRow(source);
assertEquals(" 2026-07-25 ", source.getDate(), "source 不被修改");
assertEquals(" B001 ", source.getAsin());
}
// ---- rowEmpty ----
@Test
void test_row_empty_detection() {
ShopDataCrawlRowDto blank = new ShopDataCrawlRowDto();
assertTrue(ShopDataCrawlRowNormalizer.rowEmpty(null), "null 行视为空");
assertTrue(ShopDataCrawlRowNormalizer.rowEmpty(blank), "全字段空白视为空");
blank.setAsin(" ");
assertTrue(ShopDataCrawlRowNormalizer.rowEmpty(blank), "空白 asin 不算有内容");
blank.setAsin("B001");
assertFalse(ShopDataCrawlRowNormalizer.rowEmpty(blank), "任一字段有值即非空");
}
// ---- sameRow ----
@Test
void test_same_row_compares_ten_trimmed_fields() {
ShopDataCrawlRowDto a = row("2026-07-25", "B001", "Brand", "img",
"10", "20", "30", "40", "50", "60");
ShopDataCrawlRowDto b = row(" 2026-07-25 ", " B001 ", " Brand ", " img ",
" 10 ", " 20 ", " 30 ", " 40 ", " 50 ", " 60 ");
assertTrue(ShopDataCrawlRowNormalizer.sameRow(a, b), "trim 后语义相同");
b.setPrice("51");
assertFalse(ShopDataCrawlRowNormalizer.sameRow(a, b), "单字段差异即不同");
assertFalse(ShopDataCrawlRowNormalizer.sameRow(null, a), "null 行不同");
}
// ---- rowDedupKey ----
@Test
void test_row_dedup_key_semantics_equivalent_to_same_row() {
ShopDataCrawlRowDto a = row("2026-07-25", "B001", "Brand", "img",
"10", "20", "30", "40", "50", "60");
ShopDataCrawlRowDto b = row(" 2026-07-25 ", " B001 ", " Brand ", " img ",
" 10 ", " 20 ", " 30 ", " 40 ", " 50 ", " 60 ");
assertEquals(ShopDataCrawlRowNormalizer.rowDedupKey(a),
ShopDataCrawlRowNormalizer.rowDedupKey(b), "语义相同行键相等");
assertNull(ShopDataCrawlRowNormalizer.rowDedupKey(null), "null 行键为 null");
b.setUnitsSold("41");
assertNotEquals(ShopDataCrawlRowNormalizer.rowDedupKey(a),
ShopDataCrawlRowNormalizer.rowDedupKey(b), "任一字段差异键不同");
}
@Test
void test_row_dedup_key_stable_order() {
ShopDataCrawlRowDto a = row("2026-07-25", "B001", "Brand", "img",
"10", "20", "30", "40", "50", "60");
assertEquals(ShopDataCrawlRowNormalizer.rowDedupKey(a),
ShopDataCrawlRowNormalizer.rowDedupKey(a), "同一行键稳定");
assertTrue(ShopDataCrawlRowNormalizer.rowDedupKey(a).contains("B001"), "键含字段值");
}
private static ShopDataCrawlRowDto row(String date, String asin, String brand, String image,
String inventory, String rank, String views, String units,
String price, String offer) {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setDate(date);
row.setAsin(asin);
row.setBrand(brand);
row.setCommodityImage(image);
row.setInventorySales(inventory);
row.setSalesRank(rank);
row.setPageViews(views);
row.setUnitsSold(units);
row.setPrice(price);
row.setRecommendedOffer(offer);
return row;
}
}
@@ -0,0 +1,200 @@
package com.nanri.aiimage.modules.shopdatacrawl.service.support;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 100ShopDataCrawlSheetBuilder Sheet 构造器。
* 结果 Workbook/Sheet 构造辅助(表头、列序、样式、行值派生、模板校验)。
* 与现状 ShopDataCrawlExcelAssemblyService 一致:5 个国家工作表、10 列表头(品牌末列)、
* 图片列 2 宽 18*256、模板列映射(legacy/current/带品牌变体)。不落库、无 IO 依赖。
*/
class ShopDataCrawlSheetBuilderTest {
private static ShopDataCrawlRowDto row(String date, String asin, String brand, String image) {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setDate(date);
row.setAsin(asin);
row.setBrand(brand);
row.setCommodityImage(image);
row.setInventorySales("11");
row.setSalesRank("22");
row.setPageViews("33");
row.setUnitsSold("44");
row.setPrice("12.50");
row.setRecommendedOffer("12.00");
return row;
}
@Test
void test_headers_and_sheet_names() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 0);
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 1);
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 2);
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 3);
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 4);
assertEquals(List.of("英国", "德国", "法国", "西班牙", "意大利"), sheetNames(wb), "5 个国家 sheet 顺序");
for (int i = 0; i < 5; i++) {
Row header = wb.getSheetAt(i).getRow(0);
assertEquals(ShopDataCrawlSheetBuilder.HEADERS.size(), header.getLastCellNum(), "" + i + " 个 sheet 10 列");
for (int column = 0; column < ShopDataCrawlSheetBuilder.HEADERS.size(); column++) {
assertEquals(ShopDataCrawlSheetBuilder.HEADERS.get(column),
header.getCell(column).getStringCellValue(), "" + i + " 个 sheet 第 " + column + " 列表头");
}
}
}
}
@Test
void test_column_order_brand_last() {
assertEquals("品牌", ShopDataCrawlSheetBuilder.HEADERS.get(9), "品牌为最后一列");
assertEquals(9, ShopDataCrawlSheetBuilder.BRAND_COLUMN);
assertEquals("商品图片", ShopDataCrawlSheetBuilder.HEADERS.get(2), "图片列为第 3 列");
assertEquals(2, ShopDataCrawlSheetBuilder.IMAGE_COLUMN);
}
@Test
void test_streaming_sheet_column_width() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 0);
assertEquals(18 * 256, sheet.getColumnWidth(2), "图片列宽");
}
}
@Test
void test_validate_template_accepts_current_brand_template() throws Exception {
try (XSSFWorkbook wb = new XSSFWorkbook()) {
for (int i = 0; i < 5; i++) {
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, i);
}
ShopDataCrawlSheetBuilder.validateTemplate(wb);
}
}
@Test
void test_validate_template_rejects_wrong_sheet_count() throws Exception {
try (XSSFWorkbook wb = new XSSFWorkbook()) {
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 0);
assertThrows(BusinessException.class, () -> ShopDataCrawlSheetBuilder.validateTemplate(wb),
"工作表数量不正确");
}
}
@Test
void test_validate_template_rejects_wrong_headers() throws Exception {
try (XSSFWorkbook wb = new XSSFWorkbook()) {
for (int i = 0; i < 5; i++) {
wb.createSheet(ShopDataCrawlSheetBuilder.SHEETS.get(i));
}
wb.getSheetAt(0).createRow(0).createCell(0).setCellValue("错误表头");
assertThrows(BusinessException.class, () -> ShopDataCrawlSheetBuilder.validateTemplate(wb),
"表头不正确");
}
}
@Test
void test_write_data_row_values_layout() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 0);
Row row = sheet.createRow(1);
ShopDataCrawlSheetBuilder.writeDataRowValues(row, row("2026-07-25", "B012345678", "Brand-X", "https://img/x.jpg"),
new CellStyle[ShopDataCrawlSheetBuilder.HEADERS.size()]);
assertEquals("2026-07-25", row.getCell(0).getStringCellValue(), "日期列");
assertEquals("B012345678", row.getCell(1).getStringCellValue(), "asin 列");
assertEquals("", row.getCell(2).getStringCellValue(), "图片列留空由调用方嵌入");
assertEquals("11", row.getCell(3).getStringCellValue(), "库存销量列");
assertEquals("22", row.getCell(4).getStringCellValue(), "销售排名列");
assertEquals("33", row.getCell(5).getStringCellValue(), "页面浏览量列");
assertEquals("44", row.getCell(6).getStringCellValue(), "售出件数列");
assertEquals("12.50", row.getCell(7).getStringCellValue(), "价格列");
assertEquals("12.00", row.getCell(8).getStringCellValue(), "推荐报价列");
assertEquals("Brand-X", row.getCell(9).getStringCellValue(), "品牌末列");
}
}
@Test
void test_write_data_row_null_values_blank_cells() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 0);
ShopDataCrawlRowDto rowDto = new ShopDataCrawlRowDto();
rowDto.setAsin("B01");
Row row = sheet.createRow(1);
ShopDataCrawlSheetBuilder.writeDataRowValues(row, rowDto, new CellStyle[ShopDataCrawlSheetBuilder.HEADERS.size()]);
assertEquals("", row.getCell(0).getStringCellValue(), "null 日期归空串");
assertEquals("B01", row.getCell(1).getStringCellValue());
assertEquals("", row.getCell(9).getStringCellValue(), "null 品牌归空串");
}
}
@Test
void test_template_column_mapping_variants() {
assertEquals(0, ShopDataCrawlSheetBuilder.templateColumnForOutput(0, true, true), "日期列恒为 0");
assertEquals(2, ShopDataCrawlSheetBuilder.templateColumnForOutput(2, true, true), "current 带品牌图片列不变");
assertEquals(3, ShopDataCrawlSheetBuilder.templateColumnForOutput(3, true, true), "current 库存列不变");
assertEquals(9, ShopDataCrawlSheetBuilder.templateColumnForOutput(9, true, true), "current 品牌列不变");
assertEquals(1, ShopDataCrawlSheetBuilder.templateColumnForOutput(9, true, false), "current 无品牌品牌列映射到 1");
assertEquals(2, ShopDataCrawlSheetBuilder.templateColumnForOutput(3, false, false), "legacy 无图片列库存列映射到 2");
assertEquals(2, ShopDataCrawlSheetBuilder.templateColumnForOutput(3, false, true), "legacy 带品牌库存列映射到 2");
assertEquals(1, ShopDataCrawlSheetBuilder.templateColumnForOutput(9, false, false), "legacy 无品牌品牌列映射到 1");
}
@Test
void test_clear_data_rows_keeps_header() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 0);
Row row = sheet.createRow(1);
ShopDataCrawlSheetBuilder.writeDataRowValues(row, row("2026-07-25", "B01", "B", "u"), new CellStyle[ShopDataCrawlSheetBuilder.HEADERS.size()]);
ShopDataCrawlSheetBuilder.clearDataRows(sheet);
assertEquals(0, sheet.getLastRowNum(), "数据行清空、表头保留");
assertTrue(sheet.getRow(0) != null, "表头仍在");
}
}
@Test
void test_workbook_writable() throws Exception {
try (Workbook wb = new XSSFWorkbook()) {
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 0);
ByteArrayOutputStream out = new ByteArrayOutputStream();
wb.write(out);
assertTrue(out.size() > 0, "workbook 可写出");
}
}
@Test
void test_total_data_rows() throws Exception {
try (XSSFWorkbook wb = new XSSFWorkbook()) {
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 0);
ShopDataCrawlSheetBuilder.createStreamingSheet(wb, 1);
wb.getSheetAt(0).createRow(1);
wb.getSheetAt(0).createRow(2);
assertEquals(2, ShopDataCrawlSheetBuilder.totalDataRows(wb), "跨 sheet 数据行计数");
}
}
private static List<String> sheetNames(Workbook wb) {
return java.util.stream.IntStream.range(0, wb.getNumberOfSheets())
.mapToObj(i -> wb.getSheetAt(i).getSheetName()).toList();
}
}
@@ -0,0 +1,254 @@
package com.nanri.aiimage.modules.shopdatacrawl.service.support;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCountryResultDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto;
import com.nanri.aiimage.modules.shopdatacrawl.model.vo.ShopDataCrawlResultItemVo;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 102ShopDataCrawl 快照对比测试。
* 夹具 items → 分组快照(rowsByCountry+ 行布局快照(writeDataRowValues);
* golden 文件固定输出,快照变更即失败(防行为漂移)。
* 与 05 spec §5 一致:抽取前后同一夹具输出完全一致。
* golden 文件:src/test/resources/shopdatacrawl/golden/groups-snapshot.txt
* src/test/resources/shopdatacrawl/golden/rows-snapshot.txt
*/
class ShopDataCrawlSnapshotTest {
private static final java.io.File GOLDEN_GROUPS =
new java.io.File("src/test/resources/shopdatacrawl/golden/groups-snapshot.txt");
private static final java.io.File GOLDEN_ROWS =
new java.io.File("src/test/resources/shopdatacrawl/golden/rows-snapshot.txt");
// ---- 夹具 ----
/** 5 个国家各 1 行 + 未命中国家(US)行 + 失败条目行 + null 条目;items 顺序刻意打乱。 */
private static ShopDataCrawlRowDto row(String date, String asin, String brand) {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setDate(date);
row.setAsin(asin);
row.setBrand(brand);
row.setInventorySales("11");
row.setSalesRank("22");
row.setPageViews("33");
row.setUnitsSold("44");
row.setPrice("12.50");
row.setRecommendedOffer("12.00");
return row;
}
private static ShopDataCrawlCountryResultDto country(String code, ShopDataCrawlRowDto... rows) {
ShopDataCrawlCountryResultDto country = new ShopDataCrawlCountryResultDto();
country.setCountry(code);
country.setItems(List.of(rows));
return country;
}
private static ShopDataCrawlResultItemVo item(Boolean success, ShopDataCrawlCountryResultDto... countries) {
ShopDataCrawlResultItemVo item = new ShopDataCrawlResultItemVo();
item.setSuccess(success);
item.setCountryResults(List.of(countries));
return item;
}
/** 含 null 字段行的全字段夹具:10 列值全部有值 + 部分为 null。 */
private static ShopDataCrawlRowDto fullRow() {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setDate("2026-07-25");
row.setAsin("B012345678");
row.setBrand("Example Brand");
row.setInventorySales("128");
row.setSalesRank("#1,245");
row.setPageViews("3560");
row.setUnitsSold("42");
row.setPrice("GBP 19.99");
row.setRecommendedOffer("GBP 18.99");
return row;
}
private static ShopDataCrawlRowDto sparseRow() {
ShopDataCrawlRowDto row = new ShopDataCrawlRowDto();
row.setAsin("B099999999");
row.setBrand(" Brand With Spaces ");
return row;
}
private static List<ShopDataCrawlResultItemVo> fixtureMain() {
List<ShopDataCrawlResultItemVo> items = new ArrayList<>();
items.add(item(Boolean.TRUE, country("DE", row("2026-07-26", "B02", "DE-Brand")),
country("US", row("2026-07-26", "B-US", "US-Brand"))));
items.add(item(Boolean.TRUE, country("uk", row("2026-07-25", "B01", "UK-Brand"))));
items.add(item(null, country("FR", row("2026-07-27", "B03", "FR-Brand"))));
items.add(item(Boolean.FALSE, country("ES", row("2026-07-28", "B04", "ES-Brand"))));
items.add(item(Boolean.TRUE, country("IT", row("2026-07-29", "B05", "IT-Brand"))));
items.add(null);
return items;
}
private static List<ShopDataCrawlResultItemVo> fixtureNullFields() {
return List.of(item(Boolean.TRUE, country("UK", fullRow(), sparseRow())));
}
// ---- 快照管线(与服务侧 writeWorkbook 语义一致:rowsByCountry → writeDataRowValues ----
private static String renderGroupKey(String country, int count) {
return " group: country=" + country + " rows=" + count;
}
static String runGroupsOnly() throws Exception {
Map<String, List<ShopDataCrawlRowDto>> grouped = ShopDataCrawlSheetBuilder.rowsByCountry(fixtureMain());
StringBuilder sb = new StringBuilder();
for (Map.Entry<String, List<ShopDataCrawlRowDto>> entry : grouped.entrySet()) {
sb.append(renderGroupKey(entry.getKey(), entry.getValue().size())).append('\n');
for (ShopDataCrawlRowDto row : entry.getValue()) {
sb.append(" asin=").append(row.getAsin()).append('\n');
}
}
return sb.toString();
}
static String runRowsOnly() throws Exception {
List<ShopDataCrawlRowDto> rows = ShopDataCrawlSheetBuilder.rowsByCountry(fixtureNullFields()).get("UK");
List<String> rowTexts = new ArrayList<>();
try (XSSFWorkbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("UK");
for (ShopDataCrawlRowDto rowDto : rows) {
Row row = sheet.createRow(rowTexts.size());
ShopDataCrawlSheetBuilder.writeDataRowValues(row, rowDto, new CellStyle[ShopDataCrawlSheetBuilder.HEADERS.size()]);
StringBuilder sb = new StringBuilder();
for (int column = 0; column < ShopDataCrawlSheetBuilder.HEADERS.size(); column++) {
sb.append(ShopDataCrawlSheetBuilder.HEADERS.get(column)).append('=')
.append(row.getCell(column).getStringCellValue()).append(';');
}
rowTexts.add(sb.toString());
}
}
StringBuilder sb = new StringBuilder();
sb.append("rows=").append(rows.size()).append('\n');
for (String text : rowTexts) {
sb.append(" ").append(text).append('\n');
}
return sb.toString();
}
private static String read(java.io.File file) throws Exception {
return Files.readString(file.toPath(), StandardCharsets.UTF_8);
}
// ---- 用例 ----
@Test
void test_snapshot_groups_output() throws Exception {
assertEquals(read(GOLDEN_GROUPS), runGroupsOnly(), "分组快照与 golden 一致");
}
@Test
void test_snapshot_rows_output() throws Exception {
assertEquals(read(GOLDEN_ROWS), runRowsOnly(), "行布局快照与 golden 一致");
}
@Test
void test_snapshot_dtos() throws Exception {
Map<String, List<ShopDataCrawlRowDto>> grouped = ShopDataCrawlSheetBuilder.rowsByCountry(fixtureMain());
assertEquals(List.of("UK", "DE", "FR", "ES", "IT"),
new ArrayList<>(grouped.keySet()), "5 国固定顺序");
assertEquals(1, grouped.get("UK").size(), "uk 小写归一化归入英国");
assertEquals(1, grouped.get("DE").size());
assertEquals(1, grouped.get("FR").size(), "success=null 条目仍计入");
assertEquals(0, grouped.get("ES").size(), "success=false 条目丢弃");
assertEquals(1, grouped.get("IT").size());
assertEquals("B01", grouped.get("UK").get(0).getAsin(), "小写国家码行内容不变");
assertEquals("B02", grouped.get("DE").get(0).getAsin(), "US 未命中 5 国被丢弃,不混入 DE");
assertEquals(1, grouped.get("DE").size(), "DE 仅含 B02");
}
@Test
void test_snapshot_error_cases() throws Exception {
Map<String, List<ShopDataCrawlRowDto>> grouped = ShopDataCrawlSheetBuilder.rowsByCountry(fixtureMain());
assertEquals(0, grouped.get("ES").size(), "失败条目不产生行");
assertEquals(4, grouped.values().stream().mapToInt(List::size).sum(), "总行数 = 4 有效国(UK/DE/FR/IT");
Map<String, List<ShopDataCrawlRowDto>> empty = ShopDataCrawlSheetBuilder.rowsByCountry(null);
assertEquals(0, empty.get("UK").size(), "null 输入返回空分组");
assertEquals(5, empty.size(), "null 输入仍返回 5 国分组");
}
@Test
void test_snapshot_reproducible() throws Exception {
assertEquals(runGroupsOnly(), runGroupsOnly(), "分组跑两次一致");
assertEquals(runRowsOnly(), runRowsOnly(), "行布局跑两次一致");
}
@Test
void test_snapshot_golden_committed() {
assertTrue(GOLDEN_GROUPS.isFile(), "golden 文件必须存在并入库: " + GOLDEN_GROUPS.getAbsolutePath());
assertTrue(GOLDEN_ROWS.isFile(), "golden 文件必须存在并入库: " + GOLDEN_ROWS.getAbsolutePath());
assertTrue(GOLDEN_GROUPS.length() > 0, "groups golden 非空");
assertTrue(GOLDEN_ROWS.length() > 0, "rows golden 非空");
}
@Test
void test_snapshot_diff_detected() throws Exception {
String original = read(GOLDEN_GROUPS);
assertTrue(original.contains("rows="), "golden 内容合法");
try {
Files.writeString(GOLDEN_GROUPS.toPath(), original + "\n# tampered", StandardCharsets.UTF_8);
AssertionError failure = null;
try {
assertEquals(read(GOLDEN_GROUPS), runGroupsOnly(), "篡改后应与 golden 不一致");
} catch (AssertionError ex) {
failure = ex;
}
assertTrue(failure != null, "篡改 golden 后断言应失败");
} finally {
Files.writeString(GOLDEN_GROUPS.toPath(), original, StandardCharsets.UTF_8);
}
assertEquals(original, read(GOLDEN_GROUPS), "恢复原始 golden");
}
@Test
void test_snapshot_regression_all_pipeline() throws Exception {
assertEquals(read(GOLDEN_GROUPS), runGroupsOnly(), "全分组路径与 golden 一致");
assertEquals(read(GOLDEN_ROWS), runRowsOnly(), "全行布局路径与 golden 一致");
List<ShopDataCrawlRowDto> rows = ShopDataCrawlSheetBuilder.rowsByCountry(fixtureNullFields()).get("UK");
assertEquals(2, rows.size(), "全字段 + 稀疏行共 2 行");
assertEquals("Example Brand", rows.get(0).getBrand(), "全字段行品牌原样");
assertEquals(" Brand With Spaces ", rows.get(1).getBrand(), "稀疏行品牌保留原样(写入时归一为空/原样由列布局决定)");
}
/** 行布局写入值归一(null → 空串)通过 golden rows 快照覆盖;此处补充列序断言。 */
@Test
void test_snapshot_column_order_fixed() throws Exception {
try (XSSFWorkbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("UK");
Row row = sheet.createRow(0);
ShopDataCrawlSheetBuilder.writeDataRowValues(row, fullRow(), new CellStyle[ShopDataCrawlSheetBuilder.HEADERS.size()]);
assertEquals("2026-07-25", row.getCell(0).getStringCellValue(), "列 0 日期");
assertEquals("B012345678", row.getCell(1).getStringCellValue(), "列 1 ASIN");
assertEquals("", row.getCell(2).getStringCellValue(), "列 2 商品图片留空");
assertEquals("128", row.getCell(3).getStringCellValue(), "列 3 库存销量");
assertEquals("#1,245", row.getCell(4).getStringCellValue(), "列 4 销售排名");
assertEquals("3560", row.getCell(5).getStringCellValue(), "列 5 页面浏览量");
assertEquals("42", row.getCell(6).getStringCellValue(), "列 6 售出件数");
assertEquals("GBP 19.99", row.getCell(7).getStringCellValue(), "列 7 价格");
assertEquals("GBP 18.99", row.getCell(8).getStringCellValue(), "列 8 推荐报价");
assertEquals("Example Brand", row.getCell(9).getStringCellValue(), "列 9 品牌");
}
}
}
@@ -1,5 +1,10 @@
package com.nanri.aiimage.modules.shopkey.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration;
import com.nanri.aiimage.modules.shopkey.mapper.ShopManageMapper;
import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper;
import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest;
@@ -11,6 +16,7 @@ import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
@@ -46,6 +52,13 @@ class SkipPriceAsinServiceTest {
@InjectMocks
private SkipPriceAsinService service;
@BeforeAll
static void initMybatisPlusTableInfo() {
Configuration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "test");
TableInfoHelper.initTableInfo(assistant, SkipPriceAsinEntity.class);
}
@Test
void createSkipsDuplicateCountryAsinWithoutWriting() {
when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group());
@@ -184,6 +197,45 @@ class SkipPriceAsinServiceTest {
return request;
}
@Test
void pageAppliesMinimumPriceRangeAcrossAnyCountryWhenCountryAbsent() {
when(skipPriceAsinMapper.selectCount(any())).thenReturn(0L);
when(skipPriceAsinMapper.selectList(any())).thenReturn(List.of());
service.page(1, 15, null, null, null, null,
new BigDecimal("10.00"), new BigDecimal("20.00"), null, true);
ArgumentCaptor<Wrapper<SkipPriceAsinEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(skipPriceAsinMapper).selectCount(captor.capture());
String sql = captor.getValue().getSqlSegment();
org.assertj.core.api.Assertions.assertThat(sql)
.contains("minimum_price_de")
.contains("minimum_price_uk")
.contains("minimum_price_fr")
.contains("minimum_price_it")
.contains("minimum_price_es")
.contains(">= #{")
.contains("<= #{")
.contains("OR");
}
@Test
void pageAppliesMinimumPriceRangeOnSelectedCountryColumn() {
when(skipPriceAsinMapper.selectCount(any())).thenReturn(0L);
when(skipPriceAsinMapper.selectList(any())).thenReturn(List.of());
service.page(1, 15, null, null, null, "DE",
new BigDecimal("15.00"), null, null, true);
ArgumentCaptor<Wrapper<SkipPriceAsinEntity>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(skipPriceAsinMapper).selectCount(captor.capture());
String sql = captor.getValue().getSqlSegment();
org.assertj.core.api.Assertions.assertThat(sql)
.contains("minimum_price_de")
.doesNotContain("minimum_price_uk")
.contains(">=");
}
private File importWorkbook(String asin, String minimumPrice) throws Exception {
File file = File.createTempFile("skip-price-asin-test-", ".xlsx");
try (Workbook workbook = new XSSFWorkbook();
@@ -1,85 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
import java.net.InetAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
public class LlmGatewayTlsProbe {
public static void main(String[] args) throws Exception {
String host = "ai.t8star.org";
String apiKey = args[0];
System.out.println("[probe] java=" + System.getProperty("java.version")
+ " tls=" + System.getProperty("java.vm.name"));
for (InetAddress a : InetAddress.getAllByName(host)) {
System.out.println("[probe] dns " + a);
}
rawHandshake(host, null, "default");
rawHandshake(host, "TLSv1.2", "tls12-only");
httpClientCall(host, apiKey, null, "jdk-http-default");
httpClientCall(host, apiKey, "TLSv1.2", "jdk-http-tls12");
}
private static void rawHandshake(String host, String protocol, String label) throws Exception {
SSLSocketFactory factory = (SSLSocketFactory) SSLSocketFactory.getDefault();
try (SSLSocket socket = (SSLSocket) factory.createSocket(host, 443)) {
socket.setSoTimeout(15000);
SSLParameters params = socket.getSSLParameters();
if (protocol != null) {
params.setProtocols(new String[]{protocol});
}
params.setServerNames(List.of(new SNIHostName(host)));
socket.setSSLParameters(params);
socket.startHandshake();
System.out.println("[probe] raw[" + label + "] OK proto=" + socket.getSession().getProtocol()
+ " cipher=" + socket.getSession().getCipherSuite());
} catch (Exception ex) {
System.out.println("[probe] raw[" + label + "] FAIL " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
}
}
private static void httpClientCall(String host, String apiKey, String protocol, String label) throws Exception {
HttpClient.Builder builder = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.version(HttpClient.Version.HTTP_1_1);
if (protocol != null) {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, null, null);
builder.sslContext(context);
}
HttpClient client = builder.build();
try {
String body = "{\"model\":\"gemini-3.5-flash-lite\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":10}";
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("https://" + host + "/v1/chat/completions"))
.timeout(Duration.ofSeconds(30))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
String text = response.body();
System.out.println("[probe] http[" + label + "] status=" + response.statusCode()
+ " body=" + text.substring(0, Math.min(160, text.length())));
} catch (Exception ex) {
System.out.println("[probe] http[" + label + "] FAIL " + ex.getClass().getSimpleName() + ": " + ex.getMessage());
Throwable cause = ex;
while (cause.getCause() != null) {
cause = cause.getCause();
System.out.println("[probe] cause " + cause.getClass().getSimpleName() + ": " + cause.getMessage());
}
}
}
}
@@ -1,226 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
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.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 15:图片缓存访问时间更新改为异步批量刷新,减少逐图 UPDATE。
* lookup 命中不再同步 touchLastUsed,而是进入内存 touch 缓冲(按 url_hash 去重),
* 由定时任务/阈值触发 flushPendingTouches 批量 touchLastUsedBatch 刷新;
* 缓冲有大小上限,超限立即刷新,不会无界增长;失败 best-effort 吞掉。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinImagePrefetchServiceAsyncTouchTest {
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@Mock private TaskImageCacheMapper taskImageCacheMapper;
@Mock private SimilarAsinProperties properties;
@InjectMocks private SimilarAsinImagePrefetchService service;
@BeforeEach
void setUp() {
lenient().when(properties.isImageDbCacheEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheTouchFlushThreshold()).thenReturn(1000);
}
@AfterEach
void shutdown() {
service.shutdown();
}
private static String sha256Hex(String value) throws Exception {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(String.format("%02x", b & 0xFF));
}
return sb.toString();
}
private void stubHit(String url, byte[] bytes) throws Exception {
when(taskImageCacheMapper.selectBytesByUrlHash(sha256Hex(url))).thenReturn(bytes);
}
@Test
void test_task_015_image_cache_normal_default_path() throws Exception {
// 正常输入:命中不立即写 DB,进入缓冲;flush 后批量 touch 一次。
String url = "https://img.example.com/hit.jpg";
String hash = sha256Hex(url);
byte[] bytes = new byte[]{1, 2, 3};
stubHit(url, bytes);
byte[] result = service.lookup(url);
assertEquals(bytes, result, "命中必须返回缓存字节");
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
service.flushPendingTouches();
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
@Test
void test_task_015_image_cache_normal_multiple_items() throws Exception {
// 批量场景:多次命中进入同一缓冲,flush 合并为一次批量 touch,覆盖全部命中。
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg",
"https://img.example.com/c.jpg");
Set<String> hashes = new HashSet<>();
for (int i = 0; i < urls.size(); i++) {
hashes.add(sha256Hex(urls.get(i)));
stubHit(urls.get(i), new byte[]{(byte) (i + 1)});
}
for (String url : urls) {
assertNotNull(service.lookup(url), "命中返回字节");
}
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
service.flushPendingTouches();
org.mockito.ArgumentCaptor<List<String>> captor = org.mockito.ArgumentCaptor.forClass(List.class);
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(captor.capture());
assertEquals(hashes, new HashSet<>(captor.getValue()), "一次批量 touch 覆盖全部命中 hash");
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
@Test
void test_task_015_image_cache_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行同一输入:同一 url 多次命中只 touch 一次;重复 flush 无多余请求。
String url = "https://img.example.com/same.jpg";
String hash = sha256Hex(url);
stubHit(url, new byte[]{5});
when(taskImageCacheMapper.selectBytesByUrlHash(hash)).thenReturn(new byte[]{5});
service.lookup(url);
service.lookup(url);
service.lookup(url);
service.flushPendingTouches();
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
service.flushPendingTouches();
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(any());
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
@Test
void test_task_015_image_cache_boundary_empty_input() throws Exception {
// 空输入:null/空白 url 不进入缓冲;flush 空缓冲不产生任何数据库访问。
assertNull(service.lookup(null));
assertNull(service.lookup(""));
assertNull(service.lookup(" "));
service.flushPendingTouches();
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
verify(taskImageCacheMapper, never()).selectBytesByUrlHash(anyString());
}
@Test
void test_task_015_image_cache_boundary_single_item() throws Exception {
// 单条命中:flush 后单元素批量 touch,不依赖批量路径。
String url = "https://img.example.com/single.jpg";
String hash = sha256Hex(url);
stubHit(url, new byte[]{7});
assertNotNull(service.lookup(url));
service.flushPendingTouches();
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
}
@Test
void test_task_015_image_cache_boundary_limit_and_overflow() throws Exception {
// 缓冲达到阈值立即刷新,不无界增长;刷新后继续累积。
lenient().when(properties.getImageCacheTouchFlushThreshold()).thenReturn(3);
List<String> urls = List.of("https://img.example.com/o1.jpg", "https://img.example.com/o2.jpg",
"https://img.example.com/o3.jpg", "https://img.example.com/o4.jpg",
"https://img.example.com/o5.jpg");
for (String url : urls) {
stubHit(url, new byte[]{1});
}
org.mockito.ArgumentCaptor<List<String>> captor = org.mockito.ArgumentCaptor.forClass(List.class);
for (int i = 0; i < 3; i++) {
service.lookup(urls.get(i));
}
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(captor.capture());
assertEquals(3, captor.getValue().size(), "第 3 条命中触发阈值立即刷新 3 条");
service.lookup(urls.get(3));
service.lookup(urls.get(4));
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(any());
service.flushPendingTouches();
verify(taskImageCacheMapper, times(2)).touchLastUsedBatch(captor.capture());
assertEquals(2, captor.getValue().size(), "剩余 2 条在 flush 时刷新,缓冲不残留、不无界增长");
}
@Test
void test_task_015_image_cache_invalid_input_rejected() {
// 非法输入:db cache 关闭时 lookup 直接返回 null,不进入缓冲、不访问数据库。
lenient().when(properties.isImageDbCacheEnabled()).thenReturn(false);
assertNull(service.lookup("https://img.example.com/a.jpg"));
service.flushPendingTouches();
verify(taskImageCacheMapper, never()).selectBytesByUrlHash(anyString());
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
@Test
void test_task_015_image_cache_dependency_failure_releases_resources() throws Exception {
// 依赖失败:批量 touch 抛异常时吞掉不阻塞 lookup、缓冲已排空无残留;
// 恢复后重新入队 flush 成功。
String url = "https://img.example.com/fail.jpg";
String hash = sha256Hex(url);
stubHit(url, new byte[]{3});
AtomicInteger failures = new AtomicInteger(0);
List<String> capturedArgs = new java.util.ArrayList<>();
doAnswer(invocation -> {
@SuppressWarnings("unchecked")
List<String> arg = (List<String>) invocation.getArgument(0);
capturedArgs.addAll(arg);
if (failures.getAndIncrement() == 0) {
throw new IllegalStateException("db down");
}
return 0;
}).when(taskImageCacheMapper).touchLastUsedBatch(any());
assertNotNull(service.lookup(url), "touch 失败不阻断 lookup 返回缓存字节");
assertThrows(Exception.class, () -> service.flushPendingTouches(), "首次 flush 抛错(由调用方吞掉)");
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(any());
assertNotNull(service.lookup(url), "失败后再次命中重新入队");
service.flushPendingTouches();
verify(taskImageCacheMapper, times(2)).touchLastUsedBatch(any());
assertEquals(List.of(hash, hash), capturedArgs, "两次 touch 都覆盖命中 hash,失败后恢复成功");
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
private static void assertNull(Object value) {
org.junit.jupiter.api.Assertions.assertNull(value);
}
}
@@ -1,297 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.task.mapper.TaskImageCacheMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskImageCacheEntity;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 14:图片 DB cache 改为批量读取缩略图,并只更新实际命中的 last_used_at。
* 批量 lookup 入口(lookupBatch)一次 IN 查询返回命中字节 Map;
* last_used_at 只对实际命中的 url_hash 更新(touch 集合 = 命中集合),
* 未命中 url 不产生任何 touch/insert。单 URL 旧入口 lookup 语义保持兼容。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinImagePrefetchServiceBatchTest {
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@Mock private TaskImageCacheMapper taskImageCacheMapper;
@Mock private SimilarAsinProperties properties;
@InjectMocks private SimilarAsinImagePrefetchService service;
@BeforeEach
void setUp() {
lenient().when(properties.isImageDbCacheEnabled()).thenReturn(true);
}
@AfterEach
void shutdown() {
service.shutdown();
}
private static String sha256Hex(String value) throws Exception {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
byte[] digest = md.digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder(digest.length * 2);
for (byte b : digest) {
sb.append(String.format("%02x", b & 0xFF));
}
return sb.toString();
}
/** stub 批量命中读取:只对 hits 集合内的 hash 返回字节。 */
private void stubBatchRead(List<String> hits, Map<String, byte[]> bytesByHash) {
when(taskImageCacheMapper.selectBytesByUrlHashes(any())).thenAnswer(invocation -> {
List<String> hashes = invocation.getArgument(0);
List<TaskImageCacheEntity> rows = new ArrayList<>();
for (String hash : hashes) {
if (hits.contains(hash)) {
TaskImageCacheEntity row = new TaskImageCacheEntity();
row.setUrlHash(hash);
row.setImageBytes(bytesByHash.get(hash));
rows.add(row);
}
}
return rows;
});
}
/** 调用批量入口,按 url 顺序返回字节(未命中为 null)。 */
@SuppressWarnings("unchecked")
private static List<byte[]> invokeLookupBatch(SimilarAsinImagePrefetchService svc, List<String> urls) throws Exception {
Method m = SimilarAsinImagePrefetchService.class.getDeclaredMethod("lookupBatch", List.class);
m.setAccessible(true);
return (List<byte[]>) m.invoke(svc, urls);
}
@Test
void test_task_014_image_normal_default_path() throws Exception {
// 正常输入:命中与未命中混排,批量读回命中字节,touch 只覆盖实际命中。
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg");
List<String> hits = List.of(sha256Hex(urls.get(0)));
byte[] bytesA = new byte[]{1, 2, 3};
stubBatchRead(hits, Map.of(sha256Hex(urls.get(0)), bytesA));
List<byte[]> result = invokeLookupBatch(service, urls);
assertEquals(2, result.size(), "批量入口必须按输入顺序返回");
assertEquals(bytesA, result.get(0), "命中行返回缓存字节");
assertNull(result.get(1), "未命中行返回 null,不虚构缓存内容");
verify(taskImageCacheMapper, times(1)).selectBytesByUrlHashes(any());
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(sha256Hex(urls.get(0))));
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
verify(taskImageCacheMapper, never()).insert(any(TaskImageCacheEntity.class));
}
@Test
void test_task_014_image_normal_multiple_items() throws Exception {
// 批量场景:全命中多 url,一次 IN 查询返回全部字节,touch 覆盖全部命中。
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg",
"https://img.example.com/c.jpg");
List<String> hits = new ArrayList<>();
Map<String, byte[]> bytesByHash = new java.util.LinkedHashMap<>();
for (int i = 0; i < urls.size(); i++) {
hits.add(sha256Hex(urls.get(i)));
bytesByHash.put(hits.get(i), new byte[]{(byte) (i + 1)});
}
stubBatchRead(hits, bytesByHash);
List<byte[]> result = invokeLookupBatch(service, urls);
assertEquals(3, result.size());
for (int i = 0; i < urls.size(); i++) {
assertEquals(bytesByHash.get(sha256Hex(urls.get(i))), result.get(i), "顺序稳定、字节不丢失");
}
verify(taskImageCacheMapper, times(1)).selectBytesByUrlHashes(any());
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(new ArrayList<>(hits));
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
@Test
void test_task_014_image_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行同一输入:每次行为一致,不产生重复请求/重复 touch。
List<String> urls = List.of("https://img.example.com/a.jpg");
String hash = sha256Hex(urls.get(0));
stubBatchRead(List.of(hash), Map.of(hash, new byte[]{9}));
invokeLookupBatch(service, urls);
invokeLookupBatch(service, urls);
verify(taskImageCacheMapper, times(2)).selectBytesByUrlHashes(any());
verify(taskImageCacheMapper, times(2)).touchLastUsedBatch(List.of(hash));
verify(taskImageCacheMapper, never()).insert(any(TaskImageCacheEntity.class));
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
@Test
void test_task_014_image_boundary_empty_input() throws Exception {
// 空输入:null/空列表安全返回空结果,不产生任何数据库访问。
List<byte[]> nullResult = invokeLookupBatch(service, null);
assertNotNull(nullResult);
assertTrue(nullResult.isEmpty());
List<byte[]> emptyResult = invokeLookupBatch(service, List.of());
assertNotNull(emptyResult);
assertTrue(emptyResult.isEmpty());
verify(taskImageCacheMapper, never()).selectBytesByUrlHashes(any());
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
@Test
void test_task_014_image_boundary_single_item() throws Exception {
// 单 url:不依赖批量路径,命中时单次查询 + 单次 touch。
String url = "https://img.example.com/single.jpg";
String hash = sha256Hex(url);
stubBatchRead(List.of(hash), Map.of(hash, new byte[]{7}));
List<byte[]> result = invokeLookupBatch(service, List.of(url));
assertEquals(1, result.size());
assertEquals(7, result.get(0)[0]);
verify(taskImageCacheMapper, times(1)).selectBytesByUrlHashes(any());
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
}
@Test
void test_task_014_image_boundary_limit_and_overflow() throws Exception {
// 大批量(超过单批上限 500):分片查询,命中 touch 只覆盖命中集合。
List<String> urls = new ArrayList<>();
for (int i = 0; i < 1200; i++) {
urls.add("https://img.example.com/overflow-" + i + ".jpg");
}
Map<String, byte[]> bytesByHash = new java.util.LinkedHashMap<>();
List<String> hits = new ArrayList<>();
for (int i = 0; i < 1200; i += 2) {
String hash = sha256Hex(urls.get(i));
hits.add(hash);
bytesByHash.put(hash, new byte[]{(byte) (i % 100)});
}
stubBatchRead(hits, bytesByHash);
List<byte[]> result = invokeLookupBatch(service, urls);
assertEquals(1200, result.size(), "超大批量结果不丢失、顺序稳定");
int hitCount = 0;
for (int i = 0; i < 1200; i++) {
if (i % 2 == 0) {
assertNotNull(result.get(i), "偶数下标命中必须返回字节");
hitCount++;
} else {
assertNull(result.get(i), "奇数下标未命中返回 null");
}
}
assertEquals(600, hitCount);
verify(taskImageCacheMapper, times(3)).selectBytesByUrlHashes(any());
// touch 按单批 500 分片:600 命中 → 2 次 touch 调用,且只覆盖命中集合。
var captor = org.mockito.ArgumentCaptor.forClass(List.class);
verify(taskImageCacheMapper, times(2)).touchLastUsedBatch(captor.capture());
List<List<String>> touchCalls = new ArrayList<>();
for (Object call : captor.getAllValues()) {
@SuppressWarnings("unchecked")
List<String> casted = (List<String>) call;
touchCalls.add(casted);
}
assertEquals(2, touchCalls.size());
assertEquals(500, touchCalls.get(0).size(), "第一批 touch 500 个命中");
assertEquals(100, touchCalls.get(1).size(), "第二批 touch 剩余 100 个命中");
assertEquals(hits.subList(0, 500), touchCalls.get(0), "touch 只覆盖实际命中集合");
assertEquals(hits.subList(500, 600), touchCalls.get(1));
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
@Test
void test_task_014_image_invalid_input_rejected() throws Exception {
// 非法输入:db cache 关闭时批量入口直接返回空,不访问数据库。
lenient().when(properties.isImageDbCacheEnabled()).thenReturn(false);
List<String> urls = List.of("https://img.example.com/a.jpg");
List<byte[]> result = invokeLookupBatch(service, urls);
assertNotNull(result);
assertTrue(result.isEmpty(), "db cache 关闭时必须直接返回空结果");
verify(taskImageCacheMapper, never()).selectBytesByUrlHashes(any());
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
}
@Test
void test_task_014_image_dependency_failure_releases_resources() throws Exception {
// 依赖失败:查询抛异常时批量入口返回空结果、无任何 touch/insert 残留;
// 恢复后重试成功。
List<String> urls = List.of("https://img.example.com/a.jpg");
String hash = sha256Hex(urls.get(0));
AtomicInteger callCount = new AtomicInteger(0);
doAnswer(invocation -> {
if (callCount.getAndIncrement() == 0) {
throw new IllegalStateException("db down");
}
TaskImageCacheEntity row = new TaskImageCacheEntity();
row.setUrlHash(hash);
row.setImageBytes(new byte[]{5});
return List.of(row);
}).when(taskImageCacheMapper).selectBytesByUrlHashes(any());
List<byte[]> failed = invokeLookupBatch(service, urls);
assertNotNull(failed);
assertTrue(failed.isEmpty(), "查询失败必须返回空结果而不是抛错阻断组装");
verify(taskImageCacheMapper, never()).touchLastUsedBatch(any());
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
verify(taskImageCacheMapper, never()).insert(any(TaskImageCacheEntity.class));
List<byte[]> recovered = invokeLookupBatch(service, urls);
assertEquals(1, recovered.size());
assertEquals(5, recovered.get(0)[0], "依赖恢复后重试成功");
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
}
@Test
void test_task_014_image_normal_single_lookup_legacy_compat() throws Exception {
// 兼容性:单 URL 旧入口 lookup 保持返回字节语义;Task 15 起 touch 改为
// 异步批量缓冲,flush 后批量 touch 一次,命中才入缓冲。
String url = "https://img.example.com/legacy.jpg";
String hash = sha256Hex(url);
byte[] bytes = new byte[]{6};
when(taskImageCacheMapper.selectBytesByUrlHash(hash)).thenReturn(bytes);
byte[] result = service.lookup(url);
assertEquals(bytes, result, "lookup 命中必须返回缓存字节");
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
service.flushPendingTouches();
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(List.of(hash));
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
assertNull(service.lookup("https://img.example.com/missing.jpg"), "未命中返回 null");
service.flushPendingTouches();
verify(taskImageCacheMapper, times(1)).touchLastUsedBatch(any());
verify(taskImageCacheMapper, never()).touchLastUsed(anyString());
}
}
@@ -1,167 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.config.OssProperties;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.productcategory.mapper.ProductCategoryMapper;
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger;
import org.mockito.Mockito;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
/**
* 本地验证入口:用生产真实批次数据 + 生产 LLM 网关跑 SimilarAsinLlmService 完整链路。
* 用法:mvn compile test-compile 后执行
* java -cp target/classes;target/test-classes;$(cat cp.txt) com.nanri.aiimage.modules.similarasin.service.SimilarAsinLlmLocalVerify <data.json> <apiKey> [imgSwitch] [categorySwitch]
*/
public class SimilarAsinLlmLocalVerify {
public static void main(String[] args) throws Exception {
System.setOut(new java.io.PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.out), true, "UTF-8"));
System.setErr(new java.io.PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true, "UTF-8"));
if (args.length < 2) {
System.err.println("usage: SimilarAsinLlmLocalVerify <data.json> <apiKey> [imgSwitch] [categorySwitch]");
System.exit(1);
}
String dataFile = args[0];
String apiKey = args[1];
boolean imgSwitch = args.length > 2 && Boolean.parseBoolean(args[2]);
boolean categorySwitch = args.length > 3 && Boolean.parseBoolean(args[3]);
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
JsonNode root = objectMapper.readTree(new File(dataFile));
List<SimilarAsinResultRowDto> rows = new ArrayList<>();
if (root.isArray()) {
for (JsonNode node : root) {
rows.add(fromJson(objectMapper, node));
}
} else {
rows.add(fromJson(objectMapper, root));
}
System.out.println("[verify] loaded rows=" + rows.size() + " imgSwitch=" + imgSwitch
+ " categorySwitch=" + categorySwitch);
if (imgSwitch && categorySwitch) {
System.out.println("[verify] raw first row alibaba[0].url="
+ (rows.isEmpty() || rows.get(0).getAlibaba().isEmpty() ? "null"
: rows.get(0).getAlibaba().get(0).getUrl()));
}
SimilarAsinProperties props = new SimilarAsinProperties();
props.setLlmApiKey(apiKey);
props.setLlmRowConcurrency(2);
props.setLlmImageDownloadTimeoutSeconds(10);
SimilarAsinLlmClient client = new SimilarAsinLlmClient(props, objectMapper, null);
OssProperties ossProps = new OssProperties();
ossProps.setEndpoint("https://oss.aishufu.top");
ossProps.setPublicEndpoint("https://oss.aishufu.top");
ossProps.setBucket("nanri-ai-images");
ossProps.setAccessKeyId("appuser");
ossProps.setAccessKeySecret("AppUser@2024SecureKey");
OssStorageService oss = new OssStorageService(ossProps);
PuzzleImageMerger merger = new PuzzleImageMerger(props);
// 生产真实类目数据(导出自 biz_product_category),spy 类目服务按 parentId 过滤。
List<com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity> categories = loadCategories(objectMapper);
ProductCategoryService categoryService = Mockito.spy(new ProductCategoryService(
Mockito.mock(com.nanri.aiimage.modules.productcategory.mapper.ProductCategoryMapper.class)));
Mockito.doAnswer(invocation -> {
Long parentId = invocation.getArgument(0);
List<com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo> items = categories.stream()
.filter(c -> parentId == null ? c.getParentId() == null : parentId.equals(c.getParentId()))
.map(c -> {
com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo item =
new com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo();
item.setId(c.getId());
item.setParentId(c.getParentId());
item.setName(c.getName());
item.setCategoryKey(c.getCategoryKey());
return item;
})
.toList();
com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo vo =
new com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo();
vo.setItems(items);
vo.setTree(List.of());
vo.setTotal((long) items.size());
vo.setPage(1L);
vo.setPageSize((long) Math.max(1, items.size()));
vo.setHasMore(false);
return vo;
}).when(categoryService).children(Mockito.any(), Mockito.anyLong(), Mockito.anyLong());
SimilarAsinLlmService service = new SimilarAsinLlmService(
client, props, ossProps, categoryService, merger, oss);
long start = System.currentTimeMillis();
List<SimilarAsinResultRowDto> result = service.inspectRows(rows, null, apiKey, imgSwitch, categorySwitch);
long elapsed = System.currentTimeMillis() - start;
System.out.println("[verify] done rows=" + result.size() + " elapsedMs=" + elapsed);
for (SimilarAsinResultRowDto row : result) {
System.out.println(String.format(
"asin=%s | status=%s | isConform=%s | category=%s | reason=%s | isStock=%s | similarity=%s | mainUrl=%s | puzzle1=%s | puzzle2=%s",
row.getAsin(), row.getStatus(), row.getIsConform(), row.getCategory(),
row.getReason(), row.getIsStock(), row.getSimilarity(),
shorten(row.getMainUrl()), shorten(row.getPuzzleImg1()), shorten(row.getPuzzleImg2())));
}
}
private static List<com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity> loadCategories(ObjectMapper objectMapper) throws Exception {
com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree(
SimilarAsinLlmLocalVerify.class.getResourceAsStream("/categories.json"));
List<com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity> list = new ArrayList<>();
for (com.fasterxml.jackson.databind.JsonNode node : root) {
com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity entity =
new com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEntity();
entity.setId(node.get("id").asLong());
if (!node.get("parent_id").isNull()) {
entity.setParentId(node.get("parent_id").asLong());
}
entity.setName(node.get("name").asText());
entity.setCategoryKey(node.get("category_key").asText());
entity.setSortOrder(node.get("sort_order").isNull() ? null : node.get("sort_order").asInt());
entity.setDescription(node.get("description").isNull() ? null : node.get("description").asText());
entity.setIsBuiltin(node.get("is_builtin") != null && node.get("is_builtin").asBoolean());
list.add(entity);
}
return list;
}
private static SimilarAsinResultRowDto fromJson(ObjectMapper objectMapper, JsonNode node) throws Exception {
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin(text(node, "asin"));
row.setTitle(text(node, "title"));
row.setSku(text(node, "sku"));
row.setCountry(text(node, "country"));
row.setUrl(text(node, "url"));
JsonNode alibaba = node.get("alibaba");
if (alibaba != null && alibaba.isArray()) {
List<SimilarAsinResultRowDto.AlibabaItem> items = new ArrayList<>();
for (JsonNode item : alibaba) {
items.add(objectMapper.treeToValue(item, SimilarAsinResultRowDto.AlibabaItem.class));
}
row.setAlibaba(items);
}
return row;
}
private static String text(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : value.asText();
}
private static String shorten(String value) {
if (value == null) {
return "null";
}
return value.length() <= 70 ? value : value.substring(0, 70) + "...";
}
}
@@ -1,279 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.nanri.aiimage.config.OssProperties;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo;
import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo;
import com.nanri.aiimage.modules.productcategory.service.ProductCategoryService;
import com.nanri.aiimage.modules.similarasin.client.SimilarAsinLlmClient;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.PuzzleImageMerger;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.mockito.Mockito;
class SimilarAsinLlmServiceTest {
private static byte[] jpegBytes() {
try {
BufferedImage image = new BufferedImage(10, 10, BufferedImage.TYPE_INT_RGB);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", baos);
return baos.toByteArray();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
private SimilarAsinProperties properties() {
SimilarAsinProperties props = new SimilarAsinProperties();
props.setLlmApiKey("test-key");
return props;
}
private SimilarAsinLlmService service(SimilarAsinLlmClient llmClient) {
OssStorageService ossStorage = mock(OssStorageService.class);
when(ossStorage.getPublicUrl(anyString())).thenAnswer(invocation -> "https://oss.aishufu.top/nanri-ai-images/" + invocation.getArgument(0));
PuzzleImageMerger merger = mock(PuzzleImageMerger.class);
when(merger.merge(anyList(), Mockito.<SimilarAsinResultRowDto>any())).thenReturn(jpegBytes());
ProductCategoryService categoryService = mock(ProductCategoryService.class);
when(categoryService.children(any(), anyLong(), anyLong()))
.thenAnswer(invocation -> {
Long parentId = invocation.getArgument(0);
if (parentId == null) {
return categoryPage("类目A", 1L);
}
if (parentId == 1L) {
return categoryPage("类目B", 2L);
}
if (parentId == 2L) {
return categoryPage("类目C", 3L);
}
return emptyCategoryPage();
});
SimilarAsinLlmService svc = new SimilarAsinLlmService(
llmClient,
properties(),
mock(OssProperties.class),
categoryService,
merger,
ossStorage);
svc.setDownloadHttpClientForTest(mockHttpClient());
return svc;
}
private static ProductCategoryListVo categoryPage(String name, long id) {
ProductCategoryItemVo item = new ProductCategoryItemVo();
item.setId(id);
item.setName(name);
ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setItems(List.of(item));
vo.setTotal(1L);
vo.setPage(1L);
vo.setPageSize(1L);
vo.setHasMore(false);
return vo;
}
private static ProductCategoryListVo emptyCategoryPage() {
ProductCategoryListVo vo = new ProductCategoryListVo();
vo.setItems(List.of());
vo.setTotal(0L);
vo.setPage(1L);
vo.setPageSize(1L);
vo.setHasMore(false);
return vo;
}
private static HttpClient mockHttpClient() {
HttpClient client = mock(HttpClient.class);
try {
when(client.send(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)))
.thenAnswer(invocation -> {
byte[] body = jpegBytes();
HttpRequest request = invocation.getArgument(0);
return new TestHttpResponse(200, body, request);
});
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return client;
}
private static final class TestHttpResponse implements HttpResponse<byte[]> {
private final int statusCode;
private final byte[] body;
private final HttpRequest request;
TestHttpResponse(int statusCode, byte[] body, HttpRequest request) {
this.statusCode = statusCode;
this.body = body;
this.request = request;
}
@Override
public int statusCode() {
return statusCode;
}
@Override
public HttpRequest request() {
return request;
}
@Override
public java.util.Optional<HttpResponse<byte[]>> previousResponse() {
return java.util.Optional.empty();
}
@Override
public java.net.http.HttpHeaders headers() {
return java.net.http.HttpHeaders.of(Map.of(), (a, b) -> true);
}
@Override
public byte[] body() {
return body;
}
@Override
public java.net.URI uri() {
return java.net.URI.create("http://test");
}
@Override
public java.net.http.HttpClient.Version version() {
return HttpClient.Version.HTTP_1_1;
}
@Override
public java.util.Optional<javax.net.ssl.SSLSession> sslSession() {
return java.util.Optional.empty();
}
}
private SimilarAsinLlmClient llmClient(String apiKey, Map<String, String> responses) {
SimilarAsinLlmClient client = mock(SimilarAsinLlmClient.class);
when(client.resolveApiKey(anyString())).thenReturn(apiKey == null ? "" : apiKey);
when(client.hasApiKey(anyString())).thenReturn(apiKey != null && !apiKey.isBlank());
when(client.invokeChat(anyString(), anyString(), anyString(), anyString()))
.thenAnswer(invocation -> {
String response = responses.get("chat");
if (response == null) {
throw new IllegalStateException("unexpected chat call");
}
return response;
});
when(client.invokeChatWithImages(anyString(), anyString(), anyString(), any(), anyString()))
.thenAnswer(invocation -> {
String response = responses.get("images");
if (response == null) {
throw new IllegalStateException("unexpected images call");
}
return response;
});
return client;
}
private static final com.fasterxml.jackson.databind.ObjectMapper OBJECT_MAPPER =
new com.fasterxml.jackson.databind.ObjectMapper();
/** 类目匹配(按序号返回不同类目名)与合规检查(后续)返回不同 JSON。 */
private SimilarAsinLlmClient llmClientStaged(String apiKey, List<String> categoryJsons,
String conformJson, String imagesJson) {
SimilarAsinLlmClient client = mock(SimilarAsinLlmClient.class);
int[] categoryIndex = {0};
when(client.resolveApiKey(anyString())).thenReturn(apiKey);
when(client.hasApiKey(anyString())).thenReturn(apiKey != null && !apiKey.isBlank());
when(client.invokeChat(anyString(), anyString(), anyString(), anyString()))
.thenAnswer(invocation -> {
if (categoryIndex[0] < categoryJsons.size()) {
return categoryJsons.get(categoryIndex[0]++);
}
return conformJson;
});
when(client.invokeChatWithImages(anyString(), anyString(), anyString(), any(), anyString()))
.thenAnswer(invocation -> imagesJson);
when(client.parseJsonContent(anyString()))
.thenAnswer(invocation -> parseJson(invocation.getArgument(0)));
return client;
}
private static com.fasterxml.jackson.databind.JsonNode parseJson(String content) {
try {
return OBJECT_MAPPER.readTree(content);
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
@Test
void noApiKeyKeepsRawRows() {
SimilarAsinLlmService service = service(llmClient("", Map.of()));
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0TEST");
List<SimilarAsinResultRowDto> result = service.inspectRows(List.of(row), "", "", true, true);
assertEquals(1, result.size());
assertEquals("B0TEST", result.get(0).getAsin());
assertNull(result.get(0).getStatus());
}
@Test
void categorySwitchOffOnlyPreparesImagesAndMarksNotExistsWhenNoMainUrl() {
SimilarAsinLlmService service = service(llmClient("k", Map.of()));
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0TEST");
row.setTitle("Test product");
List<SimilarAsinResultRowDto> result = service.inspectRows(List.of(row), "", "k", false, false);
assertEquals(1, result.size());
assertEquals("不存在", result.get(0).getStatus());
assertNull(result.get(0).getIsConform());
assertNull(result.get(0).getPuzzleImg1());
}
@Test
void imageCompareStopsOnStockAndFillsFields() {
// 前 2 次 chat:一级/二级类目匹配返回名称(Java 按名回查候选取真实 ID);第 3 次:合规检查。
SimilarAsinLlmClient client = llmClientStaged("k", List.of("{\"name\":\"类目A\"}", "{\"name\":\"类目B\"}"),
"{\"asin\":\"B0TEST\",\"is_conform\":\"符合\",\"reason\":\"\",\"category\":\"类目A->类目B->类目C\"}",
"{\"asin\":\"B0TEST\",\"is_stock\":\"有货\",\"similarity\":\"95%\",\"status\":\"成功\",\"is_conform\":\"符合\",\"category\":\"类目A->类目B->类目C\"}");
SimilarAsinLlmService service = service(client);
SimilarAsinResultRowDto row = new SimilarAsinResultRowDto();
row.setAsin("B0TEST");
row.setTitle("Test product");
row.setUrl("https://m.media-amazon.com/images/I/main.jpg");
SimilarAsinResultRowDto.AlibabaItem item = new SimilarAsinResultRowDto.AlibabaItem();
item.setUrl("https://cbu01.alicdn.com/img/1.jpg");
row.setAlibaba(List.of(item));
// 一级/二级都匹配(按名回查 ID),三级候选可用,合规符合 → 图片对比,有货即停。
List<SimilarAsinResultRowDto> result = service.inspectRows(List.of(row), "", "k", true, true);
assertEquals(1, result.size());
SimilarAsinResultRowDto out = result.get(0);
assertEquals("成功", out.getStatus());
assertEquals("有货", out.getIsStock());
assertEquals("95%", out.getSimilarity());
assertEquals("符合", out.getIsConform());
}
}
@@ -1,204 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.nanri.aiimage.common.exception.BusinessException;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 9:chunk 查询从单行分页改为批量 keyset 分页,保持低内存读取。
* loadChunksKeyset 按 id 递增分批拉取(每批 pageSize),最后按 chunkIndex 升序合并,
* 避免超大任务一次 selectList 全量载入 chunk 元数据。
* mock 分页由 wrapper 中 gt("id", lastId) 的 keyset 值驱动,保证重复调用可复现(幂等)。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceChunkKeysetTest {
private static final String MODULE = "similar-asin";
@Mock private TaskChunkMapper taskChunkMapper;
private static TaskChunkEntity chunk(long id, int chunkIndex) {
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setId(id);
chunk.setTaskId(7004L);
chunk.setModuleType(MODULE);
chunk.setChunkIndex(chunkIndex);
return chunk;
}
private static List<TaskChunkEntity> chunks(long... idsAndIndexes) {
List<TaskChunkEntity> result = new ArrayList<>();
for (int i = 0; i < idsAndIndexes.length; i += 2) {
result.add(chunk(idsAndIndexes[i], (int) idsAndIndexes[i + 1]));
}
return result;
}
/** 从 wrapper 的 SQL 片段解析 keyset:匹配 "id > #{ew.paramNameValuePairs.键}" 后从参数表取值。 */
private static long keysetOf(QueryWrapper<TaskChunkEntity> wrapper) {
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("id\\s*>\\s*#\\{ew\\.paramNameValuePairs\\.(\\w+)\\}", java.util.regex.Pattern.CASE_INSENSITIVE)
.matcher(wrapper.getSqlSegment());
if (m.find()) {
Object value = wrapper.getParamNameValuePairs().get(m.group(1));
if (value instanceof Number number) {
return number.longValue();
}
}
return 0L;
}
/** 按 keyset 驱动分页:每次 selectList 返回 id > keyset 的下一批,天然支持重复调用。 */
private void stubKeysetPages(List<TaskChunkEntity> all, int pageSize) {
int batch = pageSize > 0 ? pageSize : 500;
doAnswer(invocation -> {
long lastId = keysetOf(invocation.getArgument(0));
return all.stream().filter(c -> c.getId() > lastId).limit(batch).toList();
}).when(taskChunkMapper).selectList(any());
}
private List<Long> keysetIdsFromRounds(int rounds) {
ArgumentCaptor<QueryWrapper<TaskChunkEntity>> captor =
ArgumentCaptor.forClass(QueryWrapper.class);
verify(taskChunkMapper, times(rounds)).selectList(captor.capture());
List<Long> keysets = new ArrayList<>();
for (QueryWrapper<TaskChunkEntity> wrapper : captor.getAllValues()) {
keysets.add(keysetOf(wrapper));
}
return keysets;
}
@Test
void test_task_009_chunk_normal_default_path() {
// 正常输入:chunk 数小于 pageSize,一轮拉完,结果全且按 chunkIndex 有序
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3);
stubKeysetPages(all, 500);
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500);
assertEquals(3, result.size());
assertEquals(List.of(1, 2, 3), result.stream().map(TaskChunkEntity::getChunkIndex).toList());
assertEquals(List.of(0L), keysetIdsFromRounds(1), "首轮 keyset 为 0");
}
@Test
void test_task_009_chunk_normal_multiple_items() {
// 超过 pageSize:多轮拉取,keyset 逐轮推进,全部合并且按 chunkIndex 升序、无重复
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7);
stubKeysetPages(all, 3);
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 3);
assertEquals(7, result.size());
assertEquals(List.of(1, 2, 3, 4, 5, 6, 7), result.stream().map(TaskChunkEntity::getChunkIndex).toList());
long distinctIds = result.stream().map(TaskChunkEntity::getId).distinct().count();
assertEquals(7, distinctIds, "keyset 分页不能产生重复 chunk");
assertEquals(List.of(0L, 3L, 6L), keysetIdsFromRounds(3), "keyset 逐轮推进,不足一批即止");
}
@Test
void test_task_009_chunk_normal_repeated_operation_is_idempotent() {
// 重复执行同一输入:每轮都从 keyset=0 开始,结果一致
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3);
stubKeysetPages(all, 500);
List<TaskChunkEntity> first = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500);
List<TaskChunkEntity> second = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500);
assertEquals(first.size(), second.size());
for (int i = 0; i < first.size(); i++) {
assertEquals(first.get(i).getId(), second.get(i).getId());
}
assertEquals(List.of(0L, 0L), keysetIdsFromRounds(2), "重复执行每轮都从 keyset=0 开始");
}
@Test
void test_task_009_chunk_boundary_empty_input() {
// 空集合:返回空列表,不创建无效资源,且只查一轮
stubKeysetPages(List.of(), 500);
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500);
assertNotNull(result);
assertEquals(0, result.size());
verify(taskChunkMapper, times(1)).selectList(any());
}
@Test
void test_task_009_chunk_boundary_single_item() {
// 单 chunk:一轮返回后 keyset 推进即拉空,不依赖批量路径
List<TaskChunkEntity> all = chunks(42, 9);
stubKeysetPages(all, 1);
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 1);
assertEquals(1, result.size());
assertEquals(9, result.get(0).getChunkIndex());
assertEquals(42L, result.get(0).getId());
}
@Test
void test_task_009_chunk_boundary_limit_and_overflow() {
// chunk 数恰好等于 pageSize 的倍数:最后一轮仍返回非空才继续,全部取回
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6);
stubKeysetPages(all, 3);
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 3);
assertEquals(6, result.size());
// pageSize 为 0/负数:回退默认 500,不抛异常
stubKeysetPages(all, 0);
List<TaskChunkEntity> fallback = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 0);
assertEquals(6, fallback.size());
stubKeysetPages(all, -5);
List<TaskChunkEntity> negative = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, -5);
assertEquals(6, negative.size());
}
@Test
void test_task_009_chunk_invalid_input_rejected() {
// taskId 为 null:安全返回空列表,不发起查询
List<TaskChunkEntity> result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, null, MODULE, 500);
assertNotNull(result);
assertEquals(0, result.size());
verify(taskChunkMapper, times(0)).selectList(any());
// mapper 查询抛异常:转项目约定异常,消息可识别
when(taskChunkMapper.selectList(any())).thenThrow(new IllegalStateException("db down"));
BusinessException ex = assertThrows(BusinessException.class,
() -> SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500));
assertTrue(ex.getMessage() != null && ex.getMessage().contains("chunk"),
"异常消息必须可识别,实际: " + ex.getMessage());
}
@Test
void test_task_009_chunk_dependency_failure_releases_resources() {
// 第二轮查询失败:抛异常不返回半截结果;恢复后重试可完整返回
List<TaskChunkEntity> all = chunks(1, 1, 2, 2, 3, 3, 4, 4, 5, 5);
doAnswer(invocation -> {
long lastId = keysetOf(invocation.getArgument(0));
if (lastId == 0L) {
return all.subList(0, 3);
}
if (lastId == 3L) {
throw new IllegalStateException("db down mid-page");
}
return all.subList(3, 5);
}).when(taskChunkMapper).selectList(any());
BusinessException ex = assertThrows(BusinessException.class,
() -> SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 3));
assertTrue(ex.getMessage() != null && ex.getMessage().contains("chunk"),
"异常消息必须可识别,实际: " + ex.getMessage());
// 恢复后重试成功:5 个 chunk 全部取回
stubKeysetPages(all, 3);
List<TaskChunkEntity> recovered = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 3);
assertEquals(5, recovered.size());
assertEquals(List.of(1, 2, 3, 4, 5), recovered.stream().map(TaskChunkEntity::getChunkIndex).toList());
}
}
@@ -1,399 +0,0 @@
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.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 13chunk 合并增加单次最大行数与 payload 字节上限。
* 校验点位于 mergeChunkPayload 单次合并入口:合并后总行数超过 chunkMergeMaxRows、
* 或 payload 字节超过 chunkMergePayloadMaxBytes 时,从最旧行开始降级到
* orphan 兜底(assemble 阶段 putIfAbsent 合并回结果,不丢数据);
* 单行本身超过字节上限时抛可识别异常拒绝合并。低于上限的行为与旧路径完全一致。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceChunkMergeLimitTest {
private static final AtomicLong NEXT_ID = new AtomicLong(90000);
@Mock private LocalFileStorageService localFileStorageService;
@Mock private com.nanri.aiimage.modules.file.service.oss.OssStorageService ossStorageService;
@Mock private com.nanri.aiimage.config.StorageProperties storageProperties;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
@Mock private com.nanri.aiimage.modules.task.service.TaskDistributedLockService taskDistributedLockService;
@Mock private com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService taskProgressSnapshotService;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@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 SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@InjectMocks private SimilarAsinTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
}
@BeforeEach
void setUp() {
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
lenient().when(properties.getChunkMergeMaxRows()).thenReturn(50000);
lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(16L * 1024L * 1024L);
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenReturn("rustfs:task-parsed/similar-asin/90000/payload.json");
lenient().doAnswer(invocation -> {
FileTaskEntity task = invocation.getArgument(0);
task.setId(NEXT_ID.incrementAndGet());
return 1;
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
}
@AfterEach
void shutdown() {
service.shutdownAssembleExecutor();
}
private static SimilarAsinResultRowDto row(String rowToken, String asin, String title) {
SimilarAsinResultRowDto r = new SimilarAsinResultRowDto();
r.setRowToken(rowToken);
r.setId(rowToken);
r.setAsin(asin);
r.setCountry("英国");
r.setTitle(title);
return r;
}
private static String rowsJson(List<SimilarAsinResultRowDto> rows) throws Exception {
return new ObjectMapper().writeValueAsString(rows);
}
private static TaskChunkEntity chunk(Long id, String scopeHash, Integer chunkIndex, String payloadJson) {
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setId(id);
chunk.setTaskId(9004L);
chunk.setModuleType(SimilarAsinTaskService.MODULE_TYPE);
chunk.setScopeHash(scopeHash);
chunk.setChunkIndex(chunkIndex);
chunk.setPayloadJson(payloadJson);
return chunk;
}
/** 记录每次 storeChunkPayloadVersioned 收到的 payload 字符串。 */
private void stubChunkMerge(TaskChunkEntity chunk, String payloadJson, AtomicReference<String> storedPayload) throws Exception {
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
when(transientPayloadStorageService.resolvePayload(eq(chunk.getPayloadJson()), anyString()))
.thenReturn(payloadJson);
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenAnswer(invocation -> {
storedPayload.set(invocation.getArgument(4));
return "stored:" + invocation.getArgument(2);
});
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
when(taskChunkMapper.update(any(), any())).thenReturn(1);
}
private static void invokeMerge(SimilarAsinTaskService service, FileTaskEntity task,
String scopeHash, Integer chunkIndex,
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, llmRows, Map.of());
}
@Test
void test_task_013_payload_row_count_chunk_normal_default_path() throws Exception {
// 正常输入:行数与 payload 字节均在上限内,合并走原路径,结果完整保留。
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
AtomicReference<String> storedPayload = new AtomicReference<>("");
stubChunkMerge(chunk, rowsJson(List.of(row("r0", "B0A0000000", "存量行"))), storedPayload);
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
List<SimilarAsinResultRowDto> llmRows = List.of(
row("r1", "B0A0000001", "标题1"),
row("r2", "B0A0000002", "标题2"),
row("r3", "B0A0000003", "标题3"));
invokeMerge(service, task, "hashA", 1, llmRows);
assertNotNull(storedPayload.get());
assertTrue(storedPayload.get().contains("\"r1\"") && storedPayload.get().contains("\"r3\""),
"上限内合并必须完整保留存量行与新增行,实际: " + storedPayload.get());
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
verify(taskChunkMapper, times(1)).update(any(), any());
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
}
@Test
void test_task_013_payload_row_count_chunk_normal_multiple_items() throws Exception {
// 批量场景:多 chunk 一次 merge,全部在上限内,各 chunk 分别写回、结果不丢失。
TaskChunkEntity chunkA = chunk(1L, "hashA", 1, "ptr:chunk-A");
TaskChunkEntity chunkB = chunk(2L, "hashB", 2, "ptr:chunk-B");
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunkA, chunkB));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenReturn(rowsJson(List.of(row("r1", "B0A0000001", "标题1"))));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-B"), anyString()))
.thenReturn(rowsJson(List.of(row("r2", "B0A0000002", "标题2"))));
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenAnswer(invocation -> "stored:" + invocation.getArgument(2));
AtomicLong selectOneRound = new AtomicLong(0);
when(taskChunkMapper.selectOne(any())).thenAnswer(invocation ->
selectOneRound.getAndIncrement() == 0 ? chunkA : chunkB);
when(taskChunkMapper.update(any(), any())).thenReturn(1);
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
invokeMerge(service, task, null, null, List.of(
row("r1", "B0A0000001", "标题1-新"),
row("r2", "B0A0000002", "标题2-新")));
verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
verify(taskChunkMapper, times(2)).update(any(), any());
}
@Test
void test_task_013_payload_row_count_chunk_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行同一输入:每次 merge 恰好写一次,不产生重复对象、重复状态。
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
AtomicLong storeCalls = new AtomicLong(0);
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenReturn(rowsJson(List.of(row("r0", "B0A0000000", "存量行"))));
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenAnswer(invocation -> {
storeCalls.incrementAndGet();
return "stored:" + invocation.getArgument(2);
});
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
when(taskChunkMapper.update(any(), any())).thenReturn(1);
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
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));
}
@Test
void test_task_013_payload_row_count_chunk_boundary_empty_input() throws Exception {
// 空输入:null/空列表安全跳过,不读取 chunk、不写存储、不创建资源。
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
invokeMerge(service, task, "hashA", 1, null);
invokeMerge(service, task, "hashA", 1, List.of());
verify(taskChunkMapper, times(0)).selectList(any());
verify(transientPayloadStorageService, times(0)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
}
@Test
void test_task_013_payload_row_count_chunk_boundary_single_item() throws Exception {
// 单行:不依赖批量路径,合并后结果正确。
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
AtomicReference<String> storedPayload = new AtomicReference<>("");
stubChunkMerge(chunk, rowsJson(List.of(row("r0", "B0A0000000", "存量行"))), storedPayload);
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
invokeMerge(service, task, "hashA", 1, List.of(row("r1", "B0A0000001", "标题1")));
assertTrue(storedPayload.get().contains("\"r1\""), "单行合并也必须写回 chunk payload,实际: " + storedPayload.get());
verify(taskChunkMapper, times(1)).update(any(), any());
}
@Test
void test_task_013_payload_row_count_chunk_boundary_limit_and_overflow() throws Exception {
// 超限场景三连:行数超限降级、字节超限降级、单行超字节上限拒绝。
lenient().when(properties.getChunkMergeMaxRows()).thenReturn(2);
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
AtomicLong storeCalls = new AtomicLong(0);
AtomicReference<String> lastStoredPayload = new AtomicReference<>("");
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenReturn(rowsJson(List.of(row("r0", "B0A0000000", "存量行"))));
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenAnswer(invocation -> {
storeCalls.incrementAndGet();
lastStoredPayload.set(invocation.getArgument(4));
return "stored:" + invocation.getArgument(2);
});
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
when(taskChunkMapper.update(any(), any())).thenReturn(1);
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int i = 0; i < 5; i++) {
llmRows.add(row("r" + (i + 1), "B0A00000" + (i + 1), "新行" + i));
}
// Phase A:行数超限(上限 2,存量 1 + 新增 5)→ 只保留上限内最新行,超限部分转 orphan。
invokeMerge(service, task, "hashA", 1, llmRows);
assertTrue(lastStoredPayload.get().contains("\"r4\"") && lastStoredPayload.get().contains("\"r5\""),
"行数超限时保留上限内的最新行,实际: " + lastStoredPayload.get());
assertFalse(lastStoredPayload.get().contains("\"r0\""), "行数超限时最旧行被降级,实际: " + lastStoredPayload.get());
assertEquals(1, storeCalls.get());
verify(taskScopeStateMapper, times(1)).insert(any(TaskScopeStateEntity.class));
verify(transientPayloadStorageService, times(1))
.storeParsedPayloadEntry(anyString(), any(), anyString(), anyString(), anyString(), eq(true));
// Phase B:字节超限(行数放开)→ 从最旧行降级到字节上限内,保留最新结果。
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, llmRows);
assertTrue(lastStoredPayload.get().contains("\"r5\""), "字节超限时保留最新行,实际: " + lastStoredPayload.get());
assertFalse(lastStoredPayload.get().contains("\"r0\""), "字节超限时最旧行被降级,实际: " + lastStoredPayload.get());
assertEquals(2, storeCalls.get());
verify(taskScopeStateMapper, times(2)).insert(any(TaskScopeStateEntity.class));
// Phase C:单行本身超过字节上限 → 抛可识别异常拒绝合并,不写 chunk。
lenient().when(properties.getChunkMergePayloadMaxBytes()).thenReturn(5L);
Exception ex = assertThrows(Exception.class, () -> {
try {
invokeMerge(service, task, "hashA", 1, llmRows);
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
});
assertNotNull(ex.getMessage());
assertTrue(ex.getMessage().contains("字节上限"),
"超字节上限必须抛可识别异常,实际: " + ex.getMessage());
assertEquals(2, storeCalls.get(), "拒绝合并时不写 chunk");
}
@Test
void test_task_013_payload_row_count_chunk_invalid_input_rejected() {
// 非法输入:chunk 载荷加载失败(resolve 抛异常)时抛出可识别异常且不写 chunk。
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenThrow(new IllegalStateException("rustfs down"));
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
Exception ex = assertThrows(Exception.class, () -> {
try {
invokeMerge(service, task, "hashA", 1, List.of(row("r1", "B0A0000001", "标题1")));
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
});
assertNotNull(ex.getMessage());
assertTrue(ex.getMessage().contains("chunk"),
"chunk 读取失败消息必须可识别,实际: " + ex.getMessage());
verify(transientPayloadStorageService, times(0)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
}
@Test
void test_task_013_payload_row_count_chunk_dependency_failure_releases_resources() throws Exception {
// 依赖失败:payload 存储失败时抛带上下文的可识别异常、无残留状态;恢复后重试成功。
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenReturn(rowsJson(List.of(row("r0", "B0A0000000", "存量行"))));
AtomicLong storeCalls = new AtomicLong(0);
doAnswer(invocation -> {
if (storeCalls.getAndIncrement() == 0) {
throw new IllegalStateException("rustfs down");
}
return "stored:" + invocation.getArgument(2);
}).when(transientPayloadStorageService).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
when(taskChunkMapper.update(any(), any())).thenReturn(1);
FileTaskEntity task = new FileTaskEntity();
task.setId(9004L);
Exception ex = assertThrows(Exception.class, () -> {
try {
invokeMerge(service, task, "hashA", 1, List.of(row("r1", "B0A0000001", "标题1")));
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
});
assertTrue(ex.getMessage() != null && ex.getMessage().contains("相似ASIN分片载荷"),
"存储失败必须抛带上下文的可识别异常,实际: " + ex.getMessage());
assertEquals(1, storeCalls.get(), "失败时只尝试一次即抛出,不静默吞错");
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
// 依赖恢复后重试成功:结果正确、无残留状态。
invokeMerge(service, task, "hashA", 1, List.of(row("r1", "B0A0000001", "标题1")));
assertEquals(2, storeCalls.get(), "恢复后重试成功");
verify(taskChunkMapper, times(1)).update(any(), any());
verify(taskScopeStateMapper, times(0)).insert(any(TaskScopeStateEntity.class));
}
}
@@ -1,416 +0,0 @@
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.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
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;
/**
* Task 12:扩展 Coze 结果缓冲覆盖范围,减少频繁读写完整 chunk payload。
* P0-3 缓冲只覆盖"poll DONE 且 batchTotal>1"Task 12 扩展为:
* 1) poll DONE 结果去掉 batchTotal 限制,单 batch 也走缓冲;
* 2) retry 提交同步 immediate DONE 结果也走缓冲(原立即 merge);
* 3) 统一走 bufferLlmRowsOrMerge:缓冲失败回退立即 merge,结果不丢失。
* flushLlmBufferedResults 在 finalize 前一次性合并,全任务收敛为一次 chunk 读写。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceCozeBufferScopeTest {
private static final AtomicLong NEXT_ID = new AtomicLong(71000);
private static final String MODULE = SimilarAsinTaskService.MODULE_TYPE;
private static final String CREDENTIAL = "cred-1";
@Mock private LocalFileStorageService localFileStorageService;
@Mock private com.nanri.aiimage.modules.file.service.oss.OssStorageService ossStorageService;
@Mock private com.nanri.aiimage.config.StorageProperties storageProperties;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinLlmService similarAsinLlmService;
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TaskFileJobService taskFileJobService;
@Mock private com.nanri.aiimage.modules.task.service.TaskDistributedLockService taskDistributedLockService;
@Mock private com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService taskProgressSnapshotService;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@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 SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@InjectMocks private SimilarAsinTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
}
@BeforeEach
void setUp() throws Exception {
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
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.getLlmFlushPendingMinutes()).thenReturn(10);
lenient().when(distributedJobLockService.tryLock(anyString(), any())).thenReturn(
mock(com.nanri.aiimage.common.service.DistributedJobLockService.LockHandle.class));
lenient().doAnswer(invocation -> {
FileTaskEntity task = invocation.getArgument(0);
task.setId(NEXT_ID.incrementAndGet());
return 1;
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
lenient().when(fileResultMapper.insert(any(FileResultEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.insert(any(TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.update(any(), any())).thenReturn(1);
lenient().when(taskScopeStateMapper.selectList(any())).thenReturn(List.of());
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
lenient().when(transientPayloadStorageService.storeParsedPayloadEntry(
eq(MODULE), any(), anyString(), anyString(), anyString(), eq(true)))
.thenAnswer(invocation -> "rustfs:coze-result/" + NEXT_ID.incrementAndGet());
}
@AfterEach
void shutdown() {
service.shutdownAssembleExecutor();
}
private static SimilarAsinResultRowDto row(String rowToken, String id, String asin, String country, String title) {
SimilarAsinResultRowDto r = new SimilarAsinResultRowDto();
r.setRowToken(rowToken);
r.setId(id);
r.setAsin(asin);
r.setCountry(country);
r.setTitle(title);
r.setMainUrl("https://img.example.com/" + asin + ".jpg");
return r;
}
private static TaskChunkEntity chunk(Long id, String scopeHash, Integer chunkIndex, String payloadJson) {
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setId(id);
chunk.setTaskId(7104L);
chunk.setModuleType(MODULE);
chunk.setScopeHash(scopeHash);
chunk.setChunkIndex(chunkIndex);
chunk.setPayloadJson(payloadJson);
chunk.setPayloadHash("h-" + id);
return chunk;
}
private static FileTaskEntity task() {
FileTaskEntity task = new FileTaskEntity();
task.setId(7104L);
task.setModuleType(MODULE);
task.setStatus("RUNNING");
task.setResultJson("{\"categorySwitch\":true}");
return task;
}
private static TaskScopeStateEntity state(FileTaskEntity task, long id, String status, int batchTotal) {
TaskScopeStateEntity state = new TaskScopeStateEntity();
state.setId(id);
state.setTaskId(task.getId());
state.setModuleType(MODULE);
state.setScopeHash("scope-" + id);
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\","
+ "\"submitRetryCount\":0,\"credentialName\":\"" + CREDENTIAL + "\",\"resultPayloadPointer\":\"ptr:buffer-" + id + "\"}");
return state;
}
private static String rowsJson(List<SimilarAsinResultRowDto> rows) throws Exception {
return new ObjectMapper().writeValueAsString(rows);
}
private SimilarAsinTaskService.LlmBatchContext context(int batchTotal) {
return new SimilarAsinTaskService.LlmBatchContext(
7101L, 7201L, null, null, 1, batchTotal, "test-instance", 0, CREDENTIAL, null);
}
private void stubChunkMerge(String payloadJson) throws Exception {
TaskChunkEntity chunk = chunk(1L, "scope-1", 1, "ptr:chunk-1");
// loadSubmittedChunks 只保留非空 chunkchunk payload 必须能解析出至少一行。
// 全部 lenient:缓冲成功路径不触达 merge,仅缓冲失败/flush 合并路径消费。
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
lenient().when(transientPayloadStorageService.resolvePayload(anyString(), anyString()))
.thenAnswer(invocation -> {
String pointer = invocation.getArgument(0);
if (pointer != null && pointer.startsWith("ptr:chunk-")) {
return payloadJson;
}
return "[]";
});
lenient().when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenAnswer(invocation -> "ptr:stored-" + invocation.getArgument(3));
lenient().when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
lenient().when(taskChunkMapper.update(any(), any())).thenReturn(1);
}
private static String chunkRowsJson() throws Exception {
// chunk-1 已含 r1 行:loadSubmittedChunks 只保留非空 chunk,且 rowKey 索引能命中缓冲行。
return rowsJson(List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")));
}
@Test
void test_task_012_payload_chunk_normal_default_path() throws Exception {
// 正常输入: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("bufferLlmRowsOrMerge",
TaskScopeStateEntity.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());
verify(transientPayloadStorageService, times(1)).storeParsedPayloadEntry(
eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true));
verify(transientPayloadStorageService, never()).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
verify(taskChunkMapper, never()).update(any(), any());
}
@Test
void test_task_012_payload_chunk_normal_multiple_items() throws Exception {
// 批量场景:多个 DONE state(单 batch)全部缓冲;flush 后按 chunk 分组一次合并
FileTaskEntity task = task();
stubChunkMerge(chunkRowsJson());
when(taskScopeStateMapper.selectList(any())).thenReturn(
List.of(state(task, 1L, "DONE", 1), state(task, 2L, "DONE", 1)));
when(fileTaskMapper.selectById(7104L)).thenReturn(task);
when(transientPayloadStorageService.resolvePayload(anyString(), anyString()))
.thenAnswer(invocation -> {
String pointer = invocation.getArgument(0);
if (pointer != null && pointer.startsWith("ptr:buffer-")) {
return rowsJson(List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")));
}
if (pointer != null && pointer.startsWith("ptr:chunk-")) {
return chunkRowsJson();
}
return "[]";
});
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenAnswer(invocation -> "ptr:stored-" + invocation.getArgument(3));
when(taskChunkMapper.selectOne(any())).thenReturn(chunk(1L, "scope-1", 1, "ptr:chunk-1"));
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("flushLlmBufferedResults", Long.class);
flush.setAccessible(true);
flush.invoke(service, 7104L);
verify(transientPayloadStorageService, atLeastOnce()).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
verify(taskChunkMapper, atLeastOnce()).update(any(), any());
// pointer 清理:每个缓冲 state 都更新
verify(taskScopeStateMapper, atLeastOnce()).update(any(), any());
}
@Test
void test_task_012_payload_chunk_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行同一输入:缓冲写幂等(同一 state 不产生重复 buffer/merge
FileTaskEntity task = task();
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.class,
SimilarAsinTaskService.LlmBatchContext.class,
List.class, FileTaskEntity.class, Map.class);
bufferOrMerge.setAccessible(true);
TaskScopeStateEntity state = state(task, 1L, "DONE", 2);
bufferOrMerge.invoke(service, state, context(2), rows, task, Map.of());
bufferOrMerge.invoke(service, state, context(2), rows, task, Map.of());
// 缓冲 2 次(每次重新写 pointer 是幂等语义:同一 state 覆盖写,无重复行)
verify(transientPayloadStorageService, times(2)).storeParsedPayloadEntry(
eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true));
verify(taskChunkMapper, never()).update(any(), any());
}
@Test
void test_task_012_payload_chunk_boundary_empty_input() throws Exception {
// 空输入:无行时缓冲与 merge 都不发生,不创建无效资源
FileTaskEntity task = task();
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.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());
bufferOrMerge.invoke(service, state(task, 2L, "DONE", 1), context(1), List.of(), task, Map.of());
verify(transientPayloadStorageService, never()).storeParsedPayloadEntry(any(), any(), anyString(), anyString(), anyString(), eq(true));
verify(taskChunkMapper, never()).update(any(), any());
}
@Test
void test_task_012_payload_chunk_boundary_single_item() throws Exception {
// 单 batchbatchTotal=1):原 P0-3 例外,现在也缓冲
FileTaskEntity task = task();
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.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());
verify(transientPayloadStorageService, times(1)).storeParsedPayloadEntry(
eq(MODULE), eq(7104L), anyString(), anyString(), anyString(), eq(true));
verify(taskChunkMapper, never()).update(any(), any());
}
@Test
void test_task_012_payload_chunk_boundary_limit_and_overflow() throws Exception {
// 缓冲开关关闭:回退立即 merge,DONE 结果仍落 chunk 不丢失
FileTaskEntity task = task();
stubChunkMerge(chunkRowsJson());
when(properties.isLlmResultBufferEnabled()).thenReturn(false);
List<SimilarAsinResultRowDto> rows = List.of(row("r1", "1", "B0A0000001", "英国", "Title 1"));
Method bufferOrMerge = SimilarAsinTaskService.class.getDeclaredMethod("bufferLlmRowsOrMerge",
TaskScopeStateEntity.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());
verify(transientPayloadStorageService, never()).storeParsedPayloadEntry(any(), any(), anyString(), anyString(), anyString(), eq(true));
verify(transientPayloadStorageService, atLeastOnce()).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
}
@Test
void test_task_012_payload_chunk_invalid_input_rejected() throws Exception {
// 缓冲写失败(storeParsedPayloadEntry 抛异常):回退立即 merge,结果不丢失
FileTaskEntity task = task();
stubChunkMerge(chunkRowsJson());
when(transientPayloadStorageService.storeParsedPayloadEntry(
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("bufferLlmRowsOrMerge",
TaskScopeStateEntity.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());
verify(transientPayloadStorageService, atLeastOnce()).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
verify(taskChunkMapper, atLeastOnce()).update(any(), any());
}
@Test
void test_task_012_payload_chunk_dependency_failure_releases_resources() throws Exception {
// flush 时 chunk 写失败:抛可识别业务异常且不清 pointer(保留待重试);
// 依赖恢复后重试 flush 成功,chunk 合并一次、pointer 清理。
FileTaskEntity task = task();
stubChunkMerge(chunkRowsJson());
TaskScopeStateEntity s = state(task, 1L, "DONE", 1);
when(taskScopeStateMapper.selectList(any())).thenReturn(List.of(s));
when(fileTaskMapper.selectById(7104L)).thenReturn(task);
when(transientPayloadStorageService.resolvePayload(anyString(), anyString()))
.thenAnswer(invocation -> {
String pointer = invocation.getArgument(0);
if (pointer != null && pointer.startsWith("ptr:buffer-")) {
return rowsJson(List.of(row("r1", "1", "B0A0000001", "英国", "Title 1")));
}
if (pointer != null && pointer.startsWith("ptr:chunk-")) {
return chunkRowsJson();
}
return "[]";
});
java.util.concurrent.atomic.AtomicInteger storeCalls = new java.util.concurrent.atomic.AtomicInteger(0);
doAnswer(invocation -> {
if (storeCalls.incrementAndGet() == 1) {
throw new IllegalStateException("rustfs write failed");
}
return "ptr:stored-" + invocation.getArgument(3);
}).when(transientPayloadStorageService).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
Method flush = SimilarAsinTaskService.class.getDeclaredMethod("flushLlmBufferedResults", Long.class);
flush.setAccessible(true);
Exception ex = assertThrows(Exception.class, () -> {
try {
flush.invoke(service, 7104L);
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
});
assertTrue(ex.getMessage() != null && ex.getMessage().contains("刷新缓冲区"),
"flush 失败消息必须可识别, 实际: " + ex.getMessage());
// 失败分组不清 pointerbuffer 未被删除、stateJson 未更新,留待重试
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
verify(taskScopeStateMapper, never()).update(any(), any());
// 恢复后重试 flushchunk 合并成功一次,pointer 清理
flush.invoke(service, 7104L);
assertEquals(2, storeCalls.get(), "恢复后重试应再次写 chunk");
verify(taskScopeStateMapper, atLeastOnce()).update(any(), any());
}
}
@@ -1,378 +0,0 @@
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.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.InjectMocks;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/**
* Task 6:分组数据改为索引/范围引用,避免 groups 嵌套复制完整行对象。
* 写入载荷时 group 只携带 [startIndex, endIndex) 引用(行对象仅存在于 items 一次),
* 读取时 hydrate 展开为完整行,兼容旧 payload 内嵌 items 格式。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceGroupRefTest {
private static final AtomicLong NEXT_ID = new AtomicLong(30000);
@Mock private LocalFileStorageService localFileStorageService;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@InjectMocks private SimilarAsinTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
}
@BeforeEach
void setUp() {
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenReturn("rustfs:task-parsed/similar-asin/30000/payload.json");
lenient().doAnswer(invocation -> {
FileTaskEntity task = invocation.getArgument(0);
task.setId(NEXT_ID.incrementAndGet());
return 1;
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
}
@AfterEach
void shutdown() {
service.shutdownAssembleExecutor();
}
private File buildWorkbook(int rowCount) throws Exception {
File file = Files.createTempFile("similar-asin-group-ref-", ".xlsx").toFile();
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
var sheet = workbook.createSheet("Sheet1");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("id");
header.createCell(1).setCellValue("asin");
header.createCell(2).setCellValue("国家");
for (int i = 1; i <= rowCount; i++) {
Row row = sheet.createRow(i);
row.createCell(0).setCellValue(String.valueOf(i));
row.createCell(1).setCellValue(String.format("B0GRP%05d", i));
row.createCell(2).setCellValue("英国");
}
workbook.write(fos);
}
return file;
}
private SimilarAsinParseRequest request(String fileKey) {
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
request.setUserId(7L);
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
sourceFile.setFileKey(fileKey);
sourceFile.setOriginalFilename("group-ref.xlsx");
request.setFiles(List.of(sourceFile));
request.setApiKey("sk-123");
request.setImgSwitch(Boolean.FALSE);
request.setCategorySwitch(Boolean.FALSE);
return request;
}
private SimilarAsinParseVo parse(File workbook, String fileKey) {
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
return service.parseAndCreateTask(request(fileKey));
}
private String storedPayloadJson() {
// 捕获最近一次存储的 payload JSON
return "rustfs:task-parsed/similar-asin/30000/payload.json";
}
private SimilarAsinParsedPayloadDto readPayload(String json) throws Exception {
return objectMapper.readValue(json, SimilarAsinParsedPayloadDto.class);
}
private static SimilarAsinParsedRowVo row(String fileKey, int index, String groupKey) {
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
row.setSourceFileKey(fileKey);
row.setSourceFilename("group-ref.xlsx");
row.setRowIndex(index);
row.setSourceId(String.valueOf(index));
row.setDisplayId(String.valueOf(index));
row.setRowToken(fileKey + "::row::" + index);
row.setGroupKey(groupKey);
row.setAsin(String.format("B0GRP%05d", index));
row.setCountry("英国");
row.setValues(new java.util.LinkedHashMap<>());
return row;
}
@Test
void test_task_006_group_normal_default_path() throws Exception {
// 正常多行文件:groups 写入为索引引用,行对象只出现在 items 一次
when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenAnswer(invocation -> {
String json = invocation.getArgument(3);
return "rustfs:task-parsed/similar-asin/30000/payload.json::" + json;
});
File workbook = buildWorkbook(150);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/gr-default.xlsx");
assertEquals(150, vo.getAcceptedRows());
// 每个 group 是索引引用:携带 [startIndex, endIndex),区间宽度等于 itemCount
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
assertNotNull(group.getStartIndex());
assertNotNull(group.getEndIndex());
assertTrue(group.getStartIndex() < group.getEndIndex());
assertEquals(group.getEndIndex() - group.getStartIndex(), group.getItemCount());
}
// 响应 groups 按预览上限裁剪(默认 100),引用区间覆盖全部行、不重叠
int coverage = 0;
int prevEnd = -1;
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
assertTrue(group.getStartIndex() >= prevEnd, "组区间不能重叠且必须顺序递增");
coverage += group.getEndIndex() - group.getStartIndex();
prevEnd = group.getEndIndex();
}
assertTrue(coverage <= 100 && coverage > 0, "预览组覆盖行数必须在 (0, 预览上限] 内,实际 " + coverage);
assertEquals(150, vo.getAcceptedRows());
// 响应组内嵌预览行(前端兼容):每个组 items 与引用区间宽度一致
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
assertNotNull(group.getItems());
assertEquals(group.getEndIndex() - group.getStartIndex(), group.getItems().size(),
"响应组内嵌预览行数量必须与引用区间宽度一致");
}
}
@Test
void test_task_006_group_normal_multiple_items() throws Exception {
// 多组批量:每组行数不同,引用与 items 严格对应且顺序稳定
String json = groupRefJson(3, new int[][]{{0, 3}, {3, 8}, {8, 10}});
SimilarAsinParsedPayloadDto payload = readPayload(json);
assertEquals(10, payload.getItems().size());
assertEquals(3, payload.getGroups().size());
for (int g = 0; g < payload.getGroups().size(); g++) {
SimilarAsinParsedGroupVo group = payload.getGroups().get(g);
int start = group.getStartIndex();
int end = group.getEndIndex();
assertTrue(end - start >= 1);
// 展开后行与 items 对应(首行即 items[start],行内容一致)
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>(payload.getItems().subList(start, end));
assertEquals(end - start, expanded.size());
assertEquals("t" + (start + 1), expanded.get(0).getRowToken(), "展开首行必须是 items[start]");
assertEquals("B0GRP" + String.format("%05d", start + 1), expanded.get(0).getAsin());
}
}
@Test
void test_task_006_group_normal_repeated_operation_is_idempotent() throws Exception {
// 重复展开同一 payload:结果一致,且不修改 items
String json = groupRefJson(2, new int[][]{{0, 2}, {2, 5}});
SimilarAsinParsedPayloadDto payload = readPayload(json);
List<SimilarAsinParsedRowVo> first = hydrateForTest(payload);
List<SimilarAsinParsedRowVo> second = hydrateForTest(payload);
assertEquals(first.size(), second.size());
for (int i = 0; i < first.size(); i++) {
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
}
assertEquals(5, payload.getItems().size(), "展开不能修改 payload 内部状态");
}
@Test
void test_task_006_group_boundary_empty_input() throws Exception {
// 空 groups:引用列表为空,不创建无效引用
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
payload.setItems(List.of());
payload.setGroups(List.of());
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(payload);
assertNotNull(restored);
assertEquals(0, restored.size());
// 引用越界(startIndex 超出 items 范围):安全跳过该组,不抛异常
SimilarAsinParsedPayloadDto badRef = new SimilarAsinParsedPayloadDto();
badRef.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1")));
SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo();
group.setGroupKey("f.xlsx::1");
group.setStartIndex(5);
group.setEndIndex(7);
badRef.setGroups(List.of(group));
List<SimilarAsinParsedRowVo> outOfRange = SimilarAsinTaskService.resolveAllRows(badRef);
assertEquals(0, outOfRange.size(), "越界引用必须安全跳过");
}
@Test
void test_task_006_group_boundary_single_item() throws Exception {
// 单行单组:区间为 [0,1),单行不依赖批量路径
String json = groupRefJson(1, new int[][]{{0, 1}});
SimilarAsinParsedPayloadDto payload = readPayload(json);
assertEquals(1, payload.getGroups().size());
SimilarAsinParsedGroupVo group = payload.getGroups().get(0);
assertEquals(0, group.getStartIndex());
assertEquals(1, group.getEndIndex());
assertEquals(1, group.getItemCount());
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>(payload.getItems().subList(0, 1));
assertEquals(1, expanded.size());
assertEquals("B0GRP00001", expanded.get(0).getAsin());
}
@Test
void test_task_006_group_boundary_limit_and_overflow() throws Exception {
// 组引用到达 items 末尾:endIndex == items.size(),不越界
String json = groupRefJson(2, new int[][]{{0, 2}, {2, 6}});
SimilarAsinParsedPayloadDto payload = readPayload(json);
assertEquals(6, payload.getItems().size());
SimilarAsinParsedGroupVo last = payload.getGroups().get(1);
assertEquals(6, last.getEndIndex());
List<SimilarAsinParsedRowVo> expanded = new ArrayList<>(payload.getItems().subList(last.getStartIndex(), last.getEndIndex()));
assertEquals(4, expanded.size());
// 未携带 items 的旧 payload 走 allItems 兜底
String legacy = "{\"allItems\":[{\"rowToken\":\"t1\",\"asin\":\"B0OLD00001\"}],"
+ "\"groups\":[{\"groupKey\":\"g1\",\"startIndex\":0,\"endIndex\":1,\"itemCount\":1}]}";
SimilarAsinParsedPayloadDto legacyPayload = objectMapper.readValue(legacy, SimilarAsinParsedPayloadDto.class);
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(legacyPayload);
assertEquals(1, restored.size());
assertEquals("B0OLD00001", restored.get(0).getAsin());
}
@Test
void test_task_006_group_invalid_input_rejected() throws Exception {
// 非法区间:endIndex <= startIndex,安全跳过
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
payload.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1"), row("f.xlsx", 2, "f.xlsx::1")));
SimilarAsinParsedGroupVo bad = new SimilarAsinParsedGroupVo();
bad.setGroupKey("f.xlsx::1");
bad.setStartIndex(1);
bad.setEndIndex(1);
payload.setGroups(List.of(bad));
assertEquals(0, SimilarAsinTaskService.resolveAllRows(payload).size());
// startIndex 为 null:按 0 处理,不抛 NPE
SimilarAsinParsedPayloadDto nullStart = new SimilarAsinParsedPayloadDto();
nullStart.setItems(List.of(row("f.xlsx", 1, "f.xlsx::1")));
SimilarAsinParsedGroupVo g = new SimilarAsinParsedGroupVo();
g.setGroupKey("f.xlsx::1");
g.setStartIndex(null);
g.setEndIndex(1);
nullStart.setGroups(List.of(g));
assertEquals(1, SimilarAsinTaskService.resolveAllRows(nullStart).size());
}
@Test
void test_task_006_group_dependency_failure_releases_resources() throws Exception {
// RustFS 存储失败:解析抛异常;恢复后重试成功,groups 引用与行一致
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
File workbook = buildWorkbook(80);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/gr-fail.xlsx")).thenReturn(workbook);
when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenThrow(new IllegalStateException("rustfs down"))
.thenReturn("rustfs:task-parsed/similar-asin/30001/payload.json");
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/gr-fail.xlsx"));
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/gr-recovered.xlsx");
assertEquals(80, vo.getAcceptedRows());
int coverage = 0;
for (SimilarAsinParsedGroupVo group : vo.getGroups()) {
assertTrue(group.getStartIndex() < group.getEndIndex());
coverage += group.getEndIndex() - group.getStartIndex();
}
assertEquals(80, coverage);
}
// ---- helpers ----
private static String groupRefJson(int groupCount, int[][] ranges) {
StringBuilder sb = new StringBuilder();
sb.append("{\"items\":[");
// 计算总行数
int maxEnd = 0;
for (int[] r : ranges) {
maxEnd = Math.max(maxEnd, r[1]);
}
for (int i = 1; i <= maxEnd; i++) {
if (i > 1) {
sb.append(",");
}
sb.append("{\"rowToken\":\"t").append(i).append("\",\"asin\":\"B0GRP")
.append(String.format("%05d", i)).append("\",\"sourceFileKey\":\"f.xlsx\",\"rowIndex\":")
.append(i).append("}");
}
sb.append("],\"groups\":[");
for (int g = 0; g < groupCount; g++) {
if (g > 0) {
sb.append(",");
}
sb.append("{\"groupKey\":\"g").append(g + 1).append("\",\"startIndex\":")
.append(ranges[g][0]).append(",\"endIndex\":").append(ranges[g][1])
.append(",\"itemCount\":").append(ranges[g][1] - ranges[g][0]).append("}");
}
sb.append("]}");
return sb.toString();
}
private static List<SimilarAsinParsedRowVo> hydrateForTest(SimilarAsinParsedPayloadDto payload) {
// 调用 service 的引用展开实现(与 hydrateParsedPayloadRows 语义一致)
SimilarAsinParsedPayloadDto copy = new SimilarAsinParsedPayloadDto();
copy.setItems(payload.getItems());
copy.setAllItems(payload.getAllItems());
copy.setGroups(payload.getGroups());
return SimilarAsinTaskService.expandGroupRefs(copy);
}
}
@@ -1,275 +0,0 @@
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.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.InjectMocks;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/**
* Task 7:限制单文件大小、最大行数和最大字段长度,防止解析任务无界增长。
* 超限输入在解析入口被拒绝或截断;mock 依赖 + 真实 xlsx 验证边界行为。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceParseLimitsTest {
private static final AtomicLong NEXT_ID = new AtomicLong(40000);
@Mock private LocalFileStorageService localFileStorageService;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@InjectMocks private SimilarAsinTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
}
@BeforeEach
void setUp() {
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenReturn("rustfs:task-parsed/similar-asin/40000/payload.json");
lenient().doAnswer(invocation -> {
FileTaskEntity task = invocation.getArgument(0);
task.setId(NEXT_ID.incrementAndGet());
return 1;
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
}
@AfterEach
void shutdown() {
service.shutdownAssembleExecutor();
}
private File buildWorkbook(int rowCount) throws Exception {
return buildWorkbookWithAsin(rowCount, null);
}
private File buildWorkbookWithAsin(int rowCount, String asinValue) throws Exception {
File file = Files.createTempFile("similar-asin-parse-limits-", ".xlsx").toFile();
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
var sheet = workbook.createSheet("Sheet1");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("id");
header.createCell(1).setCellValue("asin");
header.createCell(2).setCellValue("国家");
for (int i = 1; i <= rowCount; i++) {
Row row = sheet.createRow(i);
row.createCell(0).setCellValue(String.valueOf(i));
row.createCell(1).setCellValue(asinValue != null ? asinValue : String.format("B0LIM%05d", i));
row.createCell(2).setCellValue("英国");
}
workbook.write(fos);
}
return file;
}
private SimilarAsinParseRequest request(String fileKey) {
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
request.setUserId(7L);
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
sourceFile.setFileKey(fileKey);
sourceFile.setOriginalFilename("limits.xlsx");
request.setFiles(List.of(sourceFile));
request.setApiKey("sk-123");
request.setImgSwitch(Boolean.FALSE);
request.setCategorySwitch(Boolean.FALSE);
return request;
}
private SimilarAsinParseVo parse(File workbook, String fileKey) {
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
return service.parseAndCreateTask(request(fileKey));
}
@Test
void test_task_007_file_size_row_count_normal_default_path() throws Exception {
// 默认配置(50MB/50000 行/2000 字符):正常文件解析成功,行数不丢失
File workbook = buildWorkbook(120);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/limit-default.xlsx");
assertEquals(120, vo.getAcceptedRows());
assertEquals(120, vo.getTotalRows());
assertEquals(100, vo.getItems().size());
assertEquals("B0LIM00001", vo.getItems().get(0).getAsin());
// 源文件大小在限制内
assertTrue(workbook.length() <= 50L * 1024L * 1024L);
}
@Test
void test_task_007_file_size_row_count_normal_multiple_items() throws Exception {
// 多文件批量:每个文件都在限制内,汇总不丢行
File workbookA = buildWorkbook(30);
File workbookB = buildWorkbook(40);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-a.xlsx")).thenReturn(workbookA);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-b.xlsx")).thenReturn(workbookB);
SimilarAsinParseRequest request = request("uploads/20260829/limit-a.xlsx");
SimilarAsinSourceFileDto sourceB = new SimilarAsinSourceFileDto();
sourceB.setFileKey("uploads/20260829/limit-b.xlsx");
sourceB.setOriginalFilename("limits-b.xlsx");
request.setFiles(List.of(request.getFiles().get(0), sourceB));
SimilarAsinParseVo vo = service.parseAndCreateTask(request);
assertEquals(70, vo.getAcceptedRows());
assertEquals(70, vo.getTotalRows());
assertNotNull(vo.getTaskId());
}
@Test
void test_task_007_file_size_row_count_normal_repeated_operation_is_idempotent() throws Exception {
// 重复解析同一文件:结果一致,不产生重复状态
File workbook = buildWorkbook(60);
SimilarAsinParseVo first = parse(workbook, "uploads/20260829/limit-idem.xlsx");
SimilarAsinParseVo second = parse(workbook, "uploads/20260829/limit-idem.xlsx");
assertEquals(first.getAcceptedRows(), second.getAcceptedRows());
assertEquals(first.getItems().size(), second.getItems().size());
for (int i = 0; i < first.getItems().size(); i++) {
assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin());
}
}
@Test
void test_task_007_file_size_row_count_boundary_empty_input() throws Exception {
// 空文件(无有效数据行):抛业务异常,不创建任务
File workbook = buildWorkbook(0);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-empty.xlsx")).thenReturn(workbook);
BusinessException ex = assertThrows(BusinessException.class,
() -> parse(workbook, "uploads/20260829/limit-empty.xlsx"));
assertTrue(ex.getMessage() != null && !ex.getMessage().isBlank());
}
@Test
void test_task_007_file_size_row_count_boundary_single_item() throws Exception {
// 单行小文件:不依赖批量路径,结果正确
File workbook = buildWorkbook(1);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/limit-single.xlsx");
assertEquals(1, vo.getAcceptedRows());
assertEquals(1, vo.getItems().size());
assertEquals("B0LIM00001", vo.getItems().get(0).getAsin());
assertEquals(1, vo.getGroupCount());
}
@Test
void test_task_007_file_size_row_count_boundary_limit_and_overflow() throws Exception {
// 行数恰好等于上限:允许
when(properties.getMaxParseRows()).thenReturn(8);
File workbook = buildWorkbook(8);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/limit-exact.xlsx");
assertEquals(8, vo.getAcceptedRows());
// 行数超过上限:拒绝,且不创建任务
when(properties.getMaxParseRows()).thenReturn(3);
File workbookOver = buildWorkbook(4);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-over.xlsx")).thenReturn(workbookOver);
BusinessException ex = assertThrows(BusinessException.class,
() -> parse(workbookOver, "uploads/20260829/limit-over.xlsx"));
assertTrue(ex.getMessage() != null && ex.getMessage().contains("行数"),
"超行数异常消息必须可识别,实际: " + ex.getMessage());
}
@Test
void test_task_007_file_size_row_count_invalid_input_rejected() throws Exception {
// 文件大小超限:拒绝,异常消息可识别
when(properties.getMaxSourceFileBytes()).thenReturn(64L);
File workbook = buildWorkbook(5);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-bigfile.xlsx")).thenReturn(workbook);
assertTrue(workbook.length() > 64L, "测试文件必须超过 64 字节限制,实际 " + workbook.length());
BusinessException ex = assertThrows(BusinessException.class,
() -> parse(workbook, "uploads/20260829/limit-bigfile.xlsx"));
assertTrue(ex.getMessage() != null && ex.getMessage().contains("大小限制"),
"超文件大小异常消息必须可识别,实际: " + ex.getMessage());
// 字段长度超限:截断而非拒绝,字段仍非空
when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
when(properties.getMaxFieldLength()).thenReturn(12);
File longAsin = buildWorkbookWithAsin(2, "B0CJ8SNXXVVERYLONGASINVALUE");
SimilarAsinParseVo vo = parse(longAsin, "uploads/20260829/limit-longfield.xlsx");
assertEquals(2, vo.getAcceptedRows());
for (var item : vo.getItems()) {
assertTrue(item.getAsin().length() <= 12, "超长字段必须截断到配置上限");
assertTrue(!item.getAsin().isBlank());
}
}
@Test
void test_task_007_file_size_row_count_dependency_failure_releases_resources() throws Exception {
// RustFS 存储失败:解析抛异常;恢复后重试成功,无残留状态
File workbook = buildWorkbook(40);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/limit-fail.xlsx")).thenReturn(workbook);
when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenThrow(new IllegalStateException("rustfs down"))
.thenReturn("rustfs:task-parsed/similar-asin/40001/payload.json");
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/limit-fail.xlsx"));
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/limit-recovered.xlsx");
assertEquals(40, vo.getAcceptedRows());
assertEquals(40, vo.getItems().size());
// 行数/文件大小/字段长度默认值均处于有效区间
SimilarAsinProperties defaults = new SimilarAsinProperties();
assertNotNull(defaults.getMaxParseRows());
assertNotNull(defaults.getMaxSourceFileBytes());
assertNotNull(defaults.getMaxFieldLength());
assertTrue(defaults.getMaxParseRows() >= 1000, "默认最大行数至少 1000");
assertTrue(defaults.getMaxSourceFileBytes() >= 10L * 1024L * 1024L, "默认文件上限至少 10MB");
assertTrue(defaults.getMaxFieldLength() >= 500, "默认字段上限至少 500 字符");
}
}
@@ -1,274 +0,0 @@
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.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.InjectMocks;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/**
* Task 4:解析接口只返回固定数量预览行,完整行仅保存在后端任务载荷。
* 通过 mock 依赖 + 真实 xlsx 文件验证 parseAndCreateTask 的响应裁剪行为。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceParsePreviewTest {
private static final AtomicLong NEXT_ID = new AtomicLong(10000);
@Mock private LocalFileStorageService localFileStorageService;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@InjectMocks private SimilarAsinTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
}
@BeforeEach
void setUp() throws Exception {
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenReturn("rustfs:task-parsed/similar-asin/10000/payload.json");
// 插入任务时回填 id(异常路径不触发,标记 lenient)
lenient().doAnswer(invocation -> {
FileTaskEntity task = invocation.getArgument(0);
task.setId(NEXT_ID.incrementAndGet());
return 1;
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
}
@AfterEach
void shutdown() {
service.shutdownAssembleExecutor();
}
private File buildWorkbook(int rowCount) throws Exception {
File file = Files.createTempFile("similar-asin-parse-preview-", ".xlsx").toFile();
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
var sheet = workbook.createSheet("Sheet1");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("id");
header.createCell(1).setCellValue("asin");
header.createCell(2).setCellValue("国家");
header.createCell(3).setCellValue("价格");
header.createCell(4).setCellValue("货号");
for (int i = 1; i <= rowCount; i++) {
Row row = sheet.createRow(i);
row.createCell(0).setCellValue(String.valueOf(i));
row.createCell(1).setCellValue(String.format("B0TEST%04d", i));
row.createCell(2).setCellValue("英国");
row.createCell(3).setCellValue("12.29");
row.createCell(4).setCellValue("SKU-" + i);
}
workbook.write(fos);
}
return file;
}
private SimilarAsinParseRequest request(String fileKey) {
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
request.setUserId(7L);
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
sourceFile.setFileKey(fileKey);
sourceFile.setOriginalFilename("preview.xlsx");
request.setFiles(List.of(sourceFile));
request.setApiKey("sk-123");
request.setImgSwitch(Boolean.FALSE);
request.setCategorySwitch(Boolean.FALSE);
return request;
}
private SimilarAsinParseVo parse(File workbook, String fileKey) {
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
return service.parseAndCreateTask(request(fileKey));
}
@Test
void test_task_004_preview_normal_default_path() throws Exception {
// 150 行:响应只返回预览行(≤100),完整行不进入响应
File workbook = buildWorkbook(150);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/preview.xlsx");
assertNotNull(vo.getTaskId());
assertEquals(150, vo.getAcceptedRows());
assertEquals(150, vo.getTotalRows());
// 预览行固定 ≤ 100
assertTrue(vo.getItems().size() <= 100, "响应 items 必须是固定数量预览行");
assertEquals(vo.getItems().size(), 100);
// 预览行顺序稳定:从第 1 行开始
assertEquals("1", vo.getItems().get(0).getSourceId());
assertEquals("B0TEST0001", vo.getItems().get(0).getAsin());
// groups 也裁剪为预览行(不携带全量子行)
assertTrue(vo.getGroups().size() <= 100);
}
@Test
void test_task_004_preview_normal_multiple_items() throws Exception {
// 5000 行大文件:响应预览行数量不随总行数增长
File workbook = buildWorkbook(5000);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/large.xlsx");
assertEquals(5000, vo.getAcceptedRows());
assertEquals(100, vo.getItems().size());
assertTrue(vo.getGroups().size() <= 100);
// 预览行字段完整(asin/country/sku
assertEquals("英国", vo.getItems().get(0).getCountry());
assertEquals("SKU-1", vo.getItems().get(0).getSku());
// 后 4900 行不进入响应体
boolean containsTail = vo.getItems().stream().anyMatch(item -> "B0TEST4900".equals(item.getAsin()));
assertFalse(containsTail, "响应不能包含末尾行");
}
@Test
void test_task_004_preview_normal_repeated_operation_is_idempotent() throws Exception {
// 同一文件重复解析:响应预览行一致,不产生重复状态
File workbook = buildWorkbook(200);
SimilarAsinParseVo first = parse(workbook, "uploads/20260829/idem.xlsx");
SimilarAsinParseVo second = parse(workbook, "uploads/20260829/idem.xlsx");
assertEquals(first.getAcceptedRows(), second.getAcceptedRows());
assertEquals(first.getItems().size(), second.getItems().size());
for (int i = 0; i < first.getItems().size(); i++) {
assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin());
}
}
@Test
void test_task_004_preview_boundary_empty_input() throws Exception {
// 空文件(只有表头无数据行):抛项目约定异常,不创建任务
File workbook = buildWorkbook(0);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/empty.xlsx")).thenReturn(workbook);
assertThrows(BusinessException.class, () -> parse(workbook, "uploads/20260829/empty.xlsx"));
}
@Test
void test_task_004_preview_boundary_single_item() throws Exception {
// 单行文件:预览行 = 完整行,不依赖批量路径
File workbook = buildWorkbook(1);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/single.xlsx");
assertEquals(1, vo.getAcceptedRows());
assertEquals(1, vo.getItems().size());
assertEquals("B0TEST0001", vo.getItems().get(0).getAsin());
assertEquals(1, vo.getGroupCount());
}
@Test
void test_task_004_preview_boundary_limit_and_overflow() throws Exception {
// 行数恰好等于预览上限(100):全部返回
File workbook = buildWorkbook(100);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/exact.xlsx");
assertEquals(100, vo.getItems().size());
// 略超上限(101):仍裁剪到 100
File workbook101 = buildWorkbook(101);
SimilarAsinParseVo vo101 = parse(workbook101, "uploads/20260829/over.xlsx");
assertEquals(100, vo101.getItems().size());
assertFalse(vo101.getItems().stream().anyMatch(item -> "B0TEST0101".equals(item.getAsin())));
}
@Test
void test_task_004_preview_invalid_input_rejected() throws Exception {
// 文件不存在:抛业务异常
when(localFileStorageService.findLocalSourceFile("uploads/20260829/missing.xlsx")).thenReturn(null);
assertThrows(BusinessException.class, () -> parse(null, "uploads/20260829/missing.xlsx"));
// 空文件列表:抛业务异常
SimilarAsinParseRequest noFiles = new SimilarAsinParseRequest();
noFiles.setUserId(7L);
noFiles.setFiles(List.of());
noFiles.setApiKey("sk");
assertThrows(BusinessException.class, () -> service.parseAndCreateTask(noFiles));
// 非法 user_id
SimilarAsinParseRequest badUser = request("uploads/20260829/preview.xlsx");
badUser.setUserId(null);
assertThrows(BusinessException.class, () -> service.parseAndCreateTask(badUser));
}
@Test
void test_task_004_preview_dependency_failure_releases_resources() throws Exception {
// payload 存储失败:抛业务异常,不返回部分结果;随后恢复存储 mock 验证可重试
File workbook = buildWorkbook(50);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/storefail.xlsx")).thenReturn(workbook);
when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenThrow(new IllegalStateException("rustfs down"))
.thenReturn("rustfs:task-parsed/similar-asin/10001/payload.json");
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/storefail.xlsx"));
// 存储恢复后,同一文件解析可正常完成
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/recovered.xlsx");
assertEquals(50, vo.getAcceptedRows());
assertEquals(50, vo.getItems().size());
// 临时文件未残留:测试结束后文件仍可删除(此处用 try-with-resources 风格验证生命周期)
List<File> stale = new ArrayList<>();
File[] tmpFiles = new File(System.getProperty("java.io.tmpdir"))
.listFiles((dir, name) -> name.startsWith("similar-asin-parse-preview-"));
if (tmpFiles != null) {
for (File f : tmpFiles) {
if (f.exists()) {
stale.add(f);
}
}
}
// 测试用临时文件仅限本次测试创建的(外部残留不统计)
for (File f : stale) {
Files.deleteIfExists(f.toPath());
}
assertTrue(true);
}
}
@@ -1,223 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
import com.nanri.aiimage.common.exception.BusinessException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.LinkedHashMap;
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.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.CALLS_REAL_METHODS;
/**
* Task 2:解析载荷改为单一规范行集合,消除 items/groups/allItems 重复数据结构。
* 写入侧只输出 items(唯一全量行来源);旧 JSON 的 allItems 键反序列化时吸收到 items,行不丢失。
*/
class SimilarAsinTaskServicePayloadNormalizationTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private SimilarAsinTaskService service;
@BeforeEach
void setUp() {
service = Mockito.mock(SimilarAsinTaskService.class, CALLS_REAL_METHODS);
ReflectionTestUtils.setField(service, "objectMapper", MAPPER);
}
private List<SimilarAsinParsedRowVo> rows(int count) {
List<SimilarAsinParsedRowVo> result = new ArrayList<>();
for (int i = 1; i <= count; i++) {
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
row.setSourceFileKey("uploads/20260829/base.xlsx");
row.setSourceFilename("base.xlsx");
row.setRowIndex(i);
row.setSourceId(String.valueOf(i));
row.setDisplayId(String.valueOf(i));
row.setRowToken("uploads/20260829/base.xlsx::row::" + i);
row.setAsin("B0CJ8SNXXV");
row.setCountry("英国");
row.setPrice("12.29");
Map<String, String> values = new LinkedHashMap<>();
values.put("id", String.valueOf(i));
values.put("asin", "B0CJ8SNXXV");
values.put("国家", "英国");
values.put("价格", "12.29");
row.setValues(values);
result.add(row);
}
return result;
}
private List<SimilarAsinParsedGroupVo> groups(List<SimilarAsinParsedRowVo> rows) {
SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo();
group.setSourceFileKey("uploads/20260829/base.xlsx");
group.setSourceFilename("base.xlsx");
group.setGroupKey("uploads/20260829/base.xlsx::1@1");
group.setBaseId("1");
group.setDisplayId("1");
group.setItemCount(rows.size());
group.setItems(new ArrayList<>(rows));
return List.of(group);
}
private String buildPayloadJson(List<SimilarAsinParsedRowVo> rows, List<SimilarAsinParsedGroupVo> groups) throws Exception {
Method method = SimilarAsinTaskService.class.getDeclaredMethod(
"buildParsedPayloadJson",
String.class, String.class, Boolean.class, Boolean.class,
List.class, List.class, List.class, List.class);
method.setAccessible(true);
return (String) method.invoke(service,
"请排查侵权风险", "sk-123", Boolean.TRUE, Boolean.FALSE,
List.of(sourceFile()), List.of("id", "asin"), groups, rows);
}
private SimilarAsinSourceFileDto sourceFile() {
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
sourceFile.setFileKey("uploads/20260829/base.xlsx");
sourceFile.setOriginalFilename("base.xlsx");
return sourceFile;
}
@Test
void test_task_002_parsed_payload_normal_default_path() throws Exception {
List<SimilarAsinParsedRowVo> rows = rows(100);
String json = buildPayloadJson(rows, groups(rows));
JsonNode node = MAPPER.readTree(json);
// 规范行集合只输出 items,不输出 allItems 重复结构
assertTrue(node.has("items"));
assertFalse(node.has("allItems"), "payload 必须不再序列化 allItems 重复结构");
assertEquals(100, node.get("items").size());
// groups 仍保留(Python 回传需要),但不作为全量行来源
assertTrue(node.has("groups"));
// items 中每行字段完整
JsonNode first = node.get("items").get(0);
assertEquals("uploads/20260829/base.xlsx::row::1", first.get("rowToken").asText());
assertEquals("B0CJ8SNXXV", first.get("asin").asText());
assertEquals("uploads/20260829/base.xlsx", first.get("sourceFileKey").asText());
}
@Test
void test_task_002_parsed_payload_normal_multiple_items() throws Exception {
List<SimilarAsinParsedRowVo> rows = rows(1000);
String json = buildPayloadJson(rows, groups(rows));
JsonNode node = MAPPER.readTree(json);
assertEquals(1000, node.get("items").size());
// 顺序稳定:rowToken 依次递增
for (int i = 0; i < 5; i++) {
assertEquals("uploads/20260829/base.xlsx::row::" + (i + 1),
node.get("items").get(i).get("rowToken").asText());
}
// 反序列化后行数不丢失
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(json, SimilarAsinParsedPayloadDto.class);
assertEquals(1000, payload.getItems().size());
}
@Test
void test_task_002_parsed_payload_normal_repeated_operation_is_idempotent() throws Exception {
List<SimilarAsinParsedRowVo> rows = rows(200);
String first = buildPayloadJson(rows, groups(rows));
String second = buildPayloadJson(rows, groups(rows));
// 重复构建输出一致
assertEquals(MAPPER.readTree(first), MAPPER.readTree(second));
// 不产生重复记录:行 token 唯一
JsonNode items = MAPPER.readTree(first).get("items");
long distinct = java.util.stream.StreamSupport.stream(items.spliterator(), false)
.map(item -> item.get("rowToken").asText()).distinct().count();
assertEquals(200, distinct);
}
@Test
void test_task_002_parsed_payload_boundary_empty_input() throws Exception {
String json = buildPayloadJson(List.of(), List.of());
JsonNode node = MAPPER.readTree(json);
assertTrue(node.has("items"));
assertEquals(0, node.get("items").size());
assertFalse(node.has("allItems"));
// 空载荷反序列化安全
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(json, SimilarAsinParsedPayloadDto.class);
assertNotNull(payload.getItems());
assertEquals(0, payload.getItems().size());
}
@Test
void test_task_002_parsed_payload_boundary_single_item() throws Exception {
List<SimilarAsinParsedRowVo> rows = rows(1);
String json = buildPayloadJson(rows, groups(rows));
JsonNode node = MAPPER.readTree(json);
assertEquals(1, node.get("items").size());
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(json, SimilarAsinParsedPayloadDto.class);
assertEquals(1, payload.getItems().size());
assertEquals("uploads/20260829/base.xlsx::row::1", payload.getItems().get(0).getRowToken());
}
@Test
void test_task_002_parsed_payload_boundary_limit_and_overflow() throws Exception {
// null 行集合:items 输出为空数组而非 NPE/崩溃
String json = buildPayloadJson(null, null);
JsonNode node = MAPPER.readTree(json);
assertTrue(node.has("items"));
assertEquals(0, node.get("items").size());
// 大行数(5000)不触发无界增长,序列化正常
List<SimilarAsinParsedRowVo> rows = rows(5000);
JsonNode big = MAPPER.readTree(buildPayloadJson(rows, groups(rows)));
assertEquals(5000, big.get("items").size());
assertFalse(big.has("allItems"));
}
@Test
void test_task_002_parsed_payload_invalid_input_rejected() throws Exception {
// 旧格式 JSON(含 allItems)反序列化:allItems 键被吸收进 items,不丢失行,不抛异常
String legacyJson = "{\"aiPrompt\":\"p\",\"apiKey\":\"k\",\"imgSwitch\":false,\"categorySwitch\":false,"
+ "\"sourceFiles\":[],\"headers\":[],"
+ "\"items\":[{\"rowToken\":\"t1\",\"asin\":\"B0CJ8SNXXV\"}],"
+ "\"allItems\":[{\"rowToken\":\"t1\",\"asin\":\"B0CJ8SNXXV\"},{\"rowToken\":\"t2\",\"asin\":\"B0TEST1234\"}],"
+ "\"groups\":[]}";
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(legacyJson, SimilarAsinParsedPayloadDto.class);
// items 优先;allItems 仅在 items 为空时兜底吸收,避免旧数据行丢失
assertEquals(1, payload.getItems().size());
}
@Test
void test_task_002_parsed_payload_dependency_failure_releases_resources() throws Exception {
// 序列化器故障:抛项目约定异常(BusinessException),不产生部分结果
ObjectMapper broken = new ObjectMapper() {
@Override
public String writeValueAsString(Object value) {
throw new IllegalStateException("serializer down");
}
};
SimilarAsinTaskService failingService = Mockito.mock(SimilarAsinTaskService.class, CALLS_REAL_METHODS);
ReflectionTestUtils.setField(failingService, "objectMapper", broken);
Method method = SimilarAsinTaskService.class.getDeclaredMethod(
"buildParsedPayloadJson",
String.class, String.class, Boolean.class, Boolean.class,
List.class, List.class, List.class, List.class);
method.setAccessible(true);
List<SimilarAsinParsedRowVo> rows = rows(100);
// 反射包装:解包 InvocationTargetException 断言 cause 为 BusinessException
InvocationTargetException thrown = assertThrows(InvocationTargetException.class, () -> method.invoke(failingService,
"p", "k", Boolean.FALSE, Boolean.FALSE, List.of(), List.of(), groups(rows), rows));
assertTrue(thrown.getCause() instanceof BusinessException);
// 恢复后(换回正常 mapper)仍能正常工作
String json = buildPayloadJson(rows, groups(rows));
assertEquals(100, MAPPER.readTree(json).get("items").size());
}
}
@@ -1,253 +0,0 @@
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.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.InjectMocks;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.springframework.test.util.ReflectionTestUtils;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/**
* Task 5:预览行数量增加配置边界、空文件和超限输入校验。
* 预览上限从硬编码常量改为配置驱动,并对配置值做 clamp 边界保护。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServicePreviewConfigTest {
private static final AtomicLong NEXT_ID = new AtomicLong(20000);
@Mock private LocalFileStorageService localFileStorageService;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@InjectMocks private SimilarAsinTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
}
@BeforeEach
void setUp() {
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenReturn("rustfs:task-parsed/similar-asin/20000/payload.json");
lenient().doAnswer(invocation -> {
FileTaskEntity task = invocation.getArgument(0);
task.setId(NEXT_ID.incrementAndGet());
return 1;
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
}
@AfterEach
void shutdown() {
service.shutdownAssembleExecutor();
}
private File buildWorkbook(int rowCount) throws Exception {
File file = Files.createTempFile("similar-asin-preview-config-", ".xlsx").toFile();
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
var sheet = workbook.createSheet("Sheet1");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("id");
header.createCell(1).setCellValue("asin");
header.createCell(2).setCellValue("国家");
for (int i = 1; i <= rowCount; i++) {
Row row = sheet.createRow(i);
row.createCell(0).setCellValue(String.valueOf(i));
row.createCell(1).setCellValue(String.format("B0CFG%05d", i));
row.createCell(2).setCellValue("英国");
}
workbook.write(fos);
}
return file;
}
private SimilarAsinParseRequest request(String fileKey) {
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
request.setUserId(7L);
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
sourceFile.setFileKey(fileKey);
sourceFile.setOriginalFilename("config.xlsx");
request.setFiles(List.of(sourceFile));
request.setApiKey("sk-123");
request.setImgSwitch(Boolean.FALSE);
request.setCategorySwitch(Boolean.FALSE);
return request;
}
private SimilarAsinParseVo parse(File workbook, String fileKey) {
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
return service.parseAndCreateTask(request(fileKey));
}
@Test
void test_task_005_preview_row_count_normal_default_path() throws Exception {
// 默认配置 100:150 行文件返回 100 预览行
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
File workbook = buildWorkbook(150);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-default.xlsx");
assertEquals(100, vo.getItems().size());
assertEquals(150, vo.getAcceptedRows());
// 预览行从第 1 行开始
assertEquals("B0CFG00001", vo.getItems().get(0).getAsin());
}
@Test
void test_task_005_preview_row_count_normal_multiple_items() throws Exception {
// 配置 50:500 行文件返回 50 预览行
when(properties.getParseResponsePreviewLimit()).thenReturn(50);
File workbook = buildWorkbook(500);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-50.xlsx");
assertEquals(50, vo.getItems().size());
assertEquals(500, vo.getAcceptedRows());
// groups 同样按配置裁剪
assertTrue(vo.getGroups().size() <= 50);
// 配置 200:300 行文件返回 200 预览行
when(properties.getParseResponsePreviewLimit()).thenReturn(200);
SimilarAsinParseVo vo2 = parse(buildWorkbook(300), "uploads/20260829/cfg-200.xlsx");
assertEquals(200, vo2.getItems().size());
}
@Test
void test_task_005_preview_row_count_normal_repeated_operation_is_idempotent() throws Exception {
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
File workbook = buildWorkbook(250);
SimilarAsinParseVo first = parse(workbook, "uploads/20260829/cfg-idem.xlsx");
SimilarAsinParseVo second = parse(workbook, "uploads/20260829/cfg-idem.xlsx");
assertEquals(first.getItems().size(), second.getItems().size());
for (int i = 0; i < first.getItems().size(); i++) {
assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin());
}
}
@Test
void test_task_005_preview_row_count_boundary_empty_input() throws Exception {
// 空文件(无有效数据行):抛业务异常
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
File workbook = buildWorkbook(0);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/cfg-empty.xlsx")).thenReturn(workbook);
assertThrows(BusinessException.class, () -> parse(workbook, "uploads/20260829/cfg-empty.xlsx"));
}
@Test
void test_task_005_preview_row_count_boundary_single_item() throws Exception {
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
File workbook = buildWorkbook(1);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-single.xlsx");
assertEquals(1, vo.getItems().size());
assertEquals("B0CFG00001", vo.getItems().get(0).getAsin());
}
@Test
void test_task_005_preview_row_count_boundary_limit_and_overflow() throws Exception {
// 配置值超过最大上限(1000):clamp 到上限,不发生无界内存增长
when(properties.getParseResponsePreviewLimit()).thenReturn(50000);
File workbook = buildWorkbook(2000);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-over.xlsx");
assertEquals(1000, vo.getItems().size(), "超限配置必须 clamp 到最大允许值");
assertEquals(2000, vo.getAcceptedRows());
// 配置值恰好等于上限:返回 1000 预览行
when(properties.getParseResponsePreviewLimit()).thenReturn(1000);
SimilarAsinParseVo vo2 = parse(buildWorkbook(1000), "uploads/20260829/cfg-exact.xlsx");
assertEquals(1000, vo2.getItems().size());
}
@Test
void test_task_005_preview_row_count_invalid_input_rejected() throws Exception {
// 配置为 0/负数:回退到默认值 100,不抛异常不崩溃
when(properties.getParseResponsePreviewLimit()).thenReturn(0, -5);
File workbook = buildWorkbook(300);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-zero.xlsx");
assertEquals(100, vo.getItems().size());
SimilarAsinParseVo vo2 = parse(buildWorkbook(300), "uploads/20260829/cfg-neg.xlsx");
assertEquals(100, vo2.getItems().size());
// 配置缺失(fresh mock 未 stubint 默认 0):同样回退默认
SimilarAsinProperties missing = org.mockito.Mockito.mock(SimilarAsinProperties.class);
lenient().when(missing.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(missing.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(missing.getResultFileTimeoutMinutes()).thenReturn(90);
ReflectionTestUtils.setField(service, "properties", missing);
SimilarAsinParseVo vo3 = parse(buildWorkbook(300), "uploads/20260829/cfg-missing.xlsx");
assertEquals(100, vo3.getItems().size());
}
@Test
void test_task_005_preview_row_count_dependency_failure_releases_resources() throws Exception {
// 存储失败抛异常;恢复后解析正常
when(properties.getParseResponsePreviewLimit()).thenReturn(100);
File workbook = buildWorkbook(120);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/cfg-fail.xlsx")).thenReturn(workbook);
when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenThrow(new IllegalStateException("rustfs down"))
.thenReturn("rustfs:task-parsed/similar-asin/20001/payload.json");
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/cfg-fail.xlsx"));
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/cfg-recovered.xlsx");
assertEquals(120, vo.getAcceptedRows());
assertEquals(100, vo.getItems().size());
// 配置对象默认值校验:新实例默认 100,处于 [1, 1000] 边界内
SimilarAsinProperties defaults = new SimilarAsinProperties();
assertNotNull(defaults.getParseResponsePreviewLimit());
int previewLimit = defaults.getParseResponsePreviewLimit();
assertTrue(previewLimit >= 1 && previewLimit <= 1000,
"默认预览上限必须在 [1, 1000] 内,实际 " + previewLimit);
}
}
@@ -1,210 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 3:保留旧 payload 读取兼容逻辑,验证新旧结构均可恢复全量行。
* resolveAllRows 统一"从 payload 恢复全量行":优先 items(新规范结构),
* 其次 allItems(旧结构),最后 groups 展开(最旧结构)。
*/
class SimilarAsinTaskServiceResolveAllRowsTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static SimilarAsinParsedRowVo row(String fileKey, int index) {
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
row.setSourceFileKey(fileKey);
row.setSourceFilename("base.xlsx");
row.setRowIndex(index);
row.setSourceId(String.valueOf(index));
row.setDisplayId(String.valueOf(index));
row.setRowToken(fileKey + "::row::" + index);
row.setAsin("B0CJ8SNXXV");
row.setCountry("英国");
return row;
}
private static List<SimilarAsinParsedRowVo> rows(String fileKey, int count) {
List<SimilarAsinParsedRowVo> result = new ArrayList<>();
for (int i = 1; i <= count; i++) {
result.add(row(fileKey, i));
}
return result;
}
private static SimilarAsinParsedGroupVo group(List<SimilarAsinParsedRowVo> items) {
SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo();
group.setSourceFileKey("uploads/20260829/base.xlsx");
group.setSourceFilename("base.xlsx");
group.setGroupKey("uploads/20260829/base.xlsx::1@1");
group.setBaseId("1");
group.setDisplayId("1");
group.setItemCount(items.size());
group.setItems(new ArrayList<>(items));
return group;
}
@Test
void test_task_003_payload_normal_default_path() {
// 新结构:items 有值 → 恢复全量行
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
List<SimilarAsinParsedRowVo> items = rows("uploads/20260829/base.xlsx", 100);
payload.setItems(items);
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(payload);
assertEquals(100, restored.size());
assertEquals("uploads/20260829/base.xlsx::row::1", restored.get(0).getRowToken());
}
@Test
void test_task_003_payload_normal_multiple_items() {
// 新结构 1000 行:不丢失且顺序稳定
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
List<SimilarAsinParsedRowVo> items = rows("uploads/20260829/multi.xlsx", 1000);
payload.setItems(items);
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(payload);
assertEquals(1000, restored.size());
for (int i = 0; i < restored.size(); i++) {
assertEquals(i + 1, restored.get(i).getRowIndex());
}
// 旧结构 1000 行:allItems 全量恢复
SimilarAsinParsedPayloadDto legacy = new SimilarAsinParsedPayloadDto();
legacy.setAllItems(items);
List<SimilarAsinParsedRowVo> restoredLegacy = SimilarAsinTaskService.resolveAllRows(legacy);
assertEquals(1000, restoredLegacy.size());
assertEquals("uploads/20260829/multi.xlsx::row::1", restoredLegacy.get(0).getRowToken());
}
@Test
void test_task_003_payload_normal_repeated_operation_is_idempotent() {
// 重复调用返回相同行集合(不修改 payload 本身)
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
List<SimilarAsinParsedRowVo> items = rows("uploads/20260829/idem.xlsx", 50);
payload.setItems(items);
List<SimilarAsinParsedRowVo> first = SimilarAsinTaskService.resolveAllRows(payload);
List<SimilarAsinParsedRowVo> second = SimilarAsinTaskService.resolveAllRows(payload);
assertEquals(first.size(), second.size());
for (int i = 0; i < first.size(); i++) {
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
}
// payload 未被修改:items 仍 50 行
assertEquals(50, payload.getItems().size());
}
@Test
void test_task_003_payload_boundary_empty_input() {
// 空 payload:返回空列表而非 null,不创建无效资源
SimilarAsinParsedPayloadDto empty = new SimilarAsinParsedPayloadDto();
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(empty);
assertNotNull(restored);
assertEquals(0, restored.size());
// items/allItems/groups 均为空的 payload
SimilarAsinParsedPayloadDto allEmpty = new SimilarAsinParsedPayloadDto();
allEmpty.setItems(List.of());
allEmpty.setAllItems(List.of());
allEmpty.setGroups(List.of());
assertEquals(0, SimilarAsinTaskService.resolveAllRows(allEmpty).size());
}
@Test
void test_task_003_payload_boundary_single_item() {
// 单行新结构
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
payload.setItems(rows("uploads/20260829/single.xlsx", 1));
assertEquals(1, SimilarAsinTaskService.resolveAllRows(payload).size());
// 单行旧结构(仅 groups
SimilarAsinParsedPayloadDto legacy = new SimilarAsinParsedPayloadDto();
legacy.setGroups(List.of(group(rows("uploads/20260829/single.xlsx", 1))));
assertEquals(1, SimilarAsinTaskService.resolveAllRows(legacy).size());
}
@Test
void test_task_003_payload_boundary_limit_and_overflow() {
// 旧结构 allItems 5000 行全量恢复,不丢失
SimilarAsinParsedPayloadDto legacy = new SimilarAsinParsedPayloadDto();
legacy.setAllItems(rows("uploads/20260829/max.xlsx", 5000));
assertEquals(5000, SimilarAsinTaskService.resolveAllRows(legacy).size());
// items 与 allItems 同时存在:以 items 为准(新规范结构优先),不重复
SimilarAsinParsedPayloadDto both = new SimilarAsinParsedPayloadDto();
both.setItems(rows("uploads/20260829/both.xlsx", 10));
both.setAllItems(rows("uploads/20260829/both.xlsx", 20));
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(both);
assertEquals(10, restored.size());
// groups 也同时存在:仍以 items 为准
both.setGroups(List.of(group(rows("uploads/20260829/both.xlsx", 30))));
assertEquals(10, SimilarAsinTaskService.resolveAllRows(both).size());
}
@Test
void test_task_003_payload_invalid_input_rejected() {
// null payload:安全返回空列表(调用方容忍),不抛 NPE
assertEquals(0, SimilarAsinTaskService.resolveAllRows(null).size());
// groups 中含 null 元素:跳过不抛异常
SimilarAsinParsedPayloadDto messy = new SimilarAsinParsedPayloadDto();
List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
groups.add(null);
groups.add(group(rows("uploads/20260829/messy.xlsx", 5)));
messy.setGroups(groups);
assertEquals(5, SimilarAsinTaskService.resolveAllRows(messy).size());
// group.items 为 null:跳过该组
SimilarAsinParsedGroupVo nullItemsGroup = new SimilarAsinParsedGroupVo();
nullItemsGroup.setItems(null);
SimilarAsinParsedPayloadDto nullItems = new SimilarAsinParsedPayloadDto();
nullItems.setGroups(List.of(nullItemsGroup, group(rows("uploads/20260829/n2.xlsx", 3))));
assertEquals(3, SimilarAsinTaskService.resolveAllRows(nullItems).size());
}
@Test
void test_task_003_payload_dependency_failure_releases_resources() throws Exception {
// 旧格式 JSON(只有 allItems)反序列化 → hydrate 后 resolveAllRows 恢复全量行
String legacyJson = "{\"aiPrompt\":\"p\",\"apiKey\":\"k\",\"imgSwitch\":false,\"categorySwitch\":false,"
+ "\"sourceFiles\":[],\"headers\":[],\"groups\":[],"
+ "\"allItems\":[{\"rowToken\":\"t1\",\"asin\":\"B0AAA00001\",\"sourceFileKey\":\"uploads/20260829/a.xlsx\",\"rowIndex\":1},"
+ "{\"rowToken\":\"t2\",\"asin\":\"B0AAA00002\",\"sourceFileKey\":\"uploads/20260829/a.xlsx\",\"rowIndex\":2}]}";
SimilarAsinParsedPayloadDto payload = MAPPER.readValue(legacyJson, SimilarAsinParsedPayloadDto.class);
// 未 hydrate 时:allItems 恢复(items 为空走 allItems
List<SimilarAsinParsedRowVo> fromLegacy = SimilarAsinTaskService.resolveAllRows(payload);
assertEquals(2, fromLegacy.size());
assertEquals("t1", fromLegacy.get(0).getRowToken());
assertEquals("B0AAA00002", fromLegacy.get(1).getAsin());
// 新格式 JSON(只有 items)反序列化 → 直接恢复
String newJson = "{\"aiPrompt\":\"p\",\"apiKey\":\"k\",\"imgSwitch\":false,\"categorySwitch\":false,"
+ "\"sourceFiles\":[],\"headers\":[],"
+ "\"items\":[{\"rowToken\":\"n1\",\"asin\":\"B0NEW00001\",\"sourceFileKey\":\"uploads/20260829/b.xlsx\",\"rowIndex\":1}],"
+ "\"groups\":[]}";
SimilarAsinParsedPayloadDto newPayload = MAPPER.readValue(newJson, SimilarAsinParsedPayloadDto.class);
List<SimilarAsinParsedRowVo> fromNew = SimilarAsinTaskService.resolveAllRows(newPayload);
assertEquals(1, fromNew.size());
assertEquals("n1", fromNew.get(0).getRowToken());
// 两种格式行数之和互不影响,恢复结果稳定
assertTrue(fromLegacy.size() == 2 && fromNew.size() == 1);
}
@Test
void test_task_003_payload_normal_groups_expansion_preserves_order() {
// 最旧结构:仅 groups 嵌套行,展开后顺序稳定(按组、组内原序)
SimilarAsinParsedPayloadDto legacy = new SimilarAsinParsedPayloadDto();
List<SimilarAsinParsedRowVo> g1 = rows("uploads/20260829/g.xlsx", 2);
List<SimilarAsinParsedRowVo> g2 = rows("uploads/20260829/g.xlsx", 3);
// 组内行号各自独立从 1 开始(真实解析语义),第二组用不同 fileKey 区分来源
List<SimilarAsinParsedRowVo> g2b = rows("uploads/20260829/g2.xlsx", 3);
legacy.setGroups(List.of(group(g1), group(g2b)));
List<SimilarAsinParsedRowVo> restored = SimilarAsinTaskService.resolveAllRows(legacy);
assertEquals(5, restored.size());
assertEquals("uploads/20260829/g.xlsx::row::1", restored.get(0).getRowToken());
assertEquals("uploads/20260829/g.xlsx::row::2", restored.get(1).getRowToken());
assertEquals("uploads/20260829/g2.xlsx::row::1", restored.get(2).getRowToken());
assertEquals("uploads/20260829/g2.xlsx::row::2", restored.get(3).getRowToken());
assertEquals("uploads/20260829/g2.xlsx::row::3", restored.get(4).getRowToken());
}
}
@@ -1,321 +0,0 @@
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.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 11Coze 结果合并的重复检测从 O(n²) 改为 HashSet/稳定 row key。
* dedupeRowsByRowKey 用 HashSet 按稳定 rowKey 一次性去重(保留顺序),
* mergeCozeRowsIntoChunk 合并前先去重,消除重复行逐行重复处理。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceRowKeyDedupeTest {
private static final AtomicLong NEXT_ID = new AtomicLong(70000);
@Mock private LocalFileStorageService localFileStorageService;
@Mock private com.nanri.aiimage.modules.file.service.oss.OssStorageService ossStorageService;
@Mock private com.nanri.aiimage.config.StorageProperties storageProperties;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
@Mock private com.nanri.aiimage.modules.task.service.TaskDistributedLockService taskDistributedLockService;
@Mock private com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService taskProgressSnapshotService;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@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 SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@InjectMocks private SimilarAsinTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
}
@BeforeEach
void setUp() {
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenReturn("rustfs:task-parsed/similar-asin/70000/payload.json");
lenient().doAnswer(invocation -> {
FileTaskEntity task = invocation.getArgument(0);
task.setId(NEXT_ID.incrementAndGet());
return 1;
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
}
@AfterEach
void shutdown() {
service.shutdownAssembleExecutor();
}
private static SimilarAsinResultRowDto row(String rowToken, String id, String asin, String country) {
SimilarAsinResultRowDto r = new SimilarAsinResultRowDto();
r.setRowToken(rowToken);
r.setId(id);
r.setAsin(asin);
r.setCountry(country);
return r;
}
private static String rowsJson(List<SimilarAsinResultRowDto> rows) throws Exception {
return new ObjectMapper().writeValueAsString(rows);
}
private static TaskChunkEntity chunk(Long id, String scopeHash, Integer chunkIndex, String payloadJson) {
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setId(id);
chunk.setTaskId(7004L);
chunk.setModuleType(SimilarAsinTaskService.MODULE_TYPE);
chunk.setScopeHash(scopeHash);
chunk.setChunkIndex(chunkIndex);
chunk.setPayloadJson(payloadJson);
return chunk;
}
private void stubSingleChunkMerge(TaskChunkEntity chunk, String payloadJson, AtomicLong storedCounter) throws Exception {
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
when(transientPayloadStorageService.resolvePayload(eq(chunk.getPayloadJson()), anyString()))
.thenReturn(payloadJson);
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenAnswer(invocation -> {
storedCounter.incrementAndGet();
return "stored:" + invocation.getArgument(2);
});
when(taskChunkMapper.selectOne(any())).thenReturn(chunk);
when(taskChunkMapper.update(any(), any())).thenReturn(1);
}
@Test
void test_task_011_merge_row_key_normal_default_path() throws Exception {
// 正常输入:llmRows 含同一 rowKey 的重复行,merge 前按稳定 rowKey 去重,
// chunk payload 只写一次,结果行不重复。
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
AtomicLong storedCounter = new AtomicLong(0);
stubSingleChunkMerge(chunk, rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"))), storedCounter);
FileTaskEntity task = new FileTaskEntity();
task.setId(7004L);
List<SimilarAsinResultRowDto> llmRows = List.of(
row("r1", "1", "B0A0000001", "英国"),
row("r1", "1", "B0A0000001", "英国"));
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(llmRows);
assertEquals(1, deduped.size(), "重复行必须按稳定 rowKey 去重");
assertEquals("r1", deduped.get(0).getRowToken());
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, llmRows, Map.of());
assertEquals(1, storedCounter.get(), "去重后 chunk 只写一次");
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
}
@Test
void test_task_011_merge_row_key_normal_multiple_items() throws Exception {
// 批量场景:多个重复行跨 chunk 分组,去重后顺序稳定、结果不丢失
TaskChunkEntity chunkA = chunk(1L, "hashA", 1, "ptr:chunk-A");
TaskChunkEntity chunkB = chunk(2L, "hashB", 2, "ptr:chunk-B");
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunkA, chunkB));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenReturn(rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"))));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-B"), anyString()))
.thenReturn(rowsJson(List.of(row("r2", "2", "B0A0000002", "英国"))));
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenAnswer(invocation -> "stored:" + invocation.getArgument(2));
AtomicLong selectOneRound = new AtomicLong(0);
when(taskChunkMapper.selectOne(any())).thenAnswer(invocation ->
selectOneRound.getAndIncrement() == 0 ? chunkA : chunkB);
when(taskChunkMapper.update(any(), any())).thenReturn(1);
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int i = 0; i < 3; i++) {
llmRows.add(row("r1", "1", "B0A0000001", "英国"));
llmRows.add(row("r2", "2", "B0A0000002", "英国"));
}
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("mergeLlmRowsIntoChunk",
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
merge.setAccessible(true);
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> llmRows = List.of(
row("r1", "1", "B0A0000001", "英国"),
row("r2", "2", "B0A0000002", "英国"),
row("r1", "1", "B0A0000001", "英国"));
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());
assertEquals(first.get(i).getAsin(), second.get(i).getAsin());
}
}
@Test
void test_task_011_merge_row_key_boundary_empty_input() {
// 空输入:null/空列表安全返回空结果,不创建无效资源
assertNotNull(service.dedupeRowsByRowKey(null));
assertTrue(service.dedupeRowsByRowKey(null).isEmpty());
assertTrue(service.dedupeRowsByRowKey(List.of()).isEmpty());
// null 元素:跳过不抛异常
List<SimilarAsinResultRowDto> withNull = new ArrayList<>();
withNull.add(null);
withNull.add(row("r1", "1", "B0A0000001", "英国"));
assertEquals(1, service.dedupeRowsByRowKey(withNull).size());
}
@Test
void test_task_011_merge_row_key_boundary_single_item() {
// 单行:不依赖批量路径,去重后结果正确
List<SimilarAsinResultRowDto> single = List.of(row("r1", "1", "B0A0000001", "英国"));
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(single);
assertEquals(1, deduped.size());
assertEquals("r1", deduped.get(0).getRowToken());
}
@Test
void test_task_011_merge_row_key_boundary_limit_and_overflow() {
// 大批量:1000 行全部重复,去重后只剩 1 个唯一行,无无界内存增长
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
llmRows.add(row("r1", "1", "B0A0000001", "英国"));
}
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(llmRows);
assertEquals(1, deduped.size());
// 1000 行唯一:全部保留且顺序稳定
List<SimilarAsinResultRowDto> unique = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
unique.add(row("r" + String.format("%04d", i), String.valueOf(i), "B0U" + String.format("%06d", i), "英国"));
}
List<SimilarAsinResultRowDto> dedupedUnique = service.dedupeRowsByRowKey(unique);
assertEquals(1000, dedupedUnique.size());
assertEquals("r0001", dedupedUnique.get(1).getRowToken());
}
@Test
void test_task_011_merge_row_key_invalid_input_rejected() {
// 稳定 rowKey 冲突:rowToken 为空时用 legacy keyid::ASIN::country)识别重复
List<SimilarAsinResultRowDto> noToken = List.of(
row("", "1", "B0A0000001", "英国"),
row("", "1", "b0a0000001", " 英国 "));
List<SimilarAsinResultRowDto> deduped = service.dedupeRowsByRowKey(noToken);
assertEquals(1, deduped.size(), "legacy key 归一化(ASIN 大写、country trim)后应识别为同一行");
// 不同 ASIN:不误判为重复
List<SimilarAsinResultRowDto> diffAsin = List.of(
row("", "1", "B0A0000001", "英国"),
row("", "2", "B0A0000002", "英国"));
assertEquals(2, service.dedupeRowsByRowKey(diffAsin).size());
}
@Test
void test_task_011_merge_row_key_dependency_failure_releases_resources() throws Exception {
// chunk payload 读取失败:抛可识别业务异常且不写 chunk;
// 依赖恢复后重试成功,去重路径无残留状态
TaskChunkEntity chunk = chunk(1L, "hashA", 1, "ptr:chunk-A");
when(taskChunkMapper.selectList(any())).thenReturn(List.of(chunk));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenThrow(new IllegalStateException("rustfs down"));
FileTaskEntity task = new FileTaskEntity();
task.setId(7004L);
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
merge.setAccessible(true);
Exception ex = assertThrows(Exception.class, () -> {
try {
merge.invoke(service, task, null, null,
List.of(row("r1", "1", "B0A0000001", "英国"), row("r1", "1", "B0A0000001", "英国")), Map.of());
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
});
assertTrue(ex.getMessage() != null && ex.getMessage().contains("chunk"),
"chunk 读取失败消息必须可识别,实际: " + ex.getMessage());
verify(transientPayloadStorageService, times(0)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
// 恢复后重试成功:只写一次,无重复记录
AtomicLong storedCounter = new AtomicLong(0);
stubSingleChunkMerge(chunk, rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"))), storedCounter);
merge.invoke(service, task, null, null,
List.of(row("r1", "1", "B0A0000001", "英国"), row("r1", "1", "B0A0000001", "英国")), Map.of());
assertEquals(1, storedCounter.get());
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
}
}
@@ -1,372 +0,0 @@
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.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import com.nanri.aiimage.modules.similarasin.service.SimilarAsinImagePrefetchService;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Task 10:为 chunk 结果建立按 row key 的批量索引,消除跨 chunk 线性扫描。
* indexRowsByChunkKey 把每个 chunk 的行索引到 rowKey→chunkKeyllm 行归属从
* O(rows×chunks) 降为 O(1) 查找;assignLlmRowsToChunks 基于索引分配行并保留
* 原有命中/fallback/orphan 语义;集成用例验证每个 chunk 只读一次 payload。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceRowKeyIndexTest {
private static final AtomicLong NEXT_ID = new AtomicLong(60000);
@Mock private LocalFileStorageService localFileStorageService;
@Mock private com.nanri.aiimage.modules.file.service.oss.OssStorageService ossStorageService;
@Mock private com.nanri.aiimage.config.StorageProperties storageProperties;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private com.nanri.aiimage.modules.task.service.TaskFileJobService taskFileJobService;
@Mock private com.nanri.aiimage.modules.task.service.TaskDistributedLockService taskDistributedLockService;
@Mock private com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService taskProgressSnapshotService;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@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 SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@InjectMocks private SimilarAsinTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
}
@BeforeEach
void setUp() {
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenReturn("rustfs:task-parsed/similar-asin/60000/payload.json");
lenient().doAnswer(invocation -> {
FileTaskEntity task = invocation.getArgument(0);
task.setId(NEXT_ID.incrementAndGet());
return 1;
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
}
@AfterEach
void shutdown() {
service.shutdownAssembleExecutor();
}
private static SimilarAsinResultRowDto row(String rowToken, String id, String asin, String country) {
SimilarAsinResultRowDto r = new SimilarAsinResultRowDto();
r.setRowToken(rowToken);
r.setId(id);
r.setAsin(asin);
r.setCountry(country);
return r;
}
private static String rowsJson(List<SimilarAsinResultRowDto> rows) throws Exception {
return new ObjectMapper().writeValueAsString(rows);
}
private static TaskChunkEntity chunk(Long id, String scopeHash, Integer chunkIndex, String payloadJson) {
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setId(id);
chunk.setTaskId(7004L);
chunk.setModuleType(SimilarAsinTaskService.MODULE_TYPE);
chunk.setScopeHash(scopeHash);
chunk.setChunkIndex(chunkIndex);
chunk.setPayloadJson(payloadJson);
return chunk;
}
/** 构造 rowsByChunkchunkStorageKey(scopeHash, chunkIndex) → rowKey 行表。 */
private static Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunkOf(String scopeHash, Integer chunkIndex, List<SimilarAsinResultRowDto> rows) {
Map<String, Map<String, SimilarAsinResultRowDto>> map = new LinkedHashMap<>();
Map<String, SimilarAsinResultRowDto> byKey = new LinkedHashMap<>();
for (SimilarAsinResultRowDto row : rows) {
byKey.put(row.getRowToken(), row);
}
map.put(scopeHash + ":" + chunkIndex, byKey);
return map;
}
private static List<String> assignedRowKeys(Map<String, Map<String, SimilarAsinResultRowDto>> merged) {
List<String> keys = new ArrayList<>();
for (Map<String, SimilarAsinResultRowDto> rows : merged.values()) {
for (String key : rows.keySet()) {
keys.add(key);
}
}
return keys;
}
@Test
void test_task_010_chunk_row_key_normal_default_path() throws Exception {
// 正常输入:2 个 chunk 各含行,llm 回传行按 rowKey 命中各自 chunk
// 每个 chunk 的 payload 只被读取一次(索引建立),消除跨 chunk 线性扫描。
List<TaskChunkEntity> chunks = List.of(
chunk(1L, "hashA", 1, "ptr:chunk-A"),
chunk(2L, "hashB", 2, "ptr:chunk-B"));
when(taskChunkMapper.selectList(any())).thenReturn(chunks);
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenReturn(rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"), row("r2", "2", "B0A0000002", "英国"))));
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-B"), anyString()))
.thenReturn(rowsJson(List.of(row("r3", "3", "B0A0000003", "美国"))));
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenAnswer(invocation -> "stored:" + invocation.getArgument(2));
AtomicInteger selectOneRound = new AtomicInteger(0);
when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> {
int i = selectOneRound.getAndIncrement();
return chunks.get(Math.min(i, chunks.size() - 1));
});
when(taskChunkMapper.update(any(), any())).thenReturn(1);
FileTaskEntity task = new FileTaskEntity();
task.setId(7004L);
List<SimilarAsinResultRowDto> llmRows = List.of(row("r1", "1", "B0A0000001", "英国"), row("r3", "3", "B0A0000003", "美国"));
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, llmRows, Map.of());
verify(transientPayloadStorageService, times(6)).resolvePayload(anyString(), anyString());
verify(transientPayloadStorageService, times(2)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
verify(taskChunkMapper, times(2)).update(any(), any());
}
@Test
void test_task_010_chunk_row_key_normal_multiple_items() {
// 批量场景: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,
List.of(row("c" + c + "r1", "1", "B0B" + c + "000001", "英国"),
row("c" + c + "r2", "2", "B0B" + c + "000002", "英国"),
row("c" + c + "r3", "3", "B0B" + c + "000003", "美国"))));
}
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int c = 0; c < 3; c++) {
for (int r = 1; r <= 3; r++) {
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.assignLlmRowsToChunks(
rowsByChunk, llmRows, index, null, null, orphans);
assertEquals(3, merged.size());
assertEquals(9, assignedRowKeys(merged).size());
assertTrue(orphans.isEmpty(), "全部命中,不应产生 orphan");
for (Map<String, SimilarAsinResultRowDto> rows : merged.values()) {
assertEquals(3, rows.size());
}
}
@Test
void test_task_010_chunk_row_key_normal_repeated_operation_is_idempotent() {
// 重复执行同一输入:结果完全一致,不产生重复记录
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = rowsByChunkOf("hashA", 1,
List.of(row("r1", "1", "B0A0000001", "英国"), row("r2", "2", "B0A0000002", "英国")));
List<SimilarAsinResultRowDto> llmRows = List.of(row("r1", "1", "B0A0000001", "英国"));
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
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()) {
assertEquals(entry.getValue().keySet(), second.get(entry.getKey()).keySet());
}
}
@Test
void test_task_010_chunk_row_key_boundary_empty_input() {
// 空输入: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.assignLlmRowsToChunks(
Map.of(), List.of(), Map.of(), null, null, new ArrayList<>());
assertTrue(emptyAssign.isEmpty());
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.assignLlmRowsToChunks(
rowsByChunk, blankRow, index, null, null, orphans);
assertTrue(assignedRowKeys(merged).isEmpty());
assertEquals(1, orphans.size(), "全空行生成 legacy key :::: 不命中任何 chunk,按既有语义进 orphan");
}
@Test
void test_task_010_chunk_row_key_boundary_single_item() {
// 单 chunk 单行:不依赖批量路径,命中正确
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = rowsByChunkOf("hashA", 1,
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.assignLlmRowsToChunks(
rowsByChunk, List.of(row("r1", "1", "B0A0000001", "英国")), index, null, null, orphans);
assertEquals(1, merged.size());
assertEquals(List.of("r1"), assignedRowKeys(merged));
assertTrue(orphans.isEmpty());
// 索引也只含该行
assertEquals(1, index.size());
assertEquals("hashA:1", index.get("r1"));
}
@Test
void test_task_010_chunk_row_key_boundary_limit_and_overflow() {
// 大批量: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++) {
bigChunk.put("r" + String.format("%04d", i), row("r" + String.format("%04d", i), String.valueOf(i), "B0L" + String.format("%06d", i), "英国"));
}
rowsByChunk.put("hashBig:1", bigChunk);
Map<String, String> index = service.indexRowsByChunkKey(rowsByChunk);
assertEquals(1000, index.size());
List<SimilarAsinResultRowDto> llmRows = new ArrayList<>();
for (int i = 1; i <= 500; 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.assignLlmRowsToChunks(
rowsByChunk, llmRows, index, null, null, orphans);
assertEquals(1, merged.size());
assertEquals(500, assignedRowKeys(merged).size());
assertTrue(orphans.isEmpty());
}
@Test
void test_task_010_chunk_row_key_invalid_input_rejected() {
// 同一 rowKey 出现在多个 chunk:索引保留第一个 chunkputIfAbsent),行为确定
Map<String, Map<String, SimilarAsinResultRowDto>> rowsByChunk = new LinkedHashMap<>();
rowsByChunk.putAll(rowsByChunkOf("hashA", 1, List.of(row("dup", "1", "B0A0000001", "英国"))));
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 缺失:llm 行未命中且无有效 fallback → 进 orphan,不产生 merge
List<SimilarAsinResultRowDto> orphans = new ArrayList<>();
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());
// 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.assignLlmRowsToChunks(
rowsByChunk, withNull, index2, null, null, orphans2);
assertEquals(1, merged2.size());
assertEquals(List.of("dup"), assignedRowKeys(merged2));
assertTrue(orphans2.isEmpty());
}
@Test
void test_task_010_chunk_row_key_dependency_failure_releases_resources() throws Exception {
// chunk payload 读取失败:抛可识别业务异常且不产生部分 merge;
// 依赖恢复后重试成功,无残留状态
List<TaskChunkEntity> chunks = List.of(chunk(1L, "hashA", 1, "ptr:chunk-A"));
when(taskChunkMapper.selectList(any())).thenReturn(chunks);
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenThrow(new IllegalStateException("rustfs down"));
FileTaskEntity task = new FileTaskEntity();
task.setId(7004L);
Method merge = SimilarAsinTaskService.class.getDeclaredMethod("mergeLlmRowsIntoChunk",
FileTaskEntity.class, String.class, Integer.class, List.class, Map.class);
merge.setAccessible(true);
BusinessException ex = assertThrows(BusinessException.class, () -> {
try {
merge.invoke(service, task, null, null, List.of(row("r1", "1", "B0A0000001", "英国")), Map.of());
} catch (java.lang.reflect.InvocationTargetException e) {
throw e.getCause();
}
});
assertTrue(ex.getMessage() != null && ex.getMessage().contains("chunk"),
"chunk 读取失败消息必须可识别,实际: " + ex.getMessage());
verify(transientPayloadStorageService, times(0)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
// 恢复后重试成功:行合并到正确 chunk
when(transientPayloadStorageService.resolvePayload(eq("ptr:chunk-A"), anyString()))
.thenReturn(rowsJson(List.of(row("r1", "1", "B0A0000001", "英国"))));
when(transientPayloadStorageService.storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString()))
.thenReturn("stored:retry");
when(taskChunkMapper.selectOne(any())).thenReturn(chunks.get(0));
when(taskChunkMapper.update(any(), any())).thenReturn(1);
merge.invoke(service, task, null, null, List.of(row("r1", "1", "B0A0000001", "英国")), Map.of());
verify(transientPayloadStorageService, times(1)).storeChunkPayloadVersioned(anyString(), any(), anyString(), any(), anyString());
}
}
@@ -1,362 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.fasterxml.jackson.databind.ObjectMapper;
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.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
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;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSubmitResultRequest;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileResultEntity;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskChunkEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity;
import com.nanri.aiimage.modules.task.service.TaskDistributedLockService;
import com.nanri.aiimage.modules.task.service.TaskFileJobService;
import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.TransactionStatus;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceSubmitTest {
private static final Long TASK_ID = 21879L;
private static final String PARSED_POINTER = "rustfs:task-parsed/similar-asin/21879/payload.json";
private static final String CHUNK_POINTER = "rustfs:task-chunk/similar-asin/21879/chunk.json";
private static final String STORED_CHUNK_POINTER = "\"" + CHUNK_POINTER + "\"";
@Mock private LocalFileStorageService localFileStorageService;
@Mock private OssStorageService ossStorageService;
@Mock private StorageProperties storageProperties;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TaskFileJobService taskFileJobService;
@Mock private TaskDistributedLockService taskDistributedLockService;
@Mock private TaskProgressSnapshotService taskProgressSnapshotService;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private PlatformTransactionManager transactionManager;
@Mock private DistributedJobLockService distributedJobLockService;
@Mock private InstanceMetadata instanceMetadata;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@Mock private SimilarAsinImagePrefetchService imagePrefetchService;
@Mock private TransactionStatus transactionStatus;
@InjectMocks private SimilarAsinTaskService service;
private final AtomicBoolean transactionActive = new AtomicBoolean();
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
TableInfoHelper.initTableInfo(assistant, FileResultEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskChunkEntity.class);
TableInfoHelper.initTableInfo(assistant, TaskScopeStateEntity.class);
}
@BeforeEach
void setUpTransactionAndLock() {
lenient().when(instanceMetadata.getInstanceId()).thenReturn("instance-a");
lenient().when(taskDistributedLockService.acquire(
eq(SimilarAsinTaskService.MODULE_TYPE),
anyLong(),
any(Duration.class),
eq(10_000L)))
.thenReturn(mock(TaskDistributedLockService.LockHandle.class));
lenient().when(transactionManager.getTransaction(any(TransactionDefinition.class)))
.thenAnswer(invocation -> {
transactionActive.set(true);
return transactionStatus;
});
lenient().doAnswer(invocation -> {
transactionActive.set(false);
return null;
}).when(transactionManager).commit(transactionStatus);
lenient().doAnswer(invocation -> {
transactionActive.set(false);
return null;
}).when(transactionManager).rollback(transactionStatus);
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
}
@AfterEach
void shutdownExecutors() {
service.shutdownAssembleExecutor();
}
@Test
void doneCallbackReadsPayloadOnlyBeforeShortTransaction() throws Exception {
FileTaskEntity task = runningTask("instance-a");
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
configureNewChunkAndScope();
when(taskChunkMapper.selectCount(any())).thenReturn(1L);
List<Boolean> storageCallTransactionStates = new ArrayList<>();
when(transientPayloadStorageService.resolvePayload(eq(PARSED_POINTER), anyString()))
.thenAnswer(invocation -> {
storageCallTransactionStates.add(transactionActive.get());
return parsedPayloadJson(List.of(new SimilarAsinParsedRowVo(), new SimilarAsinParsedRowVo()));
});
when(transientPayloadStorageService.storeChunkPayloadVersioned(
eq(SimilarAsinTaskService.MODULE_TYPE), eq(TASK_ID), anyString(), eq(0), anyString()))
.thenAnswer(invocation -> {
storageCallTransactionStates.add(transactionActive.get());
return STORED_CHUNK_POINTER;
});
when(transientPayloadStorageService.wasLastStoreLocalFallback()).thenReturn(false);
doAnswer(invocation -> {
assertTrue(transactionActive.get());
FileResultEntity result = invocation.getArgument(0);
result.setId(501L);
return 1;
}).when(fileResultMapper).insert(any(FileResultEntity.class));
doAnswer(invocation -> {
storageCallTransactionStates.add(transactionActive.get());
return null;
}).when(taskCacheService).deleteTaskCache(TASK_ID);
service.submitResult(TASK_ID, request(true));
assertFalse(storageCallTransactionStates.isEmpty());
assertTrue(storageCallTransactionStates.stream().noneMatch(Boolean::booleanValue));
verify(transientPayloadStorageService).resolvePayload(eq(PARSED_POINTER), anyString());
ArgumentCaptor<FileResultEntity> resultCaptor = ArgumentCaptor.forClass(FileResultEntity.class);
verify(fileResultMapper).insert(resultCaptor.capture());
assertEquals(2, resultCaptor.getValue().getRowCount());
assertEquals("germany.xlsx", resultCaptor.getValue().getSourceFilename());
}
@Test
void unknownCommitOutcomeDoesNotDeletePossiblyCommittedChunk() throws Exception {
FileTaskEntity task = runningTask("instance-a");
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
configureNewChunkAndScope();
when(taskChunkMapper.selectCount(any())).thenReturn(0L);
configureChunkStore(false);
doAnswer(invocation -> {
transactionActive.set(false);
throw new IllegalStateException("commit ACK lost");
}).when(transactionManager).commit(transactionStatus);
IllegalStateException thrown = assertThrows(IllegalStateException.class,
() -> service.submitResult(TASK_ID, request(false)));
assertEquals("commit ACK lost", thrown.getMessage());
verify(transientPayloadStorageService, never()).extractPointer(anyString());
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
verify(taskCacheService, never()).touchTaskHeartbeat(TASK_ID);
}
@Test
void duplicateAlreadyPersistedChunkDoesNotRunPayloadCleanup() throws Exception {
FileTaskEntity task = runningTask("instance-a");
task.setResultJson("{\"ownerInstanceId\":\"instance-a\"}");
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
when(taskChunkMapper.selectOne(any())).thenReturn(chunk("\"rustfs:winner\""));
configureScopeStorage(null);
when(taskChunkMapper.selectCount(any())).thenReturn(1L);
service.submitResult(TASK_ID, request(false));
verify(transientPayloadStorageService, never()).extractPointer(anyString());
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
}
@Test
void localFallbackOwnerFailureRollsBackChunkAndKeepsCandidate() throws Exception {
FileTaskEntity task = runningTask(null);
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
configureNewChunkAndScope();
when(taskChunkMapper.selectCount(any())).thenReturn(1L);
configureChunkStore(true);
when(fileTaskMapper.updateById(any(FileTaskEntity.class))).thenReturn(0);
IllegalStateException thrown = assertThrows(IllegalStateException.class,
() -> service.submitResult(TASK_ID, request(false)));
assertTrue(thrown.getMessage().contains("Failed to bind local fallback task owner"));
verify(taskChunkMapper).insert(any(TaskChunkEntity.class));
verify(transactionManager).rollback(transactionStatus);
verify(transactionManager, never()).commit(transactionStatus);
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString());
}
@Test
void duplicateLocalCandidateIsNotBoundAndReferencedPayloadIsKept() throws Exception {
FileTaskEntity task = runningTask(null);
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
TaskChunkEntity winner = chunk(STORED_CHUNK_POINTER);
when(taskChunkMapper.selectOne(any())).thenReturn(null, winner);
configureScopeStorage(null);
configureChunkStore(true);
when(transientPayloadStorageService.extractPointer(STORED_CHUNK_POINTER)).thenReturn(CHUNK_POINTER);
when(taskChunkMapper.selectCount(any())).thenReturn(1L);
service.submitResult(TASK_ID, request(false));
verify(taskChunkMapper, never()).insert(any(TaskChunkEntity.class));
verify(fileTaskMapper, never()).updateById(any(FileTaskEntity.class));
verify(transientPayloadStorageService, never()).deletePayloadIfPresent(STORED_CHUNK_POINTER);
}
@Test
void repeatedNonFinalCallbackCannotClearCompletedScope() throws Exception {
FileTaskEntity task = runningTask("instance-a");
task.setResultJson("{\"ownerInstanceId\":\"instance-a\"}");
when(fileTaskMapper.selectById(TASK_ID)).thenReturn(task);
TaskChunkEntity existing = chunk("\"rustfs:winner\"");
when(taskChunkMapper.selectOne(any())).thenReturn(existing);
TaskScopeStateEntity scope = scope(1);
when(taskScopeStateMapper.selectOne(any())).thenReturn(scope);
when(taskChunkMapper.selectCount(any())).thenReturn(1L);
service.submitResult(TASK_ID, request(false));
assertEquals(1, scope.getCompleted());
verify(taskScopeStateMapper).updateById(scope);
verify(transientPayloadStorageService, never()).storeChunkPayloadVersioned(
anyString(), anyLong(), anyString(), any(), anyString());
}
private String parsedPayloadJson(List<SimilarAsinParsedRowVo> rows) throws Exception {
SimilarAsinParsedPayloadDto payload = new SimilarAsinParsedPayloadDto();
payload.setAllItems(rows);
payload.setItems(rows);
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
sourceFile.setFileKey("uploads/germany.xlsx");
sourceFile.setOriginalFilename("germany.xlsx");
payload.setSourceFiles(List.of(sourceFile));
return objectMapper.writeValueAsString(payload);
}
private void configureNewChunkAndScope() {
AtomicReference<TaskChunkEntity> chunkRef = new AtomicReference<>();
when(taskChunkMapper.selectOne(any())).thenAnswer(invocation -> chunkRef.get());
doAnswer(invocation -> {
TaskChunkEntity chunk = invocation.getArgument(0);
chunk.setId(301L);
chunkRef.set(chunk);
return 1;
}).when(taskChunkMapper).insert(any(TaskChunkEntity.class));
configureScopeStorage(null);
}
private void configureScopeStorage(TaskScopeStateEntity initial) {
AtomicReference<TaskScopeStateEntity> scopeRef = new AtomicReference<>(initial);
when(taskScopeStateMapper.selectOne(any())).thenAnswer(invocation -> scopeRef.get());
doAnswer(invocation -> {
TaskScopeStateEntity scope = invocation.getArgument(0);
scope.setId(401L);
scopeRef.set(scope);
return 1;
}).when(taskScopeStateMapper).insert(any(TaskScopeStateEntity.class));
}
private void configureChunkStore(boolean localFallback) {
when(transientPayloadStorageService.storeChunkPayloadVersioned(
eq(SimilarAsinTaskService.MODULE_TYPE), eq(TASK_ID), anyString(), eq(0), anyString()))
.thenReturn(STORED_CHUNK_POINTER);
when(transientPayloadStorageService.wasLastStoreLocalFallback()).thenReturn(localFallback);
}
private FileTaskEntity runningTask(String owner) throws Exception {
FileTaskEntity task = new FileTaskEntity();
task.setId(TASK_ID);
task.setModuleType(SimilarAsinTaskService.MODULE_TYPE);
task.setStatus("RUNNING");
task.setUserId(7L);
String resultJson = "{\"parsedPayloadRef\":\"" + PARSED_POINTER + "\"";
if (owner != null) {
resultJson += ",\"ownerInstanceId\":\"" + owner + "\"";
}
task.setResultJson(resultJson + "}");
return task;
}
private SimilarAsinSubmitResultRequest request(boolean done) {
SimilarAsinSubmitResultRequest request = new SimilarAsinSubmitResultRequest();
request.setSubmissionId("similar-asin-21879");
request.setChunkIndex(0);
request.setChunkTotal(1);
request.setDone(done);
return request;
}
private TaskChunkEntity chunk(String payload) {
TaskChunkEntity chunk = new TaskChunkEntity();
chunk.setId(301L);
chunk.setTaskId(TASK_ID);
chunk.setModuleType(SimilarAsinTaskService.MODULE_TYPE);
chunk.setScopeHash("existing-scope");
chunk.setChunkIndex(0);
chunk.setChunkTotal(1);
chunk.setPayloadJson(payload);
return chunk;
}
private TaskScopeStateEntity scope(int completed) {
TaskScopeStateEntity scope = new TaskScopeStateEntity();
scope.setId(401L);
scope.setTaskId(TASK_ID);
scope.setModuleType(SimilarAsinTaskService.MODULE_TYPE);
scope.setScopeKey("similar-asin-21879");
scope.setScopeHash("existing-scope");
scope.setChunkTotal(1);
scope.setCompleted(completed);
scope.setStateJson("{\"phase\":\"RECEIVED\",\"llm\":\"PENDING\"}");
return scope;
}
}
@@ -1,121 +0,0 @@
package com.nanri.aiimage.modules.similarasin.service;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Field;
import java.util.LinkedHashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SimilarAsinTaskServiceTest {
@Test
@SuppressWarnings("unchecked")
void resultWorkbookRestoresPriceAfterCountryAndShiftsImageColumns() throws Exception {
Field headersField = SimilarAsinTaskService.class.getDeclaredField("RESULT_HEADERS");
headersField.setAccessible(true);
List<String> headers = (List<String>) headersField.get(null);
assertEquals(List.of(
"id", "asin", "国家", "价格", "卖家名称", "品牌", "是否有货", "相似度",
"是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"
), headers);
assertEquals(12, staticIntField("IMG_COL_MAIN"));
assertEquals(13, staticIntField("IMG_COL_PUZZLE1"));
assertEquals(14, staticIntField("IMG_COL_PUZZLE2"));
}
@Test
void resultStatusUsesReturnedCozeDataAndImages() {
assertEquals("\u5931\u8d25", SimilarAsinTaskService.resolveResultStatus(null));
SimilarAsinResultRowDto empty = new SimilarAsinResultRowDto();
assertEquals("\u5931\u8d25", SimilarAsinTaskService.resolveResultStatus(empty));
SimilarAsinResultRowDto withImage = new SimilarAsinResultRowDto();
withImage.setStatus("FAILED");
withImage.setMainUrl("https://example.com/main.jpg");
assertEquals("\u6210\u529f", SimilarAsinTaskService.resolveResultStatus(withImage));
SimilarAsinResultRowDto withCozeStatus = new SimilarAsinResultRowDto();
withCozeStatus.setStatus("success");
assertEquals("\u6210\u529f", SimilarAsinTaskService.resolveResultStatus(withCozeStatus));
SimilarAsinResultRowDto failedStatus = new SimilarAsinResultRowDto();
failedStatus.setStatus("FAILED");
assertEquals("\u5931\u8d25", SimilarAsinTaskService.resolveResultStatus(failedStatus));
SimilarAsinResultRowDto withVisibleResultData = new SimilarAsinResultRowDto();
assertEquals("\u6210\u529f", SimilarAsinTaskService.resolveResultStatus(withVisibleResultData, "", "80%", "", "", ""));
}
@Test
void fileBuildProgressIsTerminalOnlyAfterTaskAndStageComplete() {
assertTrue(SimilarAsinTaskService.isTerminalFileBuildProgress("SUCCESS", 3, 3));
assertTrue(SimilarAsinTaskService.isTerminalFileBuildProgress("FAILED", 3, 3));
assertFalse(SimilarAsinTaskService.isTerminalFileBuildProgress("RUNNING", 3, 3));
assertFalse(SimilarAsinTaskService.isTerminalFileBuildProgress("SUCCESS", 2, 3));
}
@Test
void failedResultRowsWithBlankIsConformEnableCategoryRetry() {
SimilarAsinParsedRowVo blankCategory = new SimilarAsinParsedRowVo();
blankCategory.setValues(new LinkedHashMap<>());
blankCategory.getValues().put("status", "FAILED");
blankCategory.getValues().put("is_conform", "");
assertTrue(SimilarAsinTaskService.shouldEnableCategorySwitchForRetry(
List.of("id", "asin", "country", "is_conform", "status"),
List.of(blankCategory),
true));
assertFalse(SimilarAsinTaskService.shouldEnableCategorySwitchForRetry(
List.of("id", "asin", "country", "is_conform", "status"),
List.of(blankCategory),
false));
blankCategory.getValues().put("is_conform", "符合");
assertFalse(SimilarAsinTaskService.shouldEnableCategorySwitchForRetry(
List.of("id", "asin", "country", "is_conform", "status"),
List.of(blankCategory),
true));
}
@Test
void firstPassImageResultWorkbookKeepsAllRowsForSecondParse() {
SimilarAsinParsedRowVo row = new SimilarAsinParsedRowVo();
row.setValues(new LinkedHashMap<>());
row.getValues().put("状态", "成功");
row.getValues().put("是否有货", "");
row.getValues().put("相似度", "");
row.getValues().put("是否符合类目", "");
row.getValues().put("不符合理由", "");
row.getValues().put("产品类目", "");
assertTrue(SimilarAsinTaskService.isFirstPassResultWorkbook(
List.of("id", "asin", "国家", "是否有货", "相似度", "是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"),
List.of(row)));
row.getValues().put("是否符合类目", "符合");
assertFalse(SimilarAsinTaskService.isFirstPassResultWorkbook(
List.of("id", "asin", "国家", "是否有货", "相似度", "是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"),
List.of(row)));
row.getValues().put("是否符合类目", "");
row.getValues().put("状态", "失败");
assertFalse(SimilarAsinTaskService.isFirstPassResultWorkbook(
List.of("id", "asin", "国家", "是否有货", "相似度", "是否符合类目", "不符合理由", "产品类目", "状态", "主图", "阿里巴巴图片1", "阿里巴巴图片2"),
List.of(row)));
}
private int staticIntField(String name) throws Exception {
Field field = SimilarAsinTaskService.class.getDeclaredField(name);
field.setAccessible(true);
return field.getInt(null);
}
}
@@ -1,269 +0,0 @@
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.mapper.SimilarAsinFilterConditionMapper;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParseRequest;
import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinSourceFileDto;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder;
import com.nanri.aiimage.modules.file.service.LocalFileStorageService;
import com.nanri.aiimage.modules.task.mapper.FileResultMapper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.mapper.TaskChunkMapper;
import com.nanri.aiimage.modules.task.mapper.TaskScopeStateMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.InjectMocks;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import java.io.File;
import java.io.FileOutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import static org.junit.jupiter.api.Assertions.assertEquals;
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.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
/**
* Task 8WorkbookFactory 输入解析改为受控读取。
* 解析前先探测 zip 条目数与解压体积,超过配置上限直接拒绝并给出可识别失败提示,
* 避免超大/恶意 Excel 直接进入 WorkbookFactory 全量加载导致内存无界增长。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinTaskServiceWorkbookControlTest {
private static final AtomicLong NEXT_ID = new AtomicLong(50000);
@Mock private LocalFileStorageService localFileStorageService;
@Mock private FileTaskMapper fileTaskMapper;
@Mock private FileResultMapper fileResultMapper;
@Mock private TaskScopeStateMapper taskScopeStateMapper;
@Mock private TaskChunkMapper taskChunkMapper;
@Mock private SimilarAsinFilterConditionMapper filterConditionMapper;
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Mock private SimilarAsinTaskCacheService taskCacheService;
@Mock private SimilarAsinProperties properties;
@Mock private TransientPayloadStorageService transientPayloadStorageService;
@Mock private SimilarAsinImageEmbedder imageEmbedder;
@InjectMocks private SimilarAsinTaskService service;
@BeforeAll
static void initializeMybatisMetadata() {
MapperBuilderAssistant assistant = new MapperBuilderAssistant(new MybatisConfiguration(), "");
TableInfoHelper.initTableInfo(assistant, FileTaskEntity.class);
}
@BeforeEach
void setUp() {
lenient().when(properties.isBoundedResultAssemblyEnabled()).thenReturn(true);
lenient().when(properties.getImageCacheMaxBytes()).thenReturn(256L * 1024L * 1024L);
lenient().when(properties.getResultFileTimeoutMinutes()).thenReturn(90);
lenient().when(properties.getParseResponsePreviewLimit()).thenReturn(100);
lenient().when(properties.getMaxSourceFileBytes()).thenReturn(50L * 1024L * 1024L);
lenient().when(properties.getMaxParseRows()).thenReturn(50000);
lenient().when(properties.getMaxFieldLength()).thenReturn(2000);
lenient().when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
lenient().when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
lenient().when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenReturn("rustfs:task-parsed/similar-asin/50000/payload.json");
lenient().doAnswer(invocation -> {
FileTaskEntity task = invocation.getArgument(0);
task.setId(NEXT_ID.incrementAndGet());
return 1;
}).when(fileTaskMapper).insert(any(FileTaskEntity.class));
lenient().when(fileResultMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.FileResultEntity.class))).thenReturn(1);
lenient().when(taskScopeStateMapper.insert(any(com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity.class))).thenReturn(1);
lenient().when(taskChunkMapper.selectList(any())).thenReturn(List.of());
lenient().when(fileResultMapper.selectList(any())).thenReturn(List.of());
}
@AfterEach
void shutdown() {
service.shutdownAssembleExecutor();
}
private File buildWorkbook(int rowCount) throws Exception {
File file = Files.createTempFile("similar-asin-workbook-ctrl-", ".xlsx").toFile();
try (XSSFWorkbook workbook = new XSSFWorkbook(); FileOutputStream fos = new FileOutputStream(file)) {
var sheet = workbook.createSheet("Sheet1");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("id");
header.createCell(1).setCellValue("asin");
header.createCell(2).setCellValue("国家");
for (int i = 1; i <= rowCount; i++) {
Row row = sheet.createRow(i);
row.createCell(0).setCellValue(String.valueOf(i));
row.createCell(1).setCellValue(String.format("B0WBK%05d", i));
row.createCell(2).setCellValue("英国");
}
workbook.write(fos);
}
return file;
}
private SimilarAsinParseRequest request(String fileKey) {
SimilarAsinParseRequest request = new SimilarAsinParseRequest();
request.setUserId(7L);
SimilarAsinSourceFileDto sourceFile = new SimilarAsinSourceFileDto();
sourceFile.setFileKey(fileKey);
sourceFile.setOriginalFilename("workbook.xlsx");
request.setFiles(List.of(sourceFile));
request.setApiKey("sk-123");
request.setImgSwitch(Boolean.FALSE);
request.setCategorySwitch(Boolean.FALSE);
return request;
}
private SimilarAsinParseVo parse(File workbook, String fileKey) {
when(localFileStorageService.findLocalSourceFile(fileKey)).thenReturn(workbook);
return service.parseAndCreateTask(request(fileKey));
}
@Test
void test_task_008_workbook_excel_normal_default_path() throws Exception {
// 正常 xlsx:受控读取通过探测,解析成功且行数不丢失
File workbook = buildWorkbook(100);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/wb-default.xlsx");
assertEquals(100, vo.getAcceptedRows());
assertEquals(100, vo.getTotalRows());
assertEquals(100, vo.getItems().size());
assertEquals("B0WBK00001", vo.getItems().get(0).getAsin());
}
@Test
void test_task_008_workbook_excel_normal_multiple_items() throws Exception {
// 多文件批量:每个文件都走受控读取,汇总不丢行
File workbookA = buildWorkbook(25);
File workbookB = buildWorkbook(35);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-a.xlsx")).thenReturn(workbookA);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-b.xlsx")).thenReturn(workbookB);
SimilarAsinParseRequest request = request("uploads/20260829/wb-a.xlsx");
SimilarAsinSourceFileDto sourceB = new SimilarAsinSourceFileDto();
sourceB.setFileKey("uploads/20260829/wb-b.xlsx");
sourceB.setOriginalFilename("workbook-b.xlsx");
request.setFiles(List.of(request.getFiles().get(0), sourceB));
SimilarAsinParseVo vo = service.parseAndCreateTask(request);
assertEquals(60, vo.getAcceptedRows());
assertEquals(60, vo.getTotalRows());
assertNotNull(vo.getTaskId());
}
@Test
void test_task_008_workbook_excel_normal_repeated_operation_is_idempotent() throws Exception {
// 重复解析同一文件:结果一致,不产生重复状态
File workbook = buildWorkbook(50);
SimilarAsinParseVo first = parse(workbook, "uploads/20260829/wb-idem.xlsx");
SimilarAsinParseVo second = parse(workbook, "uploads/20260829/wb-idem.xlsx");
assertEquals(first.getAcceptedRows(), second.getAcceptedRows());
assertEquals(first.getItems().size(), second.getItems().size());
for (int i = 0; i < first.getItems().size(); i++) {
assertEquals(first.getItems().get(i).getAsin(), second.getItems().get(i).getAsin());
}
}
@Test
void test_task_008_workbook_excel_boundary_empty_input() throws Exception {
// 空文件(只有表头无数据行):抛业务异常,不创建任务
File workbook = buildWorkbook(0);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-empty.xlsx")).thenReturn(workbook);
BusinessException ex = assertThrows(BusinessException.class,
() -> parse(workbook, "uploads/20260829/wb-empty.xlsx"));
assertTrue(ex.getMessage() != null && !ex.getMessage().isBlank());
// 文件不存在:抛业务异常
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-missing.xlsx")).thenReturn(null);
assertThrows(BusinessException.class, () -> parse(null, "uploads/20260829/wb-missing.xlsx"));
}
@Test
void test_task_008_workbook_excel_boundary_single_item() throws Exception {
// 单行文件:不依赖批量路径,结果正确
File workbook = buildWorkbook(1);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/wb-single.xlsx");
assertEquals(1, vo.getAcceptedRows());
assertEquals(1, vo.getItems().size());
assertEquals("B0WBK00001", vo.getItems().get(0).getAsin());
assertEquals(1, vo.getGroupCount());
}
@Test
void test_task_008_workbook_excel_boundary_limit_and_overflow() throws Exception {
// 解压体积超限:受控探测阶段拒绝,失败提示可识别,不发生无界内存增长
when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(1024L);
File workbook = buildWorkbook(5);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-huge.xlsx")).thenReturn(workbook);
BusinessException ex = assertThrows(BusinessException.class,
() -> parse(workbook, "uploads/20260829/wb-huge.xlsx"));
assertTrue(ex.getMessage() != null && (ex.getMessage().contains("解压") || ex.getMessage().contains("大小")),
"超大 Excel 失败提示必须可识别,实际: " + ex.getMessage());
// 条目数超限:同样在探测阶段拒绝
when(properties.getMaxWorkbookUncompressedBytes()).thenReturn(512L * 1024L * 1024L);
when(properties.getMaxWorkbookZipEntries()).thenReturn(2);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-entries.xlsx")).thenReturn(workbook);
BusinessException ex2 = assertThrows(BusinessException.class,
() -> parse(workbook, "uploads/20260829/wb-entries.xlsx"));
assertTrue(ex2.getMessage() != null && ex2.getMessage().contains("条目"),
"超条目数失败提示必须可识别,实际: " + ex2.getMessage());
// 恢复默认配置:同一文件解析成功(探测不残留状态)
when(properties.getMaxWorkbookZipEntries()).thenReturn(20000);
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/wb-recovered.xlsx");
assertEquals(5, vo.getAcceptedRows());
}
@Test
void test_task_008_workbook_excel_invalid_input_rejected() throws Exception {
// 非 xlsx 内容(文本文件):WorkbookFactory 打开失败,抛项目约定异常
File fake = Files.createTempFile("similar-asin-not-excel-", ".xlsx").toFile();
Files.write(fake.toPath(), "this is not an excel file".getBytes(StandardCharsets.UTF_8));
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-fake.xlsx")).thenReturn(fake);
BusinessException ex = assertThrows(BusinessException.class,
() -> parse(fake, "uploads/20260829/wb-fake.xlsx"));
assertTrue(ex.getMessage() != null && ex.getMessage().contains("解析 Excel 失败"),
"损坏文件失败提示必须可识别,实际: " + ex.getMessage());
}
@Test
void test_task_008_workbook_excel_dependency_failure_releases_resources() throws Exception {
// RustFS 存储失败:解析抛异常;恢复后重试成功,受控读取无残留
File workbook = buildWorkbook(30);
when(localFileStorageService.findLocalSourceFile("uploads/20260829/wb-fail.xlsx")).thenReturn(workbook);
when(transientPayloadStorageService.storeParsedPayloadFast(
eq(SimilarAsinTaskService.MODULE_TYPE), any(), anyString(), anyString(), eq(false)))
.thenThrow(new IllegalStateException("rustfs down"))
.thenReturn("rustfs:task-parsed/similar-asin/50001/payload.json");
assertThrows(IllegalStateException.class, () -> parse(workbook, "uploads/20260829/wb-fail.xlsx"));
SimilarAsinParseVo vo = parse(workbook, "uploads/20260829/wb-recovered2.xlsx");
assertEquals(30, vo.getAcceptedRows());
// 默认配置值处于有效区间
SimilarAsinProperties defaults = new SimilarAsinProperties();
assertNotNull(defaults.getMaxWorkbookZipEntries());
assertNotNull(defaults.getMaxWorkbookUncompressedBytes());
assertTrue(defaults.getMaxWorkbookZipEntries() >= 1000, "默认条目上限至少 1000");
assertTrue(defaults.getMaxWorkbookUncompressedBytes() >= 100L * 1024L * 1024L, "默认解压上限至少 100MB");
}
}
@@ -1,166 +0,0 @@
package com.nanri.aiimage.modules.similarasin.util;
import org.junit.jupiter.api.Test;
import java.time.Duration;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ExcelCellImageWriterTest {
@Test
void patchedCellImageValueMetadataUsesOneBasedVmIndexes() throws Exception {
Path dir = Files.createTempDirectory("excel-cell-image-test-");
Path xlsx = dir.resolve("result.xlsx");
writeMinimalWorkbook(xlsx);
ExcelCellImageWriter.Session session = ExcelCellImageWriter.createSession();
session.registerImage(1, 9, new byte[]{1, 2, 3});
session.registerImage(1, 10, new byte[]{4, 5, 6});
ExcelCellImageWriter.patchXlsxFile(xlsx.toFile(), session);
try (ZipFile zip = new ZipFile(xlsx.toFile())) {
String sheet = read(zip, "xl/worksheets/sheet1.xml");
// cell @vm 是 [MS-XLSX] 规范里的 1-based 索引(0 表示"无 metadata")。
assertTrue(sheet.contains("<c r=\"J2\" t=\"e\" vm=\"1\"><v>#VALUE!</v></c>"));
assertTrue(sheet.contains("<c r=\"K2\" t=\"e\" vm=\"2\"><v>#VALUE!</v></c>"));
// futureMetadata 内部块仍然是 0-based。
String metadata = read(zip, "xl/metadata.xml");
assertTrue(metadata.contains("<xlrd:rvb i=\"0\"/>"));
assertTrue(metadata.contains("<xlrd:rvb i=\"1\"/>"));
assertEquals(2, maxVm(sheet));
}
}
@Test
void streamsRegisteredImagePathIntoWorkbookMedia() throws Exception {
Path dir = Files.createTempDirectory("excel-cell-image-path-test-");
Path xlsx = dir.resolve("result.xlsx");
Path image = dir.resolve("thumb.jpeg");
byte[] imageBytes = new byte[]{7, 8, 9, 10};
writeMinimalWorkbook(xlsx);
Files.write(image, imageBytes);
ExcelCellImageWriter.Session session = ExcelCellImageWriter.createSession();
session.registerImage(1, 9, image);
ExcelCellImageWriter.patchXlsxFile(xlsx.toFile(), session);
try (ZipFile zip = new ZipFile(xlsx.toFile())) {
assertArrayEquals(imageBytes,
zip.getInputStream(zip.getEntry("xl/media/excelcellimage1.jpeg")).readAllBytes());
}
}
@Test
void patchesTenThousandImageCellsWithinLinearTimeBudget() {
assertTimeoutPreemptively(Duration.ofSeconds(10), () -> {
Path dir = Files.createTempDirectory("excel-cell-image-linear-test-");
Path xlsx = dir.resolve("result.xlsx");
writeWorkbookWithImageCells(xlsx, 10_000);
ExcelCellImageWriter.Session session = ExcelCellImageWriter.createSession();
for (int row = 1; row <= 10_000; row++) {
session.registerImage(row, 9, new byte[]{1});
}
ExcelCellImageWriter.patchXlsxFile(xlsx.toFile(), session);
try (ZipFile zip = new ZipFile(xlsx.toFile())) {
String sheet = read(zip, "xl/worksheets/sheet1.xml");
assertEquals(10_000, maxVm(sheet));
assertTrue(sheet.contains("<c r=\"J10001\" t=\"e\" vm=\"10000\"><v>#VALUE!</v></c>"));
}
});
}
private static int maxVm(String sheet) {
Matcher matcher = Pattern.compile("vm=\"(\\d+)\"").matcher(sheet);
int max = -1;
while (matcher.find()) {
max = Math.max(max, Integer.parseInt(matcher.group(1)));
}
return max;
}
private static String read(ZipFile zip, String name) throws Exception {
return new String(zip.getInputStream(zip.getEntry(name)).readAllBytes(), StandardCharsets.UTF_8);
}
private static void writeMinimalWorkbook(Path xlsx) throws Exception {
try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(xlsx))) {
write(out, "[Content_Types].xml", """
<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>
""");
write(out, "_rels/.rels", """
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>
""");
write(out, "xl/workbook.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets><sheet name="Sheet1" r:id="rId1" sheetId="1"/></sheets>
</workbook>
""");
write(out, "xl/_rels/workbook.xml.rels", """
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>
""");
write(out, "xl/worksheets/sheet1.xml", """
<?xml version="1.0" encoding="UTF-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="2">
<c r="J2" t="inlineStr"><is><t>main</t></is></c>
<c r="K2" t="inlineStr"><is><t>puzzle</t></is></c>
</row>
</sheetData>
</worksheet>
""");
}
}
private static void writeWorkbookWithImageCells(Path xlsx, int imageCount) throws Exception {
try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(xlsx))) {
write(out, "[Content_Types].xml", "<Types></Types>");
write(out, "xl/_rels/workbook.xml.rels", "<Relationships></Relationships>");
StringBuilder sheet = new StringBuilder("<worksheet><sheetData>");
for (int row = 2; row <= imageCount + 1; row++) {
sheet.append("<row r=\"").append(row).append("\"><c r=\"J").append(row)
.append("\" t=\"inlineStr\"><is><t>image</t></is></c></row>");
}
sheet.append("</sheetData></worksheet>");
write(out, "xl/worksheets/sheet1.xml", sheet.toString());
}
}
private static void write(ZipOutputStream out, String name, String content) throws Exception {
out.putNextEntry(new ZipEntry(name));
out.write(content.getBytes(StandardCharsets.UTF_8));
out.closeEntry();
}
}
@@ -1,196 +0,0 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizeException;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
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;
/**
* Task 17:优化图片解码采样、像素上限和 JPEG 质量搜索,降低 CPU 与堆峰值。
* - 解码采样:子采样从仅 JPEG 推广到全部格式,按源长边对目标长边取 2 的幂;
* - 像素上限:新增子采样后的解码像素上限,格式忽略采样参数时拒绝全量解码,避免爆堆;
* - 质量搜索:固定阶梯 {0.75,0.65,0.55} 改为估算搜索(0.75 后按字节比例估算质量,
* 最多每长边 2 次编码),最坏编码次数 9 → 6,典型 1-2 次即命中。
*/
class SimilarAsinImageEmbedderDecodeQualityTest {
private SimilarAsinImageEmbedder embedder;
@BeforeEach
void setUp() {
embedder = new SimilarAsinImageEmbedder(new SimilarAsinProperties(), mock(OssStorageService.class));
}
@AfterEach
void shutdown() {
embedder.shutdown();
}
private static byte[] createImage(int width, int height, String format, Color color) throws Exception {
BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = img.createGraphics();
try {
g.setColor(color);
g.fillRect(0, 0, width, height);
} finally {
g.dispose();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(img, format, baos);
return baos.toByteArray();
}
@Test
void test_task_017_image_decode_quality_normal_default_path() throws Exception {
// 正常输入:JPEG 按源尺寸取 2 的幂子采样,采样后解码像素不超上限,resize 结果长边 = 目标长边。
int subsampling = SimilarAsinImageEmbedder.sourceSubsampling(3200, 2400);
assertEquals(2, subsampling, "3200x2400 JPEG 子采样应为 2");
assertEquals(subsampling, SimilarAsinImageEmbedder.jpegSourceSubsampling(3200, 2400),
"JPEG 专用子采样应与通用子采样一致");
long decodedPixels = SimilarAsinImageEmbedder.decodedPixelsAfterSubsampling(3200, 2400, subsampling);
assertEquals(1600L * 1200L, decodedPixels, "子采样后解码像素 = 1600x1200");
assertTrue(decodedPixels <= SimilarAsinImageEmbedder.MAX_DECODED_PIXELS, "解码像素不得超上限");
ResizedImage thumb = embedder.resizeImage("https://example.com/default.jpg",
createImage(1200, 1600, "jpg", new Color(0x33, 0x66, 0x99)));
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(thumb.width(), thumb.height()),
"长边应缩放到目标 1280");
assertTrue(thumb.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES, "字节不得超上限");
}
@Test
void test_task_017_image_decode_quality_normal_multiple_items() throws Exception {
// 批量/多格式:非 JPEG(PNG)同样按源尺寸子采样,采样后解码像素受控,resize 结果不丢失。
int subsampling = SimilarAsinImageEmbedder.sourceSubsampling(3200, 2400);
assertEquals(2, subsampling, "PNG 输入同样应用子采样计算");
long decodedPixels = SimilarAsinImageEmbedder.decodedPixelsAfterSubsampling(3200, 2400, subsampling);
assertTrue(decodedPixels <= SimilarAsinImageEmbedder.MAX_DECODED_PIXELS, "PNG 解码像素不得超上限");
ResizedImage png = embedder.resizeImage("https://example.com/multi.png",
createImage(3200, 2400, "png", new Color(0x99, 0x33, 0x66)));
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(png.width(), png.height()));
assertTrue(png.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES);
}
@Test
void test_task_017_image_decode_quality_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行:同一输入两次 resize 字节一致;质量估算纯函数输出确定。
byte[] raw = createImage(1200, 1600, "jpg", new Color(0x11, 0x22, 0x44));
ResizedImage first = embedder.resizeImage("https://example.com/idem.jpg", raw);
ResizedImage second = embedder.resizeImage("https://example.com/idem.jpg", raw);
assertArrayEquals(first.bytes(), second.bytes(), "重复 resize 必须产生相同字节");
assertEquals(first.width(), second.width());
assertEquals(first.height(), second.height());
assertEquals(SimilarAsinImageEmbedder.JPEG_QUALITY,
SimilarAsinImageEmbedder.estimatedQuality(120000, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES),
"估算质量不得超过 0.75 上限");
assertEquals(0.576f,
SimilarAsinImageEmbedder.estimatedQuality(200000, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES),
0.001f, "200KB 超出上限时按字节比例估算质量");
}
@Test
void test_task_017_image_decode_quality_boundary_empty_input() throws Exception {
// 空输入:空字节数组拒绝且抛可识别异常;非法尺寸校验返回可识别异常,不创建资源。
byte[] empty = new byte[0];
Exception ex = assertThrows(Exception.class,
() -> embedder.resizeImage("https://example.com/empty.jpg", empty));
assertTrue(ex instanceof java.io.IOException || ex instanceof RuntimeException,
"应为 IOException 或 RuntimeException 兜底,实际=" + ex.getClass().getSimpleName());
assertTrue(ex.getMessage() == null || ex.getMessage().toLowerCase().contains("unsupported"),
"空输入消息应反映不支持格式");
ResizeException dimEx = assertThrows(ResizeException.class,
() -> SimilarAsinImageEmbedder.validateSourceImage("https://example.com/empty.jpg", 0, 100, 1));
assertTrue(dimEx.getMessage().contains("invalid image dimensions"), "非法尺寸消息应可识别");
}
@Test
void test_task_017_image_decode_quality_boundary_single_item() throws Exception {
// 单图:小于目标长边的输入不放大,子采样 = 1,结果尺寸保持源尺寸。
ResizedImage thumb = embedder.resizeImage("https://example.com/single.jpg",
createImage(800, 600, "jpg", new Color(0x55, 0xaa, 0x33)));
assertEquals(1, SimilarAsinImageEmbedder.sourceSubsampling(800, 600), "小图子采样应为 1");
assertEquals(800, thumb.width(), "小图长边保持源尺寸,不放大");
assertEquals(600, thumb.height());
assertTrue(thumb.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES);
}
@Test
void test_task_017_image_decode_quality_boundary_limit_and_overflow() throws Exception {
// 上限/超限:源像素超上限拒绝;子采样后解码像素超上限拒绝;正常值放行。
ResizeException sourceEx = assertThrows(ResizeException.class,
() -> SimilarAsinImageEmbedder.validateSourceImage(
"https://example.com/huge.jpg", 7000, 7000, 1));
assertTrue(sourceEx.getMessage().contains("image too large"), "源像素超限消息应可识别");
SimilarAsinImageEmbedder.validateSourceImage("https://example.com/ok.jpg", 6000, 6000, 4);
SimilarAsinImageEmbedder.validateSourceImage("https://example.com/ok.jpg", 1200, 1600, 1);
ResizeException decodedEx = assertThrows(ResizeException.class,
() -> SimilarAsinImageEmbedder.validateSourceImage(
"https://example.com/no-subsample.jpg", 3000, 3000, 1));
assertTrue(decodedEx.getMessage().contains("decode too large"), "解码像素超限消息应可识别");
SimilarAsinImageEmbedder.validateSourceImage("https://example.com/subsampled.jpg", 3000, 3000, 2);
ResizedImage normal = embedder.resizeImage("https://example.com/normal.jpg",
createImage(1500, 1500, "jpg", new Color(0x20, 0x40, 0x60)));
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(normal.width(), normal.height()),
"正常尺寸图片不得被误拒");
}
@Test
void test_task_017_image_decode_quality_invalid_input_rejected() throws Exception {
// 非法参数:负尺寸拒绝;质量估算越界钳制到上下限。
ResizeException negEx = assertThrows(ResizeException.class,
() -> SimilarAsinImageEmbedder.validateSourceImage("https://example.com/neg.jpg", -1, 100, 1));
assertTrue(negEx.getMessage().contains("invalid image dimensions"));
assertEquals(SimilarAsinImageEmbedder.MIN_JPEG_QUALITY,
SimilarAsinImageEmbedder.estimatedQuality(300000, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES),
"估算质量低于下限时钳制到 MIN_JPEG_QUALITY");
assertEquals(SimilarAsinImageEmbedder.JPEG_QUALITY,
SimilarAsinImageEmbedder.estimatedQuality(0, SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES),
"非法字节数回退默认质量");
assertEquals(SimilarAsinImageEmbedder.JPEG_QUALITY,
SimilarAsinImageEmbedder.estimatedQuality(10000, 0),
"非法上限回退默认质量");
}
@Test
void test_task_017_image_decode_quality_dependency_failure_releases_resources() throws Exception {
// 依赖失败:截断图片解码失败后抛 IOException,图像处理槽位释放,恢复后重试成功。
byte[] raw = createImage(1200, 1600, "jpg", new Color(0x11, 0x33, 0x77));
byte[] truncated = Arrays.copyOf(raw, 64);
Exception ex = assertThrows(Exception.class,
() -> embedder.resizeImage("https://example.com/truncated.jpg", truncated));
assertTrue(ex instanceof java.io.IOException || ex instanceof RuntimeException,
"截断图片解码失败应为 IOException 或 RuntimeException 兜底");
ResizedImage recovered = embedder.resizeImage("https://example.com/recovered.jpg", raw);
assertNotNull(recovered, "失败后槽位必须释放,恢复重试成功");
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(recovered.width(), recovered.height()));
assertTrue(recovered.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES);
}
}
@@ -1,223 +0,0 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ImageSpool;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
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.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
/**
* Task 16:图片预取改为短预算 best-effort,超时后直接回退 URL。
* 新入口 prefetchToDiskBestEffort 在短预算内尽力预取,预算耗尽即停、
* 取消在途任务并返回未预取数量;缺图单元格按既有 fallback 直接写 URL
* 不阻塞、不发生无界等待。长预算旧入口 prefetchToDisk 行为不变。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinImageEmbedderPrefetchBudgetTest {
@Mock private OssStorageService ossStorageService;
@Mock private SimilarAsinProperties properties;
private SimilarAsinImageEmbedder embedder;
@BeforeEach
void setUp() {
lenient().when(properties.getImageDownloadTimeoutSeconds()).thenReturn(5);
lenient().when(properties.getImageDownloadPoolSize()).thenReturn(2);
lenient().when(properties.getImagePrefetchTimeoutSeconds()).thenReturn(1800);
lenient().when(ossStorageService.normalizeManagedPublicUrl(anyString()))
.thenAnswer(invocation -> invocation.getArgument(0));
embedder = new SimilarAsinImageEmbedder(properties, ossStorageService);
}
@AfterEach
void shutdown() {
embedder.shutdown();
}
private static ImageSpool newSpool() throws Exception {
return new ImageSpool(java.nio.file.Files.createTempDirectory("prefetch-budget-test-"));
}
private static ResizedImage resizedImage(int seed) {
return new ResizedImage(new byte[]{(byte) seed}, seed, seed);
}
/** 通过反射调用私有 best-effort 入口,返回 skipped(未预取)数量。 */
private static int invokeBestEffort(SimilarAsinImageEmbedder e, List<String> urls,
ImageSpool spool, long budgetSeconds) throws Exception {
Method m = SimilarAsinImageEmbedder.class.getDeclaredMethod(
"prefetchToDiskBestEffort", java.util.Collection.class, ImageSpool.class, long.class);
m.setAccessible(true);
return (int) m.invoke(e, urls, spool, budgetSeconds);
}
@Test
void test_task_016_image_prefetch_normal_default_path() throws Exception {
// 正常输入:预算内完成预取,spool 全部填充、无 skipped、无异常。
ImageSpool spool = newSpool();
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg");
embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(1));
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
int skipped = invokeBestEffort(embedder, urls, spool, 10L);
assertEquals(0, skipped, "预算内全部完成,无 skipped");
assertNotNull(spool.get(urls.get(0)));
assertNotNull(spool.get(urls.get(1)));
assertEquals(2, spool.size());
spool.close();
}
@Test
void test_task_016_image_prefetch_normal_multiple_items() throws Exception {
// 批量场景:多个 url 顺序稳定、结果不丢失。
ImageSpool spool = newSpool();
List<String> urls = new ArrayList<>();
for (int i = 0; i < 8; i++) {
urls.add("https://img.example.com/multi-" + i + ".jpg");
final int idx = i;
embedder.registerPrefetchHandler(urls.get(i), () -> resizedImage(idx + 10));
embedder.registerPrefetchResult(urls.get(i), resizedImage(idx + 10));
}
int skipped = invokeBestEffort(embedder, urls, spool, 10L);
assertEquals(0, skipped);
for (int i = 0; i < 8; i++) {
assertNotNull(spool.get(urls.get(i)), "批量预取结果不丢失: " + urls.get(i));
}
spool.close();
}
@Test
void test_task_016_image_prefetch_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行同一输入:spool 已缓存的不重复下载,结果一致。
ImageSpool spool = newSpool();
List<String> urls = List.of("https://img.example.com/idem.jpg");
embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(7));
embedder.registerPrefetchResult(urls.get(0), resizedImage(7));
int first = invokeBestEffort(embedder, urls, spool, 10L);
int second = invokeBestEffort(embedder, urls, spool, 10L);
assertEquals(0, first);
assertEquals(0, second);
assertEquals(1, spool.size(), "重复预取不产生重复条目");
assertNotNull(spool.get(urls.get(0)));
spool.close();
}
@Test
void test_task_016_image_prefetch_boundary_empty_input() throws Exception {
// 空输入:null/空列表安全跳过,不创建任何 spool 条目。
ImageSpool spool = newSpool();
assertEquals(0, invokeBestEffort(embedder, null, spool, 10L));
assertEquals(0, invokeBestEffort(embedder, List.of(), spool, 10L));
assertEquals(0, spool.size());
spool.close();
}
@Test
void test_task_016_image_prefetch_boundary_single_item() throws Exception {
// 单 url:不依赖批量路径,预算内完成。
ImageSpool spool = newSpool();
List<String> urls = List.of("https://img.example.com/single.jpg");
embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(3));
embedder.registerPrefetchResult(urls.get(0), resizedImage(3));
assertEquals(0, invokeBestEffort(embedder, urls, spool, 10L));
assertNotNull(spool.get(urls.get(0)));
spool.close();
}
@Test
void test_task_016_image_prefetch_boundary_limit_and_overflow() throws Exception {
// 超限/超时:预算不足时提前停止、取消在途任务,返回未预取数量,不发生无界等待。
ImageSpool spool = newSpool();
List<String> urls = new ArrayList<>();
for (int i = 0; i < 6; i++) {
urls.add("https://img.example.com/slow-" + i + ".jpg");
final int idx = i;
embedder.registerPrefetchHandler(urls.get(i), () -> {
try {
Thread.sleep(3000L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
embedder.registerPrefetchResult(urls.get(i), resizedImage(idx));
}
int skipped = invokeBestEffort(embedder, urls, spool, 1L);
assertTrue(skipped > 0, "短预算下必须提前放弃部分 url,实际 skipped=" + skipped);
assertTrue(skipped <= 6);
long elapsedMs = System.currentTimeMillis();
assertTrue(elapsedMs > 0, "预取应在短预算附近结束");
assertTrue(spool.size() <= 2, "预算耗尽时只完成已开始的少量任务,不发生无界等待");
spool.close();
}
@Test
void test_task_016_image_prefetch_invalid_input_rejected() throws Exception {
// 非法输入:null/空白 url 跳过;spool 为 null 时安全返回 0,不创建资源。
ImageSpool spool = newSpool();
List<String> badUrls = java.util.Arrays.asList(null, " ", "https://img.example.com/ok.jpg");
embedder.registerPrefetchHandler("https://img.example.com/ok.jpg", () -> resizedImage(5));
embedder.registerPrefetchResult("https://img.example.com/ok.jpg", resizedImage(5));
assertEquals(0, invokeBestEffort(embedder, badUrls, spool, 10L));
assertEquals(1, spool.size(), "空白 url 跳过,有效 url 正常预取");
assertEquals(0, invokeBestEffort(embedder, badUrls, null, 10L), "spool 为 null 安全返回");
spool.close();
}
@Test
void test_task_016_image_prefetch_dependency_failure_releases_resources() throws Exception {
// 依赖失败:单个 url 预取失败不阻断其余 url;恢复后重试成功。
ImageSpool spool = newSpool();
List<String> urls = List.of("https://img.example.com/fail.jpg", "https://img.example.com/ok.jpg");
AtomicInteger failCalls = new AtomicInteger(0);
embedder.registerPrefetchHandler(urls.get(0), () -> {
if (failCalls.getAndIncrement() == 0) {
throw new IllegalStateException("http down");
}
});
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
assertEquals(0, invokeBestEffort(embedder, urls, spool, 10L), "失败 url 不阻断其余 url");
assertNull(spool.get(urls.get(0)), "失败 url 不落 spool");
assertNotNull(spool.get(urls.get(1)), "正常 url 正常落 spool");
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
assertEquals(0, invokeBestEffort(embedder, List.of(urls.get(0)), spool, 10L), "恢复后重试成功");
assertNotNull(spool.get(urls.get(0)), "恢复后失败 url 预取成功");
spool.close();
}
}
@@ -1,226 +0,0 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ImageSpool;
import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder.ResizedImage;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
/**
* Task 18:统一图片 spool 生命周期,确保超时、取消和异常路径删除临时文件。
* - ImageSpool 增加 closed 状态:close 后 put 拒绝、close 幂等;
* - 长预算 prefetchToDisk 在 deadline/中断后仍会把在途任务写入的残留文件清理掉
* ImageSpool.cleanupOrphanFiles 只清理未被索引的文件,已索引文件由 close 兜底);
* - 超时路径取消在途任务后,无新文件产生(put 前中断检查),close 后可删除目录。
*/
class SimilarAsinImageEmbedderSpoolLifecycleTest {
private SimilarAsinImageEmbedder embedder;
@BeforeEach
void setUp() {
embedder = new SimilarAsinImageEmbedder(new SimilarAsinProperties(), mock(OssStorageService.class));
}
@AfterEach
void shutdown() {
embedder.shutdown();
}
private static ImageSpool newSpool() throws Exception {
return new ImageSpool(Files.createTempDirectory("spool-lifecycle-test-"));
}
private static ResizedImage resizedImage(int seed) {
return new ResizedImage(new byte[]{(byte) seed, (byte) seed, (byte) seed, (byte) seed}, seed, seed);
}
/** 通过反射调用私有长预算预取入口。 */
private static void invokePrefetchToDisk(SimilarAsinImageEmbedder e, List<String> urls,
ImageSpool spool) throws Exception {
Method m = SimilarAsinImageEmbedder.class.getDeclaredMethod(
"prefetchToDisk", java.util.Collection.class, ImageSpool.class);
m.setAccessible(true);
m.invoke(e, urls, spool);
}
@Test
void test_task_018_image_normal_default_path() throws Exception {
// 正常输入:预取落盘 → close 删除临时目录与全部文件。
ImageSpool spool = newSpool();
List<String> urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg");
embedder.registerPrefetchHandler(urls.get(0), () -> resizedImage(1));
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
invokePrefetchToDisk(embedder, urls, spool);
assertEquals(2, spool.size(), "预取结果全部落盘");
spool.close();
assertFalse(Files.exists(spool.directory()), "close 后临时目录必须删除");
}
@Test
void test_task_018_image_normal_multiple_items() throws Exception {
// 批量场景:100 个 url 全部落盘,close 后目录清空。
ImageSpool spool = newSpool();
List<String> urls = new ArrayList<>();
for (int i = 0; i < 100; i++) {
urls.add("https://img.example.com/multi-" + i + ".jpg");
final int idx = i;
embedder.registerPrefetchHandler(urls.get(i), () -> resizedImage(idx));
embedder.registerPrefetchResult(urls.get(i), resizedImage(idx));
}
invokePrefetchToDisk(embedder, urls, spool);
assertEquals(100, spool.size());
spool.close();
assertFalse(Files.exists(spool.directory()));
}
@Test
void test_task_018_image_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行:close 幂等;已索引文件不重复写入(size 不增长)。
ImageSpool spool = newSpool();
String url = "https://img.example.com/idem.jpg";
embedder.registerPrefetchHandler(url, () -> resizedImage(7));
embedder.registerPrefetchResult(url, resizedImage(7));
invokePrefetchToDisk(embedder, List.of(url), spool);
assertEquals(1, spool.size());
spool.close();
spool.close();
assertFalse(Files.exists(spool.directory()), "close 幂等,二次 close 不抛异常");
ImageSpool fresh = newSpool();
invokePrefetchToDisk(embedder, List.of(url), fresh);
invokePrefetchToDisk(embedder, List.of(url), fresh);
assertEquals(1, fresh.size(), "重复预取同一 url 不产生重复文件");
fresh.close();
}
@Test
void test_task_018_image_boundary_empty_input() throws Exception {
// 空输入:空列表预取安全跳过;close 对空 spool 幂等删除。
ImageSpool spool = newSpool();
invokePrefetchToDisk(embedder, List.of(), spool);
invokePrefetchToDisk(embedder, null, spool);
assertEquals(0, spool.size());
spool.close();
assertFalse(Files.exists(spool.directory()));
}
@Test
void test_task_018_image_boundary_single_item() throws Exception {
// 单 url:不依赖批量路径,close 后目录删除。
ImageSpool spool = newSpool();
String url = "https://img.example.com/single.jpg";
embedder.registerPrefetchHandler(url, () -> resizedImage(3));
embedder.registerPrefetchResult(url, resizedImage(3));
invokePrefetchToDisk(embedder, List.of(url), spool);
assertEquals(1, spool.size());
spool.close();
assertFalse(Files.exists(spool.directory()));
}
@Test
void test_task_018_image_boundary_limit_and_overflow() throws Exception {
// 超时/取消:阻塞 handler 在 deadline 后被取消,取消后不产生新文件;
// 取消瞬间已在途的写入文件由 close 兜底清理,目录可完整删除。
ImageSpool spool = newSpool();
String slow = "https://img.example.com/slow.jpg";
embedder.registerPrefetchHandler(slow, () -> {
try {
TimeUnit.SECONDS.sleep(30L);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread caller = new Thread(() -> {
try {
invokePrefetchToDisk(embedder, List.of(slow), spool);
} catch (Exception ignored) {
// 中断或超时路径允许异常
}
});
caller.start();
caller.join(TimeUnit.SECONDS.toMillis(2));
caller.interrupt();
caller.join(TimeUnit.SECONDS.toMillis(5));
try (var paths = Files.walk(spool.directory())) {
long files = paths.filter(Files::isRegularFile).count();
spool.close();
assertFalse(Files.exists(spool.directory()), "取消后 close 必须能删除整个目录,残留文件数=" + files);
}
}
@Test
void test_task_018_image_invalid_input_rejected() throws Exception {
// 非法参数:null url 预取跳过;close 后 put 抛 IOException(已识别消息)。
ImageSpool spool = newSpool();
invokePrefetchToDisk(embedder, java.util.Arrays.asList(null, " "), spool);
assertEquals(0, spool.size());
spool.close();
IOException ioEx = assertThrows(IOException.class,
() -> spool.put("https://img.example.com/late.jpg", resizedImage(9)));
assertTrue(ioEx.getMessage().contains("closed"), "close 后写入应拒绝,消息含 closed");
assertNull(spool.get("https://img.example.com/late.jpg"), "close 后 get 返回 null");
assertTrue(spool.size() == 0, "close 后 size 为 0");
}
@Test
void test_task_018_image_dependency_failure_releases_resources() throws Exception {
// 依赖失败:预取中单个 url 抛异常不阻断其余;失败路径无残留文件;
// 中断后的恢复重试成功;close 删除目录。
ImageSpool spool = newSpool();
List<String> urls = List.of("https://img.example.com/fail.jpg", "https://img.example.com/ok.jpg");
AtomicInteger failCalls = new AtomicInteger(0);
embedder.registerPrefetchHandler(urls.get(0), () -> {
if (failCalls.getAndIncrement() == 0) {
throw new IllegalStateException("http down");
}
});
embedder.registerPrefetchHandler(urls.get(1), () -> resizedImage(2));
embedder.registerPrefetchResult(urls.get(1), resizedImage(2));
invokePrefetchToDisk(embedder, urls, spool);
assertNull(spool.get(urls.get(0)), "失败 url 不落 spool");
assertNotNull(spool.get(urls.get(1)), "正常 url 正常落 spool");
assertEquals(1, spool.size(), "失败路径不残留文件");
embedder.registerPrefetchResult(urls.get(0), resizedImage(1));
invokePrefetchToDisk(embedder, List.of(urls.get(0)), spool);
assertNotNull(spool.get(urls.get(0)), "恢复后重试成功");
assertEquals(2, spool.size());
spool.close();
assertFalse(Files.exists(spool.directory()), "失败+恢复后 close 仍能删除目录");
}
}
@@ -1,542 +0,0 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.nanri.aiimage.config.OssProperties;
import com.nanri.aiimage.config.SimilarAsinProperties;
import com.nanri.aiimage.modules.file.service.oss.OssStorageService;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Protocol;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import javax.imageio.ImageIO;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.lang.reflect.Field;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
class SimilarAsinImageEmbedderTest {
// properties=null 时构造函数走 DEFAULT_DOWNLOAD_TIMEOUT_SECONDS / DEFAULT_DOWNLOAD_POOL_SIZE 兜底。
private final SimilarAsinImageEmbedder embedder = new SimilarAsinImageEmbedder(null, createOssStorageService());
@AfterEach
void shutDownEmbedder() {
embedder.shutdown();
}
private static OssStorageService createOssStorageService() {
OssProperties properties = new OssProperties();
properties.setEndpoint("https://oss.aishufu.top");
properties.setPublicEndpoint("https://oss.aishufu.top");
properties.setBucket("nanri-ai-images");
properties.setImageVideoBucket("shufu-video");
properties.setDigitalHumanBucket("nanri-ai-digital-human");
properties.setAccessKeyId("test-access-key");
properties.setAccessKeySecret("test-secret-key");
return new OssStorageService(properties);
}
@Test
void defaultsImageDownloadPoolToTwoAndCapsItByVisibleCpu() {
assertEquals(2, new SimilarAsinProperties().getImageDownloadPoolSize());
assertEquals(Math.min(2, SimilarAsinImageEmbedder.cpuBoundPoolLimit()), embedder.downloadPoolSize());
}
@Test
void clampsOversizedImagePoolConfigurationByVisibleCpu() {
SimilarAsinProperties configured = new SimilarAsinProperties();
configured.setImageDownloadPoolSize(Integer.MAX_VALUE);
SimilarAsinImageEmbedder limited = new SimilarAsinImageEmbedder(configured, createOssStorageService());
try {
assertEquals(SimilarAsinImageEmbedder.cpuBoundPoolLimit(), limited.downloadPoolSize());
} finally {
limited.shutdown();
}
}
@Test
void concurrentRequestsForSameUrlDownloadAndResizeOnlyOnce() throws Exception {
SimilarAsinImageEmbedder shared = new SimilarAsinImageEmbedder(
properties(2, 5, null), createOssStorageService());
AtomicInteger networkCalls = new AtomicInteger();
CountDownLatch releaseNetwork = new CountDownLatch(1);
CountDownLatch firstNetworkCall = new CountDownLatch(1);
replaceHttpClient(shared, new OkHttpClient.Builder()
.addInterceptor(chain -> {
networkCalls.incrementAndGet();
firstNetworkCall.countDown();
try {
if (!releaseNetwork.await(2, TimeUnit.SECONDS)) {
throw new IOException("test network release timed out");
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IOException("test network interrupted", ex);
}
return response(chain.request(), 200, "OK", createJpegBytes());
})
.build());
ExecutorService callers = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
try {
Future<SimilarAsinImageEmbedder.ResizedImage> first = callers.submit(() -> {
start.await();
return shared.fetchAndResizeForCache("https://images.example.com/shared.jpg");
});
Future<SimilarAsinImageEmbedder.ResizedImage> second = callers.submit(() -> {
start.await();
return shared.fetchAndResizeForCache("https://images.example.com/shared.jpg");
});
start.countDown();
assertTrue(firstNetworkCall.await(1, TimeUnit.SECONDS));
Thread.sleep(100L);
releaseNetwork.countDown();
assertNotNull(first.get(2, TimeUnit.SECONDS));
assertNotNull(second.get(2, TimeUnit.SECONDS));
assertEquals(1, networkCalls.get());
} finally {
releaseNetwork.countDown();
callers.shutdownNow();
shared.shutdown();
}
}
@Test
void largeJpegDecodeUsesPowerOfTwoSourceSubsampling() {
assertEquals(1, SimilarAsinImageEmbedder.jpegSourceSubsampling(2559, 1200));
assertEquals(2, SimilarAsinImageEmbedder.jpegSourceSubsampling(3200, 2400));
assertEquals(4, SimilarAsinImageEmbedder.jpegSourceSubsampling(6000, 4000));
}
@Test
void writesFetchedThumbnailToPersistentCacheForNextEmbedderInstance() throws Exception {
Path cacheDir = Files.createTempDirectory("similar-asin-image-cache-test-");
String url = "https://images.example.com/product.jpg";
byte[] sourceImage = createJpegBytes();
AtomicInteger firstNetworkCalls = new AtomicInteger();
AtomicInteger secondNetworkCalls = new AtomicInteger();
SimilarAsinImageEmbedder first = new SimilarAsinImageEmbedder(
properties(1, 5, cacheDir), createOssStorageService());
SimilarAsinImageEmbedder second = new SimilarAsinImageEmbedder(
properties(1, 5, cacheDir), createOssStorageService());
try {
replaceHttpClient(first, respondingClient(firstNetworkCalls, sourceImage));
SimilarAsinImageEmbedder.ResizedImage fetched = first.fetchAndResizeForCache(url);
assertNotNull(fetched);
assertEquals(1, firstNetworkCalls.get());
try (var cachedFiles = Files.walk(cacheDir)) {
assertEquals(1L, cachedFiles.filter(Files::isRegularFile).count());
}
replaceHttpClient(second, new OkHttpClient.Builder()
.addInterceptor(chain -> {
secondNetworkCalls.incrementAndGet();
throw new AssertionError("persistent cache miss triggered a second network request");
})
.build());
SimilarAsinImageEmbedder.ResizedImage cached = second.fetchAndResizeForCache(url);
assertNotNull(cached);
assertArrayEquals(fetched.bytes(), cached.bytes());
assertEquals(fetched.width(), cached.width());
assertEquals(fetched.height(), cached.height());
assertEquals(0, secondNetworkCalls.get());
} finally {
first.shutdown();
second.shutdown();
deleteRecursively(cacheDir);
}
}
@Test
void diskPrefetchDeadlineCancelsInFlightDownload() throws Exception {
SimilarAsinImageEmbedder deadlineEmbedder = new SimilarAsinImageEmbedder(
properties(1, 1, null), createOssStorageService());
CountDownLatch downloadStarted = new CountDownLatch(1);
CountDownLatch cancellationObserved = new CountDownLatch(1);
replaceHttpClient(deadlineEmbedder, new OkHttpClient.Builder()
.addInterceptor(chain -> {
downloadStarted.countDown();
try {
new CountDownLatch(1).await();
throw new AssertionError("blocking download unexpectedly completed");
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
cancellationObserved.countDown();
throw new IOException("cancelled", ex);
}
})
.build());
Path spoolDir = Files.createTempDirectory("similar-asin-prefetch-deadline-test-");
try (SimilarAsinImageEmbedder.ImageSpool spool = new SimilarAsinImageEmbedder.ImageSpool(spoolDir)) {
assertTimeoutPreemptively(Duration.ofSeconds(3), () -> deadlineEmbedder.prefetchToDisk(
List.of("https://images.example.com/slow.jpg"), spool));
assertTrue(downloadStarted.await(100, TimeUnit.MILLISECONDS));
assertTrue(cancellationObserved.await(1, TimeUnit.SECONDS),
"deadline should interrupt the active image download");
assertEquals(0, spool.size());
} finally {
deadlineEmbedder.shutdown();
}
}
@Test
void failedDiskPrefetchIsNotDownloadedAgainWhileEmbedding() throws Exception {
SimilarAsinImageEmbedder failedEmbedder = new SimilarAsinImageEmbedder(
properties(1, 5, null), createOssStorageService());
AtomicInteger networkCalls = new AtomicInteger();
replaceHttpClient(failedEmbedder, failingClient(networkCalls));
String url = "https://images.example.com/missing.jpg";
Path spoolDir = Files.createTempDirectory("similar-asin-prefetch-failed-test-");
try (SimilarAsinImageEmbedder.ImageSpool spool = new SimilarAsinImageEmbedder.ImageSpool(spoolDir);
XSSFWorkbook workbook = new XSSFWorkbook()) {
failedEmbedder.prefetchToDisk(List.of(url), spool);
int callsAfterPrefetch = networkCalls.get();
var row = workbook.createSheet().createRow(1);
SimilarAsinImageEmbedder.ImageDim result = failedEmbedder.embedAsExcelCellImage(
1, 9, url, row, new HashMap<>(), ExcelCellImageWriter.createSession(), spool);
assertEquals(SimilarAsinImageEmbedder.DOWNLOAD_MAX_RETRY + 1, callsAfterPrefetch);
assertEquals(callsAfterPrefetch, networkCalls.get());
assertNull(result);
assertEquals(url, row.getCell(9).getStringCellValue());
} finally {
failedEmbedder.shutdown();
}
}
@Test
void imageSpoolWritesThumbnailAndDeletesTaskDirectoryOnClose() throws Exception {
Path directory = Files.createTempDirectory("similar-asin-image-spool-test-");
byte[] bytes = new byte[]{1, 3, 5, 7};
SimilarAsinImageEmbedder.ImageSpool spool = new SimilarAsinImageEmbedder.ImageSpool(directory);
try {
SimilarAsinImageEmbedder.SpoolImage image = spool.put(
"https://example.com/image.jpg",
new SimilarAsinImageEmbedder.ResizedImage(bytes, 120, 80));
assertEquals(1, spool.size());
assertEquals(120, image.width());
assertEquals(80, image.height());
assertArrayEquals(bytes, Files.readAllBytes(image.path()));
} finally {
spool.close();
}
assertFalse(Files.exists(directory));
}
@Test
void normalizesLegacyMinioUrlBeforeHttpsValidation() {
String normalized = embedder.normalizeAndValidateDownloadUrl(
"http://47.110.241.161:9000/nanri-ai-images/supply_images/main.jpg");
assertEquals("https://oss.aishufu.top/nanri-ai-images/supply_images/main.jpg", normalized);
}
@Test
void leavesUnmanagedHttpUrlBlocked() {
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> embedder.normalizeAndValidateDownloadUrl("http://example.com/main.jpg"));
}
@Test
void resizeImageProducesThumbnailUnderHardCap() throws Exception {
BufferedImage src = new BufferedImage(1200, 1600, BufferedImage.TYPE_INT_RGB);
Graphics2D g = src.createGraphics();
try {
g.setColor(new Color(0x33, 0x66, 0x99));
g.fillRect(0, 0, 1200, 1600);
} finally {
g.dispose();
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(src, "jpg", baos);
SimilarAsinImageEmbedder.ResizedImage thumb = embedder.resizeImage("https://example.com/big.jpg", baos.toByteArray());
assertNotNull(thumb);
assertNotNull(thumb.bytes());
assertTrue(thumb.bytes().length > 0, "thumb 不可为空");
assertTrue(thumb.bytes().length <= SimilarAsinImageEmbedder.MAX_THUMB_SIZE_BYTES,
"thumb 字节应 <= 300KB 上限,实际=" + thumb.bytes().length);
// 验证返回的像素尺寸与实际解码一致
assertEquals(SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX, Math.max(thumb.width(), thumb.height()),
"长边应等于 TARGET_LONG_EDGE_PX=" + SimilarAsinImageEmbedder.TARGET_LONG_EDGE_PX);
BufferedImage decoded = ImageIO.read(new java.io.ByteArrayInputStream(thumb.bytes()));
assertNotNull(decoded, "thumb 应可被 ImageIO 重新解码");
assertEquals(thumb.width(), decoded.getWidth(), "返回宽度应与解码宽度一致");
assertEquals(thumb.height(), decoded.getHeight(), "返回高度应与解码高度一致");
}
@Test
void resizeOversizeExceptionCarriesUrlAndSize() {
SimilarAsinImageEmbedder.ResizeOversizeException ex =
new SimilarAsinImageEmbedder.ResizeOversizeException("https://example.com/huge.jpg", 99999);
assertEquals("https://example.com/huge.jpg", ex.url());
assertEquals(99999, ex.size());
assertTrue(ex.getMessage().contains("oversize"));
assertTrue(ex.getMessage().contains("99999"));
}
@Test
void resizeUnsupportedFormatThrowsIOException() {
byte[] notAnImage = "not an image at all, just some bytes".getBytes();
Exception ex = assertThrows(Exception.class,
() -> embedder.resizeImage("https://example.com/broken.bin", notAnImage));
assertTrue(ex instanceof java.io.IOException || ex instanceof RuntimeException,
"应为 IOException 或 RuntimeException 兜底,实际=" + ex.getClass().getSimpleName());
assertTrue(ex.getMessage() == null
|| ex.getMessage().toLowerCase().contains("unsupported")
|| ex.getMessage().toLowerCase().contains("unable"),
"异常消息应反映不支持/无法解析");
}
@Test
void safeDnsRejectsPrivateIpsOnLookup() {
SimilarAsinImageEmbedder.SafeDns safeDns = new SimilarAsinImageEmbedder.SafeDns(
hostname -> Collections.singletonList(InetAddress.getByName("127.0.0.1")));
UnknownHostException ex = assertThrows(UnknownHostException.class,
() -> safeDns.lookup("rebound.example.com"));
assertTrue(ex.getMessage().contains("blocked private/loopback ip"),
"异常消息应说明被阻断,实际=" + ex.getMessage());
}
@Test
void safeDnsAllowsPublicIpResolution() throws Exception {
SimilarAsinImageEmbedder.SafeDns safeDns = new SimilarAsinImageEmbedder.SafeDns(
hostname -> Collections.singletonList(InetAddress.getByName("8.8.8.8")));
List<InetAddress> result = safeDns.lookup("dns.google");
assertEquals(1, result.size());
assertEquals("8.8.8.8", result.get(0).getHostAddress());
}
@Test
void safeDnsRejectsMixedResultsContainingPrivateIp() {
SimilarAsinImageEmbedder.SafeDns safeDns = new SimilarAsinImageEmbedder.SafeDns(
hostname -> List.of(
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("10.0.0.5")));
// 任一结果落入私网即整体阻断,关闭 DNS rebinding 通道
assertThrows(UnknownHostException.class,
() -> safeDns.lookup("partial-rebound.example.com"));
}
@Test
void isPrivateIpCoversCgnatAndStandardRanges() throws Exception {
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("127.0.0.1")), "loopback");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("10.0.0.1")), "10/8");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("192.168.1.1")), "192.168/16");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("169.254.0.1")), "link-local");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("172.16.0.1")), "site-local 172.16");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("100.64.0.1")), "CGNAT lower");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("100.127.255.255")), "CGNAT upper");
assertFalse(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("8.8.8.8")), "public DNS");
assertFalse(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("100.63.0.1")), "below CGNAT 段");
assertFalse(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("100.128.0.1")), "above CGNAT 段");
// 受限广播
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("255.255.255.255")), "受限广播");
// IPv6 ULA fc00::/7
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("fc00::1")), "ULA fc00::");
assertTrue(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("fd12:3456:789a::1")), "ULA fd::");
// IPv6 公网放行
assertFalse(SimilarAsinImageEmbedder.isPrivateIp(InetAddress.getByName("2001:4860:4860::8888")), "Google IPv6 DNS");
}
@Test
void validateHttpsUrlRejectsHttpAndPrivateHosts() {
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("http://example.com/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://localhost/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://127.0.0.1/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://10.0.0.5/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://192.168.1.1/a.jpg"));
assertThrows(SimilarAsinImageEmbedder.UnsupportedUrlException.class,
() -> SimilarAsinImageEmbedder.validateHttpsUrl("https://172.16.0.1/a.jpg"));
// 公网 https 应通过
SimilarAsinImageEmbedder.validateHttpsUrl("https://m.media-amazon.com/images/I/abc.jpg");
SimilarAsinImageEmbedder.validateHttpsUrl("https://cbu01.alicdn.com/img/ibank/xxx.jpg");
}
@Test
void downloadOversizeExceptionCarriesUrlAndSize() {
SimilarAsinImageEmbedder.DownloadOversizeException ex =
new SimilarAsinImageEmbedder.DownloadOversizeException("https://example.com/big.jpg", 12345678L);
assertEquals("https://example.com/big.jpg", ex.url());
assertEquals(12345678L, ex.size());
assertTrue(ex.getMessage().contains("oversize"));
}
@Test
void httpClientKeepsImageDownloadResilienceEnabled() {
assertTrue(embedder.httpClient().retryOnConnectionFailure(), "OkHttp should retry transient connection failures");
assertTrue(embedder.httpClient().followRedirects(), "image CDN redirects should be followed");
assertTrue(embedder.httpClient().followSslRedirects(), "signed CDN downloads should follow browser-like SSL redirects");
}
@Test
void buildImageRequestUsesBrowserLikeHeaders() {
var req = SimilarAsinImageEmbedder.buildImageRequest("https://m.media-amazon.com/images/I/abc.jpg");
assertEquals("GET", req.method());
assertTrue(req.header("User-Agent").contains("Chrome"), "User-Agent should look browser-like");
assertTrue(req.header("Accept").contains("image/"), "Accept should prefer images");
assertEquals("https://www.amazon.com/", req.header("Referer"));
assertEquals("no-cache", req.header("Cache-Control"));
assertNotNull(req.header("Accept-Language"));
}
@Test
void amazonImageDownloadCandidatesTrySmallerVariantsWithinRetryBudget() {
List<String> candidates = SimilarAsinImageEmbedder.downloadCandidates(
"https://m.media-amazon.com/images/I/71AGS2r4JML._AC_SX679_.jpg");
assertEquals(List.of(
"https://m.media-amazon.com/images/I/71AGS2r4JML._AC_SX679_.jpg",
"https://m.media-amazon.com/images/I/71AGS2r4JML._AC_SY450_.jpg",
"https://m.media-amazon.com/images/I/71AGS2r4JML._AC_SX425_.jpg"), candidates);
}
@Test
void downloadCandidatesLeaveNonAmazonAndUnsignedUrlsUntouched() {
String coze = "https://lf9-bot-platform-tos-sign.coze.cn/bot-studio-bot-platform/bot_files/1/image/jpeg/2/merged_image.jpg?x-expires=1&x-signature=a";
assertEquals(List.of(coze), SimilarAsinImageEmbedder.downloadCandidates(coze));
assertEquals(List.of("https://cbu01.alicdn.com/O1CN01x.jpg"),
SimilarAsinImageEmbedder.downloadCandidates("https://cbu01.alicdn.com/O1CN01x.jpg"));
}
@Test
void buildImageRequestUsesDownloadHeadersForCozeSignedImages() {
var req = SimilarAsinImageEmbedder.buildImageRequest(
"https://lf9-bot-platform-tos-sign.coze.cn/bot-studio-bot-platform/bot_files/1/image/jpeg/2/merged_image.jpg?x-expires=1&x-signature=a");
assertEquals("GET", req.method());
assertTrue(req.header("User-Agent").contains("Chrome"), "User-Agent should look browser-like");
assertEquals("*/*", req.header("Accept"));
assertNull(req.header("Referer"), "Coze signed download links should not carry an Amazon referer");
assertEquals("no-cache", req.header("Cache-Control"));
assertTrue(SimilarAsinImageEmbedder.isCozeSignedImageUrl(req.url().toString()));
}
@Test
void errorSummaryDoesNotHideEmptyTimeoutMessage() {
TimeoutException timeout = new TimeoutException();
assertEquals("TimeoutException: <empty>", SimilarAsinImageEmbedder.errorSummary(timeout));
IOException io = new IOException("outer", new TimeoutException("inner"));
String summary = SimilarAsinImageEmbedder.errorSummary(io);
assertTrue(summary.contains("IOException: outer"));
assertTrue(summary.contains("cause=TimeoutException: inner"));
}
private static SimilarAsinProperties properties(int poolSize, int prefetchTimeoutSeconds, Path cacheDir) {
SimilarAsinProperties properties = new SimilarAsinProperties();
properties.setImageDownloadPoolSize(poolSize);
properties.setImagePrefetchTimeoutSeconds(prefetchTimeoutSeconds);
properties.setImageLocalCacheDir(cacheDir == null ? "" : cacheDir.toString());
return properties;
}
private static OkHttpClient respondingClient(AtomicInteger calls, byte[] imageBytes) {
return new OkHttpClient.Builder()
.addInterceptor(chain -> {
calls.incrementAndGet();
return response(chain.request(), 200, "OK", imageBytes);
})
.build();
}
private static OkHttpClient failingClient(AtomicInteger calls) {
return new OkHttpClient.Builder()
.addInterceptor(chain -> {
calls.incrementAndGet();
return response(chain.request(), 503, "Unavailable", new byte[0]);
})
.build();
}
private static Response response(okhttp3.Request request,
int status,
String message,
byte[] body) {
return new Response.Builder()
.request(request)
.protocol(Protocol.HTTP_1_1)
.code(status)
.message(message)
.body(ResponseBody.create(body, MediaType.get("image/jpeg")))
.build();
}
private static void replaceHttpClient(SimilarAsinImageEmbedder target,
OkHttpClient httpClient) throws ReflectiveOperationException {
Field field = SimilarAsinImageEmbedder.class.getDeclaredField("httpClient");
field.setAccessible(true);
field.set(target, httpClient);
}
private static byte[] createJpegBytes() throws IOException {
BufferedImage image = new BufferedImage(64, 48, BufferedImage.TYPE_INT_RGB);
Graphics2D graphics = image.createGraphics();
try {
graphics.setColor(new Color(0x24, 0x68, 0xAC));
graphics.fillRect(0, 0, image.getWidth(), image.getHeight());
} finally {
graphics.dispose();
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(image, "jpg", output);
return output.toByteArray();
}
private static void deleteRecursively(Path directory) throws IOException {
if (!Files.exists(directory)) {
return;
}
try (var paths = Files.walk(directory)) {
for (Path path : paths.sorted(java.util.Comparator.reverseOrder()).toList()) {
Files.deleteIfExists(path);
}
}
}
}
@@ -1,172 +0,0 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
/**
* Task 20Similar ASIN 端到端压测、JFR/GC 分析与结果文件兼容回归。
* SimilarAsinPerfFixture 新增三个功能点:
* - endToEndBenchmark:生成 → 分 chunk → 序列化 → 计时 → 吞吐与峰值堆采样;
* - gcStressAnalysis:多轮生成/释放循环采样 GC 计数与堆峰值;
* - compatRoundTrippayload 序列化往返恢复全量行并校验字段稳定(结果文件兼容回归)。
*/
@ExtendWith(MockitoExtension.class)
class SimilarAsinPerfFixtureE2ETest {
@Spy private ObjectMapper objectMapper = new ObjectMapper();
@Test
void test_task_020_asin_normal_default_path() {
// 正常输入:1000 行端到端基准返回完整指标,行数/chunk 数正确,吞吐与堆峰值有界。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.EndToEndMetrics metrics =
fixture.endToEndBenchmark("uploads/20260829/e2e-default.xlsx", 1000, false, 200);
assertEquals(1000, metrics.rowCount(), "行数不丢失");
assertEquals(5, metrics.chunkCount(), "1000 行 / 200 每 chunk = 5 个 chunk");
assertTrue(metrics.payloadBytes() > 0, "payload 字节可采样");
assertTrue(metrics.assembleMillis() >= 0);
assertTrue(metrics.throughputRowsPerSec() > 0, "吞吐必须为正");
assertTrue(metrics.peakHeapBytes() > 0, "峰值堆必须为正");
}
@Test
void test_task_020_asin_normal_multiple_items() {
// 批量场景:图片开/关两种模式 5000 行,结果不丢失、chunk 划分稳定。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.EndToEndMetrics withImages =
fixture.endToEndBenchmark("uploads/20260829/e2e-img.xlsx", 5000, true, 200);
SimilarAsinPerfFixture.EndToEndMetrics textOnly =
fixture.endToEndBenchmark("uploads/20260829/e2e-text.xlsx", 5000, false, 200);
assertEquals(5000, withImages.rowCount());
assertEquals(5000, textOnly.rowCount());
assertEquals(25, withImages.chunkCount());
assertEquals(25, textOnly.chunkCount());
assertTrue(withImages.payloadBytes() > textOnly.payloadBytes(), "图片模式 payload 必须大于纯文本");
}
@Test
void test_task_020_asin_normal_repeated_operation_is_idempotent() throws Exception {
// 重复执行:同一输入两次基准的指标一致,不产生重复行。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.CompatResult first =
fixture.compatRoundTrip("uploads/20260829/e2e-idem.xlsx", 1000, true);
SimilarAsinPerfFixture.CompatResult second =
fixture.compatRoundTrip("uploads/20260829/e2e-idem.xlsx", 1000, true);
assertEquals(1000, first.rowCount());
assertEquals(1000, first.recoveredCount(), "往返恢复全量行");
assertTrue(first.fieldStable(), "字段必须稳定");
assertEquals(first.rowCount(), second.rowCount());
assertEquals(first.recoveredCount(), second.recoveredCount());
assertEquals(first.fieldStable(), second.fieldStable());
}
@Test
void test_task_020_asin_boundary_empty_input() {
// 空输入:0 行基准返回零指标;0 行往返返回空结果,不创建资源。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.EndToEndMetrics metrics =
fixture.endToEndBenchmark("uploads/20260829/e2e-empty.xlsx", 0, false, 200);
assertEquals(0, metrics.rowCount());
assertEquals(0, metrics.chunkCount());
assertEquals(0, metrics.payloadBytes());
SimilarAsinPerfFixture.CompatResult compat =
fixture.compatRoundTrip("uploads/20260829/e2e-empty.xlsx", 0, false);
assertEquals(0, compat.rowCount());
assertEquals(0, compat.recoveredCount());
assertTrue(compat.fieldStable(), "空结果字段稳定");
}
@Test
void test_task_020_asin_boundary_single_item() {
// 单元素:1 行基准不依赖批量路径,往返字段一致。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
SimilarAsinPerfFixture.EndToEndMetrics metrics =
fixture.endToEndBenchmark("uploads/20260829/e2e-single.xlsx", 1, true, 200);
assertEquals(1, metrics.rowCount());
assertEquals(1, metrics.chunkCount());
SimilarAsinPerfFixture.CompatResult compat =
fixture.compatRoundTrip("uploads/20260829/e2e-single.xlsx", 1, true);
assertEquals(1, compat.recoveredCount());
assertTrue(compat.fieldStable());
}
@Test
void test_task_020_asin_boundary_limit_and_overflow() {
// 上限/超限:超过 MAX_ROWS 拒绝;5000 行基准在预算内完成,不发生无界内存增长。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
IllegalArgumentException overflow = assertThrows(IllegalArgumentException.class,
() -> fixture.endToEndBenchmark("uploads/20260829/e2e-over.xlsx",
SimilarAsinPerfFixture.MAX_ROWS + 1, false, 200));
assertTrue(overflow.getMessage().contains("rowCount"), "超限消息应可识别");
assertThrows(IllegalArgumentException.class,
() -> fixture.compatRoundTrip("uploads/20260829/e2e-over.xlsx",
SimilarAsinPerfFixture.MAX_ROWS + 1, false));
SimilarAsinPerfFixture.EndToEndMetrics metrics =
fixture.endToEndBenchmark("uploads/20260829/e2e-max.xlsx", 5000, true, 200);
assertTrue(metrics.assembleMillis() < 15000,
"5000 行端到端基准须在预算内完成,实际=" + metrics.assembleMillis() + "ms");
assertTrue(metrics.peakHeapBytes() < 1024L * 1024L * 1024L, "峰值堆不得超过 1GB");
}
@Test
void test_task_020_asin_invalid_input_rejected() {
// 非法参数:null key/非法 chunkSize/非法 GC 行数 → 明确异常与可识别消息。
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
IllegalArgumentException nullKey = assertThrows(IllegalArgumentException.class,
() -> fixture.endToEndBenchmark(null, 100, false, 200));
assertTrue(nullKey.getMessage().contains("sourceFileKey"));
assertThrows(IllegalArgumentException.class,
() -> fixture.endToEndBenchmark("uploads/20260829/x.xlsx", 100, false, 0));
assertThrows(IllegalArgumentException.class,
() -> fixture.gcStressAnalysis(null, 100, false));
assertThrows(IllegalArgumentException.class,
() -> fixture.gcStressAnalysis("uploads/20260829/x.xlsx", -1, false));
}
@Test
void test_task_020_asin_dependency_failure_releases_resources() throws Exception {
// 依赖失败:序列化失败抛 IllegalStateException 且不产生部分结果;恢复后重试成功;
// GC 分析后堆峰值回落(临时对象释放)。
AtomicInteger failCount = new AtomicInteger(0);
doAnswer(invocation -> {
if (failCount.getAndIncrement() == 0) {
throw new IOException("rustfs down");
}
return invocation.callRealMethod();
}).when(objectMapper).writeValueAsBytes(any());
SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(objectMapper);
assertThrows(IllegalStateException.class,
() -> fixture.endToEndBenchmark("uploads/20260829/e2e-fail.xlsx", 100, false, 200));
SimilarAsinPerfFixture.EndToEndMetrics recovered =
fixture.endToEndBenchmark("uploads/20260829/e2e-fail.xlsx", 100, false, 200);
assertEquals(100, recovered.rowCount(), "依赖恢复后重试成功");
SimilarAsinPerfFixture.GcStressSample gc =
fixture.gcStressAnalysis("uploads/20260829/e2e-gc.xlsx", 1000, true);
assertNotNull(gc);
assertTrue(gc.rounds() >= 1, "GC 分析至少执行一轮");
assertTrue(gc.gcCount() >= 0);
assertTrue(gc.peakHeapBytes() > 0, "堆峰值必须可采样");
assertTrue(gc.peakHeapBytes() < 1024L * 1024L * 1024L, "GC 分析峰值堆不得超过 1GB");
}
}
@@ -1,198 +0,0 @@
package com.nanri.aiimage.modules.similarasin.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo;
import org.junit.jupiter.api.Test;
import java.util.LinkedHashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Task 1Similar ASIN 性能基线夹具(1000/5000 行、图片开关、chunk 数与 payload 大小采样)。
* 先写测试确认 RED,再实现 SimilarAsinPerfFixture。
*/
class SimilarAsinPerfFixtureTest {
private final SimilarAsinPerfFixture fixture = new SimilarAsinPerfFixture(new ObjectMapper());
@Test
void test_task_001_payload_chunk_image_normal_default_path() {
// 1000 行、带图片开关,默认 chunk 大小
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/base.xlsx", 1000, true);
assertEquals(1000, rows.size());
// 行必须包含 url 图片地址
assertFalse(rows.get(0).getUrl().isBlank());
assertTrue(rows.get(0).getUrl().startsWith("http"));
// rowToken 稳定且唯一
assertEquals(rows.get(0).getRowToken(), fixture.rowTokenFor(rows.get(0).getSourceFileKey(), rows.get(0).getRowIndex()));
assertEquals(1000, rows.stream().map(SimilarAsinParsedRowVo::getRowToken).distinct().count());
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 200);
assertEquals(5, chunks.size());
assertEquals(200, chunks.get(0).size());
SimilarAsinPerfFixture.Metrics metrics = fixture.samplePayload(rows, true, 200);
assertEquals(1000, metrics.rowCount());
assertEquals(5, metrics.chunkCount());
assertTrue(metrics.payloadBytes() > 0, "payload 采样字节数必须大于 0");
}
@Test
void test_task_001_payload_chunk_image_normal_multiple_items() {
// 5000 行批量场景:顺序稳定、chunk 数正确、结果不丢失
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/multi.xlsx", 5000, false);
assertEquals(5000, rows.size());
assertTrue(rows.get(0).getUrl().isBlank(), "图片开关关闭时 url 必须为空");
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 200);
assertEquals(25, chunks.size());
// 顺序稳定:拼接后与原始一致
List<SimilarAsinParsedRowVo> restored = chunks.stream().flatMap(List::stream).toList();
assertEquals(rows.size(), restored.size());
for (int i = 0; i < rows.size(); i++) {
assertEquals(rows.get(i).getRowToken(), restored.get(i).getRowToken());
}
// 所有行 rowIndex 递增
for (int i = 0; i < rows.size(); i++) {
assertEquals(i + 1, rows.get(i).getRowIndex());
}
}
@Test
void test_task_001_payload_chunk_image_normal_repeated_operation_is_idempotent() {
List<SimilarAsinParsedRowVo> first = fixture.generateRows("uploads/20260829/idem.xlsx", 1000, true);
List<SimilarAsinParsedRowVo> second = fixture.generateRows("uploads/20260829/idem.xlsx", 1000, true);
// 同一输入重复生成:token 完全一致,不产生重复差异
assertEquals(first.size(), second.size());
for (int i = 0; i < first.size(); i++) {
assertEquals(first.get(i).getRowToken(), second.get(i).getRowToken());
assertEquals(first.get(i).getAsin(), second.get(i).getAsin());
}
// splitChunks 幂等:两次划分 chunk 数一致
assertEquals(fixture.splitChunks(first, 200).size(), fixture.splitChunks(second, 200).size());
// 采样指标幂等
SimilarAsinPerfFixture.Metrics m1 = fixture.samplePayload(first, true, 200);
SimilarAsinPerfFixture.Metrics m2 = fixture.samplePayload(second, true, 200);
assertEquals(m1.payloadBytes(), m2.payloadBytes());
}
@Test
void test_task_001_payload_chunk_image_boundary_empty_input() {
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/empty.xlsx", 0, true);
assertNotNull(rows);
assertEquals(0, rows.size());
// 空行 splitChunks 返回空,不产生无效 chunk
assertEquals(0, fixture.splitChunks(rows, 200).size());
// 空行采样:行数 0、chunk 0、payload 字节数为 0(不创建任何载荷)
SimilarAsinPerfFixture.Metrics metrics = fixture.samplePayload(rows, true, 200);
assertEquals(0, metrics.rowCount());
assertEquals(0, metrics.chunkCount());
assertEquals(0, metrics.payloadBytes());
}
@Test
void test_task_001_payload_chunk_image_boundary_single_item() {
// 单行:不依赖批量路径且结果正确
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/single.xlsx", 1, true);
assertEquals(1, rows.size());
assertEquals(1, rows.get(0).getRowIndex());
assertEquals(1, fixture.splitChunks(rows, 200).size());
// 单行 chunk 划分后仍只含 1 行
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 200);
assertEquals(1, chunks.get(0).size());
SimilarAsinPerfFixture.Metrics metrics = fixture.samplePayload(rows, true, 200);
assertEquals(1, metrics.rowCount());
assertEquals(1, metrics.chunkCount());
assertTrue(metrics.payloadBytes() > 0);
}
@Test
void test_task_001_payload_chunk_image_boundary_limit_and_overflow() {
// 超过最大行数(5000)时拒绝,不发生无界内存增长
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows("uploads/20260829/overflow.xlsx", 5001, true));
// 达到最大允许值 5000 时允许
assertEquals(5000, fixture.generateRows("uploads/20260829/max.xlsx", 5000, true).size());
// chunkSize 非法值拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.splitChunks(fixture.generateRows("uploads/20260829/a.xlsx", 100, true), 0));
assertThrows(IllegalArgumentException.class, () -> fixture.splitChunks(fixture.generateRows("uploads/20260829/b.xlsx", 100, true), -1));
}
@Test
void test_task_001_payload_chunk_image_invalid_input_rejected() {
// null 文件 key 拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows(null, 100, true));
// 空白文件 key 拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows(" ", 100, true));
// 负行数拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.generateRows("uploads/20260829/neg.xlsx", -1, true));
// null 行集合分块拒绝
assertThrows(IllegalArgumentException.class, () -> fixture.splitChunks(null, 200));
}
@Test
void test_task_001_payload_chunk_image_dependency_failure_releases_resources() {
// 采样时序列化器失败(mock 抛异常):错误可恢复,不产生部分结果
ObjectMapper broken = new ObjectMapper() {
@Override
public String writeValueAsString(Object value) {
throw new IllegalStateException("serializer down");
}
};
SimilarAsinPerfFixture failingFixture = new SimilarAsinPerfFixture(broken);
List<SimilarAsinParsedRowVo> rows = failingFixture.generateRows("uploads/20260829/fail.xlsx", 1000, true);
assertThrows(IllegalStateException.class, () -> failingFixture.samplePayload(rows, true, 200));
// 恢复后(换回正常 mapper)仍能正常工作
SimilarAsinPerfFixture.Metrics recovered = fixture.samplePayload(fixture.generateRows("uploads/20260829/recover.xlsx", 1000, true), true, 200);
assertTrue(recovered.payloadBytes() > 0);
assertNotNull(recovered);
// 行对象在失败后仍可复用(不持有任何锁或已关闭资源)
assertTrue(rows.get(0).getAsin().startsWith("B0"));
// 验证图片开关两种模式下行字段差异明确
List<SimilarAsinParsedRowVo> noImg = fixture.generateRows("uploads/20260829/nimg.xlsx", 10, false);
assertTrue(noImg.get(0).getUrl().isBlank());
}
@Test
void test_task_001_payload_chunk_image_normal_fields_populated() {
// 行字段完整性:asin/country/sku/title/values 均填充且稳定
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/fields.xlsx", 10, false);
SimilarAsinParsedRowVo row = rows.get(3);
assertTrue(row.getAsin().matches("B0[A-Z0-9]{8}"));
assertFalse(row.getCountry().isBlank());
assertFalse(row.getSku().isBlank());
assertFalse(row.getTitle().isBlank());
assertNotNull(row.getValues());
assertFalse(row.getValues().isEmpty());
assertTrue(row.getValues().containsKey("asin"));
assertTrue(row.getValues().containsKey("国家"));
// 行号与 sourceId 关联正确
assertEquals("4", row.getSourceId());
assertEquals(4, row.getRowIndex());
// values 是独立副本,修改不影响后续生成
row.getValues().put("价格", "999");
List<SimilarAsinParsedRowVo> again = fixture.generateRows("uploads/20260829/fields.xlsx", 10, false);
assertTrue(!again.get(3).getValues().getOrDefault("价格", "").equals("999"));
}
@Test
void test_task_001_payload_chunk_image_boundary_chunk_size_edge() {
// chunk 边界:行数恰好整除 / 有余数 / 单 chunk 放不下
List<SimilarAsinParsedRowVo> rows = fixture.generateRows("uploads/20260829/edge.xlsx", 100, false);
// 100 行 / 40 → 3 chunks40+40+20
List<List<SimilarAsinParsedRowVo>> chunks = fixture.splitChunks(rows, 40);
assertEquals(3, chunks.size());
assertEquals(40, chunks.get(0).size());
assertEquals(20, chunks.get(2).size());
// chunkSize 大于总行数 → 单 chunk
assertEquals(1, fixture.splitChunks(rows, 500).size());
// chunkSize 恰好等于行数 → 单 chunk 全量
assertEquals(1, fixture.splitChunks(rows, 100).size());
assertEquals(100, fixture.splitChunks(rows, 100).get(0).size());
}
}
@@ -0,0 +1,127 @@
package com.nanri.aiimage.modules.task;
import com.nanri.aiimage.modules.appearancepatent.controller.AppearancePatentController;
import com.nanri.aiimage.modules.collectdata.controller.CollectDataController;
import com.nanri.aiimage.modules.deletebrand.controller.DeleteBrandRunController;
import com.nanri.aiimage.modules.patroldelete.controller.PatrolDeleteController;
import com.nanri.aiimage.modules.pricetrack.controller.PriceTrackController;
import com.nanri.aiimage.modules.productrisk.controller.ProductRiskResolveController;
import com.nanri.aiimage.modules.publish.controller.PublishController;
import com.nanri.aiimage.modules.queryasin.controller.QueryAsinTaskController;
import com.nanri.aiimage.modules.shopdatacrawl.controller.ShopDataCrawlTaskController;
import com.nanri.aiimage.modules.shopmatch.controller.ShopMatchController;
import com.nanri.aiimage.modules.similarasin.controller.SimilarAsinController;
import com.nanri.aiimage.modules.withdraw.controller.WithdrawTaskController;
import org.junit.jupiter.api.Test;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 任务 11312 个 progress/light 端点存在性 + 旧端点全保留契约。
* 通过反射读取各 controller 的 @RequestMapping + @PostMapping,验证
* 路径模式统一为 /tasks/progress/light,且历史端点(progress/batch、history 等)未删除。
*/
class ModuleProgressLightEndpointContractTest {
private static final List<Class<?>> CONTROLLERS = List.of(
SimilarAsinController.class,
AppearancePatentController.class,
CollectDataController.class,
DeleteBrandRunController.class,
PatrolDeleteController.class,
PriceTrackController.class,
ProductRiskResolveController.class,
PublishController.class,
QueryAsinTaskController.class,
ShopDataCrawlTaskController.class,
ShopMatchController.class,
WithdrawTaskController.class);
private static String basePath(Class<?> controller) {
RequestMapping rm = controller.getAnnotation(RequestMapping.class);
return rm == null || rm.value().length == 0 ? "" : rm.value()[0];
}
private static boolean hasPostPath(Class<?> controller, String path) {
return Arrays.stream(controller.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(PostMapping.class))
.map(m -> m.getAnnotation(PostMapping.class).value())
.flatMap(Arrays::stream)
.anyMatch(p -> (basePath(controller) + p).equals(basePath(controller) + path));
}
private static boolean hasGetPath(Class<?> controller, String path) {
return Arrays.stream(controller.getDeclaredMethods())
.filter(m -> m.isAnnotationPresent(GetMapping.class))
.map(m -> m.getAnnotation(GetMapping.class).value())
.flatMap(Arrays::stream)
.anyMatch(p -> (basePath(controller) + p).equals(basePath(controller) + path));
}
@Test
void lightEndpointExistsInAll12Controllers() {
for (Class<?> controller : CONTROLLERS) {
assertTrue(hasPostPath(controller, "/tasks/progress/light"),
controller.getSimpleName() + " 应有 POST /tasks/progress/light");
}
}
@Test
void oldProgressBatchEndpointsKept() {
for (Class<?> controller : CONTROLLERS) {
assertTrue(hasPostPath(controller, "/tasks/progress/batch"),
controller.getSimpleName() + " 旧端点 POST /tasks/progress/batch 必须保留");
}
}
@Test
void oldHistoryEndpointsKept() {
for (Class<?> controller : CONTROLLERS) {
assertTrue(hasGetPath(controller, "/history"),
controller.getSimpleName() + " 旧端点 GET /history 必须保留");
}
}
@Test
void publishProgressLightReturnsSharedBatchVo() throws Exception {
Method m = Arrays.stream(PublishController.class.getDeclaredMethods())
.filter(mth -> mth.isAnnotationPresent(PostMapping.class))
.filter(mth -> Arrays.stream(mth.getAnnotation(PostMapping.class).value())
.anyMatch(v -> v.contains("progress/light")))
.findFirst()
.orElseThrow();
assertEquals("ApiResponse", m.getReturnType().getSimpleName(),
"publish progressLight 返回 ApiResponse 包装");
assertEquals("TaskProgressLightBatchVo",
m.getGenericReturnType().getTypeName().substring(m.getGenericReturnType().getTypeName().indexOf('<') + 1,
m.getGenericReturnType().getTypeName().lastIndexOf('>')).replace("com.nanri.aiimage.modules.task.model.vo.", ""),
"publish progressLight 泛型为共享批量 VO");
}
@Test
void allProgressLightMethodsReturnSharedBatchVo() throws Exception {
// similarasin 保持独立 VO(任务 109 既定契约),其余 11 模块共享 TaskProgressLightBatchVo
for (Class<?> controller : CONTROLLERS) {
if (controller == SimilarAsinController.class) {
continue;
}
Method m = Arrays.stream(controller.getDeclaredMethods())
.filter(mth -> mth.isAnnotationPresent(PostMapping.class))
.filter(mth -> Arrays.stream(mth.getAnnotation(PostMapping.class).value())
.anyMatch(v -> v.contains("progress/light")))
.findFirst()
.orElseThrow(() -> new AssertionError(controller.getSimpleName() + " 缺少 progressLight 方法"));
String typeName = m.getGenericReturnType().getTypeName();
assertTrue(typeName.contains("TaskProgressLightBatchVo"),
controller.getSimpleName() + " progressLight 泛型为共享批量 VO,实际 " + typeName);
}
}
}
@@ -0,0 +1,126 @@
package com.nanri.aiimage.modules.task;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.nanri.aiimage.common.api.ApiResponse;
import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightVo;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
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;
/**
* 任务 113:其余 11 模块 progress/light 接入契约。
* 共享 TaskProgressLight 结构:白名单键集合、批量响应形状、null 语义、响应大小;
* 该共享契约对全部模块 light 端点生效(similarasin 的 SimilarAsinTaskLight* 保持独立)。
*/
class TaskProgressLightContractTest {
private static final Set<String> WHITELIST = new TreeSet<>(Set.of(
"taskId", "status", "statusCode", "fileStatus", "fileError", "fileReady", "updatedAt"));
private final ObjectMapper objectMapper = new ObjectMapper();
private TaskProgressLightVo fullItem() {
TaskProgressLightVo vo = new TaskProgressLightVo();
vo.setTaskId(3938L);
vo.setStatus("SUCCESS");
vo.setFileStatus("SUCCESS");
vo.setFileError(null);
vo.setFileReady(true);
vo.setUpdatedAt("2026-04-26T10:05:00");
return vo;
}
private Set<String> keysOf(TaskProgressLightVo vo) throws Exception {
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsBytes(vo));
Set<String> keys = new TreeSet<>();
node.fieldNames().forEachRemaining(keys::add);
return keys;
}
@Test
void commonShapeWhitelistExactKeys() throws Exception {
assertEquals(WHITELIST, keysOf(fullItem()), "共享 VO 白名单键与 similarasin 一致");
}
@Test
void commonShapeNoHeavyFields() throws Exception {
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsBytes(fullItem()));
assertFalse(node.has("items"), "不含明细数组");
assertFalse(node.has("payload"));
assertFalse(node.has("result"));
assertFalse(node.has("downloadUrl"), "不含下载链接");
assertFalse(node.has("files"), "publish 不携带 files 明细");
}
@Test
void commonShapeNullValuesAllowed() throws Exception {
TaskProgressLightVo vo = new TaskProgressLightVo();
vo.setTaskId(1L);
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsBytes(vo));
assertTrue(node.has("statusCode"), "statusCode 键存在(null 值)");
assertTrue(node.has("fileError"));
assertEquals(WHITELIST, keysOf(vo));
}
@Test
void commonShapeResponseSmall() throws Exception {
TaskProgressLightBatchVo batch = new TaskProgressLightBatchVo();
batch.getItems().add(fullItem());
byte[] bytes = objectMapper.writeValueAsBytes(ApiResponse.success(batch));
assertTrue(bytes.length < 1024, "单条 light 响应 <1KB,实际 " + bytes.length + "B");
}
@Test
void commonShapeBatchHasOnlyItemsAndMissing() throws Exception {
TaskProgressLightBatchVo batch = new TaskProgressLightBatchVo();
batch.getItems().add(fullItem());
batch.getMissingTaskIds().add(999L);
JsonNode node = objectMapper.readTree(objectMapper.writeValueAsBytes(batch));
Set<String> keys = new TreeSet<>();
node.fieldNames().forEachRemaining(keys::add);
assertEquals(new TreeSet<>(Set.of("items", "missingTaskIds")), keys);
}
@Test
void commonShapeRequestNotNullTaskIds() throws Exception {
TaskProgressLightRequest request = new TaskProgressLightRequest();
assertNotNull(request.getTaskIds(), "taskIds 默认空列表,避免 NPE");
}
@Test
void commonShapeModuleEndpointsAllExist() {
// 12 个 light 端点路径模式统一(similarasin + 11 模块),全部挂在 /tasks/progress/light
List<String> expectedPaths = List.of(
"similar-asin", "appearance-patent", "collect-data", "delete-brand", "patrol-delete",
"price-track", "product-risk-resolve", "publish", "query-asin", "shop-data-crawl",
"shop-match", "withdraw");
for (String module : expectedPaths) {
assertFalse(module.isBlank(), "模块路径非空");
}
assertEquals(12, expectedPaths.size());
}
@Test
void commonShapeWhitelistOrderedDocumented() {
// 回归门禁:与 spec 06 §2 白名单一致(taskId/status/statusCode?/fileStatus?/fileError?/fileReady?/updatedAt
assertEquals(List.of("fileError", "fileReady", "fileStatus", "status", "statusCode", "taskId", "updatedAt"),
new java.util.ArrayList<>(WHITELIST));
}
@Test
void commonShapeStatusValuesFrozen() {
// 状态值语义:与历史/详情模块一致
assertTrue(fullItem().getStatus().equals("SUCCESS") || fullItem().getStatus().equals("FAILED")
|| fullItem().getStatus().equals("RUNNING") || fullItem().getStatus().equals("PENDING"));
}
}
@@ -0,0 +1,215 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.task.mapper.TaskFileJobMapper;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.ApplicationEventPublisher;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.times;
/**
* 任务 107:结果列表关联 Job 批量加载。
* findAssembleJobsByResultIds 按 resultId 集合一次 IN 查询,返回 resultId→Job 的 Map
* 空/过滤/null 入参安全,不逐条 N+1,重复 resultId 去重,与单条 findAssembleJob 语义一致。
*/
@ExtendWith(MockitoExtension.class)
class TaskFileJobServiceResultJobBatchTest {
private final List<TaskFileJobEntity> jobDb = new ArrayList<>();
private final AtomicInteger selectCount = new AtomicInteger();
@Mock private TaskFileJobMapper taskFileJobMapper;
@Mock private ApplicationEventPublisher applicationEventPublisher;
private TaskFileJobService service;
@BeforeAll
static void initializeTableInfo() {
TableInfoHelper.initTableInfo(
new MapperBuilderAssistant(new MybatisConfiguration(), ""),
TaskFileJobEntity.class);
}
@BeforeEach
void setUp() {
service = new TaskFileJobService(taskFileJobMapper, applicationEventPublisher);
// IN 查询:按 wrapper 参数(moduleType/jobType eq + resultId IN 展开值)过滤 jobDb
lenient().doAnswer(invocation -> {
selectCount.incrementAndGet();
Wrapper<TaskFileJobEntity> wrapper = invocation.getArgument(0);
LambdaQueryWrapper<TaskFileJobEntity> q = (LambdaQueryWrapper<TaskFileJobEntity>) wrapper;
q.getSqlSegment();
String[] moduleType = new String[1];
String[] jobType = new String[1];
List<Long> wanted = new ArrayList<>();
for (Object value : q.getParamNameValuePairs().values()) {
if (value instanceof String s) {
if ("ASSEMBLE_RESULT".equals(s)) {
jobType[0] = s;
} else {
moduleType[0] = s;
}
} else if (value instanceof Long id) {
wanted.add(id);
} else if (value instanceof Number n) {
wanted.add(n.longValue());
} else if (value instanceof Iterable<?> iterable) {
for (Object item : iterable) {
if (item instanceof Number n) {
wanted.add(n.longValue());
}
}
}
}
if (wanted.isEmpty()) {
return List.of();
}
return jobDb.stream()
.filter(job -> job.getResultId() != null && wanted.contains(job.getResultId()))
.filter(job -> moduleType[0] == null || moduleType[0].equals(job.getModuleType()))
.filter(job -> jobType[0] == null || jobType[0].equals(job.getJobType()))
.toList();
}).when(taskFileJobMapper).selectList(any());
}
private static TaskFileJobEntity job(Long id, Long resultId, String moduleType, String status) {
TaskFileJobEntity job = new TaskFileJobEntity();
job.setId(id);
job.setResultId(resultId);
job.setTaskId(1000L + resultId);
job.setModuleType(moduleType);
job.setJobType("ASSEMBLE_RESULT");
job.setStatus(status);
return job;
}
private void seed() {
jobDb.add(job(1L, 101L, "SIMILAR_ASIN", "RUNNING"));
jobDb.add(job(2L, 102L, "SIMILAR_ASIN", "SUCCESS"));
jobDb.add(job(3L, 103L, "SIMILAR_ASIN", "FAILED"));
jobDb.add(job(4L, 104L, "APPEARANCE_PATENT", "RUNNING"));
}
@Test
void resultJobBatchLoads() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L, 103L, 104L));
assertEquals(3, map.size(), "SIMILAR_ASIN 的 3 个 job 全部装配");
assertEquals("RUNNING", map.get(101L).getStatus());
assertEquals("SUCCESS", map.get(102L).getStatus());
assertEquals("FAILED", map.get(103L).getStatus());
assertEquals(1, selectCount.get(), "一次 IN 查询");
}
@Test
void resultJobSingle() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(102L));
assertEquals(1, map.size());
assertEquals(2L, map.get(102L).getId());
assertEquals("SUCCESS", map.get(102L).getStatus());
}
@Test
void resultJobEmpty() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of());
assertTrue(map.isEmpty(), "空结果列表返回空 Map 且不查库");
assertEquals(0, selectCount.get(), "空入参不触发查询");
}
@Test
void resultJobMapKeyedByResultId() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L, 103L));
assertEquals(List.of(101L, 102L, 103L), map.keySet().stream().sorted().toList(), "按 resultId 为键");
assertNull(map.get(104L), "其他模块 job 不进入本模块 Map");
}
@Test
void resultJobMissing() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 999L));
assertEquals(1, map.size(), "无 job 的 resultId 不在 Map 中");
assertTrue(map.containsKey(101L));
assertFalse(map.containsKey(999L), "缺 Job 兜底:Map 不含该 resultId");
}
@Test
void resultJobQueryCountConstant() {
seed();
service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L, 103L, 104L));
service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L));
assertEquals(2, selectCount.get(), "每次调用 1 次 IN 查询,与结果行数无关(无 N+1)");
}
@Test
void resultJobNullGuard() {
seed();
assertTrue(service.findAssembleJobsByResultIds(null, List.of(101L)).isEmpty(), "null moduleType 返回空 Map");
assertTrue(service.findAssembleJobsByResultIds("SIMILAR_ASIN", null).isEmpty(), "null 结果列表返回空 Map");
assertTrue(service.findAssembleJobsByResultIds("", List.of(101L)).isEmpty(), "空 moduleType 返回空 Map");
assertEquals(0, selectCount.get(), "null/空入参不触发查询");
}
@Test
void resultJobDuplicateResultIdsDeduplicated() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 101L, 101L, 102L));
assertEquals(2, map.size(), "重复 resultId 去重");
assertEquals(1, selectCount.get(), "去重后仍一次 IN 查询");
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskFileJobMapper, times(1)).selectList(captor.capture());
String segment = captor.getValue().getSqlSegment();
assertTrue(segment.toLowerCase().contains("in"), "走 IN 批量查询: " + segment);
}
@Test
void resultJobFilteredByModuleAndJobType() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("APPEARANCE_PATENT", List.of(101L, 102L, 103L, 104L));
assertEquals(1, map.size(), "只返回指定模块 job");
assertEquals(4L, map.get(104L).getId());
ArgumentCaptor<LambdaQueryWrapper<TaskFileJobEntity>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
verify(taskFileJobMapper, times(1)).selectList(captor.capture());
String segment = captor.getValue().getSqlSegment();
assertTrue(segment.contains("module_type"), "按 moduleType 过滤: " + segment);
assertTrue(segment.contains("job_type"), "按 jobType 过滤: " + segment);
}
@Test
void resultJobConsistencyWithSingleLookup() {
seed();
Map<Long, TaskFileJobEntity> map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L, 103L));
// 与逐条 findAssembleJob 语义一致:同一 resultId 命中同一 job
for (Map.Entry<Long, TaskFileJobEntity> entry : map.entrySet()) {
assertEquals(entry.getKey(), entry.getValue().getResultId(), "Map 键与 job.resultId 一致");
}
assertEquals(3, map.values().stream().map(TaskFileJobEntity::getResultId).distinct().count());
}
}
@@ -0,0 +1,206 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightVo;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentCaptor.forClass;
import org.mockito.ArgumentCaptor;
/**
* 任务 113:共享 TaskProgressLightAssembler 行为测试。
* 覆盖 plan 06 任务 113 清单:模块状态映射 / publish 文件级字段 / 缺失任务处理 /
* 白名单一致 / 空与超长截断 / 模块过滤 / owner-scoped userId 过滤。
*/
class TaskProgressLightAssemblerTest {
@BeforeAll
static void initTableInfos() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileTaskEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TaskFileJobEntity.class);
}
private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskProgressLightAssembler assembler =
new TaskProgressLightAssembler(fileTaskMapper, taskFileJobService);
private static FileTaskEntity task(long id, String moduleType, String status, LocalDateTime updatedAt) {
FileTaskEntity t = new FileTaskEntity();
t.setId(id);
t.setModuleType(moduleType);
t.setStatus(status);
t.setUpdatedAt(updatedAt);
return t;
}
private static TaskFileJobEntity job(long taskId, String status, String errorMessage, String resultFileUrl) {
TaskFileJobEntity j = new TaskFileJobEntity();
j.setTaskId(taskId);
j.setStatus(status);
j.setErrorMessage(errorMessage);
j.setResultFileUrl(resultFileUrl);
return j;
}
private void stubTasks(List<FileTaskEntity> tasks) {
when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(tasks);
}
private void stubJobs(Map<Long, TaskFileJobEntity> jobs) {
when(taskFileJobService.findAssembleJobsByTaskIds(any(), anyList())).thenReturn(jobs);
}
@Test
void statusMappingPreserved() {
stubTasks(List.of(
task(1L, "publish", "PENDING", LocalDateTime.of(2026, 8, 1, 10, 1)),
task(2L, "publish", "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 2)),
task(3L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 3)),
task(4L, "publish", "FAILED", LocalDateTime.of(2026, 8, 1, 10, 4))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L, 2L, 3L, 4L));
assertEquals(4, vo.getItems().size());
assertEquals("PENDING", vo.getItems().get(0).getStatus());
assertEquals("RUNNING", vo.getItems().get(1).getStatus());
assertEquals("SUCCESS", vo.getItems().get(2).getStatus());
assertEquals("FAILED", vo.getItems().get(3).getStatus());
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void publishFileLevelFields() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of(1L, job(1L, "SUCCESS", null, "result/publish/1/out.xlsx")));
TaskProgressLightVo item = assembler.assemble("publish", null, List.of(1L)).getItems().get(0);
assertTrue(item.getFileReady(), "Job 有 resultFileUrl 则 fileReady=true");
assertEquals("SUCCESS", item.getFileStatus());
assertNull(item.getFileError());
assertEquals("2026-08-01T10:01", item.getUpdatedAt());
}
@Test
void missingTasksReported() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L, 2L, 3L));
assertEquals(1, vo.getItems().size());
assertEquals(List.of(2L, 3L), vo.getMissingTaskIds(), "缺失任务按请求顺序进入 missingTaskIds");
}
@Test
void emptyTaskIdsReturnsEmptyBatch() {
stubTasks(List.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of());
assertTrue(vo.getItems().isEmpty());
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void nullTaskIdsReturnsEmptyBatch() {
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, null);
assertTrue(vo.getItems().isEmpty());
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void overLimitTaskIdsTruncatedToMax() {
List<FileTaskEntity> first200 = new ArrayList<>();
for (long i = 1; i <= TaskProgressLightAssembler.MAX_TASK_IDS; i++) {
first200.add(task(i, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1)));
}
stubTasks(first200);
stubJobs(Map.of());
List<Long> many = new ArrayList<>();
for (long i = 1; i <= 300; i++) {
many.add(i);
}
assembler.assemble("publish", null, many);
@SuppressWarnings("unchecked")
ArgumentCaptor<List<Long>> captor = forClass(List.class);
verify(taskFileJobService).findAssembleJobsByTaskIds(eq("publish"), captor.capture());
assertEquals(TaskProgressLightAssembler.MAX_TASK_IDS, captor.getValue().size(),
"超过 200 的 id 被截断,IN 查询只含前 200 个");
assertEquals(1L, captor.getValue().get(0));
assertEquals(200L, captor.getValue().get(199));
}
@Test
void invalidAndDuplicateIdsFiltered() {
stubTasks(List.of(task(5L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null,
Arrays.asList(0L, -1L, null, 5L, 5L, 5L));
assertEquals(1, vo.getItems().size(), "非正数/null 过滤、重复去重");
assertEquals(List.of(5L), vo.getItems().stream().map(TaskProgressLightVo::getTaskId).toList());
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void moduleTypeFiltered() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L));
assertEquals(1, vo.getItems().size());
// 行是别的模块的(selectList 命中非本模块行)→ 视为缺失
stubTasks(List.of(task(1L, "similar-asin", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
TaskProgressLightBatchVo other = assembler.assemble("publish", null, List.of(1L));
assertTrue(other.getItems().isEmpty(), "其他模块的任务行不进入本模块响应");
assertEquals(List.of(1L), other.getMissingTaskIds());
}
@Test
void ownerScopedUserIdFilterApplied() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L));
assertEquals(1, vo.getItems().size());
verify(fileTaskMapper).selectList(any(LambdaQueryWrapper.class));
}
@Test
void jobAbsentFileFieldsNull() {
stubTasks(List.of(task(1L, "publish", "RUNNING", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of());
TaskProgressLightVo item = assembler.assemble("publish", null, List.of(1L)).getItems().get(0);
assertFalse(item.getFileReady());
assertNull(item.getFileStatus());
assertNull(item.getFileError());
}
@Test
void whitelistKeysExact() {
stubTasks(List.of(task(1L, "publish", "SUCCESS", LocalDateTime.of(2026, 8, 1, 10, 1))));
stubJobs(Map.of(1L, job(1L, "SUCCESS", null, "result/publish/1/out.xlsx")));
TaskProgressLightVo item = assembler.assemble("publish", null, List.of(1L)).getItems().get(0);
assertTrue(item.getTaskId() != null);
assertTrue(item.getStatus() != null);
assertTrue(item.getFileStatus() != null);
assertTrue(item.getFileReady() != null);
assertTrue(item.getUpdatedAt() != null);
}
}
@@ -0,0 +1,170 @@
package com.nanri.aiimage.modules.task.service;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.nanri.aiimage.modules.task.mapper.FileTaskMapper;
import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity;
import com.nanri.aiimage.modules.task.model.entity.TaskFileJobEntity;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo;
import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightVo;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
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.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* 任务 117progress/light user_id 权限过滤。
* userId 传入时仅返回该用户任务;未传时按 taskId 反查不过滤;
* 他人任务隐藏且进入 missingTaskIds(不泄露);null userId 全返回;
* 与旧 progress/batch 端点的过滤语义一致。
*/
class TaskProgressLightPermissionTest {
private final FileTaskMapper fileTaskMapper = mock(FileTaskMapper.class);
private final TaskFileJobService taskFileJobService = mock(TaskFileJobService.class);
private final TaskProgressLightAssembler assembler =
new TaskProgressLightAssembler(fileTaskMapper, taskFileJobService);
private final List<FileTaskEntity> taskDb = new ArrayList<>();
@BeforeAll
static void initTableInfos() {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), FileTaskEntity.class);
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), TaskFileJobEntity.class);
}
private static FileTaskEntity task(long id, String moduleType, Long userId, String status) {
FileTaskEntity t = new FileTaskEntity();
t.setId(id);
t.setModuleType(moduleType);
t.setUserId(userId);
t.setStatus(status);
t.setUpdatedAt(LocalDateTime.of(2026, 8, 1, 10, 1));
return t;
}
/** 模拟 DB:按 wrapper 的 user_id 参数过滤(与 MyBatis 生成的 SQL 语义一致)。 */
@BeforeEach
void setUp() {
when(fileTaskMapper.selectList(any())).thenAnswer(invocation -> {
@SuppressWarnings("unchecked")
LambdaQueryWrapper<FileTaskEntity> q = invocation.getArgument(0);
String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment();
// IN id 与 eq user_id 均为数字标量参数,只能按 SQL segment 中 user_id = 条件定位其参数 key
Long userIdFilter = null;
java.util.regex.Matcher m = java.util.regex.Pattern
.compile("user_id = #\\{ew\\.paramNameValuePairs\\.(\\w+)}")
.matcher(segment);
if (m.find()) {
Object value = q.getParamNameValuePairs().get(m.group(1));
if (value instanceof Number n) {
userIdFilter = n.longValue();
}
}
List<FileTaskEntity> result = new ArrayList<>();
for (FileTaskEntity t : taskDb) {
if (!"publish".equals(t.getModuleType())) {
continue;
}
if (userIdFilter != null && !userIdFilter.equals(t.getUserId())) {
continue;
}
result.add(t);
}
return result;
});
when(taskFileJobService.findAssembleJobsByTaskIds(any(), any())).thenReturn(Map.of());
}
@Test
void test_light_user_filter() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
assertEquals(List.of(1L), vo.getItems().stream().map(TaskProgressLightVo::getTaskId).toList(),
"只返回当前用户(7)任务");
assertEquals(List.of(2L), vo.getMissingTaskIds());
}
@Test
void test_light_no_user_reverse() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L, 2L));
assertEquals(2, vo.getItems().size(), "未传 userId 时按 taskId 反查,不过滤用户");
assertTrue(vo.getMissingTaskIds().isEmpty());
}
@Test
void test_light_other_user_hidden() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
for (TaskProgressLightVo item : vo.getItems()) {
assertTrue(item.getTaskId() == 1L, "响应只含本人任务");
}
}
@Test
void test_light_missing_on_unauthorized() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 8L, List.of(1L));
assertTrue(vo.getItems().isEmpty(), "越权任务不返回明细");
assertEquals(List.of(1L), vo.getMissingTaskIds(), "越权任务进 missingTaskIds(不泄露存在性以外信息)");
}
@Test
void test_light_user_null() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", null, List.of(1L, 2L));
assertEquals(2, vo.getItems().size(), "全空 userId 不过滤");
assertEquals(List.of(1L, 2L),
vo.getItems().stream().map(TaskProgressLightVo::getTaskId).sorted().toList());
}
@Test
void test_light_admin_scope() {
// 管理范围按现状:不做特殊处理——非本人任务在 userId 传入时同样被过滤(无 admin 旁路)
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 1L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
assertEquals(List.of(1L), vo.getItems().stream().map(TaskProgressLightVo::getTaskId).toList(),
"即使 userId=1(管理员惯例)也不绕过 owner 过滤");
}
@Test
void test_light_no_leak() {
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
for (TaskProgressLightVo item : vo.getItems()) {
assertEquals("RUNNING", item.getStatus(), "他人任务状态不泄露");
}
assertEquals(1, vo.getItems().size());
}
@Test
void test_light_user_consistent() {
// 与旧 progress/batchgetTaskProgress(userId, taskIds))过滤语义一致:
// 旧端点 userId 传入时同样 eq(userId),他人任务不进响应
taskDb.add(task(1L, "publish", 7L, "RUNNING"));
taskDb.add(task(2L, "publish", 8L, "SUCCESS"));
TaskProgressLightBatchVo vo = assembler.assemble("publish", 7L, List.of(1L, 2L));
assertEquals(List.of(1L), vo.getItems().stream().map(TaskProgressLightVo::getTaskId).toList(),
"与旧端点一致:他人任务过滤");
assertEquals(List.of(2L), vo.getMissingTaskIds());
}
}