From 26e062d32c9ad2f71233f9619ab4dcda7c562da4 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, 6 Sep 2026 20:17:14 +0800 Subject: [PATCH] =?UTF-8?q?task-284(=E6=94=B6=E5=B0=BE/=E5=9C=A8=E9=80=94?= =?UTF-8?q?=E6=89=B9=E6=AC=A1):=20=E9=A1=B5=E5=A4=B4=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E5=BD=92=E4=BD=8D=E5=8D=A1=E5=86=85toolbar+=E5=BA=97=E9=93=BA?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=AE=B0=E5=BD=95=E6=94=B9=E5=90=8D+?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=8A=A5=E9=94=99=E6=B8=85=E9=99=A4;=20Java?= =?UTF-8?q?=20page=5Fsize=E9=85=8D=E5=AF=B9/=E6=8C=89=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E8=8C=83=E5=9B=B4=E8=BF=87=E6=BB=A4/?= =?UTF-8?q?=E6=92=9E=E6=AC=BE=E5=AF=BC=E5=87=BAXLSX/Excel=E5=9B=BE?= =?UTF-8?q?=E6=94=B9WPS=20DISPIMG/V110=E6=9F=A5=E8=AF=A2=E7=B4=A2=E5=BC=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/account/CreateUserDialog.vue | 3 +- .../src/pages/account/EditUserDialog.vue | 1 + .../src/pages/asin/ProductCategoryPage.vue | 34 ++++--- .../src/pages/asin/product-category-api.ts | 2 +- .../RecordsDigitalHumanVersionPage.vue | 7 +- .../src/pages/records/RecordsHistoryPage.vue | 6 +- .../records/RecordsSoftwareVersionPage.vue | 7 +- .../src/pages/tasks/ImageVideoTasksPage.vue | 40 ++++---- .../src/pages/tasks/ShopDataTasksPage.vue | 92 ++++++++++--------- admin-frontend-vue/src/router/routes.ts | 2 +- .../tests/align-shop-data-cards.test.ts | 2 +- admin-frontend-vue/tests/task-266.test.ts | 4 +- .../controller/ProductCategoryController.java | 6 +- .../AdminShopDataCrawlTasksController.java | 25 ++++- .../mapper/ShopDataCrawlAdminTasksMapper.java | 9 ++ .../ShopDataCrawlAdminTasksService.java | 7 +- ...AdminShopDataDuplicateCheckController.java | 8 +- .../service/DuplicateCheckXlsxWriter.java | 38 ++++++++ .../ShopDataDuplicateCheckQueryService.java | 14 ++- .../service/SimilarAsinTaskService.java | 19 ++-- .../util/SimilarAsinImageEmbedder.java | 56 +++++------ .../similarasin/util/WpsCellImageWriter.java | 32 ++++++- .../db/V110__image_history_query_indexes.sql | 4 + .../util/SimilarAsinImageEmbedderTest.java | 4 +- 24 files changed, 269 insertions(+), 153 deletions(-) create mode 100644 backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/DuplicateCheckXlsxWriter.java create mode 100644 backend-java/src/main/resources/db/V110__image_history_query_indexes.sql diff --git a/admin-frontend-vue/src/pages/account/CreateUserDialog.vue b/admin-frontend-vue/src/pages/account/CreateUserDialog.vue index edf3e75e..5c4a5b5f 100644 --- a/admin-frontend-vue/src/pages/account/CreateUserDialog.vue +++ b/admin-frontend-vue/src/pages/account/CreateUserDialog.vue @@ -80,7 +80,7 @@ async function create(): Promise { > - + { show-password autocomplete="new-password" placeholder="密码(至少 6 个字符)" + @input="errors.password = undefined" /> diff --git a/admin-frontend-vue/src/pages/account/EditUserDialog.vue b/admin-frontend-vue/src/pages/account/EditUserDialog.vue index 490f3b49..e4da837e 100644 --- a/admin-frontend-vue/src/pages/account/EditUserDialog.vue +++ b/admin-frontend-vue/src/pages/account/EditUserDialog.vue @@ -87,6 +87,7 @@ async function save(): Promise { show-password autocomplete="new-password" placeholder="留空则不修改密码" + @input="errors.password = undefined" /> diff --git a/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue b/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue index 36d8b0f3..26bf8f8f 100644 --- a/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue +++ b/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue @@ -299,10 +299,6 @@ onMounted(loadTree)

商品类目

维护商品类目树与类目名称/说明;有子类目节点不可删除。

-
- 新增类目 - 导出 -
@@ -314,6 +310,14 @@ onMounted(loadTree) +
+
+ 新增类目 + 新增子类目 + 导出 +
+ {{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }} +
-
- {{ selected ? `已选:${selected.path || selected.name}` : '未选中节点' }} - - 新增子类目 - -
-
- 搜索结果:共 {{ searchTotal.toLocaleString() }} 条 - {{ keyword }} +
+
+ 新增类目 + 导出 +
+ 搜索结果:共 {{ searchTotal.toLocaleString() }} 条{{ keyword ? ` · ${keyword}` : '' }}
@@ -449,13 +450,10 @@ onMounted(loadTree) diff --git a/admin-frontend-vue/src/router/routes.ts b/admin-frontend-vue/src/router/routes.ts index ee1ae52b..1924889c 100644 --- a/admin-frontend-vue/src/router/routes.ts +++ b/admin-frontend-vue/src/router/routes.ts @@ -24,7 +24,7 @@ export const adminPages: AdminPageDef[] = [ { path: 'records/software-version', menuKey: 'admin_version', title: '软件版本管理', load: () => import('@/pages/records/RecordsSoftwareVersionPage.vue') }, { path: 'records/digital-human-version', menuKey: 'digital_human_version', title: '数字人版本管理', load: () => import('@/pages/records/RecordsDigitalHumanVersionPage.vue') }, { path: 'records/image-video-tasks', menuKey: 'admin_image_video_tasks', title: '视频任务记录', load: () => import('@/pages/tasks/ImageVideoTasksPage.vue') }, - { path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据任务/记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') }, + { path: 'shop-center/data-tasks', menuKey: 'admin_shop_data_crawl_tasks', title: '店铺数据记录', load: () => import('@/pages/tasks/ShopDataTasksPage.vue') }, { path: 'asin-center/registry', menuKey: 'admin_dedupe_total_data', title: '数据去重总数据', load: () => import('@/pages/asin/DedupeRegistryPage.vue') }, { path: 'asin-center/invalid', menuKey: 'admin_invalid_asin_data', title: '不符合ASIN数据', load: () => import('@/pages/asin/AsinInvalidPage.vue') }, { path: 'asin-center/query', menuKey: 'admin_query_asin', title: '查询ASIN', load: () => import('@/pages/asin/QueryAsinPage.vue') }, diff --git a/admin-frontend-vue/tests/align-shop-data-cards.test.ts b/admin-frontend-vue/tests/align-shop-data-cards.test.ts index bdb6568e..8339ed7d 100644 --- a/admin-frontend-vue/tests/align-shop-data-cards.test.ts +++ b/admin-frontend-vue/tests/align-shop-data-cards.test.ts @@ -28,5 +28,5 @@ test('align_shop_data_cards_selection_semantics', () => { test('align_shop_data_cards_empty_text', () => { const page = readSource('src/pages/tasks/ShopDataTasksPage.vue') - assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案对齐参考') + assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案对齐参考') }) diff --git a/admin-frontend-vue/tests/task-266.test.ts b/admin-frontend-vue/tests/task-266.test.ts index ae400b85..8b5abf9b 100644 --- a/admin-frontend-vue/tests/task-266.test.ts +++ b/admin-frontend-vue/tests/task-266.test.ts @@ -12,7 +12,7 @@ test('test_task_266_view_normal_primary_path', () => { assert.match(page, /shop-card-grid/, '需任务卡片网格') assert.match(page, /v-for="row in pagedResultRows"/, '逐任务渲染卡片') assert.match(page, /shop-card-task-no/, '卡片含任务号') - assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案') + assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案') }) test('test_task_266_view_normal_variant_input', () => { @@ -56,5 +56,5 @@ test('test_task_266_delete_boundary_limit_or_missing_field', () => { test('test_task_266_dependency_failure_returns_actionable_message', () => { const page = readSource('src/pages/tasks/ShopDataTasksPage.vue') assert.equal(/]*:data="(rows|allResultRows)"/.test(page), false, '结果文件不再用整表渲染') - assert.match(page, /暂无符合条件的店铺数据任务/, '空态文案') + assert.match(page, /暂无符合条件的店铺数据记录/, '空态文案') }) diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java index 080dd6ac..22fe4e9b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/controller/ProductCategoryController.java @@ -52,9 +52,9 @@ public class ProductCategoryController { @Operation(summary = "分页查询指定父级下的商品类目") public ApiResponse children( HttpServletRequest request, - @RequestParam(value = "parentId", required = false) Long parentId, + @RequestParam(value = "parent_id", required = false) Long parentId, @RequestParam(value = "page", defaultValue = "1") Long page, - @RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) { + @RequestParam(value = "page_size", defaultValue = "20") Long pageSize) { adminAuthSupport.requireAdminOrInternal(request); return ApiResponse.success(productCategoryService.children(parentId, page, pageSize)); } @@ -65,7 +65,7 @@ public class ProductCategoryController { HttpServletRequest request, @RequestParam(value = "keyword", required = false) String keyword, @RequestParam(value = "page", defaultValue = "1") Long page, - @RequestParam(value = "pageSize", defaultValue = "20") Long pageSize) { + @RequestParam(value = "page_size", defaultValue = "20") Long pageSize) { adminAuthSupport.requireAdminOrInternal(request); return ApiResponse.success(productCategoryService.search(keyword, page, pageSize)); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java index 66a786e5..9db9a143 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/controller/AdminShopDataCrawlTasksController.java @@ -7,6 +7,7 @@ import com.nanri.aiimage.modules.admin.support.AdminAuthSupport; import com.nanri.aiimage.modules.permission.model.entity.AdminUserEntity; import com.nanri.aiimage.modules.permission.service.PermissionMenuService; import com.nanri.aiimage.modules.shopdatacrawl.model.dto.ShopDataCrawlDownloadRowDto; +import com.nanri.aiimage.modules.shopduplicatecheck.mapper.ShopDuplicateCheckSourceMapper; import com.nanri.aiimage.modules.shopdatacrawl.service.ShopDataCrawlAdminTasksService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -38,6 +39,7 @@ import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.regex.Pattern; @@ -75,6 +77,7 @@ public class AdminShopDataCrawlTasksController { private final AdminAuthSupport adminAuthSupport; private final PermissionMenuService permissionMenuService; private final ShopDataCrawlAdminTasksService adminTasksService; + private final ShopDuplicateCheckSourceMapper duplicateCheckSourceMapper; @GetMapping @Operation(summary = "店铺数据抓取记录:按店铺分组分页列表(每店最新结果文件)") @@ -87,12 +90,14 @@ public class AdminShopDataCrawlTasksController { @RequestParam(name = "country", required = false) String country, @RequestParam(name = "created_from", required = false) String createdFrom, @RequestParam(name = "created_to", required = false) String createdTo) { - requireShopDataCrawlTaskAccess(request); + AdminUserEntity operator = requireShopDataCrawlTaskAccess(request); int safePage = Math.max(1, parseInt(page, 1, "page 参数不合法")); int safePageSize = Math.min(PAGE_SIZE_MAX, Math.max(10, parseInt(pageSize, PAGE_SIZE_DEFAULT, "page_size 参数不合法"))); + boolean superAdmin = "super_admin".equals(adminAuthSupport.currentRole(operator)); + List visibleShopKeys = superAdmin ? null : resolveVisibleShopNameKeys(operator.getId()); Map payload = adminTasksService.listShopGroups( - safePage, safePageSize, shopName, groupName, country, createdFrom, createdTo); + safePage, safePageSize, shopName, groupName, country, createdFrom, createdTo, visibleShopKeys); return ApiResponse.success(payload); } @@ -235,7 +240,7 @@ public class AdminShopDataCrawlTasksController { return new ArrayList<>(ids); } - private void requireShopDataCrawlTaskAccess(HttpServletRequest request) { + private AdminUserEntity requireShopDataCrawlTaskAccess(HttpServletRequest request) { AdminUserEntity operator; try { operator = adminAuthSupport.requireAdmin(request); @@ -246,6 +251,20 @@ public class AdminShopDataCrawlTasksController { } } permissionMenuService.requireShopDataCrawlTaskAccess(operator); + return operator; + } + + /** 非超管返回其可管店铺名(trim+小写去重,与撞款可见口径一致);超管调用方传 null 表示全量。 */ + private List resolveVisibleShopNameKeys(Long operatorId) { + List names = duplicateCheckSourceMapper.selectManagedShopNames(operatorId); + if (names == null || names.isEmpty()) { + return List.of(); + } + return names.stream() + .filter(name -> name != null && !name.isBlank()) + .map(name -> name.trim().toLowerCase(Locale.ROOT)) + .distinct() + .toList(); } private AdminUserEntity resolveInternalOperator(HttpServletRequest request) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java index 8466498e..2c6d3c50 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/mapper/ShopDataCrawlAdminTasksMapper.java @@ -163,6 +163,15 @@ public interface ShopDataCrawlAdminTasksMapper { if (p.get("createdTo") != null) { sql.append(" AND t.created_at <= #{createdTo}"); } + Object visibleShopKeys = p.get("visibleShopKeys"); + if (visibleShopKeys instanceof List keys) { + if (keys.isEmpty()) { + sql.append(" AND 1 = 0"); + } else { + sql.append(" AND LOWER(TRIM(COALESCE(r.source_filename, ''))) IN (") + .append(inPlaceholders(p, "visibleShopKeys", "lv")).append(')'); + } + } return sql.toString(); } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java index c2f54aa9..4fedd315 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopdatacrawl/service/ShopDataCrawlAdminTasksService.java @@ -44,7 +44,8 @@ public class ShopDataCrawlAdminTasksService { * 分页列表组装(组总数 → 当前页店铺组 → 每店最新结果行 + 文件任务/分组名补充)。 */ public Map listShopGroups(int page, int pageSize, String shopName, String groupName, - String country, String createdFromRaw, String createdToRaw) { + String country, String createdFromRaw, String createdToRaw, + List visibleShopKeys) { LocalDateTime createdFrom = parseAdminDateTime("created_from", createdFromRaw); LocalDateTime createdTo = parseAdminDateTime("created_to", createdToRaw); String countryFilter = blankToNull(country); @@ -60,6 +61,10 @@ public class ShopDataCrawlAdminTasksService { p.put("country", countryFilter); p.put("createdFrom", createdFrom); p.put("createdTo", createdTo); + if (visibleShopKeys != null) { + p.put("visibleShopKeys", visibleShopKeys); + flattenList(p, visibleShopKeys, "visibleShopKeys", "lv"); + } long total = adminTasksMapper.countShopGroups(p); List> items = new ArrayList<>(); diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java index 8121998e..321446c4 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/controller/AdminShopDataDuplicateCheckController.java @@ -202,12 +202,12 @@ public class AdminShopDataDuplicateCheckController { ShopDataDuplicateCheckQueryService.Filters.clean(asin, shopName, country, site, dateFrom, dateTo); Set visibleKeys = queryService.resolveVisibleShopKeys(operator.id(), operator.superAdmin()); try { - response.setContentType("text/csv; charset=utf-8"); - DownloadHeaderUtil.setAttachment(response, "shop-data-duplicate-check.csv"); + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + DownloadHeaderUtil.setAttachment(response, "shop-data-duplicate-check.xlsx"); response.setHeader("Cache-Control", "no-store"); - queryService.writeExport(scan, visibleKeys, viewMode, filters, response.getOutputStream()); + queryService.writeExportXlsx(scan, visibleKeys, viewMode, filters, response.getOutputStream()); } catch (Exception ex) { - log.warn("[shop-duplicate-check] 导出 CSV 失败: {}", ex.getMessage()); + log.warn("[shop-duplicate-check] 导出 Excel 失败: {}", ex.getMessage()); throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "导出失败"); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/DuplicateCheckXlsxWriter.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/DuplicateCheckXlsxWriter.java new file mode 100644 index 00000000..91588150 --- /dev/null +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/DuplicateCheckXlsxWriter.java @@ -0,0 +1,38 @@ +package com.nanri.aiimage.modules.shopduplicatecheck.service; + +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +/** 撞款导出 .xlsx 写出(逐行 = 一条上架记录,首行为表头)。 */ +public final class DuplicateCheckXlsxWriter { + + private DuplicateCheckXlsxWriter() { + } + + public static void write(OutputStream out, List> rows) throws IOException { + try (SXSSFWorkbook workbook = new SXSSFWorkbook(100)) { + Sheet sheet = workbook.createSheet("店铺数据"); + int rowCount = rows == null ? 0 : rows.size(); + for (int i = 0; i < rowCount; i++) { + List row = rows.get(i); + Row xlsRow = sheet.createRow(i); + int colCount = row == null ? 0 : row.size(); + for (int c = 0; c < colCount; c++) { + String value = row.get(c); + xlsRow.createCell(c).setCellValue(value == null ? "" : value); + } + } + int cols = rows == null || rows.isEmpty() ? 0 : rows.get(0).size(); + for (int c = 0; c < cols; c++) { + sheet.setColumnWidth(c, 14 * 256); + } + workbook.write(out); + workbook.dispose(); + } + } +} diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java index e106cd24..28c3c6cf 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopduplicatecheck/service/ShopDataDuplicateCheckQueryService.java @@ -395,6 +395,18 @@ public class ShopDataDuplicateCheckQueryService { public void writeExport(DuplicateScanView view, Set visibleKeys, String viewMode, Filters filters, OutputStream out) throws IOException { + DuplicateCheckCsvWriter.write(out, buildExportRows(view, visibleKeys, viewMode, filters)); + } + + /** 撞款/台账导出为 .xlsx(后台「导出」按钮下载)。 */ + public void writeExportXlsx(DuplicateScanView view, Set visibleKeys, + String viewMode, Filters filters, OutputStream out) throws IOException { + DuplicateCheckXlsxWriter.write(out, buildExportRows(view, visibleKeys, viewMode, filters)); + } + + /** 组装导出行:首行表头 + 逐条上架记录(撞款 view=monitor 过滤同店≥2,台账 view=all 全量)。 */ + private List> buildExportRows(DuplicateScanView view, Set visibleKeys, + String viewMode, Filters filters) { boolean monitor = "monitor".equals(viewMode); List cropped = cropItems(view.payload().items(), visibleKeys); List matched = filterItems(cropped, monitor, filters); @@ -412,7 +424,7 @@ public class ShopDataDuplicateCheckQueryService { for (Object[] row : flat) { rows.add(toStringRow(row)); } - DuplicateCheckCsvWriter.write(out, rows); + return rows; } public List filterItems(List items, boolean monitor, Filters f) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java index 809a0883..fe043ca9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/service/SimilarAsinTaskService.java @@ -41,7 +41,7 @@ import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinExcelPar import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinGroupingConverter; import com.nanri.aiimage.modules.similarasin.service.support.SimilarAsinHistoryAssembler; import com.nanri.aiimage.modules.similarasin.util.BoundedImageCache; -import com.nanri.aiimage.modules.similarasin.util.ExcelCellImageWriter; +import com.nanri.aiimage.modules.similarasin.util.WpsCellImageWriter; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinImageEmbedder; import com.nanri.aiimage.modules.similarasin.util.SimilarAsinLogSupport; import com.nanri.aiimage.modules.file.service.LocalFileStorageService; @@ -4539,9 +4539,8 @@ public class SimilarAsinTaskService { Map resultMap, Map taskImageCache) { ensureResultAssemblyNotInterrupted(); - // Excel 365 "Place in Cell" 图片:写完 workbook 后 patch richData 单元格图片结构。 - // 这不是浮动 Drawing,因此点击图片区域会选中单元格,图片不能被拖到任意位置,也不需要工作表保护。 - ExcelCellImageWriter.Session excelCellImageSession = ExcelCellImageWriter.createSession(); + // 单元格内嵌图片:写完 workbook 后 patch WPS DISPIMG 结构(WPS/钉钉/飞书/Excel365 均可显示)。 + WpsCellImageWriter.Session cellImageSession = WpsCellImageWriter.createSession(); SimilarAsinImageEmbedder.ImageSpool imageSpool; try { Path spoolDir = Files.createTempDirectory(xlsx.getParentFile().toPath(), "similar-asin-images-"); @@ -4642,11 +4641,11 @@ public class SimilarAsinTaskService { // 行高固定到 IMAGE_ROW_HEIGHT_POINTS(Excel 上限 409pt): row.setHeightInPoints(SimilarAsinImageEmbedder.IMAGE_ROW_HEIGHT_POINTS); imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_MAIN, - resultRow.getMainUrl(), row, taskImageCache, excelCellImageSession, imageSpool); + resultRow.getMainUrl(), row, taskImageCache, cellImageSession, imageSpool); imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_PUZZLE1, - resultRow.getPuzzleImg1(), row, taskImageCache, excelCellImageSession, imageSpool); + resultRow.getPuzzleImg1(), row, taskImageCache, cellImageSession, imageSpool); imageEmbedder.embedAsExcelCellImage(rowIndex, IMG_COL_PUZZLE2, - resultRow.getPuzzleImg2(), row, taskImageCache, excelCellImageSession, imageSpool); + resultRow.getPuzzleImg2(), row, taskImageCache, cellImageSession, imageSpool); } rowIndex++; } @@ -4654,14 +4653,14 @@ public class SimilarAsinTaskService { workbook.write(fos); workbook.dispose(); } - if (!excelCellImageSession.isEmpty()) { + if (!cellImageSession.isEmpty()) { try { ensureResultAssemblyNotInterrupted(); - ExcelCellImageWriter.patchXlsxFile(xlsx, excelCellImageSession); + WpsCellImageWriter.patchXlsxFile(xlsx, cellImageSession); } catch (IOException ex) { String cause = ex.toString(); log.error("[similar-asin] excel cell image patch failed xlsx={} images={} err={}", - xlsx.getAbsolutePath(), excelCellImageSession.size(), cause, ex); + xlsx.getAbsolutePath(), cellImageSession.size(), cause, ex); throw new BusinessException("写入相似ASIN结果 Excel 单元格图片失败: " + cause, ex); } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java index 6327356a..af2b0cca 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedder.java @@ -569,8 +569,6 @@ public class SimilarAsinImageEmbedder { * 失败时返回 null;成功返回缩略图。给 Excel richData 单元格图片写入路径使用。 */ private ResizedImage downloadAndResize(String trimmedUrl, - int colIdx, - Row row, Map taskImageCache) { long startedNanos = System.nanoTime(); try { @@ -605,7 +603,6 @@ public class SimilarAsinImageEmbedder { log.warn("[similar-asin][image] embed-fail url={} elapsedMs={} err={}", trimmedUrl, elapsedMs(startedNanos), errorSummary(ex)); } - row.createCell(colIdx).setCellValue(trimmedUrl); return null; } @@ -614,7 +611,7 @@ public class SimilarAsinImageEmbedder { String url, Row row, Map taskImageCache, - ExcelCellImageWriter.Session excelCellImageSession) { + WpsCellImageWriter.Session excelCellImageSession) { return embedAsExcelCellImage(rowIdx, colIdx, url, row, taskImageCache, excelCellImageSession, null); } @@ -623,46 +620,39 @@ public class SimilarAsinImageEmbedder { String url, Row row, Map taskImageCache, - ExcelCellImageWriter.Session excelCellImageSession, + WpsCellImageWriter.Session excelCellImageSession, ImageSpool imageSpool) { if (url == null || url.isBlank() || excelCellImageSession == null) { return null; } String trimmedUrl = url.trim(); SpoolImage spooled = imageSpool == null ? null : imageSpool.get(trimmedUrl); + String imageId = null; + ImageDim dim = null; if (spooled != null) { - Cell cell = row.createCell(colIdx); - cell.setCellValue("#VALUE!"); - excelCellImageSession.registerImage(rowIdx, colIdx, spooled.path()); - return new ImageDim(spooled.width(), spooled.height()); - } - if (imageSpool != null) { - row.createCell(colIdx).setCellValue(trimmedUrl); + // 预取已落盘:按路径注册,patch 阶段再读文件,避免整份图片字节驻留堆内存。 + imageId = excelCellImageSession.registerImageByPath(spooled.path()); + dim = new ImageDim(spooled.width(), spooled.height()); + } else if (imageSpool != null) { + // 预取未命中说明该 URL 下载失败:不重试、不把 URL 文本写进单元格,留空并打日志。 + log.warn("[similar-asin][image] cell-image-miss url={} colIdx={} (prefetch did not spool), leave blank", + trimmedUrl, colIdx); return null; - } - ResizedImage thumb = downloadAndResize(trimmedUrl, colIdx, row, taskImageCache); - if (thumb == null) { - return null; - } - try { - Cell cell = row.createCell(colIdx); - cell.setCellValue("#VALUE!"); - if (imageSpool == null) { - excelCellImageSession.registerImage(rowIdx, colIdx, thumb.bytes()); - } else { - spooled = imageSpool.put(trimmedUrl, thumb); - excelCellImageSession.registerImage(rowIdx, colIdx, spooled.path()); + } else { + ResizedImage thumb = downloadAndResize(trimmedUrl, taskImageCache); + if (thumb == null) { + log.warn("[similar-asin][image] cell-image-miss url={} colIdx={} (download failed), leave blank", + trimmedUrl, colIdx); + return null; } + imageId = excelCellImageSession.registerImage(thumb.bytes()); taskImageCache.remove(trimmedUrl); - return spooled == null - ? new ImageDim(thumb.width(), thumb.height()) - : new ImageDim(spooled.width(), spooled.height()); - } catch (IOException | RuntimeException ex) { - taskImageCache.remove(trimmedUrl); - log.warn("[similar-asin][image] excel-cell-image-fail url={} err={}", trimmedUrl, ex.getMessage()); - row.createCell(colIdx).setCellValue(trimmedUrl); - return null; + dim = new ImageDim(thumb.width(), thumb.height()); } + if (imageId != null) { + row.createCell(colIdx).setCellFormula(WpsCellImageWriter.dispImgFormula(imageId)); + } + return dim; } private boolean hasLocalCachedThumb(String url) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java index 67b4bf3d..4aed2c9b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/similarasin/util/WpsCellImageWriter.java @@ -136,7 +136,7 @@ public final class WpsCellImageWriter { buildCellImagesRelsXml(session).getBytes(StandardCharsets.UTF_8)); int idx = 1; for (RegisteredImage img : session.images) { - writeEntry(zout, "xl/media/cellimage" + idx + ".jpeg", img.bytes); + writeEntry(zout, "xl/media/cellimage" + idx + ".jpeg", img.bytes()); idx++; } } @@ -299,6 +299,8 @@ public final class WpsCellImageWriter { final List images = new ArrayList<>(); // 字节级去重:相同图片只写一次 cellimage{N}.jpeg,节省 xlsx 体积。 private final Map bytesHashToId = new LinkedHashMap<>(); + // 磁盘路径去重:同一任务内相同源文件(预取 spool 同一份)只写一次 media,避免内存/体积膨胀。 + private final Map pathHashToId = new LinkedHashMap<>(); public synchronized String registerImage(byte[] jpegBytes) { if (jpegBytes == null || jpegBytes.length == 0) { @@ -315,6 +317,22 @@ public final class WpsCellImageWriter { return id; } + /** 按磁盘路径注册(预取 spool 已落盘,避免整份字节驻留内存);按绝对路径去重,重复路径复用同一 media。 */ + public synchronized String registerImageByPath(Path path) { + if (path == null) { + return null; + } + String normalized = path.toAbsolutePath().normalize().toString(); + String existingId = pathHashToId.get(normalized); + if (existingId != null) { + return existingId; + } + String id = newImageId(); + images.add(new RegisteredImage(id, path)); + pathHashToId.put(normalized, id); + return id; + } + public synchronized boolean isEmpty() { return images.isEmpty(); } @@ -347,10 +365,22 @@ public final class WpsCellImageWriter { static final class RegisteredImage { final String id; final byte[] bytes; + final Path sourcePath; RegisteredImage(String id, byte[] bytes) { this.id = id; this.bytes = bytes; + this.sourcePath = null; + } + + RegisteredImage(String id, Path sourcePath) { + this.id = id; + this.bytes = null; + this.sourcePath = sourcePath; + } + + byte[] bytes() throws IOException { + return sourcePath != null ? Files.readAllBytes(sourcePath) : bytes; } } } diff --git a/backend-java/src/main/resources/db/V110__image_history_query_indexes.sql b/backend-java/src/main/resources/db/V110__image_history_query_indexes.sql new file mode 100644 index 00000000..cfb7d5d2 --- /dev/null +++ b/backend-java/src/main/resources/db/V110__image_history_query_indexes.sql @@ -0,0 +1,4 @@ +-- image_history 后台分页查询提速:按创建时间倒序取 id + 按用户过滤 +ALTER TABLE image_history + ADD INDEX idx_image_history_created_at (created_at), + ADD INDEX idx_image_history_user_id (user_id); diff --git a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java index 7d4b9da5..cbecc779 100644 --- a/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java +++ b/backend-java/src/test/java/com/nanri/aiimage/modules/similarasin/util/SimilarAsinImageEmbedderTest.java @@ -229,12 +229,12 @@ class SimilarAsinImageEmbedderTest { var row = workbook.createSheet().createRow(1); SimilarAsinImageEmbedder.ImageDim result = failedEmbedder.embedAsExcelCellImage( - 1, 9, url, row, new HashMap<>(), ExcelCellImageWriter.createSession(), spool); + 1, 9, url, row, new HashMap<>(), WpsCellImageWriter.createSession(), spool); assertEquals(SimilarAsinImageEmbedder.DOWNLOAD_MAX_RETRY + 1, callsAfterPrefetch); assertEquals(callsAfterPrefetch, networkCalls.get()); assertNull(result); - assertEquals(url, row.getCell(9).getStringCellValue()); + assertNull(row.getCell(9), "预取失败不应把 URL 文本写进单元格,应留空"); } finally { failedEmbedder.shutdown(); }