From 33a04d7f078f0d0ebd53ad278facadb6632e35a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com>
Date: Mon, 29 Dec 2025 15:07:24 +0800
Subject: [PATCH] =?UTF-8?q?feat(goods):=20=E6=B7=BB=E5=8A=A0Excel=E6=89=B9?=
=?UTF-8?q?=E9=87=8F=E5=AF=BC=E5=85=A5=E5=95=86=E5=93=81=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 集成POI库支持Excel文件读取和解析
- 实现商品信息从Excel表格到数据库的批量导入
- 支持商品分类自动创建和映射
- 添加商品图片从Excel单元格提取和保存功能
- 实现导入过程中的数据验证和错误处理
- 提供导入结果统计和反馈信息
- 支持跳过已存在商品的可选配置
- 添加导入参数的灵活配置选项
---
.idea/workspace.xml | 86 ++++
.../shop/controller/GoodsController.java | 383 ++++++++++++++++++
2 files changed, 469 insertions(+)
create mode 100644 .idea/workspace.xml
diff --git a/.idea/workspace.xml b/.idea/workspace.xml
new file mode 100644
index 0000000..ccf8d31
--- /dev/null
+++ b/.idea/workspace.xml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 1766987144189
+
+
+ 1766987144189
+
+
+
+
\ No newline at end of file
diff --git a/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java b/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java
index ae46fcc..950c988 100644
--- a/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java
+++ b/src/main/java/com/gxwebsoft/shop/controller/GoodsController.java
@@ -7,10 +7,12 @@ import cn.binarywang.wx.miniapp.bean.WxMaCodeLineColor;
import cn.hutool.core.img.ImgUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.http.HttpUtil;
+import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.freewayso.image.combiner.ImageCombiner;
import com.freewayso.image.combiner.enums.OutputFormat;
+import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.utils.JSONUtil;
import com.gxwebsoft.common.core.utils.RequestUtil;
import com.gxwebsoft.common.core.web.BaseController;
@@ -26,6 +28,8 @@ import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.system.entity.User;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -33,13 +37,27 @@ import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import javax.imageio.ImageIO;
+import javax.xml.parsers.DocumentBuilderFactory;
import java.awt.*;
+import java.awt.Color;
import java.awt.image.BufferedImage;
import java.io.File;
+import java.io.FileInputStream;
+import java.io.InputStream;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.NodeList;
/**
* 商品记录表控制器
@@ -51,6 +69,8 @@ import java.util.Map;
@RestController
@RequestMapping("/api/shop/goods")
public class GoodsController extends BaseController {
+ private static final String DEFAULT_IMPORT_EXCEL_PATH = "/Users/gxwebsoft/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files/ip170com_ae28/msg/file/2025-12/产品目录-商城储备12.26(2).xlsx";
+
@Resource
private GoodsService goodsService;
@Resource
@@ -61,6 +81,10 @@ public class GoodsController extends BaseController {
private MerchantService merchantService;
@Resource
private GoodsRoleCommissionService goodsRoleCommissionService;
+ @Resource
+ private GoodsCategoryService goodsCategoryService;
+ @Resource
+ private ConfigProperties config;
@Value("${config.upload-path}")
private String uploadPath;
@@ -271,6 +295,365 @@ public class GoodsController extends BaseController {
return success(data);
}
+ @ApiOperation("批量导入商品(Excel)")
+ @PostMapping("/import-excel")
+ public ApiResult importExcel(@RequestParam(value = "path", required = false) String path,
+ @RequestParam(value = "sheetName", required = false) String sheetName,
+ @RequestParam(value = "skipExisting", required = false, defaultValue = "true") Boolean skipExisting,
+ @RequestParam(value = "createCategory", required = false, defaultValue = "true") Boolean createCategory,
+ @RequestParam(value = "merchantId", required = false) Integer merchantId,
+ @RequestParam(value = "defaultIsShow", required = false) Integer defaultIsShow,
+ @RequestParam(value = "defaultStock", required = false) Integer defaultStock) {
+ String excelPath = StrUtil.blankToDefault(path, DEFAULT_IMPORT_EXCEL_PATH);
+ String targetSheetName = StrUtil.blankToDefault(sheetName, "Sheet1");
+ if (!FileUtil.exist(excelPath)) {
+ return fail("Excel文件不存在: " + excelPath,null);
+ }
+
+ User loginUser = getLoginUser();
+ Integer importUserId = loginUser == null ? null : loginUser.getUserId();
+ Integer importMerchantId = merchantId != null ? merchantId : (loginUser == null ? 0 : (loginUser.getMerchantId() == null ? 0 : loginUser.getMerchantId()));
+ int importIsShow = defaultIsShow != null ? defaultIsShow : 0;
+ int importStock = defaultStock != null ? defaultStock : 0;
+
+ GoodsImportResult result = new GoodsImportResult();
+ result.setExcelPath(excelPath);
+ result.setSheetName(targetSheetName);
+
+ DataFormatter formatter = new DataFormatter();
+ try (ZipFile zipFile = new ZipFile(excelPath); InputStream in = new FileInputStream(excelPath); Workbook workbook = new XSSFWorkbook(in)) {
+ Sheet sheet = workbook.getSheet(targetSheetName);
+ if (sheet == null) {
+ return fail("未找到工作表: " + targetSheetName,null);
+ }
+
+ Map cellImageIdToMediaEntry = buildCellImageIdToMediaEntry(zipFile);
+ Map categoryByTitle = loadCategoryMap(importMerchantId);
+
+ Set names = collectGoodsNames(sheet, formatter);
+ Set existingNames = java.util.Collections.emptySet();
+ if (skipExisting && !names.isEmpty()) {
+ existingNames = goodsService.list(new LambdaQueryWrapper()
+ .eq(Goods::getMerchantId, importMerchantId)
+ .in(Goods::getGoodsName, names))
+ .stream().map(Goods::getGoodsName).collect(Collectors.toSet());
+ }
+
+ int lastRow = sheet.getLastRowNum();
+ for (int i = 1; i <= lastRow; i++) {
+ Row row = sheet.getRow(i);
+ if (row == null) {
+ continue;
+ }
+
+ String goodsName = getCellString(row, 2, formatter);
+ if (StrUtil.isBlank(goodsName)) {
+ continue;
+ }
+ goodsName = goodsName.trim();
+ result.setTotalRows(result.getTotalRows() + 1);
+
+ if (skipExisting && existingNames.contains(goodsName)) {
+ result.setSkippedExists(result.getSkippedExists() + 1);
+ continue;
+ }
+
+ String actionCategory = getCellString(row, 1, formatter);
+ String brand = getCellString(row, 3, formatter);
+ String manufacturer = getCellString(row, 4, formatter);
+ String spec = getCellString(row, 6, formatter);
+ String categoryName = getCellString(row, 10, formatter);
+ String supplierName = getCellString(row, 11, formatter);
+
+ BigDecimal costPrice = getCellDecimal(row, 8, formatter);
+ BigDecimal salePrice = getCellDecimal(row, 9, formatter);
+ Integer sales = parseSales(getCellString(row, 5, formatter));
+
+ Goods goods = new Goods();
+ goods.setGoodsName(goodsName);
+ goods.setType(0);
+ goods.setSpecs(0);
+ goods.setDeductStockType(10);
+ goods.setStock(importStock);
+ goods.setIsShow(importIsShow);
+ goods.setStatus(1);
+ goods.setMerchantId(importMerchantId);
+ goods.setUserId(importUserId);
+ goods.setUnitName(spec);
+ goods.setSupplierName(supplierName);
+ goods.setSales(sales);
+ goods.setPrice(costPrice);
+ goods.setSalePrice(salePrice != null ? salePrice : costPrice);
+ goods.setOriginPrice(goods.getSalePrice());
+ goods.setCategoryName(categoryName);
+ goods.setComments(buildComments(actionCategory, brand, manufacturer));
+
+ if (StrUtil.isNotBlank(categoryName)) {
+ GoodsCategory category = categoryByTitle.get(categoryName.trim());
+ if (category == null && createCategory) {
+ category = createCategory(importMerchantId, categoryName.trim());
+ categoryByTitle.put(category.getTitle(), category);
+ }
+ if (category != null) {
+ goods.setCategoryId(category.getCategoryId());
+ }
+ }
+
+ String imageId = extractDispimgId(row.getCell(7));
+ if (StrUtil.isNotBlank(imageId)) {
+ String mediaEntry = cellImageIdToMediaEntry.get(imageId);
+ if (StrUtil.isNotBlank(mediaEntry)) {
+ String url = saveCellImage(zipFile, mediaEntry, imageId);
+ if (StrUtil.isNotBlank(url)) {
+ goods.setImage(url);
+ }
+ }
+ }
+
+ result.getGoods().add(goods);
+ }
+
+ if (result.getGoods().isEmpty()) {
+ return success("未读取到可导入的数据", result);
+ }
+ if (goodsService.saveBatch(result.getGoods())) {
+ result.setInserted(result.getGoods().size());
+ return success("导入成功", result);
+ }
+ return fail("导入失败", result);
+ } catch (Exception e) {
+ return fail("导入异常: " + e.getMessage(), result).setError(e.toString());
+ }
+ }
+
+ private Map loadCategoryMap(Integer merchantId) {
+ List categories = goodsCategoryService.list(new LambdaQueryWrapper()
+ .eq(GoodsCategory::getMerchantId, merchantId)
+ .eq(GoodsCategory::getDeleted, 0));
+ return categories.stream()
+ .filter(c -> StrUtil.isNotBlank(c.getTitle()))
+ .collect(Collectors.toMap(GoodsCategory::getTitle, c -> c, (a, b) -> a));
+ }
+
+ private Set collectGoodsNames(Sheet sheet, DataFormatter formatter) {
+ int lastRow = sheet.getLastRowNum();
+ return java.util.stream.IntStream.rangeClosed(1, lastRow)
+ .mapToObj(sheet::getRow)
+ .filter(r -> r != null)
+ .map(r -> getCellString(r, 2, formatter))
+ .filter(StrUtil::isNotBlank)
+ .map(String::trim)
+ .collect(Collectors.toSet());
+ }
+
+ private GoodsCategory createCategory(Integer merchantId, String title) {
+ GoodsCategory category = new GoodsCategory();
+ category.setTitle(title);
+ category.setType(0);
+ category.setParentId(0);
+ category.setMerchantId(merchantId);
+ category.setStatus(0);
+ category.setSortNumber(0);
+ goodsCategoryService.save(category);
+ return category;
+ }
+
+ private static String buildComments(String actionCategory, String brand, String manufacturer) {
+ StringBuilder sb = new StringBuilder();
+ if (StrUtil.isNotBlank(actionCategory)) sb.append("作用分类:").append(actionCategory.trim()).append(" ");
+ if (StrUtil.isNotBlank(brand)) sb.append("品牌:").append(brand.trim()).append(" ");
+ if (StrUtil.isNotBlank(manufacturer)) sb.append("厂家:").append(manufacturer.trim()).append(" ");
+ return sb.toString().trim();
+ }
+
+ private static String getCellString(Row row, int cellIndex, DataFormatter formatter) {
+ Cell cell = row.getCell(cellIndex, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
+ if (cell == null) return null;
+ String v = formatter.formatCellValue(cell);
+ return StrUtil.trimToNull(v);
+ }
+
+ private static BigDecimal getCellDecimal(Row row, int cellIndex, DataFormatter formatter) {
+ String v = getCellString(row, cellIndex, formatter);
+ if (StrUtil.isBlank(v)) return null;
+ v = v.replace(",", "").replace("元", "").trim();
+ if (v.endsWith("+")) v = v.substring(0, v.length() - 1);
+ try {
+ return new BigDecimal(v);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static Integer parseSales(String v) {
+ if (StrUtil.isBlank(v)) return null;
+ Matcher m = Pattern.compile("(\\d+)").matcher(v);
+ if (m.find()) {
+ try {
+ return Integer.parseInt(m.group(1));
+ } catch (Exception ignored) {
+ }
+ }
+ return null;
+ }
+
+ private static String extractDispimgId(Cell cell) {
+ if (cell == null) return null;
+ String formula = null;
+ try {
+ if (cell.getCellType() == CellType.FORMULA) {
+ formula = cell.getCellFormula();
+ }
+ } catch (Exception ignored) {
+ }
+ String v = StrUtil.blankToDefault(formula, cell.toString());
+ Matcher matcher = Pattern.compile("ID_[0-9A-Fa-f]+").matcher(v);
+ return matcher.find() ? matcher.group() : null;
+ }
+
+ private String saveCellImage(ZipFile zipFile, String mediaEntryName, String imageId) {
+ ZipEntry entry = zipFile.getEntry(mediaEntryName);
+ if (entry == null) return null;
+ String ext = ".jpg";
+ int dot = mediaEntryName.lastIndexOf('.');
+ if (dot > 0 && dot < mediaEntryName.length() - 1) {
+ ext = "." + mediaEntryName.substring(dot + 1);
+ }
+ String relativePath = "goodsImport/" + imageId + ext;
+ File out = FileUtil.file(uploadPath, relativePath);
+ try {
+ if (!out.exists()) {
+ FileUtil.mkParentDirs(out);
+ try (InputStream in = zipFile.getInputStream(entry)) {
+ FileUtil.writeFromStream(in, out);
+ }
+ }
+ String fileServer = config.getFileServer();
+ if (StrUtil.isBlank(fileServer)) {
+ return serverUrl.concat("/").concat(relativePath);
+ }
+ return StrUtil.removeSuffix(fileServer, "/") + "/api/file/" + relativePath;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static Map buildCellImageIdToMediaEntry(ZipFile zipFile) {
+ final String PKG_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships";
+ final String XDR_NS = "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing";
+ final String A_NS = "http://schemas.openxmlformats.org/drawingml/2006/main";
+ final String R_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
+
+ Map rIdToTarget = new HashMap<>();
+ Map idToMedia = new HashMap<>();
+
+ try {
+ ZipEntry relsEntry = zipFile.getEntry("xl/_rels/cellimages.xml.rels");
+ ZipEntry cellImagesEntry = zipFile.getEntry("xl/cellimages.xml");
+ if (relsEntry == null || cellImagesEntry == null) {
+ return idToMedia;
+ }
+
+ DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ dbf.setNamespaceAware(true);
+
+ Document relsDoc;
+ try (InputStream relsIn = zipFile.getInputStream(relsEntry)) {
+ relsDoc = dbf.newDocumentBuilder().parse(relsIn);
+ }
+ NodeList relNodes = relsDoc.getElementsByTagNameNS(PKG_REL_NS, "Relationship");
+ for (int i = 0; i < relNodes.getLength(); i++) {
+ Element el = (Element) relNodes.item(i);
+ String id = el.getAttribute("Id");
+ String target = el.getAttribute("Target");
+ if (StrUtil.isNotBlank(id) && StrUtil.isNotBlank(target)) {
+ String normalizedTarget = target.startsWith("/") ? target.substring(1) : target;
+ rIdToTarget.put(id, "xl/" + normalizedTarget);
+ }
+ }
+
+ Document imagesDoc;
+ try (InputStream imagesIn = zipFile.getInputStream(cellImagesEntry)) {
+ imagesDoc = dbf.newDocumentBuilder().parse(imagesIn);
+ }
+ NodeList pics = imagesDoc.getElementsByTagNameNS(XDR_NS, "pic");
+ for (int i = 0; i < pics.getLength(); i++) {
+ Element pic = (Element) pics.item(i);
+ NodeList cNvPrNodes = pic.getElementsByTagNameNS(XDR_NS, "cNvPr");
+ NodeList blipNodes = pic.getElementsByTagNameNS(A_NS, "blip");
+ if (cNvPrNodes.getLength() == 0 || blipNodes.getLength() == 0) continue;
+
+ Element cNvPr = (Element) cNvPrNodes.item(0);
+ Element blip = (Element) blipNodes.item(0);
+ String name = cNvPr.getAttribute("name");
+ String embed = blip.getAttributeNS(R_NS, "embed");
+ String target = rIdToTarget.get(embed);
+ if (StrUtil.isNotBlank(name) && StrUtil.isNotBlank(target)) {
+ idToMedia.put(name, target);
+ }
+ }
+ } catch (Exception ignored) {
+ }
+ return idToMedia;
+ }
+
+ public static class GoodsImportResult {
+ private String excelPath;
+ private String sheetName;
+ private int totalRows;
+ private int inserted;
+ private int skippedExists;
+ private List goods = new java.util.ArrayList<>();
+
+ public String getExcelPath() {
+ return excelPath;
+ }
+
+ public void setExcelPath(String excelPath) {
+ this.excelPath = excelPath;
+ }
+
+ public String getSheetName() {
+ return sheetName;
+ }
+
+ public void setSheetName(String sheetName) {
+ this.sheetName = sheetName;
+ }
+
+ public int getTotalRows() {
+ return totalRows;
+ }
+
+ public void setTotalRows(int totalRows) {
+ this.totalRows = totalRows;
+ }
+
+ public int getInserted() {
+ return inserted;
+ }
+
+ public void setInserted(int inserted) {
+ this.inserted = inserted;
+ }
+
+ public int getSkippedExists() {
+ return skippedExists;
+ }
+
+ public void setSkippedExists(int skippedExists) {
+ this.skippedExists = skippedExists;
+ }
+
+ public List getGoods() {
+ return goods;
+ }
+
+ public void setGoods(List goods) {
+ this.goods = goods;
+ }
+ }
+
@ApiOperation("生成海报")
@PostMapping("/make-goods-poster/{goodsId}")
public ApiResult> makePoster(@PathVariable Integer goodsId) throws Exception {