From 8712df2645e73506ff38ed3cc8cd9f9c364e2b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sun, 23 Aug 2026 22:50:58 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=8E=BB=E9=87=8D=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=96=B0=E5=A2=9E=E5=9B=BD=E5=AE=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DedupeTotalDataController.java | 8 +- .../dedupe/mapper/DedupeTotalDataMapper.java | 4 +- .../model/entity/DedupeTotalDataEntity.java | 1 + .../model/vo/DedupeTotalDataItemVo.java | 3 + .../service/DedupeTotalDataService.java | 132 +++++++++++++++--- .../db/V90__dedupe_total_data_country.sql | 33 +++++ .../DedupeTotalDataControllerTest.java | 15 +- .../service/DedupeTotalDataServiceTest.java | 81 ++++++++++- backend/blueprints/admin_api.py | 7 + backend/static/admin.js | 24 +++- backend/web_source/admin.html | 19 ++- backend/web_source/login.html | 4 +- 12 files changed, 294 insertions(+), 37 deletions(-) create mode 100644 backend-java/src/main/resources/db/V90__dedupe_total_data_country.sql 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 8031804f..833480ae 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 @@ -87,10 +87,11 @@ public class DedupeTotalDataController { @Parameter(description = "结束日期(包含)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId, + @Parameter(description = "国家代码(如 DE、UK)") @RequestParam(required = false) String country, HttpServletRequest request) { RequestOperator operator = requireDedupeTotalDataAccess(request); return ApiResponse.success(dedupeTotalDataService.page( - page, pageSize, keyword, username, startDate, endDate, groupId, operator.id())); + page, pageSize, keyword, username, startDate, endDate, groupId, country, operator.id())); } @GetMapping("/export") @@ -102,6 +103,7 @@ public class DedupeTotalDataController { @Parameter(description = "结束日期(包含)") @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, @Parameter(description = "分组 ID") @RequestParam(required = false) Long groupId, + @Parameter(description = "国家代码(如 DE、UK)") @RequestParam(required = false) String country, HttpServletRequest request) { if (startDate != null && endDate != null && startDate.isAfter(endDate)) { throw new BusinessException(400, "invalid export date range"); @@ -112,9 +114,9 @@ public class DedupeTotalDataController { + (monthlyZip ? ".zip" : ".xlsx"); StreamingResponseBody body = monthlyZip ? outputStream -> dedupeTotalDataService.writeMonthlyZipExport( - outputStream, username, startDate, endDate, groupId, operator.id()) + outputStream, username, startDate, endDate, groupId, country, operator.id()) : outputStream -> dedupeTotalDataService.writeExport( - outputStream, username, startDate, endDate, groupId, operator.id()); + outputStream, username, startDate, endDate, groupId, country, operator.id()); return ResponseEntity.ok() .header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename)) .contentType(MediaType.parseMediaType(monthlyZip ? ZIP_CONTENT_TYPE : XLSX_CONTENT_TYPE)) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/mapper/DedupeTotalDataMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/mapper/DedupeTotalDataMapper.java index ebcc8686..3ef7dc91 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/mapper/DedupeTotalDataMapper.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/mapper/DedupeTotalDataMapper.java @@ -28,10 +28,10 @@ public interface DedupeTotalDataMapper extends BaseMapper @Insert(""" """) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java index fe324edb..d5908376 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java @@ -14,6 +14,7 @@ public class DedupeTotalDataEntity { @TableId(type = IdType.AUTO) private Long id; private String dataValue; + private String country; private Long groupId; private Long uploaderUserId; private String uploaderUsername; diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java index de37f41b..db0fb5e0 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java @@ -15,6 +15,9 @@ public class DedupeTotalDataItemVo { @Schema(description = "总数据值") private String dataValue; + @Schema(description = "国家代码,逗号分隔,如 DE,UK") + private String country; + @Schema(description = "分组 ID") private Long groupId; 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 cd7b4617..92ab9138 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 @@ -94,6 +94,11 @@ public class DedupeTotalDataService { public DedupeTotalDataPageVo page(long page, long pageSize, String keyword, String username, LocalDate startDate, LocalDate endDate, Long groupId, Long operatorId) { + return page(page, pageSize, keyword, username, startDate, endDate, groupId, null, operatorId); + } + + public DedupeTotalDataPageVo page(long page, long pageSize, String keyword, String username, + LocalDate startDate, LocalDate endDate, Long groupId, String country, Long operatorId) { if (startDate != null && endDate != null && startDate.isAfter(endDate)) { throw new BusinessException("开始日期不能晚于结束日期"); } @@ -101,10 +106,14 @@ public class DedupeTotalDataService { long safePageSize = Math.min(Math.max(pageSize, 1), 100); String safeKeyword = keyword == null ? "" : keyword.trim(); String safeUsername = username == null ? "" : username.trim(); + String safeCountry = country == null ? "" : country.trim(); AccessScope scope = resolveAccessScope(operatorId); LambdaQueryWrapper query = new LambdaQueryWrapper() .like(!safeKeyword.isEmpty(), DedupeTotalDataEntity::getDataValue, safeKeyword) .like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername) + .apply(!safeCountry.isEmpty(), + "FIND_IN_SET({0}, IFNULL(country, '')) > 0", + safeCountry) .ge(startDate != null, DedupeTotalDataEntity::getCreatedAt, startDate == null ? null : startDate.atStartOfDay()) .lt(endDate != null, DedupeTotalDataEntity::getCreatedAt, @@ -134,13 +143,18 @@ public class DedupeTotalDataService { public byte[] export(String username, LocalDate startDate, LocalDate endDate, Long groupId, Long operatorId) { + return export(username, startDate, endDate, groupId, null, operatorId); + } + + public byte[] export(String username, LocalDate startDate, LocalDate endDate, + Long groupId, String country, Long operatorId) { if (startDate != null && endDate != null && startDate.isAfter(endDate)) { throw new BusinessException("开始日期不能晚于结束日期"); } String safeUsername = username == null ? "" : username.trim(); AccessScope scope = resolveAccessScope(operatorId); ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); - buildExportWorkbook(outputStream, safeUsername, startDate, endDate, groupId, scope); + buildExportWorkbook(outputStream, safeUsername, startDate, endDate, groupId, country, scope); return outputStream.toByteArray(); } @@ -150,6 +164,16 @@ public class DedupeTotalDataService { LocalDate endDate, Long groupId, Long operatorId) { + writeExport(outputStream, username, startDate, endDate, groupId, null, operatorId); + } + + public void writeExport(OutputStream outputStream, + String username, + LocalDate startDate, + LocalDate endDate, + Long groupId, + String country, + Long operatorId) { if (outputStream == null) { throw new BusinessException("导出输出流不能为空"); } @@ -158,7 +182,7 @@ public class DedupeTotalDataService { } String safeUsername = username == null ? "" : username.trim(); AccessScope scope = resolveAccessScope(operatorId); - buildExportWorkbook(outputStream, safeUsername, startDate, endDate, groupId, scope); + buildExportWorkbook(outputStream, safeUsername, startDate, endDate, groupId, country, scope); } public void writeMonthlyZipExport(OutputStream outputStream, @@ -167,17 +191,28 @@ public class DedupeTotalDataService { LocalDate endDate, Long groupId, Long operatorId) { + writeMonthlyZipExport(outputStream, username, startDate, endDate, groupId, null, operatorId); + } + + public void writeMonthlyZipExport(OutputStream outputStream, + String username, + LocalDate startDate, + LocalDate endDate, + Long groupId, + String country, + 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); + writeExport(outputStream, username, startDate, endDate, groupId, country, operatorId); return; } if (startDate.isAfter(endDate)) { throw new BusinessException("invalid export date range"); } String safeUsername = username == null ? "" : username.trim(); + String safeCountry = country == null ? "" : country.trim(); AccessScope scope = resolveAccessScope(operatorId); boolean groupPrevalidated = groupId != null && groupId > 0; if (groupPrevalidated) { @@ -194,7 +229,7 @@ public class DedupeTotalDataService { LocalDate entryEndDate = endDate.isBefore(monthEnd) ? endDate : monthEnd; zipOutputStream.putNextEntry(new ZipEntry(monthlyExportEntryName(currentMonth))); buildExportWorkbook(zipOutputStream, safeUsername, entryStartDate, entryEndDate, - groupId, scope, groupPrevalidated); + groupId, safeCountry, scope, groupPrevalidated); zipOutputStream.closeEntry(); currentMonth = currentMonth.plusMonths(1); } @@ -211,8 +246,9 @@ public class DedupeTotalDataService { LocalDate startDate, LocalDate endDate, Long groupId, + String country, AccessScope scope) { - buildExportWorkbook(outputStream, username, startDate, endDate, groupId, scope, false); + buildExportWorkbook(outputStream, username, startDate, endDate, groupId, country, scope, false); } private void buildExportWorkbook(OutputStream outputStream, @@ -220,6 +256,7 @@ public class DedupeTotalDataService { LocalDate startDate, LocalDate endDate, Long groupId, + String country, AccessScope scope, boolean groupPrevalidated) { try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { @@ -227,14 +264,15 @@ public class DedupeTotalDataService { Row header = sheet.createRow(0); header.createCell(0).setCellValue("ID"); header.createCell(1).setCellValue("ASIN值"); - header.createCell(2).setCellValue("用户名"); - header.createCell(3).setCellValue("分组"); - header.createCell(4).setCellValue("创建时间"); + header.createCell(2).setCellValue("国家"); + header.createCell(3).setCellValue("用户名"); + header.createCell(4).setCellValue("分组"); + header.createCell(5).setCellValue("创建时间"); Long lastId = null; int rowIndex = 1; while (true) { LambdaQueryWrapper pageQuery = buildExportQuery( - username, startDate, endDate, groupId, scope, groupPrevalidated); + username, startDate, endDate, groupId, country, scope, groupPrevalidated); if (lastId != null) { pageQuery.lt(DedupeTotalDataEntity::getId, lastId); } @@ -248,11 +286,12 @@ public class DedupeTotalDataService { Row row = sheet.createRow(rowIndex++); row.createCell(0).setCellValue(entity.getId() == null ? "" : String.valueOf(entity.getId())); row.createCell(1).setCellValue(entity.getDataValue() == null ? "" : entity.getDataValue()); - row.createCell(2).setCellValue(entity.getUploaderUsername() == null ? "" : entity.getUploaderUsername()); - row.createCell(3).setCellValue(entity.getGroupId() == null + row.createCell(2).setCellValue(entity.getCountry() == null ? "" : entity.getCountry()); + row.createCell(3).setCellValue(entity.getUploaderUsername() == null ? "" : entity.getUploaderUsername()); + row.createCell(4).setCellValue(entity.getGroupId() == null ? "" : groupNames.getOrDefault(entity.getGroupId(), "")); - row.createCell(4).setCellValue(formatExportTime(entity.getCreatedAt())); + row.createCell(5).setCellValue(formatExportTime(entity.getCreatedAt())); } DedupeTotalDataEntity lastRow = rows.getLast(); lastId = lastRow == null ? null : lastRow.getId(); @@ -265,9 +304,10 @@ public class DedupeTotalDataService { } sheet.setColumnWidth(0, 3600); sheet.setColumnWidth(1, 5200); - sheet.setColumnWidth(2, 5200); + sheet.setColumnWidth(2, 3200); sheet.setColumnWidth(3, 5200); - sheet.setColumnWidth(4, 5600); + sheet.setColumnWidth(4, 5200); + sheet.setColumnWidth(5, 5600); workbook.write(outputStream); workbook.dispose(); } catch (Exception ex) { @@ -280,18 +320,23 @@ public class DedupeTotalDataService { LocalDate endDate, Long groupId, AccessScope scope) { - return buildExportQuery(username, startDate, endDate, groupId, scope, false); + return buildExportQuery(username, startDate, endDate, groupId, null, scope, false); } private LambdaQueryWrapper buildExportQuery(String username, LocalDate startDate, LocalDate endDate, Long groupId, + String country, AccessScope scope, boolean groupPrevalidated) { String safeUsername = username == null ? "" : username.trim(); + String safeCountry = country == null ? "" : country.trim(); LambdaQueryWrapper query = new LambdaQueryWrapper() .like(!safeUsername.isEmpty(), DedupeTotalDataEntity::getUploaderUsername, safeUsername) + .apply(!safeCountry.isEmpty(), + "FIND_IN_SET({0}, IFNULL(country, '')) > 0", + safeCountry) .ge(startDate != null, DedupeTotalDataEntity::getCreatedAt, startDate == null ? null : startDate.atStartOfDay()) .lt(endDate != null, DedupeTotalDataEntity::getCreatedAt, @@ -608,9 +653,11 @@ public class DedupeTotalDataService { if (asinIndex == null) { throw new BusinessException("缺少 ASIN 列"); } + Integer countryIndex = headerMap.get("国家"); Set seenInFile = new HashSet<>(); List pendingValues = new ArrayList<>(IMPORT_BATCH_SIZE); + Map pendingCountries = new HashMap<>(); int totalRows = Math.max(sheet.getLastRowNum(), 0); int asinCount = 0; int insertedCount = 0; @@ -644,21 +691,26 @@ public class DedupeTotalDataService { continue; } pendingValues.add(asin); + if (countryIndex != null) { + pendingCountries.put(asin, parseCountries(formatter.formatCellValue(row.getCell(countryIndex)))); + } if (pendingValues.size() >= IMPORT_BATCH_SIZE) { ImportBatchResult batch = insertImportBatch( - pendingValues, groupId, uploaderUserId, uploaderUsername); + pendingValues, pendingCountries, groupId, uploaderUserId, uploaderUsername); insertedCount += batch.insertedCount(); skippedCount += batch.skippedCount(); pendingValues.clear(); + pendingCountries.clear(); } updateImportProgress(progress, rowNum, asinCount, insertedCount, skippedCount); } if (!pendingValues.isEmpty()) { ImportBatchResult batch = insertImportBatch( - pendingValues, groupId, uploaderUserId, uploaderUsername); + pendingValues, pendingCountries, groupId, uploaderUserId, uploaderUsername); insertedCount += batch.insertedCount(); skippedCount += batch.skippedCount(); pendingValues.clear(); + pendingCountries.clear(); } updateImportProgress(progress, totalRows, asinCount, insertedCount, skippedCount); @@ -676,6 +728,7 @@ public class DedupeTotalDataService { } private ImportBatchResult insertImportBatch(List values, + Map countriesByValue, Long groupId, Long uploaderUserId, String uploaderUsername) { @@ -690,6 +743,7 @@ public class DedupeTotalDataService { } DedupeTotalDataEntity entity = new DedupeTotalDataEntity(); entity.setDataValue(value); + entity.setCountry(countriesByValue == null ? null : countriesByValue.get(value)); entity.setGroupId(groupId); entity.setUploaderUserId(uploaderUserId); entity.setUploaderUsername(uploaderUsername); @@ -1076,6 +1130,49 @@ public class DedupeTotalDataService { .replaceAll("\\s+", " "); } + private static final List COUNTRY_CODE_MAP = List.of( + "DE", "UK", "FR", "IT", "ES", "US", + "美国", "德国", "英国", "法国", "意大利", "西班牙"); + + private String normalizeCountryCode(String raw) { + String upper = normalizeExcelText(raw).toUpperCase(Locale.ROOT); + return upper + .replace("GERMANY", "DE") + .replace("UNITED KINGDOM", "UK") + .replace("UNITEDKINGDOM", "UK") + .replace("ENGLAND", "UK") + .replace("FRANCE", "FR") + .replace("ITALY", "IT") + .replace("SPAIN", "ES") + .replace("USA", "US") + .replace("UNITED STATES", "US") + .replace("UNITEDSTATES", "US") + .replace("AMERICA", "US") + .replace("德国", "DE") + .replace("英国", "UK") + .replace("法国", "FR") + .replace("意大利", "IT") + .replace("西班牙", "ES") + .replace("美国", "US"); + } + + /** 将国家列单元格内容解析为标准国家代码(去重后按出现顺序),无有效国家返回 null。 */ + private String parseCountries(String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + // 先对整个单元格做别名归一(含多词国家名如 "United Kingdom"),再按分隔符/空格拆分。 + String normalized = normalizeCountryCode(raw); + LinkedHashSet codes = new LinkedHashSet<>(); + String[] tokens = normalized.split("[,,;;、/\\\\|\\s]+"); + for (String token : tokens) { + if (COUNTRY_CODE_MAP.contains(token)) { + codes.add(token); + } + } + return codes.isEmpty() ? null : String.join(",", codes); + } + private Map loadGroupNames(List rows) { List groupIds = rows.stream() .map(DedupeTotalDataEntity::getGroupId) @@ -1098,6 +1195,7 @@ public class DedupeTotalDataService { DedupeTotalDataItemVo vo = new DedupeTotalDataItemVo(); vo.setId(entity.getId()); vo.setDataValue(entity.getDataValue()); + vo.setCountry(entity.getCountry()); vo.setGroupId(entity.getGroupId()); vo.setGroupName(groupName == null ? "" : groupName); vo.setUploaderUserId(entity.getUploaderUserId()); diff --git a/backend-java/src/main/resources/db/V90__dedupe_total_data_country.sql b/backend-java/src/main/resources/db/V90__dedupe_total_data_country.sql new file mode 100644 index 00000000..2d43deed --- /dev/null +++ b/backend-java/src/main/resources/db/V90__dedupe_total_data_country.sql @@ -0,0 +1,33 @@ +SET @country_col_exists := ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_dedupe_total_data' + AND COLUMN_NAME = 'country' +); + +SET @sql_add_country := IF( + @country_col_exists = 0, + 'ALTER TABLE biz_dedupe_total_data ADD COLUMN country VARCHAR(64) DEFAULT NULL COMMENT ''国家代码,逗号分隔,如 DE,UK'' AFTER data_value', + 'SELECT 1' +); +PREPARE stmt_add_country FROM @sql_add_country; +EXECUTE stmt_add_country; +DEALLOCATE PREPARE stmt_add_country; + +SET @country_idx_exists := ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'biz_dedupe_total_data' + AND INDEX_NAME = 'idx_country' +); + +SET @sql_add_country_idx := IF( + @country_idx_exists = 0, + 'ALTER TABLE biz_dedupe_total_data ADD INDEX idx_country (country)', + 'SELECT 1' +); +PREPARE stmt_add_country_idx FROM @sql_add_country_idx; +EXECUTE stmt_add_country_idx; +DEALLOCATE PREPARE stmt_add_country_idx; 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 index a9546e1f..4f2ce793 100644 --- 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 @@ -56,6 +56,7 @@ class DedupeTotalDataControllerTest { eq(LocalDate.of(2026, 7, 1)), eq(LocalDate.of(2026, 7, 31)), eq(3L), + isNull(), eq(8L)); ResponseEntity response = controller.export( @@ -63,6 +64,7 @@ class DedupeTotalDataControllerTest { LocalDate.of(2026, 7, 1), LocalDate.of(2026, 7, 31), 3L, + null, request); assertNotNull(response.getBody()); @@ -84,9 +86,10 @@ class DedupeTotalDataControllerTest { eq(LocalDate.of(2026, 7, 1)), eq(LocalDate.of(2026, 7, 31)), eq(3L), + isNull(), eq(8L)); verify(service, never()).writeMonthlyZipExport( - any(OutputStream.class), any(), any(), any(), any(), any()); + any(OutputStream.class), any(), any(), any(), any(), any(), any()); verifyNoInteractions(permissionMenuService); } @@ -109,6 +112,7 @@ class DedupeTotalDataControllerTest { null, LocalDate.of(2026, 8, 2), 3L, + null, request); assertEquals(MediaType.parseMediaType( @@ -125,9 +129,10 @@ class DedupeTotalDataControllerTest { isNull(), eq(LocalDate.of(2026, 8, 2)), eq(3L), + isNull(), eq(8L)); verify(service, never()).writeMonthlyZipExport( - any(OutputStream.class), any(), any(), any(), any(), any()); + any(OutputStream.class), any(), any(), any(), any(), any(), any()); } @Test @@ -153,6 +158,7 @@ class DedupeTotalDataControllerTest { eq(LocalDate.of(2026, 7, 15)), eq(LocalDate.of(2026, 8, 2)), eq(3L), + isNull(), eq(8L)); ResponseEntity response = controller.export( @@ -160,6 +166,7 @@ class DedupeTotalDataControllerTest { LocalDate.of(2026, 7, 15), LocalDate.of(2026, 8, 2), 3L, + null, request); assertEquals(MediaType.parseMediaType("application/zip"), response.getHeaders().getContentType()); @@ -176,8 +183,9 @@ class DedupeTotalDataControllerTest { eq(LocalDate.of(2026, 7, 15)), eq(LocalDate.of(2026, 8, 2)), eq(3L), + isNull(), eq(8L)); - verify(service, never()).writeExport(any(OutputStream.class), any(), any(), any(), any(), any()); + verify(service, never()).writeExport(any(OutputStream.class), any(), any(), any(), any(), any(), any()); } @Test @@ -194,6 +202,7 @@ class DedupeTotalDataControllerTest { LocalDate.of(2026, 8, 2), LocalDate.of(2026, 7, 15), 3L, + null, request)); assertEquals(400, exception.getCode()); 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 a8d06955..db693eb2 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 @@ -21,6 +21,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.poi.ss.usermodel.Row; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.dao.DuplicateKeyException; import org.springframework.test.util.ReflectionTestUtils; @@ -188,6 +189,58 @@ class DedupeTotalDataServiceTest { verify(dedupeTotalDataMapper, never()).insert(any(DedupeTotalDataEntity.class)); } + @Test + void importReadsAndNormalizesCountryColumn() throws Exception { + when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a")); + stubWritableGroup(23L, 7L); + when(dedupeTotalDataMapper.selectExistingDataValues(List.of("B012345678"))).thenReturn(List.of()); + when(dedupeTotalDataMapper.insertBatchIgnore(any())).thenReturn(1); + MockMultipartFile file = asinCountryWorkbook("B012345678", "德国, US,United Kingdom"); + + var result = service.importFromExcel(file, 7L, 23L); + + assertEquals(1, result.getInsertedCount()); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(dedupeTotalDataMapper).insertBatchIgnore(captor.capture()); + assertEquals("DE,US,UK", captor.getValue().getFirst().getCountry()); + } + + @Test + void importKeepsCountryBlankWhenHeaderMissing() throws Exception { + when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a")); + stubWritableGroup(23L, 7L); + when(dedupeTotalDataMapper.selectExistingDataValues(List.of("B012345678"))).thenReturn(List.of()); + when(dedupeTotalDataMapper.insertBatchIgnore(any())).thenReturn(1); + + var result = service.importFromExcel(asinWorkbook("B012345678"), 7L, 23L); + + assertEquals(1, result.getInsertedCount()); + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(dedupeTotalDataMapper).insertBatchIgnore(captor.capture()); + assertEquals(null, captor.getValue().getFirst().getCountry()); + } + + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void pageFiltersByCountryWithFindInSet() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), ""), + DedupeTotalDataEntity.class); + when(adminUserMapper.selectById(1L)).thenReturn(user(1L, "super_admin", "root")); + when(dedupeTotalDataMapper.selectCount(any())).thenReturn(0L); + when(dedupeTotalDataMapper.selectList(any())).thenReturn(List.of()); + + service.page(1, 15, "", "", null, null, null, "UK", 1L); + + ArgumentCaptor> queryCaptor = + ArgumentCaptor.forClass((Class) LambdaQueryWrapper.class); + verify(dedupeTotalDataMapper).selectCount(queryCaptor.capture()); + LambdaQueryWrapper query = queryCaptor.getValue(); + query.getSqlSegment(); + assertTrue(query.getParamNameValuePairs().containsValue("UK")); + assertTrue(query.getCustomSqlSegment().contains("FIND_IN_SET")); + } + @Test void concurrentDuplicateDuringImportIsSkipped() throws Exception { when(adminUserMapper.selectById(23L)).thenReturn(user(23L, "normal", "member-a")); @@ -294,6 +347,7 @@ class DedupeTotalDataServiceTest { when(shopManageGroupMapper.selectManagedMemberUserIds(10L)).thenReturn(List.of(23L)); DedupeTotalDataEntity entity = data(91L, 23L); entity.setUploaderUsername("member-a"); + entity.setCountry("DE,UK"); entity.setCreatedAt(LocalDateTime.of(2026, 7, 20, 12, 30)); when(dedupeTotalDataMapper.selectList(any())).thenReturn(List.of(entity)); @@ -305,11 +359,13 @@ class DedupeTotalDataServiceTest { try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(bytes))) { var sheet = workbook.getSheetAt(0); + assertEquals("国家", sheet.getRow(0).getCell(2).getStringCellValue()); assertEquals("ASIN值", sheet.getRow(0).getCell(1).getStringCellValue()); assertEquals("B012345678", sheet.getRow(1).getCell(1).getStringCellValue()); - assertEquals("member-a", sheet.getRow(1).getCell(2).getStringCellValue()); - assertEquals("", sheet.getRow(1).getCell(3).getStringCellValue()); - assertEquals("2026-07-20 12:30:00", sheet.getRow(1).getCell(4).getStringCellValue()); + assertEquals("DE,UK", sheet.getRow(1).getCell(2).getStringCellValue()); + assertEquals("member-a", sheet.getRow(1).getCell(3).getStringCellValue()); + assertEquals("", sheet.getRow(1).getCell(4).getStringCellValue()); + assertEquals("2026-07-20 12:30:00", sheet.getRow(1).getCell(5).getStringCellValue()); } verify(shopManageGroupMapper).selectManagedMemberUserIds(10L); @@ -548,4 +604,23 @@ class DedupeTotalDataServiceTest { output.toByteArray()); } } + + private MockMultipartFile asinCountryWorkbook(String asin, String country) throws Exception { + try (XSSFWorkbook workbook = new XSSFWorkbook(); + ByteArrayOutputStream output = new ByteArrayOutputStream()) { + var sheet = workbook.createSheet("data"); + Row header = sheet.createRow(0); + header.createCell(0).setCellValue("ASIN"); + header.createCell(1).setCellValue("国家"); + Row row = sheet.createRow(1); + row.createCell(0).setCellValue(asin); + row.createCell(1).setCellValue(country); + workbook.write(output); + return new MockMultipartFile( + "file", + "data.xlsx", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + output.toByteArray()); + } + } } diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 95c8dcd1..9dd7313d 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -2929,6 +2929,7 @@ def _format_dedupe_total_data_item(item): return { 'id': item.get('id'), 'data_value': item.get('dataValue') or '', + 'country': item.get('country') or '', 'group_id': item.get('groupId'), 'group_name': item.get('groupName') or '', 'uploader_user_id': item.get('uploaderUserId'), @@ -2948,6 +2949,7 @@ def list_dedupe_total_data(): keyword = (request.args.get('keyword') or '').strip() username = (request.args.get('username') or '').strip() group_id = _parse_positive_group_id(request.args.get('group_id') or request.args.get('groupId')) + country = (request.args.get('country') or '').strip() start_date = (request.args.get('start_date') or request.args.get('startDate') or '').strip() end_date = (request.args.get('end_date') or request.args.get('endDate') or '').strip() params = { @@ -2957,6 +2959,8 @@ def list_dedupe_total_data(): 'username': username, 'operatorId': current_row.get('id'), } + if country: + params['country'] = country if start_date: params['startDate'] = start_date if end_date: @@ -2993,10 +2997,13 @@ def export_dedupe_total_data(): return denied params = {'operatorId': current_row.get('id')} username = (request.args.get('username') or '').strip() + country = (request.args.get('country') or '').strip() start_date = (request.args.get('start_date') or request.args.get('startDate') or '').strip() end_date = (request.args.get('end_date') or request.args.get('endDate') or '').strip() if username: params['username'] = username + if country: + params['country'] = country if start_date: params['startDate'] = start_date if end_date: diff --git a/backend/static/admin.js b/backend/static/admin.js index 74f82951..729d4a80 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -2180,14 +2180,28 @@ var keyword = (document.getElementById('searchDedupeTotalData').value || '').trim(); var username = (document.getElementById('searchDedupeTotalDataUsername').value || '').trim(); var groupId = (document.getElementById('dedupeTotalDataGroupFilterId').value || '').trim(); + var country = (document.getElementById('dedupeTotalDataCountryFilter').value || '').trim(); var dateRange = getDedupeTotalDataDateRange(); if (keyword) q += '&keyword=' + encodeURIComponent(keyword); if (username) q += '&username=' + encodeURIComponent(username); if (groupId) q += '&group_id=' + encodeURIComponent(groupId); + if (country) q += '&country=' + encodeURIComponent(country); if (dateRange.startDate) q += '&start_date=' + encodeURIComponent(dateRange.startDate); if (dateRange.endDate) q += '&end_date=' + encodeURIComponent(dateRange.endDate); return q; } + function getDedupeTotalDataCountryLabel(countryCodes) { + var countryLabels = { + DE: '德国', UK: '英国', FR: '法国', IT: '意大利', ES: '西班牙', US: '美国' + }; + if (Array.isArray(countryCodes)) { + return countryCodes.map(function (code) { return countryLabels[code] || code; }).join('、'); + } + return String(countryCodes || '').split(/[,,]/).map(function (code) { + code = code.trim(); + return countryLabels[code] || code; + }).filter(Boolean).join('、'); + } function loadDedupeTotalData(page) { if (!validateDedupeTotalDataDateRange()) return; dedupeTotalDataPage = page || 1; @@ -2196,15 +2210,15 @@ .then(function (res) { var tbody = document.getElementById('dedupeTotalDataListBody'); if (!res.success) { - tbody.innerHTML = '加载失败: ' + escapeHtml(res.error || '') + ''; + tbody.innerHTML = '加载失败: ' + escapeHtml(res.error || '') + ''; return; } var items = res.items || []; if (items.length === 0) { - tbody.innerHTML = '暂无总数据'; + tbody.innerHTML = '暂无总数据'; } else { tbody.innerHTML = items.map(function (item) { - return '' + escapeHtml(item.id) + '' + escapeHtml(item.data_value || '') + '' + escapeHtml(item.username || '') + '' + escapeHtml(item.group_name || '未分组') + '' + escapeHtml(item.created_at || '') + '' + + return '' + escapeHtml(item.id) + '' + escapeHtml(item.data_value || '') + '' + escapeHtml(getDedupeTotalDataCountryLabel(item.country)) + '' + escapeHtml(item.username || '') + '' + escapeHtml(item.group_name || '未分组') + '' + escapeHtml(item.created_at || '') + '' + ' ' + '' + ''; @@ -2214,7 +2228,7 @@ bindDedupeTotalDataActions(); }) .catch(function () { - document.getElementById('dedupeTotalDataListBody').innerHTML = '请求失败'; + document.getElementById('dedupeTotalDataListBody').innerHTML = '请求失败'; }); } function bindDedupeTotalDataActions() { @@ -2268,11 +2282,13 @@ if (dedupeTotalDataExportButton.disabled) return; var username = (document.getElementById('searchDedupeTotalDataUsername').value || '').trim(); var groupId = (document.getElementById('dedupeTotalDataGroupFilterId').value || '').trim(); + var country = (document.getElementById('dedupeTotalDataCountryFilter').value || '').trim(); var dateRange = getDedupeTotalDataDateRange(); if (!validateDedupeTotalDataDateRange()) return; var params = []; if (username) params.push('username=' + encodeURIComponent(username)); if (groupId) params.push('group_id=' + encodeURIComponent(groupId)); + if (country) params.push('country=' + encodeURIComponent(country)); if (dateRange.startDate) params.push('start_date=' + encodeURIComponent(dateRange.startDate)); if (dateRange.endDate) params.push('end_date=' + encodeURIComponent(dateRange.endDate)); var originalButtonText = dedupeTotalDataExportButton.textContent; diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index 0d3f4959..d415f59b 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -4,7 +4,7 @@ - 管理后台 - 南日AI + 管理后台 - 数富AI