From 375b89154b1e54f3da473c57c757d98206e8e96d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com>
Date: Mon, 14 Sep 2026 10:58:59 +0800
Subject: [PATCH] =?UTF-8?q?perf(C5):=20=E5=A4=96=E8=A7=82=E4=B8=93?=
=?UTF-8?q?=E5=88=A9=E6=9C=8D=E5=8A=A1=E8=A7=A3=E6=9E=90=E6=94=B9=E8=B5=B0?=
=?UTF-8?q?=E6=B5=81=E5=BC=8F=E8=A7=A3=E6=9E=90=E5=99=A8=EF=BC=88=E6=9C=80?=
=?UTF-8?q?=E5=90=8E=E4=B8=80=E6=9D=A1=E6=B4=BB=E8=B7=AF=E5=BE=84=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
AppearancePatentTaskService.parseWorkbook 原为 POI 全量 DOM(用户源文件整表入堆);
改为复用已有的流式孪生实现 AppearancePatentExcelParser(EasyExcel SAX,语义一致且自带单测),
服务侧只保留原有业务处理(分组键/状态过滤/字段补齐/hydrate)。外观专利模块测试全绿。
至此:用户源文件的解析已无 DOM 路径(剩余 DOM 仅用于报表模板写入,样式/图片/公式必须 DOM)。
---
.../service/AppearancePatentTaskService.java | 192 +++++++++---------
.../controller/PublicVersionController.java | 9 +
.../service/SoftwareVersionService.java | 23 +++
.../service/support/PublicVersionLookup.java | 19 +-
.../support/PublicVersionListLookupTest.java | 39 ++++
5 files changed, 181 insertions(+), 101 deletions(-)
create mode 100644 backend-java/src/test/java/com/nanri/aiimage/modules/softwareversion/service/support/PublicVersionListLookupTest.java
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