更新地址 更新后端

This commit is contained in:
super
2026-05-29 22:53:44 +08:00
parent 080625567b
commit 9835831415
10 changed files with 220 additions and 8 deletions

View File

@@ -12,8 +12,8 @@ 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://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

View File

@@ -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;

View File

@@ -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<byte[]> 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<ProductCategoryItemVo> create(@Valid @RequestBody ProductCategorySaveRequest request) {

View File

@@ -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<ProductCategoryItemVo> rows = list.getItems() == null ? List.of() : list.getItems();
Map<Long, String> 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<ProductCategoryEntity> rows, Long total, long page, long pageSize) {
Map<Long, Integer> childCountById = buildChildCountById(rows);
List<ProductCategoryItemVo> 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<Long, String> 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<Long, String> buildExportNameById(List<ProductCategoryItemVo> rows) {
Map<Long, String> 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<Long, String> 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<Long, Integer> buildChildCountById(List<ProductCategoryEntity> rows) {
Map<Long, Integer> 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);

View File

@@ -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<SimilarAsinResultRowDto> 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<String, List<SimilarAsinParsedRowVo>> 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())) {

View File

@@ -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}

View File

@@ -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():

View File

@@ -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

View File

@@ -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();
});
}) ();

View File

@@ -1426,6 +1426,7 @@
</div>
<button class="btn btn-sm" id="btnSearchProductCategory" type="button">搜索</button>
<button class="btn btn-sm btn-secondary" id="btnClearProductCategorySearch" type="button">清空</button>
<button class="btn btn-sm btn-secondary" id="btnExportProductCategory" type="button">导出</button>
</div>
</div>
<table>