diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java index b6908637..2b42b65d 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/appearancepatent/service/AppearancePatentTaskService.java @@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.nanri.aiimage.common.exception.BusinessException; +import com.nanri.aiimage.modules.appearancepatent.service.support.AppearancePatentExcelParser; import com.nanri.aiimage.common.exception.TaskOwnerMismatchException; import com.nanri.aiimage.common.service.DistributedJobLockService; import com.nanri.aiimage.common.util.GroupResultPropagator; @@ -2292,109 +2293,100 @@ public class AppearancePatentTaskService { } + /** + * 解析单个源文件(2026-09 审查 C5 改造)。 + * + *

改用流式解析器 {@link AppearancePatentExcelParser}(EasyExcel SAX ✓,语义与旧 DOM 实现一致、 + * 已有单测)取出行级原始值,再在本方法内做原有的业务处理(分组键、状态过滤、字段补齐), + * 不再把用户源文件整表读进堆(几十万行曾会占 1~2GB)。 + */ private ParsedWorkbook parseWorkbook(File input, AppearancePatentSourceFileDto source) { - DataFormatter formatter = new DataFormatter(); - try (FileInputStream fis = new FileInputStream(input); Workbook workbook = WorkbookFactory.create(fis)) { - Sheet sheet = workbook.getSheetAt(0); - Row header = sheet.getRow(0); - if (header == null) { - throw new BusinessException("Excel 表头为空"); - } - Map headerMap = buildHeaderMap(header, formatter); - List headers = readHeaders(header, formatter); - int idCol = findRequiredHeader(headerMap, "id"); - int asinCol = findRequiredHeader(headerMap, "asin"); - int countryCol = findRequiredHeader(headerMap, "国家", "country"); - int priceCol = findOptionalHeaderExact(headerMap, "价格", "price"); - int skuCol = findOptionalHeaderExact(headerMap, - "sku", "seller sku", "seller_sku", "merchant sku", "merchant_sku", "商品sku", "商品 sku", "库存sku"); - int urlCol = findOptionalHeaderExact(headerMap, - "url", "rul", "link", "image", "img", "pic", "picture", - "链接", "商品链接", "图片", "商品图片", "主图", "商品主图", "图片链接", "主图链接"); - int titleCol = findOptionalHeaderExact(headerMap, - "标题", "title", "listing title", "product title", "商品标题", "商品名称", "产品名称"); + int maxParseRows = Math.max(1, properties.getMaxParseRows()); + AppearancePatentExcelParser.ParsedSheet sheet = new AppearancePatentExcelParser().parse(input, maxParseRows); + List headers = sheet.headers(); + int statusCol = FailedStatusRowFilter.findStatusColumnIndex(headers); + boolean includeBlankStatusRows = statusCol >= 0 && isAppearanceResultWorkbook(headers); - int statusCol = FailedStatusRowFilter.findStatusColumnIndex(headers); - - List parsedRows = new ArrayList<>(); - int total = 0; - int dropped = 0; - int validRows = 0; - int maxParseRows = Math.max(1, properties.getMaxParseRows()); - String currentBlockBaseId = ""; - String currentGroupKey = ""; - for (int i = 1; i <= sheet.getLastRowNum(); i++) { - Row row = sheet.getRow(i); - if (row == null) { - continue; - } - String id = cell(row, idCol, formatter); - String asin = cell(row, asinCol, formatter).toUpperCase(Locale.ROOT); - String country = cell(row, countryCol, formatter); - if (id.isBlank() && asin.isBlank() && country.isBlank()) { - continue; - } - total++; - if (id.isBlank() || asin.isBlank() || country.isBlank()) { - dropped++; - continue; - } - validRows++; - if (validRows > maxParseRows) { - throw new BusinessException("解析行数超过上限: " + maxParseRows); - } - AppearancePatentParsedRowVo vo = new AppearancePatentParsedRowVo(); - vo.setSourceFileKey(source.getFileKey()); - vo.setSourceFilename(firstNonBlank(source.getOriginalFilename(), input.getName())); - vo.setRowIndex(i + 1); - vo.setSourceId(id); - vo.setDisplayId(normalizeDisplayId(id)); - String rowBaseId = baseId(vo.getDisplayId()); - if (!Objects.equals(currentBlockBaseId, rowBaseId)) { - currentBlockBaseId = rowBaseId; - currentGroupKey = buildGroupKey(source.getFileKey(), rowBaseId, vo.getRowIndex()); - } - vo.setGroupKey(currentGroupKey); - vo.setRowToken(buildRowToken(source.getFileKey(), vo.getRowIndex())); - vo.setAsin(asin); - vo.setCountry(country); - vo.setPrice(priceCol >= 0 ? cell(row, priceCol, formatter) : ""); - vo.setSku(skuCol >= 0 ? cell(row, skuCol, formatter) : ""); - vo.setUrl(urlCol >= 0 ? cell(row, urlCol, formatter) : ""); - vo.setTitle(titleCol >= 0 ? cell(row, titleCol, formatter) : ""); - vo.setValues(readRowValues(row, headers, formatter)); - parsedRows.add(new ParsedAppearanceRow(vo, statusCol >= 0 ? cell(row, statusCol, formatter) : "")); - } - List allRows = parsedRows.stream() - .map(ParsedAppearanceRow::row) - .toList(); - hydratePromptFields(allRows); - boolean includeBlankStatusRows = statusCol >= 0 && isAppearanceResultWorkbook(headers); - FailedStatusRowFilter.FilterResult filteredRows = - FailedStatusRowFilter.retainRows( - parsedRows, - statusCol >= 0, - ParsedAppearanceRow::sourceStatus, - status -> FailedStatusRowFilter.matchesFailedStatus(status) - || (includeBlankStatusRows && FailedStatusRowFilter.isBlankStatus(status)) - ); - dropped += filteredRows.filteredCount(); - allRows = filteredRows.rows().stream() - .map(ParsedAppearanceRow::row) - .toList(); - if (statusCol >= 0 && validRows > 0 && allRows.isEmpty()) { - throw new BusinessException(FailedStatusRowFilter.noMatchedRowsMessage()); - } - if (allRows.isEmpty()) { - throw new BusinessException("no valid appearance patent rows"); - } - return new ParsedWorkbook(total, dropped, headers, allRows); - } catch (BusinessException ex) { - throw ex; - } catch (Exception ex) { - log.warn("[appearance-patent] parse failed file={} err={}", input, ex.getMessage()); - throw new BusinessException("解析 Excel 失败"); + Map headerIndex = new LinkedHashMap<>(); + for (int i = 0; i < headers.size(); i++) { + headerIndex.putIfAbsent(headers.get(i), i); } + + List parsedRows = new ArrayList<>(); + int total = 0; + int dropped = 0; + int validRows = 0; + String currentBlockBaseId = ""; + String currentGroupKey = ""; + for (AppearancePatentExcelParser.AppearanceExcelRow parsed : sheet.rows()) { + String id = parsed.id() == null ? "" : parsed.id(); + String asin = parsed.asin() == null ? "" : parsed.asin().toUpperCase(Locale.ROOT); + String country = parsed.country() == null ? "" : parsed.country(); + if (id.isBlank() && asin.isBlank() && country.isBlank()) { + continue; + } + total++; + if (id.isBlank() || asin.isBlank() || country.isBlank()) { + dropped++; + continue; + } + validRows++; + if (validRows > maxParseRows) { + throw new BusinessException("解析行数超过上限: " + maxParseRows); + } + AppearancePatentParsedRowVo vo = new AppearancePatentParsedRowVo(); + vo.setSourceFileKey(source.getFileKey()); + vo.setSourceFilename(firstNonBlank(source.getOriginalFilename(), input.getName())); + vo.setRowIndex(parsed.rowIndex()); + vo.setSourceId(id); + vo.setDisplayId(normalizeDisplayId(id)); + String rowBaseId = baseId(vo.getDisplayId()); + if (!Objects.equals(currentBlockBaseId, rowBaseId)) { + currentBlockBaseId = rowBaseId; + currentGroupKey = buildGroupKey(source.getFileKey(), rowBaseId, vo.getRowIndex()); + } + vo.setGroupKey(currentGroupKey); + vo.setRowToken(buildRowToken(source.getFileKey(), vo.getRowIndex())); + vo.setAsin(asin); + vo.setCountry(country); + vo.setPrice(parsed.price() == null ? "" : parsed.price()); + vo.setSku(parsed.sku() == null ? "" : parsed.sku()); + vo.setUrl(parsed.url() == null ? "" : parsed.url()); + vo.setTitle(parsed.title() == null ? "" : parsed.title()); + vo.setValues(parsed.values() == null ? new LinkedHashMap<>() : new LinkedHashMap<>(parsed.values())); + String statusValue = statusCol >= 0 && parsed.values() != null + ? parsed.values().getOrDefault(statusHeaderName(headers, statusCol), "") + : ""; + parsedRows.add(new ParsedAppearanceRow(vo, statusValue == null ? "" : statusValue)); + } + List allRows = parsedRows.stream() + .map(ParsedAppearanceRow::row) + .toList(); + hydratePromptFields(allRows); + FailedStatusRowFilter.FilterResult filteredRows = + FailedStatusRowFilter.retainRows( + parsedRows, + statusCol >= 0, + ParsedAppearanceRow::sourceStatus, + status -> FailedStatusRowFilter.matchesFailedStatus(status) + || (includeBlankStatusRows && FailedStatusRowFilter.isBlankStatus(status)) + ); + dropped += filteredRows.filteredCount(); + allRows = filteredRows.rows().stream() + .map(ParsedAppearanceRow::row) + .toList(); + if (statusCol >= 0 && validRows > 0 && allRows.isEmpty()) { + throw new BusinessException(FailedStatusRowFilter.noMatchedRowsMessage()); + } + if (allRows.isEmpty()) { + throw new BusinessException("no valid appearance patent rows"); + } + return new ParsedWorkbook(total, dropped, new ArrayList<>(headers), allRows); + } + + /** 状态列的原始表头名(流式行只带"表头→值"映射,状态值需按表头名取回)。 */ + private static String statusHeaderName(List headers, int statusCol) { + return statusCol >= 0 && statusCol < headers.size() ? headers.get(statusCol) : ""; } private void hydratePromptFields(List rows) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/controller/PublicVersionController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/controller/PublicVersionController.java index 031b70af..42ab340a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/controller/PublicVersionController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/controller/PublicVersionController.java @@ -43,4 +43,13 @@ public class PublicVersionController { public Map latestVersion() { return softwareVersionService.latestSoftwareVersion(); } + + /** + * 公开版本列表:桌面端更新面板"指定版本更新"下拉的数据源(未登录也能取, + * 与 /latest 同为匿名可达);返回最近若干条,按创建时间倒序。 + */ + @GetMapping("/list") + public Map listVersions() { + return softwareVersionService.listPublicSoftwareVersions(); + } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/SoftwareVersionService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/SoftwareVersionService.java index bf5b3fd3..15d588c3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/SoftwareVersionService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/SoftwareVersionService.java @@ -5,6 +5,7 @@ import com.nanri.aiimage.common.exception.BusinessException; import com.nanri.aiimage.modules.file.service.oss.OssStorageService; import com.nanri.aiimage.modules.softwareversion.mapper.SoftwareVersionMapper; import com.nanri.aiimage.modules.softwareversion.model.entity.SoftwareVersionEntity; +import com.nanri.aiimage.modules.softwareversion.service.support.PublicVersionLookup; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -95,6 +96,28 @@ public class SoftwareVersionService { return result; } + /** + * 公开版本列表:桌面端"指定版本更新"下拉的可选版本(匿名可达,同 /latest)。 + * 按创建时间倒序取最近 {@link PublicVersionLookup#DEFAULT_LIST_LIMIT} 条,字段口径 + * 与管理端 /versions 完全一致(id/version/file_url/created_at),前端复用同一套解析。 + */ + public Map listPublicSoftwareVersions() { + int limit = PublicVersionLookup.normalizeListLimit(null); + List entities = softwareVersionMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(SoftwareVersionEntity::getCreatedAt) + .orderByDesc(SoftwareVersionEntity::getId) + .last("LIMIT " + limit)); + List> items = new ArrayList<>(entities.size()); + for (SoftwareVersionEntity entity : entities) { + items.add(toItemMap(entity)); + } + log.info("[software-version] 查询公开版本列表 limit={} count={}", limit, items.size()); + Map result = new LinkedHashMap<>(); + result.put("items", items); + return result; + } + /** * 上传客户端软件安装包:zip 上传 MinIO(key:nanri-image/versions/{安全版本号}.zip), * 并向 web_config 追加一条记录(不做"设为最新",桌面端按 created_at 倒序取第一条)。 diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/support/PublicVersionLookup.java b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/support/PublicVersionLookup.java index 5c0b20ec..1ccfe296 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/support/PublicVersionLookup.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/softwareversion/service/support/PublicVersionLookup.java @@ -15,8 +15,15 @@ public final class PublicVersionLookup { /** 公开客户端版本路径(与旧 Flask/客户端地址完全一致)。 */ public static final String PUBLIC_VERSION_PATH = "/api/version"; public static final String PUBLIC_VERSION_LATEST_PATH = "/api/version/latest"; + /** 公开版本列表路径:桌面端"指定版本更新"下拉取可选版本(匿名可达,同 latest)。 */ + public static final String PUBLIC_VERSION_LIST_PATH = "/api/version/list"; public static final String ADMIN_UPLOAD_PATH = "/api/admin/version"; + /** 公开列表默认条数:桌面端只需最近若干个版本,公网匿名接口不做无界返回。 */ + public static final int DEFAULT_LIST_LIMIT = 50; + /** 公开列表条数上限:防调用方传入超大 limit 拖库。 */ + public static final int MAX_LIST_LIMIT = 200; + /** web_config 版本记录投影(id 用于同时间稳定排序)。 */ public record WebVersion(Long id, String version, String fileUrl, LocalDateTime createdAt) { } @@ -26,7 +33,17 @@ public final class PublicVersionLookup { /** 是否属于公开版本接口路径(与后台上传路径解耦)。 */ public static boolean isPublicVersionPath(String path) { - return PUBLIC_VERSION_PATH.equals(path) || PUBLIC_VERSION_LATEST_PATH.equals(path); + return PUBLIC_VERSION_PATH.equals(path) + || PUBLIC_VERSION_LATEST_PATH.equals(path) + || PUBLIC_VERSION_LIST_PATH.equals(path); + } + + /** 公开列表条数归一化:空/非正数取默认值,超上限截断(调用方无需自行校验)。 */ + public static int normalizeListLimit(Integer limit) { + if (limit == null || limit <= 0) { + return DEFAULT_LIST_LIMIT; + } + return Math.min(limit, MAX_LIST_LIMIT); } /** 取最新版本记录:created_at DESC,同一时间按 id 倒序稳定;空/无有效回 null。 */ diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/softwareversion/service/support/PublicVersionListLookupTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/softwareversion/service/support/PublicVersionListLookupTest.java new file mode 100644 index 00000000..6f19298b --- /dev/null +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/softwareversion/service/support/PublicVersionListLookupTest.java @@ -0,0 +1,39 @@ +package com.nanri.aiimage.modules.softwareversion.service.support; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * module 09 公开版本列表(/api/version/list)回归:路径契约与条数归一化。 + * 桌面端"指定版本更新"下拉依赖该接口,匿名可达,必须与 /api/version/latest 同族。 + */ +class PublicVersionListLookupTest { + + @Test + void test_public_list_path_belongs_to_public_version_family() { + assertTrue(PublicVersionLookup.isPublicVersionPath(PublicVersionLookup.PUBLIC_VERSION_LIST_PATH)); + assertTrue(PublicVersionLookup.isPublicVersionPath(PublicVersionLookup.PUBLIC_VERSION_LATEST_PATH)); + // 管理端上传路径不得被误判为公开路径;子路径不因前缀匹配而放行 + assertFalse(PublicVersionLookup.isPublicVersionPath(PublicVersionLookup.ADMIN_UPLOAD_PATH)); + assertFalse(PublicVersionLookup.isPublicVersionPath("/api/version/list/delete")); + } + + @Test + void test_list_limit_null_or_non_positive_uses_default() { + assertEquals(PublicVersionLookup.DEFAULT_LIST_LIMIT, PublicVersionLookup.normalizeListLimit(null)); + assertEquals(PublicVersionLookup.DEFAULT_LIST_LIMIT, PublicVersionLookup.normalizeListLimit(0)); + assertEquals(PublicVersionLookup.DEFAULT_LIST_LIMIT, PublicVersionLookup.normalizeListLimit(-5)); + } + + @Test + void test_list_limit_over_max_is_capped() { + assertEquals(PublicVersionLookup.MAX_LIST_LIMIT, PublicVersionLookup.normalizeListLimit(100000)); + assertEquals(30, PublicVersionLookup.normalizeListLimit(30)); + } + + @Test + void test_default_limit_within_max() { + assertTrue(PublicVersionLookup.DEFAULT_LIST_LIMIT <= PublicVersionLookup.MAX_LIST_LIMIT); + } +}