feat(puzzle): 实现智能自动填充引擎和安全增强

- 新增拼图元素自动填充引擎 PuzzleElementFillEngine
- 支持基于规则的条件匹配和数据源解析
- 实现机位数量、机位ID等多维度条件策略
- 添加 DEVICE_IMAGE、USER_AVATAR 等数据源类型支持
- 增加景区隔离校验确保模板使用安全性
- 强化图片下载安全校验,防范 SSRF 攻击
- 支持本地文件路径解析和公网 URL 安全检查
- 完善静态值数据源策略支持 localPath 配置
- 优化生成流程中 faceId 和 scenicId 的校验逻辑
- 补充相关单元测试覆盖核心功能点
This commit is contained in:
2025-11-19 17:28:41 +08:00
parent cb17ea527b
commit cfb3625ac0
12 changed files with 748 additions and 57 deletions

View File

@@ -50,11 +50,12 @@ public class ImageConfig implements ElementConfig {
}
}
// 校验图片URL格式(可选)
if (StrUtil.isNotBlank(defaultImageUrl)) {
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
}
// 校验图片URL
if (StrUtil.isBlank(defaultImageUrl)) {
throw new IllegalArgumentException("默认图片URL不能为空");
}
if (!defaultImageUrl.startsWith("http://") && !defaultImageUrl.startsWith("https://")) {
throw new IllegalArgumentException("图片URL必须以http://或https://开头: " + defaultImageUrl);
}
}

View File

@@ -1,7 +1,7 @@
package com.ycwl.basic.puzzle.element.impl;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpUtil;
import cn.hutool.http.HttpRequest;
import com.ycwl.basic.puzzle.element.base.BaseElement;
import com.ycwl.basic.puzzle.element.config.ImageConfig;
import com.ycwl.basic.puzzle.element.exception.ElementValidationException;
@@ -14,7 +14,12 @@ import java.awt.geom.Ellipse2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.net.InetAddress;
import java.net.URI;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 图片元素实现
@@ -25,6 +30,8 @@ import java.io.File;
@Slf4j
public class ImageElement extends BaseElement {
private static final int DOWNLOAD_TIMEOUT_MS = 5000;
private ImageConfig imageConfig;
@Override
@@ -105,29 +112,32 @@ public class ImageElement extends BaseElement {
* @param imageUrl 图片URL或本地文件路径
* @return BufferedImage对象
*/
private BufferedImage downloadImage(String imageUrl) {
try {
log.debug("下载图片: url={}", imageUrl);
// 判断是否为本地文件路径
if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) {
// 网络图片
byte[] imageBytes = HttpUtil.downloadBytes(imageUrl);
return ImageIO.read(new ByteArrayInputStream(imageBytes));
} else {
// 本地文件
File file = new File(imageUrl);
if (file.exists()) {
return ImageIO.read(file);
} else {
log.error("本地图片文件不存在: path={}", imageUrl);
return null;
}
}
} catch (Exception e) {
log.error("图片下载失败: url={}", imageUrl, e);
protected BufferedImage downloadImage(String imageUrl) {
if (StrUtil.isBlank(imageUrl)) {
return null;
}
if (isRemoteUrl(imageUrl)) {
if (!isSafeRemoteUrl(imageUrl)) {
log.warn("图片URL未通过安全校验, 已拒绝下载: {}", imageUrl);
return null;
}
try {
log.debug("下载图片: url={}", imageUrl);
byte[] imageBytes = HttpRequest.get(imageUrl)
.timeout(DOWNLOAD_TIMEOUT_MS)
.setFollowRedirects(false)
.execute()
.bodyBytes();
return ImageIO.read(new ByteArrayInputStream(imageBytes));
} catch (Exception e) {
log.error("图片下载失败: url={}", imageUrl, e);
return null;
}
}
return loadLocalImage(imageUrl);
}
/**
@@ -251,4 +261,63 @@ public class ImageElement extends BaseElement {
// 绘制到主画布
g2d.drawImage(rounded, position.getX(), position.getY(), null);
}
private boolean isRemoteUrl(String imageUrl) {
return StrUtil.startWithIgnoreCase(imageUrl, "http://") ||
StrUtil.startWithIgnoreCase(imageUrl, "https://");
}
/**
* 判断URL是否为安全的公网HTTP地址,避免SSRF
*/
protected boolean isSafeRemoteUrl(String imageUrl) {
if (StrUtil.isBlank(imageUrl)) {
return false;
}
try {
URL url = new URL(imageUrl);
String protocol = url.getProtocol();
if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
return false;
}
InetAddress address = InetAddress.getByName(url.getHost());
if (address.isAnyLocalAddress()
|| address.isLoopbackAddress()
|| address.isLinkLocalAddress()
|| address.isSiteLocalAddress()) {
return false;
}
return true;
} catch (Exception e) {
log.warn("图片URL解析失败: {}", imageUrl, e);
return false;
}
}
private BufferedImage loadLocalImage(String imageUrl) {
try {
Path path;
if (StrUtil.startWithIgnoreCase(imageUrl, "file:")) {
path = Paths.get(new URI(imageUrl));
} else {
path = Paths.get(imageUrl);
}
if (!Files.exists(path) || !Files.isRegularFile(path)) {
log.error("本地图片文件不存在: {}", imageUrl);
return null;
}
log.debug("加载本地图片: {}", path);
try (var inputStream = Files.newInputStream(path)) {
return ImageIO.read(inputStream);
}
} catch (Exception e) {
log.error("本地图片加载失败: {}", imageUrl, e);
return null;
}
}
}