From 159d52194cd1aab6437d4eaccfb441e307e1e68d Mon Sep 17 00:00:00 2001 From: supernijia Date: Sat, 22 Aug 2026 21:23:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=BB=BB=E5=8A=A1=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E3=80=81=E6=9D=83=E9=99=90=E4=B8=8E=E8=B4=A7=E6=BA=90?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-java/pom.xml | 8 + .../config/TaskPressureProperties.java | 2 + .../controller/DedupeTotalDataController.java | 27 ++- .../service/DedupeTotalDataService.java | 85 +++++++- .../service/PriceTrackTaskService.java | 101 +++++++++ .../service/ShopDataCrawlTaskService.java | 35 ++- .../model/dto/ShopManageCreateRequest.java | 7 +- .../model/dto/ShopManageUpdateRequest.java | 7 +- .../shopkey/model/entity/ShopKeyEntity.java | 1 + .../model/entity/ShopManageEntity.java | 1 + .../model/vo/ShopManageCredentialVo.java | 1 + .../shopkey/model/vo/ShopManageItemVo.java | 1 + .../shopkey/service/ShopKeyService.java | 70 +++++- .../shopkey/service/ShopManageService.java | 10 + .../shopkey/service/SkipPriceAsinService.java | 17 +- .../service/ShopMatchTaskCacheService.java | 25 +++ .../service/ShopMatchTaskService.java | 23 +- .../service/ZiniaoMemoryStoreService.java | 7 + .../service/ZiniaoTransientCacheService.java | 13 ++ .../service/ZiniaoShopIndexService.java | 19 +- .../src/main/resources/application.yml | 10 + ...=> V25_1__shop_manage_group_bind_user.sql} | 0 ...ge_zn_username_and_shop_key_token_hash.sql | 20 ++ .../DedupeTotalDataControllerTest.java | 202 ++++++++++++++++++ .../service/DedupeTotalDataServiceTest.java | 115 ++++++++++ .../service/PriceTrackTaskServiceTest.java | 155 ++++++++++++++ .../service/SkipPriceAsinServiceTest.java | 158 ++++++++++++-- backend/blueprints/admin_api.py | 57 +++-- backend/static/admin.js | 49 ++++- backend/tests/test_admin_dedupe_total_data.py | 1 + backend/web_source/admin.html | 69 +++++- .../components/BrandShopDataCrawlTab.vue | 12 +- 32 files changed, 1239 insertions(+), 69 deletions(-) rename backend-java/src/main/resources/db/{V25__shop_manage_group_bind_user.sql => V25_1__shop_manage_group_bind_user.sql} (100%) create mode 100644 backend-java/src/main/resources/db/V89__shop_manage_zn_username_and_shop_key_token_hash.sql create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataControllerTest.java diff --git a/backend-java/pom.xml b/backend-java/pom.xml index 0df05606..acf726ba 100644 --- a/backend-java/pom.xml +++ b/backend-java/pom.xml @@ -70,6 +70,14 @@ mysql-connector-j runtime + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-mysql + com.alibaba easyexcel diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/TaskPressureProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/TaskPressureProperties.java index 3a157f04..9640e392 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/TaskPressureProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/TaskPressureProperties.java @@ -7,6 +7,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; @ConfigurationProperties(prefix = "aiimage.task-pressure") public class TaskPressureProperties { private long localTaskEntityCacheMillis = 3000; + // 本地文件缓存有效时长,超过该时长视为过期、强制回查 DB,避免陈旧 RUNNING 被复活 + private long localTaskEntityFileCacheMillis = 60000; private int dbSelectBatchSize = 200; private long scopePayloadFlushIntervalMillis = 15000; private long scopePayloadBufferRetentionHours = 24; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java index 3aaf6e79..8031804f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataController.java @@ -42,6 +42,7 @@ import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBo import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -58,6 +59,8 @@ public class DedupeTotalDataController { private static final String DEDUPE_TOTAL_DATA_ROUTE_PATH = "dedupe-total-data"; private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private static final String XLSX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + private static final String ZIP_CONTENT_TYPE = "application/zip"; private final DedupeTotalDataService dedupeTotalDataService; private final AdminAuthSupport adminAuthSupport; @@ -91,7 +94,7 @@ public class DedupeTotalDataController { } @GetMapping("/export") - @Operation(summary = "导出总数据", description = "按上传用户名和创建日期导出当前用户可访问的总数据。") + @Operation(summary = "导出总数据", description = "按上传用户名和创建日期导出当前用户可访问的总数据;跨自然月时按月生成 XLSX 并打包为 ZIP。") public ResponseEntity export( @Parameter(description = "用户名模糊搜索关键字") @RequestParam(required = false) String username, @Parameter(description = "开始日期(包含)") @@ -100,16 +103,30 @@ public class DedupeTotalDataController { @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId, HttpServletRequest request) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new BusinessException(400, "invalid export date range"); + } RequestOperator operator = requireDedupeTotalDataAccess(request); - String filename = "dedupe-total-data-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx"; - StreamingResponseBody body = outputStream -> dedupeTotalDataService.writeExport( - outputStream, username, startDate, endDate, groupId, operator.id()); + boolean monthlyZip = isCrossMonthRange(startDate, endDate); + String filename = "dedupe-total-data-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + + (monthlyZip ? ".zip" : ".xlsx"); + StreamingResponseBody body = monthlyZip + ? outputStream -> dedupeTotalDataService.writeMonthlyZipExport( + outputStream, username, startDate, endDate, groupId, operator.id()) + : outputStream -> dedupeTotalDataService.writeExport( + outputStream, username, startDate, endDate, groupId, operator.id()); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename)) - .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .contentType(MediaType.parseMediaType(monthlyZip ? ZIP_CONTENT_TYPE : XLSX_CONTENT_TYPE)) .body(body); } + private boolean isCrossMonthRange(LocalDate startDate, LocalDate endDate) { + return startDate != null + && endDate != null + && !YearMonth.from(startDate).equals(YearMonth.from(endDate)); + } + @PostMapping @Operation(summary = "新增总数据", description = "新增一条数据去重总数据。") @ApiResponses({ 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 abf240cd..cd7b4617 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 @@ -40,6 +40,7 @@ import java.io.OutputStream; import java.nio.file.Files; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Collection; @@ -53,6 +54,9 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.function.Supplier; +import java.util.zip.Deflater; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; @Service @RequiredArgsConstructor @@ -157,12 +161,67 @@ public class DedupeTotalDataService { buildExportWorkbook(outputStream, safeUsername, startDate, endDate, groupId, scope); } + public void writeMonthlyZipExport(OutputStream outputStream, + String username, + LocalDate startDate, + LocalDate endDate, + Long groupId, + Long operatorId) { + if (outputStream == null) { + throw new BusinessException("export output stream cannot be null"); + } + if (startDate == null || endDate == null || YearMonth.from(startDate).equals(YearMonth.from(endDate))) { + writeExport(outputStream, username, startDate, endDate, groupId, operatorId); + return; + } + if (startDate.isAfter(endDate)) { + throw new BusinessException("invalid export date range"); + } + String safeUsername = username == null ? "" : username.trim(); + AccessScope scope = resolveAccessScope(operatorId); + boolean groupPrevalidated = groupId != null && groupId > 0; + if (groupPrevalidated) { + resolveAccessibleGroup(groupId, scope); + } + try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream)) { + zipOutputStream.setLevel(Deflater.NO_COMPRESSION); + YearMonth currentMonth = YearMonth.from(startDate); + YearMonth endMonth = YearMonth.from(endDate); + while (!currentMonth.isAfter(endMonth)) { + LocalDate monthStart = currentMonth.atDay(1); + LocalDate monthEnd = currentMonth.atEndOfMonth(); + LocalDate entryStartDate = startDate.isAfter(monthStart) ? startDate : monthStart; + LocalDate entryEndDate = endDate.isBefore(monthEnd) ? endDate : monthEnd; + zipOutputStream.putNextEntry(new ZipEntry(monthlyExportEntryName(currentMonth))); + buildExportWorkbook(zipOutputStream, safeUsername, entryStartDate, entryEndDate, + groupId, scope, groupPrevalidated); + zipOutputStream.closeEntry(); + currentMonth = currentMonth.plusMonths(1); + } + zipOutputStream.finish(); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException("export dedupe total data failed"); + } + } + private void buildExportWorkbook(OutputStream outputStream, String username, LocalDate startDate, LocalDate endDate, Long groupId, AccessScope scope) { + buildExportWorkbook(outputStream, username, startDate, endDate, groupId, scope, false); + } + + private void buildExportWorkbook(OutputStream outputStream, + String username, + LocalDate startDate, + LocalDate endDate, + Long groupId, + AccessScope scope, + boolean groupPrevalidated) { try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { Sheet sheet = workbook.createSheet("DedupeTotalData"); Row header = sheet.createRow(0); @@ -175,7 +234,7 @@ public class DedupeTotalDataService { int rowIndex = 1; while (true) { LambdaQueryWrapper pageQuery = buildExportQuery( - username, startDate, endDate, groupId, scope); + username, startDate, endDate, groupId, scope, groupPrevalidated); if (lastId != null) { pageQuery.lt(DedupeTotalDataEntity::getId, lastId); } @@ -221,6 +280,15 @@ public class DedupeTotalDataService { LocalDate endDate, Long groupId, AccessScope scope) { + return buildExportQuery(username, startDate, endDate, groupId, scope, false); + } + + private LambdaQueryWrapper buildExportQuery(String username, + LocalDate startDate, + LocalDate endDate, + Long groupId, + AccessScope scope, + boolean groupPrevalidated) { String safeUsername = username == null ? "" : username.trim(); LambdaQueryWrapper query = new LambdaQueryWrapper() .like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername) @@ -229,7 +297,7 @@ public class DedupeTotalDataService { .lt(endDate != null, DedupeTotalDataEntity::getCreatedAt, endDate == null ? null : endDate.plusDays(1).atStartOfDay()) .orderByDesc(DedupeTotalDataEntity::getId); - applyGroupScope(query, scope, groupId); + applyGroupScope(query, scope, groupId, groupPrevalidated); return query; } @@ -237,6 +305,10 @@ public class DedupeTotalDataService { return value == null ? "" : value.format(EXPORT_TIME_FORMATTER); } + private String monthlyExportEntryName(YearMonth month) { + return "dedupe-total-data-" + month + ".xlsx"; + } + @Transactional public DedupeTotalDataItemVo create(DedupeTotalDataCreateRequest request, Long operatorId) { AdminUserEntity uploader = getOperator(operatorId); @@ -885,8 +957,15 @@ public class DedupeTotalDataService { private void applyGroupScope(LambdaQueryWrapper query, AccessScope scope, Long groupId) { + applyGroupScope(query, scope, groupId, false); + } + + private void applyGroupScope(LambdaQueryWrapper query, + AccessScope scope, Long groupId, boolean groupPrevalidated) { if (groupId != null && groupId > 0) { - resolveAccessibleGroup(groupId, scope); + if (!groupPrevalidated) { + resolveAccessibleGroup(groupId, scope); + } query.eq(DedupeTotalDataEntity::getGroupId, groupId); return; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java index c995240d..7dc4a81d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskService.java @@ -815,11 +815,112 @@ public class PriceTrackTaskService { private void enqueueResultFileAssembly(FileResultEntity result, String shopKey, PriceTrackSubmitResultRequest.ShopResult payload) { + applyServerModifyCounts(result.getTaskId(), payload); taskResultPayloadService.saveLatest(result.getTaskId(), MODULE_TYPE, shopKey, payload); markResultFilePending(result, shopKey, payload); taskFileJobService.enqueueAssembleResult(result.getTaskId(), MODULE_TYPE, result.getId(), shopKey); } + private void applyServerModifyCounts( + Long taskId, + PriceTrackSubmitResultRequest.ShopResult payload) { + if (payload == null || payload.getCountries() == null || payload.getCountries().isEmpty()) { + return; + } + Map baselineByAsin = buildModifyCountBaseline(taskId); + for (Map.Entry> entry : payload.getCountries().entrySet()) { + String countryCode = normalizeCountryCode(entry.getKey()); + if (entry.getValue() == null) { + continue; + } + for (PriceTrackSubmitResultRequest.AsinResult row : entry.getValue()) { + if (row == null) { + continue; + } + String asin = normalizeAsin(row.getAsin()); + int baseline = baselineByAsin.getOrDefault(buildModifyCountKey(countryCode, asin), 0); + row.setModifyCount(String.valueOf(addOneIfChanged(baseline, row))); + } + } + } + + private Map buildModifyCountBaseline(Long taskId) { + Map>> originalRows = loadTaskAsinRowsPayload(taskId); + if (originalRows == null || originalRows.isEmpty()) { + return Map.of(); + } + Map out = new LinkedHashMap<>(); + for (Map.Entry>> entry : originalRows.entrySet()) { + String countryCode = normalizeCountryCode(entry.getKey()); + if (entry.getValue() == null) { + continue; + } + for (Map row : entry.getValue()) { + if (row == null) { + continue; + } + String asin = normalizeAsin(row.get("asin")); + if (asin.isBlank()) { + continue; + } + out.putIfAbsent( + buildModifyCountKey(countryCode, asin), + parseModifyCountBaseline(row.get("modifyCount"))); + } + } + return out; + } + + private String buildModifyCountKey(String countryCode, String asin) { + return countryCode + "|" + asin; + } + + private String normalizeCountryCode(String countryCode) { + return countryCode == null ? "" : countryCode.trim().toUpperCase(Locale.ROOT); + } + + private String normalizeAsin(String asin) { + return asin == null ? "" : asin.trim().toUpperCase(Locale.ROOT); + } + + private int parseModifyCountBaseline(String value) { + String normalized = normalizeCellText(value); + if (normalized.isBlank()) { + return 0; + } + int decimalPoint = normalized.indexOf('.'); + if (decimalPoint >= 0) { + for (int i = decimalPoint + 1; i < normalized.length(); i++) { + if (normalized.charAt(i) != '0') { + return 0; + } + } + normalized = normalized.substring(0, decimalPoint); + } + try { + long parsed = Long.parseLong(normalized); + if (parsed <= 0) { + return 0; + } + return parsed > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) parsed; + } catch (NumberFormatException ex) { + return 0; + } + } + + private int addOneIfChanged(int baseline, PriceTrackSubmitResultRequest.AsinResult row) { + if (!isPriceUpdateSuccessStatus(row.getPriceChangeStatus())) { + return baseline; + } + return baseline >= Integer.MAX_VALUE ? Integer.MAX_VALUE : baseline + 1; + } + + private boolean isPriceUpdateSuccessStatus(String value) { + String normalized = normalizeCellText(value); + return "\u6539\u4ef7\u6210\u529f".equals(normalized) + || "UPDATED".equalsIgnoreCase(normalized); + } + private void markResultFilePending(FileResultEntity result, String shopKey, PriceTrackSubmitResultRequest.ShopResult payload) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java index 05e16d82..e8ce4e87 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlTaskService.java @@ -618,11 +618,15 @@ public class ShopDataCrawlTaskService { try (TaskDistributedLockService.LockHandle ignored = acquireTaskLockOrThrow(taskId)) { List taskRows = listTaskRows(taskId); try (DailyLockSet dailyLocks = acquireDailyLocks(task.getUserId(), taskRows)) { + ensureDailySyncCompletedBeforeDelete(taskRows); Set removedResultIds = taskRows.stream() .map(FileResultEntity::getId) .filter(id -> id != null && id > 0) .collect(java.util.stream.Collectors.toSet()); - DailyDeletionResult dailyResult = prepareDailyForDeletion(removedResultIds); + // A task deletion is only a frontend task-record cleanup. The daily + // workbook is an independent backend aggregate and must not roll + // back when its source task is removed. + DailyDeletionResult dailyResult = preserveDailyForTaskDeletion(removedResultIds); registerUploadedObjectRollback(dailyResult.uploadedObjectKeys()); List resultFileUrls = new ArrayList<>(dailyResult.obsoleteObjectKeys()); resultFileUrls.addAll(taskRows.stream() @@ -642,6 +646,24 @@ public class ShopDataCrawlTaskService { } } + private void ensureDailySyncCompletedBeforeDelete(List taskRows) { + if (taskRows == null || taskRows.isEmpty()) { + throw new BusinessException("后台店铺数据尚未完成同步,暂不能删除任务"); + } + for (FileResultEntity row : taskRows) { + if (row == null || !isResultFinished(row)) { + throw new BusinessException("后台店铺数据尚未完成同步,暂不能删除任务"); + } + if (!Integer.valueOf(RESULT_SUCCESS).equals(row.getSuccess())) { + continue; + } + if (!taskFileJobService.hasSuccessfulAssembleJob(row.getTaskId(), MODULE_TYPE, row.getId()) + || dailyFileService.findMembersByResultId(row.getId()).isEmpty()) { + throw new BusinessException("后台店铺数据尚未完成同步,暂不能删除任务"); + } + } + } + @Transactional public void deleteHistory(Long resultId, Long userId) { validateUserId(userId); @@ -756,6 +778,17 @@ public class ShopDataCrawlTaskService { } } + private DailyDeletionResult preserveDailyForTaskDeletion(Set removedResultIds) { + if (removedResultIds == null || removedResultIds.isEmpty()) { + return new DailyDeletionResult(List.of(), List.of()); + } + // Keep the already assembled daily object and its row count intact. Only + // remove task membership links so the deleted task is not retained as a + // frontend history record and the next crawl can append normally. + dailyFileService.deleteMembersForResults(removedResultIds); + return new DailyDeletionResult(List.of(), List.of()); + } + private List loadDailyMemberData(ShopDataCrawlDailyFileEntity dailyFile, Set removedResultIds) { List memberRows = dailyFileService.listMembers(dailyFile.getId()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopManageCreateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopManageCreateRequest.java index 607250c5..532a95be 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopManageCreateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopManageCreateRequest.java @@ -1,8 +1,9 @@ package com.nanri.aiimage.modules.shopkey.model.dto; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.Data; @Data @@ -20,6 +21,10 @@ public class ShopManageCreateRequest { @NotBlank(message = "商城名称不能为空") private String mallName; + @Schema(description = "自动化账号") + @Size(max = 128, message = "自动化账号长度不能超过128个字符") + private String znUsername; + @Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "账号不能为空") private String account; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopManageUpdateRequest.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopManageUpdateRequest.java index e31f9574..af85d427 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopManageUpdateRequest.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/dto/ShopManageUpdateRequest.java @@ -1,8 +1,9 @@ package com.nanri.aiimage.modules.shopkey.model.dto; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.Data; @Data @@ -20,6 +21,10 @@ public class ShopManageUpdateRequest { @NotBlank(message = "商城名称不能为空") private String mallName; + @Schema(description = "自动化账号") + @Size(max = 128, message = "自动化账号长度不能超过128个字符") + private String znUsername; + @Schema(description = "登录账号", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank(message = "账号不能为空") private String account; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java index 5255b85a..92aa9520 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopKeyEntity.java @@ -18,6 +18,7 @@ public class ShopKeyEntity { private String remarkName; private String ziniaoAccountName; private String ziniaoToken; + private String ziniaoTokenHash; private String ipWhitelistStatus; @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime ipWhitelistCheckedAt; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopManageEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopManageEntity.java index 918ff1dd..494dc805 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopManageEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/ShopManageEntity.java @@ -21,6 +21,7 @@ public class ShopManageEntity { private Long createdById; @TableField("mall_name") private String mallName; + private String znUsername; private String account; private String password; private LocalDateTime createdAt; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageCredentialVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageCredentialVo.java index fc4890d7..698562b6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageCredentialVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageCredentialVo.java @@ -9,6 +9,7 @@ public class ShopManageCredentialVo { private String groupName; private String shopName; private String mallName; + private String znUsername; private String account; private String password; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageItemVo.java index 709772ab..bb37a432 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/vo/ShopManageItemVo.java @@ -12,6 +12,7 @@ public class ShopManageItemVo { private String groupName; private String shopName; private String mallName; + private String znUsername; private String account; private String password; private String passwordMasked; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java index d10950dc..412d7df4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopKeyService.java @@ -11,9 +11,12 @@ import com.nanri.aiimage.modules.shopkey.model.vo.ShopKeyPageVo; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopIndexService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.List; @Service @@ -47,13 +50,16 @@ public class ShopKeyService { @Transactional public ShopKeyItemVo create(ShopKeyCreateRequest request) { String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空"); - String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空"); + String ziniaoToken = normalizeToken(request.getZiniaoToken()); + String ziniaoTokenHash = hashToken(ziniaoToken); + ensureTokenAvailable(ziniaoTokenHash, null); ShopKeyEntity entity = new ShopKeyEntity(); entity.setRemarkName(normalizeOptional(request.getRemarkName())); entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoToken(ziniaoToken); + entity.setZiniaoTokenHash(ziniaoTokenHash); entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN); - shopKeyMapper.insert(entity); + insertOrThrowDuplicateToken(entity); triggerShopIndexRefresh(); return toItemVo(getById(entity.getId())); } @@ -62,17 +68,20 @@ public class ShopKeyService { public ShopKeyItemVo update(Long id, ShopKeyUpdateRequest request) { ShopKeyEntity entity = getById(id); String ziniaoAccountName = normalizeRequired(request.getZiniaoAccountName(), "紫鸟账号名称不能为空"); - String ziniaoToken = normalizeRequired(request.getZiniaoToken(), "紫鸟令牌不能为空"); - boolean tokenChanged = !ziniaoToken.equals(entity.getZiniaoToken()); + String ziniaoToken = normalizeToken(request.getZiniaoToken()); + String ziniaoTokenHash = hashToken(ziniaoToken); + ensureTokenAvailable(ziniaoTokenHash, id); + boolean tokenChanged = !ziniaoTokenHash.equals(entity.getZiniaoTokenHash()); entity.setRemarkName(normalizeOptional(request.getRemarkName())); entity.setZiniaoAccountName(ziniaoAccountName); entity.setZiniaoToken(ziniaoToken); + entity.setZiniaoTokenHash(ziniaoTokenHash); if (tokenChanged) { entity.setIpWhitelistStatus(IP_WHITELIST_STATUS_UNKNOWN); entity.setIpWhitelistCheckedAt(null); entity.setIpWhitelistMessage(null); } - shopKeyMapper.updateById(entity); + updateOrThrowDuplicateToken(entity); triggerShopIndexRefresh(); return toItemVo(getById(id)); } @@ -125,4 +134,55 @@ public class ShopKeyService { log.warn("[ziniao-index] refresh trigger failed after shop key change: {}", ex.getMessage()); } } + + private void ensureTokenAvailable(String tokenHash, Long currentId) { + ShopKeyEntity existing = shopKeyMapper.selectOne(new LambdaQueryWrapper() + .eq(ShopKeyEntity::getZiniaoTokenHash, tokenHash) + .ne(currentId != null, ShopKeyEntity::getId, currentId) + .last("LIMIT 1")); + if (existing != null) { + throw new BusinessException("紫鸟令牌已存在,不能重复维护"); + } + } + + private void insertOrThrowDuplicateToken(ShopKeyEntity entity) { + try { + shopKeyMapper.insert(entity); + } catch (DuplicateKeyException ex) { + throw new BusinessException("紫鸟令牌已存在,不能重复维护", ex); + } + } + + private void updateOrThrowDuplicateToken(ShopKeyEntity entity) { + try { + shopKeyMapper.updateById(entity); + } catch (DuplicateKeyException ex) { + throw new BusinessException("紫鸟令牌已存在,不能重复维护", ex); + } + } + + private String normalizeToken(String value) { + String normalized = normalizeRequired(value, "紫鸟令牌不能为空"); + if (normalized.regionMatches(true, 0, "Bearer ", 0, 7)) { + normalized = normalized.substring(7).trim(); + } + if (normalized.isEmpty()) { + throw new BusinessException("紫鸟令牌不能为空"); + } + return normalized; + } + + private String hashToken(String token) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(token.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte value : digest) { + result.append(String.format("%02x", value)); + } + return result.toString(); + } catch (Exception ex) { + throw new BusinessException("紫鸟令牌指纹生成失败"); + } + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java index 7f5d1d8e..e0d2dbfa 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/ShopManageService.java @@ -94,6 +94,7 @@ public class ShopManageService { entity.setShopName(shopName); entity.setCreatedById(createdById); entity.setMallName(mallName); + entity.setZnUsername(normalizeOptional(request.getZnUsername())); entity.setAccount(normalizeRequired(request.getAccount(), "账号不能为空")); entity.setPassword(shopCredentialCryptoService.encrypt(normalizeRequired(request.getPassword(), "密码不能为空"))); shopManageMapper.insert(entity); @@ -114,6 +115,9 @@ public class ShopManageService { entity.setGroupName(group.getGroupName()); entity.setShopName(shopName); entity.setMallName(mallName); + if (request.getZnUsername() != null) { + entity.setZnUsername(normalizeOptional(request.getZnUsername())); + } entity.setAccount(normalizeRequired(request.getAccount(), "账号不能为空")); entity.setPassword(shopCredentialCryptoService.encrypt(normalizeRequired(request.getPassword(), "密码不能为空"))); shopManageMapper.updateById(entity); @@ -141,6 +145,7 @@ public class ShopManageService { vo.setGroupId(entity.getGroupId()); vo.setShopName(entity.getShopName()); vo.setMallName(entity.getMallName()); + vo.setZnUsername(entity.getZnUsername()); vo.setAccount(entity.getAccount()); vo.setPassword(shopCredentialCryptoService.decrypt(entity.getPassword())); try { @@ -195,6 +200,10 @@ public class ShopManageService { return normalized; } + private String normalizeOptional(String value) { + return value == null ? "" : value.trim(); + } + private Long normalizePositiveId(Long value, String message) { if (value == null || value <= 0) { throw new BusinessException(message); @@ -221,6 +230,7 @@ public class ShopManageService { vo.setGroupName(groupName == null ? "" : groupName); vo.setShopName(entity.getShopName()); vo.setMallName(entity.getMallName()); + vo.setZnUsername(entity.getZnUsername()); vo.setAccount(entity.getAccount()); String masked = entity.getPassword() == null || entity.getPassword().isBlank() ? "" : "******"; vo.setPassword(masked); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java index ea636cc7..655762cb 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinService.java @@ -221,9 +221,24 @@ public class SkipPriceAsinService { entity.setGroupId(group.getId()); entity.setShopName(shopName); + SkipPriceAsinEntity firstExisting = null; + boolean acceptedAny = false; for (Map.Entry entry : countryAsinMap.entrySet()) { String country = entry.getKey(); - setCountryData(entity, country, entry.getValue(), countryMinimumPriceMap.get(country)); + String asin = entry.getValue(); + SkipPriceAsinEntity existing = findCountryAsin(group.getId(), shopName, country, asin); + if (existing != null) { + if (firstExisting == null) { + firstExisting = existing; + } + continue; + } + setCountryData(entity, country, asin, countryMinimumPriceMap.get(country)); + acceptedAny = true; + } + + if (!acceptedAny) { + return toItemVo(firstExisting, group.getGroupName()); } skipPriceAsinMapper.insert(entity); 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 a186396e..fd6b08f7 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 @@ -214,6 +214,14 @@ public class ShopMatchTaskCacheService { if (!Files.isRegularFile(file)) { continue; } + if (!isFileCacheFresh(file, now)) { + // 文件超过 TTL,视为过期并删除,避免被 poll 线程无限信任导致前端轮询不收敛 + try { + Files.deleteIfExists(file); + } catch (IOException ignored) { + } + continue; + } try { FileTaskEntity task = objectMapper.readValue(Files.readString(file), FileTaskEntity.class); result.put(taskId, task); @@ -245,5 +253,22 @@ public class ShopMatchTaskCacheService { && now - cached.cachedAtMillis() <= Math.max(0L, taskPressureProperties.getLocalTaskEntityCacheMillis()); } + /** + * 文件缓存新鲜度判断:超过 {@code localTaskEntityFileCacheMillis} 视为过期。 + * 通过文件 mtime 判断,避免在 finalize 与 poll 线程的竞态下把陈旧的 RUNNING 写回后被永久信任。 + */ + private boolean isFileCacheFresh(Path file, long now) { + long ttl = Math.max(0L, taskPressureProperties.getLocalTaskEntityFileCacheMillis()); + if (ttl <= 0L) { + return false; + } + try { + long modified = Files.getLastModifiedTime(file).toMillis(); + return now - modified <= ttl; + } catch (IOException ex) { + return false; + } + } + private record LocalTaskEntityCacheEntry(long cachedAtMillis, FileTaskEntity task) {} } 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 7a3eb44c..215a1f76 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 @@ -123,14 +123,31 @@ public class ShopMatchTaskService { if (dbTask == null || !MODULE_TYPE.equals(dbTask.getModuleType())) { continue; } - result.put(dbTask.getId(), dbTask); - if ("RUNNING".equals(dbTask.getStatus()) || "SCHEDULED".equals(dbTask.getStatus())) { - shopMatchTaskCacheService.saveTaskCache(dbTask); + // 复活防护:避免陈旧 RUNNING 把 finalize 后的 SUCCESS 覆盖掉 + if (!isCacheNewerThanDb(result.get(dbTask.getId()), dbTask)) { + result.put(dbTask.getId(), dbTask); + if ("RUNNING".equals(dbTask.getStatus()) || "SCHEDULED".equals(dbTask.getStatus())) { + shopMatchTaskCacheService.saveTaskCache(dbTask); + } } } return result; } + /** + * 当缓存中已存在的实体比本次 DB 读取的结果"更新"(updatedAt 不晚于 DB)时, + * 认为缓存为权威值,避免陈旧的 DB 读把缓存中的终态覆盖回 RUNNING。 + */ + private boolean isCacheNewerThanDb(FileTaskEntity cached, FileTaskEntity dbTask) { + if (cached == null || dbTask == null) { + return false; + } + if (cached.getUpdatedAt() == null || dbTask.getUpdatedAt() == null) { + return false; + } + return !cached.getUpdatedAt().isBefore(dbTask.getUpdatedAt()); + } + private List selectTasksByIdsInBatches(List taskIds) { List tasks = new ArrayList<>(); if (taskIds == null || taskIds.isEmpty()) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java index 3c4f9927..d6e7cb92 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoMemoryStoreService.java @@ -181,6 +181,13 @@ public class ZiniaoMemoryStoreService { return deleted; } + @Transactional + public int deleteAllByType(String cacheType) { + String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); + return ziniaoMemoryStoreMapper.delete(new LambdaQueryWrapper() + .eq(ZiniaoMemoryStoreEntity::getCacheType, normalizedType)); + } + @Transactional public int deleteExpired(int limit) { int safeLimit = Math.max(limit, 1); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoTransientCacheService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoTransientCacheService.java index 06867aa6..e08984c6 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoTransientCacheService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/memory/service/ZiniaoTransientCacheService.java @@ -105,6 +105,19 @@ public class ZiniaoTransientCacheService { log.trace("[ziniao-transient] delete type={}", normalizedType); } + public int deleteByType(String cacheType) { + String normalizedType = normalizeRequired(cacheType, "cacheType 不能为空"); + String prefix = normalizedType + SEP; + int deleted = 0; + for (String key : map.keySet()) { + if (key.startsWith(prefix) && map.remove(key) != null) { + deleted++; + } + } + log.trace("[ziniao-transient] delete by type={} count={}", normalizedType, deleted); + return deleted; + } + @Scheduled(fixedDelayString = "${aiimage.ziniao.transient-cache-cleanup-delay-ms:60000}") void cleanupExpiredEntriesScheduled() { cleanupExpiredEntries(LocalDateTime.now()); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java index adc9748d..94cf6fd2 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/ziniao/service/ZiniaoShopIndexService.java @@ -44,6 +44,11 @@ public class ZiniaoShopIndexService { private static final String CACHE_TYPE_SHOP_INDEX_SCOPE_SNAPSHOT = "SHOP_INDEX_SCOPE_SNAPSHOT"; private static final String CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR = "SHOP_INDEX_REFRESH_CURSOR"; + private static final String CACHE_TYPE_COMPANY_ID = "COMPANY_ID"; + private static final String CACHE_TYPE_STAFF_LIST = "STAFF_LIST"; + private static final String CACHE_TYPE_USER_STORES = "USER_STORES"; + private static final String CACHE_TYPE_INVALID_USER_STORES = "INVALID_USER_STORES"; + private static final String CACHE_TYPE_SHOP_MATCH = "SHOP_MATCH"; /** 有 shopId 时唯一键,避免同一店铺因不同员工/哈希产生多行。 */ private static final String SHOP_ENTRY_KEY_SHOP_PREFIX = "s:"; /** 无 shopId(冲突占位等)时仍按规范化店名存一行。 */ @@ -414,8 +419,18 @@ public class ZiniaoShopIndexService { } public void invalidateIndex() { - ziniaoTransientCacheService.delete(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR, "global"); - log.info("[ziniao-index] cursor invalidated (transient only; shop rows unchanged)"); + int persistentDeleted = ziniaoMemoryStoreService.deleteAllByType( + ZiniaoMemoryStoreService.CACHE_TYPE_SHOP_INDEX_ENTRY); + int transientDeleted = 0; + transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_SHOP_INDEX_SCOPE_SNAPSHOT); + transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_SHOP_INDEX_REFRESH_CURSOR); + transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_COMPANY_ID); + transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_STAFF_LIST); + transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_USER_STORES); + transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_INVALID_USER_STORES); + transientDeleted += ziniaoTransientCacheService.deleteByType(CACHE_TYPE_SHOP_MATCH); + log.info("[ziniao-index] invalidated persistentRows={} transientEntries={} next refresh starts from offset 0", + persistentDeleted, transientDeleted); } private void markIpWhitelistAllowedSafely(ZiniaoApiKeyProvider.ApiKeyAccount account) { diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index d53fc433..e81e9833 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -8,6 +8,9 @@ spring: multipart: max-file-size: 2GB max-request-size: 2GB + mvc: + async: + request-timeout: ${AIIMAGE_MVC_ASYNC_REQUEST_TIMEOUT:30m} jackson: time-zone: Asia/Shanghai datasource: @@ -24,6 +27,13 @@ spring: max-lifetime: ${AIIMAGE_DB_POOL_MAX_LIFETIME_MS:1500000} keepalive-time: ${AIIMAGE_DB_POOL_KEEPALIVE_TIME_MS:120000} leak-detection-threshold: ${AIIMAGE_DB_POOL_LEAK_DETECT_MS:15000} + flyway: + enabled: ${AIIMAGE_FLYWAY_ENABLED:true} + locations: classpath:db + baseline-on-migrate: ${AIIMAGE_FLYWAY_BASELINE_ON_MIGRATE:true} + baseline-version: ${AIIMAGE_FLYWAY_BASELINE_VERSION:88} + baseline-description: ${AIIMAGE_FLYWAY_BASELINE_DESCRIPTION:existing-schema} + validate-on-migrate: true data: redis: username: ${AIIMAGE_REDIS_USERNAME:} diff --git a/backend-java/src/main/resources/db/V25__shop_manage_group_bind_user.sql b/backend-java/src/main/resources/db/V25_1__shop_manage_group_bind_user.sql similarity index 100% rename from backend-java/src/main/resources/db/V25__shop_manage_group_bind_user.sql rename to backend-java/src/main/resources/db/V25_1__shop_manage_group_bind_user.sql diff --git a/backend-java/src/main/resources/db/V89__shop_manage_zn_username_and_shop_key_token_hash.sql b/backend-java/src/main/resources/db/V89__shop_manage_zn_username_and_shop_key_token_hash.sql new file mode 100644 index 00000000..67e6d32d --- /dev/null +++ b/backend-java/src/main/resources/db/V89__shop_manage_zn_username_and_shop_key_token_hash.sql @@ -0,0 +1,20 @@ +ALTER TABLE biz_shop_manage + ADD COLUMN zn_username VARCHAR(128) NOT NULL DEFAULT '' COMMENT '自动化账号' AFTER mall_name; + +ALTER TABLE biz_shop_key + ADD COLUMN ziniao_token_hash CHAR(64) NOT NULL DEFAULT '' COMMENT '规范化紫鸟令牌 SHA-256' AFTER ziniao_token; + +UPDATE biz_shop_key +SET ziniao_token = TRIM( + CASE + WHEN LOWER(LEFT(TRIM(ziniao_token), 7)) = 'bearer ' + THEN SUBSTRING(TRIM(ziniao_token), 8) + ELSE TRIM(ziniao_token) + END +); + +UPDATE biz_shop_key +SET ziniao_token_hash = SHA2(ziniao_token, 256); + +ALTER TABLE biz_shop_key + ADD UNIQUE KEY uk_ziniao_token_hash (ziniao_token_hash); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataControllerTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataControllerTest.java new file mode 100644 index 00000000..a9546e1f --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/controller/DedupeTotalDataControllerTest.java @@ -0,0 +1,202 @@ +package com.nanri.aiimage.modules.dedupe.controller; + +import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; +import com.nanri.aiimage.modules.dedupe.service.DedupeTotalDataService; +import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; +import com.nanri.aiimage.modules.permission.service.PermissionMenuService; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class DedupeTotalDataControllerTest { + + @Test + void exportReturnsStreamingBodyAndDelegatesToService() throws Exception { + DedupeTotalDataService service = mock(DedupeTotalDataService.class); + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + PermissionMenuService permissionMenuService = mock(PermissionMenuService.class); + HttpServletRequest request = mock(HttpServletRequest.class); + DedupeTotalDataController controller = new DedupeTotalDataController( + service, authSupport, permissionMenuService); + AdminUserEntity operator = new AdminUserEntity(); + operator.setId(8L); + operator.setRole("super_admin"); + when(authSupport.requireUser(request)).thenReturn(operator); + when(authSupport.currentRole(operator)).thenReturn("super_admin"); + doAnswer(invocation -> { + OutputStream outputStream = invocation.getArgument(0); + outputStream.write(new byte[]{1, 2, 3}); + return null; + }).when(service).writeExport( + any(OutputStream.class), + eq("member"), + eq(LocalDate.of(2026, 7, 1)), + eq(LocalDate.of(2026, 7, 31)), + eq(3L), + eq(8L)); + + ResponseEntity response = controller.export( + "member", + LocalDate.of(2026, 7, 1), + LocalDate.of(2026, 7, 31), + 3L, + request); + + assertNotNull(response.getBody()); + assertTrue(response.getBody() instanceof StreamingResponseBody); + assertEquals(MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + response.getHeaders().getContentType()); + assertNotNull(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION)); + assertTrue(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION) + .contains("dedupe-total-data-")); + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + response.getBody().writeTo(outputStream); + + assertArrayEquals(new byte[]{1, 2, 3}, outputStream.toByteArray()); + verify(service).writeExport( + any(OutputStream.class), + eq("member"), + eq(LocalDate.of(2026, 7, 1)), + eq(LocalDate.of(2026, 7, 31)), + eq(3L), + eq(8L)); + verify(service, never()).writeMonthlyZipExport( + any(OutputStream.class), any(), any(), any(), any(), any()); + verifyNoInteractions(permissionMenuService); + } + + @Test + void exportWithIncompleteDatesStaysSingleXlsx() throws Exception { + DedupeTotalDataService service = mock(DedupeTotalDataService.class); + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + PermissionMenuService permissionMenuService = mock(PermissionMenuService.class); + HttpServletRequest request = mock(HttpServletRequest.class); + DedupeTotalDataController controller = new DedupeTotalDataController( + service, authSupport, permissionMenuService); + AdminUserEntity operator = new AdminUserEntity(); + operator.setId(8L); + operator.setRole("super_admin"); + when(authSupport.requireUser(request)).thenReturn(operator); + when(authSupport.currentRole(operator)).thenReturn("super_admin"); + + ResponseEntity response = controller.export( + "member", + null, + LocalDate.of(2026, 8, 2), + 3L, + request); + + assertEquals(MediaType.parseMediaType( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + response.getHeaders().getContentType()); + assertTrue(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION) + .contains(".xlsx")); + assertNotNull(response.getBody()); + response.getBody().writeTo(new ByteArrayOutputStream()); + + verify(service).writeExport( + any(OutputStream.class), + eq("member"), + isNull(), + eq(LocalDate.of(2026, 8, 2)), + eq(3L), + eq(8L)); + verify(service, never()).writeMonthlyZipExport( + any(OutputStream.class), any(), any(), any(), any(), any()); + } + + @Test + void crossMonthExportReturnsZipAndDelegatesMonthlyExport() throws Exception { + DedupeTotalDataService service = mock(DedupeTotalDataService.class); + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + PermissionMenuService permissionMenuService = mock(PermissionMenuService.class); + HttpServletRequest request = mock(HttpServletRequest.class); + DedupeTotalDataController controller = new DedupeTotalDataController( + service, authSupport, permissionMenuService); + AdminUserEntity operator = new AdminUserEntity(); + operator.setId(8L); + operator.setRole("super_admin"); + when(authSupport.requireUser(request)).thenReturn(operator); + when(authSupport.currentRole(operator)).thenReturn("super_admin"); + doAnswer(invocation -> { + OutputStream outputStream = invocation.getArgument(0); + outputStream.write(new byte[]{4, 5}); + return null; + }).when(service).writeMonthlyZipExport( + any(OutputStream.class), + eq("member"), + eq(LocalDate.of(2026, 7, 15)), + eq(LocalDate.of(2026, 8, 2)), + eq(3L), + eq(8L)); + + ResponseEntity response = controller.export( + "member", + LocalDate.of(2026, 7, 15), + LocalDate.of(2026, 8, 2), + 3L, + request); + + assertEquals(MediaType.parseMediaType("application/zip"), response.getHeaders().getContentType()); + assertTrue(response.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION) + .contains(".zip")); + assertNotNull(response.getBody()); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + response.getBody().writeTo(outputStream); + + assertArrayEquals(new byte[]{4, 5}, outputStream.toByteArray()); + verify(service).writeMonthlyZipExport( + any(OutputStream.class), + eq("member"), + eq(LocalDate.of(2026, 7, 15)), + eq(LocalDate.of(2026, 8, 2)), + eq(3L), + eq(8L)); + verify(service, never()).writeExport(any(OutputStream.class), any(), any(), any(), any(), any()); + } + + @Test + void reversedDateRangeIsRejectedBeforeStreamingBodyIsReturned() { + DedupeTotalDataService service = mock(DedupeTotalDataService.class); + AdminAuthSupport authSupport = mock(AdminAuthSupport.class); + PermissionMenuService permissionMenuService = mock(PermissionMenuService.class); + HttpServletRequest request = mock(HttpServletRequest.class); + DedupeTotalDataController controller = new DedupeTotalDataController( + service, authSupport, permissionMenuService); + + BusinessException exception = assertThrows(BusinessException.class, () -> controller.export( + "member", + LocalDate.of(2026, 8, 2), + LocalDate.of(2026, 7, 15), + 3L, + request)); + + assertEquals(400, exception.getCode()); + verifyNoInteractions(service, authSupport, permissionMenuService); + } +} diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java index f1856d39..a8d06955 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataServiceTest.java @@ -33,9 +33,13 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -312,6 +316,75 @@ class DedupeTotalDataServiceTest { verify(dedupeTotalDataMapper).selectList(any()); } + @Test + void writeExportPagesThroughLargeResultSet() throws Exception { + when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root")); + when(dedupeTotalDataMapper.selectList(any())) + .thenReturn(exportBatch(5000L, 2000), List.of(data(3000L, 23L))); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + service.writeExport(output, "", null, null, null, 1L); + + verify(dedupeTotalDataMapper, times(2)).selectList(any()); + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(output.toByteArray()))) { + var sheet = workbook.getSheetAt(0); + assertEquals(2001, sheet.getLastRowNum()); + assertEquals("5000", sheet.getRow(1).getCell(0).getStringCellValue()); + assertEquals("3000", sheet.getRow(2001).getCell(0).getStringCellValue()); + } + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void writeMonthlyZipExportSplitsByMonthAndClipsDateRanges() throws Exception { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + DedupeTotalDataEntity.class); + when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root")); + when(shopManageGroupMapper.selectById(3L)).thenReturn(group(3L)); + DedupeTotalDataEntity july = data(500L, 23L); + july.setDataValue("JULY"); + july.setCreatedAt(LocalDateTime.of(2026, 7, 31, 10, 0)); + DedupeTotalDataEntity august = data(400L, 23L); + august.setDataValue("AUGUST"); + august.setCreatedAt(LocalDateTime.of(2026, 8, 1, 10, 0)); + when(dedupeTotalDataMapper.selectList(any())) + .thenReturn(List.of(july), List.of(august)); + + ByteArrayOutputStream output = new ByteArrayOutputStream(); + service.writeMonthlyZipExport( + output, + " member ", + LocalDate.of(2026, 7, 15), + LocalDate.of(2026, 8, 2), + 3L, + 1L); + + Map entries = unzip(output.toByteArray()); + assertEquals(List.of( + "dedupe-total-data-2026-07.xlsx", + "dedupe-total-data-2026-08.xlsx"), new ArrayList<>(entries.keySet())); + assertWorkbookDataValue(entries.get("dedupe-total-data-2026-07.xlsx"), "JULY"); + assertWorkbookDataValue(entries.get("dedupe-total-data-2026-08.xlsx"), "AUGUST"); + + ArgumentCaptor> queryCaptor = + ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class); + verify(dedupeTotalDataMapper, times(2)).selectList(queryCaptor.capture()); + List> queries = queryCaptor.getAllValues(); + assertQueryContainsText(queries.get(0), "member"); + assertQueryContains(queries.get(0), + LocalDate.of(2026, 7, 15).atStartOfDay(), + LocalDate.of(2026, 8, 1).atStartOfDay(), + 3L); + assertQueryContainsText(queries.get(1), "member"); + assertQueryContains(queries.get(1), + LocalDate.of(2026, 8, 1).atStartOfDay(), + LocalDate.of(2026, 8, 3).atStartOfDay(), + 3L); + verify(adminUserMapper).selectById(1L); + verify(shopManageGroupMapper).selectById(3L); + } + @Test void exportRejectsReversedDateRange() { assertThrows(BusinessException.class, () -> service.export( @@ -404,6 +477,48 @@ class DedupeTotalDataServiceTest { return entity; } + private List exportBatch(long startId, int count) { + List rows = new ArrayList<>(count); + for (long id = startId; id > startId - count; id--) { + rows.add(data(id, 23L)); + } + return rows; + } + + private Map unzip(byte[] bytes) throws Exception { + Map entries = new LinkedHashMap<>(); + try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(bytes))) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + ByteArrayOutputStream entryOutput = new ByteArrayOutputStream(); + zipInputStream.transferTo(entryOutput); + entries.put(entry.getName(), entryOutput.toByteArray()); + zipInputStream.closeEntry(); + } + } + return entries; + } + + private void assertWorkbookDataValue(byte[] bytes, String expectedValue) throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) { + assertEquals(expectedValue, workbook.getSheetAt(0).getRow(1).getCell(1).getStringCellValue()); + } + } + + private void assertQueryContains(LambdaQueryWrapper query, Object... values) { + query.getSqlSegment(); + for (Object value : values) { + assertTrue(query.getParamNameValuePairs().containsValue(value)); + } + } + + private void assertQueryContainsText(LambdaQueryWrapper query, String expectedText) { + query.getSqlSegment(); + assertTrue(query.getParamNameValuePairs().values().stream() + .map(String::valueOf) + .anyMatch(value -> value.contains(expectedText))); + } + private void stubWritableGroup(Long operatorId, Long groupId) { when(shopManageGroupMapper.selectAccessibleGroupIds(operatorId)).thenReturn(List.of(groupId)); when(shopManageGroupMapper.selectUserIdsByGroupIds(List.of(groupId))).thenReturn(List.of(operatorId)); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java index 880a0157..2b1f77ec 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/pricetrack/service/PriceTrackTaskServiceTest.java @@ -16,10 +16,12 @@ import com.nanri.aiimage.modules.task.service.TaskResultPayloadService; import com.nanri.aiimage.modules.ziniao.service.ZiniaoShopSwitchService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -29,8 +31,10 @@ import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -146,4 +150,155 @@ class PriceTrackTaskServiceTest { verify(priceTrackLoopRunService).syncLoopRunAfterChildTerminal(taskId); verify(lock).close(); } + + @Test + void submitResultComputesModifyCountFromOriginalRowsBeforeAssembly() { + long taskId = 22165L; + String shopName = "shop-a"; + + FileTaskEntity task = runningTask(taskId); + FileResultEntity result = pendingResult(taskId, shopName); + + PriceTrackSubmitResultRequest.AsinResult changed = asinRow("B001", "\u6539\u4ef7\u6210\u529f", null); + PriceTrackSubmitResultRequest.AsinResult skipped = asinRow("B002", "\u8df3\u8fc7\uff0c\u65e0\u9700\u6539\u4ef7", null); + PriceTrackSubmitResultRequest.AsinResult invalidBaseline = asinRow("B003", "UPDATED", null); + PriceTrackSubmitResultRequest.AsinResult missingBaseline = asinRow("B004", "UPDATED", null); + PriceTrackSubmitResultRequest.AsinResult statusOnly = asinRow("B005", null, "UPDATED"); + PriceTrackSubmitResultRequest.AsinResult blankBaseline = asinRow("B006", "UPDATED", null); + changed.setModifyCount("1"); + skipped.setModifyCount("1"); + invalidBaseline.setModifyCount("1"); + missingBaseline.setModifyCount("1"); + statusOnly.setModifyCount("1"); + blankBaseline.setModifyCount("1"); + + PriceTrackSubmitResultRequest.ShopResult shopResult = shopResult( + shopName, + Map.of("DE", List.of(changed, skipped, invalidBaseline, missingBaseline, statusOnly, blankBaseline))); + PriceTrackSubmitResultRequest request = new PriceTrackSubmitResultRequest(); + request.setShops(List.of(shopResult)); + + Map>> originalRows = Map.of("de", List.of( + originalRow("b001", "3.0"), + originalRow("b002", "5"), + originalRow("b003", "abc"), + originalRow("b005", ""), + originalRow("b006", "") + )); + + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + when(taskDistributedLockService.acquire("PRICE_TRACK", taskId)).thenReturn(lock); + when(priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId))).thenReturn(Map.of(taskId, task)); + when(fileResultMapper.selectList(any())).thenReturn(List.of(result)); + when(ziniaoShopSwitchService.normalizeShopName(shopName)).thenReturn(shopName); + when(excelAssemblyService.normalizeCountriesMap(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(taskResultPayloadService.getLatest(eq(taskId), eq("PRICE_TRACK"), eq("price-track-asin-rows"), eq(Map.class))) + .thenReturn(originalRows); + when(taskFileJobService.findAssembleJobsByResultIds(anyString(), any())).thenReturn(Map.of()); + + service.submitResult(taskId, request); + + ArgumentCaptor payloadCaptor = + ArgumentCaptor.forClass(PriceTrackSubmitResultRequest.ShopResult.class); + verify(taskResultPayloadService).saveLatest(eq(taskId), eq("PRICE_TRACK"), eq(shopName), payloadCaptor.capture()); + List savedRows = payloadCaptor.getValue().getCountries().get("DE"); + assertEquals("4", savedRows.get(0).getModifyCount()); + assertEquals("5", savedRows.get(1).getModifyCount()); + assertEquals("1", savedRows.get(2).getModifyCount()); + assertEquals("1", savedRows.get(3).getModifyCount()); + assertEquals("0", savedRows.get(4).getModifyCount()); + assertEquals("1", savedRows.get(5).getModifyCount()); + verify(taskFileJobService).enqueueAssembleResult(taskId, "PRICE_TRACK", result.getId(), shopName); + verify(lock).close(); + } + + @Test + void submitResultUsesZeroBaselineWhenOriginalRowsPayloadIsMissing() { + long taskId = 22166L; + String shopName = "shop-b"; + + FileTaskEntity task = runningTask(taskId); + FileResultEntity result = pendingResult(taskId, shopName); + + PriceTrackSubmitResultRequest.AsinResult changed = asinRow("B010", "UPDATED", null); + PriceTrackSubmitResultRequest.AsinResult skipped = asinRow("B011", "\u8df3\u8fc7\uff0c\u65e0\u9700\u6539\u4ef7", null); + changed.setModifyCount("99"); + skipped.setModifyCount("99"); + + PriceTrackSubmitResultRequest.ShopResult shopResult = shopResult( + shopName, + Map.of("UK", List.of(changed, skipped))); + PriceTrackSubmitResultRequest request = new PriceTrackSubmitResultRequest(); + request.setShops(List.of(shopResult)); + + TaskDistributedLockService.LockHandle lock = mock(TaskDistributedLockService.LockHandle.class); + when(taskDistributedLockService.acquire("PRICE_TRACK", taskId)).thenReturn(lock); + when(priceTrackTaskCacheService.getTaskCacheBatch(List.of(taskId))).thenReturn(Map.of(taskId, task)); + when(fileResultMapper.selectList(any())).thenReturn(List.of(result)); + when(ziniaoShopSwitchService.normalizeShopName(shopName)).thenReturn(shopName); + when(excelAssemblyService.normalizeCountriesMap(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(taskResultPayloadService.getLatest(eq(taskId), eq("PRICE_TRACK"), eq("price-track-asin-rows"), eq(Map.class))) + .thenReturn(null); + when(taskFileJobService.findAssembleJobsByResultIds(anyString(), any())).thenReturn(Map.of()); + + service.submitResult(taskId, request); + service.submitResult(taskId, request); + + ArgumentCaptor payloadCaptor = + ArgumentCaptor.forClass(PriceTrackSubmitResultRequest.ShopResult.class); + verify(taskResultPayloadService, times(2)) + .saveLatest(eq(taskId), eq("PRICE_TRACK"), eq(shopName), payloadCaptor.capture()); + List savedRows = payloadCaptor.getAllValues().get(1).getCountries().get("UK"); + assertEquals("1", savedRows.get(0).getModifyCount()); + assertEquals("0", savedRows.get(1).getModifyCount()); + verify(taskFileJobService, times(2)).enqueueAssembleResult(taskId, "PRICE_TRACK", result.getId(), shopName); + verify(lock, times(2)).close(); + } + + private FileTaskEntity runningTask(long taskId) { + FileTaskEntity task = new FileTaskEntity(); + task.setId(taskId); + task.setUserId(672L); + task.setModuleType("PRICE_TRACK"); + task.setStatus("RUNNING"); + task.setRequestJson("{}"); + return task; + } + + private FileResultEntity pendingResult(long taskId, String shopName) { + FileResultEntity result = new FileResultEntity(); + result.setId(taskId + 1000L); + result.setTaskId(taskId); + result.setModuleType("PRICE_TRACK"); + result.setSourceFilename(shopName); + result.setSuccess(0); + return result; + } + + private PriceTrackSubmitResultRequest.ShopResult shopResult( + String shopName, + Map> countries) { + PriceTrackSubmitResultRequest.ShopResult shopResult = new PriceTrackSubmitResultRequest.ShopResult(); + shopResult.setShopName(shopName); + shopResult.setCountries(countries); + shopResult.setSuccess(true); + shopResult.setError(""); + return shopResult; + } + + private PriceTrackSubmitResultRequest.AsinResult asinRow(String asin, String priceChangeStatus, String status) { + PriceTrackSubmitResultRequest.AsinResult row = new PriceTrackSubmitResultRequest.AsinResult(); + row.setShopMallName("mall"); + row.setAsin(asin); + row.setPriceChangeStatus(priceChangeStatus); + row.setStatus(status); + return row; + } + + private Map originalRow(String asin, String modifyCount) { + Map row = new LinkedHashMap<>(); + row.put("asin", asin); + row.put("modifyCount", modifyCount); + return row; + } } diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinServiceTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinServiceTest.java index 08f458b4..1604d6d0 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinServiceTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/shopkey/service/SkipPriceAsinServiceTest.java @@ -5,23 +5,30 @@ import com.nanri.aiimage.modules.shopkey.mapper.SkipPriceAsinMapper; import com.nanri.aiimage.modules.shopkey.model.dto.SkipPriceAsinCreateRequest; import com.nanri.aiimage.modules.shopkey.model.entity.ShopManageGroupEntity; import com.nanri.aiimage.modules.shopkey.model.entity.SkipPriceAsinEntity; +import com.nanri.aiimage.modules.shopkey.model.vo.QueryAsinImportProgressVo; import com.nanri.aiimage.modules.shopkey.model.vo.SkipPriceAsinItemVo; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import java.io.File; +import java.io.FileOutputStream; import java.math.BigDecimal; +import java.nio.file.Files; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -40,18 +47,40 @@ class SkipPriceAsinServiceTest { private SkipPriceAsinService service; @Test - void createInsertsNewRowWhenGroupAndShopAlreadyExist() { - ShopManageGroupEntity group = new ShopManageGroupEntity(); - group.setId(10L); - group.setGroupName("group-a"); - when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group); + void createSkipsDuplicateCountryAsinWithoutWriting() { + when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group()); SkipPriceAsinEntity existing = new SkipPriceAsinEntity(); existing.setId(100L); existing.setGroupId(10L); existing.setShopName("shop-a"); - existing.setAsinDe("OLD-ASIN"); - lenient().when(skipPriceAsinMapper.selectOne(any())).thenReturn(existing); + existing.setAsinDe("DUP-ASIN"); + existing.setMinimumPriceDe(new BigDecimal("14.00")); + when(skipPriceAsinMapper.selectOne(any())).thenReturn(existing); + + SkipPriceAsinItemVo result = service.create(request(List.of("DE"), + Map.of("DE", "dup-asin"), + Map.of("DE", new BigDecimal("99.99"))), 7L, true); + + verify(skipPriceAsinMapper, never()).insert(any(SkipPriceAsinEntity.class)); + verify(skipPriceAsinMapper, never()).updateById(any(SkipPriceAsinEntity.class)); + assertEquals(100L, result.getId()); + assertEquals("DUP-ASIN", result.getAsinDe()); + assertEquals(new BigDecimal("14.00"), result.getMinimumPriceDe()); + assertEquals(new BigDecimal("14.00"), existing.getMinimumPriceDe()); + } + + @Test + void createInsertsOnlyNonDuplicateCountriesWithoutUpdatingExisting() { + when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group()); + + SkipPriceAsinEntity existing = new SkipPriceAsinEntity(); + existing.setId(100L); + existing.setGroupId(10L); + existing.setShopName("shop-a"); + existing.setAsinDe("DUP-ASIN"); + existing.setMinimumPriceDe(new BigDecimal("14.00")); + when(skipPriceAsinMapper.selectOne(any())).thenReturn(existing).thenReturn(null); AtomicReference inserted = new AtomicReference<>(); when(skipPriceAsinMapper.insert(any(SkipPriceAsinEntity.class))).thenAnswer(invocation -> { @@ -62,23 +91,112 @@ class SkipPriceAsinServiceTest { }); when(skipPriceAsinMapper.selectById(101L)).thenAnswer(invocation -> inserted.get()); - SkipPriceAsinCreateRequest request = new SkipPriceAsinCreateRequest(); - request.setGroupId(10L); - request.setShopName("shop-a"); - request.setCountries(List.of("DE")); - request.setAsinMappings(Map.of("DE", "NEW-ASIN")); - request.setMinimumPriceMappings(Map.of("DE", new BigDecimal("19.99"))); - - SkipPriceAsinItemVo result = service.create(request, 7L, true); + SkipPriceAsinItemVo result = service.create(request(List.of("DE", "UK"), + Map.of("DE", "dup-asin", "UK", "new-asin"), + Map.of("DE", new BigDecimal("99.99"), "UK", new BigDecimal("19.99"))), 7L, true); ArgumentCaptor captor = ArgumentCaptor.forClass(SkipPriceAsinEntity.class); verify(skipPriceAsinMapper).insert(captor.capture()); - verify(skipPriceAsinMapper, never()).selectOne(any()); verify(skipPriceAsinMapper, never()).updateById(any(SkipPriceAsinEntity.class)); - assertNotSame(existing, captor.getValue()); - assertEquals("OLD-ASIN", existing.getAsinDe()); + assertNull(captor.getValue().getAsinDe()); + assertNull(captor.getValue().getMinimumPriceDe()); + assertEquals("NEW-ASIN", captor.getValue().getAsinUk()); + assertEquals(new BigDecimal("19.99"), captor.getValue().getMinimumPriceUk()); + assertEquals("DUP-ASIN", existing.getAsinDe()); + assertEquals(new BigDecimal("14.00"), existing.getMinimumPriceDe()); + assertEquals(101L, result.getId()); + assertEquals("NEW-ASIN", result.getAsinUk()); + assertEquals(new BigDecimal("19.99"), result.getMinimumPriceUk()); + } + + @Test + void createInsertsDifferentAsinForSameShopAndCountry() { + when(shopManageGroupService.getAccessibleById(10L, 7L, true)).thenReturn(group()); + when(skipPriceAsinMapper.selectOne(any())).thenReturn(null); + + AtomicReference inserted = new AtomicReference<>(); + when(skipPriceAsinMapper.insert(any(SkipPriceAsinEntity.class))).thenAnswer(invocation -> { + SkipPriceAsinEntity entity = invocation.getArgument(0); + entity.setId(101L); + inserted.set(entity); + return 1; + }); + when(skipPriceAsinMapper.selectById(101L)).thenAnswer(invocation -> inserted.get()); + + SkipPriceAsinItemVo result = service.create(request(List.of("DE"), + Map.of("DE", "new-asin"), + Map.of("DE", new BigDecimal("19.99"))), 7L, true); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SkipPriceAsinEntity.class); + verify(skipPriceAsinMapper).insert(captor.capture()); + verify(skipPriceAsinMapper, never()).updateById(any(SkipPriceAsinEntity.class)); + assertEquals("NEW-ASIN", captor.getValue().getAsinDe()); + assertEquals(new BigDecimal("19.99"), captor.getValue().getMinimumPriceDe()); assertEquals(101L, result.getId()); assertEquals("NEW-ASIN", result.getAsinDe()); assertEquals(new BigDecimal("19.99"), result.getMinimumPriceDe()); } + + @Test + void importSkipsDuplicateCountryAsinWithoutWriting() throws Exception { + SkipPriceAsinEntity existing = new SkipPriceAsinEntity(); + existing.setId(100L); + existing.setGroupId(10L); + existing.setShopName("shop-a"); + existing.setAsinUk("DUP-ASIN"); + existing.setMinimumPriceUk(new BigDecimal("14.00")); + when(skipPriceAsinMapper.selectOne(any())).thenReturn(existing); + + File workbookFile = importWorkbook("DUP-ASIN", "99.99"); + QueryAsinImportProgressVo progress = new QueryAsinImportProgressVo(); + try { + ReflectionTestUtils.invokeMethod(service, "processImportFile", + workbookFile, "shop-a.xlsx", 10L, "shop-a", false, progress); + } finally { + Files.deleteIfExists(workbookFile.toPath()); + } + + verify(skipPriceAsinMapper, never()).insert(any(SkipPriceAsinEntity.class)); + verify(skipPriceAsinMapper, never()).updateById(any(SkipPriceAsinEntity.class)); + assertEquals(1, progress.getTotalRows()); + assertEquals(1, progress.getProcessedRows()); + assertEquals(1, progress.getAsinCount()); + assertEquals(0, progress.getInsertedCount()); + assertEquals(1, progress.getSkippedCount()); + assertEquals(new BigDecimal("14.00"), existing.getMinimumPriceUk()); + } + + private ShopManageGroupEntity group() { + ShopManageGroupEntity group = new ShopManageGroupEntity(); + group.setId(10L); + group.setGroupName("group-a"); + return group; + } + + private SkipPriceAsinCreateRequest request(List countries, Map asinMappings, + Map minimumPriceMappings) { + SkipPriceAsinCreateRequest request = new SkipPriceAsinCreateRequest(); + request.setGroupId(10L); + request.setShopName("shop-a"); + request.setCountries(countries); + request.setAsinMappings(asinMappings); + request.setMinimumPriceMappings(minimumPriceMappings); + return request; + } + + private File importWorkbook(String asin, String minimumPrice) throws Exception { + File file = File.createTempFile("skip-price-asin-test-", ".xlsx"); + try (Workbook workbook = new XSSFWorkbook(); + FileOutputStream outputStream = new FileOutputStream(file)) { + Sheet sheet = workbook.createSheet("import"); + sheet.createRow(0).createCell(0).setCellValue("英国"); + sheet.getRow(0).createCell(1).setCellValue("英国"); + sheet.createRow(1).createCell(0).setCellValue("ASIN"); + sheet.getRow(1).createCell(1).setCellValue("最低价"); + sheet.createRow(2).createCell(0).setCellValue(asin); + sheet.getRow(2).createCell(1).setCellValue(minimumPrice); + workbook.write(outputStream); + } + return file; + } } diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 6ee436c3..95c8dcd1 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -1356,19 +1356,7 @@ _SHOP_DATA_CRAWL_ADMIN_COLUMNS = f""" t.task_no, t.status AS task_status, t.request_json, t.result_json, t.error_message AS task_error, t.created_at, t.updated_at, t.finished_at, {_SHOP_DATA_CRAWL_LATEST_TIME_SQL} AS latest_file_updated_at, - u.username, - (SELECT j.id FROM biz_task_file_job j - WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id - AND j.job_type = 'ASSEMBLE_RESULT' - ORDER BY j.id DESC LIMIT 1) AS file_job_id, - (SELECT j.status FROM biz_task_file_job j - WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id - AND j.job_type = 'ASSEMBLE_RESULT' - ORDER BY j.id DESC LIMIT 1) AS file_status, - (SELECT j.error_message FROM biz_task_file_job j - WHERE j.module_type = 'SHOP_DATA_CRAWL' AND j.result_id = r.id - AND j.job_type = 'ASSEMBLE_RESULT' - ORDER BY j.id DESC LIMIT 1) AS file_error + u.username """ @@ -1599,7 +1587,34 @@ def list_shop_data_crawl_tasks(): 'ORDER BY ranked.latest_file_updated_at DESC, ranked.result_id DESC', tuple(params + selected_shop_names), ) - for row in cur.fetchall(): + all_result_rows = cur.fetchall() + + result_ids = [int(row['result_id']) for row in all_result_rows if row.get('result_id')] + file_job_map = {} + if result_ids: + fj_placeholders = ','.join(['%s'] * len(result_ids)) + cur.execute( + 'SELECT fj.result_id, fj.id AS file_job_id, fj.status AS file_status, ' + 'fj.error_message AS file_error ' + 'FROM biz_task_file_job fj ' + f'INNER JOIN (SELECT result_id, MAX(id) AS max_id ' + f'FROM biz_task_file_job ' + f"WHERE module_type = 'SHOP_DATA_CRAWL' AND job_type = 'ASSEMBLE_RESULT' " + f'AND result_id IN (' + fj_placeholders + ') ' + f'GROUP BY result_id) latest ' + 'ON fj.id = latest.max_id', + tuple(result_ids), + ) + for fj_row in cur.fetchall(): + file_job_map[int(fj_row['result_id'])] = fj_row + + for row in all_result_rows: + rid = int(row.get('result_id') or 0) + fj = file_job_map.get(rid) + if fj: + row['file_job_id'] = fj.get('file_job_id') + row['file_status'] = fj.get('file_status') + row['file_error'] = fj.get('file_error') shop_key = _shop_data_crawl_shop_key(row.get('shop_name')) result_rows_by_shop.setdefault(shop_key, []).append(row) finally: @@ -2997,7 +3012,7 @@ def export_dedupe_total_data(): params=params, headers={'X-Internal-Token': _resolve_internal_token()}, stream=True, - timeout=60, + timeout=(10, 1800), ) except requests.RequestException: return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502 @@ -3362,6 +3377,7 @@ def _format_shop_manage_item(item): 'group_name': item.get('groupName') or '', 'shop_name': item.get('shopName') or '', 'mall_name': item.get('mallName') or '', + 'zn_username': item.get('znUsername') or '', 'account': item.get('account') or '', 'password': item.get('passwordMasked') or '', 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], @@ -3493,7 +3509,11 @@ def get_shop_manage_credential(item_id): credential = credential_result.get('data') or {} if str(credential.get('id')) != str(item_id): return jsonify({'success': False, 'error': '店铺凭据不匹配'}), 409 - response = jsonify({'success': True, 'password': credential.get('password') or ''}) + response = jsonify({ + 'success': True, + 'zn_username': credential.get('znUsername') or '', + 'password': credential.get('password') or '', + }) response.headers['Cache-Control'] = 'no-store' return response @@ -3509,6 +3529,7 @@ def create_shop_manage(): 'groupId': data.get('group_id'), 'shopName': (data.get('shop_name') or '').strip(), 'mallName': (data.get('mall_name') or '').strip(), + 'znUsername': (data.get('zn_username') or '').strip(), 'account': (data.get('account') or '').strip(), 'password': (data.get('password') or '').strip(), 'createdById': current_row.get('id') if current_row else None, @@ -3534,6 +3555,7 @@ def create_shop_manage(): 'group_name': item.get('groupName') or '', 'shop_name': item.get('shopName') or '', 'mall_name': item.get('mallName') or '', + 'zn_username': item.get('znUsername') or '', 'account': item.get('account') or '', 'password': item.get('password') or '', 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], @@ -3556,6 +3578,8 @@ def update_shop_manage(item_id): 'account': (data.get('account') or '').strip(), 'password': (data.get('password') or '').strip(), } + if 'zn_username' in data: + payload['znUsername'] = (data.get('zn_username') or '').strip() result, error_response, status = _proxy_backend_java( 'PUT', f'/api/admin/shop-manages/{item_id}', @@ -3576,6 +3600,7 @@ def update_shop_manage(item_id): 'group_name': item.get('groupName') or '', 'shop_name': item.get('shopName') or '', 'mall_name': item.get('mallName') or '', + 'zn_username': item.get('znUsername') or '', 'account': item.get('account') or '', 'password': item.get('password') or '', 'created_at': (item.get('createdAt') or '').replace('T', ' ')[:16], diff --git a/backend/static/admin.js b/backend/static/admin.js index 8e15778a..74f82951 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -2245,7 +2245,27 @@ }); } document.getElementById('btnSearchDedupeTotalData').onclick = function () { loadDedupeTotalData(1); }; - document.getElementById('btnExportDedupeTotalData').onclick = function () { + var dedupeTotalDataExportButton = document.getElementById('btnExportDedupeTotalData'); + var dedupeTotalDataExportWait = document.getElementById('dedupeTotalDataExportWait'); + var dedupeTotalDataExportWaitSeconds = document.getElementById('dedupeTotalDataExportWaitSeconds'); + var dedupeTotalDataExportWaitTimer = null; + function showDedupeTotalDataExportWait() { + var startedAt = Date.now(); + dedupeTotalDataExportWaitSeconds.textContent = '0'; + dedupeTotalDataExportWait.classList.add('show'); + dedupeTotalDataExportWait.setAttribute('aria-hidden', 'false'); + dedupeTotalDataExportWaitTimer = setInterval(function () { + dedupeTotalDataExportWaitSeconds.textContent = String(Math.floor((Date.now() - startedAt) / 1000)); + }, 1000); + } + function hideDedupeTotalDataExportWait() { + clearInterval(dedupeTotalDataExportWaitTimer); + dedupeTotalDataExportWaitTimer = null; + dedupeTotalDataExportWait.classList.remove('show'); + dedupeTotalDataExportWait.setAttribute('aria-hidden', 'true'); + } + dedupeTotalDataExportButton.onclick = function () { + if (dedupeTotalDataExportButton.disabled) return; var username = (document.getElementById('searchDedupeTotalDataUsername').value || '').trim(); var groupId = (document.getElementById('dedupeTotalDataGroupFilterId').value || '').trim(); var dateRange = getDedupeTotalDataDateRange(); @@ -2255,6 +2275,11 @@ if (groupId) params.push('group_id=' + encodeURIComponent(groupId)); if (dateRange.startDate) params.push('start_date=' + encodeURIComponent(dateRange.startDate)); if (dateRange.endDate) params.push('end_date=' + encodeURIComponent(dateRange.endDate)); + var originalButtonText = dedupeTotalDataExportButton.textContent; + dedupeTotalDataExportButton.disabled = true; + dedupeTotalDataExportButton.setAttribute('aria-busy', 'true'); + dedupeTotalDataExportButton.textContent = '导出中...'; + showDedupeTotalDataExportWait(); fetch('/api/admin/dedupe-total-data/export' + (params.length ? ('?' + params.join('&')) : '')) .then(function (response) { var contentType = response.headers.get('content-type') || ''; @@ -2278,6 +2303,12 @@ }) .catch(function (err) { alert((err && err.message) || '导出失败'); + }) + .finally(function () { + dedupeTotalDataExportButton.disabled = false; + dedupeTotalDataExportButton.removeAttribute('aria-busy'); + dedupeTotalDataExportButton.textContent = originalButtonText; + hideDedupeTotalDataExportWait(); }); }; var dedupeImportPollTimer = null; @@ -2907,16 +2938,16 @@ .then(function (res) { var tbody = document.getElementById('shopManageListBody'); if (!res.success) { - tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; + tbody.innerHTML = '加载失败: ' + (res.error || '') + ''; return; } var items = res.items || []; if (items.length === 0) { - tbody.innerHTML = '暂无店铺'; + tbody.innerHTML = '暂无店铺'; } else { tbody.innerHTML = items.map(function (item, index) { var rowNo = (shopManagePage - 1) * shopManagePageSize + index + 1; - return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + (item.mall_name || '') + '' + (item.account || '') + '' + renderShopPasswordCell(item) + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + + return '' + rowNo + '' + (item.group_name || '') + '' + (item.shop_name || '') + '' + (item.mall_name || '') + '' + (item.zn_username || '') + '' + (item.account || '') + '' + renderShopPasswordCell(item) + '' + (item.created_at || '') + '' + (item.updated_at || '') + '' + ' ' + '' + ''; @@ -2926,7 +2957,7 @@ bindShopManageActions(); }) .catch(function () { - document.getElementById('shopManageListBody').innerHTML = '请求失败'; + document.getElementById('shopManageListBody').innerHTML = '请求失败'; }); } @@ -2974,6 +3005,7 @@ document.getElementById('editShopManageId').value = item.id || ''; document.getElementById('editShopManageShopName').value = item.shop_name || ''; document.getElementById('editShopManageMallName').value = item.mall_name || ''; + document.getElementById('editShopManageZnUsername').value = item.zn_username || ''; document.getElementById('editShopManageAccount').value = item.account || ''; document.getElementById('editShopManagePassword').value = item.password || ''; document.getElementById('msgEditShopManage').textContent = ''; @@ -3367,6 +3399,7 @@ var groupId = (document.getElementById('shopManageGroupSelect').value || '').trim(); var shopName = (document.getElementById('shopManageShopName').value || '').trim(); var mallName = (document.getElementById('shopManageMallName').value || '').trim(); + var znUsername = (document.getElementById('shopManageZnUsername').value || '').trim(); var account = (document.getElementById('shopManageAccount').value || '').trim(); var password = (document.getElementById('shopManagePassword').value || '').trim(); var msgEl = document.getElementById('msgShopManage'); @@ -3380,7 +3413,7 @@ fetch('/api/admin/shop-manage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, account: account, password: password }) + body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, zn_username: znUsername, account: account, password: password }) }) .then(function (r) { return r.json(); }) .then(function (res) { @@ -3388,6 +3421,7 @@ document.getElementById('shopManageGroupSelect').value = ''; document.getElementById('shopManageShopName').value = ''; document.getElementById('shopManageMallName').value = ''; + document.getElementById('shopManageZnUsername').value = ''; document.getElementById('shopManageAccount').value = ''; document.getElementById('shopManagePassword').value = ''; msgEl.textContent = res.msg || '创建成功'; @@ -3409,6 +3443,7 @@ var groupId = (document.getElementById('editShopManageGroupSelect').value || '').trim(); var shopName = (document.getElementById('editShopManageShopName').value || '').trim(); var mallName = (document.getElementById('editShopManageMallName').value || '').trim(); + var znUsername = (document.getElementById('editShopManageZnUsername').value || '').trim(); var account = (document.getElementById('editShopManageAccount').value || '').trim(); var password = (document.getElementById('editShopManagePassword').value || '').trim(); var msgEl = document.getElementById('msgEditShopManage'); @@ -3422,7 +3457,7 @@ fetch('/api/admin/shop-manage/' + itemId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, account: account, password: password }) + body: JSON.stringify({ group_id: Number(groupId), shop_name: shopName, mall_name: mallName, zn_username: znUsername, account: account, password: password }) }) .then(function (r) { return r.json(); }) .then(function (res) { diff --git a/backend/tests/test_admin_dedupe_total_data.py b/backend/tests/test_admin_dedupe_total_data.py index a0d34adf..40693950 100644 --- a/backend/tests/test_admin_dedupe_total_data.py +++ b/backend/tests/test_admin_dedupe_total_data.py @@ -152,6 +152,7 @@ class AdminDedupeTotalDataTest(unittest.TestCase): }) self.assertEqual(session.kwargs['headers'], {'X-Internal-Token': 'token'}) self.assertTrue(session.kwargs['stream']) + self.assertEqual(session.kwargs['timeout'], (10, 1800)) def test_import_requires_group(self): with self.app.test_request_context( diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index ce01b3ba..0d3f4959 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -291,6 +291,52 @@ display: flex; } + .dedupe-export-wait-mask { + position: fixed; + inset: 0; + z-index: 3100; + display: none; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(17, 24, 39, 0.38); + } + + .dedupe-export-wait-mask.show { + display: flex; + } + + .dedupe-export-wait { + display: flex; + align-items: center; + gap: 14px; + width: min(440px, calc(100vw - 40px)); + padding: 20px; + border-radius: 8px; + background: #fff; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2); + } + + .dedupe-export-wait .request-spinner { + flex: 0 0 auto; + width: 24px; + height: 24px; + border-width: 3px; + } + + .dedupe-export-wait-title { + margin-bottom: 4px; + color: #1f2937; + font-size: 15px; + font-weight: 600; + } + + .dedupe-export-wait-detail { + color: #687386; + font-size: 13px; + line-height: 1.5; + } + .request-spinner { width: 16px; height: 16px; @@ -1721,6 +1767,18 @@ 请求处理中... +

管理后台

@@ -2188,6 +2246,10 @@
+
+ + +
@@ -2222,6 +2284,7 @@ 分组 店铺名 店铺商城名 + 自动化账号 账号 密码 创建时间 @@ -3003,6 +3066,10 @@
+
+ + +
@@ -3108,7 +3175,7 @@
- + diff --git a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue index 24396a9b..1717c0c2 100644 --- a/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue +++ b/frontend-vue/src/pages/brand/components/BrandShopDataCrawlTab.vue @@ -170,7 +170,7 @@ const TaskRow = defineComponent({ props.item.error ? h('div', { class: 'files error-text' }, `错误:${props.item.error}`) : null, ]), h('div', { class: 'task-right' }, [ - h('span', { class: ['status', statusClass(props.item.taskStatus)] }, statusText(props.item.taskStatus)), + h('span', { class: ['status', statusClass(props.item.taskStatus)] }, statusText(props.item)), canDownload(props.item) ? h('button', { type: 'button', class: 'download', onClick: () => emit('download', props.item) }, '下载') : null, h('button', { type: 'button', class: 'btn-delete', onClick: () => emit('delete', props.item) }, '删除'), ]), @@ -226,7 +226,13 @@ function countryLabel(code: string) { return COUNTRY_OPTIONS.find((row) => row.c function isCountrySelected(code: string) { return orderedCountryCodes.value.includes(code) } function isCountrySelectionLocked(code: string) { return orderedCountryCodes.value.length === 1 && orderedCountryCodes.value[0] === code } function isTerminal(status?: string) { return status === 'SUCCESS' || status === 'FAILED' || status === 'COMPLETED' } -function statusText(status?: string) { return status === 'SUCCESS' || status === 'COMPLETED' ? '已完成' : status === 'FAILED' ? '失败' : '执行中' } +function statusText(item: ShopDataCrawlHistoryItem) { + const status = item.taskStatus + if (status === 'SUCCESS' || status === 'COMPLETED') return '已完成' + if (status === 'FAILED') return '失败' + if (item.success === true) return '结果文件生成中' + return 'Python 处理中' +} function statusClass(status?: string) { return status === 'SUCCESS' || status === 'COMPLETED' ? 'success' : status === 'FAILED' ? 'failed' : 'running' } function canDownload(item: ShopDataCrawlHistoryItem) { return Boolean(item.resultId && (item.fileReady || item.downloadUrl)) } function formatDateTime(value?: string) { if (!value) return '-'; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN', { hour12: false }) } @@ -596,7 +602,7 @@ onUnmounted(() => { :deep(.id) { display: block; margin-bottom: 5px; color: #eee; font-weight: 700; } :deep(.files) { margin-top: 3px; color: #888; font-size: 12px; } :deep(.task-right) { display: flex; align-items: center; gap: 10px; } -:deep(.status) { min-width: 52px; font-size: 12px; text-align: center; } +:deep(.status) { min-width: 88px; font-size: 12px; text-align: center; white-space: nowrap; } :deep(.status.success) { color: #67c23a; }:deep(.status.failed) { color: #f56c6c; }:deep(.status.running) { color: #e6a23c; } :deep(.download), :deep(.btn-delete) { padding: 5px 10px; border: 1px solid #444; border-radius: 4px; background: transparent; color: #ccc; cursor: pointer; } :deep(.download) { border-color: #409eff; color: #8dc4ff; }:deep(.btn-delete) { color: #f56c6c; }