diff --git a/.gitignore b/.gitignore index a1d9e70f..e610b991 100644 --- a/.gitignore +++ b/.gitignore @@ -114,6 +114,11 @@ OPS_REDIS_MYSQL_OPTIMIZATION_NOTES.md # ===== 本地规划文档(不入库)===== docs/plans/ docs/*plan*.md +backend-java/docs/plans/ +backend-java/docs/*plan*.md +backend-java/docs/specs/ +backend-java/docs/*audit*.md +*.broken/ # ===== 进度/规划工具(本地使用,不入库)===== check_progress.py diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java index bc666dca..d20d12af 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java @@ -31,6 +31,8 @@ import org.springframework.web.server.ResponseStatusException; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -98,6 +100,14 @@ public class AppearancePatentController { return ApiResponse.success(service.progressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(service.progressLight(request.getTaskIds())); + } + @PostMapping("/tasks/{taskId}/activate") @Operation(summary = "激活任务", description = "前端手动推送 Python 队列成功后调用,将任务从 PENDING 改为 RUNNING,并记录后端内部活跃时间。后续活跃时间由 Python 回传结果接口自动刷新,不需要单独心跳接口。") public ApiResponse activate( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index a34c7fa8..31290091 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -24,6 +24,7 @@ import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSour import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentSubmitResultRequest; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentDashboardVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryItemVo; +import com.nanri.aiimage.modules.appearancepatent.service.support.AppearancePatentHistoryAssembler; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedGroupVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo; @@ -31,6 +32,7 @@ import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParse import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskBatchVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskDetailVo; import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentTaskItemVo; +import com.nanri.aiimage.modules.appearancepatent.service.support.AppearancePatentSheetBuilder; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.task.mapper.FileResultMapper; @@ -88,6 +90,8 @@ import java.util.UUID; import java.util.function.Supplier; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -95,6 +99,10 @@ import java.util.zip.ZipOutputStream; public class AppearancePatentTaskService { public static final String MODULE_TYPE = "APPEARANCE_PATENT"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } private static final String STATUS_PENDING = "PENDING"; private static final String STATUS_RUNNING = "RUNNING"; private static final String STATUS_SUCCESS = "SUCCESS"; @@ -107,18 +115,6 @@ public class AppearancePatentTaskService { private static final Duration TASK_LOCK_TTL = Duration.ofMinutes(5); private static final long TASK_LOCK_WAIT_MILLIS = 10000L; private static final long TASK_LOCK_RETRY_DELAY_MILLIS = 200L; - private static final List RESULT_HEADERS = List.of( - "id", - "asin", - "国家", - "卖家名称", - "品牌", - "价格", - "标题维度(商标)", - "外观维度(外观设计专利)", - "结论", - "状态" - ); private final LocalFileStorageService localFileStorageService; private final OssStorageService ossStorageService; @@ -138,6 +134,7 @@ public class AppearancePatentTaskService { private final DistributedJobLockService distributedJobLockService; private final TaskDistributedLockService taskDistributedLockService; private final InstanceMetadata instanceMetadata; + private final TaskProgressLightAssembler taskProgressLightAssembler; public AppearancePatentParseVo parseAndCreateTask(AppearancePatentParseRequest request) { long startedAt = System.nanoTime(); @@ -2199,44 +2196,8 @@ public class AppearancePatentTaskService { List receivedRows, Map resultMap) { try (SXSSFWorkbook workbook = new SXSSFWorkbook(200); FileOutputStream fos = new FileOutputStream(xlsx)) { - Sheet sheet = workbook.createSheet("外观专利检测结果"); - CellStyle headerStyle = workbook.createCellStyle(); - Font font = workbook.createFont(); - font.setBold(true); - headerStyle.setFont(font); - - List resultHeaders = new ArrayList<>(RESULT_HEADERS); - resultHeaders.add(6, "标题"); - resultHeaders.add(7, "图片链接"); - resultHeaders.add(8, "sku"); - Row header = sheet.createRow(0); - for (int i = 0; i < resultHeaders.size(); i++) { - Cell cell = header.createCell(i); - cell.setCellValue(resultHeaders.get(i)); - cell.setCellStyle(headerStyle); - } - - int rowIndex = 1; - List rowsToWrite = receivedRows == null ? List.of() : receivedRows; - for (AppearancePatentParsedRowVo parsedRow : rowsToWrite) { - AppearancePatentResultRowDto resultRow = findResultRow(parsedRow, resultMap); - Row row = sheet.createRow(rowIndex++); - int col = 0; - row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId())); - row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), "")); - row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); - row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", "seller-name", "sellername", "store name", "shop name")); - row.createCell(col++).setCellValue(resolveBrand(resultRow, parsedRow)); - row.createCell(col++).setCellValue(resolvePrice(resultRow)); - row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle())); - row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl())); - row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku())); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getTitleRisk())); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getAppearanceRisk())); - row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingConclusion(resultRow)); - row.createCell(col).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow)); - } - writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap); + AppearancePatentSheetBuilder.buildResultSheet(workbook, receivedRows, resultMap, + parsedRow -> findResultRow(parsedRow, resultMap)); workbook.write(fos); workbook.dispose(); } catch (Exception ex) { @@ -2244,36 +2205,6 @@ public class AppearancePatentTaskService { } } - private void writeReasonSheet(SXSSFWorkbook workbook, - CellStyle headerStyle, - List rowsToWrite, - Map resultMap) { - Sheet sheet = workbook.createSheet("原因"); - Row header = sheet.createRow(0); - List headers = List.of("ASIN", "外观原因", "专利原因", "标题原因"); - for (int i = 0; i < headers.size(); i++) { - Cell cell = header.createCell(i); - cell.setCellValue(headers.get(i)); - cell.setCellStyle(headerStyle); - } - - Set writtenAsins = new LinkedHashSet<>(); - int rowIndex = 1; - for (AppearancePatentParsedRowVo parsedRow : rowsToWrite == null ? List.of() : rowsToWrite) { - String asin = normalize(parsedRow.getAsin()).toUpperCase(Locale.ROOT); - if (asin.isBlank() || !writtenAsins.add(asin)) { - continue; - } - AppearancePatentResultRowDto resultRow = findResultRow(parsedRow, resultMap); - Row row = sheet.createRow(rowIndex++); - row.createCell(0).setCellValue(asin); - row.createCell(1).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getAppearanceReason(), "")); - row.createCell(2).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getPatentReason(), "")); - row.createCell(3).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getTitleReason(), "")); - rowIndex++; - } - } - private AppearancePatentResultRowDto findResultRowByAsin(String asin, Map resultMap) { String normalizedAsin = normalize(asin).toUpperCase(Locale.ROOT); if (normalizedAsin.isBlank() || resultMap == null || resultMap.isEmpty()) { @@ -2496,7 +2427,7 @@ public class AppearancePatentTaskService { private boolean hasUsableLlmField(String value) { String normalized = normalize(value); - return !normalized.isBlank() && !isTechnicalLlmFailure(normalized); + return !normalized.isBlank() && !AppearancePatentSheetBuilder.isTechnicalLlmFailure(normalized); } private Map buildHeaderMap(Row header, DataFormatter formatter) { @@ -2641,119 +2572,33 @@ public class AppearancePatentTaskService { return vo; } + private AppearancePatentHistoryAssembler historyAssembler() { + return new AppearancePatentHistoryAssembler( + taskProgressSnapshotService, + this::calculateDisplayProgressPercent, + this::extractSnapshotDisplayPercent); + } + private AppearancePatentHistoryItemVo toHistoryItem(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { - AppearancePatentHistoryItemVo vo = new AppearancePatentHistoryItemVo(); - vo.setResultId(row.getId()); - vo.setTaskId(row.getTaskId()); - vo.setSourceFilename(row.getSourceFilename()); - vo.setResultFilename(row.getResultFilename()); - vo.setDownloadUrl(null); - attachFileJobState(vo, row, job); - vo.setTaskStatus(task == null ? null : task.getStatus()); - vo.setSuccess(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank() - || row.getSuccess() != null && row.getSuccess() == 1); - vo.setError(row.getErrorMessage()); - vo.setRowCount(row.getRowCount()); - vo.setCreatedAt(fmt(row.getCreatedAt())); - // 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底 - vo.setStartedAt(fmt(task == null ? row.getCreatedAt() : task.getCreatedAt())); - vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt())); - return vo; + return historyAssembler().toHistoryItem(row, task, job); } private int historyPriority(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { - String taskStatus = task == null ? null : task.getStatus(); - if (STATUS_PENDING.equals(taskStatus) || STATUS_RUNNING.equals(taskStatus) || isHistoryFileBuilding(row, taskStatus, job)) { - return 0; - } - return 1; + return historyAssembler().historyPriority(row, task, job); } private LocalDateTime historyActivityTime(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { - LocalDateTime latest = latestTime( - task == null ? null : task.getUpdatedAt(), - job == null ? null : job.getUpdatedAt(), - task == null ? null : task.getFinishedAt(), - row == null ? null : row.getCreatedAt(), - task == null ? null : task.getCreatedAt()); - return latest == null && row != null ? row.getCreatedAt() : latest; + return historyAssembler().historyActivityTime(row, task, job); } private boolean isHistoryFileBuilding(FileResultEntity row, String taskStatus, TaskFileJobEntity job) { - if (!STATUS_SUCCESS.equals(taskStatus)) { - return false; - } - boolean fileReady = row != null && row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank(); - if (fileReady) { - return false; - } - String fileStatus = job == null ? null : job.getStatus(); - return !STATUS_SUCCESS.equals(fileStatus) && !STATUS_FAILED.equals(fileStatus); - } - - private void attachFileJobState(AppearancePatentHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job) { - vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank()); - if (job == null) { - vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); - attachFileProgress(vo, row, job); - return; - } - vo.setFileJobId(job.getId()); - vo.setFileStatus(job.getStatus()); - vo.setFileError(STATUS_FAILED.equals(job.getStatus()) ? firstNonBlank(job.getErrorMessage(), null) : null); - attachFileProgress(vo, row, job); - } - - private void attachFileProgress(AppearancePatentHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job) { - if (row == null || row.getTaskId() == null) { - return; - } - if (Boolean.TRUE.equals(vo.getFileReady())) { - vo.setFileProgressCurrent(1); - vo.setFileProgressTotal(1); - vo.setFileProgressPercent(100); - vo.setFileProgressMessage("结果文件已生成"); - return; - } - TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(row.getTaskId(), MODULE_TYPE); - if (snapshot == null) { - return; - } - int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount(); - int current = snapshot.getSuccessCount() == null ? 0 : snapshot.getSuccessCount(); - if (total <= 0) { - return; - } - LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : (job == null ? null : job.getUpdatedAt()); - int percent = calculateDisplayProgressPercent(current, total, job, baseTime); - percent = Math.max(percent, extractSnapshotDisplayPercent(snapshot)); - percent = Boolean.TRUE.equals(vo.getFileReady()) ? 100 : Math.min(99, percent); - vo.setFileProgressCurrent(current); - vo.setFileProgressTotal(total); - vo.setFileProgressPercent(percent); - vo.setFileProgressMessage(snapshot.getMessage()); + return historyAssembler().isHistoryFileBuilding(row, taskStatus, job); } private String fmt(LocalDateTime t) { return t == null ? null : t.toString(); } - private LocalDateTime latestTime(LocalDateTime... values) { - LocalDateTime latest = null; - if (values == null) { - return null; - } - for (LocalDateTime value : values) { - if (value == null) { - continue; - } - if (latest == null || value.isAfter(latest)) { - latest = value; - } - } - return latest; - } - private String firstNonBlank(String preferred, String fallback) { return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); } @@ -3004,43 +2849,6 @@ public class AppearancePatentTaskService { return normalize(id) + "::" + normalize(asin).toUpperCase(Locale.ROOT) + "::" + normalize(country); } - private String readValueByHeader(AppearancePatentParsedRowVo row, String... candidates) { - if (row == null || row.getValues() == null || row.getValues().isEmpty() || candidates == null) { - return ""; - } - for (Map.Entry entry : row.getValues().entrySet()) { - String header = normalize(entry.getKey()).toLowerCase(Locale.ROOT); - for (String candidate : candidates) { - String expected = normalize(candidate).toLowerCase(Locale.ROOT); - if (!expected.isBlank() && header.contains(expected)) { - return entry.getValue() == null ? "" : entry.getValue(); - } - } - } - return ""; - } - - static String resolveBrand(AppearancePatentResultRowDto resultRow, AppearancePatentParsedRowVo parsedRow) { - String pythonBrand = resultRow == null ? "" : resultRow.getBrand(); - if (pythonBrand != null && !pythonBrand.isBlank()) { - return pythonBrand.trim(); - } - if (parsedRow == null || parsedRow.getValues() == null) { - return ""; - } - for (Map.Entry entry : parsedRow.getValues().entrySet()) { - String header = entry.getKey() == null ? "" : entry.getKey().trim().toLowerCase(Locale.ROOT); - if (header.contains("品牌") || header.contains("brand")) { - return entry.getValue() == null ? "" : entry.getValue(); - } - } - return ""; - } - - static String resolvePrice(AppearancePatentResultRowDto resultRow) { - return resultRow == null || resultRow.getPrice() == null ? "" : resultRow.getPrice().trim(); - } - static String resolveTaskExecutionStatus(boolean waitingForAssemble, boolean executionFailed) { if (waitingForAssemble) { return STATUS_RUNNING; @@ -3048,57 +2856,6 @@ public class AppearancePatentTaskService { return executionFailed ? STATUS_FAILED : STATUS_SUCCESS; } - private String userFacingLlmCellValue(AppearancePatentResultRowDto row, String value) { - String normalizedValue = normalize(value); - if (!normalizedValue.isBlank() && !isTechnicalLlmFailure(normalizedValue)) { - return value; - } - // llm 技术性失败:有错误信息则放入错误信息,没有则留空 - if (row != null && isTechnicalLlmFailure(row.getError())) { - return firstNonBlank(row.getError(), ""); - } - return firstNonBlank(value, ""); - } - - private String userFacingConclusion(AppearancePatentResultRowDto row) { - if (row == null) { - return ""; - } - String conclusion = normalize(row.getConclusion()); - if (!conclusion.isBlank() && !isTechnicalLlmFailure(conclusion)) { - return row.getConclusion(); - } - // llm 技术性失败:有错误信息则放入错误信息,没有则留空 - if (isTechnicalLlmFailure(row.getError())) { - return firstNonBlank(row.getError(), ""); - } - return firstNonBlank(row.getConclusion(), ""); - } - - - private String userFacingStatus(AppearancePatentResultRowDto row) { - if (row == null) { - return ""; - } - String conclusion = userFacingConclusion(row); - return resolveResultStatus(conclusion); - } - - static String resolveResultStatus(String conclusion) { - String normalized = conclusion == null ? "" : conclusion.trim(); - return normalized.isBlank() ? "\u5931\u8d25" : "\u6210\u529f"; - } - - private boolean isTechnicalLlmFailure(String value) { - String normalized = normalize(value).toLowerCase(Locale.ROOT); - return normalized.contains("coze") || normalized.contains("llm") - || normalized.contains("结果不完整") - || normalized.contains("工作流节点执行超限") - || normalized.contains("调用超时") - || normalized.contains("timeout"); - } - - private String safeFileStem(String filename) { String name = filename == null || filename.isBlank() ? "appearance-patent" : filename; int idx = name.lastIndexOf('.'); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentExcelParser.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentExcelParser.java new file mode 100644 index 00000000..f8cdff30 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentExcelParser.java @@ -0,0 +1,225 @@ +package com.nanri.aiimage.modules.appearancepatent.service.support; + +import com.nanri.aiimage.common.exception.BusinessException; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.DataFormatter; +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.ss.usermodel.WorkbookFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * 任务 93:AppearancePatentExcelParser 行解析器。 + * POI 读 Excel 行 → 中间行对象(原始单元格值)。语义与 AppearancePatentTaskService.parseWorkbook + * 中对应段落一致:cell 归一化(BOM/全角空格/trim/连续空白折叠)、表头别名匹配(contains + 精确别名)、 + * 空行跳过、必填表头缺失抛错。注意:appearancepatent 无 2000 字段截断、无错误值转空(与 similarasin 不同)。 + */ +@Slf4j +public class AppearancePatentExcelParser { + + public ParsedSheet parse(File input) { + if (input == null) { + throw new IllegalArgumentException("input must not be null"); + } + try (FileInputStream fis = new FileInputStream(input); + Workbook workbook = WorkbookFactory.create(fis)) { + return parseWorkbook(workbook); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + log.warn("[appearance-patent] parse failed file={} err={}", input.getName(), ex.getMessage()); + throw new BusinessException("解析 Excel 失败"); + } + } + + public ParsedSheet parse(InputStream input) { + if (input == null) { + throw new IllegalArgumentException("input must not be null"); + } + try (Workbook workbook = WorkbookFactory.create(input)) { + return parseWorkbook(workbook); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + log.warn("[appearance-patent] parse failed input err={}", ex.getMessage()); + throw new BusinessException("解析 Excel 失败"); + } + } + + private ParsedSheet parseWorkbook(Workbook workbook) { + DataFormatter formatter = new DataFormatter(); + Sheet sheet = workbook.getSheetAt(0); + Row header = sheet.getRow(0); + if (header == null) { + throw new BusinessException("Excel 表头为空"); + } + Map headerMap = buildHeaderMap(header, formatter); + List headers = readHeaders(header, formatter); + int idCol = findRequiredHeader(headerMap, "id"); + int asinCol = findRequiredHeader(headerMap, "asin"); + int countryCol = findRequiredHeader(headerMap, "国家", "country"); + int priceCol = findOptionalHeaderExact(headerMap, "价格", "price"); + int skuCol = findOptionalHeaderExact(headerMap, + "sku", "seller sku", "seller_sku", "merchant sku", "merchant_sku", "商品sku", "商品 sku", "库存sku"); + int urlCol = findOptionalHeaderExact(headerMap, + "url", "rul", "link", "image", "img", "pic", "picture", + "链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接"); + int titleCol = findOptionalHeaderExact(headerMap, + "标题", "title", "listing title", "product title", "商品标题", "商品名称", "产品名称"); + + List rows = new ArrayList<>(); + for (int i = 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (row == null) { + continue; + } + String id = cell(row, idCol, formatter); + String asin = cell(row, asinCol, formatter).toUpperCase(Locale.ROOT); + String country = cell(row, countryCol, formatter); + if (id.isBlank() && asin.isBlank() && country.isBlank()) { + continue; + } + rows.add(new AppearanceExcelRow( + i + 1, + id, + asin, + country, + priceCol >= 0 ? cell(row, priceCol, formatter) : "", + skuCol >= 0 ? cell(row, skuCol, formatter) : "", + urlCol >= 0 ? cell(row, urlCol, formatter) : "", + titleCol >= 0 ? cell(row, titleCol, formatter) : "", + readRowValues(row, headers, formatter))); + } + return new ParsedSheet(headers, rows); + } + + private Map buildHeaderMap(Row header, DataFormatter formatter) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < header.getLastCellNum(); i++) { + String val = normalize(formatter.formatCellValue(header.getCell(i))); + if (!val.isBlank()) { + map.putIfAbsent(val.toLowerCase(Locale.ROOT), i); + } + } + return map; + } + + private List readHeaders(Row header, DataFormatter formatter) { + List headers = new ArrayList<>(); + for (int i = 0; i < header.getLastCellNum(); i++) { + String val = normalize(formatter.formatCellValue(header.getCell(i))); + headers.add(val.isBlank() ? "列" + (i + 1) : val); + } + return headers; + } + + private Map readRowValues(Row row, List headers, DataFormatter formatter) { + Map values = new LinkedHashMap<>(); + for (int i = 0; i < headers.size(); i++) { + values.put(headers.get(i), cell(row, i, formatter)); + } + return values; + } + + private int findRequiredHeader(Map map, String... names) { + int idx = findOptionalHeader(map, names); + if (idx < 0) { + throw new BusinessException("缺少必要表头: " + String.join("/", names)); + } + return idx; + } + + private int findOptionalHeader(Map map, String... names) { + for (Map.Entry entry : map.entrySet()) { + for (String name : names) { + if (entry.getKey().contains(name.toLowerCase(Locale.ROOT))) { + return entry.getValue(); + } + } + } + return -1; + } + + private int findOptionalHeaderExact(Map map, String... names) { + for (Map.Entry entry : map.entrySet()) { + String normalizedHeader = normalizeHeaderAlias(entry.getKey()); + for (String name : names) { + if (normalizedHeader.equals(normalizeHeaderAlias(name))) { + return entry.getValue(); + } + } + } + return -1; + } + + private String normalizeHeaderAlias(String value) { + String normalized = normalize(value).toLowerCase(Locale.ROOT); + return normalized.replaceAll("[\\s_\\-()()\\[\\]{}::/\\\\]+", ""); + } + + private String cell(Row row, int col, DataFormatter formatter) { + return col < 0 ? "" : normalize(formatter.formatCellValue(row.getCell(col))); + } + + private String normalize(String val) { + return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " "); + } + + public record ParsedSheet(List headers, List rows) { + + public ParsedSheet { + headers = headers == null ? List.of() : new ArrayList<>(headers); + rows = rows == null ? List.of() : new ArrayList<>(rows); + } + + @Override + public List headers() { + return java.util.Collections.unmodifiableList(headers); + } + + @Override + public List rows() { + return java.util.Collections.unmodifiableList(rows); + } + } + + public record AppearanceExcelRow(int rowIndex, String id, String asin, String country, + String price, String sku, String url, String title, + Map values) { + + public AppearanceExcelRow { + values = values == null ? Map.of() : new LinkedHashMap<>(values); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof AppearanceExcelRow other)) { + return false; + } + return rowIndex == other.rowIndex + && Objects.equals(id, other.id) + && Objects.equals(asin, other.asin) + && Objects.equals(country, other.country) + && Objects.equals(price, other.price) + && Objects.equals(sku, other.sku) + && Objects.equals(url, other.url) + && Objects.equals(title, other.title) + && values.equals(other.values); + } + + @Override + public int hashCode() { + return Objects.hash(rowIndex, id, asin, country, price, sku, url, title, values); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentHistoryAssembler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentHistoryAssembler.java new file mode 100644 index 00000000..280462f6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentHistoryAssembler.java @@ -0,0 +1,161 @@ +package com.nanri.aiimage.modules.appearancepatent.service.support; + +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentHistoryItemVo; +import com.nanri.aiimage.modules.appearancepatent.service.AppearancePatentTaskService; +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 java.time.LocalDateTime; +import java.util.function.Function; + +/** + * 任务 97:AppearancePatentHistoryAssembler 历史查询组装器。 + * 历史列表 VO 拼装(toHistoryItem + 排序辅助 historyPriority/historyActivityTime + + * 文件进度链)从 AppearancePatentTaskService 原样搬移;只读不落库;输出与现状逐字段一致。 + * 进度百分比计算(calculateDisplayProgressPercent/extractSnapshotDisplayPercent)仍在 + * 服务侧(与 saveFileBuildProgress 实时路径共享),以函数注入。 + */ +public class AppearancePatentHistoryAssembler { + + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_RUNNING = "RUNNING"; + private static final String STATUS_SUCCESS = "SUCCESS"; + private static final String STATUS_FAILED = "FAILED"; + + @FunctionalInterface + public interface ProgressPercentCalculator { + int calculate(int current, int total, TaskFileJobEntity job, LocalDateTime baseTime); + } + + private final TaskProgressSnapshotService taskProgressSnapshotService; + private final ProgressPercentCalculator calculateDisplayProgressPercent; + private final Function extractSnapshotDisplayPercent; + + public AppearancePatentHistoryAssembler(TaskProgressSnapshotService taskProgressSnapshotService, + ProgressPercentCalculator calculateDisplayProgressPercent, + Function extractSnapshotDisplayPercent) { + this.taskProgressSnapshotService = taskProgressSnapshotService; + this.calculateDisplayProgressPercent = calculateDisplayProgressPercent; + this.extractSnapshotDisplayPercent = extractSnapshotDisplayPercent; + } + + public AppearancePatentHistoryItemVo toHistoryItem(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { + AppearancePatentHistoryItemVo vo = new AppearancePatentHistoryItemVo(); + vo.setResultId(row.getId()); + vo.setTaskId(row.getTaskId()); + vo.setSourceFilename(row.getSourceFilename()); + vo.setResultFilename(row.getResultFilename()); + vo.setDownloadUrl(null); + attachFileJobState(vo, row, job); + vo.setTaskStatus(task == null ? null : task.getStatus()); + vo.setSuccess(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank() + || row.getSuccess() != null && row.getSuccess() == 1); + vo.setError(row.getErrorMessage()); + vo.setRowCount(row.getRowCount()); + vo.setCreatedAt(fmt(row.getCreatedAt())); + // 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底 + vo.setStartedAt(fmt(task == null ? row.getCreatedAt() : task.getCreatedAt())); + vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt())); + return vo; + } + + public int historyPriority(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { + String taskStatus = task == null ? null : task.getStatus(); + if (STATUS_PENDING.equals(taskStatus) || STATUS_RUNNING.equals(taskStatus) || isHistoryFileBuilding(row, taskStatus, job)) { + return 0; + } + return 1; + } + + public LocalDateTime historyActivityTime(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { + LocalDateTime latest = latestTime( + task == null ? null : task.getUpdatedAt(), + job == null ? null : job.getUpdatedAt(), + task == null ? null : task.getFinishedAt(), + row == null ? null : row.getCreatedAt(), + task == null ? null : task.getCreatedAt()); + return latest == null && row != null ? row.getCreatedAt() : latest; + } + + public boolean isHistoryFileBuilding(FileResultEntity row, String taskStatus, TaskFileJobEntity job) { + if (!STATUS_SUCCESS.equals(taskStatus)) { + return false; + } + boolean fileReady = row != null && row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank(); + if (fileReady) { + return false; + } + String fileStatus = job == null ? null : job.getStatus(); + return !STATUS_SUCCESS.equals(fileStatus) && !STATUS_FAILED.equals(fileStatus); + } + + private void attachFileJobState(AppearancePatentHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job) { + vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank()); + if (job == null) { + vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); + attachFileProgress(vo, row, job); + return; + } + vo.setFileJobId(job.getId()); + vo.setFileStatus(job.getStatus()); + vo.setFileError(STATUS_FAILED.equals(job.getStatus()) ? firstNonBlank(job.getErrorMessage(), null) : null); + attachFileProgress(vo, row, job); + } + + private void attachFileProgress(AppearancePatentHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job) { + if (row == null || row.getTaskId() == null) { + return; + } + if (Boolean.TRUE.equals(vo.getFileReady())) { + vo.setFileProgressCurrent(1); + vo.setFileProgressTotal(1); + vo.setFileProgressPercent(100); + vo.setFileProgressMessage("结果文件已生成"); + return; + } + TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(row.getTaskId(), AppearancePatentTaskService.MODULE_TYPE); + if (snapshot == null) { + return; + } + int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount(); + int current = snapshot.getSuccessCount() == null ? 0 : snapshot.getSuccessCount(); + if (total <= 0) { + return; + } + LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : (job == null ? null : job.getUpdatedAt()); + int percent = calculateDisplayProgressPercent.calculate(current, total, job, baseTime); + percent = Math.max(percent, extractSnapshotDisplayPercent.apply(snapshot)); + percent = Boolean.TRUE.equals(vo.getFileReady()) ? 100 : Math.min(99, percent); + vo.setFileProgressCurrent(current); + vo.setFileProgressTotal(total); + vo.setFileProgressPercent(percent); + vo.setFileProgressMessage(snapshot.getMessage()); + } + + private String fmt(LocalDateTime t) { + return t == null ? null : t.toString(); + } + + private LocalDateTime latestTime(LocalDateTime... values) { + LocalDateTime latest = null; + if (values == null) { + return null; + } + for (LocalDateTime value : values) { + if (value == null) { + continue; + } + if (latest == null || value.isAfter(latest)) { + latest = value; + } + } + return latest; + } + + private String firstNonBlank(String preferred, String fallback) { + return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentRowNormalizer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentRowNormalizer.java new file mode 100644 index 00000000..d1253c07 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentRowNormalizer.java @@ -0,0 +1,31 @@ +package com.nanri.aiimage.modules.appearancepatent.service.support; + +/** + * 任务 93:AppearancePatentRowNormalizer 字段归一化器。 + * 规则与 AppearancePatentTaskService.normalize / firstNonBlank / baseId / normalizeDisplayId + * 现状逐字节一致(BOM 剥离、全角空格转半角、trim、连续空白折叠为单空格; + * firstNonBlank 取首非空并 trim;baseId 取下划线前块基 id;displayId 仅 trim)。纯函数无状态。 + */ +public final class AppearancePatentRowNormalizer { + + private AppearancePatentRowNormalizer() { + } + + public static String normalize(String val) { + return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " "); + } + + public static String firstNonBlank(String preferred, String fallback) { + return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); + } + + public static String baseId(String id) { + String s = normalize(id); + int idx = s.indexOf('_'); + return idx > 0 ? s.substring(0, idx) : s; + } + + public static String normalizeDisplayId(String id) { + return id == null ? "" : id.trim(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentSheetBuilder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentSheetBuilder.java new file mode 100644 index 00000000..bb1b6100 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentSheetBuilder.java @@ -0,0 +1,265 @@ +package com.nanri.aiimage.modules.appearancepatent.service.support; + +import com.nanri.aiimage.modules.appearancepatent.model.dto.AppearancePatentResultRowDto; +import com.nanri.aiimage.modules.appearancepatent.model.vo.AppearancePatentParsedRowVo; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * 任务 94:AppearancePatentSheetBuilder Sheet 构造器。 + * 结果 Workbook/Sheet 构造辅助(表头、列顺序、样式、行值派生)。 + * 与现状 writeResultWorkbook / writeReasonSheet 语义一致:主 sheet 名"外观专利检测结果"、 + * 13 列表头(RESULT_HEADERS 10 列 + 第 6/7/8 位插入标题/图片链接/sku)、加粗表头、 + * 数据行从第 1 行;"原因"sheet 4 列按 ASIN 去重;LLM 技术性失败展示错误信息。 + * 不落库、无 IO 依赖;rowKey/行值解析/LLM 失败判定均为静态纯函数。 + */ +public final class AppearancePatentSheetBuilder { + + public static final List RESULT_HEADERS = List.of( + "id", + "asin", + "国家", + "卖家名称", + "品牌", + "价格", + "标题维度(商标)", + "外观维度(外观设计专利)", + "结论", + "状态" + ); + + private AppearancePatentSheetBuilder() { + } + + public static void buildResultSheet(Workbook workbook, List rows, + Map resultMap) { + buildResultSheet(workbook, rows, resultMap, null); + } + + public static void buildResultSheet(Workbook workbook, List rows, + Map resultMap, + java.util.function.Function findResultRowFn) { + java.util.function.Function finder = + findResultRowFn == null ? row -> findResultRow(row, resultMap) : findResultRowFn; + Sheet sheet = workbook.createSheet("外观专利检测结果"); + CellStyle headerStyle = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setBold(true); + headerStyle.setFont(font); + + List resultHeaders = new ArrayList<>(RESULT_HEADERS); + resultHeaders.add(6, "标题"); + resultHeaders.add(7, "图片链接"); + resultHeaders.add(8, "sku"); + Row header = sheet.createRow(0); + for (int i = 0; i < resultHeaders.size(); i++) { + Cell cell = header.createCell(i); + cell.setCellValue(resultHeaders.get(i)); + cell.setCellStyle(headerStyle); + } + + int rowIndex = 1; + List rowsToWrite = rows == null ? List.of() : rows; + for (AppearancePatentParsedRowVo parsedRow : rowsToWrite) { + if (parsedRow == null) { + continue; + } + AppearancePatentResultRowDto resultRow = finder.apply(parsedRow); + Row row = sheet.createRow(rowIndex++); + int col = 0; + row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId())); + row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), "")); + row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); + row.createCell(col++).setCellValue(readValueByHeader(parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", "seller-name", "sellername", "store name", "shop name")); + row.createCell(col++).setCellValue(resolveBrand(resultRow, parsedRow)); + row.createCell(col++).setCellValue(resolvePrice(resultRow)); + row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getTitle(), "") : firstNonBlank(resultRow.getTitle(), parsedRow.getTitle())); + row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getUrl(), "") : firstNonBlank(resultRow.getUrl(), parsedRow.getUrl())); + row.createCell(col++).setCellValue(resultRow == null ? firstNonBlank(parsedRow.getSku(), "") : firstNonBlank(resultRow.getSku(), parsedRow.getSku())); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getTitleRisk())); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingLlmCellValue(resultRow, resultRow.getAppearanceRisk())); + row.createCell(col++).setCellValue(resultRow == null ? "" : userFacingConclusion(resultRow)); + row.createCell(col).setCellValue(resultRow == null ? "" : userFacingStatus(resultRow)); + } + writeReasonSheet(workbook, headerStyle, rowsToWrite, resultMap, finder); + } + + private static void writeReasonSheet(Workbook workbook, CellStyle headerStyle, + List rowsToWrite, + Map resultMap, + java.util.function.Function finder) { + Sheet sheet = workbook.createSheet("原因"); + Row header = sheet.createRow(0); + List headers = List.of("ASIN", "外观原因", "专利原因", "标题原因"); + for (int i = 0; i < headers.size(); i++) { + Cell cell = header.createCell(i); + cell.setCellValue(headers.get(i)); + cell.setCellStyle(headerStyle); + } + + Set writtenAsins = new LinkedHashSet<>(); + int rowIndex = 1; + for (AppearancePatentParsedRowVo parsedRow : rowsToWrite == null ? List.of() : rowsToWrite) { + if (parsedRow == null) { + continue; + } + String asin = normalize(parsedRow.getAsin()).toUpperCase(Locale.ROOT); + if (asin.isBlank() || !writtenAsins.add(asin)) { + continue; + } + AppearancePatentResultRowDto resultRow = finder.apply(parsedRow); + Row row = sheet.createRow(rowIndex++); + row.createCell(0).setCellValue(asin); + row.createCell(1).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getAppearanceReason(), "")); + row.createCell(2).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getPatentReason(), "")); + row.createCell(3).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getTitleReason(), "")); + rowIndex++; + } + } + + public static AppearancePatentResultRowDto findResultRow(AppearancePatentParsedRowVo parsedRow, + Map resultMap) { + if (parsedRow == null || resultMap == null || resultMap.isEmpty()) { + return null; + } + AppearancePatentResultRowDto resultRow = resultMap.get(rowKey(parsedRow)); + if (resultRow != null) { + return resultRow; + } + String legacyKey = legacyRowKey(parsedRow); + if (legacyKey.isBlank()) { + return null; + } + return resultMap.get(legacyKey); + } + + public static String legacyRowKey(AppearancePatentParsedRowVo row) { + if (row == null) { + return ""; + } + return rowKey(row.getDisplayId(), row.getAsin(), row.getCountry()); + } + + public static String rowKey(AppearancePatentParsedRowVo row) { + if (row == null) { + return ""; + } + return rowKey(row.getDisplayId(), row.getAsin(), row.getCountry()); + } + + public static String rowKey(AppearancePatentResultRowDto row) { + if (row == null) { + return ""; + } + return rowKey(row.getId(), row.getAsin(), row.getCountry()); + } + + public static String rowKey(String id, String asin, String country) { + return normalize(id) + "::" + normalize(asin).toUpperCase(Locale.ROOT) + "::" + normalize(country); + } + + public static String readValueByHeader(AppearancePatentParsedRowVo row, String... candidates) { + if (row == null || row.getValues() == null || row.getValues().isEmpty() || candidates == null) { + return ""; + } + for (Map.Entry entry : row.getValues().entrySet()) { + String header = normalize(entry.getKey()).toLowerCase(Locale.ROOT); + for (String candidate : candidates) { + String expected = normalize(candidate).toLowerCase(Locale.ROOT); + if (!expected.isBlank() && header.contains(expected)) { + return entry.getValue() == null ? "" : entry.getValue(); + } + } + } + return ""; + } + + public static String resolveBrand(AppearancePatentResultRowDto resultRow, AppearancePatentParsedRowVo parsedRow) { + String pythonBrand = resultRow == null ? "" : resultRow.getBrand(); + if (pythonBrand != null && !pythonBrand.isBlank()) { + return pythonBrand.trim(); + } + if (parsedRow == null || parsedRow.getValues() == null) { + return ""; + } + for (Map.Entry entry : parsedRow.getValues().entrySet()) { + String header = entry.getKey() == null ? "" : entry.getKey().trim().toLowerCase(Locale.ROOT); + if (header.contains("品牌") || header.contains("brand")) { + return entry.getValue() == null ? "" : entry.getValue(); + } + } + return ""; + } + + public static String resolvePrice(AppearancePatentResultRowDto resultRow) { + return resultRow == null || resultRow.getPrice() == null ? "" : resultRow.getPrice().trim(); + } + + public static String userFacingLlmCellValue(AppearancePatentResultRowDto row, String value) { + String normalizedValue = normalize(value); + if (!normalizedValue.isBlank() && !isTechnicalLlmFailure(normalizedValue)) { + return value; + } + // llm 技术性失败:有错误信息则放入错误信息,没有则留空 + if (row != null && isTechnicalLlmFailure(row.getError())) { + return firstNonBlank(row.getError(), ""); + } + return firstNonBlank(value, ""); + } + + public static String userFacingConclusion(AppearancePatentResultRowDto row) { + if (row == null) { + return ""; + } + String conclusion = normalize(row.getConclusion()); + if (!conclusion.isBlank() && !isTechnicalLlmFailure(conclusion)) { + return row.getConclusion(); + } + // llm 技术性失败:有错误信息则放入错误信息,没有则留空 + if (isTechnicalLlmFailure(row.getError())) { + return firstNonBlank(row.getError(), ""); + } + return firstNonBlank(row.getConclusion(), ""); + } + + public static String userFacingStatus(AppearancePatentResultRowDto row) { + if (row == null) { + return ""; + } + String conclusion = userFacingConclusion(row); + return resolveResultStatus(conclusion); + } + + public static String resolveResultStatus(String conclusion) { + String normalized = conclusion == null ? "" : conclusion.trim(); + return normalized.isBlank() ? "失败" : "成功"; + } + + public static boolean isTechnicalLlmFailure(String value) { + String normalized = normalize(value).toLowerCase(Locale.ROOT); + return normalized.contains("coze") || normalized.contains("llm") + || normalized.contains("结果不完整") + || normalized.contains("工作流节点执行超限") + || normalized.contains("调用超时") + || normalized.contains("timeout"); + } + + private static String firstNonBlank(String preferred, String fallback) { + return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); + } + + private static String normalize(String val) { + return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " "); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java index 83de3b19..b3a2ca3c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/controller/CollectDataController.java @@ -27,6 +27,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -113,6 +115,14 @@ public class CollectDataController { return ApiResponse.success(service.progressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(service.progressLight(request.getTaskIds())); + } + @DeleteMapping("/tasks/{taskId}") @Operation(summary = "删除任务", description = "删除任务及其明细行、关联结果记录。") public ApiResponse deleteTask( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java index 5308b125..7f651b6a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/collectdata/service/CollectDataService.java @@ -87,6 +87,8 @@ import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.regex.Pattern; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -94,6 +96,10 @@ import java.util.regex.Pattern; public class CollectDataService { public static final String MODULE_TYPE = "COLLECT_DATA"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } public static final int DEFAULT_PAGE_SIZE = 50; /** @@ -152,6 +158,7 @@ public class CollectDataService { /** 结果明细 chunk 级读取器:生成结果文件时按 chunk 一次读取,替代逐行对象读取。 */ private final CollectDataResultDetailReader resultDetailReader; + private final TaskProgressLightAssembler taskProgressLightAssembler; @Value("${aiimage.collect-data.stale-timeout-minutes:30}") private long staleTimeoutMinutes; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java index 2d923207..81a41104 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/controller/DeleteBrandRunController.java @@ -34,6 +34,8 @@ import org.springframework.web.server.ResponseStatusException; import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @@ -90,6 +92,14 @@ public class DeleteBrandRunController { return ApiResponse.success(deleteBrandRunService.getTaskProgress(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(deleteBrandRunService.progressLight(request.getTaskIds())); + } + @GetMapping("/tasks/{taskId}/deletion-status") @Operation(summary = "获取删除品牌任务删除状态", description = "供 Python/插件按 taskId 查询该删除品牌任务是否已被前端删空。") public ApiResponse getTaskDeletionStatus( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java index 75c4515d..7830296c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandRunService.java @@ -68,6 +68,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -75,6 +77,10 @@ import java.util.Set; public class DeleteBrandRunService { private static final String MODULE_TYPE = "DELETE_BRAND"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final Duration TASK_LOCK_TTL = TaskDistributedLockService.DEFAULT_LOCK_TTL; private static final long TASK_LOCK_WAIT_MILLIS = TaskDistributedLockService.DEFAULT_WAIT_MILLIS; @@ -92,6 +98,7 @@ public class DeleteBrandRunService { private final TaskDistributedLockService taskDistributedLockService; private final TaskFileJobService taskFileJobService; private final PlatformTransactionManager transactionManager; + private final TaskProgressLightAssembler taskProgressLightAssembler; private TransactionTemplate newRequiresNewTemplate() { TransactionTemplate template = new TransactionTemplate(transactionManager); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/controller/InvalidAsinDataController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/controller/InvalidAsinDataController.java index 39540261..65f2c8d5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/controller/InvalidAsinDataController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/controller/InvalidAsinDataController.java @@ -63,11 +63,13 @@ public class InvalidAsinDataController { @Parameter(description = "页码") @RequestParam(defaultValue = "1") Long page, @Parameter(description = "每页数量") @RequestParam(defaultValue = "15") Long pageSize, @Parameter(description = "模糊搜索关键字") @RequestParam(required = false) String keyword, + @Parameter(description = "ASIN 模糊搜索") @RequestParam(required = false) String dataValue, + @Parameter(description = "品牌模糊搜索") @RequestParam(required = false) String brand, @Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId, HttpServletRequest request) { RequestOperator operator = requireInvalidAsinDataAccess(request); return ApiResponse.success(invalidAsinDataService.page( - page, pageSize, keyword, groupId, operator.id(), operator.superAdmin())); + page, pageSize, keyword, dataValue, brand, groupId, operator.id(), operator.superAdmin())); } @PostMapping diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataService.java index 80948ab9..9abdf17e 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataService.java @@ -31,15 +31,19 @@ public class InvalidAsinDataService { private final InvalidAsinDataMapper invalidAsinDataMapper; private final ShopManageGroupService shopManageGroupService; - public InvalidAsinDataPageVo page(long page, long pageSize, String keyword, Long groupId, Long operatorId, boolean superAdmin) { + public InvalidAsinDataPageVo page(long page, long pageSize, String keyword, String dataValue, String brand, Long groupId, Long operatorId, boolean superAdmin) { long safePage = Math.max(page, 1); long safePageSize = Math.min(Math.max(pageSize, 1), 100); String safeKeyword = keyword == null ? "" : keyword.trim(); + String safeDataValue = dataValue == null ? "" : dataValue.trim(); + String safeBrand = brand == null ? "" : brand.trim(); LambdaQueryWrapper query = new LambdaQueryWrapper() .and(!safeKeyword.isEmpty(), wrapper -> wrapper .like(InvalidAsinDataEntity::getDataValue, safeKeyword) .or() .like(InvalidAsinDataEntity::getBrand, safeKeyword)) + .like(!safeDataValue.isEmpty(), InvalidAsinDataEntity::getDataValue, safeDataValue) + .like(!safeBrand.isEmpty(), InvalidAsinDataEntity::getBrand, safeBrand) .orderByDesc(InvalidAsinDataEntity::getId); if (!superAdmin) { Long fixedGroupId = resolveFixedAccessibleGroupId(operatorId); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java index c4eeba00..a75dd21d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/controller/PatrolDeleteController.java @@ -41,6 +41,8 @@ import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -167,6 +169,14 @@ public class PatrolDeleteController { return ApiResponse.success(patrolDeleteTaskService.getTaskProgressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(patrolDeleteTaskService.progressLight(request.getTaskIds())); + } + @PostMapping("/tasks") @Operation(summary = "创建巡店删除任务", description = "根据已匹配的店铺创建任务和占位结果记录,后续由 Python 端处理并回传结果。") public ApiResponse createTask( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java index bee0f8eb..97179cec 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/patroldelete/service/PatrolDeleteTaskService.java @@ -43,6 +43,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -50,6 +52,10 @@ import java.util.Objects; public class PatrolDeleteTaskService { private static final String MODULE_TYPE = "PATROL_DELETE"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final int RESULT_PENDING = -1; private static final int RESULT_FAILED = 0; @@ -70,6 +76,7 @@ public class PatrolDeleteTaskService { private final TaskProgressSnapshotService taskProgressSnapshotService; private final TaskScopePayloadStorageService taskScopePayloadStorageService; private final TaskDistributedLockService taskDistributedLockService; + private final TaskProgressLightAssembler taskProgressLightAssembler; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java index f64e8277..1ae6048f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuSchemaInitializer.java @@ -50,6 +50,7 @@ public class PermissionMenuSchemaInitializer { private static final List DEFAULT_ADMIN_MENUS = List.of( new DefaultAdminMenu("用户管理", "admin_users", "users", 10), new DefaultAdminMenu("菜单权限配置", "admin_columns", "columns", 20), + new DefaultAdminMenu("分组管理", "admin_group_manage", "group-manage", 25), new DefaultAdminMenu("去重数据汇总", "admin_dedupe_total_data", "dedupe-total-data", 30), new DefaultAdminMenu("无效ASIN数据", "admin_invalid_asin_data", "invalid-asin-data", 35), new DefaultAdminMenu("店铺密钥管理", "admin_shop_keys", "shop-keys", 40), diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java index 02a79c9f..6c932091 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/controller/PriceTrackController.java @@ -49,6 +49,8 @@ import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -264,6 +266,14 @@ public class PriceTrackController { return ApiResponse.success(priceTrackTaskService.getTaskProgressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(priceTrackTaskService.progressLight(request.getTaskIds())); + } + @PostMapping("/tasks/{taskId}/result") @Operation( summary = "Python 回传处理结果", diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index 7dc4a81d..425362ec 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -54,6 +54,8 @@ import java.util.Map; import java.util.Objects; import java.util.Locale; import java.util.concurrent.atomic.AtomicReference; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -61,6 +63,10 @@ import java.util.concurrent.atomic.AtomicReference; public class PriceTrackTaskService { private static final String MODULE_TYPE = "PRICE_TRACK"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final String ASIN_ROWS_PAYLOAD_SCOPE = "price-track-asin-rows"; private static final String NO_USABLE_ROWS_ERROR = "未收到有效跟价数据,未生成结果文件"; @@ -79,6 +85,7 @@ public class PriceTrackTaskService { private final TaskResultPayloadService taskResultPayloadService; private final TaskFileJobService taskFileJobService; private final TaskDistributedLockService taskDistributedLockService; + private final TaskProgressLightAssembler taskProgressLightAssembler; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java index b67d2883..045b90a1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/controller/ProductRiskResolveController.java @@ -44,6 +44,8 @@ import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -165,6 +167,14 @@ public class ProductRiskResolveController { return ApiResponse.success(productRiskTaskService.getTaskProgressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(productRiskTaskService.progressLight(request.getTaskIds())); + } + @PostMapping("/tasks/{taskId}/result") @Operation( summary = "Python 回传处理结果", diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java index d451a340..fd23d1d8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productrisk/service/ProductRiskTaskService.java @@ -47,6 +47,8 @@ import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -54,6 +56,10 @@ import java.util.Objects; public class ProductRiskTaskService { private static final String MODULE_TYPE = "PRODUCT_RISK_RESOLVE"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } private static final String CONTENT_TYPE_ZIP = "application/zip"; private final FileTaskMapper fileTaskMapper; @@ -70,6 +76,7 @@ public class ProductRiskTaskService { private final TaskResultItemService taskResultItemService; private final TaskProgressSnapshotService taskProgressSnapshotService; private final TaskDistributedLockService taskDistributedLockService; + private final TaskProgressLightAssembler taskProgressLightAssembler; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = productRiskTaskCacheService.getTaskCacheBatch(List.of(taskId)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java index 2e0c6219..e606f440 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/controller/PublishController.java @@ -24,6 +24,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -104,6 +106,15 @@ public class PublishController { return ApiResponse.success(publishTaskService.getTaskProgress(request.getUserId(), request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。user_id 可省略,传入时仅返回该用户的任务。") + public ApiResponse progressLight( + @Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(publishTaskService.progressLight(request.getUserId(), request.getTaskIds())); + } + @PostMapping("/tasks/{taskId}/result") @Operation( summary = "Python 按文件分片回传上架结果", diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java index 8a35d02e..b33ef376 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/publish/service/PublishTaskService.java @@ -4,6 +4,7 @@ import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.crypto.digest.DigestUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; @@ -70,6 +71,8 @@ import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Collectors; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Slf4j @Service @@ -77,6 +80,10 @@ import java.util.stream.Collectors; public class PublishTaskService { public static final String MODULE_TYPE = "PUBLISH"; + + public TaskProgressLightBatchVo progressLight(Long userId, List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, userId, taskIds); + } public static final int DEFAULT_PAGE_SIZE = 50; private static final int MAX_PAGE_SIZE = 200; private static final int INSERT_BATCH_SIZE = 500; @@ -102,6 +109,7 @@ public class PublishTaskService { private final ObjectMapper objectMapper; private final TransactionTemplate transactionTemplate; private final InstanceMetadata instanceMetadata; + private final TaskProgressLightAssembler taskProgressLightAssembler; @Value("${aiimage.publish.stale-timeout-minutes:30}") private int staleTimeoutMinutes; @@ -340,10 +348,30 @@ public class PublishTaskService { public PublishDashboardVo dashboard(Long userId) { validateUserId(userId); PublishDashboardVo response = new PublishDashboardVo(); - response.setPendingCount(countTasks(userId, STATUS_PENDING)); - response.setRunningCount(countTasks(userId, STATUS_RUNNING)); - response.setSuccessCount(countTasks(userId, STATUS_SUCCESS)); - response.setFailedCount(countTasks(userId, STATUS_FAILED)); + response.setPendingCount(0L); + response.setRunningCount(0L); + response.setSuccessCount(0L); + response.setFailedCount(0L); + for (Map row : fileTaskMapper.selectMaps(new QueryWrapper() + .select("status", "count(*) AS cnt") + .eq("module_type", MODULE_TYPE) + .eq("user_id", userId) + .groupBy("status"))) { + Object status = row.get("status"); + Object count = row.get("cnt"); + if (status == null || count == null) { + continue; + } + long n = ((Number) count).longValue(); + switch (status.toString()) { + case STATUS_PENDING -> response.setPendingCount(n); + case STATUS_RUNNING -> response.setRunningCount(n); + case STATUS_SUCCESS -> response.setSuccessCount(n); + case STATUS_FAILED -> response.setFailedCount(n); + default -> { + } + } + } response.setRecent(history(userId, 10).getItems()); return response; } @@ -1741,13 +1769,6 @@ public class PublishTaskService { return STATUS_SUCCESS.equals(status) || STATUS_FAILED.equals(status); } - private long countTasks(Long userId, String status) { - return Objects.requireNonNullElse(fileTaskMapper.selectCount(new LambdaQueryWrapper() - .eq(FileTaskEntity::getModuleType, MODULE_TYPE) - .eq(FileTaskEntity::getUserId, userId) - .eq(FileTaskEntity::getStatus, status)), 0L); - } - private List normalizeTaskIds(List taskIds) { if (taskIds == null) { return List.of(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java index 0a76427e..c5b45c23 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java @@ -39,6 +39,8 @@ import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -137,6 +139,14 @@ public class QueryAsinTaskController { return ApiResponse.success(queryAsinTaskService.getTaskProgressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(queryAsinTaskService.progressLight(request.getTaskIds())); + } + @PostMapping("/tasks") @Operation(summary = "创建查询 ASIN 任务", description = "根据已匹配的店铺创建任务和占位结果记录,并把后台维护的整张 ASIN 表数据随店铺项返回给 Python 端。") public ApiResponse createTask( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java index 809fa81b..ff70efa1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java @@ -42,6 +42,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -49,6 +51,10 @@ import java.util.Objects; public class QueryAsinTaskService { private static final String MODULE_TYPE = "QUERY_ASIN"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final int RESULT_PENDING = -1; private static final int RESULT_FAILED = 0; @@ -68,6 +74,7 @@ public class QueryAsinTaskService { private final TaskResultItemService taskResultItemService; private final TaskProgressSnapshotService taskProgressSnapshotService; private final TaskDistributedLockService taskDistributedLockService; + private final TaskProgressLightAssembler taskProgressLightAssembler; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java index b1dafbd3..cf88c21c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/ShopDataCrawlTaskController.java @@ -33,6 +33,8 @@ import org.springframework.web.server.ResponseStatusException; import java.io.InputStream; import java.net.URI; import java.util.List; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -151,6 +153,14 @@ public class ShopDataCrawlTaskController { return ApiResponse.success(taskService.getTaskProgressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(taskService.progressLight(request.getTaskIds())); + } + @PostMapping("/tasks") @Operation( summary = "创建店铺数据抓取任务", diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java index 80acb805..132aea02 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyService.java @@ -1,14 +1,13 @@ package com.nanri.aiimage.modules.shopdatacrawl.service; import com.nanri.aiimage.common.exception.BusinessException; -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.util.BoundedImageCache; import com.nanri.aiimage.modules.shopdatacrawl.util.ShopDataCrawlPrefetchBudget; +import com.nanri.aiimage.modules.shopdatacrawl.service.support.ShopDataCrawlSheetBuilder; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; import lombok.extern.slf4j.Slf4j; -import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.ClientAnchor; import org.apache.poi.ss.usermodel.Drawing; @@ -27,7 +26,6 @@ import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.InputStream; -import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -35,16 +33,17 @@ import java.util.Map; @Service @Slf4j public class ShopDataCrawlExcelAssemblyService { - static final List COUNTRIES = List.of("UK", "DE", "FR", "ES", "IT"); - static final List SHEETS = List.of("英国", "德国", "法国", "西班牙", "意大利"); - static final List LEGACY_HEADERS = List.of("日期", "ASIN", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价"); - static final List HEADERS_WITHOUT_BRAND = List.of("日期", "ASIN", "商品图片", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价"); - static final List HEADERS = List.of("日期", "ASIN", "商品图片", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价", "品牌"); + /** 常量已抽取到 ShopDataCrawlSheetBuilder,此处保留别名供既有调用方/测试引用。 */ + static final List COUNTRIES = ShopDataCrawlSheetBuilder.COUNTRIES; + static final List SHEETS = ShopDataCrawlSheetBuilder.SHEETS; + static final List LEGACY_HEADERS = ShopDataCrawlSheetBuilder.LEGACY_HEADERS; + static final List HEADERS_WITHOUT_BRAND = ShopDataCrawlSheetBuilder.HEADERS_WITHOUT_BRAND; + static final List HEADERS = ShopDataCrawlSheetBuilder.HEADERS; private static final String TEMPLATE = "templates/shop-data-crawl/文档格式.xlsx"; - private static final int IMAGE_COLUMN = 2; - private static final int BRAND_COLUMN = HEADERS.size() - 1; - private static final int IMAGE_COLUMN_WIDTH = 18 * 256; - private static final float IMAGE_ROW_HEIGHT_POINTS = 80f; + private static final int IMAGE_COLUMN = ShopDataCrawlSheetBuilder.IMAGE_COLUMN; + private static final int BRAND_COLUMN = ShopDataCrawlSheetBuilder.BRAND_COLUMN; + private static final int IMAGE_COLUMN_WIDTH = ShopDataCrawlSheetBuilder.IMAGE_COLUMN_WIDTH; + private static final float IMAGE_ROW_HEIGHT_POINTS = ShopDataCrawlSheetBuilder.IMAGE_ROW_HEIGHT_POINTS; /** 图片缓存默认上限:64MB 字节预算 / 2000 条目,超过按 FIFO 淘汰,保证组装期内存有界。 */ private static final long DEFAULT_IMAGE_CACHE_MAX_BYTES = 64L * 1024 * 1024; private static final int DEFAULT_IMAGE_CACHE_MAX_ENTRIES = 2000; @@ -189,12 +188,7 @@ public class ShopDataCrawlExcelAssemblyService { List rows, BoundedImageCache imageCache, Map pictureIndexes) { - Sheet sheet = workbook.createSheet(SHEETS.get(index)); - Row header = sheet.createRow(0); - for (int column = 0; column < HEADERS.size(); column++) { - header.createCell(column).setCellValue(HEADERS.get(column)); - } - sheet.setColumnWidth(IMAGE_COLUMN, IMAGE_COLUMN_WIDTH); + Sheet sheet = ShopDataCrawlSheetBuilder.createStreamingSheet(workbook, index); int rowIndex = 1; for (ShopDataCrawlRowDto value : rows == null ? List.of() : rows) { Row row = sheet.createRow(rowIndex++); @@ -203,27 +197,7 @@ public class ShopDataCrawlExcelAssemblyService { } void validateTemplate(XSSFWorkbook workbook) { - if (workbook.getNumberOfSheets() != SHEETS.size()) { - throw new BusinessException("店铺数据抓取模板工作表数量不正确"); - } - for (int i = 0; i < SHEETS.size(); i++) { - Sheet sheet = workbook.getSheetAt(i); - if (!SHEETS.get(i).equals(sheet.getSheetName())) { - throw new BusinessException("店铺数据抓取模板工作表顺序不正确"); - } - Row header = sheet.getRow(0); - boolean currentTemplate = header != null && "商品图片".equals(cellText(header, IMAGE_COLUMN)); - boolean templateHasBrand = "品牌".equals(cellText(header, BRAND_COLUMN)); - List expectedHeaders = currentTemplate - ? (templateHasBrand ? HEADERS : HEADERS_WITHOUT_BRAND) - : LEGACY_HEADERS; - for (int column = 0; column < expectedHeaders.size(); column++) { - String actual = header == null || header.getCell(column) == null ? "" : header.getCell(column).getStringCellValue().trim(); - if (!expectedHeaders.get(column).equals(actual)) { - throw new BusinessException("店铺数据抓取模板表头不正确: " + sheet.getSheetName()); - } - } - } + ShopDataCrawlSheetBuilder.validateTemplate(workbook); } private void writeSheet(XSSFWorkbook workbook, @@ -232,32 +206,16 @@ public class ShopDataCrawlExcelAssemblyService { BoundedImageCache imageCache, Map pictureIndexes) { Row header = sheet.getRow(0); - Row styleRow = sheet.getRow(1); - boolean currentTemplate = header != null && "商品图片".equals(cellText(header, IMAGE_COLUMN)); - boolean templateHasBrand = "品牌".equals(cellText(header, BRAND_COLUMN)); - CellStyle[] styles = new CellStyle[HEADERS.size()]; - for (int column = 0; column < styles.length; column++) { - int sourceColumn = templateColumnForOutput(column, currentTemplate, templateHasBrand); - Cell cell = styleRow == null ? null : styleRow.getCell(sourceColumn); - styles[column] = cell == null ? null : cell.getCellStyle(); - } - int[] columnWidths = new int[HEADERS.size()]; - for (int column = 0; column < columnWidths.length; column++) { - int sourceColumn = templateColumnForOutput(column, currentTemplate, templateHasBrand); - columnWidths[column] = sheet.getColumnWidth(sourceColumn); - } - writeHeaders(sheet, currentTemplate, templateHasBrand); + boolean currentTemplate = header != null && "商品图片".equals(ShopDataCrawlSheetBuilder.cellText(header, IMAGE_COLUMN)); + boolean templateHasBrand = "品牌".equals(ShopDataCrawlSheetBuilder.cellText(header, BRAND_COLUMN)); + CellStyle[] styles = ShopDataCrawlSheetBuilder.templateStyles(sheet, currentTemplate, templateHasBrand); + int[] columnWidths = ShopDataCrawlSheetBuilder.templateColumnWidths(sheet, currentTemplate, templateHasBrand); + ShopDataCrawlSheetBuilder.writeHeaders(sheet, currentTemplate, templateHasBrand); for (int column = 0; column < columnWidths.length; column++) { sheet.setColumnWidth(column, columnWidths[column]); } sheet.setColumnWidth(IMAGE_COLUMN, IMAGE_COLUMN_WIDTH); - int last = sheet.getLastRowNum(); - for (int rowIndex = 1; rowIndex <= last; rowIndex++) { - Row row = sheet.getRow(rowIndex); - if (row != null) { - sheet.removeRow(row); - } - } + ShopDataCrawlSheetBuilder.clearDataRows(sheet); clearSheetPictures(sheet, pictureIndexes); int rowIndex = 1; for (ShopDataCrawlRowDto value : rows == null ? List.of() : rows) { @@ -273,43 +231,13 @@ public class ShopDataCrawlExcelAssemblyService { CellStyle[] styles, BoundedImageCache imageCache, Map pictureIndexes) { - String[] values = {value.getDate(), value.getAsin(), "", value.getInventorySales(), value.getSalesRank(), - value.getPageViews(), value.getUnitsSold(), value.getPrice(), value.getRecommendedOffer(), value.getBrand()}; - for (int column = 0; column < values.length; column++) { - Cell cell = row.createCell(column); - if (styles[column] != null) cell.setCellStyle(styles[column]); - cell.setCellValue(values[column] == null ? "" : values[column]); - } - if (!blank(value.getCommodityImage())) { + ShopDataCrawlSheetBuilder.writeDataRowValues(row, value, styles); + if (!ShopDataCrawlSheetBuilder.blank(value.getCommodityImage())) { row.setHeightInPoints(IMAGE_ROW_HEIGHT_POINTS); embedImage(workbook, sheet, row, value.getCommodityImage(), imageCache, pictureIndexes); } } - private void writeHeaders(Sheet sheet, boolean currentTemplate, boolean templateHasBrand) { - Row header = sheet.getRow(0); - if (header == null) header = sheet.createRow(0); - CellStyle[] styles = new CellStyle[HEADERS.size()]; - for (int column = 0; column < HEADERS.size(); column++) { - int sourceColumn = templateColumnForOutput(column, currentTemplate, templateHasBrand); - Cell source = header.getCell(sourceColumn); - styles[column] = source == null ? null : source.getCellStyle(); - } - for (int column = 0; column < HEADERS.size(); column++) { - Cell cell = header.getCell(column); - if (cell == null) cell = header.createCell(column); - if (styles[column] != null) cell.setCellStyle(styles[column]); - cell.setCellValue(HEADERS.get(column)); - } - } - - private int templateColumnForOutput(int outputColumn, boolean currentTemplate, boolean templateHasBrand) { - if (outputColumn == BRAND_COLUMN) { - return templateHasBrand ? BRAND_COLUMN : 1; - } - return currentTemplate || outputColumn < IMAGE_COLUMN ? outputColumn : outputColumn - 1; - } - private void embedImage(Workbook workbook, Sheet sheet, Row row, @@ -357,26 +285,15 @@ public class ShopDataCrawlExcelAssemblyService { } private String cellText(Row row, int column) { - Cell cell = row == null ? null : row.getCell(column); - return cell == null ? "" : cell.getStringCellValue().trim(); + return ShopDataCrawlSheetBuilder.cellText(row, column); } private boolean blank(String value) { - return value == null || value.isBlank(); + return ShopDataCrawlSheetBuilder.blank(value); } private Map> rowsByCountry(List items) { - Map> result = new LinkedHashMap<>(); - COUNTRIES.forEach(country -> result.put(country, new ArrayList<>())); - if (items == null) return result; - for (ShopDataCrawlResultItemVo item : items) { - if (item == null || Boolean.FALSE.equals(item.getSuccess()) || item.getCountryResults() == null) continue; - for (ShopDataCrawlCountryResultDto countryResult : item.getCountryResults()) { - String country = countryResult == null || countryResult.getCountry() == null ? "" : countryResult.getCountry().trim().toUpperCase(); - if (result.containsKey(country) && countryResult.getItems() != null) result.get(country).addAll(countryResult.getItems()); - } - } - return result; + return ShopDataCrawlSheetBuilder.rowsByCountry(items); } private void clearSheetPictures(Sheet sheet, Map pictureIndexes) { @@ -393,10 +310,6 @@ public class ShopDataCrawlExcelAssemblyService { } private int totalDataRows(XSSFWorkbook workbook) { - int total = 0; - for (int i = 0; i < workbook.getNumberOfSheets(); i++) { - total += Math.max(0, workbook.getSheetAt(i).getLastRowNum()); - } - return total; + return ShopDataCrawlSheetBuilder.totalDataRows(workbook); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java index 96e60d7d..c991d6fc 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java @@ -16,6 +16,8 @@ import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto; import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlCreateTaskRequest; import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlShopPayloadDto; import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlSubmitResultRequest; +import com.nanri.aiimage.modules.shopdatacrawl.service.support.ShopDataCrawlHistoryAssembler; +import com.nanri.aiimage.modules.shopdatacrawl.service.support.ShopDataCrawlRowNormalizer; import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlTaskItemDto; import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyFileEntity; import com.nanri.aiimage.modules.shopdatacrawl.model.entity.ShopDataCrawlDailyMemberEntity; @@ -71,6 +73,8 @@ import java.util.Set; import java.util.TreeMap; import java.util.UUID; import java.util.function.Supplier; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -78,6 +82,10 @@ import java.util.function.Supplier; public class ShopDataCrawlTaskService { private static final String MODULE_TYPE = "SHOP_DATA_CRAWL"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final int RESULT_PENDING = -1; private static final int RESULT_FAILED = 0; @@ -91,8 +99,6 @@ public class ShopDataCrawlTaskService { private static final String INTERRUPTED_MESSAGE = "Python 在该店铺结果提交完成前中断"; private static final String PARTIAL_RESULT_MESSAGE = "Python 中断,已保留已回传的部分数据"; private static final String RESULT_CHUNK_SCOPE_PREFIX = "result-chunks:"; - /** 国家结果行去重键字段分隔符(控制字符,字段值 trim 后不可能包含)。 */ - private static final String ROW_KEY_SEPARATOR = ""; private final FileTaskMapper fileTaskMapper; private final FileResultMapper fileResultMapper; @@ -113,6 +119,7 @@ public class ShopDataCrawlTaskService { private final InstanceMetadata instanceMetadata; private final ShopDataCrawlDailyFileService dailyFileService; private final PlatformTransactionManager transactionManager; + private final TaskProgressLightAssembler taskProgressLightAssembler; @Value("${aiimage.shop-data-crawl.stale-timeout-minutes:30}") private long staleTimeoutMinutes; @@ -999,11 +1006,7 @@ public class ShopDataCrawlTaskService { } private String trimToNull(String value) { - if (value == null) { - return null; - } - String normalized = value.trim(); - return normalized.isEmpty() ? null : normalized; + return ShopDataCrawlRowNormalizer.trimToNull(value); } private long countTasks(Long userId, List statuses) { @@ -1108,26 +1111,11 @@ public class ShopDataCrawlTaskService { } private ShopDataCrawlResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, ShopDataCrawlResultItemVo snapshot, TaskFileJobEntity job) { - ShopDataCrawlResultItemVo item = snapshot != null ? snapshot : new ShopDataCrawlResultItemVo(); - item.setResultId(entity.getId()); - item.setTaskId(entity.getTaskId()); - item.setShopName(firstNonBlank(item.getShopName(), entity.getSourceFilename())); - item.setShopId(firstNonBlank(item.getShopId(), entity.getSourceFileUrl())); - item.setTaskStatus(task != null ? task.getStatus() : item.getTaskStatus()); - item.setSuccess(toSuccessFlag(entity.getSuccess(), item.getSuccess())); - item.setError(!blank(entity.getErrorMessage()) ? entity.getErrorMessage() : item.getError()); - item.setCreatedAt(entity.getCreatedAt()); - item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt()); - item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename())); - item.setDownloadUrl(null); - attachFileJobState(item, entity, job); - if (item.getCountryResults() == null) { - item.setCountryResults(new ArrayList<>()); - } - if (item.getCountryCodes() == null) { - item.setCountryCodes(new ArrayList<>()); - } - return item; + return historyAssembler().toHistoryItem(entity, task, snapshot, job); + } + + private ShopDataCrawlHistoryAssembler historyAssembler() { + return new ShopDataCrawlHistoryAssembler(); } private void attachFileJobState(ShopDataCrawlResultItemVo item, FileResultEntity entity) { @@ -1135,14 +1123,7 @@ public class ShopDataCrawlTaskService { } private void attachFileJobState(ShopDataCrawlResultItemVo item, FileResultEntity entity, TaskFileJobEntity job) { - item.setFileReady(!blank(entity.getResultFileUrl())); - if (job == null) { - item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null); - return; - } - item.setFileJobId(job.getId()); - item.setFileStatus(job.getStatus()); - item.setFileError(job.getErrorMessage()); + historyAssembler().attachFileJobState(item, entity, job); } private List dedupeItems(List items) { @@ -2817,18 +2798,7 @@ public class ShopDataCrawlTaskService { } private ShopDataCrawlRowDto copyRow(ShopDataCrawlRowDto source) { - ShopDataCrawlRowDto row = new ShopDataCrawlRowDto(); - row.setDate(trim(source.getDate())); - row.setAsin(trim(source.getAsin())); - row.setBrand(trim(source.getBrand())); - row.setCommodityImage(trim(source.getCommodityImage())); - row.setInventorySales(trim(source.getInventorySales())); - row.setSalesRank(trim(source.getSalesRank())); - row.setPageViews(trim(source.getPageViews())); - row.setUnitsSold(trim(source.getUnitsSold())); - row.setPrice(trim(source.getPrice())); - row.setRecommendedOffer(trim(source.getRecommendedOffer())); - return row; + return ShopDataCrawlRowNormalizer.copyRow(source); } public void handleResultFileJobFailure(TaskFileJobEntity job, String message) { @@ -2856,39 +2826,20 @@ public class ShopDataCrawlTaskService { } private boolean sameRow(ShopDataCrawlRowDto left, ShopDataCrawlRowDto right) { - return left != null && right != null - && Objects.equals(trim(left.getDate()), trim(right.getDate())) - && Objects.equals(trim(left.getAsin()), trim(right.getAsin())) - && Objects.equals(trim(left.getBrand()), trim(right.getBrand())) - && Objects.equals(trim(left.getCommodityImage()), trim(right.getCommodityImage())) - && Objects.equals(trim(left.getInventorySales()), trim(right.getInventorySales())) - && Objects.equals(trim(left.getSalesRank()), trim(right.getSalesRank())) - && Objects.equals(trim(left.getPageViews()), trim(right.getPageViews())) - && Objects.equals(trim(left.getUnitsSold()), trim(right.getUnitsSold())) - && Objects.equals(trim(left.getPrice()), trim(right.getPrice())) - && Objects.equals(trim(left.getRecommendedOffer()), trim(right.getRecommendedOffer())); + return ShopDataCrawlRowNormalizer.sameRow(left, right); } /** 国家结果行稳定去重键:与 sameRow 的 10 字段 trim 比较语义等价,用于 O(1) 去重。 */ static String rowDedupKey(ShopDataCrawlRowDto row) { - if (row == null) { - return null; - } - return trim(row.getDate()) + ROW_KEY_SEPARATOR + trim(row.getAsin()) + ROW_KEY_SEPARATOR + trim(row.getBrand()) - + ROW_KEY_SEPARATOR + trim(row.getCommodityImage()) + ROW_KEY_SEPARATOR + trim(row.getInventorySales()) - + ROW_KEY_SEPARATOR + trim(row.getSalesRank()) + ROW_KEY_SEPARATOR + trim(row.getPageViews()) - + ROW_KEY_SEPARATOR + trim(row.getUnitsSold()) + ROW_KEY_SEPARATOR + trim(row.getPrice()) - + ROW_KEY_SEPARATOR + trim(row.getRecommendedOffer()); + return ShopDataCrawlRowNormalizer.rowDedupKey(row); } private boolean rowEmpty(ShopDataCrawlRowDto row) { - return row == null || (blank(row.getDate()) && blank(row.getAsin()) && blank(row.getBrand()) && blank(row.getCommodityImage()) && blank(row.getInventorySales()) - && blank(row.getSalesRank()) && blank(row.getPageViews()) && blank(row.getUnitsSold()) - && blank(row.getPrice()) && blank(row.getRecommendedOffer())); + return ShopDataCrawlRowNormalizer.rowEmpty(row); } private static String trim(String value) { - return value == null ? "" : value.trim(); + return ShopDataCrawlRowNormalizer.trim(value); } void deleteResultObjectIfUnreferenced(String resultFileUrl) { @@ -2964,18 +2915,7 @@ public class ShopDataCrawlTaskService { } private String normalizeCountry(String country) { - if (country == null) { - return ""; - } - String value = country.trim().toUpperCase(); - return switch (value) { - case "德国" -> "DE"; - case "英国" -> "UK"; - case "法国" -> "FR"; - case "意大利" -> "IT"; - case "西班牙" -> "ES"; - default -> value; - }; + return ShopDataCrawlRowNormalizer.normalizeCountry(country); } private String buildTaskWorkbookFilename(FileTaskEntity task) { @@ -3000,7 +2940,7 @@ public class ShopDataCrawlTaskService { } private String blankToNull(String value) { - return blank(value) ? null : value.trim(); + return ShopDataCrawlRowNormalizer.blankToNull(value); } private void validateUserId(Long userId) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlHistoryAssembler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlHistoryAssembler.java new file mode 100644 index 00000000..1d70b079 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlHistoryAssembler.java @@ -0,0 +1,70 @@ +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 java.util.ArrayList; + +/** + * 任务 103:ShopDataCrawlHistoryAssembler 历史查询组装器。 + * 历史列表 VO 拼装(toHistoryItem + 文件任务状态链 attachFileJobState)从 + * ShopDataCrawlTaskService 原样搬移;只读不落库;输出与现状逐字段一致 + * (快照优先、实体字段兜底、task/job 附加,downloadUrl 恒为空)。 + */ +public class ShopDataCrawlHistoryAssembler { + + private static final int RESULT_PENDING = -1; + private static final int RESULT_SUCCESS = 1; + + public ShopDataCrawlResultItemVo toHistoryItem(FileResultEntity entity, FileTaskEntity task, + ShopDataCrawlResultItemVo snapshot, TaskFileJobEntity job) { + ShopDataCrawlResultItemVo item = snapshot != null ? snapshot : new ShopDataCrawlResultItemVo(); + item.setResultId(entity.getId()); + item.setTaskId(entity.getTaskId()); + item.setShopName(firstNonBlank(item.getShopName(), entity.getSourceFilename())); + item.setShopId(firstNonBlank(item.getShopId(), entity.getSourceFileUrl())); + item.setTaskStatus(task != null ? task.getStatus() : item.getTaskStatus()); + item.setSuccess(toSuccessFlag(entity.getSuccess(), item.getSuccess())); + item.setError(!blank(entity.getErrorMessage()) ? entity.getErrorMessage() : item.getError()); + item.setCreatedAt(entity.getCreatedAt()); + item.setFinishedAt(task != null ? task.getFinishedAt() : item.getFinishedAt()); + item.setOutputFilename(firstNonBlank(item.getOutputFilename(), entity.getResultFilename())); + item.setDownloadUrl(null); + attachFileJobState(item, entity, job); + if (item.getCountryResults() == null) { + item.setCountryResults(new ArrayList<>()); + } + if (item.getCountryCodes() == null) { + item.setCountryCodes(new ArrayList<>()); + } + return item; + } + + public void attachFileJobState(ShopDataCrawlResultItemVo item, FileResultEntity entity, TaskFileJobEntity job) { + item.setFileReady(!blank(entity.getResultFileUrl())); + if (job == null) { + item.setFileStatus(Boolean.TRUE.equals(item.getFileReady()) ? "SUCCESS" : null); + return; + } + item.setFileJobId(job.getId()); + item.setFileStatus(job.getStatus()); + item.setFileError(job.getErrorMessage()); + } + + private Boolean toSuccessFlag(Integer dbValue, Boolean fallback) { + if (dbValue == null || Integer.valueOf(RESULT_PENDING).equals(dbValue)) { + return fallback; + } + return Integer.valueOf(RESULT_SUCCESS).equals(dbValue); + } + + private String firstNonBlank(String first, String second) { + return !blank(first) ? first : second; + } + + private boolean blank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlRowNormalizer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlRowNormalizer.java new file mode 100644 index 00000000..c905b255 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlRowNormalizer.java @@ -0,0 +1,101 @@ +package com.nanri.aiimage.modules.shopdatacrawl.service.support; + +import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlRowDto; + +/** + * 任务 99:ShopDataCrawlRowNormalizer 行解析/归一化器。 + * 规则与 ShopDataCrawlTaskService.trim / blank / blankToNull / trimToNull / + * normalizeCountry / copyRow / rowEmpty / sameRow / rowDedupKey 现状逐字节一致。 + * rowDedupKey 与国家结果行稳定去重键语义(10 字段 trim 拼接)等价于 sameRow 的 + * 逐字段 trim 比较。纯函数无状态。 + */ +public final class ShopDataCrawlRowNormalizer { + + /** 国家结果行去重键字段分隔符(控制字符,字段值 trim 后不可能包含)。 */ + public static final String ROW_KEY_SEPARATOR = ""; + + private ShopDataCrawlRowNormalizer() { + } + + public static String trim(String value) { + return value == null ? "" : value.trim(); + } + + public static boolean blank(String value) { + return value == null || value.isBlank(); + } + + public static String blankToNull(String value) { + return blank(value) ? null : value.trim(); + } + + public static String trimToNull(String value) { + if (value == null) { + return null; + } + String normalized = value.trim(); + return normalized.isEmpty() ? null : normalized; + } + + public static String normalizeCountry(String country) { + if (country == null) { + return ""; + } + String value = country.trim().toUpperCase(); + return switch (value) { + case "德国" -> "DE"; + case "英国" -> "UK"; + case "法国" -> "FR"; + case "意大利" -> "IT"; + case "西班牙" -> "ES"; + default -> value; + }; + } + + public static ShopDataCrawlRowDto copyRow(ShopDataCrawlRowDto source) { + ShopDataCrawlRowDto row = new ShopDataCrawlRowDto(); + row.setDate(trim(source.getDate())); + row.setAsin(trim(source.getAsin())); + row.setBrand(trim(source.getBrand())); + row.setCommodityImage(trim(source.getCommodityImage())); + row.setInventorySales(trim(source.getInventorySales())); + row.setSalesRank(trim(source.getSalesRank())); + row.setPageViews(trim(source.getPageViews())); + row.setUnitsSold(trim(source.getUnitsSold())); + row.setPrice(trim(source.getPrice())); + row.setRecommendedOffer(trim(source.getRecommendedOffer())); + return row; + } + + public static boolean rowEmpty(ShopDataCrawlRowDto row) { + return row == null || (blank(row.getDate()) && blank(row.getAsin()) && blank(row.getBrand()) && blank(row.getCommodityImage()) && blank(row.getInventorySales()) + && blank(row.getSalesRank()) && blank(row.getPageViews()) && blank(row.getUnitsSold()) + && blank(row.getPrice()) && blank(row.getRecommendedOffer())); + } + + public static boolean sameRow(ShopDataCrawlRowDto left, ShopDataCrawlRowDto right) { + return left != null && right != null + && java.util.Objects.equals(trim(left.getDate()), trim(right.getDate())) + && java.util.Objects.equals(trim(left.getAsin()), trim(right.getAsin())) + && java.util.Objects.equals(trim(left.getBrand()), trim(right.getBrand())) + && java.util.Objects.equals(trim(left.getCommodityImage()), trim(right.getCommodityImage())) + && java.util.Objects.equals(trim(left.getInventorySales()), trim(right.getInventorySales())) + && java.util.Objects.equals(trim(left.getSalesRank()), trim(right.getSalesRank())) + && java.util.Objects.equals(trim(left.getPageViews()), trim(right.getPageViews())) + && java.util.Objects.equals(trim(left.getUnitsSold()), trim(right.getUnitsSold())) + && java.util.Objects.equals(trim(left.getPrice()), trim(right.getPrice())) + && java.util.Objects.equals(trim(left.getRecommendedOffer()), trim(right.getRecommendedOffer())); + } + + /** 国家结果行稳定去重键:与 sameRow 的 10 字段 trim 比较语义等价,用于 O(1) 去重。 */ + public static String rowDedupKey(ShopDataCrawlRowDto row) { + if (row == null) { + return null; + } + return trim(row.getDate()) + ROW_KEY_SEPARATOR + trim(row.getAsin()) + ROW_KEY_SEPARATOR + trim(row.getBrand()) + + ROW_KEY_SEPARATOR + trim(row.getCommodityImage()) + ROW_KEY_SEPARATOR + trim(row.getInventorySales()) + + ROW_KEY_SEPARATOR + trim(row.getSalesRank()) + ROW_KEY_SEPARATOR + trim(row.getPageViews()) + + ROW_KEY_SEPARATOR + trim(row.getUnitsSold()) + ROW_KEY_SEPARATOR + trim(row.getPrice()) + + ROW_KEY_SEPARATOR + trim(row.getRecommendedOffer()); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlSheetBuilder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlSheetBuilder.java new file mode 100644 index 00000000..9ba74254 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlSheetBuilder.java @@ -0,0 +1,177 @@ +package com.nanri.aiimage.modules.shopdatacrawl.service.support; + +import com.nanri.aiimage.common.exception.BusinessException; +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.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 java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 任务 100:ShopDataCrawlSheetBuilder Sheet 构造器。 + * 结果 Workbook/Sheet 构造辅助(表头、列序、样式、行值派生、模板校验)。 + * 与现状 ShopDataCrawlExcelAssemblyService 一致:5 个国家工作表(英国/德国/法国/西班牙/意大利)、 + * 10 列表头(HEADERS,品牌为最后一列)、图片列 2 宽 18*256、行高 80pt、 + * 模板列映射(legacy/current/带品牌变体)、数据行从第 1 行。 + * 不落库、无 IO 依赖;纯函数无状态。 + */ +public final class ShopDataCrawlSheetBuilder { + + public static final List COUNTRIES = List.of("UK", "DE", "FR", "ES", "IT"); + public static final List SHEETS = List.of("英国", "德国", "法国", "西班牙", "意大利"); + public static final List LEGACY_HEADERS = List.of("日期", "ASIN", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价"); + public static final List HEADERS_WITHOUT_BRAND = List.of("日期", "ASIN", "商品图片", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价"); + public static final List HEADERS = List.of("日期", "ASIN", "商品图片", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价", "品牌"); + public static final int IMAGE_COLUMN = 2; + public static final int BRAND_COLUMN = HEADERS.size() - 1; + public static final int IMAGE_COLUMN_WIDTH = 18 * 256; + public static final float IMAGE_ROW_HEIGHT_POINTS = 80f; + + private ShopDataCrawlSheetBuilder() { + } + + /** 流式路径:空 workbook 上自建国家工作表与表头(数据行由调用方写入)。 */ + public static Sheet createStreamingSheet(Workbook workbook, int index) { + Sheet sheet = workbook.createSheet(SHEETS.get(index)); + Row header = sheet.createRow(0); + for (int column = 0; column < HEADERS.size(); column++) { + header.createCell(column).setCellValue(HEADERS.get(column)); + } + sheet.setColumnWidth(IMAGE_COLUMN, IMAGE_COLUMN_WIDTH); + return sheet; + } + + public static void validateTemplate(XSSFWorkbook workbook) { + if (workbook.getNumberOfSheets() != SHEETS.size()) { + throw new BusinessException("店铺数据抓取模板工作表数量不正确"); + } + for (int i = 0; i < SHEETS.size(); i++) { + Sheet sheet = workbook.getSheetAt(i); + if (!SHEETS.get(i).equals(sheet.getSheetName())) { + throw new BusinessException("店铺数据抓取模板工作表顺序不正确"); + } + Row header = sheet.getRow(0); + boolean currentTemplate = header != null && "商品图片".equals(cellText(header, IMAGE_COLUMN)); + boolean templateHasBrand = "品牌".equals(cellText(header, BRAND_COLUMN)); + List expectedHeaders = currentTemplate + ? (templateHasBrand ? HEADERS : HEADERS_WITHOUT_BRAND) + : LEGACY_HEADERS; + for (int column = 0; column < expectedHeaders.size(); column++) { + String actual = header == null || header.getCell(column) == null ? "" : header.getCell(column).getStringCellValue().trim(); + if (!expectedHeaders.get(column).equals(actual)) { + throw new BusinessException("店铺数据抓取模板表头不正确: " + sheet.getSheetName()); + } + } + } + } + + /** 模板路径:从模板第 1 行按列映射提取输出列样式。 */ + public static CellStyle[] templateStyles(Sheet sheet, boolean currentTemplate, boolean templateHasBrand) { + Row styleRow = sheet.getRow(1); + CellStyle[] styles = new CellStyle[HEADERS.size()]; + for (int column = 0; column < styles.length; column++) { + int sourceColumn = templateColumnForOutput(column, currentTemplate, templateHasBrand); + Cell cell = styleRow == null ? null : styleRow.getCell(sourceColumn); + styles[column] = cell == null ? null : cell.getCellStyle(); + } + return styles; + } + + /** 模板路径:按列映射提取模板列宽。 */ + public static int[] templateColumnWidths(Sheet sheet, boolean currentTemplate, boolean templateHasBrand) { + int[] columnWidths = new int[HEADERS.size()]; + for (int column = 0; column < columnWidths.length; column++) { + int sourceColumn = templateColumnForOutput(column, currentTemplate, templateHasBrand); + columnWidths[column] = sheet.getColumnWidth(sourceColumn); + } + return columnWidths; + } + + /** 模板路径:覆写表头文本并保留模板表头样式。 */ + public static void writeHeaders(Sheet sheet, boolean currentTemplate, boolean templateHasBrand) { + Row header = sheet.getRow(0); + if (header == null) header = sheet.createRow(0); + CellStyle[] styles = new CellStyle[HEADERS.size()]; + for (int column = 0; column < HEADERS.size(); column++) { + int sourceColumn = templateColumnForOutput(column, currentTemplate, templateHasBrand); + Cell source = header.getCell(sourceColumn); + styles[column] = source == null ? null : source.getCellStyle(); + } + for (int column = 0; column < HEADERS.size(); column++) { + Cell cell = header.getCell(column); + if (cell == null) cell = header.createCell(column); + if (styles[column] != null) cell.setCellStyle(styles[column]); + cell.setCellValue(HEADERS.get(column)); + } + } + + /** 模板路径:清空第 1 行起的全部数据行(保留表头)。 */ + public static void clearDataRows(Sheet sheet) { + int last = sheet.getLastRowNum(); + for (int rowIndex = 1; rowIndex <= last; rowIndex++) { + Row row = sheet.getRow(rowIndex); + if (row != null) { + sheet.removeRow(row); + } + } + } + + /** 数据行 10 列值布局 + 样式应用(图片嵌入由调用方负责)。 */ + public static void writeDataRowValues(Row row, ShopDataCrawlRowDto value, CellStyle[] styles) { + String[] values = {value.getDate(), value.getAsin(), "", value.getInventorySales(), value.getSalesRank(), + value.getPageViews(), value.getUnitsSold(), value.getPrice(), value.getRecommendedOffer(), value.getBrand()}; + for (int column = 0; column < values.length; column++) { + Cell cell = row.createCell(column); + if (styles[column] != null) cell.setCellStyle(styles[column]); + cell.setCellValue(values[column] == null ? "" : values[column]); + } + } + + public static int templateColumnForOutput(int outputColumn, boolean currentTemplate, boolean templateHasBrand) { + if (outputColumn == BRAND_COLUMN) { + return templateHasBrand ? BRAND_COLUMN : 1; + } + return currentTemplate || outputColumn < IMAGE_COLUMN ? outputColumn : outputColumn - 1; + } + + public static String cellText(Row row, int column) { + Cell cell = row == null ? null : row.getCell(column); + return cell == null ? "" : cell.getStringCellValue().trim(); + } + + public static boolean blank(String value) { + return value == null || value.isBlank(); + } + + /** 结果条目 → 按国家分组的行列表(国家码 trim+UPPER,未命中 5 国列表的行丢弃)。 */ + public static Map> rowsByCountry(List items) { + Map> result = new LinkedHashMap<>(); + COUNTRIES.forEach(country -> result.put(country, new ArrayList<>())); + if (items == null) return result; + for (ShopDataCrawlResultItemVo item : items) { + if (item == null || Boolean.FALSE.equals(item.getSuccess()) || item.getCountryResults() == null) continue; + for (ShopDataCrawlCountryResultDto countryResult : item.getCountryResults()) { + String country = countryResult == null || countryResult.getCountry() == null ? "" : countryResult.getCountry().trim().toUpperCase(); + if (result.containsKey(country) && countryResult.getItems() != null) result.get(country).addAll(countryResult.getItems()); + } + } + return result; + } + + public static int totalDataRows(XSSFWorkbook workbook) { + int total = 0; + for (int i = 0; i < workbook.getNumberOfSheets(); i++) { + total += Math.max(0, workbook.getSheetAt(i).getLastRowNum()); + } + return total; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java index cee07d49..523128c5 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/QueryAsinController.java @@ -49,6 +49,7 @@ public class QueryAsinController { @Parameter(description = "分组ID") @RequestParam(required = false) Long groupId, @Parameter(description = "店铺名") @RequestParam(required = false) String shopName, @Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin, + @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country, @Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId, @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { return ApiResponse.success(queryAsinService.page( @@ -57,6 +58,7 @@ public class QueryAsinController { groupId, shopName, asin, + country, operatorId, Boolean.TRUE.equals(superAdmin))); } @@ -67,9 +69,10 @@ public class QueryAsinController { @Parameter(description = "分组ID") @RequestParam(required = false) Long groupId, @Parameter(description = "店铺名") @RequestParam(required = false) String shopName, @Parameter(description = "商品 ASIN") @RequestParam(required = false) String asin, + @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(required = false) String country, @Parameter(description = "当前操作人用户ID") @RequestParam(required = false) Long operatorId, @Parameter(description = "是否超级管理员") @RequestParam(defaultValue = "false") Boolean superAdmin) { - byte[] bytes = queryAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin)); + byte[] bytes = queryAsinService.export(groupId, shopName, asin, country, operatorId, Boolean.TRUE.equals(superAdmin)); String filename = "query-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx"; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename)) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java index 59e06be6..9a3194db 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/controller/SkipPriceAsinController.java @@ -28,8 +28,10 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.math.BigDecimal; @RestController @RequiredArgsConstructor @@ -49,6 +51,9 @@ public class SkipPriceAsinController { @Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId, @Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName, @Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin, + @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(name = "country", required = false) String country, + @Parameter(description = "最低价下限") @RequestParam(name = "minimum_price_from", required = false) BigDecimal minimumPriceFrom, + @Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo, @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId, @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { return ApiResponse.success(skipPriceAsinService.page( @@ -57,6 +62,9 @@ public class SkipPriceAsinController { groupId, shopName, asin, + country, + minimumPriceFrom, + minimumPriceTo, operatorId, Boolean.TRUE.equals(superAdmin))); } @@ -67,9 +75,13 @@ public class SkipPriceAsinController { @Parameter(description = "分组 ID") @RequestParam(name = "group_id", required = false) Long groupId, @Parameter(description = "店铺名称") @RequestParam(name = "shop_name", required = false) String shopName, @Parameter(description = "商品 ASIN") @RequestParam(name = "asin", required = false) String asin, + @Parameter(description = "国家代码(DE/UK/FR/IT/ES)") @RequestParam(name = "country", required = false) String country, + @Parameter(description = "最低价下限") @RequestParam(name = "minimum_price_from", required = false) BigDecimal minimumPriceFrom, + @Parameter(description = "最低价上限") @RequestParam(name = "minimum_price_to", required = false) BigDecimal minimumPriceTo, @Parameter(description = "当前操作人用户 ID") @RequestParam(name = "operator_id", required = false) Long operatorId, @Parameter(description = "是否超级管理员") @RequestParam(name = "super_admin", defaultValue = "false") Boolean superAdmin) { - byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, operatorId, Boolean.TRUE.equals(superAdmin)); + byte[] bytes = skipPriceAsinService.export(groupId, shopName, asin, country, minimumPriceFrom, minimumPriceTo, + operatorId, Boolean.TRUE.equals(superAdmin)); String filename = "skip-price-asin-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx"; return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename)) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/QueryAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/QueryAsinService.java index aa90cef3..a5cd35f0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/QueryAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/QueryAsinService.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.shopkey.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; import cn.hutool.core.util.IdUtil; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.common.util.ExcelStreamReader; @@ -56,14 +57,15 @@ public class QueryAsinService { private final Map deleteImportProgressMap = new ConcurrentHashMap<>(); public QueryAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin, - Long operatorId, boolean superAdmin) { + String country, Long operatorId, boolean superAdmin) { long safePage = Math.max(page, 1); long safePageSize = Math.min(Math.max(pageSize, 1), 100); - Long total = countFilteredRows(groupId, shopName, asin, operatorId, superAdmin); + Long total = countFilteredRows(groupId, shopName, asin, country, operatorId, superAdmin); List rows = listFilteredRows( groupId, shopName, asin, + country, operatorId, superAdmin, (safePage - 1) * safePageSize, @@ -83,8 +85,8 @@ public class QueryAsinService { return vo; } - public byte[] export(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) { - List rows = listFilteredRows(groupId, shopName, asin, operatorId, superAdmin, null, null); + public byte[] export(Long groupId, String shopName, String asin, String country, Long operatorId, boolean superAdmin) { + List rows = listFilteredRows(groupId, shopName, asin, country, operatorId, superAdmin, null, null); Map groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream() .map(QueryAsinEntity::getGroupId) .filter(id -> id != null && id > 0) @@ -106,35 +108,46 @@ public class QueryAsinService { } } - private Long countFilteredRows(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) { - return queryAsinMapper.selectCount(buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin)); + private Long countFilteredRows(Long groupId, String shopName, String asin, String country, Long operatorId, boolean superAdmin) { + return queryAsinMapper.selectCount(buildFilterQuery(groupId, shopName, asin, country, operatorId, superAdmin)); } - private List listFilteredRows(Long groupId, String shopName, String asin, + private List listFilteredRows(Long groupId, String shopName, String asin, String country, Long operatorId, boolean superAdmin, Long offset, Long limit) { - LambdaQueryWrapper query = buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin); + LambdaQueryWrapper query = buildFilterQuery(groupId, shopName, asin, country, operatorId, superAdmin); if (offset != null && limit != null) { query.last("LIMIT " + Math.max(0L, offset) + ", " + Math.max(1L, limit)); } return queryAsinMapper.selectList(query); } - private LambdaQueryWrapper buildFilterQuery(Long groupId, String shopName, String asin, + private LambdaQueryWrapper buildFilterQuery(Long groupId, String shopName, String asin, String country, Long operatorId, boolean superAdmin) { String safeShopName = normalizeBlank(shopName); String safeAsin = normalizeBlank(asin); + String safeCountry = normalizeBlank(country).toUpperCase(Locale.ROOT); Set accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false); LambdaQueryWrapper query = new LambdaQueryWrapper() .eq(groupId != null && groupId > 0, QueryAsinEntity::getGroupId, groupId) .like(!safeShopName.isEmpty(), QueryAsinEntity::getShopName, safeShopName) - .and(!safeAsin.isEmpty(), wrapper -> wrapper + .orderByDesc(QueryAsinEntity::getId); + if (!safeAsin.isEmpty()) { + if (safeCountry.isEmpty()) { + query.and(wrapper -> wrapper .like(QueryAsinEntity::getAsinDe, safeAsin) .or().like(QueryAsinEntity::getAsinUk, safeAsin) .or().like(QueryAsinEntity::getAsinFr, safeAsin) .or().like(QueryAsinEntity::getAsinIt, safeAsin) - .or().like(QueryAsinEntity::getAsinEs, safeAsin)) - .orderByDesc(QueryAsinEntity::getId); + .or().like(QueryAsinEntity::getAsinEs, safeAsin)); + } else { + query.like(queryAsinCountryColumn(safeCountry), safeAsin); + } + } + if (!safeCountry.isEmpty()) { + SFunction countryColumn = queryAsinCountryColumn(safeCountry); + query.isNotNull(countryColumn).ne(countryColumn, ""); + } if (!superAdmin) { if (accessibleGroupIds.isEmpty()) { query.eq(QueryAsinEntity::getId, -1L); @@ -145,6 +158,17 @@ public class QueryAsinService { return query; } + private SFunction queryAsinCountryColumn(String country) { + return switch (country) { + case "DE" -> QueryAsinEntity::getAsinDe; + case "UK" -> QueryAsinEntity::getAsinUk; + case "FR" -> QueryAsinEntity::getAsinFr; + case "IT" -> QueryAsinEntity::getAsinIt; + case "ES" -> QueryAsinEntity::getAsinEs; + default -> throw new BusinessException("不支持的国家代码: " + country); + }; + } + private void writeExportHeader(Sheet sheet) { Row header = sheet.createRow(0); String[] headers = { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java index 655762cb..d7a3c9a3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.shopkey.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.support.SFunction; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import cn.hutool.core.util.IdUtil; import com.nanri.aiimage.common.exception.BusinessException; @@ -61,14 +62,18 @@ public class SkipPriceAsinService { private final Map skipAsinLookupCache = new ConcurrentHashMap<>(); public SkipPriceAsinPageVo page(long page, long pageSize, Long groupId, String shopName, String asin, + String country, BigDecimal minimumPriceFrom, BigDecimal minimumPriceTo, Long operatorId, boolean superAdmin) { long safePage = Math.max(page, 1); long safePageSize = Math.min(Math.max(pageSize, 1), 100); - Long total = countFilteredRows(groupId, shopName, asin, operatorId, superAdmin); + Long total = countFilteredRows(groupId, shopName, asin, country, minimumPriceFrom, minimumPriceTo, operatorId, superAdmin); List rows = listFilteredRows( groupId, shopName, asin, + country, + minimumPriceFrom, + minimumPriceTo, operatorId, superAdmin, (safePage - 1) * safePageSize, @@ -88,8 +93,11 @@ public class SkipPriceAsinService { return vo; } - public byte[] export(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) { - List rows = listFilteredRows(groupId, shopName, asin, operatorId, superAdmin, null, null); + public byte[] export(Long groupId, String shopName, String asin, String country, + BigDecimal minimumPriceFrom, BigDecimal minimumPriceTo, + Long operatorId, boolean superAdmin) { + List rows = listFilteredRows(groupId, shopName, asin, country, + minimumPriceFrom, minimumPriceTo, operatorId, superAdmin, null, null); Map groupNameById = shopManageGroupService.buildGroupNameMap(rows.stream() .map(SkipPriceAsinEntity::getGroupId) .filter(id -> id != null && id > 0) @@ -111,35 +119,84 @@ public class SkipPriceAsinService { } } - private Long countFilteredRows(Long groupId, String shopName, String asin, Long operatorId, boolean superAdmin) { - return skipPriceAsinMapper.selectCount(buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin)); + private Long countFilteredRows(Long groupId, String shopName, String asin, String country, + BigDecimal minimumPriceFrom, BigDecimal minimumPriceTo, + Long operatorId, boolean superAdmin) { + return skipPriceAsinMapper.selectCount(buildFilterQuery(groupId, shopName, asin, country, + minimumPriceFrom, minimumPriceTo, operatorId, superAdmin)); } - private List listFilteredRows(Long groupId, String shopName, String asin, + private List listFilteredRows(Long groupId, String shopName, String asin, String country, + BigDecimal minimumPriceFrom, BigDecimal minimumPriceTo, Long operatorId, boolean superAdmin, Long offset, Long limit) { - LambdaQueryWrapper query = buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin); + LambdaQueryWrapper query = buildFilterQuery(groupId, shopName, asin, country, + minimumPriceFrom, minimumPriceTo, operatorId, superAdmin); if (offset != null && limit != null) { query.last("LIMIT " + Math.max(0L, offset) + ", " + Math.max(1L, limit)); } return skipPriceAsinMapper.selectList(query); } - private LambdaQueryWrapper buildFilterQuery(Long groupId, String shopName, String asin, + private LambdaQueryWrapper buildFilterQuery(Long groupId, String shopName, String asin, String country, + BigDecimal minimumPriceFrom, BigDecimal minimumPriceTo, Long operatorId, boolean superAdmin) { String safeShopName = normalizeBlank(shopName); String safeAsin = normalizeBlank(asin); + String safeCountry = normalizeBlank(country).toUpperCase(Locale.ROOT); Set accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false); LambdaQueryWrapper query = new LambdaQueryWrapper() .eq(groupId != null && groupId > 0, SkipPriceAsinEntity::getGroupId, groupId) .like(!safeShopName.isEmpty(), SkipPriceAsinEntity::getShopName, safeShopName) - .and(!safeAsin.isEmpty(), wrapper -> wrapper + .orderByDesc(SkipPriceAsinEntity::getId); + if (!safeAsin.isEmpty()) { + if (safeCountry.isEmpty()) { + query.and(wrapper -> wrapper .like(SkipPriceAsinEntity::getAsinDe, safeAsin) .or().like(SkipPriceAsinEntity::getAsinUk, safeAsin) .or().like(SkipPriceAsinEntity::getAsinFr, safeAsin) .or().like(SkipPriceAsinEntity::getAsinIt, safeAsin) - .or().like(SkipPriceAsinEntity::getAsinEs, safeAsin)) - .orderByDesc(SkipPriceAsinEntity::getId); + .or().like(SkipPriceAsinEntity::getAsinEs, safeAsin)); + } else { + query.like(skipPriceAsinCountryColumn(safeCountry), safeAsin); + } + } + if (!safeCountry.isEmpty()) { + SFunction countryColumn = skipPriceAsinCountryColumn(safeCountry); + query.isNotNull(countryColumn).ne(countryColumn, ""); + } + boolean hasMinimumPriceFrom = minimumPriceFrom != null; + boolean hasMinimumPriceTo = minimumPriceTo != null; + if (hasMinimumPriceFrom || hasMinimumPriceTo) { + if (safeCountry.isEmpty()) { + List> priceColumns = skipPriceAsinMinimumPriceColumns(); + query.and(wrapper -> { + boolean first = true; + for (SFunction column : priceColumns) { + if (!first) { + wrapper.or(); + } + first = false; + wrapper.isNotNull(column); + if (hasMinimumPriceFrom) { + wrapper.ge(column, minimumPriceFrom); + } + if (hasMinimumPriceTo) { + wrapper.le(column, minimumPriceTo); + } + } + }); + } else { + SFunction priceColumn = skipPriceAsinMinimumPriceColumn(safeCountry); + query.isNotNull(priceColumn); + if (hasMinimumPriceFrom) { + query.ge(priceColumn, minimumPriceFrom); + } + if (hasMinimumPriceTo) { + query.le(priceColumn, minimumPriceTo); + } + } + } if (!superAdmin) { if (accessibleGroupIds.isEmpty()) { query.eq(SkipPriceAsinEntity::getId, -1L); @@ -485,6 +542,37 @@ public class SkipPriceAsinService { return findCountryAsin(groupId, shopName, country, asin) != null; } + private SFunction skipPriceAsinCountryColumn(String country) { + return switch (country) { + case "DE" -> SkipPriceAsinEntity::getAsinDe; + case "UK" -> SkipPriceAsinEntity::getAsinUk; + case "FR" -> SkipPriceAsinEntity::getAsinFr; + case "IT" -> SkipPriceAsinEntity::getAsinIt; + case "ES" -> SkipPriceAsinEntity::getAsinEs; + default -> throw new BusinessException("不支持的国家代码: " + country); + }; + } + + private SFunction skipPriceAsinMinimumPriceColumn(String country) { + return switch (country) { + case "DE" -> SkipPriceAsinEntity::getMinimumPriceDe; + case "UK" -> SkipPriceAsinEntity::getMinimumPriceUk; + case "FR" -> SkipPriceAsinEntity::getMinimumPriceFr; + case "IT" -> SkipPriceAsinEntity::getMinimumPriceIt; + case "ES" -> SkipPriceAsinEntity::getMinimumPriceEs; + default -> throw new BusinessException("不支持的国家代码: " + country); + }; + } + + private List> skipPriceAsinMinimumPriceColumns() { + return List.of( + SkipPriceAsinEntity::getMinimumPriceDe, + SkipPriceAsinEntity::getMinimumPriceUk, + SkipPriceAsinEntity::getMinimumPriceFr, + SkipPriceAsinEntity::getMinimumPriceIt, + SkipPriceAsinEntity::getMinimumPriceEs); + } + private SkipPriceAsinEntity findCountryAsin(Long groupId, String shopName, String country, String asin) { LambdaQueryWrapper query = new LambdaQueryWrapper() .eq(SkipPriceAsinEntity::getGroupId, groupId) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java index ccf462d7..4fde8430 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/controller/ShopMatchController.java @@ -41,6 +41,8 @@ import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -189,6 +191,14 @@ public class ShopMatchController { return ApiResponse.success(shopMatchTaskService.getTaskProgressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(shopMatchTaskService.progressLight(request.getTaskIds())); + } + @PostMapping("/tasks/{taskId}/result") @Operation(summary = "提交匹配结果", description = "Python 可多次分批回传结果;后端会组装文件并在超时场景自动补偿收尾。") public ApiResponse submitResult( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java index 215a1f76..b957b0de 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java @@ -55,6 +55,8 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -62,6 +64,10 @@ import java.util.Map; public class ShopMatchTaskService { private static final String MODULE_TYPE = "SHOP_MATCH"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); @@ -79,6 +85,7 @@ public class ShopMatchTaskService { private final TaskDistributedLockService taskDistributedLockService; private final SkipPriceAsinService skipPriceAsinService; private final QueryAsinMapper queryAsinMapper; + private final TaskProgressLightAssembler taskProgressLightAssembler; private FileTaskEntity loadTaskForExecution(Long taskId) { Map cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId)); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java index f3eedc86..824e74db 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/controller/SimilarAsinController.java @@ -7,11 +7,13 @@ 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.SimilarAsinSubmitResultRequest; import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinTaskBatchRequest; +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinTaskLightRequest; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinDashboardVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinFilterConditionVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinHistoryVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskBatchVo; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskLightBatchVo; import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService; import com.nanri.aiimage.modules.similarasin.service.SimilarAsinTaskService.ResultDownloadInfo; import io.swagger.v3.oas.annotations.Operation; @@ -120,6 +122,14 @@ public class SimilarAsinController { return ApiResponse.success(service.progressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody SimilarAsinTaskLightRequest request) { + return ApiResponse.success(service.progressLight(request.getTaskIds())); + } + @PostMapping("/tasks/{taskId}/activate") @Operation(summary = "激活任务", description = "前端手动推送 Python 队列成功后调用,将任务从 PENDING 改为 RUNNING。") public ApiResponse activate( diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinTaskLightRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinTaskLightRequest.java new file mode 100644 index 00000000..697f2202 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/dto/SimilarAsinTaskLightRequest.java @@ -0,0 +1,19 @@ +package com.nanri.aiimage.modules.similarasin.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "相似ASIN检测任务轻量进度查询请求") +public class SimilarAsinTaskLightRequest { + @NotNull + @Schema(description = "需要查询进度的任务 ID 列表。前端只传正在轮询的活跃任务;后端会批量查询。", example = "[3938,3939]", requiredMode = Schema.RequiredMode.REQUIRED) + private List taskIds; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinTaskLightBatchVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinTaskLightBatchVo.java new file mode 100644 index 00000000..ac653e66 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinTaskLightBatchVo.java @@ -0,0 +1,16 @@ +package com.nanri.aiimage.modules.similarasin.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +@Schema(description = "相似ASIN检测任务轻量进度响应") +public class SimilarAsinTaskLightBatchVo { + @Schema(description = "轻量进度项列表。顺序按请求 taskIds 处理。") + private List items = new ArrayList<>(); + @Schema(description = "未找到或不属于相似ASIN检测模块的任务 ID 列表。", example = "[99999]") + private List missingTaskIds = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinTaskLightVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinTaskLightVo.java new file mode 100644 index 00000000..bc0bf3e6 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/model/vo/SimilarAsinTaskLightVo.java @@ -0,0 +1,23 @@ +package com.nanri.aiimage.modules.similarasin.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "相似ASIN检测任务轻量进度项(前端轮询专用,不含明细/result 内容)") +public class SimilarAsinTaskLightVo { + @Schema(description = "任务 ID。", example = "3938") + private Long taskId; + @Schema(description = "任务状态:PENDING=已解析待推送,RUNNING=执行中,SUCCESS=成功,FAILED=失败。", example = "RUNNING") + private String status; + @Schema(description = "业务状态码(预留字段,当前恒为 null)。", example = "null") + private String statusCode; + @Schema(description = "结果文件组装 Job 状态:SUCCESS/RUNNING/FAILED;无 Job 时为空。", example = "SUCCESS") + private String fileStatus; + @Schema(description = "文件组装失败时的错误信息;无错误时为空。", example = "组装失败: 文件写入超时") + private String fileError; + @Schema(description = "结果文件是否已就绪。", example = "true") + private Boolean fileReady; + @Schema(description = "最后更新时间,ISO 本地时间字符串。", example = "2026-04-26T10:05:00") + private String updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 5a50b428..b77848fa 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -35,6 +35,11 @@ import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParseVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskBatchVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskDetailVo; import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskItemVo; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskLightBatchVo; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinTaskLightVo; +import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinExcelParser; +import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinGroupingConverter; +import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinHistoryAssembler; import com.nanri.aiimage.modules.similarasin.util.BoundedImageCache; import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; @@ -59,12 +64,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; import org.apache.poi.ss.usermodel.CellStyle; -import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.Font; 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.ss.usermodel.WorkbookFactory; import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -136,6 +138,7 @@ public class SimilarAsinTaskService { private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final String CONTENT_TYPE_ZIP = "application/zip"; private static final int RESULT_ROWS_READ_RETRY_LIMIT = 3; + private static final int MAX_LIGHT_TASK_IDS = 200; /** Task 19:Python 回传逐行日志采样频率(每 N 行记一行)。 */ private static final long PYTHON_INBOUND_LOG_EVERY_N = 20L; private static final long RESULT_ROWS_READ_RETRY_DELAY_MS = 500L; @@ -336,6 +339,20 @@ public class SimilarAsinTaskService { * best-effort:service 内部所有异常都已吞掉,不影响主流程。 */ private final SimilarAsinImagePrefetchService imagePrefetchService; + /** + * Task 89:Excel 行解析器(表头/单元格读取、别名匹配、空行跳过、单字段截断)。 + * 由 parseAndCreateTask 委托;解析语义与搬移前 parseWorkbook 完全一致。 + */ + SimilarAsinExcelParser excelParser = new SimilarAsinExcelParser(); + /** + * Task 91:历史查询 VO 拼装(toHistoryItem + 进度链)搬入独立组件; + * 由 history/progressBatch 委托,只读不落库,输出与现状一致。 + */ + private SimilarAsinHistoryAssembler historyAssembler() { + return new SimilarAsinHistoryAssembler( + taskScopeStateMapper, taskChunkMapper, fileTaskMapper, + taskProgressSnapshotService, ossStorageService, transientPayloadStorageService, objectMapper); + } @Autowired @Qualifier("taskQueueExecutor") private TaskExecutor taskQueueExecutor; @@ -466,7 +483,7 @@ public class SimilarAsinTaskService { + " (" + input.length() + " bytes > " + maxBytes + " bytes)"); } - ParsedWorkbook parsed = parseWorkbook(input, source); + ParsedWorkbook parsed = parseWorkbookDelegated(input, source); totalRows += parsed.totalRows(); droppedRows += parsed.droppedRows(); allRows.addAll(parsed.allRows()); @@ -487,7 +504,7 @@ public class SimilarAsinTaskService { sourceFiles.size(), allRows.size()); } long parsedAt = System.nanoTime(); - List groups = buildParsedGroups(allRows); + List groups = SimilarAsinGroupingConverter.convert(allRows); long groupedAt = System.nanoTime(); FileTaskEntity task = new FileTaskEntity(); @@ -640,13 +657,49 @@ public class SimilarAsinTaskService { .map(FileResultEntity::getId) .filter(Objects::nonNull) .toList()); - for (FileResultEntity row : rows) { - FileTaskEntity task = taskMap.get(row.getTaskId()); - String taskStatus = task == null ? null : task.getStatus(); - if (STATUS_PENDING.equals(taskStatus)) { + vo.getItems().addAll(historyAssembler().buildHistoryItems(rows, taskMap, jobMap)); + return vo; + } + + public SimilarAsinTaskLightBatchVo progressLight(List taskIds) { + SimilarAsinTaskLightBatchVo vo = new SimilarAsinTaskLightBatchVo(); + List normalizedIds = taskIds == null ? List.of() : taskIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .limit(MAX_LIGHT_TASK_IDS) + .toList(); + if (normalizedIds.isEmpty()) { + return vo; + } + Map taskMap = new LinkedHashMap<>(); + for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper() + .select(FileTaskEntity::getId, + FileTaskEntity::getStatus, + FileTaskEntity::getUpdatedAt) + .in(FileTaskEntity::getId, normalizedIds))) { + if (task != null && MODULE_TYPE.equals(task.getModuleType())) { + taskMap.put(task.getId(), task); + } + } + Map jobMap = taskFileJobService.findAssembleJobsByTaskIds(MODULE_TYPE, new ArrayList<>(taskMap.keySet())); + for (Long taskId : normalizedIds) { + FileTaskEntity task = taskMap.get(taskId); + if (task == null) { + vo.getMissingTaskIds().add(taskId); continue; } - vo.getItems().add(toHistoryItem(row, task, jobMap.get(row.getId()))); + SimilarAsinTaskLightVo item = new SimilarAsinTaskLightVo(); + item.setTaskId(task.getId()); + item.setStatus(resolveDisplayTaskStatus(task)); + TaskFileJobEntity job = jobMap.get(taskId); + boolean fileReady = job != null + && job.getResultFileUrl() != null + && !job.getResultFileUrl().isBlank(); + item.setFileReady(fileReady); + item.setFileStatus(job == null ? null : job.getStatus()); + item.setFileError(job == null ? null : job.getErrorMessage()); + item.setUpdatedAt(fmt(task.getUpdatedAt())); + vo.getItems().add(item); } return vo; } @@ -708,7 +761,8 @@ public class SimilarAsinTaskService { detail.setTask(toTaskItem(task)); FileResultEntity resultRow = resultByTaskId.get(taskId); if (resultRow != null) { - detail.getItems().add(toHistoryItem(resultRow, task, jobMap.get(resultRow.getId()))); + detail.getItems().addAll(historyAssembler().buildHistoryItems( + List.of(resultRow), taskMap, jobMap)); } vo.getItems().add(detail); } @@ -1751,7 +1805,7 @@ public class SimilarAsinTaskService { private Map> loadAllRowsByBaseId(FileTaskEntity task) { try { SimilarAsinParsedPayloadDto payload = readParsedPayload(task); - return groupRowsByBaseId(resolveAllRows(payload)); + return SimilarAsinGroupingConverter.groupRowsByBaseId(resolveAllRows(payload)); } catch (Exception ex) { log.warn("[similar-asin] read all rows failed taskId={} err={}", task.getId(), ex.getMessage()); return new LinkedHashMap<>(); @@ -1791,36 +1845,6 @@ public class SimilarAsinTaskService { return candidates; } - /** - * Task 6:分组数据改为索引/范围引用。组内行在原 items 中必然连续 - * (parseWorkbook 按行遍历、同 baseId 块连续收集),因此只需要 - * [startIndex, endIndex) 半开区间即可唯一定位组内行, - * 行对象仅存在于 items 一次,避免 groups 嵌套复制完整行对象。 - */ - private List buildParsedGroups(List rows) { - List groups = new ArrayList<>(); - int cursor = 0; - for (List siblings : groupRowsByBaseId(rows).values()) { - if (siblings == null || siblings.isEmpty()) { - continue; - } - SimilarAsinParsedRowVo first = siblings.getFirst(); - SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo(); - group.setSourceFileKey(first.getSourceFileKey()); - group.setSourceFilename(first.getSourceFilename()); - group.setGroupKey(firstNonBlank(first.getGroupKey(), buildGroupKey(first.getSourceFileKey(), baseId(first.getDisplayId()), first.getRowIndex()))); - group.setBaseId(baseId(first.getDisplayId())); - group.setDisplayId(firstNonBlank(first.getDisplayId(), first.getSourceId())); - group.setItemCount(siblings.size()); - int end = cursor + siblings.size(); - group.setStartIndex(cursor); - group.setEndIndex(end); - cursor = end; - groups.add(group); - } - return groups; - } - private List buildResponsePreviewGroups(List groups, List allRows) { if (groups == null || groups.isEmpty()) { return List.of(); @@ -1887,18 +1911,6 @@ public class SimilarAsinTaskService { return vo; } - private Map> groupRowsByBaseId(List rows) { - Map> result = new LinkedHashMap<>(); - if (rows == null) { - return result; - } - for (SimilarAsinParsedRowVo row : rows) { - String key = firstNonBlank(normalize(row.getGroupKey()), baseId(row.getDisplayId())); - result.computeIfAbsent(key, ignored -> new ArrayList<>()).add(row); - } - return result; - } - private List flattenSubmittedRows(SimilarAsinSubmitResultRequest request) { if (request == null) { return List.of(); @@ -4718,8 +4730,11 @@ public class SimilarAsinTaskService { return firstNonBlank(resultRow.getUrl(), fallbackUrl); } - private ParsedWorkbook parseWorkbook(File input, SimilarAsinSourceFileDto source) { - DataFormatter formatter = new DataFormatter(); + /** + * Task 89:解析委托 SimilarAsinExcelParser(表头/单元格读取、别名匹配、空行跳过、字段截断), + * 保留服务侧编排语义:zip 探针、status 列过滤、类目重试判定、行号/分组键/rowToken 组装。 + */ + private ParsedWorkbook parseWorkbookDelegated(File input, SimilarAsinSourceFileDto source) { try { // Task 8:受控读取——先探测 zip 条目数与解压体积,超限拒绝 probeWorkbookZipBounds(input, source.getOriginalFilename()); @@ -4729,26 +4744,9 @@ public class SimilarAsinTaskService { // 非 zip 或损坏文件:留给 WorkbookFactory 尝试后由下方 catch 转业务异常 log.debug("[similar-asin] workbook zip probe skipped file={} err={}", input, ex.getMessage()); } - try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) { Sheet sheet = workbook.getSheetAt(0); - Row header = sheet.getRow(0); - if (header == null) { - throw new BusinessException("Excel 表头为空"); - } - Map headerMap = buildHeaderMap(header, formatter); - List headers = readHeaders(header, formatter); - int idCol = findRequiredHeader(headerMap, "id"); - int asinCol = findRequiredHeader(headerMap, "asin"); - int countryCol = findRequiredHeader(headerMap, "国家", "country"); - // 兼容旧 xlsx:RESULT_HEADERS 已不再写出 sku 列,但仍要支持旧版结果簿重新上传, - // 因此这里仍按可选列读取并保留到 DTO,enrichRowForLlm 仍可使用,仅导出阶段不再写出。 - int skuCol = findOptionalHeaderExact(headerMap, "sku", "seller sku", "seller_sku", "msku", "货号"); - int priceCol = findOptionalHeaderExact(headerMap, "价格", "price"); - int urlCol = findOptionalHeaderExact(headerMap, - "url", "rul", "link", "image", "img", "pic", "picture", - "链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接"); - int titleCol = findOptionalHeaderExact(headerMap, - "标题", "title", "listing title", "product title", "商品标题", "商品名称", "产品名称"); - + try { + SimilarAsinExcelParser.ParsedSheet parsedSheet = excelParser.parse(input, resolveMaxFieldLength()); + List headers = parsedSheet.headers(); int statusCol = FailedStatusRowFilter.findStatusColumnIndex(headers); String statusHeader = statusCol >= 0 && statusCol < headers.size() ? headers.get(statusCol) : null; @@ -4758,14 +4756,10 @@ public class SimilarAsinTaskService { int validRows = 0; String currentBlockBaseId = ""; String currentGroupKey = ""; - for (int i = 1; i <= sheet.getLastRowNum(); i++) { - Row row = sheet.getRow(i); - if (row == null) { - continue; - } - String id = cell(row, idCol, formatter); - String asin = cell(row, asinCol, formatter).toUpperCase(Locale.ROOT); - String country = cell(row, countryCol, formatter); + for (SimilarAsinExcelParser.SimilarAsinExcelRow parsedRow : parsedSheet.rows()) { + String id = parsedRow.id(); + String asin = parsedRow.asin(); + String country = parsedRow.country(); if (id.isBlank() && asin.isBlank() && country.isBlank()) { continue; } @@ -4778,7 +4772,7 @@ public class SimilarAsinTaskService { SimilarAsinParsedRowVo vo = new SimilarAsinParsedRowVo(); vo.setSourceFileKey(source.getFileKey()); vo.setSourceFilename(firstNonBlank(source.getOriginalFilename(), input.getName())); - vo.setRowIndex(i + 1); + vo.setRowIndex(parsedRow.rowIndex()); vo.setSourceId(id); vo.setDisplayId(normalizeDisplayId(id)); String rowBaseId = baseId(vo.getDisplayId()); @@ -4790,11 +4784,11 @@ public class SimilarAsinTaskService { vo.setRowToken(buildRowToken(source.getFileKey(), vo.getRowIndex())); vo.setAsin(asin); vo.setCountry(country); - vo.setSku(skuCol >= 0 ? cell(row, skuCol, formatter) : ""); - vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : ""); - vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : ""); - vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : ""); - vo.setValues(readRowValues(row, headers, formatter)); + vo.setSku(parsedRow.sku()); + vo.setPrice(parsedRow.price()); + vo.setUrl(parsedRow.url()); + vo.setTitle(parsedRow.title()); + vo.setValues(parsedRow.values()); allRows.add(vo); } boolean resultWorkbook = statusCol >= 0 && isSimilarAsinResultWorkbook(headers); @@ -4911,86 +4905,6 @@ public class SimilarAsinTaskService { return !normalized.isBlank() && !isTechnicalLlmFailure(normalized); } - private Map buildHeaderMap(Row header, DataFormatter formatter) { - Map map = new LinkedHashMap<>(); - for (int i = 0; i < header.getLastCellNum(); i++) { - String val = normalize(formatter.formatCellValue(header.getCell(i))); - if (!val.isBlank()) { - map.putIfAbsent(val.toLowerCase(Locale.ROOT), i); - } - } - return map; - } - - private List readHeaders(Row header, DataFormatter formatter) { - List headers = new ArrayList<>(); - for (int i = 0; i < header.getLastCellNum(); i++) { - String val = normalize(formatter.formatCellValue(header.getCell(i))); - headers.add(val.isBlank() ? "列" + (i + 1) : val); - } - return headers; - } - - private Map readRowValues(Row row, List headers, DataFormatter formatter) { - Map values = new LinkedHashMap<>(); - for (int i = 0; i < headers.size(); i++) { - values.put(headers.get(i), cell(row, i, formatter)); - } - return values; - } - - private int findRequiredHeader(Map map, String... names) { - int idx = findOptionalHeader(map, names); - if (idx < 0) { - throw new BusinessException("缺少必要表头: " + String.join("/", names)); - } - return idx; - } - - private int findOptionalHeader(Map map, String... names) { - for (Map.Entry entry : map.entrySet()) { - for (String name : names) { - if (entry.getKey().contains(name.toLowerCase(Locale.ROOT))) { - return entry.getValue(); - } - } - } - return -1; - } - - private int findOptionalHeaderExact(Map map, String... names) { - for (Map.Entry entry : map.entrySet()) { - String normalizedHeader = normalizeHeaderAlias(entry.getKey()); - for (String name : names) { - if (normalizedHeader.equals(normalizeHeaderAlias(name))) { - return entry.getValue(); - } - } - } - return -1; - } - - private String normalizeHeaderAlias(String value) { - String normalized = normalize(value).toLowerCase(Locale.ROOT); - return normalized.replaceAll("[\\s_\\-()()\\[\\]{}::/\\\\]+", ""); - } - - private String cell(Row row, int col, DataFormatter formatter) { - if (col < 0) { - return ""; - } - String value = normalize(formatter.formatCellValue(row.getCell(col))); - if (isSpreadsheetErrorValue(value)) { - return ""; - } - // Task 7:单字段长度上限,防止超长单元格导致内存无界增长 - int maxLen = resolveMaxFieldLength(); - if (value.length() > maxLen) { - return value.substring(0, maxLen); - } - return value; - } - private static boolean isSpreadsheetErrorValue(String value) { String normalized = value == null ? "" : value.trim(); return normalized.startsWith("#") && normalized.endsWith("!"); @@ -5133,253 +5047,6 @@ public class SimilarAsinTaskService { return vo; } - private SimilarAsinHistoryItemVo toHistoryItem(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { - SimilarAsinHistoryItemVo vo = new SimilarAsinHistoryItemVo(); - vo.setResultId(row.getId()); - vo.setTaskId(row.getTaskId()); - vo.setSourceFilename(row.getSourceFilename()); - vo.setResultFilename(row.getResultFilename()); - vo.setDownloadUrl(buildFreshDownloadUrl(row)); - vo.setTaskStatus(resolveDisplayTaskStatus(task, row)); - attachFileJobState(vo, row, job, task); - vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1); - vo.setError(row.getErrorMessage()); - vo.setRowCount(row.getRowCount()); - vo.setCreatedAt(fmt(row.getCreatedAt())); - // 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底 - vo.setStartedAt(fmt(task == null ? row.getCreatedAt() : task.getCreatedAt())); - vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt())); - return vo; - } - - private String buildFreshDownloadUrl(FileResultEntity row) { - String resultFileUrl = row == null ? null : row.getResultFileUrl(); - if (resultFileUrl == null || resultFileUrl.isBlank()) { - return null; - } - try { - // bucket 对 result/ 前缀已开放公开读,使用无签名公开 URL,避免签名 1 小时过期后下载 403。 - // result/ 前缀走 download-endpoint 独立下载域名(带宽/缓存分流),非 result/(如图片)仍走公开端点。 - return ossStorageService.generateFreshDownloadUrl(resultFileUrl); - } catch (Exception ex) { - log.warn("[similar-asin] build public download url failed resultId={} err={}", - row.getId(), ex.getMessage()); - return null; - } - } - - private void attachFileJobState(SimilarAsinHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job, FileTaskEntity task) { - vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank()); - if (job == null) { - vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); - attachFileProgress(vo, row, job, task); - return; - } - vo.setFileJobId(job.getId()); - vo.setFileStatus(job.getStatus()); - vo.setFileError(job.getErrorMessage()); - attachFileProgress(vo, row, job, task); - } - - private void attachFileProgress(SimilarAsinHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job, FileTaskEntity task) { - if (row == null || row.getTaskId() == null) { - return; - } - Long taskId = row.getTaskId(); - if (Boolean.TRUE.equals(vo.getFileReady())) { - vo.setFileProgressCurrent(1); - vo.setFileProgressTotal(1); - vo.setFileProgressPercent(100); - vo.setFileProgressMessage("结果文件已生成"); - return; - } - int llmCompleted = countCompletedLlmStates(taskId); - int llmPending = countPendingLlmStates(taskId); - boolean uploadComplete = isResultSubmissionComplete(taskId); - TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE); - if (!uploadComplete) { - if (canTreatTaskAsCompleted(task, row, job)) { - vo.setFileProgressCurrent(1); - vo.setFileProgressTotal(1); - vo.setFileProgressPercent(100); - vo.setFileProgressMessage("结果文件已生成"); - return; - } - if (task != null && !STATUS_RUNNING.equals(task.getStatus())) { - return; - } - if (llmCompleted + llmPending > 0) { - attachLlmProgress(vo, job, snapshot, llmCompleted, llmPending, false); - return; - } - attachPythonUploadProgress(vo, taskId); - return; - } - if (llmCompleted + llmPending > 0) { - attachLlmProgress(vo, job, snapshot, llmCompleted, llmPending, true); - return; - } - if (snapshot == null) { - return; - } - int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount(); - int current = snapshot.getSuccessCount() == null ? 0 : snapshot.getSuccessCount(); - if (total <= 0) { - return; - } - LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : (job == null ? null : job.getUpdatedAt()); - int percent = calculateDisplayProgressPercent(current, total, job, baseTime); - percent = Math.max(percent, extractSnapshotDisplayPercent(snapshot)); - percent = Boolean.TRUE.equals(vo.getFileReady()) ? 100 : Math.min(99, percent); - vo.setFileProgressCurrent(current); - vo.setFileProgressTotal(total); - vo.setFileProgressPercent(percent); - vo.setFileProgressMessage(snapshot.getMessage()); - } - - private void attachLlmProgress(SimilarAsinHistoryItemVo vo, - TaskFileJobEntity job, - TaskProgressSnapshotEntity snapshot, - int llmCompleted, - int llmPending, - boolean uploadComplete) { - int observed = Math.max(0, llmCompleted) + Math.max(0, llmPending); - int total = calculateLlmDisplayTotal(snapshot, observed); - if (!uploadComplete) { - total = Math.max(total, observed + 1); - } - int current = Math.max(0, Math.min(llmCompleted, total)); - vo.setFileProgressCurrent(current); - vo.setFileProgressTotal(total); - int percent = calculateDisplayProgressPercent(current, total, job, snapshot == null ? null : snapshot.getUpdatedAt()); - percent = Math.max(percent, calculateSnapshotDisplayPercent(snapshot, job)); - vo.setFileProgressPercent(Math.min(uploadComplete ? 99 : 98, percent)); - vo.setFileProgressMessage(uploadComplete - ? (llmPending > 0 - ? buildLlmProgressMessage(current, total, llmPending) - : "LLM 已回流 " + current + "/" + total + " 批次,正在生成结果文件") - : buildUploadingLlmProgressMessage(current, total, llmPending)); - } - - private void attachPythonUploadProgress(SimilarAsinHistoryItemVo vo, Long taskId) { - PythonUploadProgress progress = resolvePythonUploadProgress(taskId); - int total = progress.total() > 0 ? progress.total() : Math.max(1, progress.current() + 1); - int current = Math.max(0, Math.min(progress.current(), total)); - int percent = current <= 0 ? 1 : Math.min(95, (int) Math.ceil(current * 100.0 / total)); - vo.setFileProgressCurrent(current); - vo.setFileProgressTotal(total); - vo.setFileProgressPercent(percent); - vo.setFileProgressMessage(buildPythonUploadProgressMessage(current, total, progress.unit())); - } - - private PythonUploadProgress resolvePythonUploadProgress(Long taskId) { - PythonUploadProgress chunkProgress = resolvePythonChunkProgress(taskId); - if (chunkProgress.total() > 0) { - return chunkProgress; - } - if (chunkProgress.current() > 0) { - return new PythonUploadProgress(chunkProgress.current(), chunkProgress.current() + 1, chunkProgress.unit()); - } - int totalRows = 0; - try { - FileTaskEntity task = fileTaskMapper.selectById(taskId); - totalRows = allRowCount(task); - } catch (Exception ignored) { - totalRows = 0; - } - int uploadedRows = countSubmittedRows(taskId); - if (totalRows <= 0 && uploadedRows > 0) { - totalRows = uploadedRows + 1; - } - return new PythonUploadProgress(uploadedRows, totalRows, "行"); - } - - private PythonUploadProgress resolvePythonChunkProgress(Long taskId) { - if (taskId == null || taskId <= 0) { - return new PythonUploadProgress(0, 0, "分片"); - } - List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() - .eq(TaskScopeStateEntity::getTaskId, taskId) - .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) - .isNull(TaskScopeStateEntity::getLlmStatus) - .orderByDesc(TaskScopeStateEntity::getUpdatedAt)); - if (states == null || states.isEmpty()) { - return resolvePythonChunkProgressFromChunks(taskId); - } - int current = 0; - int total = 0; - for (TaskScopeStateEntity state : states) { - if (state == null) { - continue; - } - int stateTotal = state.getChunkTotal() == null ? 0 : state.getChunkTotal(); - int stateCurrent = state.getReceivedChunkCount() == null ? 0 : state.getReceivedChunkCount(); - stateCurrent = Math.max(stateCurrent, resolveReceivedChunkProgress(taskId, state.getScopeHash(), stateTotal)); - if (stateTotal > total || total <= 0 && stateCurrent > current) { - total = stateTotal; - current = stateCurrent; - } - } - return new PythonUploadProgress(current, total, "分片"); - } - - private PythonUploadProgress resolvePythonChunkProgressFromChunks(Long taskId) { - List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() - .eq(TaskChunkEntity::getTaskId, taskId) - .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); - if (chunks == null || chunks.isEmpty()) { - return new PythonUploadProgress(0, 0, "分片"); - } - Map receivedByScope = new LinkedHashMap<>(); - Map totalByScope = new LinkedHashMap<>(); - for (TaskChunkEntity chunk : chunks) { - if (chunk == null) { - continue; - } - String scopeHash = firstNonBlank(chunk.getScopeHash(), ""); - receivedByScope.merge(scopeHash, 1, Integer::sum); - if (chunk.getChunkIndex() != null && chunk.getChunkIndex() > 0) { - receivedByScope.merge(scopeHash, chunk.getChunkIndex(), Math::max); - } - if (chunk.getChunkTotal() != null && chunk.getChunkTotal() > 0) { - totalByScope.merge(scopeHash, chunk.getChunkTotal(), Math::max); - } - } - int current = 0; - int total = 0; - for (Map.Entry entry : receivedByScope.entrySet()) { - int scopeCurrent = entry.getValue() == null ? 0 : entry.getValue(); - int scopeTotal = totalByScope.getOrDefault(entry.getKey(), 0); - if (scopeTotal > total || total <= 0 && scopeCurrent > current) { - current = scopeCurrent; - total = scopeTotal; - } - } - return new PythonUploadProgress(current, total, "分片"); - } - - private int countSubmittedRows(Long taskId) { - if (taskId == null || taskId <= 0) { - return 0; - } - int total = 0; - List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() - .eq(TaskChunkEntity::getTaskId, taskId) - .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); - if (chunks == null || chunks.isEmpty()) { - return 0; - } - for (TaskChunkEntity chunk : chunks) { - total += readChunkRows(chunk).size(); - } - return total; - } - - private String buildPythonUploadProgressMessage(int current, int total, String unit) { - String safeUnit = unit == null || unit.isBlank() ? "分片" : unit; - return "等待 Python 回传,已回传 " + Math.max(0, current) + "/" + Math.max(1, total) + " " + safeUnit; - } - private String resolveDisplayTaskStatus(FileTaskEntity task) { return resolveDisplayTaskStatus(task, null); } @@ -5416,36 +5083,6 @@ public class SimilarAsinTaskService { return true; } - private int calculateLlmDisplayTotal(TaskProgressSnapshotEntity snapshot, int observedLlmStates) { - int observed = Math.max(0, observedLlmStates); - // 小任务 totalCount <= 3 时直接使用 observed,避免 totalCount - 3 → 0 引起进度条 0/0。 - if (snapshot == null || snapshot.getTotalCount() == null || snapshot.getTotalCount() <= 3) { - return Math.max(1, observed); - } - int expectedFromSnapshot = Math.max(1, snapshot.getTotalCount() - 3); - return Math.max(1, Math.max(observed, expectedFromSnapshot)); - } - - private String buildLlmProgressMessage(int completed, int total, int pending) { - int safeTotal = Math.max(1, total); - int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); - int submitted = Math.max(safeCompleted, Math.min(safeTotal, safeCompleted + Math.max(0, pending))); - if (safeCompleted <= 0 && submitted > 0) { - return "LLM 已提交 " + submitted + "/" + safeTotal + " 批次,等待回流"; - } - return "LLM 已回流 " + safeCompleted + "/" + safeTotal + " 批次,等待结果文件"; - } - - private String buildUploadingLlmProgressMessage(int completed, int total, int pending) { - int safeTotal = Math.max(1, total); - int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); - int submitted = Math.max(safeCompleted, Math.min(safeTotal, safeCompleted + Math.max(0, pending))); - if (pending > 0) { - return "Python 仍在回传,LLM 已提交 " + submitted + "/" + safeTotal + " 批次"; - } - return "Python 仍在回传,LLM 已回流 " + safeCompleted + "/" + safeTotal + " 批次"; - } - private String fmt(LocalDateTime t) { return t == null ? null : t.toString(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinExcelParser.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinExcelParser.java new file mode 100644 index 00000000..8eb82aae --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinExcelParser.java @@ -0,0 +1,245 @@ +package com.nanri.aiimage.modules.similarasin.service.support; + +import com.nanri.aiimage.common.exception.BusinessException; +import lombok.extern.slf4j.Slf4j; +import org.apache.poi.ss.usermodel.DataFormatter; +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.ss.usermodel.WorkbookFactory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * 任务 83:SimilarAsinExcelParser 行解析器。 + * POI 读 Excel 行 → 中间行对象(原始单元格值)。语义与 SimilarAsinTaskService.parseWorkbook + * 中对应段落逐字节一致:cell 归一化(BOM/全角空格/trim/连续空白折叠)、错误值转空、 + * 单字段截断 2000、表头别名匹配、空行跳过。 + */ +@Slf4j +public class SimilarAsinExcelParser { + + private static final int DEFAULT_MAX_FIELD_LENGTH = 2000; + + public ParsedSheet parse(File input) { + return parse(input, DEFAULT_MAX_FIELD_LENGTH); + } + + public ParsedSheet parse(File input, int maxFieldLength) { + if (input == null) { + throw new IllegalArgumentException("input must not be null"); + } + try (FileInputStream fis = new FileInputStream(input); + Workbook workbook = WorkbookFactory.create(fis)) { + return parseWorkbook(workbook, maxFieldLength); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + log.warn("[similar-asin] parse failed file={} err={}", input.getName(), ex.getMessage()); + throw new BusinessException("解析 Excel 失败"); + } + } + + public ParsedSheet parse(InputStream input) { + if (input == null) { + throw new IllegalArgumentException("input must not be null"); + } + try (Workbook workbook = WorkbookFactory.create(input)) { + return parseWorkbook(workbook, DEFAULT_MAX_FIELD_LENGTH); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + log.warn("[similar-asin] parse failed input err={}", ex.getMessage()); + throw new BusinessException("解析 Excel 失败"); + } + } + + private ParsedSheet parseWorkbook(Workbook workbook, int maxFieldLength) { + DataFormatter formatter = new DataFormatter(); + Sheet sheet = workbook.getSheetAt(0); + Row header = sheet.getRow(0); + if (header == null) { + throw new BusinessException("Excel 表头为空"); + } + Map headerMap = buildHeaderMap(header, formatter); + List headers = readHeaders(header, formatter); + int idCol = findRequiredHeader(headerMap, "id"); + int asinCol = findRequiredHeader(headerMap, "asin"); + int countryCol = findRequiredHeader(headerMap, "国家", "country"); + int skuCol = findOptionalHeaderExact(headerMap, "sku", "seller sku", "seller_sku", "msku", "货号"); + int priceCol = findOptionalHeaderExact(headerMap, "价格", "price"); + int urlCol = findOptionalHeaderExact(headerMap, + "url", "rul", "link", "image", "img", "pic", "picture", + "链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接"); + int titleCol = findOptionalHeaderExact(headerMap, + "标题", "title", "listing title", "product title", "商品标题", "商品名称", "产品名称"); + + List rows = new ArrayList<>(); + for (int i = 1; i <= sheet.getLastRowNum(); i++) { + Row row = sheet.getRow(i); + if (row == null) { + continue; + } + String id = cell(row, idCol, formatter, maxFieldLength); + String asin = cell(row, asinCol, formatter, maxFieldLength).toUpperCase(Locale.ROOT); + String country = cell(row, countryCol, formatter, maxFieldLength); + if (id.isBlank() && asin.isBlank() && country.isBlank()) { + continue; + } + rows.add(new SimilarAsinExcelRow( + i + 1, + id, + asin, + country, + skuCol >= 0 ? cell(row, skuCol, formatter, maxFieldLength) : "", + priceCol >= 0 ? cell(row, priceCol, formatter, maxFieldLength) : "", + urlCol >= 0 ? cell(row, urlCol, formatter, maxFieldLength) : "", + titleCol >= 0 ? cell(row, titleCol, formatter, maxFieldLength) : "", + readRowValues(row, headers, formatter, maxFieldLength))); + } + return new ParsedSheet(headers, rows); + } + + private Map buildHeaderMap(Row header, DataFormatter formatter) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i < header.getLastCellNum(); i++) { + String val = normalize(formatter.formatCellValue(header.getCell(i))); + if (!val.isBlank()) { + map.putIfAbsent(val.toLowerCase(Locale.ROOT), i); + } + } + return map; + } + + private List readHeaders(Row header, DataFormatter formatter) { + List headers = new ArrayList<>(); + for (int i = 0; i < header.getLastCellNum(); i++) { + String val = normalize(formatter.formatCellValue(header.getCell(i))); + headers.add(val.isBlank() ? "列" + (i + 1) : val); + } + return headers; + } + + private Map readRowValues(Row row, List headers, DataFormatter formatter, int maxFieldLength) { + Map values = new LinkedHashMap<>(); + for (int i = 0; i < headers.size(); i++) { + values.put(headers.get(i), cell(row, i, formatter, maxFieldLength)); + } + return values; + } + + private int findRequiredHeader(Map map, String... names) { + int idx = findOptionalHeader(map, names); + if (idx < 0) { + throw new BusinessException("缺少必要表头: " + String.join("/", names)); + } + return idx; + } + + private int findOptionalHeader(Map map, String... names) { + for (Map.Entry entry : map.entrySet()) { + for (String name : names) { + if (entry.getKey().contains(name.toLowerCase(Locale.ROOT))) { + return entry.getValue(); + } + } + } + return -1; + } + + private int findOptionalHeaderExact(Map map, String... names) { + for (Map.Entry entry : map.entrySet()) { + String normalizedHeader = normalizeHeaderAlias(entry.getKey()); + for (String name : names) { + if (normalizedHeader.equals(normalizeHeaderAlias(name))) { + return entry.getValue(); + } + } + } + return -1; + } + + private String normalizeHeaderAlias(String value) { + String normalized = normalize(value).toLowerCase(Locale.ROOT); + return normalized.replaceAll("[\\s_\\-()()\\[\\]{}::/\\\\]+", ""); + } + + private String cell(Row row, int col, DataFormatter formatter, int maxFieldLength) { + if (col < 0) { + return ""; + } + String value = normalize(formatter.formatCellValue(row.getCell(col))); + if (isSpreadsheetErrorValue(value)) { + return ""; + } + if (value.length() > maxFieldLength) { + return value.substring(0, maxFieldLength); + } + return value; + } + + private static boolean isSpreadsheetErrorValue(String value) { + String normalized = value == null ? "" : value.trim(); + return normalized.startsWith("#") && normalized.endsWith("!"); + } + + private String normalize(String val) { + return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " "); + } + + public record ParsedSheet(List headers, List rows) { + + public ParsedSheet { + headers = headers == null ? List.of() : new ArrayList<>(headers); + rows = rows == null ? List.of() : new ArrayList<>(rows); + } + + @Override + public List headers() { + return java.util.Collections.unmodifiableList(headers); + } + + @Override + public List rows() { + return java.util.Collections.unmodifiableList(rows); + } + } + + public record SimilarAsinExcelRow(int rowIndex, String id, String asin, String country, + String sku, String price, String url, String title, + Map values) { + + public SimilarAsinExcelRow { + values = values == null ? Map.of() : new LinkedHashMap<>(values); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof SimilarAsinExcelRow other)) { + return false; + } + return rowIndex == other.rowIndex + && Objects.equals(id, other.id) + && Objects.equals(asin, other.asin) + && Objects.equals(country, other.country) + && Objects.equals(sku, other.sku) + && Objects.equals(price, other.price) + && Objects.equals(url, other.url) + && Objects.equals(title, other.title) + && values.equals(other.values); + } + + @Override + public int hashCode() { + return Objects.hash(rowIndex, id, asin, country, sku, price, url, title, values); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinGroupingConverter.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinGroupingConverter.java new file mode 100644 index 00000000..83547df5 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinGroupingConverter.java @@ -0,0 +1,78 @@ +package com.nanri.aiimage.modules.similarasin.service.support; + +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 任务 86:SimilarAsinGroupingConverter 分组转换器。 + * 归一化行 → 分组 DTO;分组规则与现状 SimilarAsinTaskService.buildParsedGroups 完全一致: + * 按 groupKey(缺省兜底 displayId 的 baseId)分组、startIndex/endIndex 半开区间游标、 + * 组内行数、顺序保持行首次出现顺序。纯函数无状态。 + */ +public final class SimilarAsinGroupingConverter { + + private SimilarAsinGroupingConverter() { + } + + public static List convert(List rows) { + List groups = new ArrayList<>(); + if (rows == null || rows.isEmpty()) { + return groups; + } + int cursor = 0; + for (List siblings : groupRowsByBaseId(rows).values()) { + if (siblings == null || siblings.isEmpty()) { + continue; + } + SimilarAsinParsedRowVo first = siblings.getFirst(); + SimilarAsinParsedGroupVo group = new SimilarAsinParsedGroupVo(); + group.setSourceFileKey(first.getSourceFileKey()); + group.setSourceFilename(first.getSourceFilename()); + group.setGroupKey(firstNonBlank(first.getGroupKey(), + buildGroupKey(first.getSourceFileKey(), baseId(first.getDisplayId()), first.getRowIndex()))); + group.setBaseId(baseId(first.getDisplayId())); + group.setDisplayId(firstNonBlank(first.getDisplayId(), first.getSourceId())); + group.setItemCount(siblings.size()); + int end = cursor + siblings.size(); + group.setStartIndex(cursor); + group.setEndIndex(end); + cursor = end; + groups.add(group); + } + return groups; + } + + /** 行 → baseId 分组映射(LinkedHashMap 保序)。供 convert 及服务侧 loadAllRowsByBaseId 复用。 */ + public static Map> groupRowsByBaseId(List rows) { + Map> result = new LinkedHashMap<>(); + for (SimilarAsinParsedRowVo row : rows) { + if (row == null) { + continue; + } + String key = firstNonBlank(SimilarAsinRowNormalizer.normalize(row.getGroupKey()), + baseId(row.getDisplayId())); + result.computeIfAbsent(key, ignored -> new ArrayList<>()).add(row); + } + return result; + } + + private static String baseId(String id) { + String s = SimilarAsinRowNormalizer.normalize(id); + int idx = s.indexOf('_'); + return idx > 0 ? s.substring(0, idx) : s; + } + + private static String buildGroupKey(String sourceFileKey, String rowBaseId, Integer rowIndex) { + return SimilarAsinRowNormalizer.normalize(sourceFileKey) + "::" + + SimilarAsinRowNormalizer.normalize(rowBaseId) + "@" + (rowIndex == null ? 0 : rowIndex); + } + + private static String firstNonBlank(String preferred, String fallback) { + return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinHistoryAssembler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinHistoryAssembler.java new file mode 100644 index 00000000..cf08220f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinHistoryAssembler.java @@ -0,0 +1,757 @@ +package com.nanri.aiimage.modules.similarasin.service.support; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.file.service.oss.OssStorageService; +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinParsedPayloadDto; +import com.nanri.aiimage.modules.similarasin.model.dto.SimilarAsinResultRowDto; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinHistoryItemVo; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedGroupVo; +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo; +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.TaskProgressSnapshotEntity; +import com.nanri.aiimage.modules.task.model.entity.TaskScopeStateEntity; +import com.nanri.aiimage.modules.task.service.TaskProgressSnapshotService; +import com.nanri.aiimage.modules.task.service.TransientPayloadStorageService; +import lombok.extern.slf4j.Slf4j; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * 任务 91:SimilarAsinHistoryAssembler 历史查询组装器。 + * 历史列表 VO 拼装(toHistoryItem + 进度链:file job 状态、LLM 回流进度、Python 上传进度、 + * 快照百分比)从 SimilarAsinTaskService 原样搬移;只读不落库;输出与现状逐字段一致。 + */ +@Slf4j +public class SimilarAsinHistoryAssembler { + + private static final String MODULE_TYPE = "SIMILAR_ASIN"; + private static final String STATUS_PENDING = "PENDING"; + private static final String STATUS_RUNNING = "RUNNING"; + private static final String STATUS_SUCCESS = "SUCCESS"; + private static final String LLM_STATUS_SUBMITTED = "SUBMITTED"; + private static final String LLM_STATUS_RUNNING = "RUNNING"; + private static final String LLM_STATUS_DONE = "DONE"; + private static final String LLM_STATUS_FAILED = "FAILED"; + + private final TaskScopeStateMapper taskScopeStateMapper; + private final TaskChunkMapper taskChunkMapper; + private final FileTaskMapper fileTaskMapper; + private final TaskProgressSnapshotService taskProgressSnapshotService; + private final OssStorageService ossStorageService; + private final TransientPayloadStorageService transientPayloadStorageService; + private final ObjectMapper objectMapper; + + public SimilarAsinHistoryAssembler(TaskScopeStateMapper taskScopeStateMapper, + TaskChunkMapper taskChunkMapper, + FileTaskMapper fileTaskMapper, + TaskProgressSnapshotService taskProgressSnapshotService, + OssStorageService ossStorageService, + TransientPayloadStorageService transientPayloadStorageService, + ObjectMapper objectMapper) { + this.taskScopeStateMapper = taskScopeStateMapper; + this.taskChunkMapper = taskChunkMapper; + this.fileTaskMapper = fileTaskMapper; + this.taskProgressSnapshotService = taskProgressSnapshotService; + this.ossStorageService = ossStorageService; + this.transientPayloadStorageService = transientPayloadStorageService; + this.objectMapper = objectMapper; + } + + /** + * 按 rows 顺序组装历史项;PENDING 任务(history 语义)跳过。 + * taskMap/jobMap 均为调用方已查好的映射,本方法只读不改输入。 + */ + public List buildHistoryItems(List rows, + Map taskMap, + Map jobMap) { + List items = new ArrayList<>(); + if (rows == null || rows.isEmpty()) { + return items; + } + for (FileResultEntity row : rows) { + FileTaskEntity task = taskMap.get(row.getTaskId()); + String taskStatus = task == null ? null : task.getStatus(); + if (STATUS_PENDING.equals(taskStatus)) { + continue; + } + items.add(toHistoryItem(row, task, jobMap.get(row.getId()))); + } + return items; + } + + private SimilarAsinHistoryItemVo toHistoryItem(FileResultEntity row, FileTaskEntity task, TaskFileJobEntity job) { + SimilarAsinHistoryItemVo vo = new SimilarAsinHistoryItemVo(); + vo.setResultId(row.getId()); + vo.setTaskId(row.getTaskId()); + vo.setSourceFilename(row.getSourceFilename()); + vo.setResultFilename(row.getResultFilename()); + vo.setDownloadUrl(buildFreshDownloadUrl(row)); + vo.setTaskStatus(resolveDisplayTaskStatus(task, row)); + attachFileJobState(vo, row, job, task); + vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1); + vo.setError(row.getErrorMessage()); + vo.setRowCount(row.getRowCount()); + vo.setCreatedAt(fmt(row.getCreatedAt())); + // 任务开始时间复用 biz_file_task.created_at;缺 task 时回退到 result.createdAt 兜底 + vo.setStartedAt(fmt(task == null ? row.getCreatedAt() : task.getCreatedAt())); + vo.setFinishedAt(fmt(task == null ? null : task.getFinishedAt())); + return vo; + } + + private String buildFreshDownloadUrl(FileResultEntity row) { + String resultFileUrl = row == null ? null : row.getResultFileUrl(); + if (resultFileUrl == null || resultFileUrl.isBlank()) { + return null; + } + try { + // bucket 对 result/ 前缀已开放公开读,使用无签名公开 URL,避免签名 1 小时过期后下载 403。 + // result/ 前缀走 download-endpoint 独立下载域名(带宽/缓存分流),非 result/(如图片)仍走公开端点。 + return ossStorageService.generateFreshDownloadUrl(resultFileUrl); + } catch (Exception ex) { + log.warn("[similar-asin] build public download url failed resultId={} err={}", + row.getId(), ex.getMessage()); + return null; + } + } + + private void attachFileJobState(SimilarAsinHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job, FileTaskEntity task) { + vo.setFileReady(row.getResultFileUrl() != null && !row.getResultFileUrl().isBlank()); + if (job == null) { + vo.setFileStatus(Boolean.TRUE.equals(vo.getFileReady()) ? "SUCCESS" : null); + attachFileProgress(vo, row, job, task); + return; + } + vo.setFileJobId(job.getId()); + vo.setFileStatus(job.getStatus()); + vo.setFileError(job.getErrorMessage()); + attachFileProgress(vo, row, job, task); + } + + private void attachFileProgress(SimilarAsinHistoryItemVo vo, FileResultEntity row, TaskFileJobEntity job, FileTaskEntity task) { + if (row == null || row.getTaskId() == null) { + return; + } + Long taskId = row.getTaskId(); + if (Boolean.TRUE.equals(vo.getFileReady())) { + vo.setFileProgressCurrent(1); + vo.setFileProgressTotal(1); + vo.setFileProgressPercent(100); + vo.setFileProgressMessage("结果文件已生成"); + return; + } + int llmCompleted = countCompletedLlmStates(taskId); + int llmPending = countPendingLlmStates(taskId); + boolean uploadComplete = isResultSubmissionComplete(taskId); + TaskProgressSnapshotEntity snapshot = taskProgressSnapshotService.find(taskId, MODULE_TYPE); + if (!uploadComplete) { + if (canTreatTaskAsCompleted(task, row, job)) { + vo.setFileProgressCurrent(1); + vo.setFileProgressTotal(1); + vo.setFileProgressPercent(100); + vo.setFileProgressMessage("结果文件已生成"); + return; + } + if (task != null && !STATUS_RUNNING.equals(task.getStatus())) { + return; + } + if (llmCompleted + llmPending > 0) { + attachLlmProgress(vo, job, snapshot, llmCompleted, llmPending, false); + return; + } + attachPythonUploadProgress(vo, taskId); + return; + } + if (llmCompleted + llmPending > 0) { + attachLlmProgress(vo, job, snapshot, llmCompleted, llmPending, true); + return; + } + if (snapshot == null) { + return; + } + int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount(); + int current = snapshot.getSuccessCount() == null ? 0 : snapshot.getSuccessCount(); + if (total <= 0) { + return; + } + LocalDateTime baseTime = snapshot.getUpdatedAt() != null ? snapshot.getUpdatedAt() : (job == null ? null : job.getUpdatedAt()); + int percent = calculateDisplayProgressPercent(current, total, job, baseTime); + percent = Math.max(percent, extractSnapshotDisplayPercent(snapshot)); + percent = Boolean.TRUE.equals(vo.getFileReady()) ? 100 : Math.min(99, percent); + vo.setFileProgressCurrent(current); + vo.setFileProgressTotal(total); + vo.setFileProgressPercent(percent); + vo.setFileProgressMessage(snapshot.getMessage()); + } + + private void attachLlmProgress(SimilarAsinHistoryItemVo vo, + TaskFileJobEntity job, + TaskProgressSnapshotEntity snapshot, + int llmCompleted, + int llmPending, + boolean uploadComplete) { + int observed = Math.max(0, llmCompleted) + Math.max(0, llmPending); + int total = calculateLlmDisplayTotal(snapshot, observed); + if (!uploadComplete) { + total = Math.max(total, observed + 1); + } + int current = Math.max(0, Math.min(llmCompleted, total)); + vo.setFileProgressCurrent(current); + vo.setFileProgressTotal(total); + int percent = calculateDisplayProgressPercent(current, total, job, snapshot == null ? null : snapshot.getUpdatedAt()); + percent = Math.max(percent, calculateSnapshotDisplayPercent(snapshot, job)); + vo.setFileProgressPercent(Math.min(uploadComplete ? 99 : 98, percent)); + vo.setFileProgressMessage(uploadComplete + ? (llmPending > 0 + ? buildLlmProgressMessage(current, total, llmPending) + : "LLM 已回流 " + current + "/" + total + " 批次,正在生成结果文件") + : buildUploadingLlmProgressMessage(current, total, llmPending)); + } + + private void attachPythonUploadProgress(SimilarAsinHistoryItemVo vo, Long taskId) { + PythonUploadProgress progress = resolvePythonUploadProgress(taskId); + int total = progress.total() > 0 ? progress.total() : Math.max(1, progress.current() + 1); + int current = Math.max(0, Math.min(progress.current(), total)); + int percent = current <= 0 ? 1 : Math.min(95, (int) Math.ceil(current * 100.0 / total)); + vo.setFileProgressCurrent(current); + vo.setFileProgressTotal(total); + vo.setFileProgressPercent(percent); + vo.setFileProgressMessage(buildPythonUploadProgressMessage(current, total, progress.unit())); + } + + private PythonUploadProgress resolvePythonUploadProgress(Long taskId) { + PythonUploadProgress chunkProgress = resolvePythonChunkProgress(taskId); + if (chunkProgress.total() > 0) { + return chunkProgress; + } + if (chunkProgress.current() > 0) { + return new PythonUploadProgress(chunkProgress.current(), chunkProgress.current() + 1, chunkProgress.unit()); + } + int totalRows = 0; + try { + FileTaskEntity task = fileTaskMapper.selectById(taskId); + totalRows = allRowCount(task); + } catch (Exception ignored) { + totalRows = 0; + } + int uploadedRows = countSubmittedRows(taskId); + if (totalRows <= 0 && uploadedRows > 0) { + totalRows = uploadedRows + 1; + } + return new PythonUploadProgress(uploadedRows, totalRows, "行"); + } + + private int allRowCount(FileTaskEntity task) { + try { + return rowCount(readParsedPayload(task)); + } catch (Exception ex) { + log.warn("[similar-asin] read all row count failed taskId={} err={}", task.getId(), ex.getMessage()); + return 0; + } + } + + private int rowCount(SimilarAsinParsedPayloadDto payload) { + if (payload == null) { + return 0; + } + return resolveAllRows(payload).size(); + } + + private SimilarAsinParsedPayloadDto readParsedPayload(FileTaskEntity task) { + try { + JsonNode root = objectMapper.readTree(task.getResultJson()); + String pointer = root.path("parsedPayloadRef").asText(""); + if (!pointer.isBlank()) { + String json = transientPayloadStorageService.resolvePayload(pointer, "read similar ASIN parsed payload failed"); + return hydrateParsedPayloadRows(objectMapper.readValue(json, SimilarAsinParsedPayloadDto.class)); + } + if (root.path("allItems").isArray()) { + return hydrateParsedPayloadRows(objectMapper.treeToValue(root, SimilarAsinParsedPayloadDto.class)); + } + } catch (Exception ex) { + throw new BusinessException("读取解析载荷失败"); + } + return hydrateParsedPayloadRows(new SimilarAsinParsedPayloadDto()); + } + + private SimilarAsinParsedPayloadDto hydrateParsedPayloadRows(SimilarAsinParsedPayloadDto payload) { + if (payload == null) { + return new SimilarAsinParsedPayloadDto(); + } + List rows = resolveAllRows(payload); + if (payload.getAllItems() == null || payload.getAllItems().isEmpty()) { + payload.setAllItems(rows); + } + if (payload.getItems() == null || payload.getItems().isEmpty()) { + payload.setItems(rows); + } + return payload; + } + + /** + * 从解析载荷统一恢复全量行:优先 items(新规范结构),其次 allItems(旧结构), + * 最后 groups 展开(最旧结构)。新旧结构均可恢复完整行集合,供 rowCount、 + * LLM 候选加载与结果文件组装复用。 + */ + private static List resolveAllRows(SimilarAsinParsedPayloadDto payload) { + if (payload == null) { + return List.of(); + } + if (containsGroupRefs(payload)) { + // 新格式:分组携带索引引用,信任引用展开结果(越界/非法区间安全跳过) + return expandGroupRefs(payload); + } + List rows = payload.getItems(); + if (rows == null || rows.isEmpty()) { + rows = payload.getAllItems(); + } + if ((rows == null || rows.isEmpty()) && payload.getGroups() != null) { + rows = payload.getGroups().stream() + .filter(Objects::nonNull) + .flatMap(group -> group.getItems() == null ? java.util.stream.Stream.empty() : group.getItems().stream()) + .toList(); + } + return rows == null ? List.of() : rows; + } + + private static boolean containsGroupRefs(SimilarAsinParsedPayloadDto payload) { + if (payload.getGroups() == null) { + return false; + } + for (SimilarAsinParsedGroupVo group : payload.getGroups()) { + if (group != null && (group.getStartIndex() != null || group.getEndIndex() != null)) { + return true; + } + } + return false; + } + + /** + * 按分组引用 [startIndex, endIndex) 从 items 展开组内行。 + * 引用越界或区间非法时安全跳过,不抛异常;展开不修改 payload 内部状态。 + */ + private static List expandGroupRefs(SimilarAsinParsedPayloadDto payload) { + if (payload == null || payload.getGroups() == null || payload.getGroups().isEmpty()) { + return List.of(); + } + List rows = payload.getItems(); + if (rows == null || rows.isEmpty()) { + rows = payload.getAllItems(); + } + if (rows == null || rows.isEmpty()) { + return List.of(); + } + List expanded = new ArrayList<>(); + for (SimilarAsinParsedGroupVo group : payload.getGroups()) { + if (group == null) { + continue; + } + int start = group.getStartIndex() == null ? 0 : group.getStartIndex(); + int end = group.getEndIndex() == null ? 0 : group.getEndIndex(); + if (start < 0 || end <= start || end > rows.size()) { + continue; + } + expanded.addAll(rows.subList(start, end)); + } + return expanded; + } + + private int countSubmittedRows(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0; + } + int total = 0; + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + if (chunks == null || chunks.isEmpty()) { + return 0; + } + for (TaskChunkEntity chunk : chunks) { + total += readChunkRows(chunk).size(); + } + return total; + } + + private Map readChunkRows(TaskChunkEntity chunk) { + Map rows = new LinkedHashMap<>(); + if (chunk == null || chunk.getPayloadJson() == null || chunk.getPayloadJson().isBlank()) { + return rows; + } + try { + String payloadJson = transientPayloadStorageService.resolvePayload(chunk.getPayloadJson(), "read similar ASIN chunk failed"); + JsonNode array = objectMapper.readTree(payloadJson); + if (!array.isArray()) { + return rows; + } + for (JsonNode node : array) { + SimilarAsinResultRowDto row = objectMapper.treeToValue(node, SimilarAsinResultRowDto.class); + rows.put(rowKey(row), row); + } + } catch (Exception ex) { + String msg = ex.getMessage() == null ? "" : ex.getMessage(); + // P1-4:识别跨实例 local 指针读不到的场景,把"chunk 在另一实例"的元信息 + // 通过 task_scope_state.last_error 留痕,便于排查"为何 owner 切换后 chunk 读不到"。 + boolean crossInstance = msg.contains("only exists on instance="); + log.warn("[similar-asin] read chunk payload failed taskId={} chunk={} crossInstance={} err={}", + chunk.getTaskId(), chunk.getChunkIndex(), crossInstance, msg); + recordChunkReadFailure(chunk, crossInstance, msg); + throw new BusinessException("similar ASIN chunk payload read failed chunk=" + + chunk.getChunkIndex() + ": " + msg, ex); + } + return rows; + } + + /** + * 把 chunk 读失败信息聚合到 task_scope_state.last_error, + * 形式 "chunk-read-failed[{idx}]" / "chunk-read-failed[{idx}@cross-instance]"。 + * best-effort:查询 / 更新失败时仅记日志,不抛回主流程。 + */ + private void recordChunkReadFailure(TaskChunkEntity chunk, boolean crossInstance, String msg) { + if (chunk == null || chunk.getTaskId() == null || chunk.getScopeHash() == null) { + return; + } + try { + TaskScopeStateEntity scope = taskScopeStateMapper.selectOne(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, chunk.getTaskId()) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .eq(TaskScopeStateEntity::getScopeHash, chunk.getScopeHash()) + .last("limit 1")); + if (scope == null) { + return; + } + String existing = scope.getLastError() == null ? "" : scope.getLastError(); + String tag = "chunk-read-failed[" + chunk.getChunkIndex() + (crossInstance ? "@cross-instance" : "") + "]"; + if (existing.contains(tag)) { + return; + } + String updated = existing.isBlank() ? tag : existing + "; " + tag; + taskScopeStateMapper.update(null, new LambdaUpdateWrapper() + .eq(TaskScopeStateEntity::getId, scope.getId()) + .set(TaskScopeStateEntity::getLastError, updated) + .set(TaskScopeStateEntity::getUpdatedAt, LocalDateTime.now())); + } catch (Exception ignored) { + // best-effort:失败不影响主流程 + } + } + + private String rowKey(SimilarAsinResultRowDto row) { + if (row == null) { + return ""; + } + String rowToken = normalize(row.getRowToken()); + if (!rowToken.isBlank()) { + return rowToken; + } + return legacyRowKey(row); + } + + private String legacyRowKey(SimilarAsinResultRowDto row) { + if (row == null) { + return ""; + } + return rowKey(row.getId(), row.getAsin(), row.getCountry()); + } + + private String rowKey(String id, String asin, String country) { + return normalize(id) + "::" + normalize(asin).toUpperCase(Locale.ROOT) + "::" + normalize(country); + } + + private String normalize(String val) { + return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " "); + } + + private PythonUploadProgress resolvePythonChunkProgress(Long taskId) { + if (taskId == null || taskId <= 0) { + return new PythonUploadProgress(0, 0, "分片"); + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .isNull(TaskScopeStateEntity::getLlmStatus) + .orderByDesc(TaskScopeStateEntity::getUpdatedAt)); + if (states == null || states.isEmpty()) { + return resolvePythonChunkProgressFromChunks(taskId); + } + int current = 0; + int total = 0; + for (TaskScopeStateEntity state : states) { + if (state == null) { + continue; + } + int stateTotal = state.getChunkTotal() == null ? 0 : state.getChunkTotal(); + int stateCurrent = state.getReceivedChunkCount() == null ? 0 : state.getReceivedChunkCount(); + stateCurrent = Math.max(stateCurrent, resolveReceivedChunkProgress(taskId, state.getScopeHash(), stateTotal)); + if (stateTotal > total || total <= 0 && stateCurrent > current) { + total = stateTotal; + current = stateCurrent; + } + } + return new PythonUploadProgress(current, total, "分片"); + } + + private PythonUploadProgress resolvePythonChunkProgressFromChunks(Long taskId) { + List chunks = taskChunkMapper.selectList(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE)); + if (chunks == null || chunks.isEmpty()) { + return new PythonUploadProgress(0, 0, "分片"); + } + Map receivedByScope = new LinkedHashMap<>(); + Map totalByScope = new LinkedHashMap<>(); + for (TaskChunkEntity chunk : chunks) { + if (chunk == null) { + continue; + } + String scopeHash = firstNonBlank(chunk.getScopeHash(), ""); + receivedByScope.merge(scopeHash, 1, Integer::sum); + if (chunk.getChunkIndex() != null && chunk.getChunkIndex() > 0) { + receivedByScope.merge(scopeHash, chunk.getChunkIndex(), Math::max); + } + if (chunk.getChunkTotal() != null && chunk.getChunkTotal() > 0) { + totalByScope.merge(scopeHash, chunk.getChunkTotal(), Math::max); + } + } + int current = 0; + int total = 0; + for (Map.Entry entry : receivedByScope.entrySet()) { + int scopeCurrent = entry.getValue() == null ? 0 : entry.getValue(); + int scopeTotal = totalByScope.getOrDefault(entry.getKey(), 0); + if (scopeTotal > total || total <= 0 && scopeCurrent > current) { + current = scopeCurrent; + total = scopeTotal; + } + } + return new PythonUploadProgress(current, total, "分片"); + } + + private int resolveReceivedChunkProgress(Long taskId, String scopeHash, Integer chunkTotal) { + int receivedCount = countChunks(taskId, scopeHash); + Integer maxChunkIndex = taskChunkMapper.selectObjs(new LambdaQueryWrapper() + .select(TaskChunkEntity::getChunkIndex) + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash) + .orderByDesc(TaskChunkEntity::getChunkIndex) + .last("limit 1")) + .stream() + .filter(Objects::nonNull) + .map(value -> { + if (value instanceof Number number) { + return number.intValue(); + } + try { + return Integer.parseInt(value.toString()); + } catch (Exception ignored) { + return 0; + } + }) + .findFirst() + .orElse(0); + int current = Math.max(receivedCount, maxChunkIndex == null ? 0 : maxChunkIndex); + if (chunkTotal != null && chunkTotal > 0) { + current = Math.min(current, chunkTotal); + } + return Math.max(0, current); + } + + private int countChunks(Long taskId, String scopeHash) { + if (taskId == null || taskId <= 0) { + return 0; + } + Long count = taskChunkMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskChunkEntity::getTaskId, taskId) + .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) + .eq(TaskChunkEntity::getScopeHash, scopeHash)); + return count == null ? 0 : count.intValue(); + } + + private String buildPythonUploadProgressMessage(int current, int total, String unit) { + String safeUnit = unit == null || unit.isBlank() ? "分片" : unit; + return "等待 Python 回传,已回传 " + Math.max(0, current) + "/" + Math.max(1, total) + " " + safeUnit; + } + + private String resolveDisplayTaskStatus(FileTaskEntity task, FileResultEntity row) { + if (task == null) { + return null; + } + if (canTreatTaskAsCompleted(task, row, null)) { + return STATUS_SUCCESS; + } + return task.getStatus(); + } + + private boolean canTreatTaskAsCompleted(FileTaskEntity task, FileResultEntity row, TaskFileJobEntity job) { + if (task == null) { + return false; + } + if (STATUS_SUCCESS.equals(task.getStatus())) { + return true; + } + if (!STATUS_RUNNING.equals(task.getStatus())) { + return false; + } + if (row == null) { + return false; + } + if (row.getResultFileUrl() == null || row.getResultFileUrl().isBlank()) { + return false; + } + if (job != null && "FAILED".equals(job.getStatus())) { + return false; + } + return true; + } + + private int calculateLlmDisplayTotal(TaskProgressSnapshotEntity snapshot, int observedLlmStates) { + int observed = Math.max(0, observedLlmStates); + // 小任务 totalCount <= 3 时直接使用 observed,避免 totalCount - 3 → 0 引起进度条 0/0。 + if (snapshot == null || snapshot.getTotalCount() == null || snapshot.getTotalCount() <= 3) { + return Math.max(1, observed); + } + int expectedFromSnapshot = Math.max(1, snapshot.getTotalCount() - 3); + return Math.max(1, Math.max(observed, expectedFromSnapshot)); + } + + private String buildLlmProgressMessage(int completed, int total, int pending) { + int safeTotal = Math.max(1, total); + int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); + int submitted = Math.max(safeCompleted, Math.min(safeTotal, safeCompleted + Math.max(0, pending))); + if (safeCompleted <= 0 && submitted > 0) { + return "LLM 已提交 " + submitted + "/" + safeTotal + " 批次,等待回流"; + } + return "LLM 已回流 " + safeCompleted + "/" + safeTotal + " 批次,等待结果文件"; + } + + private String buildUploadingLlmProgressMessage(int completed, int total, int pending) { + int safeTotal = Math.max(1, total); + int safeCompleted = Math.max(0, Math.min(completed, safeTotal)); + int submitted = Math.max(safeCompleted, Math.min(safeTotal, safeCompleted + Math.max(0, pending))); + if (pending > 0) { + return "Python 仍在回传,LLM 已提交 " + submitted + "/" + safeTotal + " 批次"; + } + return "Python 仍在回传,LLM 已回流 " + safeCompleted + "/" + safeTotal + " 批次"; + } + + private int calculateDisplayProgressPercent(int current, + int total, + TaskFileJobEntity job, + LocalDateTime baseTime) { + if (total <= 0) { + return 0; + } + current = Math.max(0, Math.min(current, total)); + int percent = Math.max(1, Math.min(99, (int) Math.floor(current * 100.0 / total))); + if (job != null && STATUS_RUNNING.equals(job.getStatus())) { + long elapsedSeconds = baseTime == null ? 0 : Math.max(0, Duration.between(baseTime, LocalDateTime.now()).getSeconds()); + if (current <= 0) { + int firstRealProgressPercent = Math.max(1, Math.min(99, (int) Math.floor(100.0 / total))); + int waitingCap = Math.max(8, Math.min(35, firstRealProgressPercent - 1)); + percent = Math.max(percent, Math.min(waitingCap, 8 + (int) (elapsedSeconds / 6))); + } else if (current < total) { + percent = Math.max(percent, Math.min(92, percent + (int) (elapsedSeconds / 10))); + } + } + return percent; + } + + private int calculateSnapshotDisplayPercent(TaskProgressSnapshotEntity snapshot, TaskFileJobEntity job) { + if (snapshot == null) { + return 0; + } + int total = snapshot.getTotalCount() == null ? 0 : snapshot.getTotalCount(); + int current = snapshot.getSuccessCount() == null ? 0 : snapshot.getSuccessCount(); + int percent = total <= 0 ? 0 : calculateDisplayProgressPercent(current, total, job, snapshot.getUpdatedAt()); + return Math.max(percent, extractSnapshotDisplayPercent(snapshot)); + } + + private int extractSnapshotDisplayPercent(TaskProgressSnapshotEntity snapshot) { + if (snapshot == null || snapshot.getSnapshotJson() == null || snapshot.getSnapshotJson().isBlank()) { + return 0; + } + try { + JsonNode displayPercent = objectMapper.readTree(snapshot.getSnapshotJson()).path("displayPercent"); + if (!displayPercent.isNumber()) { + return 0; + } + return Math.max(0, Math.min(100, displayPercent.asInt())); + } catch (Exception ignored) { + return 0; + } + } + + private int countPendingLlmStates(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0; + } + Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .in(TaskScopeStateEntity::getLlmStatus, List.of(LLM_STATUS_SUBMITTED, LLM_STATUS_RUNNING))); + return count == null ? 0 : count.intValue(); + } + + private int countCompletedLlmStates(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0; + } + Long count = taskScopeStateMapper.selectCount(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .in(TaskScopeStateEntity::getLlmStatus, List.of(LLM_STATUS_DONE, LLM_STATUS_FAILED))); + return count == null ? 0 : count.intValue(); + } + + private boolean isResultSubmissionComplete(Long taskId) { + if (taskId == null || taskId <= 0) { + return false; + } + List states = taskScopeStateMapper.selectList(new LambdaQueryWrapper() + .eq(TaskScopeStateEntity::getTaskId, taskId) + .eq(TaskScopeStateEntity::getModuleType, MODULE_TYPE) + .isNull(TaskScopeStateEntity::getLlmStatus) + .isNotNull(TaskScopeStateEntity::getLastChunkAt)); + if (states == null || states.isEmpty()) { + return false; + } + boolean hasCompletedScope = false; + for (TaskScopeStateEntity state : states) { + if (state == null) { + continue; + } + if (Integer.valueOf(1).equals(state.getCompleted())) { + hasCompletedScope = true; + continue; + } + return false; + } + return hasCompletedScope; + } + + private String fmt(LocalDateTime t) { + return t == null ? null : t.toString(); + } + + private String firstNonBlank(String preferred, String fallback) { + return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); + } + + private record PythonUploadProgress(int current, int total, String unit) { + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinRowNormalizer.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinRowNormalizer.java new file mode 100644 index 00000000..5673aa16 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinRowNormalizer.java @@ -0,0 +1,22 @@ +package com.nanri.aiimage.modules.similarasin.service.support; + +import java.util.Locale; + +/** + * 任务 85:SimilarAsinRowNormalizer 字段归一化器。 + * 规则与 SimilarAsinTaskService.normalize 现状逐字节一致(BOM 剥离、全角空格转半角、 + * trim、连续空白折叠为单空格);normalizeAsin = normalize + 大写。纯函数无状态。 + */ +public final class SimilarAsinRowNormalizer { + + private SimilarAsinRowNormalizer() { + } + + public static String normalize(String val) { + return val == null ? "" : val.replace(String.valueOf((char) 0xFEFF), "").replace((char) 0x3000, ' ').trim().replaceAll("\\s+", " "); + } + + public static String normalizeAsin(String val) { + return normalize(val).toUpperCase(Locale.ROOT); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinRowValidator.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinRowValidator.java new file mode 100644 index 00000000..f88e127a --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinRowValidator.java @@ -0,0 +1,66 @@ +package com.nanri.aiimage.modules.similarasin.service.support; + +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +/** + * 任务 87:SimilarAsinRowValidator 纯校验器。 + * 必填检查(id/asin/country)、ASIN 格式校验(仅允许字母数字,含校验前大写化)、 + * 重复检测(asin+country 键,保留首次出现)。返回错误清单不抛异常,由调用方决定处理。 + * 规则与现状 parseWorkbook 必填丢弃 / dedupeRowsByRowKey 保留首次语义一致。 + */ +public final class SimilarAsinRowValidator { + + public record RowError(int rowIndex, String column, String message) { + } + + private SimilarAsinRowValidator() { + } + + public static List validate(List rows) { + List errors = new ArrayList<>(); + if (rows == null || rows.isEmpty()) { + return errors; + } + Set seenKeys = new java.util.HashSet<>(); + for (SimilarAsinParsedRowVo row : rows) { + if (row == null) { + continue; + } + String id = SimilarAsinRowNormalizer.normalize(row.getSourceId()); + String asin = SimilarAsinRowNormalizer.normalizeAsin(row.getAsin()); + String country = SimilarAsinRowNormalizer.normalize(row.getCountry()); + if (id.isEmpty() && asin.isEmpty() && country.isEmpty()) { + continue; + } + int rowIndex = row.getRowIndex() == null ? 0 : row.getRowIndex(); + if (id.isEmpty()) { + errors.add(new RowError(rowIndex, "id", "缺少必要字段: id")); + } + if (asin.isEmpty()) { + errors.add(new RowError(rowIndex, "asin", "缺少必要字段: asin")); + } + if (country.isEmpty()) { + errors.add(new RowError(rowIndex, "country", "缺少必要字段: 国家")); + } + if (asin.isEmpty()) { + continue; + } + if (!asin.matches("[A-Z0-9]+")) { + errors.add(new RowError(rowIndex, "asin", + "ASIN 格式不正确(仅允许字母数字): " + asin)); + continue; + } + String dupKey = asin + "::" + country; + if (!seenKeys.add(dupKey)) { + errors.add(new RowError(rowIndex, "asin", + "重复 ASIN: " + asin + " 国家: " + country)); + } + } + return errors; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinSheetBuilder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinSheetBuilder.java new file mode 100644 index 00000000..2656b457 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/support/SimilarAsinSheetBuilder.java @@ -0,0 +1,112 @@ +package com.nanri.aiimage.modules.similarasin.service.support; + +import com.nanri.aiimage.modules.similarasin.model.vo.SimilarAsinParsedRowVo; +import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellStyle; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 任务 88:SimilarAsinSheetBuilder Sheet 构造器。 + * 结果 Workbook/Sheet 构造辅助(表头、列顺序、样式);输入数据 → 输出 workbook;不落库、无 IO 依赖。 + * 表头/列序/样式与现状 assembleResultWorkbook 一致:sheet 名"相似asin检测"、15 列表头、 + * 加粗表头、数据行从第 1 行、图片列 12-14 列宽、行高 409pt、值兜底与现状一致。 + * 结果派生列(是否有货/相似度/是否符合类目/不符合理由/产品类目/状态)来自 LLM 结果行, + * 本 builder 输入只有解析行,这些列由调用方补写。 + */ +public final class SimilarAsinSheetBuilder { + + public static final List RESULT_HEADERS = List.of( + "id", + "asin", + "国家", + "价格", + "卖家名称", + "品牌", + "是否有货", + "相似度", + "是否符合类目", + "不符合理由", + "产品类目", + "状态", + "主图", + "阿里巴巴图片1", + "阿里巴巴图片2" + ); + + public static final int IMG_COL_MAIN = 12; + public static final int IMG_COL_PUZZLE1 = 13; + public static final int IMG_COL_PUZZLE2 = 14; + + private SimilarAsinSheetBuilder() { + } + + public static void build(Workbook workbook, List rows) { + Sheet sheet = workbook.createSheet("相似asin检测"); + CellStyle headerStyle = workbook.createCellStyle(); + Font font = workbook.createFont(); + font.setBold(true); + headerStyle.setFont(font); + + sheet.setColumnWidth(IMG_COL_MAIN, SimilarAsinImageEmbedder.IMAGE_COL_WIDTH_CHARS * 256); + sheet.setColumnWidth(IMG_COL_PUZZLE1, SimilarAsinImageEmbedder.IMAGE_COL_WIDTH_CHARS * 256); + sheet.setColumnWidth(IMG_COL_PUZZLE2, SimilarAsinImageEmbedder.IMAGE_COL_WIDTH_CHARS * 256); + + Row header = sheet.createRow(0); + for (int i = 0; i < RESULT_HEADERS.size(); i++) { + Cell cell = header.createCell(i); + cell.setCellValue(RESULT_HEADERS.get(i)); + cell.setCellStyle(headerStyle); + } + + if (rows == null) { + return; + } + int rowIndex = 1; + for (SimilarAsinParsedRowVo parsedRow : rows) { + if (parsedRow == null) { + continue; + } + Row row = sheet.createRow(rowIndex); + int col = 0; + row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getDisplayId(), parsedRow.getSourceId())); + row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getAsin(), "")); + row.createCell(col++).setCellValue(firstNonBlank(parsedRow.getCountry(), "")); + row.createCell(col++).setCellValue(firstNonBlank( + parsedRow.getPrice(), readValueByHeader(parsedRow, "价格", "price"))); + row.createCell(col++).setCellValue(readValueByHeader( + parsedRow, "卖家名称", "卖家名", "卖家", "店铺名称", "店铺名", "seller name", "seller_name", + "seller-name", "sellername", "store name", "shop name")); + row.createCell(col).setCellValue(readValueByHeader(parsedRow, "品牌", "brand")); + row.setHeightInPoints(SimilarAsinImageEmbedder.IMAGE_ROW_HEIGHT_POINTS); + rowIndex++; + } + } + + private static String readValueByHeader(SimilarAsinParsedRowVo row, String... candidates) { + if (row == null || row.getValues() == null || row.getValues().isEmpty() || candidates == null) { + return ""; + } + for (Map.Entry entry : row.getValues().entrySet()) { + String header = SimilarAsinRowNormalizer.normalize(entry.getKey()).toLowerCase(Locale.ROOT); + for (String candidate : candidates) { + String expected = SimilarAsinRowNormalizer.normalize(candidate).toLowerCase(Locale.ROOT); + if (!expected.isBlank() && header.contains(expected)) { + return entry.getValue() == null ? "" : entry.getValue(); + } + } + } + return ""; + } + + private static String firstNonBlank(String preferred, String fallback) { + return preferred == null || preferred.isBlank() ? fallback : preferred.trim(); + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskProgressLightRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskProgressLightRequest.java new file mode 100644 index 00000000..8c267a33 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/dto/TaskProgressLightRequest.java @@ -0,0 +1,27 @@ +package com.nanri.aiimage.modules.task.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +/** + * 共享轻量进度查询请求(各模块 progress/light 端点共用)。 + * 最多处理 200 个任务 ID,超长部分截断丢弃(轮询端点保持响应快)。 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "任务轻量进度查询请求") +public class TaskProgressLightRequest { + @NotNull + @Schema(description = "需要查询进度的任务 ID 列表。前端只传正在轮询的活跃任务;后端批量查询,最多 200 个。", + example = "[3938,3939]", requiredMode = Schema.RequiredMode.REQUIRED) + private List taskIds = new ArrayList<>(); + @Schema(description = "当前用户 ID(可省略;传入时仅返回该用户的任务,owner-scoped 模块使用)。", example = "1") + private Long userId; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightBatchVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightBatchVo.java new file mode 100644 index 00000000..a6faf3db --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightBatchVo.java @@ -0,0 +1,19 @@ +package com.nanri.aiimage.modules.task.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 共享轻量进度批量响应:items 按请求顺序返回,查不到的任务进 missingTaskIds。 + */ +@Data +@Schema(description = "任务轻量进度批量响应") +public class TaskProgressLightBatchVo { + @Schema(description = "按请求顺序返回的轻量进度项。") + private List items = new ArrayList<>(); + @Schema(description = "查询不到的任务 ID 列表。") + private List missingTaskIds = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightVo.java new file mode 100644 index 00000000..358fd465 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/model/vo/TaskProgressLightVo.java @@ -0,0 +1,28 @@ +package com.nanri.aiimage.modules.task.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 共享轻量进度项(11 模块 progress/light 轮询专用)。 + * 白名单键与 similarasin 的 SimilarAsinTaskLightVo 保持一致: + * taskId/status/statusCode?/fileStatus?/fileError?/fileReady?/updatedAt。 + */ +@Data +@Schema(description = "任务轻量进度项(前端轮询专用,不含明细/result 内容)") +public class TaskProgressLightVo { + @Schema(description = "任务 ID。", example = "3938") + private Long taskId; + @Schema(description = "任务状态:PENDING=已解析待推送,RUNNING=执行中,SUCCESS=成功,FAILED=失败。", example = "RUNNING") + private String status; + @Schema(description = "业务状态码(预留字段,当前恒为 null)。", example = "null") + private String statusCode; + @Schema(description = "结果文件组装 Job 状态:SUCCESS/RUNNING/FAILED;无 Job 时为空。", example = "SUCCESS") + private String fileStatus; + @Schema(description = "文件组装失败时的错误信息;无错误时为空。", example = "组装失败: 文件写入超时") + private String fileError; + @Schema(description = "结果文件是否已就绪。", example = "true") + private Boolean fileReady; + @Schema(description = "最后更新时间,ISO 本地时间字符串。", example = "2026-04-26T10:05:00") + private String updatedAt; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java index 1f26a1b5..4a401d36 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java @@ -531,6 +531,28 @@ public class TaskFileJobService { return jobMap; } + /** 按任务 ID 集合一次 IN 查询 ASSEMBLE_RESULT Job,返回 taskId→Job 的 Map(轻量进度接口用)。 */ + public Map findAssembleJobsByTaskIds(String moduleType, List taskIds) { + List normalizedIds = taskIds == null ? List.of() : taskIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (normalizedIds.isEmpty() || moduleType == null || moduleType.isBlank()) { + return Map.of(); + } + List jobs = taskFileJobMapper.selectList(new LambdaQueryWrapper() + .eq(TaskFileJobEntity::getModuleType, moduleType) + .eq(TaskFileJobEntity::getJobType, JOB_TYPE_ASSEMBLE_RESULT) + .in(TaskFileJobEntity::getTaskId, normalizedIds)); + Map jobMap = new LinkedHashMap<>(); + for (TaskFileJobEntity job : jobs) { + if (job != null && job.getTaskId() != null) { + jobMap.putIfAbsent(job.getTaskId(), job); + } + } + return jobMap; + } + public long countUnfinishedAssembleJobs(Long taskId, String moduleType) { if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank()) { return 0L; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssembler.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssembler.java new file mode 100644 index 00000000..69592a1f --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssembler.java @@ -0,0 +1,76 @@ +package com.nanri.aiimage.modules.task.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 共享轻量进度装配器:11 模块 progress/light 统一实现。 + * 语义与 similarasin 的 progressLight 一致——只查任务行 + 一次 ASSEMBLE Job IN 批量查询; + * 空/重复/非正数过滤;超过 MAX_TASK_IDS 截断丢弃;缺失任务进 missingTaskIds; + * fileStatus/fileError/fileReady 由 Job 推导;状态值语义与各模块 progress/batch 一致。 + * userId 非空时按用户过滤(publish 等 owner-scoped 模块使用)。 + */ +@Component +@RequiredArgsConstructor +public class TaskProgressLightAssembler { + + public static final int MAX_TASK_IDS = 200; + + private final FileTaskMapper fileTaskMapper; + private final TaskFileJobService taskFileJobService; + + public TaskProgressLightBatchVo assemble(String moduleType, Long userId, List taskIds) { + TaskProgressLightBatchVo vo = new TaskProgressLightBatchVo(); + List normalizedIds = taskIds == null ? List.of() : taskIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .limit(MAX_TASK_IDS) + .toList(); + if (normalizedIds.isEmpty()) { + return vo; + } + LambdaQueryWrapper query = new LambdaQueryWrapper() + .select(FileTaskEntity::getId, + FileTaskEntity::getStatus, + FileTaskEntity::getUpdatedAt) + .in(FileTaskEntity::getId, normalizedIds); + if (userId != null) { + query.eq(FileTaskEntity::getUserId, userId); + } + Map taskMap = new LinkedHashMap<>(); + for (FileTaskEntity task : fileTaskMapper.selectList(query)) { + if (task != null && moduleType.equals(task.getModuleType())) { + taskMap.put(task.getId(), task); + } + } + Map jobMap = taskFileJobService.findAssembleJobsByTaskIds(moduleType, new ArrayList<>(taskMap.keySet())); + for (Long taskId : normalizedIds) { + FileTaskEntity task = taskMap.get(taskId); + if (task == null) { + vo.getMissingTaskIds().add(taskId); + continue; + } + TaskFileJobEntity job = jobMap.get(taskId); + TaskProgressLightVo item = new TaskProgressLightVo(); + item.setTaskId(task.getId()); + item.setStatus(task.getStatus()); + item.setFileReady(job != null && job.getResultFileUrl() != null && !job.getResultFileUrl().isBlank()); + item.setFileStatus(job == null ? null : job.getStatus()); + item.setFileError(job == null ? null : job.getErrorMessage()); + item.setUpdatedAt(task.getUpdatedAt() == null ? null : task.getUpdatedAt().toString()); + vo.getItems().add(item); + } + return vo; + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java index 3f229e33..585cae1d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/controller/WithdrawTaskController.java @@ -37,6 +37,8 @@ import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; +import com.nanri.aiimage.modules.task.model.dto.TaskProgressLightRequest; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; @RestController @RequiredArgsConstructor @@ -107,6 +109,14 @@ public class WithdrawTaskController { return ApiResponse.success(withdrawTaskService.getTaskProgressBatch(request.getTaskIds())); } + @PostMapping("/tasks/progress/light") + @Operation(summary = "批量查询任务轻量进度(前端轮询专用)", + description = "与 progress/batch 同输入,只返回轻量字段(taskId/status/statusCode/fileStatus/fileError/fileReady/updatedAt)," + + "不查明细/result 行,响应体更小;旧 progress/batch 端点保留不动。") + public ApiResponse progressLight(@Valid @RequestBody TaskProgressLightRequest request) { + return ApiResponse.success(withdrawTaskService.progressLight(request.getTaskIds())); + } + @PostMapping("/tasks") @Operation(summary = "创建取款任务", description = "为一批已匹配店铺创建一个取款任务,写入任务记录和店铺结果占位记录,并返回 taskId 及初始店铺快照。") public ApiResponse createTask(@Valid @RequestBody WithdrawCreateTaskRequest request) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java index 3218ed6b..0fd740ba 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/withdraw/service/WithdrawTaskService.java @@ -42,6 +42,8 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import com.nanri.aiimage.modules.task.model.vo.TaskProgressLightBatchVo; +import com.nanri.aiimage.modules.task.service.TaskProgressLightAssembler; @Service @RequiredArgsConstructor @@ -49,6 +51,10 @@ import java.util.Objects; public class WithdrawTaskService { private static final String MODULE_TYPE = "WITHDRAW"; + + public TaskProgressLightBatchVo progressLight(List taskIds) { + return taskProgressLightAssembler.assemble(MODULE_TYPE, null, taskIds); + } private static final String CONTENT_TYPE_XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private static final int RESULT_PENDING = -1; private static final int RESULT_FAILED = 0; @@ -68,6 +74,7 @@ public class WithdrawTaskService { private final TaskResultItemService taskResultItemService; private final TaskProgressSnapshotService taskProgressSnapshotService; private final TaskDistributedLockService taskDistributedLockService; + private final TaskProgressLightAssembler taskProgressLightAssembler; public ProductRiskDashboardVo dashboard(Long userId) { validateUserId(userId); diff --git a/backend-java/src/main/resources/db/V101__task_file_job_module_type_task_index.sql b/backend-java/src/main/resources/db/V101__task_file_job_module_type_task_index.sql new file mode 100644 index 00000000..5de40c87 --- /dev/null +++ b/backend-java/src/main/resources/db/V101__task_file_job_module_type_task_index.sql @@ -0,0 +1,31 @@ +-- Task 116(依赖 115 审计):biz_task_file_job 增加 (module_type, job_type, task_id) 复合索引。 +-- 背景:findAssembleJobsByTaskIds(progress/light)以 module_type + job_type 等值 + task_id IN 查询, +-- 现有 idx_file_job_task (task_id, module_type) 最左前缀不匹配,导致 index merge/大范围扫描。 +-- 风险:ALTER TABLE ADD INDEX 需申请元数据锁(MySQL 8 在线 DDL,INSTANT/INPLACE), +-- 建议在低峰窗口(凌晨 02:00-06:00)执行;表若极大,评估 pt-online-schema-change。 +-- 回滚:DROP INDEX idx_file_job_module_type_task ON biz_task_file_job(见文件末尾注释块)。 + +SET @db_name = DATABASE(); + +SET @idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = @db_name + AND TABLE_NAME = 'biz_task_file_job' + AND INDEX_NAME = 'idx_file_job_module_type_task' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE biz_task_file_job ADD INDEX idx_file_job_module_type_task (module_type, job_type, task_id)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 验证 SQL:应返回 1 行(索引已存在) +-- SELECT COUNT(*) FROM information_schema.STATISTICS +-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'biz_task_file_job' +-- AND INDEX_NAME = 'idx_file_job_module_type_task'; + +-- 回滚(低峰执行): +-- ALTER TABLE biz_task_file_job DROP INDEX idx_file_job_module_type_task; diff --git a/backend-java/src/main/resources/db/V102__group_manage_admin_menu.sql b/backend-java/src/main/resources/db/V102__group_manage_admin_menu.sql new file mode 100644 index 00000000..f36b9d4d --- /dev/null +++ b/backend-java/src/main/resources/db/V102__group_manage_admin_menu.sql @@ -0,0 +1,4 @@ +-- 分组管理:独立管理后台菜单项,放入「账号与权限」菜单组(sort_order 25) +INSERT INTO columns (name, column_key, menu_type, route_path, sort_order) +SELECT '分组管理', 'admin_group_manage', 'admin', 'group-manage', 25 +WHERE NOT EXISTS (SELECT 1 FROM columns WHERE column_key = 'admin_group_manage'); diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/ExplainIndexAuditDocTest.java b/backend-java/src/test/java/com/nanri/aiimage/config/ExplainIndexAuditDocTest.java new file mode 100644 index 00000000..df54fbe1 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/ExplainIndexAuditDocTest.java @@ -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 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 TABLE(DDL 属于后续任务 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 聚合查询"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/config/IndexMigrationV101Test.java b/backend-java/src/test/java/com/nanri/aiimage/config/IndexMigrationV101Test.java new file mode 100644 index 00000000..5d1f0ca4 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/config/IndexMigrationV101Test.java @@ -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 allMigrations() throws IOException { + try (Stream 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 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 all = allMigrations(); + List 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"), "仅追加索引,向后兼容"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceDelegationTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceDelegationTest.java new file mode 100644 index 00000000..16db5b80 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceDelegationTest.java @@ -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; + +/** + * 任务 95:AppearancePatent 门面改委托。 + * 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 status;commit/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 键为 rowToken(service 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 命中 resultRow(legacy 键不匹配仍能解析结论)"); + 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 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 rows, + Map 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 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; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceHistoryBatchTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceHistoryBatchTest.java new file mode 100644 index 00000000..915b701b --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceHistoryBatchTest.java @@ -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; + +/** + * 任务 106:appearancepatent 历史列表批量加载(同 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 resultDb = new ArrayList<>(); + private final List taskDb = new ArrayList<>(); + private final List jobDb = new ArrayList<>(); + + private final AtomicInteger resultSelectCount = new AtomicInteger(); + private final AtomicInteger taskSelectCount = new AtomicInteger(); + private final AtomicInteger jobSelectCount = new AtomicInteger(); + private final List 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.} 引用的参数值。 */ + private static List paramValuesOf(LambdaQueryWrapper q, String segment) { + List values = new ArrayList<>(); + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("#\\{ew\\.paramNameValuePairs\\.(\\w+)}").matcher(segment); + Map 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 q = invocation.getArgument(0); + String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment(); + List params = paramValuesOf(q, segment); + List 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 q = invocation.getArgument(0); + String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment(); + List params = paramValuesOf(q, segment); + List 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 resultIds = invocation.getArgument(1); + Map 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)"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java index 6b3649b1..6b43af8f 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskServiceTest.java @@ -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)); - } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentExcelParserTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentExcelParserTest.java new file mode 100644 index 00000000..5851a567 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentExcelParserTest.java @@ -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; + +/** + * 任务 93:AppearancePatentExcelParser 行解析器。 + * 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 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.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.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.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.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.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(), "重复列取首个匹配列"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentHistoryAssemblerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentHistoryAssemblerTest.java new file mode 100644 index 00000000..83dc623e --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentHistoryAssemblerTest.java @@ -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; + +/** + * 任务 97:AppearancePatentHistoryAssembler 历史查询组装器。 + * 历史列表 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"))); + + // 活动时间取 latestTime(row.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()); + } +} \ No newline at end of file diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentRowNormalizerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentRowNormalizerTest.java new file mode 100644 index 00000000..89d5c3eb --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentRowNormalizerTest.java @@ -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; + +/** + * 任务 93:AppearancePatentRowNormalizer 字段归一化器。 + * 规则与 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")); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentSheetBuilderTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentSheetBuilderTest.java new file mode 100644 index 00000000..68c7c1ef --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentSheetBuilderTest.java @@ -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; + +/** + * 任务 94:AppearancePatentSheetBuilder 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 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"); + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentSnapshotTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentSnapshotTest.java new file mode 100644 index 00000000..2bd25dea --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/appearancepatent/service/support/AppearancePatentSnapshotTest.java @@ -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; + +/** + * 任务 96:AppearancePatent 快照对比测试。 + * 夹具 Excel(appearancepatent 实际结构)→ 解析结果快照(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 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 toRows(File file, String fileKey, String filename) throws Exception { + AppearancePatentExcelParser.ParsedSheet parsed = new AppearancePatentExcelParser().parse(file); + List 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 rows) { + if (rows == null || rows.isEmpty()) { + return; + } + Map> 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 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 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 mergeRows(List> all) { + List merged = new ArrayList<>(); + for (List 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 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 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 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 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 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 列"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataDeleteConsistencyTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataDeleteConsistencyTest.java index 3ecefc15..1e91f4dc 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataDeleteConsistencyTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataDeleteConsistencyTest.java @@ -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) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataStorageCallCountTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataStorageCallCountTest.java index 34133641..0ac8f890 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataStorageCallCountTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/collectdata/service/CollectDataStorageCallCountTest.java @@ -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) { diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/invalidasin/controller/InvalidAsinDataControllerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/invalidasin/controller/InvalidAsinDataControllerTest.java index 27309934..06206fef 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/invalidasin/controller/InvalidAsinDataControllerTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/invalidasin/controller/InvalidAsinDataControllerTest.java @@ -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")); } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataServiceTest.java index 67c1bdcc..06ea1a14 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataServiceTest.java @@ -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> 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> 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> 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)); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishBatchLoadingSnapshotEquivTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishBatchLoadingSnapshotEquivTest.java new file mode 100644 index 00000000..61233216 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishBatchLoadingSnapshotEquivTest.java @@ -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 taskDb = new ArrayList<>(); + private final List fileDb = new ArrayList<>(); + private final List resultDb = new ArrayList<>(); + private final List 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 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 inParamIds(LambdaQueryWrapper q) { + String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment(); + List ids = new ArrayList<>(); + java.util.regex.Matcher m = java.util.regex.Pattern + .compile("#\\{ew\\.paramNameValuePairs\\.(\\w+)}").matcher(segment); + Map 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 q = invocation.getArgument(0); + String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment(); + List params = new ArrayList<>(q.getParamNameValuePairs().values()); + List 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 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 q = invocation.getArgument(0); + List 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 q = invocation.getArgument(0); + List 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 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 wrapper = invocation.getArgument(0); + String segment = wrapper.getSqlSegment() == null ? "" : wrapper.getSqlSegment(); + assertTrue(segment.toLowerCase().contains("group by"), "聚合查询必须带 GROUP BY: " + segment); + Map 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> rows = new ArrayList<>(); + for (Map.Entry entry : counts.entrySet()) { + Map 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 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(); + } + + /** 逐条基准:与批量路径相同的装配语义,但每任务一次 getTaskDetail(selectById 单条路径)。 */ + 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 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(), "数据变化后快照必须不同(等价门禁可感知改动)"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishDashboardAggregateTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishDashboardAggregateTest.java new file mode 100644 index 00000000..758e4547 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/publish/service/PublishDashboardAggregateTest.java @@ -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; + +/** + * 任务 108:dashboard 聚合查询。 + * 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 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 wrapper, String segment) { + AbstractWrapper q = (AbstractWrapper) wrapper; + List 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 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 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> rows = new ArrayList<>(); + for (Map.Entry entry : counts.entrySet()) { + Map 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 q = invocation.getArgument(0); + String segment = q.getSqlSegment() == null ? "" : q.getSqlSegment(); + Long userId = userIdOf(q, segment); + List 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 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 列表一次明细查询"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlChunkUpsertTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlChunkUpsertTest.java index e56a428e..5abc68c7 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlChunkUpsertTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlChunkUpsertTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlCleanupTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlCleanupTest.java index b197b515..3d7180bb 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlCleanupTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlCleanupTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileIncrementalTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileIncrementalTest.java index acdaa8e6..a7fb149c 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileIncrementalTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileIncrementalTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileJobSplitTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileJobSplitTest.java index 6e5f30b7..de35cba3 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileJobSplitTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileJobSplitTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileLockTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileLockTest.java index c8626f18..cee96cdd 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileLockTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlDailyFileLockTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyServiceDelegationTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyServiceDelegationTest.java new file mode 100644 index 00000000..bc3c6126 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlExcelAssemblyServiceDelegationTest.java @@ -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; + +/** + * 任务 101:ShopDataCrawl 门面改委托。 + * 门面(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> 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 sheetNames(XSSFWorkbook wb) { + return java.util.stream.IntStream.range(0, wb.getNumberOfSheets()) + .mapToObj(i -> wb.getSheetAt(i).getSheetName()).toList(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlLightweightProgressTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlLightweightProgressTest.java index 882c7602..4057c339 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlLightweightProgressTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlLightweightProgressTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlOwnerColumnTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlOwnerColumnTest.java index 2869cd1d..23c04a0a 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlOwnerColumnTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlOwnerColumnTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlProgressQueryTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlProgressQueryTest.java index d6d03799..210b2db4 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlProgressQueryTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlProgressQueryTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlRowDedupKeyTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlRowDedupKeyTest.java index 1a029d59..34a46a31 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlRowDedupKeyTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlRowDedupKeyTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlScopeCounterTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlScopeCounterTest.java index 0da7ca55..c3fe2f74 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlScopeCounterTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlScopeCounterTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlScopeMergeTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlScopeMergeTest.java index 9c7973a2..34d07c81 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlScopeMergeTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlScopeMergeTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskServiceChunkTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskServiceChunkTest.java index eac4ac5f..2bf5e7cc 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskServiceChunkTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskServiceChunkTest.java @@ -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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlHistoryAssemblerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlHistoryAssemblerTest.java new file mode 100644 index 00000000..d9286aa2 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlHistoryAssemblerTest.java @@ -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; + +/** + * 任务 103:ShopDataCrawlHistoryAssembler 历史查询组装器。 + * 历史列表 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 就绪"); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlRowNormalizerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlRowNormalizerTest.java new file mode 100644 index 00000000..0a964c0f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlRowNormalizerTest.java @@ -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; + +/** + * 任务 99:ShopDataCrawlRowNormalizer 行解析/归一化器。 + * 规则与 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; + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlSheetBuilderTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlSheetBuilderTest.java new file mode 100644 index 00000000..3ba328df --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlSheetBuilderTest.java @@ -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; + +/** + * 任务 100:ShopDataCrawlSheetBuilder 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 sheetNames(Workbook wb) { + return java.util.stream.IntStream.range(0, wb.getNumberOfSheets()) + .mapToObj(i -> wb.getSheetAt(i).getSheetName()).toList(); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlSnapshotTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlSnapshotTest.java new file mode 100644 index 00000000..59494e98 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopdatacrawl/service/support/ShopDataCrawlSnapshotTest.java @@ -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; + +/** + * 任务 102:ShopDataCrawl 快照对比测试。 + * 夹具 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 fixtureMain() { + List 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 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> grouped = ShopDataCrawlSheetBuilder.rowsByCountry(fixtureMain()); + StringBuilder sb = new StringBuilder(); + for (Map.Entry> 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 rows = ShopDataCrawlSheetBuilder.rowsByCountry(fixtureNullFields()).get("UK"); + List 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> 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> 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> 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 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 品牌"); + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinServiceTest.java index 1604d6d0..ed020792 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinServiceTest.java @@ -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> 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> 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(); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/LlmGatewayTlsProbe.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/LlmGatewayTlsProbe.java deleted file mode 100644 index acb483bc..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/LlmGatewayTlsProbe.java +++ /dev/null @@ -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 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()); - } - } - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchServiceAsyncTouchTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchServiceAsyncTouchTest.java deleted file mode 100644 index ab0d44c8..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchServiceAsyncTouchTest.java +++ /dev/null @@ -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 urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg", - "https://img.example.com/c.jpg"); - Set 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> 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 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> 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 capturedArgs = new java.util.ArrayList<>(); - doAnswer(invocation -> { - @SuppressWarnings("unchecked") - List arg = (List) 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); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchServiceBatchTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchServiceBatchTest.java deleted file mode 100644 index 5b484d75..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinImagePrefetchServiceBatchTest.java +++ /dev/null @@ -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 hits, Map bytesByHash) { - when(taskImageCacheMapper.selectBytesByUrlHashes(any())).thenAnswer(invocation -> { - List hashes = invocation.getArgument(0); - List 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 invokeLookupBatch(SimilarAsinImagePrefetchService svc, List urls) throws Exception { - Method m = SimilarAsinImagePrefetchService.class.getDeclaredMethod("lookupBatch", List.class); - m.setAccessible(true); - return (List) m.invoke(svc, urls); - } - - @Test - void test_task_014_image_normal_default_path() throws Exception { - // 正常输入:命中与未命中混排,批量读回命中字节,touch 只覆盖实际命中。 - List urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg"); - List hits = List.of(sha256Hex(urls.get(0))); - byte[] bytesA = new byte[]{1, 2, 3}; - stubBatchRead(hits, Map.of(sha256Hex(urls.get(0)), bytesA)); - - List 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 urls = List.of("https://img.example.com/a.jpg", "https://img.example.com/b.jpg", - "https://img.example.com/c.jpg"); - List hits = new ArrayList<>(); - Map 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 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 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 nullResult = invokeLookupBatch(service, null); - assertNotNull(nullResult); - assertTrue(nullResult.isEmpty()); - - List 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 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 urls = new ArrayList<>(); - for (int i = 0; i < 1200; i++) { - urls.add("https://img.example.com/overflow-" + i + ".jpg"); - } - Map bytesByHash = new java.util.LinkedHashMap<>(); - List 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 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> touchCalls = new ArrayList<>(); - for (Object call : captor.getAllValues()) { - @SuppressWarnings("unchecked") - List casted = (List) 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 urls = List.of("https://img.example.com/a.jpg"); - List 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 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 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 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()); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java deleted file mode 100644 index f8ef995d..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmLocalVerify.java +++ /dev/null @@ -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 [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 [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 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 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 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 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 loadCategories(ObjectMapper objectMapper) throws Exception { - com.fasterxml.jackson.databind.JsonNode root = objectMapper.readTree( - SimilarAsinLlmLocalVerify.class.getResourceAsStream("/categories.json")); - List 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 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) + "..."; - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmServiceTest.java deleted file mode 100644 index 85ad1801..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinLlmServiceTest.java +++ /dev/null @@ -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.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 { - 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> 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 sslSession() { - return java.util.Optional.empty(); - } - } - - - private SimilarAsinLlmClient llmClient(String apiKey, Map 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 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 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 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 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()); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkKeysetTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkKeysetTest.java deleted file mode 100644 index 9d7cf142..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkKeysetTest.java +++ /dev/null @@ -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 chunks(long... idsAndIndexes) { - List 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 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 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 keysetIdsFromRounds(int rounds) { - ArgumentCaptor> captor = - ArgumentCaptor.forClass(QueryWrapper.class); - verify(taskChunkMapper, times(rounds)).selectList(captor.capture()); - List keysets = new ArrayList<>(); - for (QueryWrapper wrapper : captor.getAllValues()) { - keysets.add(keysetOf(wrapper)); - } - return keysets; - } - - @Test - void test_task_009_chunk_normal_default_path() { - // 正常输入:chunk 数小于 pageSize,一轮拉完,结果全且按 chunkIndex 有序 - List all = chunks(1, 1, 2, 2, 3, 3); - stubKeysetPages(all, 500); - List 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 all = chunks(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7); - stubKeysetPages(all, 3); - List 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 all = chunks(1, 1, 2, 2, 3, 3); - stubKeysetPages(all, 500); - List first = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 500); - List 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 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 all = chunks(42, 9); - stubKeysetPages(all, 1); - List 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 all = chunks(1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6); - stubKeysetPages(all, 3); - List result = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 3); - assertEquals(6, result.size()); - // pageSize 为 0/负数:回退默认 500,不抛异常 - stubKeysetPages(all, 0); - List fallback = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, 0); - assertEquals(6, fallback.size()); - stubKeysetPages(all, -5); - List negative = SimilarAsinTaskService.loadChunksKeyset(taskChunkMapper, 7004L, MODULE, -5); - assertEquals(6, negative.size()); - } - - @Test - void test_task_009_chunk_invalid_input_rejected() { - // taskId 为 null:安全返回空列表,不发起查询 - List 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 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 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()); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkMergeLimitTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkMergeLimitTest.java deleted file mode 100644 index dbfcc54d..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceChunkMergeLimitTest.java +++ /dev/null @@ -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 13:chunk 合并增加单次最大行数与 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 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 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 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 storedPayload = new AtomicReference<>(""); - stubChunkMerge(chunk, rowsJson(List.of(row("r0", "B0A0000000", "存量行"))), storedPayload); - - FileTaskEntity task = new FileTaskEntity(); - task.setId(9004L); - List 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 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 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 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 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)); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceCozeBufferScopeTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceCozeBufferScopeTest.java deleted file mode 100644 index 73a8d843..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceCozeBufferScopeTest.java +++ /dev/null @@ -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 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 只保留非空 chunk,chunk 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 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 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 { - // 单 batch(batchTotal=1):原 P0-3 例外,现在也缓冲 - FileTaskEntity task = task(); - List 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 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 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()); - // 失败分组不清 pointer:buffer 未被删除、stateJson 未更新,留待重试 - verify(transientPayloadStorageService, never()).deletePayloadIfPresent(anyString()); - verify(taskScopeStateMapper, never()).update(any(), any()); - - // 恢复后重试 flush:chunk 合并成功一次,pointer 清理 - flush.invoke(service, 7104L); - assertEquals(2, storeCalls.get(), "恢复后重试应再次写 chunk"); - verify(taskScopeStateMapper, atLeastOnce()).update(any(), any()); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java deleted file mode 100644 index 9b9e6fc7..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceGroupRefTest.java +++ /dev/null @@ -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 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 first = hydrateForTest(payload); - List 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 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 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 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 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 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 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); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParseLimitsTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParseLimitsTest.java deleted file mode 100644 index 4c1894ac..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParseLimitsTest.java +++ /dev/null @@ -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 字符"); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java deleted file mode 100644 index a0c23e9c..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceParsePreviewTest.java +++ /dev/null @@ -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 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); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePayloadNormalizationTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePayloadNormalizationTest.java deleted file mode 100644 index 02ead9ea..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePayloadNormalizationTest.java +++ /dev/null @@ -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 rows(int count) { - List 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 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 groups(List 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 rows, List 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 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 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 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 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 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 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()); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePreviewConfigTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePreviewConfigTest.java deleted file mode 100644 index 72cb3865..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServicePreviewConfigTest.java +++ /dev/null @@ -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 未 stub,int 默认 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); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceResolveAllRowsTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceResolveAllRowsTest.java deleted file mode 100644 index 6c7a00f6..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceResolveAllRowsTest.java +++ /dev/null @@ -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 rows(String fileKey, int count) { - List result = new ArrayList<>(); - for (int i = 1; i <= count; i++) { - result.add(row(fileKey, i)); - } - return result; - } - - private static SimilarAsinParsedGroupVo group(List 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 items = rows("uploads/20260829/base.xlsx", 100); - payload.setItems(items); - List 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 items = rows("uploads/20260829/multi.xlsx", 1000); - payload.setItems(items); - List 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 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 items = rows("uploads/20260829/idem.xlsx", 50); - payload.setItems(items); - List first = SimilarAsinTaskService.resolveAllRows(payload); - List 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 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 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 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 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 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 g1 = rows("uploads/20260829/g.xlsx", 2); - List g2 = rows("uploads/20260829/g.xlsx", 3); - // 组内行号各自独立从 1 开始(真实解析语义),第二组用不同 fileKey 区分来源 - List g2b = rows("uploads/20260829/g2.xlsx", 3); - legacy.setGroups(List.of(group(g1), group(g2b))); - List 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()); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyDedupeTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyDedupeTest.java deleted file mode 100644 index 6e4f7903..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyDedupeTest.java +++ /dev/null @@ -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 11:Coze 结果合并的重复检测从 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 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 llmRows = List.of( - row("r1", "1", "B0A0000001", "英国"), - row("r1", "1", "B0A0000001", "英国")); - - List 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 llmRows = new ArrayList<>(); - for (int i = 0; i < 3; i++) { - llmRows.add(row("r1", "1", "B0A0000001", "英国")); - llmRows.add(row("r2", "2", "B0A0000002", "英国")); - } - List 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 llmRows = List.of( - row("r1", "1", "B0A0000001", "英国"), - row("r2", "2", "B0A0000002", "英国"), - row("r1", "1", "B0A0000001", "英国")); - List first = service.dedupeRowsByRowKey(llmRows); - List 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 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 single = List.of(row("r1", "1", "B0A0000001", "英国")); - List 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 llmRows = new ArrayList<>(); - for (int i = 0; i < 1000; i++) { - llmRows.add(row("r1", "1", "B0A0000001", "英国")); - } - List deduped = service.dedupeRowsByRowKey(llmRows); - assertEquals(1, deduped.size()); - // 1000 行唯一:全部保留且顺序稳定 - List 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 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 key(id::ASIN::country)识别重复 - List noToken = List.of( - row("", "1", "B0A0000001", "英国"), - row("", "1", "b0a0000001", " 英国 ")); - List deduped = service.dedupeRowsByRowKey(noToken); - assertEquals(1, deduped.size(), "legacy key 归一化(ASIN 大写、country trim)后应识别为同一行"); - // 不同 ASIN:不误判为重复 - List 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()); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyIndexTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyIndexTest.java deleted file mode 100644 index 4fbff56f..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceRowKeyIndexTest.java +++ /dev/null @@ -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→chunkKey,llm 行归属从 - * 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 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; - } - - /** 构造 rowsByChunk:chunkStorageKey(scopeHash, chunkIndex) → rowKey 行表。 */ - private static Map> rowsByChunkOf(String scopeHash, Integer chunkIndex, List rows) { - Map> map = new LinkedHashMap<>(); - Map byKey = new LinkedHashMap<>(); - for (SimilarAsinResultRowDto row : rows) { - byKey.put(row.getRowToken(), row); - } - map.put(scopeHash + ":" + chunkIndex, byKey); - return map; - } - - private static List assignedRowKeys(Map> merged) { - List keys = new ArrayList<>(); - for (Map 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 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 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> 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 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 orphans = new ArrayList<>(); - Map index = service.indexRowsByChunkKey(rowsByChunk); - Map> merged = service.assignLlmRowsToChunks( - rowsByChunk, llmRows, index, null, null, orphans); - assertEquals(3, merged.size()); - assertEquals(9, assignedRowKeys(merged).size()); - assertTrue(orphans.isEmpty(), "全部命中,不应产生 orphan"); - for (Map rows : merged.values()) { - assertEquals(3, rows.size()); - } - } - - @Test - void test_task_010_chunk_row_key_normal_repeated_operation_is_idempotent() { - // 重复执行同一输入:结果完全一致,不产生重复记录 - Map> rowsByChunk = rowsByChunkOf("hashA", 1, - List.of(row("r1", "1", "B0A0000001", "英国"), row("r2", "2", "B0A0000002", "英国"))); - List llmRows = List.of(row("r1", "1", "B0A0000001", "英国")); - Map index = service.indexRowsByChunkKey(rowsByChunk); - Map> first = service.assignLlmRowsToChunks( - rowsByChunk, llmRows, index, null, null, new ArrayList<>()); - Map> second = service.assignLlmRowsToChunks( - rowsByChunk, llmRows, index, null, null, new ArrayList<>()); - assertEquals(assignedRowKeys(first), assignedRowKeys(second)); - assertEquals(first.size(), second.size()); - for (Map.Entry> 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> 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> rowsByChunk = rowsByChunkOf("hashA", 1, List.of(row("r1", "1", "B0A0000001", "英国"))); - List blankRow = List.of(row("", "", "", "")); - List orphans = new ArrayList<>(); - Map index = service.indexRowsByChunkKey(rowsByChunk); - Map> 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> rowsByChunk = rowsByChunkOf("hashA", 1, - List.of(row("r1", "1", "B0A0000001", "英国"))); - List orphans = new ArrayList<>(); - Map index = service.indexRowsByChunkKey(rowsByChunk); - Map> 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> rowsByChunk = new LinkedHashMap<>(); - Map 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 index = service.indexRowsByChunkKey(rowsByChunk); - assertEquals(1000, index.size()); - List 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 orphans = new ArrayList<>(); - Map> 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:索引保留第一个 chunk(putIfAbsent),行为确定 - Map> 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 index = service.indexRowsByChunkKey(rowsByChunk); - assertEquals("hashA:1", index.get("dup"), "重复 rowKey 应保留第一个 chunk"); - // fallback 缺失:llm 行未命中且无有效 fallback → 进 orphan,不产生 merge - List orphans = new ArrayList<>(); - Map> 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 withNull = new ArrayList<>(); - withNull.add(null); - withNull.add(row("dup", "1", "B0A0000001", "英国")); - List orphans2 = new ArrayList<>(); - Map index2 = service.indexRowsByChunkKey(rowsByChunk); - Map> 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 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()); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceSubmitTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceSubmitTest.java deleted file mode 100644 index 6ee9ea7b..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceSubmitTest.java +++ /dev/null @@ -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 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 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 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 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 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; - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceTest.java deleted file mode 100644 index 158a31a3..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceTest.java +++ /dev/null @@ -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 headers = (List) 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); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceWorkbookControlTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceWorkbookControlTest.java deleted file mode 100644 index 11fcb2c4..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskServiceWorkbookControlTest.java +++ /dev/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 8:WorkbookFactory 输入解析改为受控读取。 - * 解析前先探测 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"); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/ExcelCellImageWriterTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/ExcelCellImageWriterTest.java deleted file mode 100644 index 5b2a10ad..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/ExcelCellImageWriterTest.java +++ /dev/null @@ -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("#VALUE!")); - assertTrue(sheet.contains("#VALUE!")); - - // futureMetadata 内部块仍然是 0-based。 - String metadata = read(zip, "xl/metadata.xml"); - assertTrue(metadata.contains("")); - assertTrue(metadata.contains("")); - 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("#VALUE!")); - } - }); - } - - 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", """ - - - - - - - - """); - write(out, "_rels/.rels", """ - - - - - """); - write(out, "xl/workbook.xml", """ - - - - - """); - write(out, "xl/_rels/workbook.xml.rels", """ - - - - - """); - write(out, "xl/worksheets/sheet1.xml", """ - - - - - main - puzzle - - - - """); - } - } - - private static void writeWorkbookWithImageCells(Path xlsx, int imageCount) throws Exception { - try (ZipOutputStream out = new ZipOutputStream(Files.newOutputStream(xlsx))) { - write(out, "[Content_Types].xml", ""); - write(out, "xl/_rels/workbook.xml.rels", ""); - StringBuilder sheet = new StringBuilder(""); - for (int row = 2; row <= imageCount + 1; row++) { - sheet.append("image"); - } - sheet.append(""); - 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(); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderDecodeQualityTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderDecodeQualityTest.java deleted file mode 100644 index 70194753..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderDecodeQualityTest.java +++ /dev/null @@ -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); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderPrefetchBudgetTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderPrefetchBudgetTest.java deleted file mode 100644 index d33f434d..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderPrefetchBudgetTest.java +++ /dev/null @@ -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 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 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 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 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 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 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 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 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(); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderSpoolLifecycleTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderSpoolLifecycleTest.java deleted file mode 100644 index 30d10fa6..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderSpoolLifecycleTest.java +++ /dev/null @@ -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 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 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 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 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 仍能删除目录"); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java deleted file mode 100644 index 7d4b9da5..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java +++ /dev/null @@ -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 first = callers.submit(() -> { - start.await(); - return shared.fetchAndResizeForCache("https://images.example.com/shared.jpg"); - }); - Future 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 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 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: ", 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); - } - } - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixtureE2ETest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixtureE2ETest.java deleted file mode 100644 index c9f71b92..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixtureE2ETest.java +++ /dev/null @@ -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 20:Similar ASIN 端到端压测、JFR/GC 分析与结果文件兼容回归。 - * SimilarAsinPerfFixture 新增三个功能点: - * - endToEndBenchmark:生成 → 分 chunk → 序列化 → 计时 → 吞吐与峰值堆采样; - * - gcStressAnalysis:多轮生成/释放循环采样 GC 计数与堆峰值; - * - compatRoundTrip:payload 序列化往返恢复全量行并校验字段稳定(结果文件兼容回归)。 - */ -@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"); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixtureTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixtureTest.java deleted file mode 100644 index 429b671f..00000000 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinPerfFixtureTest.java +++ /dev/null @@ -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 1:Similar 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 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> 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 rows = fixture.generateRows("uploads/20260829/multi.xlsx", 5000, false); - assertEquals(5000, rows.size()); - assertTrue(rows.get(0).getUrl().isBlank(), "图片开关关闭时 url 必须为空"); - - List> chunks = fixture.splitChunks(rows, 200); - assertEquals(25, chunks.size()); - // 顺序稳定:拼接后与原始一致 - List 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 first = fixture.generateRows("uploads/20260829/idem.xlsx", 1000, true); - List 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 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 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> 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 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 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 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 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 rows = fixture.generateRows("uploads/20260829/edge.xlsx", 100, false); - // 100 行 / 40 → 3 chunks(40+40+20) - List> 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()); - } -} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/ModuleProgressLightEndpointContractTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/ModuleProgressLightEndpointContractTest.java new file mode 100644 index 00000000..e978a691 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/ModuleProgressLightEndpointContractTest.java @@ -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; + +/** + * 任务 113:12 个 progress/light 端点存在性 + 旧端点全保留契约。 + * 通过反射读取各 controller 的 @RequestMapping + @PostMapping,验证 + * 路径模式统一为 /tasks/progress/light,且历史端点(progress/batch、history 等)未删除。 + */ +class ModuleProgressLightEndpointContractTest { + + private static final List> 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); + } + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/TaskProgressLightContractTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/TaskProgressLightContractTest.java new file mode 100644 index 00000000..38cb2917 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/TaskProgressLightContractTest.java @@ -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 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 keysOf(TaskProgressLightVo vo) throws Exception { + JsonNode node = objectMapper.readTree(objectMapper.writeValueAsBytes(vo)); + Set 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 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 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")); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskFileJobServiceResultJobBatchTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskFileJobServiceResultJobBatchTest.java new file mode 100644 index 00000000..805d48e3 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskFileJobServiceResultJobBatchTest.java @@ -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 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 wrapper = invocation.getArgument(0); + LambdaQueryWrapper q = (LambdaQueryWrapper) wrapper; + q.getSqlSegment(); + String[] moduleType = new String[1]; + String[] jobType = new String[1]; + List 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 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 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 map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of()); + assertTrue(map.isEmpty(), "空结果列表返回空 Map 且不查库"); + assertEquals(0, selectCount.get(), "空入参不触发查询"); + } + + @Test + void resultJobMapKeyedByResultId() { + seed(); + Map 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 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 map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 101L, 101L, 102L)); + assertEquals(2, map.size(), "重复 resultId 去重"); + assertEquals(1, selectCount.get(), "去重后仍一次 IN 查询"); + ArgumentCaptor> 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 map = service.findAssembleJobsByResultIds("APPEARANCE_PATENT", List.of(101L, 102L, 103L, 104L)); + assertEquals(1, map.size(), "只返回指定模块 job"); + assertEquals(4L, map.get(104L).getId()); + ArgumentCaptor> 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 map = service.findAssembleJobsByResultIds("SIMILAR_ASIN", List.of(101L, 102L, 103L)); + // 与逐条 findAssembleJob 语义一致:同一 resultId 命中同一 job + for (Map.Entry entry : map.entrySet()) { + assertEquals(entry.getKey(), entry.getValue().getResultId(), "Map 键与 job.resultId 一致"); + } + assertEquals(3, map.values().stream().map(TaskFileJobEntity::getResultId).distinct().count()); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssemblerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssemblerTest.java new file mode 100644 index 00000000..ded38f98 --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightAssemblerTest.java @@ -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 tasks) { + when(fileTaskMapper.selectList(any(LambdaQueryWrapper.class))).thenReturn(tasks); + } + + private void stubJobs(Map 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 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 many = new ArrayList<>(); + for (long i = 1; i <= 300; i++) { + many.add(i); + } + assembler.assemble("publish", null, many); + @SuppressWarnings("unchecked") + ArgumentCaptor> 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); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightPermissionTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightPermissionTest.java new file mode 100644 index 00000000..32dfb14f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/task/service/TaskProgressLightPermissionTest.java @@ -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; + +/** + * 任务 117:progress/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 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 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 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/batch(getTaskProgress(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()); + } +} diff --git a/backend-java/src/test/resources/appearancepatent/golden/parse-snapshot.txt b/backend-java/src/test/resources/appearancepatent/golden/parse-snapshot.txt new file mode 100644 index 00000000..e2e189ea --- /dev/null +++ b/backend-java/src/test/resources/appearancepatent/golden/parse-snapshot.txt @@ -0,0 +1,9 @@ +rows=8 + row: idx=2 sourceId=2_1 displayId=2_1 asin=B01A country=US price=19.90 sku=SKU-1 url=http://img/a.jpg title=title a groupKey=uploads/main.xlsx::2@2 rowToken=uploads/main.xlsx::row::2 values.size=9 + row: idx=3 sourceId=2_2 displayId=2_2 asin=B01B country=DE price=29.90 sku=SKU-2 url=http://img/b.jpg title=title a groupKey=uploads/main.xlsx::2@2 rowToken=uploads/main.xlsx::row::3 values.size=9 + row: idx=4 sourceId=2_3 displayId=2_3 asin=B01C country=FR price= sku=SKU-1 url=http://img/c.jpg title=title a groupKey=uploads/main.xlsx::2@2 rowToken=uploads/main.xlsx::row::4 values.size=9 + row: idx=5 sourceId=5 displayId=5 asin=B01D country=UK price=9.90 sku=SKU-4 url=http://img/d.jpg title=title d groupKey=uploads/main.xlsx::5@5 rowToken=uploads/main.xlsx::row::5 values.size=9 + row: idx=6 sourceId=6 displayId=6 asin=B01E country=US price=1.00 sku=SKU-5 url=http://img/e.jpg title=title e groupKey=uploads/main.xlsx::6@6 rowToken=uploads/main.xlsx::row::6 values.size=9 + row: idx=5 sourceId=11 displayId=11 asin=B02C country=FR price= sku= url= title= groupKey=uploads/error.xlsx::11@5 rowToken=uploads/error.xlsx::row::5 values.size=9 + row: idx=6 sourceId=12 displayId=12 asin=B02D country=FR price= sku= url= title= groupKey=uploads/error.xlsx::12@6 rowToken=uploads/error.xlsx::row::6 values.size=9 + row: idx=7 sourceId=13 displayId=13 asin=B02E country=FR price= sku= url= title= groupKey=uploads/error.xlsx::13@7 rowToken=uploads/error.xlsx::row::7 values.size=9 diff --git a/backend-java/src/test/resources/publish/golden/batch-loading-snapshot.txt b/backend-java/src/test/resources/publish/golden/batch-loading-snapshot.txt new file mode 100644 index 00000000..450ec49e --- /dev/null +++ b/backend-java/src/test/resources/publish/golden/batch-loading-snapshot.txt @@ -0,0 +1,16 @@ +total=5 +task id=5 no=T5 status=SUCCESS src=1 ok=1 fail=0 comp=1 rows=15/15 pct=100 err=null + file id=501 key=key501 shop=Shop 0/SHOP0 matched=true status=SUCCESS rows=15/15 pct=100 +task id=4 no=T4 status=FAILED src=1 ok=0 fail=1 comp=1 rows=9/4 pct=100 err=null + file id=401 key=key401 shop=Shop 2/SHOP2 matched=true status=FAILED rows=9/4 pct=100 + result id=904 ready=true url=https://ossresult/publish/904/out.xlsx job=804/FAILED retry=2 joberr=assemble failed err=boom +task id=3 no=T3 status=PENDING src=1 ok=0 fail=0 comp=0 rows=8/0 pct=0 err=null + file id=301 key=key301 shop=Shop 1/SHOP1 matched=true status=PENDING rows=8/0 pct=0 +task id=2 no=T2 status=RUNNING src=2 ok=0 fail=1 comp=1 rows=35/15 pct=70 err=null + file id=201 key=key201 shop=Shop 0/SHOP0 matched=true status=RUNNING rows=30/12 pct=40 + file id=202 key=key202 shop=Shop 1/SHOP1 matched=true status=FAILED rows=5/3 pct=100 + result id=902 ready=true url=https://ossresult/publish/902/out.xlsx job=802/RUNNING retry=1 joberr=null err=still running +task id=1 no=T1 status=SUCCESS src=2 ok=2 fail=0 comp=2 rows=30/30 pct=100 err=null + file id=101 key=key101 shop=Shop 2/SHOP2 matched=true status=SUCCESS rows=10/10 pct=100 + file id=102 key=key102 shop=Shop 0/SHOP0 matched=true status=SUCCESS rows=20/20 pct=100 + result id=901 ready=true url=https://ossresult/publish/901/out.xlsx job=801/SUCCESS retry=0 joberr=null err=null diff --git a/backend-java/src/test/resources/shopdatacrawl/golden/groups-snapshot.txt b/backend-java/src/test/resources/shopdatacrawl/golden/groups-snapshot.txt new file mode 100644 index 00000000..76ac2a3e --- /dev/null +++ b/backend-java/src/test/resources/shopdatacrawl/golden/groups-snapshot.txt @@ -0,0 +1,9 @@ + group: country=UK rows=1 + asin=B01 + group: country=DE rows=1 + asin=B02 + group: country=FR rows=1 + asin=B03 + group: country=ES rows=0 + group: country=IT rows=1 + asin=B05 diff --git a/backend-java/src/test/resources/shopdatacrawl/golden/rows-snapshot.txt b/backend-java/src/test/resources/shopdatacrawl/golden/rows-snapshot.txt new file mode 100644 index 00000000..1956e362 --- /dev/null +++ b/backend-java/src/test/resources/shopdatacrawl/golden/rows-snapshot.txt @@ -0,0 +1,3 @@ +rows=2 + 日期=2026-07-25;ASIN=B012345678;商品图片=;库存销量=128;销售排名=#1,245;页面浏览量=3560;售出件数=42;价格=GBP 19.99;推荐报价=GBP 18.99;品牌=Example Brand; + 日期=;ASIN=B099999999;商品图片=;库存销量=;销售排名=;页面浏览量=;售出件数=;价格=;推荐报价=;品牌= Brand With Spaces ; diff --git a/backend-java/src/test/resources/similarasin/golden/groups-snapshot.txt b/backend-java/src/test/resources/similarasin/golden/groups-snapshot.txt new file mode 100644 index 00000000..2efa4549 --- /dev/null +++ b/backend-java/src/test/resources/similarasin/golden/groups-snapshot.txt @@ -0,0 +1,7 @@ +rows=3 + row: idx=2 sourceId=1 displayId=1 asin=B10A country=US sku= price= url= title= groupKey=uploads/groups.xlsx::1@2 rowToken=uploads/groups.xlsx::row::2 values.size=7 + row: idx=3 sourceId=2_1 displayId=2_1 asin=B11A country=DE sku= price= url= title= groupKey=uploads/groups.xlsx::2@3 rowToken=uploads/groups.xlsx::row::3 values.size=7 + row: idx=4 sourceId=2_2 displayId=2_2 asin=B11B country=FR sku= price= url= title= groupKey=uploads/groups.xlsx::2@3 rowToken=uploads/groups.xlsx::row::4 values.size=7 +groups=2 + group: fileKey=uploads/groups.xlsx filename=groups.xlsx groupKey=uploads/groups.xlsx::1@2 baseId=1 displayId=1 itemCount=1 range=[0,1) + group: fileKey=uploads/groups.xlsx filename=groups.xlsx groupKey=uploads/groups.xlsx::2@3 baseId=2 displayId=2_1 itemCount=2 range=[1,3) diff --git a/backend-java/src/test/resources/similarasin/golden/parse-snapshot.txt b/backend-java/src/test/resources/similarasin/golden/parse-snapshot.txt new file mode 100644 index 00000000..0d12aa45 --- /dev/null +++ b/backend-java/src/test/resources/similarasin/golden/parse-snapshot.txt @@ -0,0 +1,16 @@ +rows=8 + row: idx=2 sourceId=2_1 displayId=2_1 asin=B01A country=US sku=SKU-1 price=19.90 url=http://img/a.jpg title=title a groupKey=uploads/main.xlsx::2@2 rowToken=uploads/main.xlsx::row::2 values.size=7 + row: idx=3 sourceId=2_2 displayId=2_2 asin=B01B country=DE sku=SKU-2 price=29.90 url=http://img/b.jpg title=title b groupKey=uploads/main.xlsx::2@2 rowToken=uploads/main.xlsx::row::3 values.size=7 + row: idx=4 sourceId=2_3 displayId=2_3 asin=B01C country=FR sku=SKU-3 price= url=http://img/c.jpg title= groupKey=uploads/main.xlsx::2@2 rowToken=uploads/main.xlsx::row::4 values.size=7 + row: idx=5 sourceId=5 displayId=5 asin=B01-ABC country=UK sku=SKU-4 price=9.90 url=http://img/d.jpg title=title d groupKey=uploads/main.xlsx::5@5 rowToken=uploads/main.xlsx::row::5 values.size=7 + row: idx=6 sourceId=6 displayId=6 asin=B01D country=US sku=SKU-5 price=1.00 url=http://img/e.jpg title=title e groupKey=uploads/main.xlsx::6@6 rowToken=uploads/main.xlsx::row::6 values.size=7 + row: idx=5 sourceId=11 displayId=11 asin=B02-ABC country=FR sku= price= url= title= groupKey=uploads/error.xlsx::11@5 rowToken=uploads/error.xlsx::row::5 values.size=7 + row: idx=6 sourceId=12 displayId=12 asin=B02C country=FR sku= price= url= title= groupKey=uploads/error.xlsx::12@6 rowToken=uploads/error.xlsx::row::6 values.size=7 + row: idx=7 sourceId=13 displayId=13 asin=B02C country=FR sku= price= url= title= groupKey=uploads/error.xlsx::13@7 rowToken=uploads/error.xlsx::row::7 values.size=7 +groups=6 + group: fileKey=uploads/main.xlsx filename=main.xlsx groupKey=uploads/main.xlsx::2@2 baseId=2 displayId=2_1 itemCount=3 range=[0,3) + group: fileKey=uploads/main.xlsx filename=main.xlsx groupKey=uploads/main.xlsx::5@5 baseId=5 displayId=5 itemCount=1 range=[3,4) + group: fileKey=uploads/main.xlsx filename=main.xlsx groupKey=uploads/main.xlsx::6@6 baseId=6 displayId=6 itemCount=1 range=[4,5) + group: fileKey=uploads/error.xlsx filename=error.xlsx groupKey=uploads/error.xlsx::11@5 baseId=11 displayId=11 itemCount=1 range=[5,6) + group: fileKey=uploads/error.xlsx filename=error.xlsx groupKey=uploads/error.xlsx::12@6 baseId=12 displayId=12 itemCount=1 range=[6,7) + group: fileKey=uploads/error.xlsx filename=error.xlsx groupKey=uploads/error.xlsx::13@7 baseId=13 displayId=13 itemCount=1 range=[7,8) diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 02d4bc80..28fcd32d 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -64,6 +64,11 @@ ADMIN_MENU_ACCESS_CONFIG = { 'route_path': 'dedupe-total-data', 'error': '无权访问数据去重汇总数据', }, + 'group-manage': { + 'column_key': 'admin_group_manage', + 'route_path': 'group-manage', + 'error': '无权访问分组管理模块', + }, 'shop-manage': { 'column_key': 'admin_shop_manage', 'route_path': 'shop-manage', @@ -1523,6 +1528,7 @@ def list_shop_data_crawl_tasks(): page_size = min(100, max(10, int(request.args.get('page_size', 20)))) shop_name = (request.args.get('shop_name') or request.args.get('shop') or '').strip() group_name = (request.args.get('group_name') or request.args.get('group') or '').strip() + country = (request.args.get('country') or '').strip().upper() created_from = _parse_admin_datetime_arg('created_from') created_to = _parse_admin_datetime_arg('created_to') @@ -1544,6 +1550,16 @@ def list_shop_data_crawl_tasks(): "AND COALESCE(NULLIF(g.group_name, ''), NULLIF(sm.group_name, '')) LIKE %s)" ) params.append('%' + group_name + '%') + if country: + if country not in ('DE', 'UK', 'FR', 'IT', 'ES'): + raise ValueError('不支持的国家代码: ' + country) + conditions.append( + 'JSON_CONTAINS(' + 'COALESCE(df.country_codes_json, ' + 'JSON_EXTRACT(t.request_json, \'$.countryCodes\'), ' + 'JSON_EXTRACT(t.request_json, \'$.country_codes\'), \'[]\'), %s)' + ) + params.append('"' + country + '"') if created_from: conditions.append('t.created_at >= %s') params.append(created_from) @@ -3352,12 +3368,16 @@ def list_invalid_asin_data(): page = max(1, int(request.args.get('page', 1))) page_size = min(100, max(1, int(request.args.get('page_size', 15)))) keyword = (request.args.get('keyword') or '').strip() + data_value = (request.args.get('data_value') or '').strip() + brand = (request.args.get('brand') or '').strip() group_id_raw = (request.args.get('group_id') or '').strip() group_id = int(group_id_raw) if group_id_raw.isdigit() and int(group_id_raw) > 0 else None params = { 'page': page, 'pageSize': page_size, 'keyword': keyword, + 'dataValue': data_value, + 'brand': brand, } if group_id is not None: params['groupId'] = group_id @@ -3766,7 +3786,7 @@ def delete_shop_manage(item_id): @login_required def list_shop_manage_groups(): role, current_row, denied = _ensure_backend_menu_access( - 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data', 'invalid-asin-data' + 'group-manage', 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data', 'invalid-asin-data' ) if denied: return denied @@ -3788,7 +3808,7 @@ def list_shop_manage_groups(): @login_required def create_shop_manage_group(): role, current_row, denied = _ensure_backend_menu_access( - 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data', 'invalid-asin-data' + 'group-manage', 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data', 'invalid-asin-data' ) if denied: return denied @@ -3835,7 +3855,7 @@ def create_shop_manage_group(): @login_required def update_shop_manage_group(item_id): role, current_row, denied = _ensure_backend_menu_access( - 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data', 'invalid-asin-data' + 'group-manage', 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data', 'invalid-asin-data' ) if denied: return denied @@ -3888,6 +3908,9 @@ def list_skip_price_asins(): group_id_raw = (request.args.get('group_id') or '').strip() shop_name = (request.args.get('shop_name') or '').strip() asin = (request.args.get('asin') or '').strip() + country = (request.args.get('country') or '').strip() + minimum_price_from = (request.args.get('minimum_price_from') or '').strip() + minimum_price_to = (request.args.get('minimum_price_to') or '').strip() params = {'page': page, 'page_size': page_size, 'pageSize': page_size} params.update(_skip_price_asin_auth_params(role, current_row)) if group_id_raw: @@ -3903,6 +3926,12 @@ def list_skip_price_asins(): params['shopName'] = shop_name if asin: params['asin'] = asin + if country: + params['country'] = country + if minimum_price_from: + params['minimum_price_from'] = minimum_price_from + if minimum_price_to: + params['minimum_price_to'] = minimum_price_to result, error_response, status = _proxy_backend_java( 'GET', '/api/admin/skip-price-asins', @@ -3962,6 +3991,9 @@ def export_skip_price_asins(): group_id_raw = (request.args.get('group_id') or request.args.get('groupId') or '').strip() shop_name = (request.args.get('shop_name') or request.args.get('shopName') or '').strip() asin = (request.args.get('asin') or '').strip() + country = (request.args.get('country') or '').strip() + minimum_price_from = (request.args.get('minimum_price_from') or '').strip() + minimum_price_to = (request.args.get('minimum_price_to') or '').strip() if group_id_raw: params['group_id'] = group_id_raw params['groupId'] = group_id_raw @@ -3970,6 +4002,12 @@ def export_skip_price_asins(): params['shopName'] = shop_name if asin: params['asin'] = asin + if country: + params['country'] = country + if minimum_price_from: + params['minimum_price_from'] = minimum_price_from + if minimum_price_to: + params['minimum_price_to'] = minimum_price_to url = f"{backend_java_base_url}/api/admin/skip-price-asins/export" try: @@ -4082,7 +4120,7 @@ def delete_skip_price_asin_country(item_id, country): @login_required def delete_shop_manage_group(item_id): role, current_row, denied = _ensure_backend_menu_access( - 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data', 'invalid-asin-data' + 'group-manage', 'shop-manage', 'skip-price-asin', 'query-asin', 'dedupe-total-data', 'invalid-asin-data' ) if denied: return denied @@ -4276,6 +4314,7 @@ def list_query_asins(): group_id_raw = (request.args.get('group_id') or '').strip() shop_name = (request.args.get('shop_name') or '').strip() asin = (request.args.get('asin') or '').strip() + country = (request.args.get('country') or '').strip() params = {'page': page, 'pageSize': page_size} if current_row and current_row.get('id'): params['operatorId'] = current_row.get('id') @@ -4291,6 +4330,8 @@ def list_query_asins(): params['shopName'] = shop_name if asin: params['asin'] = asin + if country: + params['country'] = country result, error_response, status = _proxy_backend_java( 'GET', '/api/admin/query-asins', @@ -4321,12 +4362,15 @@ def export_query_asins(): group_id_raw = (request.args.get('group_id') or request.args.get('groupId') or '').strip() shop_name = (request.args.get('shop_name') or request.args.get('shopName') or '').strip() asin = (request.args.get('asin') or '').strip() + country = (request.args.get('country') or '').strip() if group_id_raw: params['groupId'] = group_id_raw if shop_name: params['shopName'] = shop_name if asin: params['asin'] = asin + if country: + params['country'] = country url = f"{backend_java_base_url}/api/admin/query-asins/export" try: diff --git a/backend/static/admin-interactions.js b/backend/static/admin-interactions.js index 63d76a9d..a6c79d27 100644 --- a/backend/static/admin-interactions.js +++ b/backend/static/admin-interactions.js @@ -24,13 +24,13 @@ var guides = { users: { text: '先用筛选定位账号,再编辑角色和菜单权限;删除账号会要求二次确认。', steps: ['筛选账号', '编辑权限', '确认保存'] }, - columns: { text: '菜单会影响后台和软件端的可见范围。建议先填写名称、标识和页面路径,再设置上级菜单。', steps: ['新增或调整菜单', '设置层级', '检查权限'] }, + columns: { text: '菜单会影响后台和软件端的可见范围。先填写名称并选择对应页面,再设置上级菜单;顺序直接拖动列表左侧手柄调整。', steps: ['新增或调整菜单', '设置层级', '拖动排序'] }, 'dedupe-total-data': { text: '支持按关键词、用户、分组和国家筛选;导入前先确认所选分组,导出会按日期范围生成文件。', steps: ['选择分组', '筛选或导入', '核对并导出'] }, 'invalid-asin-data': { text: '维护不符合规则的 ASIN 或品牌。添加后可使用上方筛选快速回查。', steps: ['填写 ASIN/品牌', '选择分组', '保存并回查'] }, 'shop-keys': { text: '紫鸟令牌属于敏感配置。白名单状态可悬停查看检测详情,编辑前请先核对账号名称。', steps: ['新增或筛选密钥', '查看白名单状态', '编辑或删除'] }, 'shop-manage': { text: '店铺信息按分组管理。长商城名会自动缩略,悬停即可查看完整内容。', steps: ['选择分组', '维护店铺信息', '筛选核对结果'] }, - 'skip-price-asin': { text: '最低价 ASIN 以店铺和国家为单位维护。每个国家可单独编辑 ASIN 与最低价。', steps: ['选择店铺和国家', '填写 ASIN/最低价', '保存或批量导入'] }, - 'query-asin': { text: '查询 ASIN 按店铺和国家独立维护。空国家列可直接编辑,已有记录可修改或删除。', steps: ['选择店铺和国家', '维护各国 ASIN', '筛选或导出'] }, + 'skip-price-asin': { text: '最低价 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN 与最低价。', steps: ['筛选店铺', '打开配置抽屉', '保存或批量导入'] }, + 'query-asin': { text: '查询 ASIN 以店铺为单位维护。列表里点击 ASIN 即可复制,点右侧「配置」可一次维护该店铺所有站点的 ASIN。', steps: ['筛选店铺', '打开配置抽屉', '保存或导出'] }, 'product-categories': { text: '类目树支持展开查看层级。搜索、编辑和删除都在同一列表中完成。', steps: ['搜索类目', '展开层级', '新增或编辑'] }, 'image-video-tasks': { text: '可先使用筛选缩小任务范围,再查看任务状态、结果和权限范围。', steps: ['设置筛选', '查看任务结果', '按需处理任务'] }, 'shop-data-crawl-tasks': { text: '店铺数据任务按状态和时间筛选。批量操作前请核对已选任务。', steps: ['筛选任务', '检查状态', '执行批量操作'] }, diff --git a/backend/static/admin.js b/backend/static/admin.js index f2d0f4ed..15031dfb 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -95,13 +95,14 @@ // ===== 菜单分组(一级分类 + 二级菜单)===== var ADMIN_MENU_GROUPS = [ - { key: 'account', title: '账号与权限', items: ['users', 'columns'] }, + { key: 'account', title: '账号与权限', items: ['users', 'columns', 'group-manage'] }, { key: 'data', title: '数据管理', items: ['dedupe-total-data', 'invalid-asin-data', 'query-asin', 'product-categories'] }, { key: 'shop', title: '店铺管理', items: ['shop-keys', 'shop-manage', 'skip-price-asin', 'shop-data-crawl-tasks'] }, { key: 'record', title: '记录与版本', items: ['history', 'version', 'digital-human-version', 'image-video-tasks'] } ]; var ADMIN_MENU_ICONS = { 'users': '', + 'group-manage': '', 'columns': '', 'dedupe-total-data': '', 'invalid-asin-data': '', @@ -128,6 +129,7 @@ var ADMIN_PANEL_MAP = { 'users': 'panel-users', 'columns': 'panel-columns', + 'group-manage': 'panel-group-manage', 'dedupe-total-data': 'panel-dedupe-total-data', 'invalid-asin-data': 'panel-invalid-asin-data', 'shop-keys': 'panel-shop-keys', @@ -144,6 +146,7 @@ function runTabLoader(tabName) { if (tabName === 'users') { loadUsers(1); loadColumnsForPermission(); } else if (tabName === 'columns') loadColumns(); + else if (tabName === 'group-manage') initShopManageGroupPanel(); else if (tabName === 'dedupe-total-data') { loadDedupeTotalData(1); loadDedupeGroupSummary(); @@ -920,6 +923,13 @@ } document.getElementById('btnSearchUsers').onclick = function () { loadUsers(1); }; + document.getElementById('btnOpenCreateUser').onclick = function () { + updateCreateFormByRole(); + document.getElementById('createUserModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateUserModal').onclick = function () { + document.getElementById('createUserModal').classList.remove('show'); + }; document.getElementById('createRole').onchange = function () { updateCreateFormByRole(); }; document.getElementById('btnCreate').onclick = function () { var username = (document.getElementById('username').value || '').trim(); @@ -1586,6 +1596,7 @@ var values = { shop_name: document.getElementById('shopDataTaskFilterShop').value.trim(), group_name: document.getElementById('shopDataTaskFilterGroup').value.trim(), + country: document.getElementById('shopDataTaskFilterCountry').value.trim(), created_from: document.getElementById('shopDataTaskFilterFrom').value, created_to: document.getElementById('shopDataTaskFilterTo').value }; @@ -2010,7 +2021,7 @@ document.getElementById('btnFilterShopDataTasks').onclick = function () { loadShopDataCrawlTasks(1); }; document.getElementById('btnResetShopDataTasks').onclick = function () { - ['shopDataTaskFilterShop', 'shopDataTaskFilterGroup', 'shopDataTaskFilterFrom', 'shopDataTaskFilterTo'] + ['shopDataTaskFilterShop', 'shopDataTaskFilterGroup', 'shopDataTaskFilterCountry', 'shopDataTaskFilterFrom', 'shopDataTaskFilterTo'] .forEach(function (id) { document.getElementById(id).value = ''; }); loadShopDataCrawlTasks(1); }; @@ -2151,6 +2162,35 @@ .replace(/"/g, '"') .replace(/'/g, '''); } + // 站点列改成纯展示后,点击 ASIN 文本即复制。 + function copyAsinText(button) { + var value = button && button.dataset ? (button.dataset.copyAsin || '') : ''; + if (!value) return; + var copyPromise; + if (navigator.clipboard && navigator.clipboard.writeText) { + copyPromise = navigator.clipboard.writeText(value); + } else { + copyPromise = new Promise(function (resolve, reject) { + var textarea = document.createElement('textarea'); + textarea.value = value; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.select(); + var copied = document.execCommand('copy'); + textarea.remove(); + if (copied) resolve(); + else reject(new Error('copy failed')); + }); + } + copyPromise.then(function () { + button.classList.add('is-copied'); + setTimeout(function () { button.classList.remove('is-copied'); }, 1200); + if (window.__adminToast) window.__adminToast('已复制 ' + value, 'info'); + }).catch(function () { + if (window.__adminToast) window.__adminToast('复制失败,请手动选择', 'error'); + }); + } var dropdownMultiSelects = {}; function initDropdownMultiSelect(selectId, placeholder) { var select = document.getElementById(selectId); @@ -2617,6 +2657,28 @@ dedupeDeleteImportPollTimer = setInterval(tick, 1000); } + document.getElementById('btnOpenCreateDedupeTotalData').onclick = function () { + document.getElementById('dedupeTotalDataFile').value = ''; + document.getElementById('dedupeTotalDataImportGroupId').value = ''; + document.getElementById('msgDedupeTotalData').textContent = ''; + document.getElementById('msgDedupeTotalData').className = 'msg'; + document.getElementById('dedupeTotalDataProgressWrap').style.display = 'none'; + document.getElementById('createDedupeTotalDataModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateDedupeTotalDataModal').onclick = function () { + document.getElementById('createDedupeTotalDataModal').classList.remove('show'); + }; + document.getElementById('btnOpenDeleteDedupeTotalData').onclick = function () { + document.getElementById('dedupeTotalDataDeleteFile').value = ''; + document.getElementById('dedupeTotalDataDeleteGroupId').value = ''; + document.getElementById('msgDeleteDedupeTotalData').textContent = ''; + document.getElementById('msgDeleteDedupeTotalData').className = 'msg'; + document.getElementById('dedupeTotalDataDeleteProgressWrap').style.display = 'none'; + document.getElementById('deleteDedupeTotalDataModal').classList.add('show'); + }; + document.getElementById('btnCloseDeleteDedupeTotalDataModal').onclick = function () { + document.getElementById('deleteDedupeTotalDataModal').classList.remove('show'); + }; document.getElementById('btnAddDedupeTotalData').onclick = function () { var fileInput = document.getElementById('dedupeTotalDataFile'); var msgEl = document.getElementById('msgDedupeTotalData'); @@ -2781,9 +2843,11 @@ var invalidAsinDataPage = 1, invalidAsinDataPageSize = 15; function buildInvalidAsinDataQuery(page) { var q = 'page=' + (page || 1) + '&page_size=' + invalidAsinDataPageSize; - var keyword = (document.getElementById('searchInvalidAsinData').value || '').trim(); + var dataValue = (document.getElementById('searchInvalidAsinData').value || '').trim(); + var brand = (document.getElementById('searchInvalidAsinDataBrand').value || '').trim(); var groupId = (document.getElementById('invalidAsinDataFilterGroupId').value || '').trim(); - if (keyword) q += '&keyword=' + encodeURIComponent(keyword); + if (dataValue) q += '&data_value=' + encodeURIComponent(dataValue); + if (brand) q += '&brand=' + encodeURIComponent(brand); if (groupId) q += '&group_id=' + encodeURIComponent(groupId); return q; } @@ -2859,6 +2923,19 @@ }); } document.getElementById('btnSearchInvalidAsinData').onclick = function () { loadInvalidAsinData(1); }; + document.getElementById('btnOpenCreateInvalidAsinData').onclick = function () { + document.getElementById('invalidAsinDataValue').value = ''; + document.getElementById('invalidAsinDataBrand').value = ''; + document.getElementById('invalidAsinDataGroupSelect').value = currentUserRole === 'super_admin' + ? '' + : getInvalidAsinDataLockedGroupId(); + document.getElementById('msgInvalidAsinData').textContent = ''; + document.getElementById('msgInvalidAsinData').className = 'msg'; + document.getElementById('createInvalidAsinDataModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateInvalidAsinData').onclick = function () { + document.getElementById('createInvalidAsinDataModal').classList.remove('show'); + }; document.getElementById('btnAddInvalidAsinData').onclick = function () { var dataValue = (document.getElementById('invalidAsinDataValue').value || '').trim(); var brand = (document.getElementById('invalidAsinDataBrand').value || '').trim(); @@ -3021,6 +3098,17 @@ }; }); } + document.getElementById('btnOpenCreateShopKey').onclick = function () { + document.getElementById('shopKeyRemarkName').value = ''; + document.getElementById('shopKeyZiniaoAccountName').value = ''; + document.getElementById('shopKeyZiniaoToken').value = ''; + document.getElementById('msgShopKey').textContent = ''; + document.getElementById('msgShopKey').className = 'msg'; + document.getElementById('createShopKeyModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateShopKey').onclick = function () { + document.getElementById('createShopKeyModal').classList.remove('show'); + }; document.getElementById('btnCreateShopKey').onclick = function () { var remarkName = (document.getElementById('shopKeyRemarkName').value || '').trim(); var ziniaoAccountName = (document.getElementById('shopKeyZiniaoAccountName').value || '').trim(); @@ -3322,9 +3410,13 @@ var skipCreateSel = document.getElementById('skipPriceAsinGroupSelect'); var skipFilterSel = document.getElementById('skipPriceAsinFilterGroupId'); var chooseSkipShopSel = document.getElementById('chooseSkipPriceAsinShopGroupId'); + var skipImportSel = document.getElementById('skipPriceAsinImportGroupId'); + var skipDeleteImportSel = document.getElementById('skipPriceAsinDeleteImportGroupId'); var queryCreateSel = document.getElementById('queryAsinGroupSelect'); var queryFilterSel = document.getElementById('queryAsinFilterGroupId'); var chooseQueryShopSel = document.getElementById('chooseQueryAsinShopGroupId'); + var queryImportSel = document.getElementById('queryAsinImportGroupId'); + var queryDeleteImportSel = document.getElementById('queryAsinDeleteImportGroupId'); var selectedInvalidCreateId = invalidCreateSel ? invalidCreateSel.value : ''; var selectedInvalidEditId = invalidEditSel ? invalidEditSel.value : ''; var selectedInvalidFilterId = invalidFilterSel ? invalidFilterSel.value : ''; @@ -3381,6 +3473,10 @@ if (dedupeEditSel) dedupeEditSel.innerHTML = createOpts.join(''); if (skipCreateSel) skipCreateSel.innerHTML = createOpts.join(''); if (queryCreateSel) queryCreateSel.innerHTML = createOpts.join(''); + if (skipImportSel) skipImportSel.innerHTML = createOpts.join(''); + if (skipDeleteImportSel) skipDeleteImportSel.innerHTML = createOpts.join(''); + if (queryImportSel) queryImportSel.innerHTML = createOpts.join(''); + if (queryDeleteImportSel) queryDeleteImportSel.innerHTML = createOpts.join(''); if (filterSel) filterSel.innerHTML = filterOpts.join(''); if (skipFilterSel) skipFilterSel.innerHTML = filterOpts.join(''); if (chooseSkipShopSel) chooseSkipShopSel.innerHTML = filterOpts.join(''); @@ -3416,6 +3512,28 @@ if (chooseQueryShopSel && selectedChooseQueryShopId) chooseQueryShopSel.value = selectedChooseQueryShopId; } + function renderShopManageGroupSummary() { + var summaryEl = document.getElementById('shopManageGroupSummary'); + if (!summaryEl) return; + if (currentUserRole === 'super_admin') { + summaryEl.innerHTML = '全部分组 · ' + + escapeHtml(shopManageGroups.length) + ' 个'; + return; + } + if (!shopManageGroups.length) { + summaryEl.textContent = '未加入分组'; + return; + } + var visibleGroups = shopManageGroups.slice(0, 4); + summaryEl.innerHTML = visibleGroups.map(function (group) { + var relation = String(group.leader_user_id || '') === String(currentUserId || '') ? '组长' : '组员'; + return '' + + escapeHtml(group.group_name || '') + ' · ' + relation + ''; + }).join('') + (shopManageGroups.length > visibleGroups.length + ? '另 ' + escapeHtml(shopManageGroups.length - visibleGroups.length) + ' 个' + : ''); + } + function loadShopManageGroups(selectedCreateId, selectedEditId, force) { if (!force && shopManageGroups.length && Date.now() - shopManageGroupsLoadedAt < 30000) { refreshShopGroupSelects(selectedCreateId, selectedEditId); @@ -3486,21 +3604,19 @@ currentShopManageGroupGrantRoutes = Array.isArray(routes) ? routes.slice() : []; } + // 分组管理已升级为独立菜单(panel-group-manage),各功能面板内不再有「管理分组」按钮 function updateShopManageGroupButtonsAccess() { - var canManageGroups = !!currentUserId; - ['btnManageShopGroups', 'btnManageShopGroupsFromEdit', 'btnManageSkipPriceAsinGroups', 'btnManageQueryAsinGroups', 'btnManageDedupeGroups', 'btnManageInvalidAsinDataGroups', 'btnManageInvalidAsinDataGroupsFromEdit'].forEach(function (id) { - var btn = document.getElementById(id); - if (btn) btn.style.display = canManageGroups ? '' : 'none'; - }); + // 面板按钮已全部移除,无需控制显隐 } - function openShopManageGroupModal() { + // 分组管理独立菜单页初始化 + function initShopManageGroupPanel() { resetShopManageGroupForm(); loadUserOptions() .then(function () { return loadShopManageGroups(); }) .then(function () { renderShopManageGroupRows(); - document.getElementById('shopManageGroupModal').classList.add('show'); + renderShopManageGroupSummary(); }); } @@ -3513,9 +3629,9 @@ tbody.innerHTML = shopManageGroups.map(function (item, index) { var memberNames = Array.isArray(item.member_usernames) ? item.member_usernames : []; var memberHtml = memberNames.length - ? '
' + + ? '
' + memberNames.map(function (name) { - return '' + escapeHtml(name || '') + ''; + return '' + escapeHtml(name || '') + ''; }).join('') + '
' : '-'; var canEdit = currentUserRole === 'super_admin' || String(item.leader_user_id || '') === String(currentUserId || ''); @@ -3551,6 +3667,8 @@ setShopManageGroupLeader(item.leader_user_id || '', item.leader_username || '', item.member_user_ids || []); document.getElementById('msgShopManageGroup').textContent = ''; document.getElementById('msgShopManageGroup').className = 'msg'; + document.getElementById('shopManageGroupModalTitle').textContent = '编辑分组'; + document.getElementById('shopManageGroupModal').classList.add('show'); }; }); @@ -3568,6 +3686,7 @@ } loadShopManageGroups(null, null, true).then(function () { renderShopManageGroupRows(); + renderShopManageGroupSummary(); renderDedupeGroupSummary(); loadShopManage(shopManagePage); loadSkipPriceAsin(skipPriceAsinPage); @@ -3579,33 +3698,15 @@ }); } - document.getElementById('btnManageShopGroups').onclick = function () { - setShopManageGroupGrantRoutes(['shop-manage']); - openShopManageGroupModal(); + // 分组管理已升级为独立菜单(panel-group-manage via group-manage tab),新建/编辑走 shopManageGroupModal 弹窗 + setShopManageGroupGrantRoutes(['group-manage']); + document.getElementById('btnOpenCreateShopManageGroup').onclick = function () { + resetShopManageGroupForm(); + document.getElementById('shopManageGroupModalTitle').textContent = '新建分组'; + document.getElementById('shopManageGroupModal').classList.add('show'); }; - document.getElementById('btnManageShopGroupsFromEdit').onclick = function () { - setShopManageGroupGrantRoutes(['shop-manage']); - openShopManageGroupModal(); - }; - document.getElementById('btnManageSkipPriceAsinGroups').onclick = function () { - setShopManageGroupGrantRoutes(['skip-price-asin']); - openShopManageGroupModal(); - }; - document.getElementById('btnManageQueryAsinGroups').onclick = function () { - setShopManageGroupGrantRoutes(['query-asin']); - openShopManageGroupModal(); - }; - document.getElementById('btnManageDedupeGroups').onclick = function () { - setShopManageGroupGrantRoutes(['dedupe-total-data']); - openShopManageGroupModal(); - }; - document.getElementById('btnManageInvalidAsinDataGroups').onclick = function () { - setShopManageGroupGrantRoutes(['invalid-asin-data']); - openShopManageGroupModal(); - }; - document.getElementById('btnManageInvalidAsinDataGroupsFromEdit').onclick = function () { - setShopManageGroupGrantRoutes(['invalid-asin-data']); - openShopManageGroupModal(); + document.getElementById('btnCloseShopManageGroupModal').onclick = function () { + document.getElementById('shopManageGroupModal').classList.remove('show'); }; document.getElementById('btnSearchShopManage').onclick = function () { loadShopManage(1); @@ -3616,10 +3717,6 @@ loadShopManage(1); } }); - document.getElementById('btnCloseShopManageGroupModal').onclick = function () { - document.getElementById('shopManageGroupModal').classList.remove('show'); - }; - document.getElementById('btnCancelShopManageGroupEdit').onclick = resetShopManageGroupForm; document.getElementById('btnSaveShopManageGroup').onclick = function () { var editId = (document.getElementById('shopManageGroupEditId').value || '').trim(); var groupName = (document.getElementById('shopManageGroupInput').value || '').trim(); @@ -3651,10 +3748,12 @@ return; } resetShopManageGroupForm(); + document.getElementById('shopManageGroupModal').classList.remove('show'); msgEl.textContent = res.msg || '保存成功'; msgEl.className = 'msg ok'; loadShopManageGroups(null, null, true).then(function () { renderShopManageGroupRows(); + renderShopManageGroupSummary(); renderDedupeGroupSummary(); loadShopManage(shopManagePage); loadSkipPriceAsin(skipPriceAsinPage); @@ -3668,6 +3767,20 @@ }); }; + document.getElementById('btnOpenCreateShopManage').onclick = function () { + document.getElementById('shopManageGroupSelect').value = ''; + document.getElementById('shopManageShopName').value = ''; + document.getElementById('shopManageMallName').value = ''; + document.getElementById('shopManageZnUsername').value = ''; + document.getElementById('shopManageAccount').value = ''; + document.getElementById('shopManagePassword').value = ''; + document.getElementById('msgShopManage').textContent = ''; + document.getElementById('msgShopManage').className = 'msg'; + document.getElementById('createShopManageModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateShopManage').onclick = function () { + document.getElementById('createShopManageModal').classList.remove('show'); + }; document.getElementById('btnCreateShopManage').onclick = function () { var groupId = (document.getElementById('shopManageGroupSelect').value || '').trim(); var shopName = (document.getElementById('shopManageShopName').value || '').trim(); @@ -3754,6 +3867,7 @@ // ========== 版本管理 ========== // ========== 跳过跟价 ASIN ========== var skipPriceAsinPage = 1, skipPriceAsinPageSize = 15; + var skipPriceAsinItemsById = {}; var chooseSkipPriceAsinShopPage = 1, chooseSkipPriceAsinShopPageSize = 10; var skipPriceCountryColumns = [ { code: 'DE', field: 'asin_de', minimumPriceField: 'minimum_price_de', label: '德国' }, @@ -3818,26 +3932,6 @@ document.getElementById('chooseSkipPriceAsinShopModal').classList.add('show'); loadChooseSkipPriceAsinShops(1); } - function setupSkipPriceAsinShopPicker() { - var input = document.getElementById('skipPriceAsinShopName'); - var button = document.getElementById('btnChooseSkipPriceAsinShop'); - if (!input || !button) return; - var inputParent = input.parentNode; - if (inputParent && inputParent.style && inputParent.style.display === 'flex') return; - var legacyWrap = button.parentNode; - var wrapper = document.createElement('div'); - wrapper.style.display = 'flex'; - wrapper.style.gap = '8px'; - wrapper.style.alignItems = 'center'; - input.parentNode.insertBefore(wrapper, input); - wrapper.appendChild(input); - wrapper.appendChild(button); - button.style.whiteSpace = 'nowrap'; - button.style.marginTop = '0'; - if (legacyWrap && legacyWrap !== wrapper) { - legacyWrap.style.display = 'none'; - } - } function getSelectedSkipPriceCountries() { return Array.from(document.getElementById('skipPriceAsinCountries').selectedOptions).map(function (option) { return option.value; @@ -3911,10 +4005,16 @@ var query = ''; var groupId = (document.getElementById('skipPriceAsinFilterGroupId').value || '').trim(); var shopName = (document.getElementById('skipPriceAsinFilterShopName').value || '').trim(); + var country = (document.getElementById('skipPriceAsinFilterCountry').value || '').trim(); var asin = (document.getElementById('skipPriceAsinFilterAsin').value || '').trim(); + var minimumPriceFrom = (document.getElementById('skipPriceAsinFilterPriceFrom').value || '').trim(); + var minimumPriceTo = (document.getElementById('skipPriceAsinFilterPriceTo').value || '').trim(); if (groupId) query += (query ? '&' : '') + 'group_id=' + encodeURIComponent(groupId); if (shopName) query += (query ? '&' : '') + 'shop_name=' + encodeURIComponent(shopName); + if (country) query += (query ? '&' : '') + 'country=' + encodeURIComponent(country); if (asin) query += (query ? '&' : '') + 'asin=' + encodeURIComponent(asin); + if (minimumPriceFrom) query += (query ? '&' : '') + 'minimum_price_from=' + encodeURIComponent(minimumPriceFrom); + if (minimumPriceTo) query += (query ? '&' : '') + 'minimum_price_to=' + encodeURIComponent(minimumPriceTo); return query; } function buildSkipPriceAsinOperatorQuery() { @@ -3983,70 +4083,143 @@ alert((err && err.message) || '导出失败'); }); } - function renderSkipPriceAsinCell(item, country) { - var asinValue = String(item[country.field] || '').trim(); - var minimumPriceValue = formatSkipPriceMinimumPrice(item[country.minimumPriceField]); - var hasValue = !!asinValue || !!minimumPriceValue; - var safeAsin = escapeHtml(asinValue); - var safeMinimumPrice = escapeHtml(minimumPriceValue || '-'); - var countryLabel = escapeHtml(country.label || country.code || '国家'); - var infoHtml = hasValue - ? '
' + - (asinValue ? '' + safeAsin + '' : '-') + - '最低价:' + safeMinimumPrice + '' + - '
' - : '-'; - var deleteHtml = hasValue - ? '' - : ''; - return '
' + - infoHtml + - '
' + - '' + - deleteHtml + - '
'; + function renderAsinCopyButton(asinValue) { + if (!asinValue) return '-'; + return ''; } - function openEditSkipPriceAsinModal(itemId, countryCode, shopName, asinValue, minimumPriceValue) { - document.getElementById('editSkipPriceAsinId').value = itemId || ''; - document.getElementById('editSkipPriceAsinCountry').value = countryCode || ''; - document.getElementById('editSkipPriceAsinShopName').value = shopName || ''; - document.getElementById('editSkipPriceAsinCountryLabel').value = getSkipPriceCountryLabel(countryCode || ''); - document.getElementById('editSkipPriceAsinValue').value = asinValue || ''; - document.getElementById('editSkipPriceMinimumPrice').value = minimumPriceValue || ''; - document.getElementById('msgEditSkipPriceAsin').textContent = ''; - document.getElementById('msgEditSkipPriceAsin').className = 'msg'; - document.getElementById('editSkipPriceAsinModal').classList.add('show'); + function buildSkipPriceAsinEntries(item) { + var entries = skipPriceCountryColumns.map(function (country) { + return { + country: country, + asin: String(item[country.field] || '').trim(), + minimumPrice: formatSkipPriceMinimumPrice(item[country.minimumPriceField]) + }; + }).filter(function (entry) { + return entry.asin || entry.minimumPrice; + }); + if (!entries.length) { + entries.push({ country: null, asin: '', minimumPrice: '' }); + } + return entries; + } + function renderSkipPriceAsinRows(item, rowNo) { + var entries = buildSkipPriceAsinEntries(item); + var rowspan = entries.length; + var shopName = escapeHtml(item.shop_name || ''); + return entries.map(function (entry, index) { + var isFirst = index === 0; + return '' + + (isFirst ? '' + rowNo + '' : '') + + (isFirst ? '' + renderShopTableText(item.group_name) + '' : '') + + (isFirst ? '' + renderShopTableText(item.shop_name) + '' : '') + + '' + renderAsinCopyButton(entry.asin) + '' + + '' + (entry.country ? escapeHtml(entry.country.label) : '-') + '' + + '' + (entry.country ? escapeHtml(entry.minimumPrice || '-') : '-') + '' + + (isFirst ? '' : '') + + ''; + }).join(''); + } + function openSkipPriceAsinDrawer(item) { + document.getElementById('skipPriceAsinDrawerId').value = item.id; + document.getElementById('skipPriceAsinDrawerSubtitle').textContent = + (item.group_name ? item.group_name + ' / ' : '') + (item.shop_name || ''); + document.getElementById('skipPriceAsinDrawerFields').innerHTML = skipPriceCountryColumns.map(function (country) { + var asinValue = String(item[country.field] || '').trim(); + var minimumPriceValue = formatSkipPriceMinimumPrice(item[country.minimumPriceField]); + return '
' + + '' + + '
' + + '' + + '' + + '
'; + }).join(''); + var msgEl = document.getElementById('msgSkipPriceAsinDrawer'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + document.getElementById('skipPriceAsinDrawer').classList.add('show'); + } + function closeSkipPriceAsinDrawer() { + document.getElementById('skipPriceAsinDrawer').classList.remove('show'); + } + function saveSkipPriceAsinDrawer() { + var itemId = (document.getElementById('skipPriceAsinDrawerId').value || '').trim(); + var item = skipPriceAsinItemsById[itemId]; + var msgEl = document.getElementById('msgSkipPriceAsinDrawer'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!itemId || !item) { + msgEl.textContent = '缺少编辑记录'; + msgEl.className = 'msg err'; + return; + } + var requests = []; + for (var i = 0; i < skipPriceCountryColumns.length; i++) { + var country = skipPriceCountryColumns[i]; + var asinInput = document.querySelector('[data-skip-price-drawer-input="' + country.code + '"]'); + var priceInput = document.querySelector('[data-skip-price-drawer-minimum-price="' + country.code + '"]'); + var nextAsin = asinInput ? (asinInput.value || '').trim().toUpperCase() : ''; + var nextPrice = priceInput ? (priceInput.value || '').trim() : ''; + var currentAsin = String(item[country.field] || '').trim().toUpperCase(); + var currentPrice = formatSkipPriceMinimumPrice(item[country.minimumPriceField]); + if (nextPrice) { + var priceNumber = Number(nextPrice); + if (!isFinite(priceNumber) || priceNumber < 0) { + msgEl.textContent = country.label + ' 最低价格式不正确'; + msgEl.className = 'msg err'; + return; + } + } + if (!nextAsin && nextPrice) { + msgEl.textContent = country.label + ' 填写最低价时必须填写 ASIN'; + msgEl.className = 'msg err'; + return; + } + if (nextAsin === currentAsin && (nextPrice || '') === (currentPrice || '')) continue; + if (!nextAsin) { + requests.push({ country: country, method: 'DELETE', body: null }); + } else { + requests.push({ country: country, method: 'PUT', body: { asin: nextAsin, minimum_price: nextPrice || null } }); + } + } + if (!requests.length) { + closeSkipPriceAsinDrawer(); + return; + } + var operatorQuery = buildSkipPriceAsinOperatorQuery(); + Promise.all(requests.map(function (request) { + var options = { method: request.method }; + if (request.body) { + options.headers = { 'Content-Type': 'application/json' }; + options.body = JSON.stringify(request.body); + } + var url = '/api/admin/skip-price-asin/' + itemId + '/country/' + request.country.code + + (operatorQuery ? ('?' + operatorQuery) : ''); + return fetch(url, options) + .then(function (r) { return r.json(); }) + .then(function (res) { + return res && res.success ? null : (request.country.label + ':' + ((res && res.error) || '保存失败')); + }) + .catch(function () { return request.country.label + ':请求失败'; }); + })).then(function (errors) { + var failed = errors.filter(Boolean); + if (failed.length) { + msgEl.textContent = failed.join(';'); + msgEl.className = 'msg err'; + loadSkipPriceAsin(skipPriceAsinPage); + return; + } + closeSkipPriceAsinDrawer(); + loadSkipPriceAsin(skipPriceAsinPage); + }); } function bindSkipPriceAsinActions() { - document.querySelectorAll('[data-skip-price-asin-edit]').forEach(function (btn) { - btn.onclick = function () { - var shopName = (btn.dataset.shopName || '').replace(/"/g, '"'); - var countryCode = btn.dataset.country || ''; - var currentAsin = (btn.dataset.asin || '').replace(/"/g, '"'); - var currentMinimumPrice = (btn.dataset.minimumPrice || '').replace(/"/g, '"'); - openEditSkipPriceAsinModal( - btn.dataset.skipPriceAsinEdit, - countryCode, - shopName, - currentAsin, - currentMinimumPrice - ); - }; + document.querySelectorAll('#skipPriceAsinListBody [data-copy-asin]').forEach(function (btn) { + btn.onclick = function () { copyAsinText(btn); }; }); - document.querySelectorAll('[data-skip-price-asin-delete]').forEach(function (btn) { + document.querySelectorAll('[data-skip-price-asin-configure]').forEach(function (btn) { btn.onclick = function () { - var shopName = (btn.dataset.shopName || '').replace(/"/g, '"'); - var countryCode = btn.dataset.country || ''; - if (!confirm('确定删除店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN 吗?')) return; - var operatorQuery = buildSkipPriceAsinOperatorQuery(); - fetch('/api/admin/skip-price-asin/' + btn.dataset.skipPriceAsinDelete + '/country/' + countryCode + (operatorQuery ? ('?' + operatorQuery) : ''), { - method: 'DELETE' - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) loadSkipPriceAsin(skipPriceAsinPage); - else alert(res.error || '删除失败'); - }); + var item = skipPriceAsinItemsById[btn.dataset.skipPriceAsinConfigure]; + if (item) openSkipPriceAsinDrawer(item); }; }); } @@ -4057,30 +4230,25 @@ .then(function (res) { var tbody = document.getElementById('skipPriceAsinListBody'); if (!res.success) { - tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; return; } var items = res.items || []; + skipPriceAsinItemsById = {}; + items.forEach(function (item) { skipPriceAsinItemsById[String(item.id)] = item; }); if (items.length === 0) { - tbody.innerHTML = '暂无数据'; + tbody.innerHTML = '暂无数据'; } else { tbody.innerHTML = items.map(function (item, index) { var rowNo = (skipPriceAsinPage - 1) * skipPriceAsinPageSize + index + 1; - return '' + - '' + rowNo + '' + - '' + renderShopTableText(item.group_name) + '' + - '' + renderShopTableText(item.shop_name) + '' + - skipPriceCountryColumns.map(function (country) { - return '' + renderSkipPriceAsinCell(item, country) + ''; - }).join('') + - ''; + return renderSkipPriceAsinRows(item, rowNo); }).join(''); } renderPagination('skipPriceAsinPagination', res.total, res.page, res.page_size, loadSkipPriceAsin); bindSkipPriceAsinActions(); }) .catch(function () { - document.getElementById('skipPriceAsinListBody').innerHTML = '请求失败'; + document.getElementById('skipPriceAsinListBody').innerHTML = '请求失败'; }); } var skipPriceAsinImportPollTimer = null; @@ -4285,7 +4453,7 @@ stopSkipPriceAsinImportProgress(); document.getElementById('skipPriceAsinImportProgressWrap').style.display = 'none'; } - var groupId = (document.getElementById('skipPriceAsinGroupSelect').value || '').trim(); + var groupId = (document.getElementById(deleteMode ? 'skipPriceAsinDeleteImportGroupId' : 'skipPriceAsinImportGroupId').value || '').trim(); if (!groupId) { msgEl.textContent = '请先选择分组'; msgEl.className = 'msg err'; @@ -4365,59 +4533,12 @@ document.getElementById('btnCloseChooseSkipPriceAsinShopModal').onclick = function () { document.getElementById('chooseSkipPriceAsinShopModal').classList.remove('show'); }; - document.getElementById('btnCloseEditSkipPriceAsin').onclick = function () { - document.getElementById('editSkipPriceAsinModal').classList.remove('show'); - }; - document.getElementById('btnSaveSkipPriceAsin').onclick = function () { - var itemId = (document.getElementById('editSkipPriceAsinId').value || '').trim(); - var countryCode = (document.getElementById('editSkipPriceAsinCountry').value || '').trim(); - var asin = (document.getElementById('editSkipPriceAsinValue').value || '').trim().toUpperCase(); - var minimumPrice = (document.getElementById('editSkipPriceMinimumPrice').value || '').trim(); - var msgEl = document.getElementById('msgEditSkipPriceAsin'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!itemId || !countryCode) { - msgEl.textContent = '缺少编辑记录'; - msgEl.className = 'msg err'; - return; - } - if (!asin) { - msgEl.textContent = 'ASIN 不能为空'; - msgEl.className = 'msg err'; - return; - } - if (minimumPrice) { - var minimumPriceNumber = Number(minimumPrice); - if (!isFinite(minimumPriceNumber) || minimumPriceNumber < 0) { - msgEl.textContent = '最低价格式不正确'; - msgEl.className = 'msg err'; - return; - } - } - var operatorQuery = buildSkipPriceAsinOperatorQuery(); - fetch('/api/admin/skip-price-asin/' + itemId + '/country/' + countryCode + (operatorQuery ? ('?' + operatorQuery) : ''), { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - asin: asin, - minimum_price: minimumPrice || null - }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - msgEl.textContent = res.error || '保存失败'; - msgEl.className = 'msg err'; - return; - } - document.getElementById('editSkipPriceAsinModal').classList.remove('show'); - loadSkipPriceAsin(skipPriceAsinPage); - }) - .catch(function () { - msgEl.textContent = '请求失败'; - msgEl.className = 'msg err'; - }); - }; + document.getElementById('btnCloseSkipPriceAsinDrawer').onclick = closeSkipPriceAsinDrawer; + document.getElementById('btnCancelSkipPriceAsinDrawer').onclick = closeSkipPriceAsinDrawer; + document.getElementById('btnSaveSkipPriceAsinDrawer').onclick = saveSkipPriceAsinDrawer; + document.getElementById('skipPriceAsinDrawer').addEventListener('click', function (event) { + if (event.target === this) closeSkipPriceAsinDrawer(); + }); document.getElementById('skipPriceAsinCountries').addEventListener('change', renderSkipPriceAsinInputs); document.getElementById('btnSearchSkipPriceAsin').onclick = function () { loadSkipPriceAsin(1); @@ -4437,6 +4558,43 @@ loadSkipPriceAsin(1); } }); + document.getElementById('btnOpenCreateSkipPriceAsin').onclick = function () { + document.getElementById('skipPriceAsinGroupSelect').value = ''; + document.getElementById('skipPriceAsinShopName').value = ''; + Array.from(document.getElementById('skipPriceAsinCountries').options).forEach(function (option) { + option.selected = false; + }); + refreshDropdownMultiSelect('skipPriceAsinCountries'); + renderSkipPriceAsinInputs(); + document.getElementById('msgSkipPriceAsin').textContent = ''; + document.getElementById('msgSkipPriceAsin').className = 'msg'; + document.getElementById('createSkipPriceAsinModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateSkipPriceAsinModal').onclick = function () { + document.getElementById('createSkipPriceAsinModal').classList.remove('show'); + }; + document.getElementById('btnOpenImportSkipPriceAsin').onclick = function () { + document.getElementById('skipPriceAsinImportGroupId').value = ''; + document.getElementById('skipPriceAsinImportFile').value = ''; + document.getElementById('msgSkipPriceAsinImport').textContent = ''; + document.getElementById('msgSkipPriceAsinImport').className = 'msg'; + document.getElementById('skipPriceAsinImportProgressWrap').style.display = 'none'; + document.getElementById('importSkipPriceAsinModal').classList.add('show'); + }; + document.getElementById('btnCloseImportSkipPriceAsinModal').onclick = function () { + document.getElementById('importSkipPriceAsinModal').classList.remove('show'); + }; + document.getElementById('btnOpenDeleteImportSkipPriceAsin').onclick = function () { + document.getElementById('skipPriceAsinDeleteImportGroupId').value = ''; + document.getElementById('skipPriceAsinDeleteImportFile').value = ''; + document.getElementById('msgSkipPriceAsinDeleteImport').textContent = ''; + document.getElementById('msgSkipPriceAsinDeleteImport').className = 'msg'; + document.getElementById('skipPriceAsinDeleteImportProgressWrap').style.display = 'none'; + document.getElementById('deleteImportSkipPriceAsinModal').classList.add('show'); + }; + document.getElementById('btnCloseDeleteImportSkipPriceAsinModal').onclick = function () { + document.getElementById('deleteImportSkipPriceAsinModal').classList.remove('show'); + }; document.getElementById('btnCreateSkipPriceAsin').onclick = function () { var groupId = (document.getElementById('skipPriceAsinGroupSelect').value || '').trim(); var shopName = (document.getElementById('skipPriceAsinShopName').value || '').trim(); @@ -4501,7 +4659,6 @@ msgEl.className = 'msg err'; }); }; - setupSkipPriceAsinShopPicker(); initDropdownMultiSelect('skipPriceAsinCountries', '请选择国家'); document.getElementById('btnImportSkipPriceAsin').onclick = function () { uploadSkipPriceAsinImport(false); @@ -4513,6 +4670,7 @@ // ========== 查询 ASIN ========== var queryAsinPage = 1, queryAsinPageSize = 15; + var queryAsinItemsById = {}; var chooseQueryAsinShopPage = 1, chooseQueryAsinShopPageSize = 10; var queryAsinCountryColumns = [ { code: 'DE', field: 'asin_de', label: '德国' }, @@ -4629,9 +4787,11 @@ var query = ''; var groupId = (document.getElementById('queryAsinFilterGroupId').value || '').trim(); var shopName = (document.getElementById('queryAsinFilterShopName').value || '').trim(); + var country = (document.getElementById('queryAsinFilterCountry').value || '').trim(); var asin = (document.getElementById('queryAsinFilterAsin').value || '').trim(); if (groupId) query += (query ? '&' : '') + 'group_id=' + encodeURIComponent(groupId); if (shopName) query += (query ? '&' : '') + 'shop_name=' + encodeURIComponent(shopName); + if (country) query += (query ? '&' : '') + 'country=' + encodeURIComponent(country); if (asin) query += (query ? '&' : '') + 'asin=' + encodeURIComponent(asin); return query; } @@ -4662,56 +4822,115 @@ alert((err && err.message) || '导出失败'); }); } - function renderQueryAsinCell(item, country) { - var asinValue = String(item[country.field] || '').trim(); - var countryLabel = escapeHtml(country.label || country.code || '国家'); - var infoHtml = asinValue - ? '' + escapeHtml(asinValue) + '' - : '-'; - var deleteHtml = asinValue - ? '' - : ''; - return '
' + - '
' + infoHtml + '
' + - '
' + - '' + - deleteHtml + - '
'; + function openQueryAsinDrawer(item) { + document.getElementById('queryAsinDrawerId').value = item.id; + document.getElementById('queryAsinDrawerSubtitle').textContent = + (item.group_name ? item.group_name + ' / ' : '') + (item.shop_name || ''); + document.getElementById('queryAsinDrawerFields').innerHTML = queryAsinCountryColumns.map(function (country) { + var value = String(item[country.field] || '').trim(); + return '
' + + '' + + '' + + '
'; + }).join(''); + var msgEl = document.getElementById('msgQueryAsinDrawer'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + document.getElementById('queryAsinDrawer').classList.add('show'); } - function openEditQueryAsinModal(itemId, countryCode, shopName, asinValue) { - document.getElementById('editQueryAsinId').value = itemId || ''; - document.getElementById('editQueryAsinCountry').value = countryCode || ''; - document.getElementById('editQueryAsinShopName').value = shopName || ''; - document.getElementById('editQueryAsinCountryLabel').value = getQueryAsinCountryLabel(countryCode || ''); - document.getElementById('editQueryAsinValue').value = asinValue || ''; - document.getElementById('msgEditQueryAsin').textContent = ''; - document.getElementById('msgEditQueryAsin').className = 'msg'; - document.getElementById('editQueryAsinModal').classList.add('show'); + function closeQueryAsinDrawer() { + document.getElementById('queryAsinDrawer').classList.remove('show'); + } + function saveQueryAsinDrawer() { + var itemId = (document.getElementById('queryAsinDrawerId').value || '').trim(); + var item = queryAsinItemsById[itemId]; + var msgEl = document.getElementById('msgQueryAsinDrawer'); + msgEl.textContent = ''; + msgEl.className = 'msg'; + if (!itemId || !item) { + msgEl.textContent = '缺少编辑记录'; + msgEl.className = 'msg err'; + return; + } + var requests = []; + for (var i = 0; i < queryAsinCountryColumns.length; i++) { + var country = queryAsinCountryColumns[i]; + var input = document.querySelector('[data-query-asin-drawer-input="' + country.code + '"]'); + var nextValue = input ? (input.value || '').trim().toUpperCase() : ''; + var currentValue = String(item[country.field] || '').trim().toUpperCase(); + if (nextValue === currentValue) continue; + if (!nextValue) { + requests.push({ country: country, method: 'DELETE', body: null }); + } else { + requests.push({ country: country, method: 'PUT', body: { asin: nextValue } }); + } + } + if (!requests.length) { + closeQueryAsinDrawer(); + return; + } + Promise.all(requests.map(function (request) { + var options = { method: request.method }; + if (request.body) { + options.headers = { 'Content-Type': 'application/json' }; + options.body = JSON.stringify(request.body); + } + return fetch('/api/admin/query-asin/' + itemId + '/country/' + request.country.code, options) + .then(function (r) { return r.json(); }) + .then(function (res) { + return res && res.success ? null : (request.country.label + ':' + ((res && res.error) || '保存失败')); + }) + .catch(function () { return request.country.label + ':请求失败'; }); + })).then(function (errors) { + var failed = errors.filter(Boolean); + if (failed.length) { + msgEl.textContent = failed.join(';'); + msgEl.className = 'msg err'; + loadQueryAsin(queryAsinPage); + return; + } + closeQueryAsinDrawer(); + loadQueryAsin(queryAsinPage); + }); + } + function renderQueryAsinEntries(item) { + var entries = queryAsinCountryColumns.map(function (country) { + return { + country: country, + asin: String(item[country.field] || '').trim() + }; + }).filter(function (entry) { + return entry.asin; + }); + if (!entries.length) { + entries.push({ country: null, asin: '' }); + } + return entries; + } + function renderQueryAsinRows(item, rowNo) { + var entries = renderQueryAsinEntries(item); + var rowspan = entries.length; + var shopName = escapeHtml(item.shop_name || ''); + return entries.map(function (entry, index) { + var isFirst = index === 0; + return '' + + (isFirst ? '' + rowNo + '' : '') + + (isFirst ? '' + renderShopTableText(item.group_name) + '' : '') + + (isFirst ? '' + renderShopTableText(item.shop_name) + '' : '') + + '' + renderAsinCopyButton(entry.asin) + '' + + '' + (entry.country ? escapeHtml(entry.country.label) : '-') + '' + + (isFirst ? '' : '') + + ''; + }).join(''); } function bindQueryAsinActions() { - document.querySelectorAll('[data-query-asin-edit]').forEach(function (btn) { - btn.onclick = function () { - openEditQueryAsinModal( - btn.dataset.queryAsinEdit, - btn.dataset.country || '', - (btn.dataset.shopName || '').replace(/"/g, '"'), - (btn.dataset.asin || '').replace(/"/g, '"') - ); - }; + document.querySelectorAll('#queryAsinListBody [data-copy-asin]').forEach(function (btn) { + btn.onclick = function () { copyAsinText(btn); }; }); - document.querySelectorAll('[data-query-asin-delete]').forEach(function (btn) { + document.querySelectorAll('[data-query-asin-configure]').forEach(function (btn) { btn.onclick = function () { - var shopName = (btn.dataset.shopName || '').replace(/"/g, '"'); - var countryCode = btn.dataset.country || ''; - if (!confirm('确定删除店铺“' + shopName + '”在 ' + countryCode + ' 的 ASIN 吗?')) return; - fetch('/api/admin/query-asin/' + btn.dataset.queryAsinDelete + '/country/' + countryCode, { - method: 'DELETE' - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (res.success) loadQueryAsin(queryAsinPage); - else alert(res.error || '删除失败'); - }); + var item = queryAsinItemsById[btn.dataset.queryAsinConfigure]; + if (item) openQueryAsinDrawer(item); }; }); } @@ -4722,30 +4941,25 @@ .then(function (res) { var tbody = document.getElementById('queryAsinListBody'); if (!res.success) { - tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; return; } var items = res.items || []; + queryAsinItemsById = {}; + items.forEach(function (item) { queryAsinItemsById[String(item.id)] = item; }); if (items.length === 0) { - tbody.innerHTML = '暂无数据'; + tbody.innerHTML = '暂无数据'; } else { tbody.innerHTML = items.map(function (item, index) { var rowNo = (queryAsinPage - 1) * queryAsinPageSize + index + 1; - return '' + - '' + rowNo + '' + - '' + renderShopTableText(item.group_name) + '' + - '' + renderShopTableText(item.shop_name) + '' + - queryAsinCountryColumns.map(function (country) { - return '' + renderQueryAsinCell(item, country) + ''; - }).join('') + - ''; + return renderQueryAsinRows(item, rowNo); }).join(''); } renderPagination('queryAsinPagination', res.total, res.page, res.page_size, loadQueryAsin); bindQueryAsinActions(); }) .catch(function () { - document.getElementById('queryAsinListBody').innerHTML = '请求失败'; + document.getElementById('queryAsinListBody').innerHTML = '请求失败'; }); } var queryAsinImportPollTimer = null; @@ -4881,7 +5095,7 @@ } var formData = new FormData(); formData.append('file', file); - var groupId = (document.getElementById('queryAsinGroupSelect').value || '').trim(); + var groupId = (document.getElementById(deleteMode ? 'queryAsinDeleteImportGroupId' : 'queryAsinImportGroupId').value || '').trim(); if (groupId) formData.append('group_id', groupId); var xhr = new XMLHttpRequest(); xhr.open('POST', deleteMode ? '/api/admin/query-asins/delete-import' : '/api/admin/query-asins/import', true); @@ -4937,46 +5151,12 @@ document.getElementById('btnCloseChooseQueryAsinShopModal').onclick = function () { document.getElementById('chooseQueryAsinShopModal').classList.remove('show'); }; - document.getElementById('btnCloseEditQueryAsin').onclick = function () { - document.getElementById('editQueryAsinModal').classList.remove('show'); - }; - document.getElementById('btnSaveQueryAsin').onclick = function () { - var itemId = (document.getElementById('editQueryAsinId').value || '').trim(); - var countryCode = (document.getElementById('editQueryAsinCountry').value || '').trim(); - var asin = (document.getElementById('editQueryAsinValue').value || '').trim().toUpperCase(); - var msgEl = document.getElementById('msgEditQueryAsin'); - msgEl.textContent = ''; - msgEl.className = 'msg'; - if (!itemId || !countryCode) { - msgEl.textContent = '缺少编辑记录'; - msgEl.className = 'msg err'; - return; - } - if (!asin) { - msgEl.textContent = 'ASIN 不能为空'; - msgEl.className = 'msg err'; - return; - } - fetch('/api/admin/query-asin/' + itemId + '/country/' + countryCode, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ asin: asin }) - }) - .then(function (r) { return r.json(); }) - .then(function (res) { - if (!res.success) { - msgEl.textContent = res.error || '保存失败'; - msgEl.className = 'msg err'; - return; - } - document.getElementById('editQueryAsinModal').classList.remove('show'); - loadQueryAsin(queryAsinPage); - }) - .catch(function () { - msgEl.textContent = '请求失败'; - msgEl.className = 'msg err'; - }); - }; + document.getElementById('btnCloseQueryAsinDrawer').onclick = closeQueryAsinDrawer; + document.getElementById('btnCancelQueryAsinDrawer').onclick = closeQueryAsinDrawer; + document.getElementById('btnSaveQueryAsinDrawer').onclick = saveQueryAsinDrawer; + document.getElementById('queryAsinDrawer').addEventListener('click', function (event) { + if (event.target === this) closeQueryAsinDrawer(); + }); document.getElementById('queryAsinCountries').addEventListener('change', renderQueryAsinInputs); document.getElementById('btnSearchQueryAsin').onclick = function () { loadQueryAsin(1); @@ -4996,6 +5176,43 @@ loadQueryAsin(1); } }); + document.getElementById('btnOpenCreateQueryAsin').onclick = function () { + document.getElementById('queryAsinGroupSelect').value = ''; + document.getElementById('queryAsinShopName').value = ''; + Array.from(document.getElementById('queryAsinCountries').options).forEach(function (option) { + option.selected = false; + }); + refreshDropdownMultiSelect('queryAsinCountries'); + renderQueryAsinInputs(); + document.getElementById('msgQueryAsin').textContent = ''; + document.getElementById('msgQueryAsin').className = 'msg'; + document.getElementById('createQueryAsinModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateQueryAsinModal').onclick = function () { + document.getElementById('createQueryAsinModal').classList.remove('show'); + }; + document.getElementById('btnOpenImportQueryAsin').onclick = function () { + document.getElementById('queryAsinImportGroupId').value = ''; + document.getElementById('queryAsinImportFile').value = ''; + document.getElementById('msgQueryAsinImport').textContent = ''; + document.getElementById('msgQueryAsinImport').className = 'msg'; + document.getElementById('queryAsinImportProgressWrap').style.display = 'none'; + document.getElementById('importQueryAsinModal').classList.add('show'); + }; + document.getElementById('btnCloseImportQueryAsinModal').onclick = function () { + document.getElementById('importQueryAsinModal').classList.remove('show'); + }; + document.getElementById('btnOpenDeleteImportQueryAsin').onclick = function () { + document.getElementById('queryAsinDeleteImportGroupId').value = ''; + document.getElementById('queryAsinDeleteImportFile').value = ''; + document.getElementById('msgQueryAsinDeleteImport').textContent = ''; + document.getElementById('msgQueryAsinDeleteImport').className = 'msg'; + document.getElementById('queryAsinDeleteImportProgressWrap').style.display = 'none'; + document.getElementById('deleteImportQueryAsinModal').classList.add('show'); + }; + document.getElementById('btnCloseDeleteImportQueryAsinModal').onclick = function () { + document.getElementById('deleteImportQueryAsinModal').classList.remove('show'); + }; document.getElementById('btnCreateQueryAsin').onclick = function () { var groupId = (document.getElementById('queryAsinGroupSelect').value || '').trim(); var shopName = (document.getElementById('queryAsinShopName').value || '').trim(); @@ -5224,6 +5441,8 @@ document.getElementById('btnCancelProductCategoryEdit').style.display = ''; document.getElementById('msgProductCategory').textContent = ''; document.getElementById('msgProductCategory').className = 'msg'; + document.getElementById('createProductCategoryModalTitle').textContent = '编辑商品类目'; + document.getElementById('createProductCategoryModal').classList.add('show'); }; }); document.querySelectorAll('[data-product-category-delete]').forEach(function (btn) { @@ -5382,6 +5601,16 @@ }); } + document.getElementById('btnOpenCreateProductCategory').onclick = function () { + resetProductCategoryForm(); + document.getElementById('createProductCategoryModalTitle').textContent = '新增商品类目'; + document.getElementById('createProductCategoryModal').classList.add('show'); + }; + + document.getElementById('btnCloseCreateProductCategoryModal').onclick = function () { + document.getElementById('createProductCategoryModal').classList.remove('show'); + }; + document.getElementById('btnCancelProductCategoryEdit').onclick = function () { resetProductCategoryForm(); }; @@ -5445,6 +5674,7 @@ msgEl.textContent = res.msg || '保存成功'; msgEl.className = 'msg ok'; resetProductCategoryForm(); + document.getElementById('createProductCategoryModal').classList.remove('show'); loadProductCategories(); }) .catch(function () { @@ -5594,6 +5824,28 @@ window.loadDigitalHumanVersions = loadDigitalHumanVersions; window.downloadDigitalHumanVersion = downloadDigitalHumanVersion; + document.getElementById('btnOpenCreateVersion').onclick = function () { + document.getElementById('versionNumber').value = ''; + document.getElementById('versionZip').value = ''; + document.getElementById('msgVersion').textContent = ''; + document.getElementById('msgVersion').className = 'msg'; + document.getElementById('createVersionModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateVersionModal').onclick = function () { + document.getElementById('createVersionModal').classList.remove('show'); + }; + document.getElementById('btnOpenCreateDigitalHumanVersion').onclick = function () { + document.getElementById('digitalHumanVersionNumber').value = ''; + document.getElementById('digitalHumanMinClientVersion').value = ''; + document.getElementById('digitalHumanVersionChangelog').value = ''; + document.getElementById('digitalHumanVersionZip').value = ''; + document.getElementById('msgDigitalHumanVersion').textContent = ''; + document.getElementById('msgDigitalHumanVersion').className = 'msg'; + document.getElementById('createDigitalHumanVersionModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateDigitalHumanVersionModal').onclick = function () { + document.getElementById('createDigitalHumanVersionModal').classList.remove('show'); + }; document.getElementById('btnUploadVersion').onclick = function () { var version = (document.getElementById('versionNumber').value || '').trim(); var fileInput = document.getElementById('versionZip'); @@ -5795,6 +6047,143 @@ document.getElementById('btnCloseEditColumn').onclick = function () { document.getElementById('editColumnModal').classList.remove('show'); }; + document.getElementById('btnOpenCreateColumn').onclick = function () { + document.getElementById('columnName').value = ''; + document.getElementById('columnPageSelect').value = ''; + document.getElementById('columnMenuType').value = 'admin'; + document.getElementById('columnParentId').value = ''; + document.getElementById('msgColumn').textContent = ''; + document.getElementById('msgColumn').className = 'msg'; + if (allColumnsList.length) { + populateColumnPageSelects(); + populateColumnParentSelects(); + } else { + loadColumns(); + } + document.getElementById('createColumnModal').classList.add('show'); + }; + document.getElementById('btnCloseCreateColumnModal').onclick = function () { + document.getElementById('createColumnModal').classList.remove('show'); + }; + + // 菜单标识与页面路径都是后端固定匹配的值,这里用已知页面清单代替手工输入。 + var COLUMN_PAGE_CATALOG = [ + { name: '用户管理', column_key: 'admin_users', route_path: 'users', menu_type: 'admin' }, + { name: '菜单权限配置', column_key: 'admin_columns', route_path: 'columns', menu_type: 'admin' }, + { name: '去重数据汇总', column_key: 'admin_dedupe_total_data', route_path: 'dedupe-total-data', menu_type: 'admin' }, + { name: '品牌数据库', column_key: 'admin_invalid_asin_data', route_path: 'invalid-asin-data', menu_type: 'admin' }, + { name: '查询ASIN', column_key: 'admin_query_asin', route_path: 'query-asin', menu_type: 'admin' }, + { name: '商品类目', column_key: 'admin_product_categories', route_path: 'product-categories', menu_type: 'admin' }, + { name: '店铺密钥管理', column_key: 'admin_shop_keys', route_path: 'shop-keys', menu_type: 'admin' }, + { name: '店铺管理', column_key: 'admin_shop_manage', route_path: 'shop-manage', menu_type: 'admin' }, + { name: '最低价ASIN设置', column_key: 'admin_skip_price_asin', route_path: 'skip-price-asin', menu_type: 'admin' }, + { name: '店铺数据记录', column_key: 'admin_shop_data_crawl_tasks', route_path: 'shop-data-crawl-tasks', menu_type: 'admin' }, + { name: '视频任务管理', column_key: 'admin_image_video_tasks', route_path: 'image-video-tasks', menu_type: 'admin' }, + { name: '生成记录', column_key: 'admin_history', route_path: 'history', menu_type: 'admin' }, + { name: '软件版本管理', column_key: 'admin_version', route_path: 'version', menu_type: 'admin' }, + { name: '数字人版本管理', column_key: 'digital_human_version', route_path: 'digital-human-version', menu_type: 'admin' }, + { name: '视频', column_key: 'wb', route_path: 'image-video', menu_type: 'app' }, + { name: '图片', column_key: 'image', route_path: 'image', menu_type: 'app' }, + { name: '数字人', column_key: 'digital-human', route_path: 'digital-human', menu_type: 'app' }, + { name: '带货视频', column_key: 'delivery-video', route_path: 'delivery-video', menu_type: 'app' }, + { name: '混剪', column_key: 'mix-video', route_path: 'mix-video', menu_type: 'app' }, + { name: '前端工具', column_key: 'brand_front_tools', route_path: 'brand-front-tools', menu_type: 'app' }, + { name: '运营工具', column_key: 'brand_operation_tools', route_path: 'brand-operation-tools', menu_type: 'app' }, + { name: '后勤工具', column_key: 'brand_logistics_tools', route_path: 'brand-logistics-tools', menu_type: 'app' }, + { name: '品牌检测', column_key: 'brand', route_path: 'brand', menu_type: 'app' }, + { name: '上架', column_key: 'publish', route_path: 'publish', menu_type: 'app' }, + { name: '数据去重', column_key: 'dedupe', route_path: 'dedupe', menu_type: 'app' }, + { name: '格式转换', column_key: 'convert', route_path: 'convert', menu_type: 'app' }, + { name: '数据拆分', column_key: 'split', route_path: 'split', menu_type: 'app' }, + { name: '删除ASIN', column_key: 'delete-brand', route_path: 'delete-brand', menu_type: 'app' }, + { name: '外观专利检测', column_key: 'appearance-patent', route_path: 'appearance-patent', menu_type: 'app' }, + { name: '货源查询', column_key: 'similar-asin', route_path: 'similar-asin', menu_type: 'app' }, + { name: '商品风险解决', column_key: 'product-risk', route_path: 'product-risk', menu_type: 'app' }, + { name: '定时匹配', column_key: 'shop-match', route_path: 'shop-match', menu_type: 'app' }, + { name: '跟价', column_key: 'pricing', route_path: 'price-track', menu_type: 'app' }, + { name: '巡店删除', column_key: 'patrol-delete', route_path: 'patrol-delete', menu_type: 'app' }, + { name: '查询ASIN', column_key: 'query-asin', route_path: 'query-asin', menu_type: 'app' }, + { name: '店铺数据抓取', column_key: 'shop_data_crawl', route_path: 'shop-data-crawl', menu_type: 'app' }, + { name: '采集数据', column_key: 'collect-data', route_path: 'collect-data', menu_type: 'app' }, + { name: 'ASIN变体采集', column_key: 'variant-collection', route_path: 'variant-collection', menu_type: 'app' }, + { name: '取款', column_key: 'withdraw', route_path: 'withdraw', menu_type: 'app' }, + { name: '采购', column_key: 'purchase', route_path: 'purchase', menu_type: 'app' }, + { name: 'ERP', column_key: 'erp', route_path: 'erp', menu_type: 'app' } + ]; + var COLUMN_PAGE_VALUE_SEPARATOR = '::'; + function columnPageValue(columnKey, routePath) { + return String(columnKey || '') + COLUMN_PAGE_VALUE_SEPARATOR + String(routePath || ''); + } + function parseColumnPageValue(value) { + var parts = String(value || '').split(COLUMN_PAGE_VALUE_SEPARATOR); + return { column_key: parts[0] || '', route_path: parts[1] || '' }; + } + // 目录里没有的历史菜单也要能编辑,所以把列表中已在用的页面并进来。 + function columnPageOptions(menuType) { + var seen = {}; + var options = []; + var push = function (item) { + var key = columnPageValue(item.column_key, item.route_path); + if (!item.column_key || !item.route_path || seen[key]) return; + seen[key] = true; + options.push({ value: key, label: (item.name || item.route_path) + '(' + item.route_path + ')' }); + }; + COLUMN_PAGE_CATALOG.forEach(function (item) { + if (String(item.menu_type) === String(menuType)) push(item); + }); + allColumnsList.forEach(function (item) { + if (String(item.menu_type || 'app') === String(menuType)) push(item); + }); + return options; + } + function populateColumnPageSelects() { + [ + { selectId: 'columnPageSelect', typeId: 'columnMenuType' }, + { selectId: 'editColumnPageSelect', typeId: 'editColumnMenuType' } + ].forEach(function (config) { + var select = document.getElementById(config.selectId); + if (!select) return; + var typeSelect = document.getElementById(config.typeId); + var menuType = typeSelect ? (typeSelect.value || 'admin') : 'admin'; + var current = select.value; + select.innerHTML = ''; + columnPageOptions(menuType).forEach(function (option) { + var el = document.createElement('option'); + el.value = option.value; + el.textContent = option.label; + select.appendChild(el); + }); + select.value = current; + }); + } + // 列表按「同类型 → 父级 → 排序」展开成树形顺序,拖动排序才有直观的同级关系。 + function columnTreeOrderedItems() { + var byParent = {}; + allColumnsList.forEach(function (item) { + var groupKey = columnSiblingGroupKey(item); + (byParent[groupKey] = byParent[groupKey] || []).push(item); + }); + var ordered = []; + var walk = function (menuType, parentId, depth) { + (byParent[menuType + '|' + parentId] || []).forEach(function (item) { + ordered.push({ item: item, depth: depth }); + walk(menuType, columnId(item), depth + 1); + }); + }; + ['admin', 'app'].forEach(function (menuType) { walk(menuType, null, 0); }); + // 父级缺失(被删除或不可见)的菜单仍然要出现在列表里。 + if (ordered.length < allColumnsList.length) { + var shown = {}; + ordered.forEach(function (entry) { shown[columnId(entry.item)] = true; }); + allColumnsList.forEach(function (item) { + if (!shown[columnId(item)]) ordered.push({ item: item, depth: 0 }); + }); + } + return ordered; + } + function columnSiblingGroupKey(item) { + return String(item && item.menu_type || 'app') + '|' + columnParentId(item); + } function loadColumns() { fetch('/api/admin/columns') @@ -5802,30 +6191,38 @@ .then(function (res) { var tbody = document.getElementById('columnListBody'); if (!res.success) { - tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; return; } - allColumnsList = res.items || []; + allColumnsList = (res.items || []).slice().sort(function (a, b) { + var left = a.sort_order != null ? Number(a.sort_order) : 0; + var right = b.sort_order != null ? Number(b.sort_order) : 0; + if (left !== right) return left - right; + return columnId(a) - columnId(b); + }); if (allColumnsList.length === 0) { - tbody.innerHTML = '暂无菜单,请先在上方新增'; + tbody.innerHTML = '暂无菜单,请先在上方新增'; } else { - tbody.innerHTML = allColumnsList.map(function (c) { - var siblings = columnSiblingItems(c); - var siblingIndex = siblings.findIndex(function (item) { return String(item.id) === String(c.id); }); - var moveButtons = - ' ' + - ' '; - var parent = allColumnsList.find(function (item) { return String(item.id) === String(c.parent_id || c.parentId || ''); }); - return '' + c.id + '' + (c.name || '') + '' + (c.column_key || '') + '' + ((c.menu_type || '') === 'admin' ? '后台' : '软件') + '' + (parent ? (parent.name || '') : '-') + '' + (c.sort_order != null ? c.sort_order : 0) + '' + (c.route_path || '') + '' + (c.created_at || '') + '' + - moveButtons + - ' ' + - ''; + tbody.innerHTML = columnTreeOrderedItems().map(function (entry) { + var c = entry.item; + var parent = allColumnsList.find(function (item) { return columnId(item) === columnParentId(c); }); + var indent = entry.depth > 0 ? '' : ''; + return '' + + '' + + '' + escapeHtml(c.id) + '' + + '' + indent + escapeHtml(c.name || '') + '' + + '' + ((c.menu_type || '') === 'admin' ? '后台' : '软件') + '' + + '' + (parent ? escapeHtml(parent.name || '') : '-') + '' + + '' + escapeHtml(c.created_at || '') + '' + + ' ' + + ''; }).join(''); } + populateColumnPageSelects(); bindColumnActions(); }) .catch(function () { - document.getElementById('columnListBody').innerHTML = '请求失败'; + document.getElementById('columnListBody').innerHTML = '请求失败'; }); } function columnSiblingItems(item) { @@ -5836,28 +6233,18 @@ }); } function bindColumnActions() { - document.querySelectorAll('[data-column-move-up]').forEach(function (btn) { - btn.onclick = function () { - if (btn.disabled) return; - moveColumnItem(btn.dataset.columnMoveUp, -1, btn); - }; - }); - document.querySelectorAll('[data-column-move-down]').forEach(function (btn) { - btn.onclick = function () { - if (btn.disabled) return; - moveColumnItem(btn.dataset.columnMoveDown, 1, btn); - }; - }); document.querySelectorAll('[data-column-edit]').forEach(function (btn) { btn.onclick = function () { - document.getElementById('editColumnId').value = btn.dataset.columnEdit || ''; - document.getElementById('editColumnName').value = (btn.dataset.name || '').replace(/"/g, '"'); - document.getElementById('editColumnKey').value = (btn.dataset.key || '').replace(/"/g, '"'); - document.getElementById('editColumnRoutePath').value = (btn.dataset.route || '').replace(/"/g, '"'); - document.getElementById('editColumnMenuType').value = ((btn.dataset.menuType || 'app').replace(/"/g, '"') || 'app'); - document.getElementById('editColumnSortOrder').value = ((btn.dataset.sortOrder || '0').replace(/"/g, '"') || '0'); + var item = allColumnsList.find(function (candidate) { return String(candidate.id) === String(btn.dataset.columnEdit); }); + if (!item) return; + document.getElementById('editColumnId').value = item.id; + document.getElementById('editColumnName').value = item.name || ''; + document.getElementById('editColumnMenuType').value = item.menu_type || 'app'; + document.getElementById('editColumnSortOrder').value = item.sort_order != null ? String(item.sort_order) : '0'; + populateColumnPageSelects(); + document.getElementById('editColumnPageSelect').value = columnPageValue(item.column_key, item.route_path); populateColumnParentSelects(); - document.getElementById('editColumnParentId').value = btn.dataset.parentId || ''; + document.getElementById('editColumnParentId').value = columnParentId(item) || ''; document.getElementById('msgEditColumn').textContent = ''; document.getElementById('editColumnModal').classList.add('show'); }; @@ -5873,29 +6260,88 @@ }); }; }); + bindColumnDragSort(); } - function moveColumnItem(columnId, delta, triggerBtn) { - var currentItem = allColumnsList.find(function (item) { return String(item.id) === String(columnId); }); - if (!currentItem) return; - var siblings = columnSiblingItems(currentItem); - var currentIndex = siblings.findIndex(function (item) { return String(item.id) === String(columnId); }); - if (currentIndex < 0) return; - var targetIndex = currentIndex + delta; - if (targetIndex < 0 || targetIndex >= siblings.length) return; - var targetItem = siblings[targetIndex]; - if (triggerBtn) triggerBtn.disabled = true; + // 只允许同级之间拖动:跨父级或跨菜单类型的落点直接忽略。 + var columnDragState = null; + function clearColumnDropMarks() { + document.querySelectorAll('#columnListBody tr').forEach(function (row) { + row.classList.remove('is-column-drop-before', 'is-column-drop-after'); + }); + } + function bindColumnDragSort() { + var tbody = document.getElementById('columnListBody'); + if (!tbody) return; + tbody.querySelectorAll('[data-column-drag-handle]').forEach(function (handle) { + var row = handle.closest('tr'); + if (!row) return; + handle.addEventListener('dragstart', function (event) { + columnDragState = { id: row.dataset.columnId, group: row.dataset.columnGroup }; + row.classList.add('is-column-dragging'); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move'; + try { event.dataTransfer.setData('text/plain', row.dataset.columnId); } catch (error) {} + if (event.dataTransfer.setDragImage) event.dataTransfer.setDragImage(row, 24, row.offsetHeight / 2); + } + }); + handle.addEventListener('dragend', function () { + row.classList.remove('is-column-dragging'); + clearColumnDropMarks(); + columnDragState = null; + }); + }); + tbody.querySelectorAll('tr[data-column-id]').forEach(function (row) { + row.addEventListener('dragover', function (event) { + if (!columnDragState || columnDragState.group !== row.dataset.columnGroup) return; + if (columnDragState.id === row.dataset.columnId) return; + event.preventDefault(); + if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'; + var rect = row.getBoundingClientRect(); + var placeAfter = (event.clientY - rect.top) > rect.height / 2; + clearColumnDropMarks(); + row.classList.add(placeAfter ? 'is-column-drop-after' : 'is-column-drop-before'); + }); + row.addEventListener('drop', function (event) { + if (!columnDragState || columnDragState.group !== row.dataset.columnGroup) return; + event.preventDefault(); + var rect = row.getBoundingClientRect(); + var placeAfter = (event.clientY - rect.top) > rect.height / 2; + var draggedId = columnDragState.id; + clearColumnDropMarks(); + columnDragState = null; + applyColumnDrop(draggedId, row.dataset.columnId, placeAfter); + }); + }); + tbody.addEventListener('dragleave', function (event) { + if (event.target === tbody) clearColumnDropMarks(); + }); + } + function applyColumnDrop(draggedId, targetId, placeAfter) { + if (String(draggedId) === String(targetId)) return; + var dragged = allColumnsList.find(function (item) { return String(item.id) === String(draggedId); }); + if (!dragged) return; + var siblings = columnSiblingItems(dragged); + var fromIndex = siblings.findIndex(function (item) { return String(item.id) === String(draggedId); }); + if (fromIndex < 0) return; + siblings.splice(fromIndex, 1); + var targetIndex = siblings.findIndex(function (item) { return String(item.id) === String(targetId); }); + if (targetIndex < 0) return; + siblings.splice(placeAfter ? targetIndex + 1 : targetIndex, 0, dragged); + persistColumnOrder(dragged.menu_type || 'app', siblings); + } + function persistColumnOrder(menuType, orderedSiblings) { fetch('/api/admin/column/reorder', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - column_id: currentItem.id, - target_id: targetItem.id, - menu_type: currentItem.menu_type || 'app' + menu_type: menuType, + ordered_ids: orderedSiblings.map(function (item) { return item.id; }) }) }).then(function (r) { return r.json(); }) .then(function (res) { if (!res || !res.success) { alert((res && res.error) || '排序保存失败'); + loadColumns(); return; } loadColumns(); @@ -5903,35 +6349,43 @@ loadAdminMenus(getActiveAdminTabName()); }).catch(function () { alert('排序保存失败'); - }).finally(function () { - if (triggerBtn) triggerBtn.disabled = false; + loadColumns(); }); } + // 新增的菜单排到同级末尾,之后由列表拖动调整顺序。 + function nextColumnSortOrder(menuType, parentId) { + var maxOrder = 0; + allColumnsList.forEach(function (item) { + if (String(item.menu_type || 'app') !== String(menuType)) return; + if (columnParentId(item) !== parentId) return; + var order = item.sort_order != null ? Number(item.sort_order) : 0; + if (isFinite(order) && order > maxOrder) maxOrder = order; + }); + return maxOrder + 10; + } document.getElementById('btnAddColumn').onclick = function () { var name = (document.getElementById('columnName').value || '').trim(); - var key = (document.getElementById('columnKey').value || '').trim(); - var routePath = (document.getElementById('columnRoutePath').value || '').trim(); - var sortOrderValue = (document.getElementById('columnSortOrder').value || '').trim(); + var page = parseColumnPageValue(document.getElementById('columnPageSelect').value); var menuType = (document.getElementById('columnMenuType').value || 'admin').trim() || 'admin'; var parentIdValue = (document.getElementById('columnParentId').value || '').trim(); - var sortOrder = sortOrderValue === '' ? null : parseInt(sortOrderValue, 10); var msgEl = document.getElementById('msgColumn'); msgEl.textContent = ''; msgEl.className = 'msg'; if (!name) { msgEl.textContent = '请填写菜单名称'; msgEl.classList.add('err'); return; } - if (!key) { - msgEl.textContent = '请填写菜单标识'; msgEl.classList.add('err'); return; - } - if (!routePath) { - msgEl.textContent = '请填写页面路径'; msgEl.classList.add('err'); return; - } - if (sortOrderValue !== '' && isNaN(sortOrder)) { - msgEl.textContent = '排序必须是数字'; msgEl.classList.add('err'); return; + if (!page.column_key || !page.route_path) { + msgEl.textContent = '请选择页面'; msgEl.classList.add('err'); return; } fetch('/api/admin/column', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: menuType, sort_order: sortOrder, parent_id: parentIdValue ? Number(parentIdValue) : null }) + body: JSON.stringify({ + name: name, + column_key: page.column_key, + route_path: page.route_path, + menu_type: menuType, + sort_order: nextColumnSortOrder(menuType, parentIdValue ? Number(parentIdValue) : null), + parent_id: parentIdValue ? Number(parentIdValue) : null + }) }) .then(function (r) { return r.json(); }) .then(function (res) { @@ -5939,9 +6393,7 @@ msgEl.textContent = res.msg || '新增成功'; msgEl.classList.add('ok'); document.getElementById('columnName').value = ''; - document.getElementById('columnKey').value = ''; - document.getElementById('columnRoutePath').value = ''; - document.getElementById('columnSortOrder').value = ''; + document.getElementById('columnPageSelect').value = ''; document.getElementById('columnMenuType').value = 'admin'; document.getElementById('columnParentId').value = ''; loadColumns(); @@ -5957,8 +6409,7 @@ document.getElementById('btnSaveColumn').onclick = function () { var cid = document.getElementById('editColumnId').value; var name = (document.getElementById('editColumnName').value || '').trim(); - var key = (document.getElementById('editColumnKey').value || '').trim(); - var routePath = (document.getElementById('editColumnRoutePath').value || '').trim(); + var page = parseColumnPageValue(document.getElementById('editColumnPageSelect').value); var sortOrderValue = (document.getElementById('editColumnSortOrder').value || '').trim(); var menuType = (document.getElementById('editColumnMenuType').value || 'admin').trim() || 'admin'; var parentIdValue = (document.getElementById('editColumnParentId').value || '').trim(); @@ -5967,19 +6418,20 @@ msgEl.textContent = ''; msgEl.className = 'msg'; if (!name) { msgEl.textContent = '请填写菜单名称'; msgEl.classList.add('err'); return; } - if (!key) { - msgEl.textContent = '请填写菜单标识'; msgEl.classList.add('err'); return; - } - if (!routePath) { - msgEl.textContent = '请填写页面路径'; msgEl.classList.add('err'); return; - } - if (sortOrderValue !== '' && isNaN(sortOrder)) { - msgEl.textContent = '排序必须是数字'; msgEl.classList.add('err'); return; + if (!page.column_key || !page.route_path) { + msgEl.textContent = '请选择页面'; msgEl.classList.add('err'); return; } fetch('/api/admin/column/' + cid, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name: name, column_key: key, route_path: routePath, menu_type: menuType, sort_order: sortOrder, parent_id: parentIdValue ? Number(parentIdValue) : null }) + body: JSON.stringify({ + name: name, + column_key: page.column_key, + route_path: page.route_path, + menu_type: menuType, + sort_order: isNaN(sortOrder) ? null : sortOrder, + parent_id: parentIdValue ? Number(parentIdValue) : null + }) }) .then(function (r) { return r.json(); }) .then(function (res) { @@ -5994,8 +6446,14 @@ } }); }; - if (document.getElementById('columnMenuType')) document.getElementById('columnMenuType').onchange = populateColumnParentSelects; - if (document.getElementById('editColumnMenuType')) document.getElementById('editColumnMenuType').onchange = populateColumnParentSelects; + ['columnMenuType', 'editColumnMenuType'].forEach(function (id) { + var select = document.getElementById(id); + if (!select) return; + select.onchange = function () { + populateColumnParentSelects(); + populateColumnPageSelects(); + }; + }); // ========== 分页 ========== function appendPaginationQuickJump(el, totalPages, page, onPage) { diff --git a/backend/tests/test_admin_invalid_asin_data.py b/backend/tests/test_admin_invalid_asin_data.py index 095ad70a..2d589540 100644 --- a/backend/tests/test_admin_invalid_asin_data.py +++ b/backend/tests/test_admin_invalid_asin_data.py @@ -30,7 +30,7 @@ class AdminInvalidAsinDataTest(unittest.TestCase): 'pageSize': 15, }, } - with self.app.test_request_context('/api/admin/invalid-asin-data?page=1&page_size=15&group_id=3'): + with self.app.test_request_context('/api/admin/invalid-asin-data?page=1&page_size=15&data_value=B01&brand=acme&group_id=3'): with patch.object( admin_api, '_ensure_backend_menu_access', @@ -58,6 +58,8 @@ class AdminInvalidAsinDataTest(unittest.TestCase): 'page': 1, 'pageSize': 15, 'keyword': '', + 'dataValue': 'B01', + 'brand': 'acme', 'groupId': 3, }, ) diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 65db179f..3258834f 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -950,10 +950,6 @@ } } - #shopManageGroupModal { - z-index: 1010; - } - .shop-key-whitelist-status { display: inline-flex; align-items: center; @@ -1146,26 +1142,6 @@ color: var(--c-text); } - #shopManageGroupModal .shop-group-modal { - width: min(1180px, calc(100vw - 48px)); - max-width: none; - max-height: calc(100vh - 48px); - padding: 0; - overflow: hidden; - display: flex; - flex-direction: column; - } - - .shop-group-modal-header { - padding: 20px 24px 14px; - border-bottom: 1px solid #edf0f5; - flex: 0 0 auto; - } - - .shop-group-modal-header h3 { - margin: 0; - } - .shop-group-editor { padding: 18px 24px 14px; display: grid; @@ -1209,11 +1185,12 @@ .shop-group-table-wrap { margin: 0 24px; - border: 1px solid #edf0f5; - border-radius: 8px; + border: 1px solid #dbe5ee; + border-radius: 10px; overflow: auto; min-height: 220px; flex: 1 1 auto; + background: #ffffff; } .shop-group-table { @@ -1222,6 +1199,8 @@ } .shop-group-table th { + background: #edf4fa; + color: #4e6479; position: sticky; top: 0; z-index: 1; @@ -1229,7 +1208,7 @@ .shop-group-table th, .shop-group-table td { - vertical-align: top; + vertical-align: middle; } .shop-group-table .col-index { @@ -1237,7 +1216,7 @@ } .shop-group-table .col-name { - width: 130px; + width: 150px; } .shop-group-table .col-leader { @@ -1245,7 +1224,7 @@ } .shop-group-table .col-count { - width: 86px; + width: 90px; } .shop-group-table .col-members { @@ -1253,11 +1232,12 @@ } .shop-group-table .col-time { - width: 120px; + width: 140px; + white-space: nowrap; } .shop-group-table .col-action { - width: 128px; + width: 124px; } .shop-group-time-cell, @@ -1294,15 +1274,6 @@ text-overflow: ellipsis; } - .shop-group-modal-footer { - padding: 14px 24px 18px; - display: flex; - gap: 8px; - justify-content: flex-end; - border-top: 1px solid #edf0f5; - flex: 0 0 auto; - } - @media (max-width: 900px) { .dedupe-filter-row { grid-template-columns: repeat(2, minmax(180px, 1fr)); @@ -1321,11 +1292,6 @@ align-self: flex-start; } - #shopManageGroupModal .shop-group-modal { - width: calc(100vw - 24px); - max-height: calc(100vh - 24px); - } - .shop-group-editor { grid-template-columns: 1fr; } @@ -1494,8 +1460,8 @@ .multi-select-trigger { width: 100%; - min-height: 42px; - padding: 10px 34px 10px 12px; + min-height: 0; + padding: 9px 34px 9px 12px; border: 1px solid var(--c-border); border-radius: var(--radius-sm); background: #fbfcfe; @@ -2892,7 +2858,6 @@ box-shadow: var(--shadow-pop); } - .shop-group-modal-header, .shop-group-table-wrap, .dedupe-group-access { border-color: rgba(148, 163, 184, 0.16); @@ -3688,33 +3653,44 @@ .pagination button:disabled { background: #eef3f7; color: #9baaba; } .shop-key-table-scroll table { min-width: 980px; } + /* 分组管理面板 */ + #panel-group-manage .table-scroll > table { min-width: 980px; } + #panel-group-manage .table-scroll th:nth-child(1) { width: 58px; } + #panel-group-manage .table-scroll th:nth-child(2) { width: 16%; } + #panel-group-manage .table-scroll th:nth-child(3) { width: 12%; } + #panel-group-manage .table-scroll th:nth-child(4) { width: 10%; } + #panel-group-manage .table-scroll th:nth-child(6) { width: 16%; } + #panel-group-manage .table-scroll th:nth-child(7) { width: 16%; } + #panel-group-manage .table-scroll th:nth-child(8) { width: 124px; } + #panel-group-manage .table-scroll th, #panel-group-manage .table-scroll td { vertical-align: middle; } + #panel-group-manage .table-scroll td { white-space: normal; word-break: break-word; } + .shop-group-time-cell, .shop-group-action-cell { white-space: nowrap; } + .shop-group-members { display: flex; flex-wrap: wrap; gap: 6px; max-height: 96px; overflow-y: auto; padding-right: 4px; } + .shop-group-member-chip { display: inline-flex; align-items: center; max-width: 150px; padding: 3px 9px; border-radius: 999px; background: var(--c-primary-soft); color: var(--c-primary-strong); font-size: 12px; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + #panel-invalid-asin-data .invalid-asin-table-scroll > table { min-width: 760px; } - #panel-columns .columns-table-scroll > table { min-width: 980px; } + #panel-columns .columns-table-scroll > table { min-width: 820px; } #panel-version .version-table-scroll > table { min-width: 760px; } #panel-digital-human-version .digital-human-version-table-scroll > table { min-width: 1120px; } #panel-history .history-table-scroll > table { min-width: 820px; } - .query-asin-table-scroll > table, .skip-price-asin-table-scroll > table { min-width: 1280px; table-layout: fixed; } + .query-asin-table-scroll > table, .skip-price-asin-table-scroll > table { min-width: 820px; table-layout: fixed; } #panel-query-asin .query-asin-table-scroll th:nth-child(1), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(1) { width: 6%; } - #panel-query-asin .query-asin-table-scroll th:nth-child(2), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(2) { width: 11%; } - #panel-query-asin .query-asin-table-scroll th:nth-child(3), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(3) { width: 12%; } - #panel-query-asin .query-asin-table-scroll th:nth-child(n+4), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(n+4) { width: 12.2%; } - #panel-query-asin .query-asin-table-scroll th:last-child, #panel-skip-price-asin .skip-price-asin-table-scroll th:last-child { width: 10%; } + #panel-query-asin .query-asin-table-scroll th:nth-child(2), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(2) { width: 13%; } + #panel-query-asin .query-asin-table-scroll th:nth-child(3), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(3) { width: 15%; } + #panel-query-asin .query-asin-table-scroll th:nth-child(4), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(4) { width: 24%; } + #panel-query-asin .query-asin-table-scroll th:nth-child(5), #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(5) { width: 12%; } + #panel-skip-price-asin .skip-price-asin-table-scroll th:nth-child(6) { width: 10%; } + #panel-query-asin .query-asin-table-scroll th:last-child, #panel-skip-price-asin .skip-price-asin-table-scroll th:last-child { width: 9%; } #panel-query-asin .query-asin-table-scroll td, #panel-skip-price-asin .skip-price-asin-table-scroll td { vertical-align: middle; } .table-ellipsis { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - .asin-cell-layout { display: flex; flex-direction: column; align-items: stretch; gap: 8px; min-width: 0; } .asin-cell-content { min-width: 0; overflow: hidden; } - .asin-cell-value { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-variant-numeric: tabular-nums; } .asin-cell-meta { display: block; margin-top: 3px; color: #73869a; font-size: 12px; white-space: nowrap; } .asin-empty-value, .asin-empty-hint { color: #8b9aaa; } - .asin-cell-actions { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } - .asin-cell-actions .btn-sm { min-height: 38px; padding: 7px 10px; } .skip-price-entry { display: grid !important; grid-template-columns: 58px minmax(0, 1fr) 140px; align-items: center; gap: 8px !important; } .skip-price-country { color: var(--c-text-2); font-weight: 600; } .skip-price-entry input { min-width: 0 !important; width: auto !important; } - - /* 站点列只做纯展示:点击 ASIN 即复制,编辑统一收进右侧抽屉 */ .asin-copy-text { display: block; max-width: 100%; margin: -3px 0 -3px -7px; padding: 3px 7px; border: 1px solid transparent; border-radius: var(--radius-sm); background: none; color: inherit; font: inherit; font-size: 13.5px; font-variant-numeric: tabular-nums; line-height: 1.5; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; } .asin-copy-text:hover { border-color: #c7d7e5; background: #eef4fa; color: var(--c-primary-strong); } .asin-copy-text:focus-visible { outline: 2px solid #4f78a5; outline-offset: 1px; } @@ -3731,7 +3707,6 @@ .visually-hidden { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; white-space: nowrap; clip: rect(0 0 0 0); clip-path: inset(50%); } /* 菜单列表拖动排序 */ - #panel-columns .columns-table-scroll > table { min-width: 820px; } .columns-drag-tip { margin: -8px 0 14px; font-size: 13px; color: var(--c-text-2); } .column-drag-cell { width: 48px; } .column-drag-handle { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border: 1px solid transparent; border-radius: var(--radius-sm); background: none; color: #93a6b8; font-size: 15px; line-height: 1; cursor: grab; user-select: none; } @@ -3764,8 +3739,33 @@ .drawer-price-grid { grid-template-columns: minmax(0, 1fr); } } - #panel-query-asin .panel-box > .form-row, #panel-skip-price-asin .panel-box > .form-row { display: grid; grid-template-columns: minmax(150px, 0.8fr) minmax(180px, 1fr) minmax(180px, 1fr) auto auto; gap: 12px; align-items: end; } - #panel-query-asin .panel-box > .form-row .form-group, #panel-skip-price-asin .panel-box > .form-row .form-group { min-width: 0 !important; } + /* 店铺列表筛选行:显式固定轨道(与左侧去重筛选 .dedupe-filter-row 同模式)。 + 禁止 auto-fit 与 auto 混用——那会导致整条 grid-template-columns 声明被判无效, + display:grid 退化为单列隐式轨道,字段/按钮全部竖排占满整行(即"筛选表单太大"的根因) */ + #panel-query-asin .panel-box > .form-row { display: grid; grid-template-columns: minmax(140px, 1fr) minmax(160px, 1.1fr) minmax(110px, 0.7fr) minmax(160px, 1.1fr) auto auto; gap: 12px; align-items: end; } + #panel-skip-price-asin .panel-box > .form-row { display: grid; grid-template-columns: minmax(120px, 0.9fr) minmax(130px, 1fr) minmax(90px, 0.6fr) minmax(130px, 1fr) minmax(150px, 1.1fr) auto auto; gap: 12px; align-items: end; } + #panel-query-asin .panel-box > .form-row .form-group, #panel-skip-price-asin .panel-box > .form-row .form-group { min-width: 0; } + #panel-query-asin .panel-box > .form-row .btn, #panel-skip-price-asin .panel-box > .form-row .btn { justify-self: start; } + + /* 新增 ASIN 表单行:分组/店铺名/国家/ASIN 字段 + 按钮同一行、同一底端基线。 + 轨道 min ≥ 组内自然宽(含「管理分组」等附属按钮),否则按钮挤出轨道挤乱间距 */ + #panel-query-asin .form-box > .form-row:not([style]), #panel-skip-price-asin .form-box > .form-row:not([style]) { display: grid; grid-template-columns: minmax(190px, 1.05fr) minmax(220px, 1.2fr) minmax(150px, 0.85fr) minmax(300px, 1.55fr) auto; gap: 12px; align-items: end; } + #panel-query-asin .form-box > .form-row:not([style]) .form-group, #panel-skip-price-asin .form-box > .form-row:not([style]) .form-group { min-width: 0; width: auto; } + /* 新增按钮与输入框底端对齐(各组都是 label+输入框 等高结构,align-items:end 即对齐) */ + #panel-query-asin .form-box > .form-row:not([style]) .btn, #panel-skip-price-asin .form-box > .form-row:not([style]) .btn { align-self: flex-end; margin: 0; white-space: nowrap; } + /* 输入框 + 附属按钮组合:允许在 grid 轨道内收缩(内联 min-width 会撑破轨道) */ + #panel-query-asin .form-box > .form-row:not([style]) .field-with-action, #panel-skip-price-asin .form-box > .form-row:not([style]) .field-with-action { min-width: 0; } + #panel-query-asin .form-box > .form-row:not([style]) .field-with-action > input, #panel-skip-price-asin .form-box > .form-row:not([style]) .field-with-action > input, + #panel-query-asin .form-box > .form-row:not([style]) .field-with-action > select, #panel-skip-price-asin .form-box > .form-row:not([style]) .field-with-action > select { min-width: 0; } + + @media (max-width: 1400px) { + /* 轨道最小值之和超出内容宽度时折两列(同 .dedupe-filter-row 的窄屏策略),ASIN 组与按钮跨整行 */ + #panel-query-asin .panel-box > .form-row, #panel-skip-price-asin .panel-box > .form-row { grid-template-columns: repeat(2, minmax(150px, 1fr)); } + #panel-query-asin .form-box > .form-row:not([style]), #panel-skip-price-asin .form-box > .form-row:not([style]) { grid-template-columns: repeat(2, minmax(190px, 1fr)); } + #panel-query-asin .form-box > .form-row:not([style]) .form-group:nth-child(4), #panel-skip-price-asin .form-box > .form-row:not([style]) .form-group:nth-child(4) { grid-column: 1 / -1; } + #panel-query-asin .panel-box > .form-row .btn, #panel-skip-price-asin .panel-box > .form-row .btn, + #panel-query-asin .form-box > .form-row:not([style]) .btn, #panel-skip-price-asin .form-box > .form-row:not([style]) .btn { grid-column: 1 / -1; justify-self: start; } + } .category-table-scroll .category-path-value { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .category-table-scroll .category-path { display: none; } @@ -3812,7 +3812,8 @@ .admin-content { padding: 18px 14px 32px; } .table-scroll { border-radius: 9px; } .shop-manage-table-scroll > table, .category-table-scroll > table, .query-asin-table-scroll > table, .skip-price-asin-table-scroll > table { min-width: 920px; } - #panel-query-asin .panel-box > .form-row, #panel-skip-price-asin .panel-box > .form-row { grid-template-columns: minmax(0, 1fr); align-items: stretch; } + #panel-query-asin .panel-box > .form-row, #panel-skip-price-asin .panel-box > .form-row, + #panel-query-asin .form-box > .form-row:not([style]), #panel-skip-price-asin .form-box > .form-row:not([style]) { grid-template-columns: minmax(0, 1fr); align-items: stretch; } .skip-price-entry { grid-template-columns: 56px minmax(0, 1fr); } .skip-price-entry .skip-price-minimum-input { grid-column: 2; } } @@ -3822,7 +3823,7 @@ #panel-users .user-table-scroll > table { min-width: 860px; } #panel-dedupe-total-data .dedupe-table-scroll > table { min-width: 900px; } #panel-invalid-asin-data .invalid-asin-table-scroll > table { min-width: 760px; } - #panel-columns .columns-table-scroll > table { min-width: 980px; } + #panel-columns .columns-table-scroll > table { min-width: 820px; } #panel-version .version-table-scroll > table { min-width: 760px; } #panel-digital-human-version .digital-human-version-table-scroll > table { min-width: 1120px; } #panel-history .history-table-scroll > table { min-width: 820px; } @@ -3832,7 +3833,7 @@ #panel-users .user-table-scroll > table { min-width: 720px; } #panel-dedupe-total-data .dedupe-table-scroll > table { min-width: 860px; } #panel-invalid-asin-data .invalid-asin-table-scroll > table { min-width: 720px; } - #panel-columns .columns-table-scroll > table { min-width: 900px; } + #panel-columns .columns-table-scroll > table { min-width: 760px; } #panel-version .version-table-scroll > table { min-width: 700px; } #panel-digital-human-version .digital-human-version-table-scroll > table { min-width: 980px; } #panel-history .history-table-scroll > table { min-width: 760px; } @@ -3959,6 +3960,7 @@