task-116: 后台管理新增/导入表单弹窗化 + 分组管理独立菜单与UI优化 + 轻量进度端点
Build Backend JAR / build (push) Has been cancelled
Build Backend JAR / build (push) Has been cancelled
- 后台管理页(admin)所有面板的新增/导入表单改为弹窗操作,原有字段 ID 全部保留、提交逻辑不变;导入删除入口不再触发二次确认拦截 - 分组管理升级为独立菜单(V102 + schema initializer),移除 5 个面板内的管理分组按钮;分组列表改为蓝白主题、增加权限分组横幅 - Python 侧 group-manage 权限守卫(_ensure_backend_menu_access 补充 group-manage) - 引入 V101(biz_task_file_job 复合索引)+ 新增 TaskProgressLight/TaskFileJob 轻量端点与进度聚合作 - 前端 progress-light / page-separated-loads / dispatch-guard 共享模块及单元测试
This commit is contained in:
+10
@@ -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<TaskProgressLightBatchVo> 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<Void> activate(
|
||||
|
||||
+23
-266
@@ -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<Long> 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<String> 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<AppearancePatentParsedRowVo> receivedRows,
|
||||
Map<String, AppearancePatentResultRowDto> 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<String> 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<AppearancePatentParsedRowVo> 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<AppearancePatentParsedRowVo> rowsToWrite,
|
||||
Map<String, AppearancePatentResultRowDto> resultMap) {
|
||||
Sheet sheet = workbook.createSheet("原因");
|
||||
Row header = sheet.createRow(0);
|
||||
List<String> 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<String> writtenAsins = new LinkedHashSet<>();
|
||||
int rowIndex = 1;
|
||||
for (AppearancePatentParsedRowVo parsedRow : rowsToWrite == null ? List.<AppearancePatentParsedRowVo>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<String, AppearancePatentResultRowDto> 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<String, Integer> 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<String, String> 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<String, String> 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('.');
|
||||
|
||||
+225
@@ -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<String, Integer> headerMap = buildHeaderMap(header, formatter);
|
||||
List<String> 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<AppearanceExcelRow> 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<String, Integer> buildHeaderMap(Row header, DataFormatter formatter) {
|
||||
Map<String, Integer> 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<String> readHeaders(Row header, DataFormatter formatter) {
|
||||
List<String> 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<String, String> readRowValues(Row row, List<String> headers, DataFormatter formatter) {
|
||||
Map<String, String> 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<String, Integer> map, String... names) {
|
||||
int idx = findOptionalHeader(map, names);
|
||||
if (idx < 0) {
|
||||
throw new BusinessException("缺少必要表头: " + String.join("/", names));
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
private int findOptionalHeader(Map<String, Integer> map, String... names) {
|
||||
for (Map.Entry<String, Integer> entry : map.entrySet()) {
|
||||
for (String name : names) {
|
||||
if (entry.getKey().contains(name.toLowerCase(Locale.ROOT))) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int findOptionalHeaderExact(Map<String, Integer> map, String... names) {
|
||||
for (Map.Entry<String, Integer> 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<String> headers, List<AppearanceExcelRow> rows) {
|
||||
|
||||
public ParsedSheet {
|
||||
headers = headers == null ? List.of() : new ArrayList<>(headers);
|
||||
rows = rows == null ? List.of() : new ArrayList<>(rows);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> headers() {
|
||||
return java.util.Collections.unmodifiableList(headers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AppearanceExcelRow> 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<String, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+161
@@ -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<TaskProgressSnapshotEntity, Integer> extractSnapshotDisplayPercent;
|
||||
|
||||
public AppearancePatentHistoryAssembler(TaskProgressSnapshotService taskProgressSnapshotService,
|
||||
ProgressPercentCalculator calculateDisplayProgressPercent,
|
||||
Function<TaskProgressSnapshotEntity, Integer> 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();
|
||||
}
|
||||
}
|
||||
+31
@@ -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();
|
||||
}
|
||||
}
|
||||
+265
@@ -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<String> RESULT_HEADERS = List.of(
|
||||
"id",
|
||||
"asin",
|
||||
"国家",
|
||||
"卖家名称",
|
||||
"品牌",
|
||||
"价格",
|
||||
"标题维度(商标)",
|
||||
"外观维度(外观设计专利)",
|
||||
"结论",
|
||||
"状态"
|
||||
);
|
||||
|
||||
private AppearancePatentSheetBuilder() {
|
||||
}
|
||||
|
||||
public static void buildResultSheet(Workbook workbook, List<AppearancePatentParsedRowVo> rows,
|
||||
Map<String, AppearancePatentResultRowDto> resultMap) {
|
||||
buildResultSheet(workbook, rows, resultMap, null);
|
||||
}
|
||||
|
||||
public static void buildResultSheet(Workbook workbook, List<AppearancePatentParsedRowVo> rows,
|
||||
Map<String, AppearancePatentResultRowDto> resultMap,
|
||||
java.util.function.Function<AppearancePatentParsedRowVo, AppearancePatentResultRowDto> findResultRowFn) {
|
||||
java.util.function.Function<AppearancePatentParsedRowVo, AppearancePatentResultRowDto> 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<String> 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<AppearancePatentParsedRowVo> 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<AppearancePatentParsedRowVo> rowsToWrite,
|
||||
Map<String, AppearancePatentResultRowDto> resultMap,
|
||||
java.util.function.Function<AppearancePatentParsedRowVo, AppearancePatentResultRowDto> finder) {
|
||||
Sheet sheet = workbook.createSheet("原因");
|
||||
Row header = sheet.createRow(0);
|
||||
List<String> 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<String> writtenAsins = new LinkedHashSet<>();
|
||||
int rowIndex = 1;
|
||||
for (AppearancePatentParsedRowVo parsedRow : rowsToWrite == null ? List.<AppearancePatentParsedRowVo>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<String, AppearancePatentResultRowDto> 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<String, String> 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<String, String> 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+", " ");
|
||||
}
|
||||
}
|
||||
+10
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(service.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@DeleteMapping("/tasks/{taskId}")
|
||||
@Operation(summary = "删除任务", description = "删除任务及其明细行、关联结果记录。")
|
||||
public ApiResponse<Void> deleteTask(
|
||||
|
||||
+7
@@ -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<Long> 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;
|
||||
|
||||
+10
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(deleteBrandRunService.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@GetMapping("/tasks/{taskId}/deletion-status")
|
||||
@Operation(summary = "获取删除品牌任务删除状态", description = "供 Python/插件按 taskId 查询该删除品牌任务是否已被前端删空。")
|
||||
public ApiResponse<DeleteBrandTaskDeletionStatusVo> getTaskDeletionStatus(
|
||||
|
||||
+7
@@ -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<Long> 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);
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+5
-1
@@ -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<InvalidAsinDataEntity> query = new LambdaQueryWrapper<InvalidAsinDataEntity>()
|
||||
.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);
|
||||
|
||||
+10
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(patrolDeleteTaskService.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(summary = "创建巡店删除任务", description = "根据已匹配的店铺创建任务和占位结果记录,后续由 Python 端处理并回传结果。")
|
||||
public ApiResponse<PatrolDeleteCreateTaskVo> createTask(
|
||||
|
||||
+7
@@ -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<Long> 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<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId));
|
||||
|
||||
+1
@@ -50,6 +50,7 @@ public class PermissionMenuSchemaInitializer {
|
||||
private static final List<DefaultAdminMenu> 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),
|
||||
|
||||
+10
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(priceTrackTaskService.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(
|
||||
summary = "Python 回传处理结果",
|
||||
|
||||
+7
@@ -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<Long> 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<Long, FileTaskEntity> cachedTasks = priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId));
|
||||
|
||||
+10
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(productRiskTaskService.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(
|
||||
summary = "Python 回传处理结果",
|
||||
|
||||
+7
@@ -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<Long> 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<Long, FileTaskEntity> cachedTasks = productRiskTaskCacheService.getTaskCacheBatch(List.of(taskId));
|
||||
|
||||
+11
@@ -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<TaskProgressLightBatchVo> progressLight(
|
||||
@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(publishTaskService.progressLight(request.getUserId(), request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(
|
||||
summary = "Python 按文件分片回传上架结果",
|
||||
|
||||
+32
-11
@@ -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<Long> 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<String, Object> row : fileTaskMapper.selectMaps(new QueryWrapper<FileTaskEntity>()
|
||||
.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<FileTaskEntity>()
|
||||
.eq(FileTaskEntity::getModuleType, MODULE_TYPE)
|
||||
.eq(FileTaskEntity::getUserId, userId)
|
||||
.eq(FileTaskEntity::getStatus, status)), 0L);
|
||||
}
|
||||
|
||||
private List<Long> normalizeTaskIds(List<Long> taskIds) {
|
||||
if (taskIds == null) {
|
||||
return List.of();
|
||||
|
||||
+10
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(queryAsinTaskService.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(summary = "创建查询 ASIN 任务", description = "根据已匹配的店铺创建任务和占位结果记录,并把后台维护的整张 ASIN 表数据随店铺项返回给 Python 端。")
|
||||
public ApiResponse<QueryAsinCreateTaskVo> createTask(
|
||||
|
||||
+7
@@ -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<Long> 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<Long, FileTaskEntity> cachedTasks = taskCacheService.getTaskCacheBatch(List.of(taskId));
|
||||
|
||||
+10
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(taskService.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(
|
||||
summary = "创建店铺数据抓取任务",
|
||||
|
||||
+25
-112
@@ -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<String> COUNTRIES = List.of("UK", "DE", "FR", "ES", "IT");
|
||||
static final List<String> SHEETS = List.of("英国", "德国", "法国", "西班牙", "意大利");
|
||||
static final List<String> LEGACY_HEADERS = List.of("日期", "ASIN", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价");
|
||||
static final List<String> HEADERS_WITHOUT_BRAND = List.of("日期", "ASIN", "商品图片", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价");
|
||||
static final List<String> HEADERS = List.of("日期", "ASIN", "商品图片", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价", "品牌");
|
||||
/** 常量已抽取到 ShopDataCrawlSheetBuilder,此处保留别名供既有调用方/测试引用。 */
|
||||
static final List<String> COUNTRIES = ShopDataCrawlSheetBuilder.COUNTRIES;
|
||||
static final List<String> SHEETS = ShopDataCrawlSheetBuilder.SHEETS;
|
||||
static final List<String> LEGACY_HEADERS = ShopDataCrawlSheetBuilder.LEGACY_HEADERS;
|
||||
static final List<String> HEADERS_WITHOUT_BRAND = ShopDataCrawlSheetBuilder.HEADERS_WITHOUT_BRAND;
|
||||
static final List<String> 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<ShopDataCrawlRowDto> rows,
|
||||
BoundedImageCache imageCache,
|
||||
Map<String, Integer> 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.<ShopDataCrawlRowDto>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<String> 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<String, Integer> 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.<ShopDataCrawlRowDto>of() : rows) {
|
||||
@@ -273,43 +231,13 @@ public class ShopDataCrawlExcelAssemblyService {
|
||||
CellStyle[] styles,
|
||||
BoundedImageCache imageCache,
|
||||
Map<String, Integer> 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<String, List<ShopDataCrawlRowDto>> rowsByCountry(List<ShopDataCrawlResultItemVo> items) {
|
||||
Map<String, List<ShopDataCrawlRowDto>> 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<String, Integer> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+23
-83
@@ -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<Long> 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<String> 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<ShopDataCrawlTaskItemDto> dedupeItems(List<ShopDataCrawlTaskItemDto> 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) {
|
||||
|
||||
+70
@@ -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();
|
||||
}
|
||||
}
|
||||
+101
@@ -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());
|
||||
}
|
||||
}
|
||||
+177
@@ -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<String> COUNTRIES = List.of("UK", "DE", "FR", "ES", "IT");
|
||||
public static final List<String> SHEETS = List.of("英国", "德国", "法国", "西班牙", "意大利");
|
||||
public static final List<String> LEGACY_HEADERS = List.of("日期", "ASIN", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价");
|
||||
public static final List<String> HEADERS_WITHOUT_BRAND = List.of("日期", "ASIN", "商品图片", "库存销量", "销售排名", "页面浏览量", "售出件数", "价格", "推荐报价");
|
||||
public static final List<String> 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<String> 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<String, List<ShopDataCrawlRowDto>> rowsByCountry(List<ShopDataCrawlResultItemVo> items) {
|
||||
Map<String, List<ShopDataCrawlRowDto>> 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;
|
||||
}
|
||||
}
|
||||
+4
-1
@@ -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))
|
||||
|
||||
+13
-1
@@ -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))
|
||||
|
||||
+36
-12
@@ -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<String, QueryAsinImportProgressVo> 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<QueryAsinEntity> 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<QueryAsinEntity> 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<QueryAsinEntity> rows = listFilteredRows(groupId, shopName, asin, country, operatorId, superAdmin, null, null);
|
||||
Map<Long, String> 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<QueryAsinEntity> listFilteredRows(Long groupId, String shopName, String asin,
|
||||
private List<QueryAsinEntity> listFilteredRows(Long groupId, String shopName, String asin, String country,
|
||||
Long operatorId, boolean superAdmin,
|
||||
Long offset, Long limit) {
|
||||
LambdaQueryWrapper<QueryAsinEntity> query = buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin);
|
||||
LambdaQueryWrapper<QueryAsinEntity> 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<QueryAsinEntity> buildFilterQuery(Long groupId, String shopName, String asin,
|
||||
private LambdaQueryWrapper<QueryAsinEntity> 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<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
|
||||
LambdaQueryWrapper<QueryAsinEntity> query = new LambdaQueryWrapper<QueryAsinEntity>()
|
||||
.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<QueryAsinEntity, ?> 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<QueryAsinEntity, ?> 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 = {
|
||||
|
||||
+99
-11
@@ -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<String, CachedSkipAsinLookup> 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<SkipPriceAsinEntity> 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<SkipPriceAsinEntity> 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<SkipPriceAsinEntity> rows = listFilteredRows(groupId, shopName, asin, country,
|
||||
minimumPriceFrom, minimumPriceTo, operatorId, superAdmin, null, null);
|
||||
Map<Long, String> 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<SkipPriceAsinEntity> listFilteredRows(Long groupId, String shopName, String asin,
|
||||
private List<SkipPriceAsinEntity> listFilteredRows(Long groupId, String shopName, String asin, String country,
|
||||
BigDecimal minimumPriceFrom, BigDecimal minimumPriceTo,
|
||||
Long operatorId, boolean superAdmin,
|
||||
Long offset, Long limit) {
|
||||
LambdaQueryWrapper<SkipPriceAsinEntity> query = buildFilterQuery(groupId, shopName, asin, operatorId, superAdmin);
|
||||
LambdaQueryWrapper<SkipPriceAsinEntity> 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<SkipPriceAsinEntity> buildFilterQuery(Long groupId, String shopName, String asin,
|
||||
private LambdaQueryWrapper<SkipPriceAsinEntity> 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<Long> accessibleGroupIds = superAdmin ? Set.of() : shopManageGroupService.listAccessibleGroupIds(operatorId, false);
|
||||
LambdaQueryWrapper<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||
.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<SkipPriceAsinEntity, ?> countryColumn = skipPriceAsinCountryColumn(safeCountry);
|
||||
query.isNotNull(countryColumn).ne(countryColumn, "");
|
||||
}
|
||||
boolean hasMinimumPriceFrom = minimumPriceFrom != null;
|
||||
boolean hasMinimumPriceTo = minimumPriceTo != null;
|
||||
if (hasMinimumPriceFrom || hasMinimumPriceTo) {
|
||||
if (safeCountry.isEmpty()) {
|
||||
List<SFunction<SkipPriceAsinEntity, ?>> priceColumns = skipPriceAsinMinimumPriceColumns();
|
||||
query.and(wrapper -> {
|
||||
boolean first = true;
|
||||
for (SFunction<SkipPriceAsinEntity, ?> 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<SkipPriceAsinEntity, ?> 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<SkipPriceAsinEntity, ?> 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<SkipPriceAsinEntity, ?> 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<SFunction<SkipPriceAsinEntity, ?>> 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<SkipPriceAsinEntity> query = new LambdaQueryWrapper<SkipPriceAsinEntity>()
|
||||
.eq(SkipPriceAsinEntity::getGroupId, groupId)
|
||||
|
||||
+10
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(shopMatchTaskService.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/result")
|
||||
@Operation(summary = "提交匹配结果", description = "Python 可多次分批回传结果;后端会组装文件并在超时场景自动补偿收尾。")
|
||||
public ApiResponse<Void> submitResult(
|
||||
|
||||
+7
@@ -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<Long> 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<Long, FileTaskEntity> cachedTasks = shopMatchTaskCacheService.getTaskCacheBatch(List.of(taskId));
|
||||
|
||||
+10
@@ -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<SimilarAsinTaskLightBatchVo> progressLight(@Valid @RequestBody SimilarAsinTaskLightRequest request) {
|
||||
return ApiResponse.success(service.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks/{taskId}/activate")
|
||||
@Operation(summary = "激活任务", description = "前端手动推送 Python 队列成功后调用,将任务从 PENDING 改为 RUNNING。")
|
||||
public ApiResponse<Void> activate(
|
||||
|
||||
+19
@@ -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<Long> taskIds;
|
||||
}
|
||||
+16
@@ -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<SimilarAsinTaskLightVo> items = new ArrayList<>();
|
||||
@Schema(description = "未找到或不属于相似ASIN检测模块的任务 ID 列表。", example = "[99999]")
|
||||
private List<Long> missingTaskIds = new ArrayList<>();
|
||||
}
|
||||
+23
@@ -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;
|
||||
}
|
||||
+84
-447
@@ -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<SimilarAsinParsedGroupVo> groups = buildParsedGroups(allRows);
|
||||
List<SimilarAsinParsedGroupVo> 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<Long> taskIds) {
|
||||
SimilarAsinTaskLightBatchVo vo = new SimilarAsinTaskLightBatchVo();
|
||||
List<Long> 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<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
|
||||
for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.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<Long, TaskFileJobEntity> 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<String, List<SimilarAsinParsedRowVo>> 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<SimilarAsinParsedGroupVo> buildParsedGroups(List<SimilarAsinParsedRowVo> rows) {
|
||||
List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
||||
int cursor = 0;
|
||||
for (List<SimilarAsinParsedRowVo> 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<SimilarAsinParsedGroupVo> buildResponsePreviewGroups(List<SimilarAsinParsedGroupVo> groups, List<SimilarAsinParsedRowVo> allRows) {
|
||||
if (groups == null || groups.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -1887,18 +1911,6 @@ public class SimilarAsinTaskService {
|
||||
return vo;
|
||||
}
|
||||
|
||||
private Map<String, List<SimilarAsinParsedRowVo>> groupRowsByBaseId(List<SimilarAsinParsedRowVo> rows) {
|
||||
Map<String, List<SimilarAsinParsedRowVo>> 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<SimilarAsinResultRowDto> 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<String, Integer> headerMap = buildHeaderMap(header, formatter);
|
||||
List<String> 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<String> 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<String, Integer> buildHeaderMap(Row header, DataFormatter formatter) {
|
||||
Map<String, Integer> 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<String> readHeaders(Row header, DataFormatter formatter) {
|
||||
List<String> 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<String, String> readRowValues(Row row, List<String> headers, DataFormatter formatter) {
|
||||
Map<String, String> 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<String, Integer> map, String... names) {
|
||||
int idx = findOptionalHeader(map, names);
|
||||
if (idx < 0) {
|
||||
throw new BusinessException("缺少必要表头: " + String.join("/", names));
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
private int findOptionalHeader(Map<String, Integer> map, String... names) {
|
||||
for (Map.Entry<String, Integer> entry : map.entrySet()) {
|
||||
for (String name : names) {
|
||||
if (entry.getKey().contains(name.toLowerCase(Locale.ROOT))) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int findOptionalHeaderExact(Map<String, Integer> map, String... names) {
|
||||
for (Map.Entry<String, Integer> 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<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||
.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<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||
.eq(TaskChunkEntity::getTaskId, taskId)
|
||||
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE));
|
||||
if (chunks == null || chunks.isEmpty()) {
|
||||
return new PythonUploadProgress(0, 0, "分片");
|
||||
}
|
||||
Map<String, Integer> receivedByScope = new LinkedHashMap<>();
|
||||
Map<String, Integer> 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<String, Integer> 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<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||
.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();
|
||||
}
|
||||
|
||||
+245
@@ -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<String, Integer> headerMap = buildHeaderMap(header, formatter);
|
||||
List<String> 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<SimilarAsinExcelRow> 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<String, Integer> buildHeaderMap(Row header, DataFormatter formatter) {
|
||||
Map<String, Integer> 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<String> readHeaders(Row header, DataFormatter formatter) {
|
||||
List<String> 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<String, String> readRowValues(Row row, List<String> headers, DataFormatter formatter, int maxFieldLength) {
|
||||
Map<String, String> 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<String, Integer> map, String... names) {
|
||||
int idx = findOptionalHeader(map, names);
|
||||
if (idx < 0) {
|
||||
throw new BusinessException("缺少必要表头: " + String.join("/", names));
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
private int findOptionalHeader(Map<String, Integer> map, String... names) {
|
||||
for (Map.Entry<String, Integer> entry : map.entrySet()) {
|
||||
for (String name : names) {
|
||||
if (entry.getKey().contains(name.toLowerCase(Locale.ROOT))) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int findOptionalHeaderExact(Map<String, Integer> map, String... names) {
|
||||
for (Map.Entry<String, Integer> 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<String> headers, List<SimilarAsinExcelRow> rows) {
|
||||
|
||||
public ParsedSheet {
|
||||
headers = headers == null ? List.of() : new ArrayList<>(headers);
|
||||
rows = rows == null ? List.of() : new ArrayList<>(rows);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> headers() {
|
||||
return java.util.Collections.unmodifiableList(headers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SimilarAsinExcelRow> 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<String, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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<SimilarAsinParsedGroupVo> convert(List<SimilarAsinParsedRowVo> rows) {
|
||||
List<SimilarAsinParsedGroupVo> groups = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return groups;
|
||||
}
|
||||
int cursor = 0;
|
||||
for (List<SimilarAsinParsedRowVo> 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<String, List<SimilarAsinParsedRowVo>> groupRowsByBaseId(List<SimilarAsinParsedRowVo> rows) {
|
||||
Map<String, List<SimilarAsinParsedRowVo>> 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();
|
||||
}
|
||||
}
|
||||
+757
@@ -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<SimilarAsinHistoryItemVo> buildHistoryItems(List<FileResultEntity> rows,
|
||||
Map<Long, FileTaskEntity> taskMap,
|
||||
Map<Long, TaskFileJobEntity> jobMap) {
|
||||
List<SimilarAsinHistoryItemVo> 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<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> resolveAllRows(SimilarAsinParsedPayloadDto payload) {
|
||||
if (payload == null) {
|
||||
return List.of();
|
||||
}
|
||||
if (containsGroupRefs(payload)) {
|
||||
// 新格式:分组携带索引引用,信任引用展开结果(越界/非法区间安全跳过)
|
||||
return expandGroupRefs(payload);
|
||||
}
|
||||
List<SimilarAsinParsedRowVo> 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<SimilarAsinParsedRowVo> expandGroupRefs(SimilarAsinParsedPayloadDto payload) {
|
||||
if (payload == null || payload.getGroups() == null || payload.getGroups().isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<SimilarAsinParsedRowVo> rows = payload.getItems();
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
rows = payload.getAllItems();
|
||||
}
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<SimilarAsinParsedRowVo> 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<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||
.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<String, SimilarAsinResultRowDto> readChunkRows(TaskChunkEntity chunk) {
|
||||
Map<String, SimilarAsinResultRowDto> 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<TaskScopeStateEntity>()
|
||||
.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<TaskScopeStateEntity>()
|
||||
.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<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||
.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<TaskChunkEntity> chunks = taskChunkMapper.selectList(new LambdaQueryWrapper<TaskChunkEntity>()
|
||||
.eq(TaskChunkEntity::getTaskId, taskId)
|
||||
.eq(TaskChunkEntity::getModuleType, MODULE_TYPE));
|
||||
if (chunks == null || chunks.isEmpty()) {
|
||||
return new PythonUploadProgress(0, 0, "分片");
|
||||
}
|
||||
Map<String, Integer> receivedByScope = new LinkedHashMap<>();
|
||||
Map<String, Integer> 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<String, Integer> 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<TaskChunkEntity>()
|
||||
.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<TaskChunkEntity>()
|
||||
.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<TaskScopeStateEntity>()
|
||||
.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<TaskScopeStateEntity>()
|
||||
.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<TaskScopeStateEntity> states = taskScopeStateMapper.selectList(new LambdaQueryWrapper<TaskScopeStateEntity>()
|
||||
.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) {
|
||||
}
|
||||
}
|
||||
+22
@@ -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);
|
||||
}
|
||||
}
|
||||
+66
@@ -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<RowError> validate(List<SimilarAsinParsedRowVo> rows) {
|
||||
List<RowError> errors = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return errors;
|
||||
}
|
||||
Set<String> 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;
|
||||
}
|
||||
}
|
||||
+112
@@ -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<String> 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<SimilarAsinParsedRowVo> 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<String, String> 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();
|
||||
}
|
||||
}
|
||||
+27
@@ -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<Long> taskIds = new ArrayList<>();
|
||||
@Schema(description = "当前用户 ID(可省略;传入时仅返回该用户的任务,owner-scoped 模块使用)。", example = "1")
|
||||
private Long userId;
|
||||
}
|
||||
+19
@@ -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<TaskProgressLightVo> items = new ArrayList<>();
|
||||
@Schema(description = "查询不到的任务 ID 列表。")
|
||||
private List<Long> missingTaskIds = new ArrayList<>();
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+22
@@ -531,6 +531,28 @@ public class TaskFileJobService {
|
||||
return jobMap;
|
||||
}
|
||||
|
||||
/** 按任务 ID 集合一次 IN 查询 ASSEMBLE_RESULT Job,返回 taskId→Job 的 Map(轻量进度接口用)。 */
|
||||
public Map<Long, TaskFileJobEntity> findAssembleJobsByTaskIds(String moduleType, List<Long> taskIds) {
|
||||
List<Long> 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<TaskFileJobEntity> jobs = taskFileJobMapper.selectList(new LambdaQueryWrapper<TaskFileJobEntity>()
|
||||
.eq(TaskFileJobEntity::getModuleType, moduleType)
|
||||
.eq(TaskFileJobEntity::getJobType, JOB_TYPE_ASSEMBLE_RESULT)
|
||||
.in(TaskFileJobEntity::getTaskId, normalizedIds));
|
||||
Map<Long, TaskFileJobEntity> 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;
|
||||
|
||||
+76
@@ -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<Long> taskIds) {
|
||||
TaskProgressLightBatchVo vo = new TaskProgressLightBatchVo();
|
||||
List<Long> 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<FileTaskEntity> query = new LambdaQueryWrapper<FileTaskEntity>()
|
||||
.select(FileTaskEntity::getId,
|
||||
FileTaskEntity::getStatus,
|
||||
FileTaskEntity::getUpdatedAt)
|
||||
.in(FileTaskEntity::getId, normalizedIds);
|
||||
if (userId != null) {
|
||||
query.eq(FileTaskEntity::getUserId, userId);
|
||||
}
|
||||
Map<Long, FileTaskEntity> taskMap = new LinkedHashMap<>();
|
||||
for (FileTaskEntity task : fileTaskMapper.selectList(query)) {
|
||||
if (task != null && moduleType.equals(task.getModuleType())) {
|
||||
taskMap.put(task.getId(), task);
|
||||
}
|
||||
}
|
||||
Map<Long, TaskFileJobEntity> 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;
|
||||
}
|
||||
}
|
||||
+10
@@ -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<TaskProgressLightBatchVo> progressLight(@Valid @RequestBody TaskProgressLightRequest request) {
|
||||
return ApiResponse.success(withdrawTaskService.progressLight(request.getTaskIds()));
|
||||
}
|
||||
|
||||
@PostMapping("/tasks")
|
||||
@Operation(summary = "创建取款任务", description = "为一批已匹配店铺创建一个取款任务,写入任务记录和店铺结果占位记录,并返回 taskId 及初始店铺快照。")
|
||||
public ApiResponse<WithdrawCreateTaskVo> createTask(@Valid @RequestBody WithdrawCreateTaskRequest request) {
|
||||
|
||||
+7
@@ -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<Long> 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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user