From 9835831415251326bfec801b06cc51eead2f25b9 Mon Sep 17 00:00:00 2001 From: super <2903208875@qq.com> Date: Fri, 29 May 2026 22:53:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=9C=B0=E5=9D=80=20=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=90=8E=E7=AB=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.env | 6 +- .../aiimage/config/SimilarAsinProperties.java | 5 + .../controller/ProductCategoryController.java | 21 ++++ .../service/ProductCategoryService.java | 108 ++++++++++++++++++ .../service/SimilarAsinTaskService.java | 11 +- .../src/main/resources/application.yml | 1 + backend/blueprints/admin_api.py | 40 +++++++ backend/config.py | 2 +- backend/static/admin.js | 33 +++++- backend/web_source/admin.html | 1 + 10 files changed, 220 insertions(+), 8 deletions(-) diff --git a/app/.env b/app/.env index 9a047de..d84ea08 100644 --- a/app/.env +++ b/app/.env @@ -11,9 +11,9 @@ zn_username=%E8%87%AA%E5%8A%A8%E5%8C%96_Robot client_name=ShuFuAI -# java_api_base=http://api.aishufu.top:18080/ -java_api_base=http://api.aishufu.top:18080/ -# java_api_base=http://api.aishufu.top:18080/ +# java_api_base=http://api.aishufu.top:18080/ +# java_api_base=http://api.aishufu.top:18080/ +java_api_base=http://127.0.0.1:18080/ # 与 Java 后端共享的 JWT 签名密钥,必须与 backend-java 的 AIIMAGE_JWT_SECRET 完全一致 AIIMAGE_JWT_SECRET=please-change-this-secret-please-rotate-at-least-32-bytes diff --git a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java index 91da921..50207e3 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java +++ b/backend-java/src/main/java/com/nanri/aiimage/config/SimilarAsinProperties.java @@ -24,6 +24,11 @@ public class SimilarAsinProperties { * 不影响 AppearancePatentProperties 的同名值。 */ private int cozeBatchSize = 3; + /** + * img_switch=false 时单次提交 Coze 的 row 数。 + * 不走图片检测时工作流压力小,恢复到 10 行一批以提高吞吐;开启图片检测时仍使用 cozeBatchSize。 + */ + private int cozeTextOnlyBatchSize = 10; private int cozeConnectTimeoutMillis = 10000; private int cozeReadTimeoutMillis = 60000; private int cozePollIntervalMillis = 30000; 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 3996374..53aea6a 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 @@ -1,6 +1,7 @@ package com.nanri.aiimage.modules.productcategory.controller; import com.nanri.aiimage.common.api.ApiResponse; +import com.nanri.aiimage.common.util.DownloadHeaderUtil; import com.nanri.aiimage.modules.productcategory.model.dto.ProductCategorySaveRequest; import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo; import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo; @@ -9,6 +10,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,12 +23,17 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + @RestController @RequiredArgsConstructor @RequestMapping("/api/admin") @Tag(name = "商品类目管理", description = "维护商品类目树") public class ProductCategoryController { + private static final DateTimeFormatter EXPORT_FILENAME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"); + private final ProductCategoryService productCategoryService; @GetMapping("/product-categories") @@ -51,6 +60,18 @@ public class ProductCategoryController { return ApiResponse.success(productCategoryService.search(keyword, page, pageSize)); } + @GetMapping("/product-categories/export") + @Operation(summary = "导出商品类目") + public ResponseEntity export(@RequestParam(value = "keyword", required = false) String keyword) { + byte[] bytes = productCategoryService.export(keyword); + String filename = "product-categories-" + LocalDateTime.now().format(EXPORT_FILENAME_FORMATTER) + ".xlsx"; + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, DownloadHeaderUtil.contentDisposition(filename)) + .contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")) + .contentLength(bytes.length) + .body(bytes); + } + @PostMapping("/product-category") @Operation(summary = "新增商品类目") public ApiResponse create(@Valid @RequestBody ProductCategorySaveRequest request) { diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/service/ProductCategoryService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/service/ProductCategoryService.java index a759ffa..5600560 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/service/ProductCategoryService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/productcategory/service/ProductCategoryService.java @@ -9,9 +9,15 @@ import com.nanri.aiimage.modules.productcategory.model.entity.ProductCategoryEnt import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryItemVo; import com.nanri.aiimage.modules.productcategory.model.vo.ProductCategoryListVo; import lombok.RequiredArgsConstructor; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.streaming.SXSSFWorkbook; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.io.ByteArrayOutputStream; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -25,6 +31,8 @@ import java.util.Set; @RequiredArgsConstructor public class ProductCategoryService { + private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private final ProductCategoryMapper productCategoryMapper; public ProductCategoryListVo list() { @@ -169,6 +177,26 @@ public class ProductCategoryService { return vo; } + public byte[] export(String keyword) { + ProductCategoryListVo list = list(keyword); + List rows = list.getItems() == null ? List.of() : list.getItems(); + Map nameById = buildExportNameById(rows); + try (SXSSFWorkbook workbook = new SXSSFWorkbook(100); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + Sheet sheet = workbook.createSheet("商品类目"); + writeExportHeader(sheet); + for (int index = 0; index < rows.size(); index++) { + writeExportRow(sheet, index + 1, rows.get(index), nameById); + } + applyExportColumnWidths(sheet); + workbook.write(outputStream); + workbook.dispose(); + return outputStream.toByteArray(); + } catch (Exception ex) { + throw new BusinessException("导出商品类目失败"); + } + } + private ProductCategoryListVo buildSearchPageVo(List rows, Long total, long page, long pageSize) { Map childCountById = buildChildCountById(rows); List items = rows.stream() @@ -200,6 +228,82 @@ public class ProductCategoryService { return vo; } + private void writeExportHeader(Sheet sheet) { + Row header = sheet.createRow(0); + String[] headers = { + "序号", "关系类型", "上级类目", "类目名称", "层级路径", "类目编码", + "层级", "排序", "来源", "备注", "子类目数", "类目ID", "父级ID", "创建时间", "更新时间" + }; + for (int i = 0; i < headers.length; i++) { + header.createCell(i).setCellValue(headers[i]); + } + } + + private void writeExportRow(Sheet sheet, int rowNo, ProductCategoryItemVo item, Map nameById) { + Row row = sheet.createRow(rowNo); + int col = 0; + row.createCell(col++).setCellValue(rowNo); + row.createCell(col++).setCellValue(resolveRelationType(item)); + row.createCell(col++).setCellValue(resolveParentName(item, nameById)); + row.createCell(col++).setCellValue(indentName(item)); + row.createCell(col++).setCellValue(blankToEmpty(item.getPath())); + row.createCell(col++).setCellValue(blankToEmpty(item.getCategoryKey())); + row.createCell(col++).setCellValue(item.getLevel() == null ? 0 : item.getLevel()); + row.createCell(col++).setCellValue(item.getSortOrder() == null ? 0 : item.getSortOrder()); + row.createCell(col++).setCellValue(Boolean.TRUE.equals(item.getIsBuiltin()) ? "内置" : "自定义"); + row.createCell(col++).setCellValue(blankToEmpty(item.getDescription())); + row.createCell(col++).setCellValue(item.getChildCount() == null ? 0 : item.getChildCount()); + row.createCell(col++).setCellValue(item.getId() == null ? "" : String.valueOf(item.getId())); + row.createCell(col++).setCellValue(item.getParentId() == null ? "" : String.valueOf(item.getParentId())); + row.createCell(col++).setCellValue(formatExportTime(item.getCreatedAt())); + row.createCell(col).setCellValue(formatExportTime(item.getUpdatedAt())); + } + + private void applyExportColumnWidths(Sheet sheet) { + int[] widths = {2400, 4200, 7200, 8600, 12000, 7200, 2400, 2400, 2800, 10000, 3200, 3600, 3600, 5200, 5200}; + for (int i = 0; i < widths.length; i++) { + sheet.setColumnWidth(i, widths[i]); + } + } + + private Map buildExportNameById(List rows) { + Map nameById = new HashMap<>(); + for (ProductCategoryItemVo row : rows) { + if (row.getId() != null) { + nameById.put(row.getId(), row.getName()); + } + } + return nameById; + } + + private String resolveRelationType(ProductCategoryItemVo item) { + int level = item.getLevel() == null ? 0 : item.getLevel(); + if (level <= 0) { + return "顶级类目"; + } + return level + "级子类目"; + } + + private String resolveParentName(ProductCategoryItemVo item, Map nameById) { + if (item.getParentId() == null) { + return "无"; + } + String parentName = nameById.get(item.getParentId()); + if (parentName == null || parentName.isBlank()) { + return "上级ID: " + item.getParentId(); + } + return parentName + "(ID: " + item.getParentId() + ")"; + } + + private String indentName(ProductCategoryItemVo item) { + int level = Math.max(0, item.getLevel() == null ? 0 : item.getLevel()); + return " ".repeat(level) + blankToEmpty(item.getName()); + } + + private String formatExportTime(LocalDateTime time) { + return time == null ? "" : time.format(EXPORT_TIME_FORMATTER); + } + private Map buildChildCountById(List rows) { Map childCountById = new HashMap<>(); if (rows.isEmpty()) { @@ -450,6 +554,10 @@ public class ProductCategoryService { return normalized.length() > 512 ? normalized.substring(0, 512) : normalized; } + private String blankToEmpty(String value) { + return value == null ? "" : value; + } + private String generateCategoryKey(Long parentId, String name) { String source = (parentId == null ? "root" : String.valueOf(parentId)) + ":" + name; return "custom_" + DigestUtil.sha1Hex(source).substring(0, 20); 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 c05527f..6d1a55b 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 @@ -1231,7 +1231,7 @@ public class SimilarAsinTaskService { String prompt = readAiPrompt(task); String apiKey = readApiKey(task); boolean imgSwitch = readImgSwitch(task); - int batchSize = Math.max(1, properties.getCozeBatchSize()); + int batchSize = resolveCozeBatchSize(imgSwitch); List result = new ArrayList<>(); for (int i = 0; i < items.size(); i += batchSize) { result.addAll(cozeClient.inspect(items.subList(i, Math.min(i + batchSize, items.size())), prompt, apiKey, imgSwitch)); @@ -1553,6 +1553,11 @@ public class SimilarAsinTaskService { } } + private int resolveCozeBatchSize(boolean imgSwitch) { + int configured = imgSwitch ? properties.getCozeBatchSize() : properties.getCozeTextOnlyBatchSize(); + return Math.max(1, configured); + } + private void finalizeTask(FileTaskEntity task, String error, int rowCount, boolean assembleWorkbook) { String finalError = error; FileResultEntity result = null; @@ -1687,7 +1692,7 @@ public class SimilarAsinTaskService { .eq(TaskChunkEntity::getModuleType, MODULE_TYPE) .orderByAsc(TaskChunkEntity::getChunkIndex)); Map> allRowsByBaseId = loadAllRowsByBaseId(task); - int batchSize = Math.max(1, properties.getCozeBatchSize()); + int batchSize = resolveCozeBatchSize(readImgSwitch(task)); int cozeWorkUnits = countCozeWorkUnits(chunks, batchSize); int totalProgressUnits = Math.max(3, cozeWorkUnits + 3); if (countPendingCozeStates(task.getId()) > 0) { @@ -1921,7 +1926,7 @@ public class SimilarAsinTaskService { String prompt = readAiPrompt(task); String apiKey = readApiKey(task); boolean imgSwitch = readImgSwitch(task); - int batchSize = Math.max(1, properties.getCozeBatchSize()); + int batchSize = resolveCozeBatchSize(imgSwitch); // P1-1:检测到 720712008 风暴时强制把 batch 降到 1,隔离毒行; // 持续 5+ 次提交命中率 ≥ 40% 才会触发,正常波动不影响吞吐。 if (isPoisonStormActive(task.getId())) { diff --git a/backend-java/src/main/resources/application.yml b/backend-java/src/main/resources/application.yml index 4e619bb..8a4cded 100644 --- a/backend-java/src/main/resources/application.yml +++ b/backend-java/src/main/resources/application.yml @@ -167,6 +167,7 @@ aiimage: coze-workflow-id: ${AIIMAGE_SIMILAR_ASIN_COZE_WORKFLOW_ID:7639708860686024756} coze-token: ${AIIMAGE_SIMILAR_ASIN_COZE_TOKEN:} coze-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_BATCH_SIZE:3} + coze-text-only-batch-size: ${AIIMAGE_SIMILAR_ASIN_COZE_TEXT_ONLY_BATCH_SIZE:10} coze-credential-stripe-size: ${AIIMAGE_SIMILAR_ASIN_COZE_CREDENTIAL_STRIPE_SIZE:0} coze-connect-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_CONNECT_TIMEOUT_MILLIS:10000} coze-read-timeout-millis: ${AIIMAGE_SIMILAR_ASIN_COZE_READ_TIMEOUT_MILLIS:60000} diff --git a/backend/blueprints/admin_api.py b/backend/blueprints/admin_api.py index 5cc8f78..9cf37bd 100644 --- a/backend/blueprints/admin_api.py +++ b/backend/blueprints/admin_api.py @@ -1306,6 +1306,46 @@ def search_product_categories(): }) +@admin_api.route('/product-categories/export') +@login_required +def export_product_categories(): + _ensure_product_category_schema() + _, _, denied = _ensure_product_category_access() + if denied: + return denied + keyword = (request.args.get('keyword') or '').strip() + params = {} + if keyword: + params['keyword'] = keyword + + url = f"{backend_java_base_url}/api/admin/product-categories/export" + try: + resp = _get_backend_java_session().get(url, params=params, timeout=60) + except requests.RequestException: + return jsonify({'success': False, 'error': 'backend-java 服务不可用'}), 502 + if resp.status_code >= 400: + try: + data = resp.json() + error = data.get('message') or data.get('error') or '导出失败' + except ValueError: + error = '导出失败' + return jsonify({'success': False, 'error': error}), resp.status_code + + headers = {} + disposition = resp.headers.get('Content-Disposition') + if disposition: + headers['Content-Disposition'] = disposition + return Response( + resp.content, + status=resp.status_code, + headers=headers, + content_type=resp.headers.get( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ), + ) + + @admin_api.route('/product-category', methods=['POST']) @login_required def create_product_category(): diff --git a/backend/config.py b/backend/config.py index b09e517..8e1beb2 100644 --- a/backend/config.py +++ b/backend/config.py @@ -60,7 +60,7 @@ backend_java_base_url, backend_java_base_url_source = _get_env( "java_api_base", "BACKEND_JAVA_BASE_URL", default="http://api.aishufu.top:18080/", - # default="http://api.aishufu.top:18080/", + # default="http://127.0.0.1:18080/", ) backend_java_base_url = backend_java_base_url.rstrip("/") os.environ["OSS_ACCESS_KEY_ID"] = accessKeyId diff --git a/backend/static/admin.js b/backend/static/admin.js index 74ac852..048d3bd 100644 --- a/backend/static/admin.js +++ b/backend/static/admin.js @@ -3420,6 +3420,34 @@ renderProductCategoryRows(); } + function exportProductCategories() { + var keyword = (document.getElementById('productCategoryKeyword').value || '').trim(); + var url = '/api/admin/product-categories/export' + (keyword ? ('?keyword=' + encodeURIComponent(keyword)) : ''); + fetch(url) + .then(function (response) { + var contentType = response.headers.get('content-type') || ''; + if (!response.ok || contentType.indexOf('application/json') >= 0) { + return response.json().then(function (res) { + throw new Error((res && (res.error || res.msg)) || '导出失败'); + }).catch(function (err) { + throw err instanceof Error ? err : new Error('导出失败'); + }); + } + return response.blob().then(function (blob) { + return { + blob: blob, + filename: extractDownloadFilename(response.headers.get('content-disposition'), 'product-categories.xlsx') + }; + }); + }) + .then(function (payload) { + triggerBrowserDownload(payload.blob, payload.filename); + }) + .catch(function (err) { + alert(err.message || '导出失败'); + }); + } + document.getElementById('btnCancelProductCategoryEdit').onclick = function () { resetProductCategoryForm(); }; @@ -3435,6 +3463,10 @@ loadProductCategories(); }; + document.getElementById('btnExportProductCategory').onclick = function () { + exportProductCategories(); + }; + document.getElementById('productCategoryKeyword').onkeydown = function (event) { if (event.key === 'Enter') { event.preventDefault(); @@ -3976,4 +4008,3 @@ loadAdminMenus(); }); }) (); - diff --git a/backend/web_source/admin.html b/backend/web_source/admin.html index d3258ef..2a32366 100644 --- a/backend/web_source/admin.html +++ b/backend/web_source/admin.html @@ -1426,6 +1426,7 @@ +