diff --git a/app/amazon/detail_spider.py b/app/amazon/detail_spider.py index f3c445f..ab5027e 100644 --- a/app/amazon/detail_spider.py +++ b/app/amazon/detail_spider.py @@ -288,7 +288,7 @@ class SpiderTask: print(f"[{timestamp}] [PriceTask] [{level}] {message}") @staticmethod - def group_by_id_prefix(data): + def group_by_id_prefix(data): """ 根据每条数据的 id 字段进行分组: - 如果 id 为 "2_1",则取 "_" 前面的 "2" 作为分组 key @@ -304,10 +304,35 @@ class SpiderTask: item_id = str(item.get("id", "")) group_key = item_id.split("_")[0] grouped[group_key].append(item) - - return list(grouped.values()) - - def process_task(self, task_data: dict): + + return list(grouped.values()) + + @staticmethod + def normalize_groups(data): + groups = data.get("groups") + if isinstance(groups, list) and len(groups) > 0: + return groups + + rows = data.get("rows") or data.get("items") or [] + if not isinstance(rows, list) or len(rows) == 0: + return [] + + normalized = [] + for items in SpiderTask.group_by_id_prefix(rows): + first = items[0] if items else {} + item_id = str(first.get("id") or first.get("displayId") or "") + base_id = item_id.split("_")[0] if item_id else "" + normalized.append({ + "sourceFileKey": first.get("sourceFileKey", ""), + "sourceFilename": first.get("sourceFilename", ""), + "groupKey": first.get("groupKey") or base_id, + "baseId": first.get("baseId") or base_id, + "displayId": first.get("displayId") or item_id, + "items": items, + }) + return normalized + + def process_task(self, task_data: dict): """处理审批任务主入口 Args: @@ -316,7 +341,7 @@ class SpiderTask: try: data = task_data.get("data", {}) task_id = data.get("taskId") - groups = data.get("groups") + groups = self.normalize_groups(data) # 用于测试 limit = data.get("limit", None) @@ -328,7 +353,11 @@ class SpiderTask: self.log(f"开始处理爬取任务 {task_id},{len(groups)} 个任务") - from config import runing_task + if not groups: + self.log("appearance-patent groups/rows is empty, skip", "WARNING") + return + + from config import runing_task runing_task[task_id] = { "status": "running", "start_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), @@ -354,11 +383,12 @@ class SpiderTask: try: # 数据整理 # new_items = self.group_by_id_prefix(items) - result = [] - for gp_index,gp in enumerate(groups): - items = gp.get("items", []) - - for index,value in enumerate(items): + result = [] + for gp_index,gp in enumerate(groups): + items = gp.get("items", []) + return_data = {} + + for index,value in enumerate(items): return_data = { 'image_url': "", @@ -369,13 +399,15 @@ class SpiderTask: return_data = None for _ in range(max_retry): try: - return_data = chrome.run(country, asin) + return_data = chrome.run(country, asin) or {} self.log(f"抓取结果->{return_data}") break except Exception as e: if "与页面的连接已断开" in str(e): chrome = ChromeAmzone() - if return_data.get("image_url"): + if not isinstance(return_data, dict): + return_data = {} + if return_data.get("image_url"): break group_item = [ @@ -8381,4 +8413,4 @@ if __name__ == '__main__': ] # spide.process_task(task_data) res = SpiderTask.group_by_id_prefix(task_data) - print(res) \ No newline at end of file + print(res) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java index bbc92db..efa4362 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/client/AppearancePatentCozeClient.java @@ -60,14 +60,24 @@ public class AppearancePatentCozeClient { log.warn("[appearance-patent] coze batch fallback split size={} left={} right={} err={}", rows.size(), middle, rows.size() - middle, failureMessage(ex)); List merged = new ArrayList<>(rows.size()); - merged.addAll(inspectWithFallback(rows.subList(0, middle), prompt)); - merged.addAll(inspectWithFallback(rows.subList(middle, rows.size()), prompt)); + merged.addAll(inspectPartitionWithFailureFallback(rows.subList(0, middle), prompt)); + merged.addAll(inspectPartitionWithFailureFallback(rows.subList(middle, rows.size()), prompt)); return merged; } throw propagate(ex); } } + private List inspectPartitionWithFailureFallback(List rows, String prompt) { + try { + return inspectWithFallback(rows, prompt); + } catch (Exception ex) { + String failureMessage = failureMessage(ex); + log.warn("[appearance-patent] coze partition failed size={} err={}", rows.size(), failureMessage); + return rows.stream().map(this::copy).map(row -> markFailed(row, failureMessage)).toList(); + } + } + private List inspectSingleRowWithRetry(List rows, String prompt) throws Exception { AppearancePatentResultRowDto row = rows.getFirst(); PartialCozeResultException lastFailure = null; @@ -154,6 +164,9 @@ public class AppearancePatentCozeClient { body.put("workflow_id", properties.getCozeWorkflowId()); body.put("parameters", parameters); body.put("is_async", Boolean.TRUE); + log.info("[appearance-patent] coze request url={} body={}", + joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath()), + writeJson(body)); RestClient.RequestBodySpec request = restClient().post() .uri(joinUrl(properties.getCozeBaseUrl(), properties.getCozeWorkflowPath())) @@ -164,7 +177,11 @@ public class AppearancePatentCozeClient { request.body(body); return request.exchange((clientRequest, clientResponse) -> { byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); - return responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); + String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); + log.info("[appearance-patent] coze submit response status={} body={}", + clientResponse.getStatusCode(), + responseText); + return responseText; }); } @@ -180,7 +197,12 @@ public class AppearancePatentCozeClient { }) .exchange((clientRequest, clientResponse) -> { byte[] responseBytes = StreamUtils.copyToByteArray(clientResponse.getBody()); - return responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); + String responseText = responseBytes.length == 0 ? "" : new String(responseBytes, StandardCharsets.UTF_8); + log.info("[appearance-patent] coze history response executeId={} status={} body={}", + executeId, + clientResponse.getStatusCode(), + responseText); + return responseText; }); } @@ -245,10 +267,26 @@ public class AppearancePatentCozeClient { text(firstNonNull( firstNonNull(node.get("country"), node.get("site")), firstNonNull(itemNode.get("country"), itemNode.get("site")))), - text(node.get("title")), - text(node.get("appearance")), - text(firstNonNull(node.get("patent"), node.get("patent "))), - text(node.get("result")) + text(firstNonNull(node.get("title"), firstNonNull(itemNode.get("title"), itemNode.get("title_risk")))), + text(firstNonNull(node.get("appearance"), + firstNonNull(node.get("appearance_risk"), + firstNonNull(itemNode.get("appearance"), itemNode.get("appearance_risk"))))), + text(firstNonNull(firstNonNull(node.get("patent"), node.get("patent ")), + firstNonNull(node.get("patent_risk"), + firstNonNull(firstNonNull(itemNode.get("patent"), itemNode.get("patent ")), itemNode.get("patent_risk"))))), + text(firstNonNull(node.get("result"), + firstNonNull(node.get("conclusion"), + firstNonNull(itemNode.get("result"), itemNode.get("conclusion"))))), + text(firstNonNull(node.get("title_reason"), + firstNonNull(node.get("titleReason"), + firstNonNull(itemNode.get("title_reason"), itemNode.get("titleReason"))))), + text(firstNonNull(node.get("appearance_reason"), + firstNonNull(node.get("appearanceReason"), + firstNonNull(itemNode.get("appearance_reason"), itemNode.get("appearanceReason"))))), + text(firstNonNull(node.get("patent_reason"), + firstNonNull(firstNonNull(node.get("patentReason"), node.get("patent reason")), + firstNonNull(itemNode.get("patent_reason"), + firstNonNull(itemNode.get("patentReason"), itemNode.get("patent reason")))))) )); } } @@ -349,6 +387,9 @@ public class AppearancePatentCozeClient { row.setAppearanceRisk(result.appearance()); row.setPatentRisk(result.patent()); row.setConclusion(result.result()); + row.setTitleReason(result.titleReason()); + row.setAppearanceReason(result.appearanceReason()); + row.setPatentReason(result.patentReason()); } private RestClient restClient() { @@ -375,6 +416,9 @@ public class AppearancePatentCozeClient { row.setAppearanceRisk(source.getAppearanceRisk()); row.setPatentRisk(source.getPatentRisk()); row.setConclusion(source.getConclusion()); + row.setTitleReason(source.getTitleReason()); + row.setAppearanceReason(source.getAppearanceReason()); + row.setPatentReason(source.getPatentReason()); return row; } @@ -507,13 +551,32 @@ public class AppearancePatentCozeClient { return false; } for (JsonNode item : array) { - if (item.has("appearance") || item.has("patent") || item.has("patent ") || item.has("result")) { + JsonNode itemNode = resultItemNode(item); + if (hasResultFields(item) || hasResultFields(itemNode)) { return true; } } return false; } + private boolean hasResultFields(JsonNode item) { + return item != null + && (item.has("appearance") + || item.has("appearance_risk") + || item.has("patent") + || item.has("patent ") + || item.has("patent_risk") + || item.has("result") + || item.has("conclusion") + || item.has("title_reason") + || item.has("titleReason") + || item.has("appearance_reason") + || item.has("appearanceReason") + || item.has("patent_reason") + || item.has("patentReason") + || item.has("patent reason")); + } + private JsonNode parseJsonOrMissing(String value) { String normalized = normalize(value); if (!(normalized.startsWith("{") || normalized.startsWith("["))) { @@ -705,7 +768,10 @@ public class AppearancePatentCozeClient { String title, String appearance, String patent, - String result + String result, + String titleReason, + String appearanceReason, + String patentReason ) { } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java index 0eddd72..b4c705a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/controller/AppearancePatentController.java @@ -57,8 +57,10 @@ public class AppearancePatentController { @Operation(summary = "查询外观专利检测历史", description = "查询当前用户最近的外观专利检测历史记录,包含源文件名、任务状态、行数、错误信息和最终 xlsx 下载地址。") public ApiResponse history( @Parameter(description = "当前用户 ID,用于查询该用户自己的历史记录。", required = true, example = "1") - @RequestParam("user_id") Long userId) { - return ApiResponse.success(service.history(userId)); + @RequestParam("user_id") Long userId, + @Parameter(description = "history limit, default 50, max 100", example = "50") + @RequestParam(value = "limit", required = false, defaultValue = "50") Integer limit) { + return ApiResponse.success(service.history(userId, limit)); } @PostMapping("/tasks/progress/batch") diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java index 10d40a0..f9f43db 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/model/dto/AppearancePatentResultRowDto.java @@ -52,4 +52,16 @@ public class AppearancePatentResultRowDto { @Schema(description = "Java 调用 Coze 后生成的最终结论,对应最终 xlsx 的“结论”列。Python 回传请求中不要传该字段。", example = "未发现明显侵权风险", accessMode = Schema.AccessMode.READ_ONLY) private String conclusion; + + @JsonAlias({"title_reason", "titleReason"}) + @Schema(description = "Coze title reason", accessMode = Schema.AccessMode.READ_ONLY) + private String titleReason; + + @JsonAlias({"appearance_reason", "appearanceReason"}) + @Schema(description = "Coze appearance reason", accessMode = Schema.AccessMode.READ_ONLY) + private String appearanceReason; + + @JsonAlias({"patent_reason", "patentReason", "patent reason"}) + @Schema(description = "Coze patent reason", accessMode = Schema.AccessMode.READ_ONLY) + private String patentReason; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index 2b6f85b..7783bbe 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -238,22 +238,42 @@ public class AppearancePatentTaskService { return vo; } - public AppearancePatentHistoryVo history(Long userId) { + public AppearancePatentHistoryVo history(Long userId, Integer limit) { AppearancePatentHistoryVo vo = new AppearancePatentHistoryVo(); + int safeLimit = Math.max(1, Math.min(limit == null ? 50 : limit, 100)); List rows = fileResultMapper.selectList(new LambdaQueryWrapper() + .select(FileResultEntity::getId, + FileResultEntity::getTaskId, + FileResultEntity::getSourceFilename, + FileResultEntity::getResultFilename, + FileResultEntity::getResultFileUrl, + FileResultEntity::getRowCount, + FileResultEntity::getSuccess, + FileResultEntity::getErrorMessage, + FileResultEntity::getCreatedAt) .eq(FileResultEntity::getModuleType, MODULE_TYPE) .eq(FileResultEntity::getUserId, userId) .orderByDesc(FileResultEntity::getCreatedAt) - .last("limit 100")); + .last("limit " + safeLimit)); Map statusMap = new LinkedHashMap<>(); List taskIds = rows.stream().map(FileResultEntity::getTaskId).filter(Objects::nonNull).distinct().toList(); if (!taskIds.isEmpty()) { - for (FileTaskEntity task : fileTaskMapper.selectBatchIds(taskIds)) { + for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper() + .select(FileTaskEntity::getId, FileTaskEntity::getStatus) + .in(FileTaskEntity::getId, taskIds))) { statusMap.put(task.getId(), task.getStatus()); } } + Map jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, rows.stream() + .map(FileResultEntity::getId) + .filter(Objects::nonNull) + .toList()); for (FileResultEntity row : rows) { - vo.getItems().add(toHistoryItem(row, statusMap.get(row.getTaskId()))); + String taskStatus = statusMap.get(row.getTaskId()); + if (STATUS_PENDING.equals(taskStatus)) { + continue; + } + vo.getItems().add(toHistoryItem(row, taskStatus, jobMap.get(row.getId()))); } return vo; } @@ -268,11 +288,43 @@ public class AppearancePatentTaskService { return vo; } Map taskMap = new LinkedHashMap<>(); - for (FileTaskEntity task : fileTaskMapper.selectBatchIds(normalizedIds)) { + for (FileTaskEntity task : fileTaskMapper.selectList(new LambdaQueryWrapper() + .select(FileTaskEntity::getId, + FileTaskEntity::getTaskNo, + FileTaskEntity::getModuleType, + FileTaskEntity::getStatus, + FileTaskEntity::getErrorMessage, + FileTaskEntity::getCreatedAt, + FileTaskEntity::getUpdatedAt, + FileTaskEntity::getFinishedAt) + .in(FileTaskEntity::getId, normalizedIds))) { if (task != null && MODULE_TYPE.equals(task.getModuleType())) { taskMap.put(task.getId(), task); } } + List resultRows = fileResultMapper.selectList(new LambdaQueryWrapper() + .select(FileResultEntity::getId, + FileResultEntity::getTaskId, + FileResultEntity::getSourceFilename, + FileResultEntity::getResultFilename, + FileResultEntity::getResultFileUrl, + FileResultEntity::getRowCount, + FileResultEntity::getSuccess, + FileResultEntity::getErrorMessage, + FileResultEntity::getCreatedAt) + .eq(FileResultEntity::getModuleType, MODULE_TYPE) + .in(FileResultEntity::getTaskId, normalizedIds) + .orderByDesc(FileResultEntity::getCreatedAt)); + Map resultByTaskId = new LinkedHashMap<>(); + for (FileResultEntity row : resultRows) { + if (row.getTaskId() != null) { + resultByTaskId.putIfAbsent(row.getTaskId(), row); + } + } + Map jobMap = taskFileJobService.findAssembleJobsByResultIds(MODULE_TYPE, resultRows.stream() + .map(FileResultEntity::getId) + .filter(Objects::nonNull) + .toList()); for (Long taskId : normalizedIds) { FileTaskEntity task = taskMap.get(taskId); if (task == null) { @@ -281,6 +333,10 @@ public class AppearancePatentTaskService { } AppearancePatentTaskDetailVo detail = new AppearancePatentTaskDetailVo(); detail.setTask(toTaskItem(task)); + FileResultEntity resultRow = resultByTaskId.get(taskId); + if (resultRow != null) { + detail.getItems().add(toHistoryItem(resultRow, task.getStatus(), jobMap.get(resultRow.getId()))); + } vo.getItems().add(detail); } return vo; @@ -890,6 +946,9 @@ public class AppearancePatentTaskService { row.setAppearanceRisk(representative.getAppearanceRisk()); row.setPatentRisk(representative.getPatentRisk()); row.setConclusion(representative.getConclusion()); + row.setTitleReason(representative.getTitleReason()); + row.setAppearanceReason(representative.getAppearanceReason()); + row.setPatentReason(representative.getPatentReason()); row.setDone(representative.getDone()); return row; } @@ -1057,6 +1116,7 @@ public class AppearancePatentTaskService { row.createCell(col++).setCellValue(resultRow == null ? missingReason : userFacingCozeCellValue(resultRow, resultRow.getPatentRisk())); row.createCell(col).setCellValue(resultRow == null ? "未送检" : userFacingConclusion(resultRow)); } + writeReasonSheet(workbook, headerStyle, parsed, resultMap); workbook.write(fos); workbook.dispose(); } catch (Exception ex) { @@ -1064,6 +1124,52 @@ public class AppearancePatentTaskService { } } + private void writeReasonSheet(SXSSFWorkbook workbook, + CellStyle headerStyle, + AppearancePatentParsedPayloadDto parsed, + Map resultMap) { + Sheet sheet = workbook.createSheet("原因"); + Row header = sheet.createRow(0); + List headers = List.of("ASIN", "外观原因", "专利原因", "标题原因"); + for (int i = 0; i < headers.size(); i++) { + Cell cell = header.createCell(i); + cell.setCellValue(headers.get(i)); + cell.setCellStyle(headerStyle); + } + + Set writtenAsins = new LinkedHashSet<>(); + int rowIndex = 1; + for (AppearancePatentParsedRowVo parsedRow : parsed.getAllItems()) { + String asin = normalize(parsedRow.getAsin()).toUpperCase(Locale.ROOT); + if (asin.isBlank() || !writtenAsins.add(asin)) { + continue; + } + AppearancePatentResultRowDto resultRow = findResultRow(parsedRow, resultMap); + if (resultRow == null) { + resultRow = findResultRowByAsin(asin, resultMap); + } + Row row = sheet.createRow(rowIndex++); + row.createCell(0).setCellValue(asin); + row.createCell(1).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getAppearanceReason(), "")); + row.createCell(2).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getPatentReason(), "")); + row.createCell(3).setCellValue(resultRow == null ? "" : firstNonBlank(resultRow.getTitleReason(), "")); + rowIndex++; + } + } + + private AppearancePatentResultRowDto findResultRowByAsin(String asin, Map resultMap) { + String normalizedAsin = normalize(asin).toUpperCase(Locale.ROOT); + if (normalizedAsin.isBlank() || resultMap == null || resultMap.isEmpty()) { + return null; + } + for (AppearancePatentResultRowDto row : resultMap.values()) { + if (row != null && normalizedAsin.equals(normalize(row.getAsin()).toUpperCase(Locale.ROOT))) { + return row; + } + } + return null; + } + private ParsedWorkbook parseWorkbook(File input, AppearancePatentSourceFileDto source) { DataFormatter formatter = new DataFormatter(); try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) { @@ -1342,14 +1448,14 @@ public class AppearancePatentTaskService { return vo; } - private AppearancePatentHistoryItemVo toHistoryItem(FileResultEntity row, String taskStatus) { + private AppearancePatentHistoryItemVo toHistoryItem(FileResultEntity row, String taskStatus, 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); + attachFileJobState(vo, row, job); vo.setTaskStatus(taskStatus); vo.setSuccess(row.getSuccess() != null && row.getSuccess() == 1); vo.setError(row.getErrorMessage()); @@ -1358,8 +1464,7 @@ public class AppearancePatentTaskService { return vo; } - private void attachFileJobState(AppearancePatentHistoryItemVo vo, FileResultEntity row) { - TaskFileJobEntity job = taskFileJobService.findAssembleJob(row.getTaskId(), MODULE_TYPE, row.getId()); + 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); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java index 8365bf5..db3d2f0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/task/service/TaskFileJobService.java @@ -12,7 +12,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor @@ -190,6 +192,27 @@ public class TaskFileJobService { return findJob(taskId, moduleType, resultId, JOB_TYPE_ASSEMBLE_RESULT); } + public Map findAssembleJobsByResultIds(String moduleType, List resultIds) { + List normalizedIds = resultIds == null ? List.of() : resultIds.stream() + .filter(id -> id != null && id > 0) + .distinct() + .toList(); + if (normalizedIds.isEmpty() || moduleType == null || moduleType.isBlank()) { + return Map.of(); + } + List jobs = taskFileJobMapper.selectList(new LambdaQueryWrapper() + .eq(TaskFileJobEntity::getModuleType, moduleType) + .eq(TaskFileJobEntity::getJobType, JOB_TYPE_ASSEMBLE_RESULT) + .in(TaskFileJobEntity::getResultId, normalizedIds)); + Map jobMap = new LinkedHashMap<>(); + for (TaskFileJobEntity job : jobs) { + if (job != null && job.getResultId() != null) { + jobMap.putIfAbsent(job.getResultId(), job); + } + } + return jobMap; + } + public long countUnfinishedAssembleJobs(Long taskId, String moduleType) { if (taskId == null || taskId <= 0 || moduleType == null || moduleType.isBlank()) { return 0L; diff --git a/backend-java/src/main/resources/db/V40__task_file_job_result_lookup_index.sql b/backend-java/src/main/resources/db/V40__task_file_job_result_lookup_index.sql new file mode 100644 index 0000000..76d57e2 --- /dev/null +++ b/backend-java/src/main/resources/db/V40__task_file_job_result_lookup_index.sql @@ -0,0 +1,16 @@ +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_result' +); +SET @sql := IF(@idx_exists = 0, + 'ALTER TABLE biz_task_file_job ADD INDEX idx_file_job_module_type_result (module_type, job_type, result_id)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue index 3b20194..2ebb9c9 100644 --- a/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandAppearancePatentTab.vue @@ -173,6 +173,9 @@ const pollingTaskIds = ref([]) const pendingFileTaskIds = ref([]) const pollTimer = ref(null) const pollingInFlight = ref(false) +const HISTORY_CACHE_TTL_MS = 3000 +let historyInFlight: Promise | null = null +let lastHistoryLoadedAt = 0 let disposed = false const timers = createCategorizedTimers('appearance-patent') @@ -189,7 +192,12 @@ const parsedRows = computed(() => ) const previewRows = computed(() => parsedGroups.value[0]?.items || []) const currentItems = computed(() => historyItems.value.filter((i) => i.taskId != null && pollingTaskIds.value.includes(i.taskId))) -const historyOnlyItems = computed(() => historyItems.value.filter((i) => (i.taskStatus || '') !== 'RUNNING')) +const historyOnlyItems = computed(() => + historyItems.value.filter((i) => { + const status = normalizeTaskStatus(i) + return status !== 'RUNNING' && status !== 'PENDING' + }), +) function effectiveAiPrompt() { return aiPrompt.value.trim() || defaultAiPrompt @@ -210,11 +218,17 @@ function savePollingIds() { else window.localStorage.setItem(pollingKey(), JSON.stringify(ids)) } -function clearPollingIds() { - pollingTaskIds.value = [] - pendingFileTaskIds.value = [] - savePollingIds() - stopPolling() +function loadPollingIds() { + if (typeof window === 'undefined') return + try { + const raw = window.localStorage.getItem(pollingKey()) + const ids = raw ? JSON.parse(raw) : [] + pollingTaskIds.value = Array.isArray(ids) + ? ids.filter((id): id is number => typeof id === 'number' && id > 0) + : [] + } catch { + pollingTaskIds.value = [] + } } async function uploadAppearancePathsToJava(paths: Array) { @@ -306,7 +320,7 @@ async function parseFiles() { : []) queuePayloadText.value = '' await loadDashboard() - await loadHistory() + await loadHistory({ force: true }) ElMessage.success(`解析完成,共 ${res.groupCount || 0} 组 / ${res.acceptedRows} 条`) } catch (e) { ElMessage.error(e instanceof Error ? e.message : '解析失败') @@ -366,21 +380,28 @@ async function pushToPythonQueue() { }, } queuePayloadText.value = JSON.stringify(payload, null, 2) + await activateAppearancePatentTask(taskId) const result = await api.enqueue_json(payload) if (!result?.success) { ElMessage.error(result?.error || '推送失败') return } - await activateAppearancePatentTask(taskId) addPollingTask(taskId) + clearParsedTask() await loadDashboard() - await loadHistory() + await loadHistory({ force: true }) ElMessage.success('已推送到 Python 队列') } finally { pushing.value = false } } +function clearParsedTask() { + parseResult.value = null + parsedGroups.value = [] + queuePayloadText.value = '' +} + function addPollingTask(taskId: number) { if (!pollingTaskIds.value.includes(taskId)) { pollingTaskIds.value = [...pollingTaskIds.value, taskId] @@ -399,6 +420,7 @@ function addPendingFileTask(taskId: number) { pendingFileTaskIds.value = [...pendingFileTaskIds.value, taskId] savePollingIds() } + ensurePolling() } function removePendingFileTask(taskId: number) { @@ -446,16 +468,29 @@ async function refreshTaskProgress() { } pollingInFlight.value = true try { - let shouldRefreshHistory = pendingFileTaskIds.value.length > 0 - if (pollingTaskIds.value.length) { - const batch = await getAppearancePatentTaskProgressBatch(pollingTaskIds.value) + let shouldRefreshDashboard = false + let shouldRefreshHistory = false + const taskIds = Array.from(new Set([...pollingTaskIds.value, ...pendingFileTaskIds.value])) + if (taskIds.length) { + const batch = await getAppearancePatentTaskProgressBatch(taskIds, { force: pendingFileTaskIds.value.length > 0 }) for (const detail of batch.items || []) { const task = detail.task if (!task?.id) continue + const item = detail.items?.[0] + if (item) mergeHistoryItem(item) if (task.status === 'SUCCESS' || task.status === 'FAILED') { removePollingTask(task.id) - shouldRefreshHistory = true + shouldRefreshDashboard = true if (task.status === 'SUCCESS') addPendingFileTask(task.id) + else removePendingFileTask(task.id) + } + if (item && task.status === 'SUCCESS' && pendingFileTaskIds.value.includes(task.id)) { + const fileStatus = (item.fileStatus || '').toUpperCase() + if (item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') { + removePendingFileTask(task.id) + shouldRefreshDashboard = true + shouldRefreshHistory = true + } } } if (batch.missingTaskIds?.length) { @@ -464,33 +499,55 @@ async function refreshTaskProgress() { removePendingFileTask(taskId) }) shouldRefreshHistory = true + shouldRefreshDashboard = true } } - if (shouldRefreshHistory) { + if (shouldRefreshDashboard) { await loadDashboard() - await loadHistory() - settlePendingFileTasks() } + if (shouldRefreshHistory) { + await loadHistory() + } + settlePendingFileTasks() } finally { pollingInFlight.value = false } } +function mergeHistoryItem(item: AppearancePatentHistoryItem) { + if (!item.taskId && !item.resultId) return + const index = historyItems.value.findIndex((row) => + (item.resultId != null && row.resultId === item.resultId) + || (item.taskId != null && row.taskId === item.taskId), + ) + if (index >= 0) { + historyItems.value[index] = { ...historyItems.value[index], ...item } + } else { + historyItems.value = [item, ...historyItems.value] + } +} + function settlePendingFileTasks() { if (!pendingFileTaskIds.value.length) return const remaining: number[] = [] for (const taskId of pendingFileTaskIds.value) { const item = historyItems.value.find((row) => row.taskId === taskId) - if (!item) continue + if (!item) { + remaining.push(taskId) + continue + } const taskStatus = (item.taskStatus || '').toUpperCase() const fileStatus = (item.fileStatus || '').toUpperCase() if (taskStatus === 'FAILED' || item.fileReady || fileStatus === 'SUCCESS' || fileStatus === 'FAILED') { continue } - remaining.push(taskId) + if (taskStatus === 'SUCCESS') { + remaining.push(taskId) + } } pendingFileTaskIds.value = remaining savePollingIds() + if (pendingFileTaskIds.value.length) ensurePolling() } function seedPendingFileTasksFromHistory() { @@ -506,14 +563,25 @@ async function loadDashboard() { dashboard.value = await getAppearancePatentDashboard() } -async function loadHistory() { - const res = await getAppearancePatentHistory() - historyItems.value = res.items || [] +async function loadHistory(options: { force?: boolean } = {}) { + const now = Date.now() + if (!options.force && historyItems.value.length && now - lastHistoryLoadedAt < HISTORY_CACHE_TTL_MS) return + if (historyInFlight) return historyInFlight + historyInFlight = getAppearancePatentHistory() + .then((res) => { + historyItems.value = res.items || [] + lastHistoryLoadedAt = Date.now() + }) + .finally(() => { + historyInFlight = null + }) + return historyInFlight } function statusText(item: AppearancePatentHistoryItem) { - const status = item.taskStatus || '' + const status = normalizeTaskStatus(item) if (status === 'RUNNING') return '执行中' + if (status === 'PENDING') return '已解析待推送' if (isResultPreparing(item)) return '结果生成中' if (isResultBuildFailed(item)) return '结果生成失败' if (status === 'SUCCESS' || item.success) return '已完成' @@ -522,22 +590,26 @@ function statusText(item: AppearancePatentHistoryItem) { } function statusClass(item: AppearancePatentHistoryItem) { - const status = item.taskStatus || '' + const status = normalizeTaskStatus(item) if (status === 'RUNNING' || isResultPreparing(item)) return 'running' if (isResultBuildFailed(item) || status === 'FAILED') return 'failed' if (status === 'SUCCESS' || item.success) return 'success' - return 'running' + return 'pending' } function isResultPreparing(item: AppearancePatentHistoryItem) { - const status = item.taskStatus || '' + const status = normalizeTaskStatus(item) const fileStatus = (item.fileStatus || '').toUpperCase() - if (!(status === 'SUCCESS' || item.success)) return false + if (status !== 'SUCCESS') return false if (canDownload(item)) return false if (fileStatus === 'FAILED' || fileStatus === 'SUCCESS') return false return true } +function normalizeTaskStatus(item: AppearancePatentHistoryItem) { + return (item.taskStatus || '').toUpperCase() +} + function isResultBuildFailed(item: AppearancePatentHistoryItem) { return (item.fileStatus || '').toUpperCase() === 'FAILED' } @@ -587,7 +659,7 @@ async function deleteTaskRecord(item: AppearancePatentHistoryItem) { await deleteAppearancePatentHistory(item.resultId) } await loadDashboard() - await loadHistory() + await loadHistory({ force: true }) ElMessage.success('已删除') } catch (e) { ElMessage.error(e instanceof Error ? e.message : '删除失败') @@ -595,12 +667,13 @@ async function deleteTaskRecord(item: AppearancePatentHistoryItem) { } onMounted(async () => { - clearPollingIds() + loadPollingIds() await Promise.all([ loadDashboard().catch(() => undefined), loadHistory().catch(() => undefined), ]) seedPendingFileTasksFromHistory() + if (pollingTaskIds.value.length || pendingFileTaskIds.value.length) ensurePolling() }) onUnmounted(() => { @@ -654,6 +727,7 @@ onUnmounted(() => { .status.success { background: rgba(46, 204, 113, .18); color: #2ecc71; } .status.failed { background: rgba(231, 76, 60, .18); color: #ff6b6b; } .status.running { background: rgba(52, 152, 219, .18); color: #3498db; } +.status.pending { background: rgba(149, 165, 166, .18); color: #bdc3c7; } .result-hint { margin-top: 6px; color: #e0b96d; } .download { padding: 6px 10px; color: #d6ecff; background: rgba(52, 152, 219, .18); } .btn-delete { padding: 6px 10px; color: #ff8f8f; background: rgba(231, 76, 60, .12); } diff --git a/frontend-vue/src/shared/api/java-modules.ts b/frontend-vue/src/shared/api/java-modules.ts index 151eeb8..f1ed496 100644 --- a/frontend-vue/src/shared/api/java-modules.ts +++ b/frontend-vue/src/shared/api/java-modules.ts @@ -15,6 +15,10 @@ type TaskProgressCacheEntry = { data: unknown; }; +interface TaskProgressBatchOptions { + force?: boolean; +} + const taskProgressResponseCache = new Map(); const taskProgressInflightRequests = new Map>(); @@ -44,7 +48,11 @@ function buildTaskProgressRequestKey(path: string, taskIds: number[]) { return `${path}::${taskIds.join(",")}`; } -async function postTaskProgressBatch(path: string, taskIds: number[]) { +async function postTaskProgressBatch( + path: string, + taskIds: number[], + options: TaskProgressBatchOptions = {}, +) { const normalizedTaskIds = normalizeTaskIds(taskIds); if (!normalizedTaskIds.length) { return { items: [], missingTaskIds: [] } as T; @@ -53,12 +61,12 @@ async function postTaskProgressBatch(path: string, taskIds: number[]) { const cacheKey = buildTaskProgressRequestKey(path, normalizedTaskIds); const now = Date.now(); const cached = taskProgressResponseCache.get(cacheKey); - if (cached && cached.expiresAt > now) { + if (!options.force && cached && cached.expiresAt > now) { return cached.data as T; } const inflight = taskProgressInflightRequests.get(cacheKey); - if (inflight) { + if (!options.force && inflight) { return (await inflight) as T; } @@ -1500,15 +1508,19 @@ export function getAppearancePatentHistory() { return unwrapJavaResponse( get>( `${JAVA_API_PREFIX}/appearance-patent/history`, - { params: { user_id: getCurrentUserId() } }, + { params: { user_id: getCurrentUserId(), limit: 50 } }, ), ); } -export function getAppearancePatentTaskProgressBatch(taskIds: number[]) { +export function getAppearancePatentTaskProgressBatch( + taskIds: number[], + options: TaskProgressBatchOptions = {}, +) { return postTaskProgressBatch( `${JAVA_API_PREFIX}/appearance-patent/tasks/progress/batch`, taskIds, + options, ); }