From e7687c890934b8e51c162c7941ff37744255dd40 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 29 Nov 2025 22:35:06 +0800 Subject: [PATCH] 1 --- .../controller/MarketingImageController.java | 185 ++++++++ .../service/MarketingImageService.java | 433 ++++++++++++++++++ 2 files changed, 618 insertions(+) create mode 100644 src/main/java/cn/van/business/controller/MarketingImageController.java create mode 100644 src/main/java/cn/van/business/service/MarketingImageService.java diff --git a/src/main/java/cn/van/business/controller/MarketingImageController.java b/src/main/java/cn/van/business/controller/MarketingImageController.java new file mode 100644 index 0000000..2398a3c --- /dev/null +++ b/src/main/java/cn/van/business/controller/MarketingImageController.java @@ -0,0 +1,185 @@ +package cn.van.business.controller; + +import cn.van.business.model.ApiResponse; +import cn.van.business.service.MarketingImageService; +import com.alibaba.fastjson2.JSONObject; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 营销图片合成控制器 + * 提供营销图片生成的HTTP接口 + * + * @author System + */ +@Slf4j +@RestController +@RequestMapping("/jarvis/marketing-image") +public class MarketingImageController { + + @Autowired + private MarketingImageService marketingImageService; + + /** + * 生成单张营销图片 + * + * POST /jarvis/marketing-image/generate + * + * 请求体: + * { + * "productImageUrl": "商品主图URL", + * "originalPrice": 499.0, + * "finalPrice": 199.0, + * "productName": "商品名称(可选)" + * } + * + * 返回: + * { + * "code": 200, + * "msg": "操作成功", + * "data": { + * "imageBase64": "data:image/jpg;base64,..." + * } + * } + */ + @PostMapping("/generate") + public JSONObject generateMarketingImage(@RequestBody Map request) { + JSONObject response = new JSONObject(); + try { + String productImageUrl = (String) request.get("productImageUrl"); + Object originalPriceObj = request.get("originalPrice"); + Object finalPriceObj = request.get("finalPrice"); + String productName = (String) request.get("productName"); + + if (productImageUrl == null || originalPriceObj == null || finalPriceObj == null) { + response.put("code", 400); + response.put("msg", "缺少必要参数: productImageUrl, originalPrice, finalPrice"); + return response; + } + + Double originalPrice = parseDouble(originalPriceObj); + Double finalPrice = parseDouble(finalPriceObj); + + if (originalPrice == null || finalPrice == null) { + response.put("code", 400); + response.put("msg", "价格参数格式错误"); + return response; + } + + String base64Image = marketingImageService.generateMarketingImage( + productImageUrl, originalPrice, finalPrice, productName); + + Map data = new HashMap<>(); + data.put("imageBase64", base64Image); + + response.put("code", 200); + response.put("msg", "操作成功"); + response.put("data", data); + + } catch (Exception e) { + log.error("生成营销图片失败", e); + response.put("code", 500); + response.put("msg", "生成营销图片失败: " + e.getMessage()); + } + return response; + } + + /** + * 批量生成营销图片 + * + * POST /jarvis/marketing-image/batch-generate + * + * 请求体: + * { + * "requests": [ + * { + * "productImageUrl": "商品主图URL1", + * "originalPrice": 499.0, + * "finalPrice": 199.0, + * "productName": "商品名称1(可选)" + * }, + * { + * "productImageUrl": "商品主图URL2", + * "originalPrice": 699.0, + * "finalPrice": 349.0, + * "productName": "商品名称2(可选)" + * } + * ] + * } + * + * 返回: + * { + * "code": 200, + * "msg": "操作成功", + * "data": { + * "results": [ + * { + * "success": true, + * "imageBase64": "data:image/jpg;base64,...", + * "index": 0 + * }, + * { + * "success": false, + * "error": "错误信息", + * "index": 1 + * } + * ], + * "total": 2, + * "successCount": 1, + * "failCount": 1 + * } + * } + */ + @PostMapping("/batch-generate") + public JSONObject batchGenerateMarketingImages(@RequestBody Map request) { + JSONObject response = new JSONObject(); + try { + @SuppressWarnings("unchecked") + List> requests = (List>) request.get("requests"); + + if (requests == null || requests.isEmpty()) { + response.put("code", 400); + response.put("msg", "请求列表不能为空"); + return response; + } + + Map result = marketingImageService.batchGenerateMarketingImages(requests); + + response.put("code", 200); + response.put("msg", "操作成功"); + response.put("data", result); + + } catch (Exception e) { + log.error("批量生成营销图片失败", e); + response.put("code", 500); + response.put("msg", "批量生成营销图片失败: " + e.getMessage()); + } + return response; + } + + /** + * 解析Double值 + */ + private Double parseDouble(Object value) { + if (value == null) { + return null; + } + if (value instanceof Double) { + return (Double) value; + } + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + try { + return Double.parseDouble(value.toString()); + } catch (Exception e) { + return null; + } + } +} + diff --git a/src/main/java/cn/van/business/service/MarketingImageService.java b/src/main/java/cn/van/business/service/MarketingImageService.java new file mode 100644 index 0000000..8c19375 --- /dev/null +++ b/src/main/java/cn/van/business/service/MarketingImageService.java @@ -0,0 +1,433 @@ +package cn.van.business.service; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.HttpUtil; +import cn.van.business.util.ds.DeepSeekClientUtil; +import lombok.extern.slf4j.Slf4j; +import net.coobird.thumbnailator.Thumbnails; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; + +/** + * 营销图片合成服务 + * 用于生成小红书等平台的营销对比图 + * + * @author System + */ +@Slf4j +@Service +public class MarketingImageService { + + @Autowired + private DeepSeekClientUtil deepSeekClientUtil; + + // 输出图片尺寸 + private static final int OUTPUT_WIDTH = 1080; + private static final int OUTPUT_HEIGHT = 1080; + + // 字体配置(支持回退) + private static final String[] FONT_NAMES = {"Microsoft YaHei", "SimHei", "Arial", Font.SANS_SERIF}; // 字体优先级 + private static final int ORIGINAL_PRICE_FONT_SIZE = 36; // 官网价字体大小 + private static final int FINAL_PRICE_FONT_SIZE = 72; // 到手价字体大小 + private static final int PRODUCT_NAME_FONT_SIZE = 32; // 商品名称字体大小 + + /** + * 获取可用字体 + */ + private Font getAvailableFont(int style, int size) { + GraphicsEnvironment ge = GraphicsEnvironment.getLocalGraphicsEnvironment(); + String[] availableFonts = ge.getAvailableFontFamilyNames(); + + for (String fontName : FONT_NAMES) { + for (String available : availableFonts) { + if (available.equals(fontName)) { + return new Font(fontName, style, size); + } + } + } + // 如果都不可用,使用默认字体 + return new Font(Font.SANS_SERIF, style, size); + } + + // 颜色配置 + private static final Color ORIGINAL_PRICE_COLOR = new Color(153, 153, 153); // 灰色 #999999 + private static final Color FINAL_PRICE_COLOR = new Color(255, 0, 0); // 红色 #FF0000 + private static final Color PRODUCT_NAME_COLOR = new Color(51, 51, 51); // 深灰色 #333333 + private static final Color BACKGROUND_COLOR = Color.WHITE; // 背景色 + + /** + * 生成营销图片 + * + * @param productImageUrl 商品主图URL + * @param originalPrice 官网价 + * @param finalPrice 到手价 + * @param productName 商品名称(可选,如果为空则使用AI提取) + * @return Base64编码的图片 + */ + public String generateMarketingImage(String productImageUrl, Double originalPrice, Double finalPrice, String productName) { + try { + log.info("开始生成营销图片: productImageUrl={}, originalPrice={}, finalPrice={}, productName={}", + productImageUrl, originalPrice, finalPrice, productName); + + // 1. 加载商品主图 + BufferedImage productImage = loadProductImage(productImageUrl); + if (productImage == null) { + throw new IOException("无法加载商品主图: " + productImageUrl); + } + + // 2. 提取商品标题关键部分(如果未提供) + String keyProductName = productName; + if (StrUtil.isBlank(keyProductName)) { + // 如果未提供商品名称,则无法提取,留空 + keyProductName = ""; + } else { + // 如果提供了完整商品名称,提取关键部分 + keyProductName = extractKeyProductName(keyProductName); + } + + // 3. 创建画布 + BufferedImage canvas = new BufferedImage(OUTPUT_WIDTH, OUTPUT_HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D g2d = canvas.createGraphics(); + + // 设置抗锯齿 + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); + + // 4. 绘制背景 + g2d.setColor(BACKGROUND_COLOR); + g2d.fillRect(0, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT); + + // 5. 缩放并绘制商品主图(居中,保持比例) + int productImageSize = 800; // 商品图尺寸 + int productImageX = (OUTPUT_WIDTH - productImageSize) / 2; + int productImageY = 80; // 顶部留白 + + BufferedImage scaledProductImage = Thumbnails.of(productImage) + .size(productImageSize, productImageSize) + .asBufferedImage(); + + g2d.drawImage(scaledProductImage, productImageX, productImageY, null); + + // 6. 绘制商品名称(如果有) + int textStartY = productImageY + productImageSize + 40; + if (StrUtil.isNotBlank(keyProductName)) { + drawProductName(g2d, keyProductName, textStartY); + textStartY += 60; // 增加间距 + } + + // 7. 绘制官网价(带删除线,在上方) + int originalPriceY = textStartY + 80; + drawOriginalPrice(g2d, originalPrice, originalPriceY); + + // 8. 绘制向下箭头 + int arrowY = originalPriceY + 60; + drawDownArrow(g2d, arrowY); + + // 9. 绘制到手价(大红色,在下方) + int finalPriceY = arrowY + 80; + drawFinalPrice(g2d, finalPrice, finalPriceY); + + // 10. 绘制爆炸贴图装饰(右下角) + drawExplosionDecoration(g2d); + + g2d.dispose(); + + // 11. 转换为Base64 + String base64Image = imageToBase64(canvas, "jpg"); + log.info("营销图片生成成功"); + return base64Image; + + } catch (Exception e) { + log.error("生成营销图片失败", e); + throw new RuntimeException("生成营销图片失败: " + e.getMessage(), e); + } + } + + /** + * 加载商品主图 + */ + private BufferedImage loadProductImage(String imageUrl) throws IOException { + try { + byte[] imageData = HttpUtil.downloadBytes(imageUrl); + if (imageData == null || imageData.length == 0) { + throw new IOException("下载图片失败或图片数据为空"); + } + + try (ByteArrayInputStream bais = new ByteArrayInputStream(imageData)) { + return ImageIO.read(bais); + } + } catch (Exception e) { + log.error("加载商品主图失败: {}", imageUrl, e); + throw new IOException("加载商品主图失败: " + e.getMessage(), e); + } + } + + /** + * 提取商品标题关键部分(使用AI) + * + * @param fullProductName 完整商品名称 + * @return 提取的关键部分 + */ + public String extractKeyProductName(String fullProductName) { + if (StrUtil.isBlank(fullProductName)) { + return ""; + } + + try { + // 使用DeepSeek提取商品标题关键部分 + String prompt = String.format( + "请从以下商品标题中提取最关键的3-8个字作为核心卖点,只返回提取的关键词,不要其他内容:\n%s", + fullProductName + ); + + String extracted = deepSeekClientUtil.getDeepSeekResponse(prompt); + if (StrUtil.isNotBlank(extracted)) { + // 清理可能的换行和多余空格 + extracted = extracted.trim().replaceAll("\\s+", ""); + // 限制长度 + if (extracted.length() > 12) { + extracted = extracted.substring(0, 12); + } + log.info("提取商品标题关键部分成功: {} -> {}", fullProductName, extracted); + return extracted; + } + } catch (Exception e) { + log.warn("使用AI提取商品标题关键部分失败,使用简单截取: {}", fullProductName, e); + } + + // 降级方案:简单截取前部分 + return simpleExtractKeyName(fullProductName); + } + + /** + * 简单提取商品名称关键部分(降级方案) + */ + private String simpleExtractKeyName(String fullName) { + if (StrUtil.isBlank(fullName)) { + return ""; + } + + // 移除常见的规格信息(如XL、175/96A等) + String cleaned = fullName + .replaceAll("\\s*XL|L|M|S|XXL\\s*", "") + .replaceAll("\\s*\\d+/\\d+[A-Z]?\\s*", "") + .replaceAll("\\s*【.*?】\\s*", "") + .replaceAll("\\s*\\(.*?\\)\\s*", ""); + + // 提取前10-15个字符 + if (cleaned.length() > 15) { + cleaned = cleaned.substring(0, 15); + } + + return cleaned.trim(); + } + + /** + * 绘制商品名称 + */ + private void drawProductName(Graphics2D g2d, String productName, int y) { + Font font = getAvailableFont(Font.BOLD, PRODUCT_NAME_FONT_SIZE); + g2d.setFont(font); + g2d.setColor(PRODUCT_NAME_COLOR); + + // 计算文字宽度,如果太长则截断 + FontMetrics fm = g2d.getFontMetrics(); + String displayName = productName; + int maxWidth = OUTPUT_WIDTH - 80; // 左右各留40px + + if (fm.stringWidth(displayName) > maxWidth) { + // 截断并添加省略号 + while (fm.stringWidth(displayName + "...") > maxWidth && displayName.length() > 0) { + displayName = displayName.substring(0, displayName.length() - 1); + } + displayName += "..."; + } + + int textWidth = fm.stringWidth(displayName); + int x = (OUTPUT_WIDTH - textWidth) / 2; // 居中 + + g2d.drawString(displayName, x, y); + } + + /** + * 绘制官网价(带删除线) + */ + private void drawOriginalPrice(Graphics2D g2d, Double originalPrice, int y) { + Font font = getAvailableFont(Font.BOLD, ORIGINAL_PRICE_FONT_SIZE); + g2d.setFont(font); + g2d.setColor(ORIGINAL_PRICE_COLOR); + + String priceText = "官网价:¥" + String.format("%.0f", originalPrice); + FontMetrics fm = g2d.getFontMetrics(); + int textWidth = fm.stringWidth(priceText); + int x = (OUTPUT_WIDTH - textWidth) / 2; // 居中 + + // 绘制文字 + g2d.drawString(priceText, x, y); + + // 绘制删除线 + int lineY = y - fm.getAscent() / 2; + g2d.setStroke(new BasicStroke(3.0f)); // 3px粗的删除线 + g2d.drawLine(x, lineY, x + textWidth, lineY); + } + + /** + * 绘制向下箭头 + */ + private void drawDownArrow(Graphics2D g2d, int y) { + int centerX = OUTPUT_WIDTH / 2; + int arrowSize = 40; + + g2d.setColor(new Color(200, 200, 200)); // 浅灰色箭头 + g2d.setStroke(new BasicStroke(3.0f)); + + // 绘制竖线 + g2d.drawLine(centerX, y, centerX, y + arrowSize); + + // 绘制箭头(向下) + int[] xPoints = {centerX, centerX - 15, centerX + 15}; + int[] yPoints = {y + arrowSize, y + arrowSize - 20, y + arrowSize - 20}; + g2d.fillPolygon(xPoints, yPoints, 3); + } + + /** + * 绘制到手价(大红色) + */ + private void drawFinalPrice(Graphics2D g2d, Double finalPrice, int y) { + Font font = getAvailableFont(Font.BOLD, FINAL_PRICE_FONT_SIZE); + g2d.setFont(font); + g2d.setColor(FINAL_PRICE_COLOR); + + String priceText = "到手价:¥" + String.format("%.0f", finalPrice); + FontMetrics fm = g2d.getFontMetrics(); + int textWidth = fm.stringWidth(priceText); + int x = (OUTPUT_WIDTH - textWidth) / 2; // 居中 + + g2d.drawString(priceText, x, y); + } + + /** + * 绘制爆炸贴图装饰(右下角) + */ + private void drawExplosionDecoration(Graphics2D g2d) { + // 绘制简单的爆炸形状(星形) + int centerX = OUTPUT_WIDTH - 120; + int centerY = OUTPUT_HEIGHT - 120; + int radius = 50; + + g2d.setColor(new Color(255, 200, 0)); // 金黄色 + g2d.setStroke(new BasicStroke(4.0f)); + + // 绘制星形爆炸效果 + int points = 8; + int[] xPoints = new int[points * 2]; + int[] yPoints = new int[points * 2]; + + for (int i = 0; i < points * 2; i++) { + double angle = Math.PI * i / points; + int r = (i % 2 == 0) ? radius : radius / 2; + xPoints[i] = (int) (centerX + r * Math.cos(angle)); + yPoints[i] = (int) (centerY + r * Math.sin(angle)); + } + + g2d.fillPolygon(xPoints, yPoints, points * 2); + + // 绘制内部圆形 + g2d.setColor(new Color(255, 100, 0)); // 橙红色 + g2d.fillOval(centerX - radius / 2, centerY - radius / 2, radius, radius); + } + + /** + * 将BufferedImage转换为Base64字符串 + */ + private String imageToBase64(BufferedImage image, String format) throws IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, format, baos); + byte[] imageBytes = baos.toByteArray(); + return "data:image/" + format + ";base64," + Base64.getEncoder().encodeToString(imageBytes); + } + + /** + * 批量生成营销图片 + * + * @param requests 批量请求列表 + * @return 结果列表,每个元素包含base64图片 + */ + public Map batchGenerateMarketingImages(java.util.List> requests) { + Map result = new HashMap<>(); + java.util.List> results = new java.util.ArrayList<>(); + int successCount = 0; + int failCount = 0; + + for (int i = 0; i < requests.size(); i++) { + Map request = requests.get(i); + Map itemResult = new HashMap<>(); + + try { + String productImageUrl = (String) request.get("productImageUrl"); + Double originalPrice = getDoubleValue(request.get("originalPrice")); + Double finalPrice = getDoubleValue(request.get("finalPrice")); + String productName = (String) request.get("productName"); + + if (productImageUrl == null || originalPrice == null || finalPrice == null) { + throw new IllegalArgumentException("缺少必要参数: productImageUrl, originalPrice, finalPrice"); + } + + String base64Image = generateMarketingImage(productImageUrl, originalPrice, finalPrice, productName); + + itemResult.put("success", true); + itemResult.put("imageBase64", base64Image); + itemResult.put("index", i); + successCount++; + + } catch (Exception e) { + log.error("批量生成第{}张图片失败", i, e); + itemResult.put("success", false); + itemResult.put("error", e.getMessage()); + itemResult.put("index", i); + failCount++; + } + + results.add(itemResult); + } + + result.put("results", results); + result.put("total", requests.size()); + result.put("successCount", successCount); + result.put("failCount", failCount); + + return result; + } + + /** + * 安全获取Double值 + */ + private Double getDoubleValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof Double) { + return (Double) value; + } + if (value instanceof Number) { + return ((Number) value).doubleValue(); + } + try { + return Double.parseDouble(value.toString()); + } catch (Exception e) { + return null; + } + } +} +