From 03e697f5d37a9f18fe1d722ef39ec40113188ea8 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Fri, 24 Apr 2026 15:14:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E6=9F=A5=E8=AF=A2Asin?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dedupe/service/DedupeRunService.java | 515 +++++++++++++----- .../service/DedupeTotalDataService.java | 2 +- .../service/DeleteBrandStaleTaskService.java | 11 +- .../file/service/oss/OssStorageService.java | 16 + .../controller/QueryAsinTaskController.java | 202 +++---- .../model/dto/QueryAsinAsinStatusDto.java | 15 + .../model/dto/QueryAsinCartRatioDto.java | 16 - .../model/dto/QueryAsinCountryAsinsDto.java | 4 +- .../dto/QueryAsinCountryMetricRowDto.java | 25 - .../model/dto/QueryAsinCountryResultDto.java | 18 + .../model/dto/QueryAsinCountrySectionDto.java | 19 - .../model/dto/QueryAsinShopPayloadDto.java | 32 +- .../dto/QueryAsinSubmitResultRequest.java | 3 +- .../model/dto/QueryAsinTaskItemDto.java | 4 +- .../model/vo/QueryAsinResultItemVo.java | 13 +- .../QueryAsinExcelAssemblyService.java | 130 ++++- .../service/QueryAsinTaskService.java | 191 +++---- .../service/ShopMatchTaskCacheService.java | 34 ++ .../service/ShopMatchTaskService.java | 5 + backend/__pycache__/config.cpython-312.pyc | Bin 1510 -> 1513 bytes .../__pycache__/admin_api.cpython-312.pyc | Bin 93391 -> 105095 bytes .../__pycache__/auth.cpython-312.pyc | Bin 4376 -> 5554 bytes backend/blueprints/admin_api.py | 7 +- backend/blueprints/auth.py | 26 +- backend/config.py | 4 +- backend/web_source/admin.html | 4 +- backend/web_source/login.html | 4 + .../brand/components/BrandQueryAsinTab.vue | 14 +- frontend-vue/src/shared/api/java-modules.ts | 15 +- new_web_source/assets/price-track.js | 2 +- new_web_source/assets/query-asin-B0yMJ9Or.css | 1 + new_web_source/assets/query-asin.js | 2 +- new_web_source/price-track.html | 2 +- new_web_source/query-asin.html | 2 +- 34 files changed, 798 insertions(+), 540 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinAsinStatusDto.java delete mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCartRatioDto.java delete mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryMetricRowDto.java create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryResultDto.java delete mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountrySectionDto.java create mode 100644 new_web_source/assets/query-asin-B0yMJ9Or.css diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java index b5f5bb0..2d1ad09 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeRunService.java @@ -3,6 +3,7 @@ package com.nanri.aiimage.modules.dedupe.service; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.IdUtil; import cn.hutool.json.JSONUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.StorageProperties; import com.nanri.aiimage.modules.dedupe.model.dto.DedupeRunRequest; @@ -15,11 +16,13 @@ import com.nanri.aiimage.modules.task.mapper.FileTaskMapper; import com.nanri.aiimage.modules.task.model.entity.FileResultEntity; import com.nanri.aiimage.modules.task.model.entity.FileTaskEntity; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Cell; 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.util.WorkbookUtil; import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -39,12 +42,14 @@ import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; - @Service @RequiredArgsConstructor +@Slf4j public class DedupeRunService { + private static final String HEADER_SPLIT_MARKER = "idASIN\u56fd\u5bb6\u72b6\u6001\u4ef7\u683c\u53d8\u4f53\u6570\u91cf"; + private static final String HEADER_STOP_COLUMN = "\u7f29\u7565\u56fe\u5730\u57408"; + private final FileTaskMapper fileTaskMapper; private final FileResultMapper fileResultMapper; private final StorageProperties storageProperties; @@ -52,8 +57,9 @@ public class DedupeRunService { private final DedupeTotalDataService dedupeTotalDataService; public DedupeRunVo run(DedupeRunRequest request) { + long runStartNs = System.nanoTime(); if (!request.isKeepIntegerIds() && !request.isKeepUnderscoreIds() && !request.isKeepIntegerMainIdsWhenNoSubIds()) { - throw new BusinessException("请至少选择一种 ID 保留规则"); + throw new BusinessException("\u8bf7\u81f3\u5c11\u9009\u62e9\u4e00\u79cd ID \u4fdd\u7559\u89c4\u5219"); } FileTaskEntity task = new FileTaskEntity(); @@ -80,9 +86,11 @@ public class DedupeRunService { DedupeResultItemVo item = new DedupeResultItemVo(); item.setSourceFilename(sourceFile.getOriginalFilename()); try { + long fileStartNs = System.nanoTime(); File inputFile = findLocalSourceFile(sourceFile.getFileKey()); + long findFileNs = elapsedNs(fileStartNs); if (inputFile == null || !inputFile.exists()) { - throw new BusinessException("上传文件不存在,请重新上传"); + throw new BusinessException("\u4e0a\u4f20\u6587\u4ef6\u4e0d\u5b58\u5728\uff0c\u8bf7\u91cd\u65b0\u4e0a\u4f20"); } String inputName = sourceFile.getOriginalFilename() == null ? inputFile.getName() : sourceFile.getOriginalFilename(); @@ -90,6 +98,7 @@ public class DedupeRunService { File outputDir = FileUtil.mkdir(FileUtil.file(storageProperties.getLocalTempDir(), "dedupe-result")); File outputFile = buildNamedOutputFile(outputDir, outputFilename); + long cleanStartNs = System.nanoTime(); cleanExcelByLegacyRules( inputFile, outputFile, @@ -98,6 +107,7 @@ public class DedupeRunService { request.isKeepUnderscoreIds(), request.isKeepIntegerMainIdsWhenNoSubIds() ); + long cleanNs = elapsedNs(cleanStartNs); if (folderMode) { archiveEntries.add(new DedupeArchiveEntry(sourceFile.getRelativePath(), inputName, outputFile)); @@ -105,13 +115,15 @@ public class DedupeRunService { continue; } - String ossObjectKey = ossStorageService.uploadResultFile(outputFile, "DEDUPE"); - // 只存 objectKey,不存预签名 URL + long uploadStartNs = System.nanoTime(); + OssStorageService.UploadedResult uploadedResult = ossStorageService.uploadResultFileWithFreshDownloadUrl(outputFile, "DEDUPE"); + long uploadNs = elapsedNs(uploadStartNs); + String ossObjectKey = uploadedResult.objectKey(); String downloadFilename = request.getFiles().size() == 1 ? inputName : outputFile.getName(); item.setSuccess(true); item.setOutputFilename(downloadFilename); - item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey)); + item.setDownloadUrl(uploadedResult.downloadUrl()); successCount++; FileResultEntity resultEntity = new FileResultEntity(); @@ -119,13 +131,26 @@ public class DedupeRunService { resultEntity.setModuleType("DEDUPE"); resultEntity.setSourceFilename(inputName); resultEntity.setResultFilename(downloadFilename); - resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey + resultEntity.setResultFileUrl(ossObjectKey); resultEntity.setResultFileSize(outputFile.length()); resultEntity.setResultContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); resultEntity.setSuccess(1); resultEntity.setUserId(request.getUserId()); resultEntity.setCreatedAt(LocalDateTime.now()); + long resultInsertStartNs = System.nanoTime(); fileResultMapper.insert(resultEntity); + long resultInsertNs = elapsedNs(resultInsertStartNs); + log.info( + "dedupe run file done fileKey={} filename={} size={} findFileMs={} cleanMs={} uploadMs={} resultInsertMs={} totalFileMs={}", + sourceFile.getFileKey(), + inputName, + inputFile.length(), + toMs(findFileNs), + toMs(cleanNs), + toMs(uploadNs), + toMs(resultInsertNs), + toMs(elapsedNs(fileStartNs)) + ); } catch (Exception ex) { item.setSuccess(false); item.setError(ex.getMessage()); @@ -146,20 +171,20 @@ public class DedupeRunService { if (folderMode && !archiveEntries.isEmpty()) { File zipFile = packageFolderDedupeResultsAsZip(request.getArchiveName(), archiveEntries); - String ossObjectKey = ossStorageService.uploadResultFile(zipFile, "DEDUPE"); - // 只存 objectKey + OssStorageService.UploadedResult uploadedResult = ossStorageService.uploadResultFileWithFreshDownloadUrl(zipFile, "DEDUPE"); + String ossObjectKey = uploadedResult.objectKey(); DedupeResultItemVo item = new DedupeResultItemVo(); item.setSourceFilename(request.getArchiveName()); item.setOutputFilename(zipFile.getName()); item.setSuccess(true); - item.setDownloadUrl(ossStorageService.generateFreshDownloadUrl(ossObjectKey)); + item.setDownloadUrl(uploadedResult.downloadUrl()); FileResultEntity resultEntity = new FileResultEntity(); resultEntity.setTaskId(task.getId()); resultEntity.setModuleType("DEDUPE"); resultEntity.setSourceFilename(request.getArchiveName()); resultEntity.setResultFilename(zipFile.getName()); - resultEntity.setResultFileUrl(ossObjectKey); // 存 objectKey + resultEntity.setResultFileUrl(ossObjectKey); resultEntity.setResultFileSize(zipFile.length()); resultEntity.setResultContentType("application/zip"); resultEntity.setSuccess(1); @@ -181,6 +206,14 @@ public class DedupeRunService { vo.setSuccessCount(successCount); vo.setFailedCount(failedCount); vo.setItems(items); + log.info( + "dedupe run done userId={} files={} success={} failed={} totalMs={}", + request.getUserId(), + request.getFiles().size(), + successCount, + failedCount, + toMs(elapsedNs(runStartNs)) + ); return vo; } @@ -209,7 +242,7 @@ public class DedupeRunService { public void deleteHistory(Long resultId, Long userId) { FileResultEntity entity = fileResultMapper.selectById(resultId); if (entity == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { - throw new BusinessException("记录不存在"); + throw new BusinessException("\u8bb0\u5f55\u4e0d\u5b58\u5728"); } fileResultMapper.deleteById(resultId); } @@ -217,115 +250,64 @@ public class DedupeRunService { public Resource getResultFile(Long resultId, Long userId) { FileResultEntity entity = fileResultMapper.selectById(resultId); if (entity == null || entity.getResultFileUrl() == null || !"DEDUPE".equals(entity.getModuleType()) || !userId.equals(entity.getUserId())) { - throw new BusinessException("结果文件不存在"); + throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728"); } File file = new File(entity.getResultFileUrl()); if (!file.exists()) { - throw new BusinessException("结果文件不存在"); + throw new BusinessException("\u7ed3\u679c\u6587\u4ef6\u4e0d\u5b58\u5728"); } return new FileSystemResource(file); } private void cleanExcelByLegacyRules(File inputFile, File outputFile, List selectedColumns, - boolean keepIntegerIds, boolean keepUnderscoreIds, - boolean keepIntegerMainIdsWhenNoSubIds) throws Exception { - DataFormatter formatter = new DataFormatter(); - try (FileInputStream fis = new FileInputStream(inputFile); - Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis); - SXSSFWorkbook outputWorkbook = new SXSSFWorkbook(200)) { - Sheet sheet = workbook.getSheetAt(0); - Row headerRow = sheet.getRow(0); - if (headerRow == null) { - throw new BusinessException("Excel 表头为空"); - } + boolean keepIntegerIds, boolean keepUnderscoreIds, + boolean keepIntegerMainIdsWhenNoSubIds) throws Exception { + long readStartNs = System.nanoTime(); + DedupeReadResult readResult = readDedupeRows( + inputFile, + selectedColumns, + keepIntegerIds, + keepUnderscoreIds, + keepIntegerMainIdsWhenNoSubIds + ); + long readNs = elapsedNs(readStartNs); - Map headerMap = new HashMap<>(); - for (int i = 0; i < headerRow.getLastCellNum(); i++) { - Cell cell = headerRow.getCell(i); - String value = normalizeCellText(cell == null ? null : formatter.formatCellValue(cell)); - if (value.isBlank()) { - continue; - } - value = value.split("idASIN国家状态价格变体数量", 2)[0].trim().isEmpty() - ? value - : value.split("idASIN国家状态价格变体数量", 2)[0].trim(); - if (headerMap.containsKey(value)) { - continue; - } - headerMap.put(value, i); - if ("缩略图地址8".equals(value)) { - break; - } + Set candidateAsinValues = new HashSet<>(); + for (DedupeCandidateRow row : readResult.rows()) { + if (!row.asinValue().isBlank()) { + candidateAsinValues.add(row.asinValue()); } + } + long dbStartNs = System.nanoTime(); + Set matchedAsinValues = dedupeTotalDataService.findExistingComparableValues(candidateAsinValues); + long dbNs = elapsedNs(dbStartNs); - List missing = selectedColumns.stream().filter(column -> !headerMap.containsKey(column)).toList(); - if (!missing.isEmpty()) { - throw new BusinessException("缺少列:" + String.join("、", missing)); - } - - Sheet outputSheet = outputWorkbook.createSheet(sheet.getSheetName()); + long writeStartNs = System.nanoTime(); + int outputRows = 0; + try (SXSSFWorkbook outputWorkbook = new SXSSFWorkbook(200)) { + org.apache.poi.ss.usermodel.Sheet outputSheet = outputWorkbook.createSheet(WorkbookUtil.createSafeSheetName(readResult.sheetName())); Row outputHeaderRow = outputSheet.createRow(0); for (int i = 0; i < selectedColumns.size(); i++) { outputHeaderRow.createCell(i).setCellValue(selectedColumns.get(i)); } - Integer idColumnIndex = headerMap.get("id"); - Integer asinColumnIndex = headerMap.get("ASIN"); - if (idColumnIndex != null) { - // The preliminary global scan for mainIdHasSubIdMap was removed. - // We now determine subset existence contextually (per group) during the main loop. - } - Set candidateAsinValues = new HashSet<>(); - for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) { - Row row = sheet.getRow(rowNum); - if (row == null) { - continue; - } - if (idColumnIndex != null) { - String idValue = normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex))); - if (!shouldKeepId(idValue, keepIntegerIds, keepUnderscoreIds, keepIntegerMainIdsWhenNoSubIds, sheet, rowNum, idColumnIndex, formatter)) { - continue; - } - } - if (asinColumnIndex != null) { - String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex))); - if (!asinValue.isBlank()) { - candidateAsinValues.add(asinValue); - } - } - } - - Set matchedAsinValues = dedupeTotalDataService.findExistingComparableValues(candidateAsinValues); Set writtenAsinValues = new HashSet<>(); int outputRowIndex = 1; - for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) { - Row row = sheet.getRow(rowNum); - if (row == null) { - continue; - } - if (idColumnIndex != null) { - String idValue = normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex))); - if (!shouldKeepId(idValue, keepIntegerIds, keepUnderscoreIds, keepIntegerMainIdsWhenNoSubIds, sheet, rowNum, idColumnIndex, formatter)) { + for (DedupeCandidateRow candidateRow : readResult.rows()) { + String asinValue = candidateRow.asinValue(); + if (!asinValue.isBlank()) { + if (!matchedAsinValues.isEmpty() && matchedAsinValues.contains(asinValue)) { continue; } - } - if (asinColumnIndex != null) { - String asinValue = dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex))); - if (!asinValue.isBlank()) { - if (!matchedAsinValues.isEmpty() && matchedAsinValues.contains(asinValue)) { - continue; - } - if (!writtenAsinValues.add(asinValue)) { - continue; - } + if (!writtenAsinValues.add(asinValue)) { + continue; } } Row outputRow = outputSheet.createRow(outputRowIndex++); - for (int i = 0; i < selectedColumns.size(); i++) { - Integer sourceIndex = headerMap.get(selectedColumns.get(i)); - String value = sourceIndex == null ? "" : normalizeCellText(formatter.formatCellValue(row.getCell(sourceIndex))); - outputRow.createCell(i).setCellValue(value); + outputRows++; + for (int i = 0; i < candidateRow.selectedValues().size(); i++) { + outputRow.createCell(i).setCellValue(candidateRow.selectedValues().get(i)); } } @@ -334,6 +316,157 @@ public class DedupeRunService { } outputWorkbook.dispose(); } + long writeNs = elapsedNs(writeStartNs); + log.info( + "dedupe clean stages file={} scannedRows={} keptRows={} uniqueAsins={} matchedAsins={} outputRows={} readFilterMs={} dbMs={} writeMs={} totalCleanMs={}", + inputFile.getName(), + readResult.scannedRows(), + readResult.rows().size(), + candidateAsinValues.size(), + matchedAsinValues.size(), + outputRows, + toMs(readNs), + toMs(dbNs), + toMs(writeNs), + toMs(readNs + dbNs + writeNs) + ); + } + + private DedupeReadResult readDedupeRows(File inputFile, List selectedColumns, + boolean keepIntegerIds, boolean keepUnderscoreIds, + boolean keepIntegerMainIdsWhenNoSubIds) throws Exception { + List rows = new ArrayList<>(); + PendingMainIdGroup pendingMainIdGroup = new PendingMainIdGroup(); + DataFormatter formatter = new DataFormatter(); + + try (FileInputStream fis = new FileInputStream(inputFile); + Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(fis)) { + Sheet sheet = workbook.getSheetAt(0); + Row headerRow = sheet.getRow(0); + if (headerRow == null) { + throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a"); + } + + Map headerMap = buildHeaderMap(headerRow, formatter); + if (headerMap.isEmpty()) { + throw new BusinessException("Excel \u8868\u5934\u4e3a\u7a7a"); + } + List missing = selectedColumns.stream() + .filter(column -> !headerMap.containsKey(column)) + .toList(); + if (!missing.isEmpty()) { + throw new BusinessException("\u7f3a\u5c11\u5217\uff1a" + String.join("\u3001", missing)); + } + + List selectedIndexes = new ArrayList<>(selectedColumns.size()); + for (String selectedColumn : selectedColumns) { + selectedIndexes.add(headerMap.get(selectedColumn)); + } + Integer idColumnIndex = headerMap.get("id"); + Integer asinColumnIndex = headerMap.get("ASIN"); + + int scannedRows = 0; + for (int rowNum = 1; rowNum <= sheet.getLastRowNum(); rowNum++) { + Row row = sheet.getRow(rowNum); + if (row == null) { + continue; + } + scannedRows++; + if (idColumnIndex == null) { + rows.add(buildCandidateRow(row, selectedIndexes, asinColumnIndex, formatter)); + continue; + } + appendRowByIdRule( + normalizeCellText(formatter.formatCellValue(row.getCell(idColumnIndex))), + row, + selectedIndexes, + asinColumnIndex, + formatter, + keepIntegerIds, + keepUnderscoreIds, + keepIntegerMainIdsWhenNoSubIds, + pendingMainIdGroup, + rows + ); + } + + pendingMainIdGroup.flush(rows); + return new DedupeReadResult(sheet.getSheetName(), rows, scannedRows); + } + } + + private Map buildHeaderMap(Row headerRow, DataFormatter formatter) { + Map headerMap = new HashMap<>(); + for (int i = 0; i < headerRow.getLastCellNum(); i++) { + Cell cell = headerRow.getCell(i); + String value = normalizeHeaderText(cell == null ? null : formatter.formatCellValue(cell)); + if (value.isBlank()) { + continue; + } + if (headerMap.containsKey(value)) { + continue; + } + headerMap.put(value, i); + if (HEADER_STOP_COLUMN.equals(value)) { + break; + } + } + return headerMap; + } + + private String normalizeHeaderText(String value) { + String normalized = normalizeCellText(value); + int markerIndex = normalized.indexOf(HEADER_SPLIT_MARKER); + if (markerIndex > 0) { + String prefix = normalized.substring(0, markerIndex).trim(); + if (!prefix.isBlank()) { + return prefix; + } + } + return normalized; + } + + private DedupeCandidateRow buildCandidateRow(Row row, List selectedIndexes, Integer asinColumnIndex, DataFormatter formatter) { + List selectedValues = new ArrayList<>(selectedIndexes.size()); + for (Integer selectedIndex : selectedIndexes) { + selectedValues.add(normalizeCellText(formatter.formatCellValue(row.getCell(selectedIndex)))); + } + String asinValue = asinColumnIndex == null + ? "" + : dedupeTotalDataService.normalizeComparableValueOrBlank(formatter.formatCellValue(row.getCell(asinColumnIndex))); + return new DedupeCandidateRow(selectedValues, asinValue); + } + + private void appendRowByIdRule(String idValue, Row row, + List selectedIndexes, Integer asinColumnIndex, + DataFormatter formatter, + boolean keepIntegerIds, boolean keepUnderscoreIds, + boolean keepIntegerMainIdsWhenNoSubIds, + PendingMainIdGroup pendingMainIdGroup, + List rows) { + if (idValue == null || idValue.isBlank()) { + return; + } + String mainId = extractMainId(idValue); + if (pendingMainIdGroup.hasDifferentMainId(mainId)) { + pendingMainIdGroup.flush(rows); + } + if (isUnderscoreId(idValue)) { + pendingMainIdGroup.discardIfSameMainId(mainId); + if (keepUnderscoreIds) { + rows.add(buildCandidateRow(row, selectedIndexes, asinColumnIndex, formatter)); + } + return; + } + if (isIntegerId(idValue)) { + if (keepIntegerIds) { + rows.add(buildCandidateRow(row, selectedIndexes, asinColumnIndex, formatter)); + return; + } + if (keepIntegerMainIdsWhenNoSubIds) { + pendingMainIdGroup.add(mainId, buildCandidateRow(row, selectedIndexes, asinColumnIndex, formatter)); + } + } } private File packageFolderDedupeResultsAsZip(String archiveName, List archiveEntries) { @@ -347,7 +480,7 @@ public class DedupeRunService { zos.closeEntry(); } } catch (Exception ex) { - throw new BusinessException("去重结果打包失败:" + ex.getMessage()); + throw new BusinessException("\u53bb\u91cd\u7ed3\u679c\u6253\u5305\u5931\u8d25\uff1a" + ex.getMessage()); } return zipFile; } @@ -368,67 +501,61 @@ public class DedupeRunService { return String.join("/", parts); } - private boolean shouldKeepId(String text, boolean keepIntegerIds, boolean keepUnderscoreIds, - boolean keepIntegerMainIdsWhenNoSubIds, Sheet sheet, int currentRowNum, int idColumnIndex, DataFormatter formatter) { - if (text == null || text.isBlank()) { - return false; - } - if (keepUnderscoreIds && text.matches("\\d+_\\d+")) { - return true; - } - if (keepIntegerIds && text.matches("\\d+")) { - return true; - } - if (keepIntegerMainIdsWhenNoSubIds && text.matches("\\d+")) { - String mainId = extractMainId(text); - if (mainId.isEmpty()) return false; - - boolean hasSubIdInGroup = false; - for (int r = currentRowNum + 1; r <= sheet.getLastRowNum(); r++) { - Row nextRow = sheet.getRow(r); - if (nextRow == null) continue; - Cell cell = nextRow.getCell(idColumnIndex); - if (cell == null) continue; - String nextIdValue = normalizeCellText(formatter.formatCellValue(cell)); - if (nextIdValue.isBlank()) continue; - - String nextMainId = extractMainId(nextIdValue); - if (nextMainId.equals(mainId)) { - if (nextIdValue.matches("\\d+_\\d+")) { - hasSubIdInGroup = true; - break; - } - } else { - // Encountered a different main ID. The group for 'mainId' has ended. - break; - } - } - return !hasSubIdInGroup; - } - return false; - } - private String extractMainId(String text) { if (text == null || text.isBlank()) { return ""; } - if (text.matches("\\d+")) { + if (isIntegerId(text)) { return text; } - if (text.matches("\\d+_\\d+")) { + if (isUnderscoreId(text)) { int idx = text.indexOf('_'); return idx > 0 ? text.substring(0, idx) : ""; } return ""; } + private boolean isIntegerId(String text) { + if (text == null || text.isEmpty()) { + return false; + } + for (int i = 0; i < text.length(); i++) { + if (!Character.isDigit(text.charAt(i))) { + return false; + } + } + return true; + } + + private boolean isUnderscoreId(String text) { + if (text == null || text.length() < 3) { + return false; + } + int underscoreIndex = text.indexOf('_'); + if (underscoreIndex <= 0 || underscoreIndex == text.length() - 1 || text.indexOf('_', underscoreIndex + 1) >= 0) { + return false; + } + for (int i = 0; i < text.length(); i++) { + if (i == underscoreIndex) { + continue; + } + if (!Character.isDigit(text.charAt(i))) { + return false; + } + } + return true; + } + private File findLocalSourceFile(String fileKey) { File baseDir = FileUtil.file(storageProperties.getLocalTempDir()); if (!baseDir.exists()) { return null; } - List matchedFiles = FileUtil.loopFiles(baseDir, pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey)); - return matchedFiles.isEmpty() ? null : matchedFiles.getFirst(); + File[] files = baseDir.listFiles(pathname -> pathname.isFile() && pathname.getName().startsWith(fileKey)); + if (files == null || files.length == 0) { + return null; + } + return files[0]; } private File buildNamedOutputFile(File outputDir, String filename) { @@ -461,16 +588,102 @@ public class DedupeRunService { if (value == null) { return ""; } - return value.replace("", "") - .replace(" ", " ") - .replace("\r\n", " ") - .replace("\r", " ") - .replace("\n", " ") - .replace("\t", " ") - .trim() - .replaceAll("\\s+", " "); + int start = 0; + int end = value.length(); + while (start < end && isTrimmedWhitespace(value.charAt(start))) { + start++; + } + while (end > start && isTrimmedWhitespace(value.charAt(end - 1))) { + end--; + } + if (start >= end) { + return ""; + } + + StringBuilder builder = null; + boolean previousWhitespace = false; + for (int i = start; i < end; i++) { + char ch = value.charAt(i); + if (ch == '\uFEFF') { + if (builder == null) { + builder = new StringBuilder(value.length()); + builder.append(value, start, i); + } + continue; + } + if (isNormalizedWhitespace(ch)) { + if (builder == null) { + builder = new StringBuilder(value.length()); + builder.append(value, start, i); + } + if (!previousWhitespace) { + builder.append(' '); + } + previousWhitespace = true; + continue; + } + if (builder != null) { + builder.append(ch); + } + previousWhitespace = false; + } + return builder == null ? value.substring(start, end) : builder.toString(); + } + + private boolean isTrimmedWhitespace(char ch) { + return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch); + } + + private boolean isNormalizedWhitespace(char ch) { + return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n' || ch == '\u3000' || Character.isWhitespace(ch); } private record DedupeArchiveEntry(String relativePath, String inputName, File outputFile) { } + + private long elapsedNs(long startNs) { + return System.nanoTime() - startNs; + } + + private long toMs(long nanos) { + return nanos / 1_000_000L; + } + + private record DedupeReadResult(String sheetName, List rows, int scannedRows) { + } + + private record DedupeCandidateRow(List selectedValues, String asinValue) { + } + + private static final class PendingMainIdGroup { + private String mainId; + private final List rows = new ArrayList<>(); + + private void add(String nextMainId, DedupeCandidateRow row) { + if (hasDifferentMainId(nextMainId)) { + rows.clear(); + } + mainId = nextMainId; + rows.add(row); + } + + private boolean hasDifferentMainId(String nextMainId) { + return mainId != null && (nextMainId == null || nextMainId.isBlank() || !mainId.equals(nextMainId)); + } + + private void discardIfSameMainId(String nextMainId) { + if (mainId != null && mainId.equals(nextMainId)) { + rows.clear(); + mainId = null; + } + } + + private void flush(List outputRows) { + if (!rows.isEmpty()) { + outputRows.addAll(rows); + rows.clear(); + } + mainId = null; + } + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java index 0516837..abcf23b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java @@ -38,7 +38,7 @@ import java.util.concurrent.ConcurrentHashMap; @RequiredArgsConstructor public class DedupeTotalDataService { - private static final int COMPARE_BATCH_SIZE = 1000; + private static final int COMPARE_BATCH_SIZE = 5000; private final DedupeTotalDataMapper dedupeTotalDataMapper; private final Map importProgressMap = new ConcurrentHashMap<>(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java index e80de33..9ebe69b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/deletebrand/service/DeleteBrandStaleTaskService.java @@ -298,6 +298,8 @@ public class DeleteBrandStaleTaskService { ShopMatchStaleCheckStats stats = new ShopMatchStaleCheckStats(); long minutes = Math.max(1L, deleteBrandProgressProperties.getShopMatchStaleTimeoutMinutes()); long initialMinutes = Math.max(minutes, deleteBrandProgressProperties.getShopMatchInitialTimeoutMinutes()); + long staleTimeoutMillis = Duration.ofMinutes(minutes).toMillis(); + long nowMillis = System.currentTimeMillis(); LocalDateTime now = LocalDateTime.now(); LocalDateTime threshold = now.minusMinutes(minutes); LocalDateTime initialThreshold = now.minusMinutes(initialMinutes); @@ -313,10 +315,17 @@ public class DeleteBrandStaleTaskService { log.info("[stale-check] shop-match candidates={} threshold={} timeoutMinutes={} initialThreshold={} initialTimeoutMinutes={}", runningTasks.size(), threshold, minutes, initialThreshold, initialMinutes); for (FileTaskEntity task : runningTasks) { + long lastHeartbeatMillis = shopMatchTaskCacheService.getTaskHeartbeatMillis(task.getId()); boolean hasUploadedPayload = shopMatchTaskCacheService.hasAnyShopMergedPayload(task.getId()); boolean hasFinishedRows = (task.getSuccessFileCount() != null && task.getSuccessFileCount() > 0) || (task.getFailedFileCount() != null && task.getFailedFileCount() > 0); - boolean hasStartedProgress = hasUploadedPayload || hasFinishedRows; + boolean hasStartedProgress = lastHeartbeatMillis > 0L || hasUploadedPayload || hasFinishedRows; + if (lastHeartbeatMillis > 0L && nowMillis - lastHeartbeatMillis < staleTimeoutMillis) { + stats.skippedTaskCount++; + log.info("[stale-check] shop-match skip recent-task-heartbeat taskId={} lastHeartbeatMillis={} timeoutMinutes={} updatedAt={}", + task.getId(), lastHeartbeatMillis, minutes, task.getUpdatedAt()); + continue; + } if (!hasStartedProgress && task.getCreatedAt() != null && task.getCreatedAt().isAfter(initialThreshold)) { stats.skippedTaskCount++; log.info("[stale-check] shop-match skip initial-grace taskId={} createdAt={} initialThreshold={}", diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java index 5fc49f3..d7ba8bf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/file/service/oss/OssStorageService.java @@ -37,6 +37,19 @@ public class OssStorageService { } } + public UploadedResult uploadResultFileWithFreshDownloadUrl(File file, String moduleType) { + String objectKey = String.format("result/%s/%s/%s", moduleType.toLowerCase(), UUID.randomUUID(), file.getName()); + OSS ossClient = buildClient(); + try { + ossClient.putObject(ossProperties.getBucket(), objectKey, file); + Date expiration = new Date(System.currentTimeMillis() + 3600_000L); + URL url = ossClient.generatePresignedUrl(ossProperties.getBucket(), objectKey, expiration); + return new UploadedResult(objectKey, url.toString()); + } finally { + ossClient.shutdown(); + } + } + public String uploadText(String objectKey, String content) { if (objectKey == null || objectKey.isBlank()) { throw new IllegalArgumentException("objectKey must not be blank"); @@ -160,4 +173,7 @@ public class OssStorageService { ossProperties.getAccessKeySecret() ); } + + public record UploadedResult(String objectKey, String downloadUrl) { + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java index 6e1a10f..c0859ee 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/controller/QueryAsinTaskController.java @@ -1,6 +1,11 @@ package com.nanri.aiimage.modules.queryasin.controller; import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; +import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo; +import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo; import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCreateTaskRequest; import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinSubmitResultRequest; import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinTaskBatchRequest; @@ -9,11 +14,6 @@ import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinHistoryVo; import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinTaskBatchVo; import com.nanri.aiimage.modules.queryasin.service.QueryAsinResolveService; import com.nanri.aiimage.modules.queryasin.service.QueryAsinTaskService; -import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskCandidateAddRequest; -import com.nanri.aiimage.modules.productrisk.model.dto.ProductRiskMatchShopsRequest; -import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskCandidateVo; -import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskDashboardVo; -import com.nanri.aiimage.modules.productrisk.model.vo.ProductRiskMatchShopsVo; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.ParameterIn; @@ -46,14 +46,14 @@ import java.util.List; @RequestMapping("/api/query-asin") @Tag( name = "查询ASIN", - description = "查询ASIN模块:管理备选店铺、批量匹配紫鸟店铺、创建任务、接收 Python 分片结果、自动收尾生成 Excel 并提供下载。涉及查询和删除的接口需要携带 user_id。") + description = "查询 ASIN 模块:管理备选店铺、批量匹配紫鸟店铺、创建任务、接收 Python 分片结果、自动收尾生成 Excel 并提供下载。查询和删除接口需要携带 user_id。") public class QueryAsinTaskController { private final QueryAsinResolveService queryAsinResolveService; private final QueryAsinTaskService queryAsinTaskService; @GetMapping("/candidates") - @Operation(summary = "查询备选店铺列表", description = "返回当前用户在查询ASIN模块中已保存的备选店铺。") + @Operation(summary = "查询备选店铺列表", description = "返回当前用户在查询 ASIN 模块中已保存的备选店铺。") public ApiResponse> listCandidates( @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @RequestParam("user_id") Long userId) { @@ -61,10 +61,10 @@ public class QueryAsinTaskController { } @PostMapping("/candidates") - @Operation(summary = "新增备选店铺", description = "向查询ASIN备选区新增一条店铺记录,供后续匹配和创建任务使用。") + @Operation(summary = "新增备选店铺", description = "向查询 ASIN 备选区新增一条店铺记录,供后续匹配和创建任务使用。") public ApiResponse addCandidate( @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "新增备选店铺请求,需传入用户 ID 和店铺名。", + description = "新增备选店铺请求,需要传入用户 ID 和店铺名。", required = true, content = @Content( mediaType = "application/json", @@ -93,7 +93,7 @@ public class QueryAsinTaskController { } @PostMapping("/match-shops") - @Operation(summary = "批量匹配店铺", description = "根据店铺名批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。") + @Operation(summary = "批量匹配店铺", description = "根据店铺名称批量匹配紫鸟店铺索引,返回是否命中、店铺 ID、平台、公司和匹配状态。") public ApiResponse matchShops( @io.swagger.v3.oas.annotations.parameters.RequestBody( description = "批量匹配请求,传入 user_id 和待匹配的店铺名称列表。", @@ -125,7 +125,7 @@ public class QueryAsinTaskController { } @GetMapping("/history") - @Operation(summary = "查询任务记录", description = "返回当前用户在查询ASIN模块中的当前任务和历史任务记录。") + @Operation(summary = "查询任务记录", description = "返回当前用户在查询 ASIN 模块中的当前任务和历史任务记录。") public ApiResponse history( @Parameter(name = "user_id", description = "当前用户 ID", required = true, in = ParameterIn.QUERY, example = "1") @RequestParam("user_id") Long userId) { @@ -133,16 +133,16 @@ public class QueryAsinTaskController { } @PostMapping("/tasks/progress/batch") - @Operation(summary = "批量查询查询ASIN任务进度", description = "仅返回任务状态和店铺结果摘要,用于前端轮询降载。") + @Operation(summary = "批量查询查询 ASIN 任务进度", description = "仅返回任务状态和店铺结果摘要,用于前端轮询降载。") public ApiResponse taskProgressBatch(@Valid @RequestBody QueryAsinTaskBatchRequest request) { return ApiResponse.success(queryAsinTaskService.getTaskProgressBatch(request.getTaskIds())); } @PostMapping("/tasks") - @Operation(summary = "创建查询ASIN任务", description = "根据已匹配的店铺创建任务和占位结果记录,后续由 Python 端处理并回传结果。") + @Operation(summary = "创建查询 ASIN 任务", description = "根据已匹配的店铺创建任务和占位结果记录,并把后台维护的整张 ASIN 表数据随店铺项返回给 Python 端。") public ApiResponse createTask( @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "创建任务请求。items 中每一项代表一个待处理店铺,需携带默认模板结构。", + description = "创建任务请求。items 中每一项代表一个待处理店铺,queryAsins 是后台维护的整张 ASIN 表数据,不按店铺过滤。", required = true, content = @Content( mediaType = "application/json", @@ -161,23 +161,14 @@ public class QueryAsinTaskController { "companyName": "示例公司", "matchStatus": "MATCHED", "matchMessage": "索引已命中", - "countrySections": [ + "queryAsins": [ { - "country": "德国", - "rows": [ - { - "status": "全部", - "quantity": "", - "deleteQuantity": "", - "processStatus": "" - } - ] - } - ], - "cartRatios": [ + "country": "DE", + "asins": ["B0BRZZR3N2", "B0BRZZR3N3"] + }, { - "country": "德国", - "ratio": "" + "country": "FR", + "asins": ["B0BRZZR3N4"] } ] } @@ -190,124 +181,72 @@ public class QueryAsinTaskController { @PostMapping("/tasks/{taskId}/result") @Operation( - summary = "提交查询ASIN结果", - description = "供 Python 端回传处理结果。支持按店铺分片多次提交,服务端会合并分片、按店铺完成状态自动收尾,并在任务结束后生成 Excel。") + summary = "提交查询 ASIN 结果", + description = "供 Python 端回传查询 ASIN 结果。业务数据包含店铺名,以及德国、英国、法国、意大利、西班牙 5 个国家下的 ASIN 和处理状态;submissionId 用于日志追踪,shopDone 可不传,不传时后端默认该店铺已提交完成。") public ApiResponse submitResult( - @Parameter(description = "任务主键,必须是运行中的任务", example = "3089") + @Parameter(description = "任务主键,必须是运行中的查询 ASIN 任务", example = "3089") @PathVariable Long taskId, @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "查询ASIN结果回传请求。shops 表示本次提交的店铺结果分片列表。shopDone=true 表示该店铺已全部提交完成。", + description = "查询 ASIN 结果回传请求。shops 表示店铺结果列表;countryResults 对应导出表中的国家 ASIN 列和处理状态列;error 用于回传单店铺失败原因。", required = true, content = @Content( mediaType = "application/json", schema = @Schema(implementation = QueryAsinSubmitResultRequest.class), - examples = { - @ExampleObject( - name = "成功分片示例", - summary = "一个店铺分片回传,包含状态数据和购物车比例数据", - value = """ + examples = @ExampleObject( + name = "回传示例", + summary = "按店铺和国家返回 ASIN 与处理状态", + value = """ + { + "shops": [ + { + "shopName": "郭亚芳", + "submissionId": "query-asin:3089:郭亚芳:1711111111111", + "shopDone": true, + "countryResults": [ { - "shops": [ + "country": "DE", + "items": [ { - "shopName": "郭亚芳", - "submissionId": "query-asin:3089:郭亚芳:1711111111111", - "chunkIndex": 1, - "chunkTotal": 2, - "shopDone": false, - "countrySections": [ - { - "country": "德国", - "rows": [ - { - "status": "正常", - "quantity": "12", - "deleteQuantity": "3", - "processStatus": "处理中" - }, - { - "status": "下架", - "quantity": "2", - "deleteQuantity": "2", - "processStatus": "已完成" - } - ] - }, - { - "country": "英国", - "rows": [ - { - "status": "正常", - "quantity": "5", - "deleteQuantity": "1", - "processStatus": "处理中" - } - ] - } - ], - "cartRatios": [ - { - "country": "德国", - "ratio": "25%" - }, - { - "country": "英国", - "ratio": "18%" - } - ] + "asin": "B0BRZZR3N2", + "status": "正常" + }, + { + "asin": "B0BRZZR3N3", + "status": "未找到" } ] - } - """), - @ExampleObject( - name = "店铺完成示例", - summary = "最后一片提交完成后,将 shopDone 置为 true", - value = """ + }, { - "shops": [ + "country": "UK", + "items": [ { - "shopName": "郭亚芳", - "submissionId": "query-asin:3089:郭亚芳:1711111111111", - "chunkIndex": 2, - "chunkTotal": 2, - "shopDone": true, - "countrySections": [ - { - "country": "法国", - "rows": [ - { - "status": "正常", - "quantity": "4", - "deleteQuantity": "0", - "processStatus": "已完成" - } - ] - } - ], - "cartRatios": [ - { - "country": "法国", - "ratio": "12%" - } - ] + "asin": "B0BRZZR3N6", + "status": "正常" } ] - } - """), - @ExampleObject( - name = "失败示例", - summary = "店铺处理失败时直接回传错误信息", - value = """ + }, { - "shops": [ + "country": "FR", + "items": [ { - "shopName": "郭亚芳", - "shopDone": true, - "error": "紫鸟页面加载超时,未能完成删除数据采集" + "asin": "B0BRZZR3N4", + "status": "正常" } ] + }, + { + "country": "IT", + "items": [] + }, + { + "country": "ES", + "items": [] } - """) - })) + ] + } + ] + } + """))) @Valid @RequestBody QueryAsinSubmitResultRequest request, jakarta.servlet.http.HttpServletResponse response) { response.setCharacterEncoding(StandardCharsets.UTF_8.name()); @@ -317,7 +256,7 @@ public class QueryAsinTaskController { } @GetMapping("/results/{resultId}/download") - @Operation(summary = "下载结果文件", description = "按结果记录下载服务端已生成并上传到 OSS 的查询ASIN Excel 文件。") + @Operation(summary = "下载结果文件", description = "按结果记录下载服务端已生成并上传到 OSS 的查询 ASIN Excel 文件。") public void downloadResult( @Parameter(description = "结果记录主键", example = "4599") @PathVariable Long resultId, @@ -348,7 +287,7 @@ public class QueryAsinTaskController { } @DeleteMapping("/tasks/{taskId}") - @Operation(summary = "删除任务", description = "删除整条查询ASIN任务以及该任务下关联的所有结果记录。") + @Operation(summary = "删除任务", description = "删除整条查询 ASIN 任务以及该任务下关联的所有结果记录。") public ApiResponse deleteTask( @Parameter(description = "任务主键", example = "3089") @PathVariable Long taskId, @@ -359,7 +298,7 @@ public class QueryAsinTaskController { } @DeleteMapping("/history/{resultId}") - @Operation(summary = "删除单条历史记录", description = "删除一条查询ASIN结果记录,并同步重算其所属任务状态。") + @Operation(summary = "删除单条历史记录", description = "删除一条查询 ASIN 结果记录,并同步重算其所属任务状态。") public ApiResponse deleteHistory( @Parameter(description = "结果记录主键", example = "4599") @PathVariable Long resultId, @@ -369,6 +308,3 @@ public class QueryAsinTaskController { return ApiResponse.success(null); } } - - - diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinAsinStatusDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinAsinStatusDto.java new file mode 100644 index 0000000..ed11fc1 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinAsinStatusDto.java @@ -0,0 +1,15 @@ +package com.nanri.aiimage.modules.queryasin.model.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "查询 ASIN 单条结果") +public class QueryAsinAsinStatusDto { + + @Schema(description = "ASIN", example = "B0BRZZR3N2") + private String asin; + + @Schema(description = "Python 查询后返回的状态", example = "正常") + private String status; +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCartRatioDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCartRatioDto.java deleted file mode 100644 index 7b5e5a3..0000000 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCartRatioDto.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.nanri.aiimage.modules.queryasin.model.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -@Data -@Schema(description = "购物车比例数据") -public class QueryAsinCartRatioDto { - - @Schema(description = "国家名称", example = "德国") - private String country; - - @Schema(description = "购物车比例", example = "25%") - private String ratio; -} - diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryAsinsDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryAsinsDto.java index 99deac9..f2cfbf2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryAsinsDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryAsinsDto.java @@ -7,12 +7,12 @@ import java.util.ArrayList; import java.util.List; @Data -@Schema(description = "查询 ASIN 国家维度 ASIN 列表") +@Schema(description = "查询 ASIN 国家维度 ASIN 清单") public class QueryAsinCountryAsinsDto { @Schema(description = "国家编码,支持 DE、UK、FR、IT、ES", example = "DE") private String country; - @Schema(description = "该国家需要查询的 ASIN 列表") + @Schema(description = "后台维护的该国家 ASIN 清单,创建任务时会整体推给 Python") private List asins = new ArrayList<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryMetricRowDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryMetricRowDto.java deleted file mode 100644 index 8be4dfa..0000000 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryMetricRowDto.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.nanri.aiimage.modules.queryasin.model.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -@Data -@Schema(description = "国家状态数据中的单行记录") -public class QueryAsinCountryMetricRowDto { - - @Schema(description = "商品状态", example = "正常") - private String status; - - @Schema(description = "该状态下的商品数量", example = "12") - private String quantity; - - @JsonProperty("deleteQuantity") - @Schema(description = "已删除数量", example = "3") - private String deleteQuantity; - - @JsonProperty("processStatus") - @Schema(description = "处理结果或处理状态", example = "处理中") - private String processStatus; -} - diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryResultDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryResultDto.java new file mode 100644 index 0000000..6be5a48 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountryResultDto.java @@ -0,0 +1,18 @@ +package com.nanri.aiimage.modules.queryasin.model.dto; + +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 QueryAsinCountryResultDto { + + @Schema(description = "国家编码,支持 DE、UK、FR、IT、ES", example = "DE") + private String country; + + @Schema(description = "该国家下的 ASIN 查询结果列表") + private List items = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountrySectionDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountrySectionDto.java deleted file mode 100644 index 903d538..0000000 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinCountrySectionDto.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.nanri.aiimage.modules.queryasin.model.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.util.ArrayList; -import java.util.List; - -@Data -@Schema(description = "单个国家的状态数据分组") -public class QueryAsinCountrySectionDto { - - @Schema(description = "国家名称", example = "德国") - private String country; - - @Schema(description = "该国家下的多行状态数据") - private List rows = new ArrayList<>(); -} - diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinShopPayloadDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinShopPayloadDto.java index de9ad04..f590cd9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinShopPayloadDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinShopPayloadDto.java @@ -8,42 +8,26 @@ import java.util.ArrayList; import java.util.List; @Data -@Schema(description = "单个店铺的查询ASIN结果分片") +@Schema(description = "单个店铺的查询 ASIN 结果") public class QueryAsinShopPayloadDto { @JsonProperty("shopName") - @Schema(description = "店铺名称,服务端按店铺名称聚合分片", example = "郭亚芳") + @Schema(description = "店铺名称", example = "郭亚芳") private String shopName; - @Schema(description = "店铺处理失败时的错误信息;有值时该店铺会直接标记失败", example = "紫鸟页面加载超时,未能完成删除数据采集") + @Schema(description = "店铺处理失败时的错误信息;有值时该店铺会直接标记失败", example = "紫鸟页面加载超时,未能完成 ASIN 查询") private String error; - @JsonProperty("countrySections") - @Schema(description = "各国家的状态数据列表。同一国家可分多片提交,服务端会按顺序合并") - private List countrySections = new ArrayList<>(); - - @JsonProperty("cartRatios") - @Schema(description = "购物车比例数据列表,通常只在店铺首行展示") - private List cartRatios = new ArrayList<>(); - - @JsonProperty("queryAsins") - @Schema(description = "Python 回传的查询 ASIN 结果分片;如果 Python 仅透传后台 ASIN,也按该字段提交") - private List queryAsins = new ArrayList<>(); + @JsonProperty("countryResults") + @Schema(description = "Python 回传的查询 ASIN 结果。每个国家下按 ASIN + 状态返回,对应导出表中的国家列和状态列") + private List countryResults = new ArrayList<>(); @JsonProperty("shopDone") - @Schema(description = "该店铺是否已全部提交完成。最后一片应传 true", example = "true") + @Schema(description = "该店铺是否已全部提交完成。不传时按已完成处理;分片提交时,中间分片传 false,最后一片传 true", example = "true") private Boolean shopDone; @JsonProperty("submissionId") - @Schema(description = "本次店铺提交批次标识,便于问题排查与日志跟踪", example = "query-asin:3089:郭亚芳:1711111111111") + @Schema(description = "本次店铺提交批次标识,便于问题排查与日志追踪", example = "query-asin:3089:郭亚芳:1711111111111") private String submissionId; - @JsonProperty("chunkIndex") - @Schema(description = "当前分片序号,建议从 1 开始", example = "1") - private Integer chunkIndex; - - @JsonProperty("chunkTotal") - @Schema(description = "当前店铺总分片数", example = "2") - private Integer chunkTotal; } - diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinSubmitResultRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinSubmitResultRequest.java index 96d0cf1..419eb00 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinSubmitResultRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinSubmitResultRequest.java @@ -9,7 +9,7 @@ import java.util.ArrayList; import java.util.List; @Data -@Schema(description = "查询ASIN结果回传请求") +@Schema(description = "查询 ASIN 结果回传请求") public class QueryAsinSubmitResultRequest { @Valid @@ -17,4 +17,3 @@ public class QueryAsinSubmitResultRequest { @Schema(description = "本次提交的店铺结果分片列表,支持一次提交多个店铺", requiredMode = Schema.RequiredMode.REQUIRED) private List shops = new ArrayList<>(); } - diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinTaskItemDto.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinTaskItemDto.java index 54a4c81..89311ac 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinTaskItemDto.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/dto/QueryAsinTaskItemDto.java @@ -9,7 +9,7 @@ import java.util.ArrayList; import java.util.List; @Data -@Schema(description = "查询ASIN任务中的单个店铺项") +@Schema(description = "查询 ASIN 任务中的单个店铺项") public class QueryAsinTaskItemDto { @JsonProperty("shopName") @@ -40,6 +40,6 @@ public class QueryAsinTaskItemDto { @Valid @JsonProperty("queryAsins") - @Schema(description = "后台查询 ASIN 维护表中的国家 ASIN 列表;查询ASIN任务按全表返回,不按店铺过滤") + @Schema(description = "后台查询 ASIN 维护表中的国家 ASIN 清单;查询 ASIN 任务按全表返回,不按店铺过滤") private List queryAsins = new ArrayList<>(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/vo/QueryAsinResultItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/vo/QueryAsinResultItemVo.java index a9bb3b3..14aa5d0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/vo/QueryAsinResultItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/model/vo/QueryAsinResultItemVo.java @@ -1,9 +1,8 @@ package com.nanri.aiimage.modules.queryasin.model.vo; import com.fasterxml.jackson.annotation.JsonProperty; -import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCartRatioDto; import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto; -import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountrySectionDto; +import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryResultDto; import lombok.Data; import java.time.LocalDateTime; @@ -45,13 +44,9 @@ public class QueryAsinResultItemVo { private String outputFilename; private String downloadUrl; - @JsonProperty("countrySections") - private List countrySections = new ArrayList<>(); - - @JsonProperty("cartRatios") - private List cartRatios = new ArrayList<>(); - @JsonProperty("queryAsins") private List queryAsins = new ArrayList<>(); -} + @JsonProperty("countryResults") + private List countryResults = new ArrayList<>(); +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinExcelAssemblyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinExcelAssemblyService.java index 3cb680a..14a4923 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinExcelAssemblyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinExcelAssemblyService.java @@ -1,7 +1,9 @@ package com.nanri.aiimage.modules.queryasin.service; import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinAsinStatusDto; import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto; +import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryResultDto; import com.nanri.aiimage.modules.queryasin.model.vo.QueryAsinResultItemVo; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Row; @@ -11,6 +13,7 @@ import org.springframework.stereotype.Service; import java.io.File; import java.io.FileOutputStream; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -20,7 +23,21 @@ import java.util.Map; public class QueryAsinExcelAssemblyService { private static final List COUNTRIES = List.of("DE", "UK", "FR", "IT", "ES"); - private static final String[] HEADER = {"店铺名", "德国", "英国", "法国", "意大利", "西班牙"}; + private static final Map COUNTRY_NAMES = Map.of( + "DE", "德国", + "UK", "英国", + "FR", "法国", + "IT", "意大利", + "ES", "西班牙" + ); + private static final String[] HEADER = { + "店铺名", + "德国", "状态", + "英国", "状态", + "法国", "状态", + "意大利", "状态", + "西班牙", "状态" + }; public void writeWorkbook(File outputXlsx, List items) { SXSSFWorkbook workbook = new SXSSFWorkbook(200); @@ -38,14 +55,17 @@ public class QueryAsinExcelAssemblyService { if (item == null || Boolean.FALSE.equals(item.getSuccess())) { continue; } - Map> asinsByCountry = asinsByCountry(item.getQueryAsins()); - int maxRows = maxRows(asinsByCountry); + Map> resultsByCountry = resultsByCountry(item); + int maxRows = maxRows(resultsByCountry); for (int i = 0; i < maxRows; i++) { Row row = sheet.createRow(rowIndex++); row.createCell(0).setCellValue(i == 0 ? safe(item.getShopName()) : ""); for (int c = 0; c < COUNTRIES.size(); c++) { - List asins = asinsByCountry.get(COUNTRIES.get(c)); - row.createCell(c + 1).setCellValue(asins != null && i < asins.size() ? safe(asins.get(i)) : ""); + List rows = resultsByCountry.get(COUNTRIES.get(c)); + QueryAsinAsinStatusDto result = rows != null && i < rows.size() ? rows.get(i) : null; + int asinColumn = 1 + c * 2; + row.createCell(asinColumn).setCellValue(result == null ? "" : safe(result.getAsin())); + row.createCell(asinColumn + 1).setCellValue(result == null ? "" : safe(result.getStatus())); } } rowIndex++; @@ -53,7 +73,7 @@ public class QueryAsinExcelAssemblyService { workbook.write(outputStream); } catch (Exception ex) { log.warn("[query-asin] write workbook failed: {}", ex.getMessage()); - throw new BusinessException("generate query-asin excel failed: " + ex.getMessage()); + throw new BusinessException("生成查询 ASIN Excel 失败: " + ex.getMessage()); } finally { try { workbook.close(); @@ -70,42 +90,106 @@ public class QueryAsinExcelAssemblyService { } for (QueryAsinResultItemVo item : items) { if (item != null && !Boolean.FALSE.equals(item.getSuccess())) { - count += maxRows(asinsByCountry(item.getQueryAsins())); + count += maxRows(resultsByCountry(item)); } } return count; } - private Map> asinsByCountry(List queryAsins) { - Map> map = new LinkedHashMap<>(); - for (String country : COUNTRIES) { - map.put(country, List.of()); - } - if (queryAsins == null) { + private Map> resultsByCountry(QueryAsinResultItemVo item) { + Map> map = emptyCountryMap(); + if (item == null) { return map; } - for (QueryAsinCountryAsinsDto item : queryAsins) { - if (item == null || item.getCountry() == null) { - continue; + if (item.getCountryResults() != null && !item.getCountryResults().isEmpty()) { + for (QueryAsinCountryResultDto countryResult : item.getCountryResults()) { + String country = normalizeCountry(countryResult == null ? null : countryResult.getCountry()); + if (!map.containsKey(country)) { + continue; + } + map.put(country, normalizeResultItems(countryResult.getItems())); } - String country = item.getCountry().trim().toUpperCase(); - if (map.containsKey(country)) { - map.put(country, item.getAsins() == null ? List.of() : item.getAsins()); + return map; + } + + // 兼容旧链路:如果 Python 仍然只透传 queryAsins,则状态列留空。 + if (item.getQueryAsins() != null) { + for (QueryAsinCountryAsinsDto countryAsins : item.getQueryAsins()) { + String country = normalizeCountry(countryAsins == null ? null : countryAsins.getCountry()); + if (!map.containsKey(country)) { + continue; + } + List rows = new ArrayList<>(); + for (String asin : countryAsins.getAsins() == null ? List.of() : countryAsins.getAsins()) { + String normalizedAsin = normalizeAsin(asin); + if (normalizedAsin.isEmpty()) { + continue; + } + QueryAsinAsinStatusDto row = new QueryAsinAsinStatusDto(); + row.setAsin(normalizedAsin); + row.setStatus(""); + rows.add(row); + } + map.put(country, rows); } } return map; } - private int maxRows(Map> asinsByCountry) { + private Map> emptyCountryMap() { + Map> map = new LinkedHashMap<>(); + for (String country : COUNTRIES) { + map.put(country, List.of()); + } + return map; + } + + private List normalizeResultItems(List items) { + List out = new ArrayList<>(); + if (items == null) { + return out; + } + for (QueryAsinAsinStatusDto source : items) { + String asin = normalizeAsin(source == null ? null : source.getAsin()); + String status = source == null ? "" : safe(source.getStatus()).trim(); + if (asin.isEmpty() && status.isEmpty()) { + continue; + } + QueryAsinAsinStatusDto item = new QueryAsinAsinStatusDto(); + item.setAsin(asin); + item.setStatus(status); + out.add(item); + } + return out; + } + + private int maxRows(Map> resultsByCountry) { int max = 1; - for (List asins : asinsByCountry.values()) { - if (asins != null && asins.size() > max) { - max = asins.size(); + for (List rows : resultsByCountry.values()) { + if (rows != null && rows.size() > max) { + max = rows.size(); } } return max; } + private String normalizeCountry(String country) { + if (country == null) { + return ""; + } + String value = country.trim().toUpperCase(); + for (Map.Entry entry : COUNTRY_NAMES.entrySet()) { + if (entry.getValue().equals(country.trim())) { + return entry.getKey(); + } + } + return value; + } + + private String normalizeAsin(String value) { + return value == null ? "" : value.trim().toUpperCase(); + } + private String safe(String value) { return value == null ? "" : value; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java index 27eee63..e9b1eb6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/queryasin/service/QueryAsinTaskService.java @@ -8,10 +8,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.config.TaskPressureProperties; import com.nanri.aiimage.modules.file.service.oss.OssStorageService; -import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCartRatioDto; +import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinAsinStatusDto; import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryAsinsDto; -import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryMetricRowDto; -import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountrySectionDto; +import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCountryResultDto; import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinCreateTaskRequest; import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinShopPayloadDto; import com.nanri.aiimage.modules.queryasin.model.dto.QueryAsinSubmitResultRequest; @@ -523,11 +522,8 @@ public class QueryAsinTaskService { item.setDownloadUrl(blank(entity.getResultFileUrl()) ? item.getDownloadUrl() : ossStorageService.generateFreshDownloadUrl(entity.getResultFileUrl())); - if (item.getCountrySections() == null) { - item.setCountrySections(new ArrayList<>()); - } - if (item.getCartRatios() == null) { - item.setCartRatios(new ArrayList<>()); + if (item.getCountryResults() == null) { + item.setCountryResults(new ArrayList<>()); } if (item.getQueryAsins() == null) { item.setQueryAsins(new ArrayList<>()); @@ -568,8 +564,7 @@ public class QueryAsinTaskService { vo.setError(result.getErrorMessage()); vo.setCreatedAt(result.getCreatedAt()); vo.setFinishedAt(finishedAt); - vo.setCountrySections(new ArrayList<>()); - vo.setCartRatios(new ArrayList<>()); + vo.setCountryResults(new ArrayList<>()); vo.setQueryAsins(copyCountryAsins(item.getQueryAsins())); vo.setOutputFilename(result.getResultFilename()); vo.setDownloadUrl(null); @@ -642,15 +637,11 @@ public class QueryAsinTaskService { if (merged == null) { merged = new QueryAsinShopPayloadDto(); merged.setShopName(shopKey); - merged.setCountrySections(new ArrayList<>()); - merged.setCartRatios(new ArrayList<>()); - merged.setQueryAsins(new ArrayList<>()); + merged.setCountryResults(new ArrayList<>()); } merged.setShopName(firstNonBlank(incoming.getShopName(), merged.getShopName())); merged.setSubmissionId(firstNonBlank(incoming.getSubmissionId(), merged.getSubmissionId())); - merged.setChunkIndex(incoming.getChunkIndex()); - merged.setChunkTotal(incoming.getChunkTotal()); if (!blank(incoming.getError())) { merged.setError(incoming.getError().trim()); @@ -659,10 +650,8 @@ public class QueryAsinTaskService { return merged; } - merged.setCountrySections(mergeCountrySections(merged.getCountrySections(), incoming.getCountrySections())); - merged.setCartRatios(mergeCartRatios(merged.getCartRatios(), incoming.getCartRatios())); - merged.setQueryAsins(mergeCountryAsins(merged.getQueryAsins(), incoming.getQueryAsins())); - if (Boolean.TRUE.equals(incoming.getShopDone())) { + merged.setCountryResults(mergeCountryResults(merged.getCountryResults(), incoming.getCountryResults())); + if (incoming.getShopDone() == null || Boolean.TRUE.equals(incoming.getShopDone())) { merged.setShopDone(Boolean.TRUE); } taskCacheService.saveShopMergedPayload(taskId, shopKey, merged); @@ -674,11 +663,7 @@ public class QueryAsinTaskService { return; } snapshot.setShopName(firstNonBlank(payload.getShopName(), snapshot.getShopName())); - snapshot.setCountrySections(copyCountrySections(payload.getCountrySections())); - snapshot.setCartRatios(copyCartRatios(payload.getCartRatios())); - if (payload.getQueryAsins() != null && !payload.getQueryAsins().isEmpty()) { - snapshot.setQueryAsins(copyCountryAsins(payload.getQueryAsins())); - } + snapshot.setCountryResults(copyCountryResults(payload.getCountryResults())); if (!blank(payload.getError())) { snapshot.setError(payload.getError().trim()); } @@ -700,37 +685,38 @@ public class QueryAsinTaskService { snapshot.setError(blankToNull(error)); } - private List mergeCountrySections(List base, List incoming) { - Map map = new LinkedHashMap<>(); - for (QueryAsinCountrySectionDto section : copyCountrySections(base)) { - map.put(section.getCountry(), section); + private List mergeCountryResults(List base, List incoming) { + Map map = new LinkedHashMap<>(); + for (QueryAsinCountryResultDto item : copyCountryResults(base)) { + map.put(item.getCountry(), item); } - for (QueryAsinCountrySectionDto section : copyCountrySections(incoming)) { - QueryAsinCountrySectionDto existing = map.get(section.getCountry()); - if (existing == null) { - map.put(section.getCountry(), section); + for (QueryAsinCountryResultDto item : copyCountryResults(incoming)) { + if (blank(item.getCountry())) { continue; } - List mergedRows = existing.getRows() == null - ? new ArrayList<>() - : new ArrayList<>(existing.getRows()); - if (section.getRows() != null) { - mergedRows.addAll(section.getRows()); + QueryAsinCountryResultDto existing = map.get(item.getCountry()); + if (existing == null) { + map.put(item.getCountry(), item); + continue; } - existing.setRows(mergedRows); - } - return new ArrayList<>(map.values()); - } - - private List mergeCartRatios(List base, List incoming) { - Map map = new LinkedHashMap<>(); - for (QueryAsinCartRatioDto ratio : copyCartRatios(base)) { - map.put(ratio.getCountry(), ratio); - } - for (QueryAsinCartRatioDto ratio : copyCartRatios(incoming)) { - if (!blank(ratio.getCountry())) { - map.put(ratio.getCountry(), ratio); + List merged = new ArrayList<>(existing.getItems() == null ? List.of() : existing.getItems()); + for (QueryAsinAsinStatusDto row : item.getItems() == null ? List.of() : item.getItems()) { + String asin = normalizeAsin(row == null ? null : row.getAsin()); + String status = row == null ? "" : firstNonBlank(row.getStatus(), "").trim(); + if (asin.isEmpty() && status.isEmpty()) { + continue; + } + boolean exists = merged.stream().anyMatch(old -> + Objects.equals(normalizeAsin(old.getAsin()), asin) + && Objects.equals(firstNonBlank(old.getStatus(), "").trim(), status)); + if (!exists) { + QueryAsinAsinStatusDto normalized = new QueryAsinAsinStatusDto(); + normalized.setAsin(asin); + normalized.setStatus(status); + merged.add(normalized); + } } + existing.setItems(merged); } return new ArrayList<>(map.values()); } @@ -741,17 +727,19 @@ public class QueryAsinTaskService { map.put(item.getCountry(), item); } for (QueryAsinCountryAsinsDto item : copyCountryAsins(incoming)) { - if (blank(item.getCountry())) { + String country = normalizeCountry(item.getCountry()); + if (blank(country)) { continue; } - QueryAsinCountryAsinsDto existing = map.get(item.getCountry()); + item.setCountry(country); + QueryAsinCountryAsinsDto existing = map.get(country); if (existing == null) { - map.put(item.getCountry(), item); + map.put(country, item); continue; } List merged = new ArrayList<>(existing.getAsins() == null ? List.of() : existing.getAsins()); for (String asin : item.getAsins() == null ? List.of() : item.getAsins()) { - String normalized = asin == null ? "" : asin.trim().toUpperCase(); + String normalized = normalizeAsin(asin); if (!normalized.isEmpty() && !merged.contains(normalized)) { merged.add(normalized); } @@ -765,35 +753,18 @@ public class QueryAsinTaskService { if (payload == null) { return false; } - if (payload.getQueryAsins() != null) { - for (QueryAsinCountryAsinsDto item : payload.getQueryAsins()) { - if (item != null && item.getAsins() != null && !item.getAsins().isEmpty()) { - return true; - } - } - } - if (payload.getCountrySections() != null) { - for (QueryAsinCountrySectionDto section : payload.getCountrySections()) { - if (section == null || section.getRows() == null) { + if (payload.getCountryResults() != null) { + for (QueryAsinCountryResultDto countryResult : payload.getCountryResults()) { + if (countryResult == null || countryResult.getItems() == null) { continue; } - for (QueryAsinCountryMetricRowDto row : section.getRows()) { - if (row != null && (!blank(row.getStatus()) - || !blank(row.getQuantity()) - || !blank(row.getDeleteQuantity()) - || !blank(row.getProcessStatus()))) { + for (QueryAsinAsinStatusDto item : countryResult.getItems()) { + if (item != null && (!blank(item.getAsin()) || !blank(item.getStatus()))) { return true; } } } } - if (payload.getCartRatios() != null) { - for (QueryAsinCartRatioDto ratio : payload.getCartRatios()) { - if (ratio != null && (!blank(ratio.getCountry()) || !blank(ratio.getRatio()))) { - return true; - } - } - } return false; } @@ -872,35 +843,32 @@ public class QueryAsinTaskService { } } - private List copyCountrySections(List sections) { - List copy = new ArrayList<>(); - if (sections == null) { + private List copyCountryResults(List results) { + List copy = new ArrayList<>(); + if (results == null) { return copy; } - for (QueryAsinCountrySectionDto section : sections) { - if (section == null) { + for (QueryAsinCountryResultDto source : results) { + if (source == null || blank(source.getCountry())) { continue; } - QueryAsinCountrySectionDto item = new QueryAsinCountrySectionDto(); - item.setCountry(section.getCountry()); - item.setRows(section.getRows() == null ? new ArrayList<>() : new ArrayList<>(section.getRows())); - copy.add(item); - } - return copy; - } - - private List copyCartRatios(List ratios) { - List copy = new ArrayList<>(); - if (ratios == null) { - return copy; - } - for (QueryAsinCartRatioDto ratio : ratios) { - if (ratio == null) { - continue; + QueryAsinCountryResultDto item = new QueryAsinCountryResultDto(); + item.setCountry(normalizeCountry(source.getCountry())); + List rows = new ArrayList<>(); + if (source.getItems() != null) { + for (QueryAsinAsinStatusDto sourceRow : source.getItems()) { + String asin = normalizeAsin(sourceRow == null ? null : sourceRow.getAsin()); + String status = sourceRow == null ? "" : firstNonBlank(sourceRow.getStatus(), "").trim(); + if (asin.isEmpty() && status.isEmpty()) { + continue; + } + QueryAsinAsinStatusDto row = new QueryAsinAsinStatusDto(); + row.setAsin(asin); + row.setStatus(status); + rows.add(row); + } } - QueryAsinCartRatioDto item = new QueryAsinCartRatioDto(); - item.setCountry(ratio.getCountry()); - item.setRatio(ratio.getRatio()); + item.setItems(rows); copy.add(item); } return copy; @@ -916,11 +884,11 @@ public class QueryAsinTaskService { continue; } QueryAsinCountryAsinsDto item = new QueryAsinCountryAsinsDto(); - item.setCountry(source.getCountry().trim().toUpperCase()); + item.setCountry(normalizeCountry(source.getCountry())); List asins = new ArrayList<>(); if (source.getAsins() != null) { for (String asin : source.getAsins()) { - String normalized = asin == null ? "" : asin.trim().toUpperCase(); + String normalized = normalizeAsin(asin); if (!normalized.isEmpty() && !asins.contains(normalized)) { asins.add(normalized); } @@ -936,6 +904,25 @@ public class QueryAsinTaskService { return !blank(first) ? first : second; } + 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; + }; + } + + private String normalizeAsin(String asin) { + return asin == null ? "" : asin.trim().toUpperCase(); + } + private String safeFileStem(String value) { String raw = value == null ? "result" : value.trim(); String safe = raw.replaceAll("[\\\\/:*?\"<>|]+", "_"); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java index 4a10295..b823dc8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskCacheService.java @@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.task.service.TaskScopePayloadStorageService; import com.nanri.aiimage.config.TaskPressureProperties; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import java.io.IOException; @@ -14,6 +15,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.time.Duration; +import java.time.Instant; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.Map; @@ -26,11 +28,37 @@ public class ShopMatchTaskCacheService { private static final String MODULE_TYPE = "SHOP_MATCH"; private static final long PAYLOAD_TTL_HOURS = 24; + private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; private final TaskPressureProperties taskPressureProperties; private final TaskScopePayloadStorageService taskScopePayloadStorageService; private final ConcurrentHashMap taskEntityLocalCache = new ConcurrentHashMap<>(); + public void touchTaskHeartbeat(Long taskId) { + if (taskId == null || taskId <= 0) { + return; + } + stringRedisTemplate.opsForValue().set( + buildTaskHeartbeatKey(taskId), + String.valueOf(Instant.now().toEpochMilli()), + Duration.ofHours(PAYLOAD_TTL_HOURS)); + } + + public long getTaskHeartbeatMillis(Long taskId) { + if (taskId == null || taskId <= 0) { + return 0L; + } + String raw = stringRedisTemplate.opsForValue().get(buildTaskHeartbeatKey(taskId)); + if (raw == null || raw.isBlank()) { + return 0L; + } + try { + return Long.parseLong(raw); + } catch (NumberFormatException ignored) { + return 0L; + } + } + public ShopMatchShopPayloadDto getShopMergedPayload(Long taskId, String shopKey) { return taskScopePayloadStorageService.getScopePayload(taskId, MODULE_TYPE, shopKey, ShopMatchShopPayloadDto.class); } @@ -40,6 +68,7 @@ public class ShopMatchTaskCacheService { return; } taskScopePayloadStorageService.saveScopePayload(taskId, MODULE_TYPE, shopKey, payload); + touchTaskHeartbeat(taskId); } public void removeShopMergedPayload(Long taskId, String shopKey) { @@ -59,6 +88,7 @@ public class ShopMatchTaskCacheService { return; } taskEntityLocalCache.remove(taskId); + stringRedisTemplate.delete(buildTaskHeartbeatKey(taskId)); taskScopePayloadStorageService.deleteTaskScopePayloads(taskId, MODULE_TYPE); try { Path taskDir = buildTaskDir(taskId); @@ -156,6 +186,10 @@ public class ShopMatchTaskCacheService { return buildTaskDir(taskId).resolve("_task-entity.json"); } + private String buildTaskHeartbeatKey(Long taskId) { + return "shop-match:task:heartbeat:" + taskId; + } + private boolean isLocalCacheFresh(LocalTaskEntityCacheEntry cached, long now) { return cached != null && now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java index 1b213c9..32f33f0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopmatch/service/ShopMatchTaskService.java @@ -364,6 +364,9 @@ public class ShopMatchTaskService { throw new BusinessException("task 不存在"); } updateTaskAndRefreshCache(task); + if ("RUNNING".equals(task.getStatus())) { + shopMatchTaskCacheService.touchTaskHeartbeat(task.getId()); + } ProductRiskCreateTaskVo vo = new ProductRiskCreateTaskVo(); vo.setTaskId(task.getId()); @@ -411,6 +414,7 @@ public class ShopMatchTaskService { task.setUpdatedAt(now); persistTaskRequest(task, state); updateTaskAndRefreshCache(task); + shopMatchTaskCacheService.touchTaskHeartbeat(taskId); } @Transactional @@ -467,6 +471,7 @@ public class ShopMatchTaskService { throw new BusinessException(40901, "任务已结束,拒绝重复提交"); } + shopMatchTaskCacheService.touchTaskHeartbeat(taskId); List resultRows = fileResultMapper.selectList(new LambdaQueryWrapper() .eq(FileResultEntity::getTaskId, taskId) .eq(FileResultEntity::getModuleType, MODULE_TYPE) diff --git a/backend/__pycache__/config.cpython-312.pyc b/backend/__pycache__/config.cpython-312.pyc index 0ea50cdc4893afdba2e69d0b8649c4c8fe691f61..08477f7ce04a0e3486e7c30061df54b3d730c5f0 100644 GIT binary patch delta 87 zcmaFH{gRveG%qg~0}wJ8qokz3N?+eX&(PRR&(KoO(A;>lFmoW2$^!|h r`CK!(=JU1{7u4-8I7D2Qh`cV5a8V-RLSo8giPQ@Wq7No_v&I1cT2vf( diff --git a/backend/blueprints/__pycache__/admin_api.cpython-312.pyc b/backend/blueprints/__pycache__/admin_api.cpython-312.pyc index eeba27b2fa24cbf0559cc85755647663008da356..d65990952e690a907fb0ea74339b6e60527f869a 100644 GIT binary patch delta 24725 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 BoK^q; diff --git a/backend/blueprints/__pycache__/auth.cpython-312.pyc b/backend/blueprints/__pycache__/auth.cpython-312.pyc index 00d50f0cc768482d9fbe7896698d0d8bdb90385f..7e1da45db512d73f5f51bece553340c1b4165cda 100644 GIT binary patch delta 3041 zcma(TOKcm*b!NHbF275Qlthu#$Cgx!l_^JZ>_C>~+ECnB(XR+wSSAE47|u!}&5tv? ztZaug6(B%VphzO@!A4@F2^t^?WVC71OFuw+i%=P;XbPpg@Uf?!Z1@l$r_3xhB;yE0 zcY!x=-h1=r&HK&VJ@&_8&yL&e0Oacr{WrDY+4KZp$L8_li&-%)X6PAZQ|C9Co9FJtT*m8=eEp|Y+Jky0uCq~5)>a2@7uR5R6PK3zv531Z+W?!19ef~o{Rg2R_fLAp;If52p?EgFBsg~pMOh4t5~iqjj0|egau2 zv0n=@VXd2C$f^H<^POT-aHxk})Wh#X{8@_ub^kWk^)%21!a~gmhq>TTFS^-5kcaME z;#xA`d;1)Og&gf5ivdXY>6^~rY))!fbBtNg97~p><X&G zHl5wWLQzOZ8NWHAjv~JdnHC6$)JbY(M@dA0{w*w9dX$txxLg+TlGX@xQAC|IM~YS^ zisw*36WJbekYPl!i%K?m$YG`}-$Id03Wqyeybx&3vaRw#gi!E%jyW_Xnd8_P13z$Z z_+a|fI z#MLGHZToW(4mAyEm8r{?*Wencn-J-qH*A^wO1_{OaMZA=Ybh4Q~Z)_L%2*myRE5b57rvFZuPIhD3k_4`3}IDCAO)+~L&fPGfobE{ zd&D0>m;Ne&PW=tB_msnMFXwTRUPPw146$(|Io1+6O_kGXK4|JM2U@SnrA z@#)I=^!8A7Jhpo!UKx+C&pj4A@7#Xt_TBb}LeD3Yk45P{_pjW)mLB{1J`H`oR6TmO z>YLaQ_eF0_?5T)7_uj1aj#hd{YrUr{y{9L4&&+IJt@d8o6BqYSENzH2F;Ec$cY7;h zWMAsskXp5W`qF0;pG@PFMT1^h9)XQ z6T9c;t3wOBv1DZ^x#7?U?DLjKQkQ(tDEhy~L| zgC{9CPk>4vIwX|%g8mP;-}*9{O@8&r?S>ATFqHZ1-})2JR2gyiEv(bAS}uViNH<;# zME2m*%*Pp>q^U{Ied$IoZ!!>1UKK6(KIM?R!$$ObO)2vs(2z%h*)Bm0M8x@9jzmFQ z9WfIa2Z#(QtWGju+JWmXqD>NqDa&3b&?}=^7x>3?N{z2Fh;iylkgD`!{nGL~k)v4sc zNt#5xn~1VZTazqf>Tkn}NlR5Qt!+$m!aViaF97~2om?Qm5YJ`vsE|?4W9kTcIjJ8K z@WcWke9Cc<|HcO3GOPim3Kaca@0c_U2cKC0ck&5GuqR9Uzq~KCS)rfOI-ha`GqryQ D3~N8* delta 1909 zcmaJ>O>7%Q6rR~#?|Rqv+OcChe{NFe*L54`kA_Gkp%h43(SXRQ=3trZHnC$TXuMWR zYR5zrD#D=+Q;7%_A;bxV3sgcvaH2pfkWi#V0$Jh4p%Mpzf`r70dAqFRR*<#)=FNNG zym|B9H}hG1GphdJ@hFIWZ;U<2%(*tyCaiAs_P&6ThBZFt%*#pH)P?#Q|G zo}?$QCRKAT=Dc}d(uWa;bWW4>2F>}2yv0LUgpz*UpY5d|a)FI6jyW1@li!tAkLJ6E zwk%j}Oa^TT^@%baz#bf;CW`y;0{exVPco0z<1m}dt_R<(hLBMaoRXIURz^S1OXdh3?>ZRg=wMkSQx z;j{r^3tRRpxWKVGIkG_(qSb+IL`8fQQRYgRa3!t~uiNFn5>t(^y{9H=PEEee*|BDj z(OmN>tP`#+&YI6485ym%Zwt9Cwal0JYy?i{d<>O^l8|jT^LLlpCh4Pj@o@S5N0i@RU;g^W&<0;?<;yGlCA5q?k-_+AeO-v0 zD~m@Tl(f~IQ!?WA+ER^B^%>1_msq>X;;Q%vH5y`v$<>1_@G@RTR(i3(G#!&$eL(lB zvslo{VmhxAFMU&f(zl}ImgX{x{qsgX7sI55elK6f&2&&X-y0%O-zq<+8w9vU;HDB0 zga8gPuN(7A8vRA-5j|j4sNm|sGL5_1RjDdyX(LU5=cI|2UCjbSqwl-o;{!~ATT%Fy z-LTituabKG>e|(tW4lshH#)i-9ozGUZ%f-9gF8(_JKo`K=kOkbtgfwYhIXWO`lEYV z+Vh5PRMsoB-SeKb7if8R;++Zlm8Vz!Q$g;)jfwRM`oQx<4b`V6rtWsNO!lF><6V<& z{5R3F2{!r8A7Jucn`5$Dx_4Xxy-%JR;qMLhOeOgHVa)XV5jW^DQ%^XiM(%u|hBzFg zpZlJvgxDcs92sJRS)7<+mW)Ke-~<6YB%L6-K)~%HJs^NEW-Vhp0RoVdAWkt+fQPrE zqk|;aSXI-nWK!v(F`t^9*Jl^VFf)z->T87VtTc#Nfv&)kgBR(#|6IksD)uX=~l)iImX)8w$JAEN^n1zFpQ47yY_$m7g1$^Hd+cUY?g*Y%5Q7q30P=L+1K z+n=ji)NrhGm^BtmwtL{(?SRBpKsX0uMQ3pOMKG`n8wM z!c-M&3b_$@sZE+y-IkT;i<(XdFstfJrGT@k6an5;9fakk6X20q5Hn438m5rPfOQ7v znf#Jg%<1FgEHlDmUf5(}pT`*g!EsplOGNkr{t2C@eZe7b6n7u+h)e8qOxwReGr>_m NhvQ%i13YfV{s&roU$X!J diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index a62ddd2..8ca619d 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -6,7 +6,7 @@ import os import re import requests -from flask import Blueprint, request, jsonify, session +from flask import Blueprint, request, jsonify, session, current_app import pymysql from werkzeug.security import generate_password_hash @@ -461,7 +461,10 @@ def get_admin_current_user_menus(): @login_required def admin_logout(): session.clear() - return jsonify({'success': True, 'msg': '退出成功', 'redirect': '/login'}) + response = jsonify({'success': True, 'msg': '退出成功', 'redirect': '/login?logout=1'}) + response.delete_cookie(current_app.config.get('SESSION_COOKIE_NAME', 'session')) + response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' + return response # ---------- 用户管理 ---------- diff --git a/backend/blueprints/auth.py b/backend/blueprints/auth.py index 4d1e5ff..1f64bb5 100644 --- a/backend/blueprints/auth.py +++ b/backend/blueprints/auth.py @@ -1,7 +1,7 @@ """ 认证蓝图:登录、登出、登录状态校验 """ -from flask import Blueprint, request, redirect, url_for, session, jsonify +from flask import Blueprint, request, redirect, url_for, session, jsonify, make_response, current_app from werkzeug.security import check_password_hash from utils.db import get_db @@ -13,14 +13,23 @@ auth = Blueprint('auth', __name__, url_prefix='') @auth.route('/login', methods=['GET', 'POST']) def login(): - if session.get('user_id') and is_session_user_valid(): + force_relogin = request.args.get('logout') == '1' or request.args.get('switch') == '1' + if request.method == 'GET' and force_relogin: + session.clear() + response = make_response(render_html('login.html')) + response.delete_cookie(current_app.config.get('SESSION_COOKIE_NAME', 'session')) + response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' + return response + if request.method == 'GET' and session.get('user_id') and is_session_user_valid(): return redirect(url_for('main.admin_page')) if request.method == 'POST': + session.clear() + wants_json = request.is_json or request.headers.get('X-Requested-With') == 'XMLHttpRequest' data = request.get_json() if request.is_json else request.form username = (data.get('username') or '').strip() password = data.get('password') or '' if not username or not password: - if request.is_json: + if wants_json: return jsonify({'success': False, 'error': '请输入用户名和密码'}) return render_html('login.html', error='请输入用户名和密码') try: @@ -36,14 +45,14 @@ def login(): session.permanent = True session['user_id'] = row['id'] session['username'] = username - if request.is_json: + if wants_json: return jsonify({'success': True, 'redirect': url_for('main.admin_page')}) return redirect(url_for('main.admin_page')) except Exception as exc: - if request.is_json: + if wants_json: return jsonify({'success': False, 'error': str(exc)}) return render_html('login.html', error='登录失败,请稍后重试') - if request.is_json: + if wants_json: return jsonify({'success': False, 'error': '用户名或密码错误'}) return render_html('login.html', error='用户名或密码错误') return render_html('login.html') @@ -71,4 +80,7 @@ def api_auth_check(): @auth.route('/logout') def logout(): session.clear() - return redirect(url_for('auth.login')) + response = redirect(url_for('auth.login', logout='1')) + response.delete_cookie(current_app.config.get('SESSION_COOKIE_NAME', 'session')) + response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' + return response diff --git a/backend/config.py b/backend/config.py index 97a3eb7..6692838 100644 --- a/backend/config.py +++ b/backend/config.py @@ -23,8 +23,8 @@ bucket_path = "nanri-image/" file_url_pre = f"https://{bucket}.oss-cn-hangzhou.aliyuncs.com/" import os -backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/') -# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/') +# backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://127.0.0.1:18080').rstrip('/') +backend_java_base_url = os.environ.get('BACKEND_JAVA_BASE_URL', 'http://8.136.19.173:18080').rstrip('/') os.environ['OSS_ACCESS_KEY_ID'] = accessKeyId os.environ['OSS_ACCESS_KEY_SECRET'] = accessKeySecret os.environ['SECRET_KEY'] = "ddffc7c1d02121d9554d7b080b2511b6" diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 051083d..6094daf 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -4162,10 +4162,10 @@ alert(res.error || '退出失败'); return; } - window.location.href = res.redirect || '/login'; + window.location.replace(res.redirect || '/login?logout=1'); }) .catch(function () { - window.location.href = '/logout'; + window.location.replace('/logout'); }); }; diff --git a/backend/web_source/login.html b/backend/web_source/login.html index 6e2b784..b8df99d 100644 --- a/backend/web_source/login.html +++ b/backend/web_source/login.html @@ -118,6 +118,10 @@