mirror of
https://github.com/jeecgboot/JeecgBoot.git
synced 2026-08-24 21:58:41 +00:00
v3.9.5开源发版 java
This commit is contained in:
parent
2d401c923d
commit
a2be896f75
58
jeecg-boot/.ignore
Normal file
58
jeecg-boot/.ignore
Normal file
@ -0,0 +1,58 @@
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
.gitmodules
|
||||
|
||||
# SVN
|
||||
.svn/
|
||||
|
||||
# IntelliJ IDEA
|
||||
.idea/
|
||||
*.iml
|
||||
*.iws
|
||||
*.ipr
|
||||
out/
|
||||
|
||||
# Eclipse
|
||||
.classpath
|
||||
.project
|
||||
.settings/
|
||||
|
||||
# VS Code
|
||||
.vscode/
|
||||
|
||||
# Maven / Gradle build output
|
||||
target/
|
||||
build/
|
||||
!.mvn/wrapper/maven-wrapper.jar
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Node (frontend artifacts if any)
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Docker volumes / data
|
||||
docker/data/
|
||||
|
||||
# Compiled classes
|
||||
*.class
|
||||
|
||||
# Custom
|
||||
*.qqy
|
||||
代码修改.log
|
||||
代码修改日志
|
||||
*.zip
|
||||
backup/
|
||||
.history/
|
||||
.cursor/
|
||||
doc/
|
||||
docs/
|
||||
db/
|
||||
File diff suppressed because one or more lines are too long
@ -159,9 +159,9 @@
|
||||
</dependency>
|
||||
<!-- oracle驱动 -->
|
||||
<dependency>
|
||||
<groupId>com.oracle</groupId>
|
||||
<artifactId>ojdbc6</artifactId>
|
||||
<version>${ojdbc6.version}</version>
|
||||
<groupId>com.oracle.database.jdbc</groupId>
|
||||
<artifactId>ojdbc11</artifactId>
|
||||
<version>${ojdbc11.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- postgresql驱动 -->
|
||||
@ -384,4 +384,4 @@
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
@ -17,7 +17,9 @@ public class DataLogDTO {
|
||||
|
||||
private String type;
|
||||
|
||||
private String createName;
|
||||
private String createBy;
|
||||
|
||||
private String createName;
|
||||
|
||||
public DataLogDTO(){
|
||||
|
||||
|
||||
@ -573,6 +573,11 @@ public interface CommonConstant {
|
||||
*/
|
||||
String WECHAT_ENTERPRISE = "WECHAT_ENTERPRISE";
|
||||
|
||||
/**
|
||||
* 飞书
|
||||
*/
|
||||
String FEISHU = "FEISHU";
|
||||
|
||||
/**
|
||||
* 系统默认租户id 0
|
||||
*/
|
||||
|
||||
@ -9,7 +9,7 @@ package org.jeecg.common.constant;
|
||||
public interface PasswordConstant {
|
||||
|
||||
/**
|
||||
* 导入用户默认密码
|
||||
* 导入用户默认密码 (重置密码)
|
||||
*/
|
||||
String DEFAULT_PASSWORD = "123456";
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import java.util.ArrayList;
|
||||
import java.util.Scanner;
|
||||
import java.util.Set;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/**
|
||||
* @Description: 省市区
|
||||
@ -20,12 +21,15 @@ import java.util.List;
|
||||
*/
|
||||
@Component("pca")
|
||||
public class ProvinceCityArea {
|
||||
List<Area> areaList;
|
||||
// 使用 CopyOnWriteArrayList + volatile 解决懒加载场景下并发 add / 遍历导致的 ConcurrentModificationException
|
||||
// 列表在 initAreaList 完成后即不再修改,迭代器为快照式,读路径无锁
|
||||
private volatile List<Area> areaList;
|
||||
|
||||
public String getText(String code){
|
||||
if(StringUtils.isNotBlank(code)){
|
||||
this.initAreaList();
|
||||
if(this.areaList!=null || this.areaList.size()>0){
|
||||
// 修复逻辑 bug:原 || 写错,areaList==null 时会先 false 再走 size() 抛 NPE,改为 &&
|
||||
if(this.areaList!=null && this.areaList.size()>0){
|
||||
List<String> ls = new ArrayList<String>();
|
||||
getAreaByCode(code,ls);
|
||||
return String.join("/",ls);
|
||||
@ -127,39 +131,44 @@ public class ProvinceCityArea {
|
||||
}
|
||||
|
||||
private void initAreaList(){
|
||||
//System.out.println("=====================");
|
||||
if(this.areaList==null || this.areaList.size()==0){
|
||||
this.areaList = new ArrayList<Area>();
|
||||
try {
|
||||
String jsonData = oConvertUtils.readStatic("classpath:static/pca.json");
|
||||
JSONObject baseJson = JSONObject.parseObject(jsonData);
|
||||
//第一层 省
|
||||
JSONObject provinceJson = baseJson.getJSONObject("86");
|
||||
for(String provinceKey: provinceJson.keySet()){
|
||||
//System.out.println("===="+provinceKey);
|
||||
Area province = new Area(provinceKey,provinceJson.getString(provinceKey),"86");
|
||||
this.areaList.add(province);
|
||||
//第二层 市
|
||||
JSONObject cityJson = baseJson.getJSONObject(provinceKey);
|
||||
for(String cityKey:cityJson.keySet()){
|
||||
//System.out.println("-----"+cityKey);
|
||||
Area city = new Area(cityKey,cityJson.getString(cityKey),provinceKey);
|
||||
this.areaList.add(city);
|
||||
//第三层 区
|
||||
JSONObject areaJson = baseJson.getJSONObject(cityKey);
|
||||
if(areaJson!=null){
|
||||
for(String areaKey:areaJson.keySet()){
|
||||
//System.out.println("········"+areaKey);
|
||||
Area area = new Area(areaKey,areaJson.getString(areaKey),cityKey);
|
||||
// 代码逻辑说明: VUEN-1088 online 导入 省市区导入后 导入数据错乱 北京市/市辖区/西城区-->山西省/晋城市/城区
|
||||
area.setAheadText(cityJson.getString(cityKey));
|
||||
this.areaList.add(area);
|
||||
// 双重检查 + volatile:保证列表仅被一个线程初始化,避免懒加载场景下并发 add / 遍历导致的 ConcurrentModificationException
|
||||
if(this.areaList==null || this.areaList.isEmpty()){
|
||||
synchronized (this) {
|
||||
if(this.areaList==null || this.areaList.isEmpty()){
|
||||
// CopyOnWriteArrayList 的迭代器为快照式,即使与其他读线程并发也不会抛 CME
|
||||
this.areaList = new CopyOnWriteArrayList<Area>();
|
||||
try {
|
||||
String jsonData = oConvertUtils.readStatic("classpath:static/pca.json");
|
||||
JSONObject baseJson = JSONObject.parseObject(jsonData);
|
||||
//第一层 省
|
||||
JSONObject provinceJson = baseJson.getJSONObject("86");
|
||||
for(String provinceKey: provinceJson.keySet()){
|
||||
//System.out.println("===="+provinceKey);
|
||||
Area province = new Area(provinceKey,provinceJson.getString(provinceKey),"86");
|
||||
this.areaList.add(province);
|
||||
//第二层 市
|
||||
JSONObject cityJson = baseJson.getJSONObject(provinceKey);
|
||||
for(String cityKey:cityJson.keySet()){
|
||||
//System.out.println("-----"+cityKey);
|
||||
Area city = new Area(cityKey,cityJson.getString(cityKey),provinceKey);
|
||||
this.areaList.add(city);
|
||||
//第三层 区
|
||||
JSONObject areaJson = baseJson.getJSONObject(cityKey);
|
||||
if(areaJson!=null){
|
||||
for(String areaKey:areaJson.keySet()){
|
||||
//System.out.println("········"+areaKey);
|
||||
Area area = new Area(areaKey,areaJson.getString(areaKey),cityKey);
|
||||
// 代码逻辑说明: VUEN-1088 online 导入 省市区导入后 导入数据错乱 北京市/市辖区/西城区-->山西省/晋城市/城区
|
||||
area.setAheadText(cityJson.getString(cityKey));
|
||||
this.areaList.add(area);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,7 +29,15 @@ public enum MessageTypeEnum {
|
||||
/**
|
||||
* 企业微信
|
||||
*/
|
||||
QYWX("wechat_enterprise", "企业微信");
|
||||
QYWX("wechat_enterprise", "企业微信"),
|
||||
/**
|
||||
* 飞书
|
||||
*/
|
||||
FS("feishu", "飞书消息"),
|
||||
/**
|
||||
* 短信消息
|
||||
*/
|
||||
DX("sms", "短信消息");
|
||||
|
||||
MessageTypeEnum(String type, String note) {
|
||||
this.type = type;
|
||||
|
||||
@ -108,13 +108,16 @@ public class JeecgBootExceptionHandler {
|
||||
|
||||
/**
|
||||
* 处理静态资源不存在异常(Spring Boot 3.2+)
|
||||
* WebSocket路径被当作静态资源请求时会触发此异常,降级为debug日志避免刷屏
|
||||
* Source Map、WebSocket路径被当作静态资源请求时会触发此异常,降级为debug日志避免刷屏
|
||||
*/
|
||||
@ExceptionHandler(NoResourceFoundException.class)
|
||||
public Result<?> handleNoResourceFoundException(NoResourceFoundException e, HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
// WebSocket路径的非upgrade请求,降级为debug日志
|
||||
if (uri.contains("Socket/") || uri.contains("websocket/") || uri.contains("Websocket/")) {
|
||||
// Source Map仅用于浏览器调试,缺失不影响业务功能,降级为debug日志
|
||||
if (uri.endsWith(".map")) {
|
||||
log.debug("Source Map资源不存在: {}", uri);
|
||||
} else if (uri.contains("Socket/") || uri.contains("websocket/") || uri.contains("Websocket/")) {
|
||||
// WebSocket路径的非upgrade请求,降级为debug日志
|
||||
log.debug("WebSocket路径被当作静态资源请求: {}", uri);
|
||||
} else {
|
||||
log.error(e.getMessage(), e);
|
||||
@ -264,7 +267,13 @@ public class JeecgBootExceptionHandler {
|
||||
// 文件上传过大异常时不能获取参数,否则会报错
|
||||
Map<String, String[]> parameterMap = request.getParameterMap();
|
||||
if(!CollectionUtils.isEmpty(parameterMap)) {
|
||||
log.setMethod(oConvertUtils.mapToString(request.getParameterMap()));
|
||||
//update-begin---author:scott ---date:2026-05-09 for:sys_log.method字段长度1000,过长导致Data truncation异常吞掉原始错误-----------
|
||||
String methodStr = oConvertUtils.mapToString(request.getParameterMap());
|
||||
if (methodStr != null && methodStr.length() > 950) {
|
||||
methodStr = methodStr.substring(0, 950) + "...(truncated)";
|
||||
}
|
||||
log.setMethod(methodStr);
|
||||
//update-end---author:scott ---date:2026-05-09 for:sys_log.method字段长度1000,过长导致Data truncation异常吞掉原始错误-----------
|
||||
}
|
||||
}
|
||||
// 请求地址
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package org.jeecg.common.system.query;
|
||||
|
||||
import java.lang.annotation.*;
|
||||
|
||||
/**
|
||||
* 查询条件忽略注解
|
||||
* <p>标记此注解的字段将被 QueryGenerator 跳过,不参与查询条件构建。
|
||||
* 适用于密码、盐值等敏感字段,防止通过请求参数进行模糊查询探测。</p>
|
||||
*
|
||||
* @see QueryGenerator#initQueryWrapper
|
||||
*/
|
||||
@Target({ElementType.FIELD})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
public @interface QueryConditionIgnore {
|
||||
}
|
||||
@ -2,6 +2,7 @@ package org.jeecg.common.system.query;
|
||||
|
||||
import java.beans.PropertyDescriptor;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.URLDecoder;
|
||||
import java.text.ParseException;
|
||||
@ -154,6 +155,11 @@ public class QueryGenerator {
|
||||
if (judgedIsUselessField(name)|| !PropertyUtils.isReadable(searchObj, name)) {
|
||||
continue;
|
||||
}
|
||||
//update-begin---author:liusq ---date:2026-05-25 for:【QQYUN-15535】敏感字段加@QueryConditionIgnore注解后跳过查询条件构建-----------
|
||||
if (isQueryConditionIgnoreField(searchObj.getClass(), name)) {
|
||||
continue;
|
||||
}
|
||||
//update-end---author:liusq ---date:2026-05-25 for:【QQYUN-15535】敏感字段加@QueryConditionIgnore注解后跳过查询条件构建-----------
|
||||
|
||||
Object value = PropertyUtils.getSimpleProperty(searchObj, name);
|
||||
column = ReflectHelper.getTableFieldName(searchObj.getClass(), name);
|
||||
@ -581,12 +587,10 @@ public class QueryGenerator {
|
||||
value = val.substring(1);
|
||||
//mysql 模糊查询之特殊字符下划线 (_、\)
|
||||
value = specialStrConvert(value.toString());
|
||||
//update-begin---author:scott ---date:20260416 for:【PR#9322】修复NE规则与LEFT_LIKE共用substring(1)导致ID首位字符丢失-----------
|
||||
} else if (rule == QueryRuleEnum.NE) {
|
||||
if (val.startsWith(QueryRuleEnum.NE.getValue())) {
|
||||
if (val.startsWith(NOT_EQUAL)) {
|
||||
value = val.substring(1);
|
||||
}
|
||||
//update-end---author:scott ---date:20260416 for:【PR#9322】修复NE规则与LEFT_LIKE共用substring(1)导致ID首位字符丢失-----------
|
||||
} else if (rule == QueryRuleEnum.RIGHT_LIKE) {
|
||||
value = val.substring(0, val.length() - 1);
|
||||
//mysql 模糊查询之特殊字符下划线 (_、\)
|
||||
@ -804,7 +808,32 @@ public class QueryGenerator {
|
||||
;
|
||||
}
|
||||
|
||||
|
||||
//update-begin---author:liusq ---date:2026-05-25 for:【QQYUN-15535】检测字段是否标注@QueryConditionIgnore,支持父类字段查找-----------
|
||||
/**
|
||||
* 判断字段是否标注了 {@link QueryConditionIgnore} 注解,支持查找父类字段。
|
||||
* 标注此注解的字段(如密码、盐值等敏感字段)将被跳过,不参与查询条件构建。
|
||||
*
|
||||
* @param clazz 实体类
|
||||
* @param fieldName 字段名
|
||||
* @return true 表示应跳过该字段
|
||||
*/
|
||||
private static boolean isQueryConditionIgnoreField(Class<?> clazz, String fieldName) {
|
||||
Class<?> current = clazz;
|
||||
while (current != null && current != Object.class) {
|
||||
try {
|
||||
Field field = current.getDeclaredField(fieldName);
|
||||
if (field.isAnnotationPresent(QueryConditionIgnore.class)) {
|
||||
return true;
|
||||
}
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
// 当前类没有该字段,继续查找父类
|
||||
}
|
||||
current = current.getSuperclass();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//update-end---author:liusq ---date:2026-05-25 for:【QQYUN-15535】检测字段是否标注@QueryConditionIgnore,支持父类字段查找-----------
|
||||
|
||||
|
||||
/**
|
||||
* 获取请求对应的数据权限规则 TODO 相同列权限多个 有问题
|
||||
@ -1033,7 +1062,7 @@ public class QueryGenerator {
|
||||
}
|
||||
|
||||
/**
|
||||
* mysql 模糊查询之特殊字符下划线 (_、\)
|
||||
* mysql、sqlserver 模糊查询特殊字符转义
|
||||
*
|
||||
* @param value:
|
||||
* @Return: java.lang.String
|
||||
@ -1046,6 +1075,10 @@ public class QueryGenerator {
|
||||
value = value.replace(str, "\\" + str);
|
||||
}
|
||||
}
|
||||
// update-begin--author:wangshuai---date:20260820---for:【LHZP-1165】【系统管理】字典 编码查询 输入br_ branch_的也查出来了
|
||||
} else if (DataBaseConstant.DB_TYPE_SQLSERVER.equals(getDbType())) {
|
||||
value = value.replace("[", "[[]").replace("%", "[%]").replace("_", "[_]");
|
||||
// update-end--author:wangshuai---date:20260820---for:【LHZP-1165】【系统管理】字典 编码查询 输入br_ branch_的也查出来了
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@ -368,8 +368,14 @@ public class CommonUtils {
|
||||
//1.【兼容】兼容微服务下的 base path-------
|
||||
String xGatewayBasePath = request.getHeader(ServiceNameConstants.X_GATEWAY_BASE_PATH);
|
||||
if(oConvertUtils.isNotEmpty(xGatewayBasePath)){
|
||||
log.info("x_gateway_base_path = "+ xGatewayBasePath);
|
||||
return xGatewayBasePath;
|
||||
//update-begin---author:wangshuai ---date:20260616 for:【issues/9695】校验X_GATEWAY_BASE_PATH防止SSRF header注入-----------
|
||||
String validated = validateGatewayBasePath(xGatewayBasePath);
|
||||
if(validated != null){
|
||||
log.info("x_gateway_base_path = {}", validated);
|
||||
return validated;
|
||||
}
|
||||
log.warn("X_GATEWAY_BASE_PATH header 校验失败,已忽略: {}", xGatewayBasePath);
|
||||
//update-end---author:wangshuai ---date:20260616 for:【issues/9695】校验X_GATEWAY_BASE_PATH防止SSRF header注入-----------
|
||||
}
|
||||
//2.【兼容】SSL认证之后,request.getScheme()获取不到https的问题
|
||||
// https://blog.csdn.net/weixin_34376986/article/details/89767950
|
||||
@ -398,6 +404,72 @@ public class CommonUtils {
|
||||
return baseDomainPath;
|
||||
}
|
||||
|
||||
//update-begin---author:wangshuai ---date:20260616 for:【issues/9695】校验X_GATEWAY_BASE_PATH防止SSRF header注入-----------
|
||||
/**
|
||||
* 校验 X_GATEWAY_BASE_PATH 请求头:仅允许 http/https 协议,不允许 userInfo,
|
||||
* 从解析后的 URI 组件重新拼接,防止注入特殊字符绕过。
|
||||
* @return 校验通过返回安全的 baseUrl,否则返回 null
|
||||
*/
|
||||
public static String validateGatewayBasePathForDomain(String headerValue) {
|
||||
return validateGatewayBasePath(headerValue);
|
||||
}
|
||||
|
||||
private static String validateGatewayBasePath(String headerValue) {
|
||||
if (oConvertUtils.isEmpty(headerValue)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
java.net.URI uri = new java.net.URI(headerValue.trim());
|
||||
String scheme = uri.getScheme();
|
||||
if (scheme == null || (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme))) {
|
||||
return null;
|
||||
}
|
||||
if (uri.getUserInfo() != null) {
|
||||
return null;
|
||||
}
|
||||
String host = uri.getHost();
|
||||
if (oConvertUtils.isEmpty(host)) {
|
||||
return null;
|
||||
}
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(scheme.toLowerCase()).append("://").append(host);
|
||||
if (uri.getPort() != -1) {
|
||||
sb.append(":").append(uri.getPort());
|
||||
}
|
||||
if (uri.getPath() != null && !uri.getPath().isEmpty()) {
|
||||
sb.append(uri.getPath());
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 URL 的 host 是否为内网地址(回环 / 局域网 / 链路本地),
|
||||
* 用于 OpenAPI 转发等服务端发起请求的场景,防止 SSRF 到公网。
|
||||
* 校验失败抛出 JeecgBootException。
|
||||
*/
|
||||
public static void checkInternalUrl(String url) {
|
||||
try {
|
||||
java.net.URI uri = new java.net.URI(url);
|
||||
String host = uri.getHost();
|
||||
if (oConvertUtils.isEmpty(host)) {
|
||||
throw new JeecgBootException("URL host 为空: " + url);
|
||||
}
|
||||
java.net.InetAddress addr = java.net.InetAddress.getByName(host);
|
||||
if (addr.isLoopbackAddress() || addr.isSiteLocalAddress() || addr.isLinkLocalAddress()) {
|
||||
return;
|
||||
}
|
||||
throw new JeecgBootException("OpenAPI baseUrl 仅允许内网地址,当前解析到外部地址: " + host + " -> " + addr.getHostAddress());
|
||||
} catch (JeecgBootException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new JeecgBootException("OpenAPI baseUrl 校验失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
//update-end---author:wangshuai ---date:20260616 for:【issues/9695】校验X_GATEWAY_BASE_PATH防止SSRF header注入-----------
|
||||
|
||||
/**
|
||||
* 递归合并 fastJSON 对象
|
||||
*
|
||||
|
||||
@ -14,7 +14,6 @@ import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
@ -122,6 +121,79 @@ public class FileDownloadUtils {
|
||||
}
|
||||
}
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-06-17 for:【issues/9681】修复SSRF重定向绕过漏洞(CWE-918),禁止自动跳转并逐跳校验-----------
|
||||
/**
|
||||
* 安全地打开 HTTP(S) 连接:禁止自动重定向,对初始 URL 及每一跳重定向目标都做 SSRF 校验,
|
||||
* 返回最终已连接(且校验通过)的连接对象。
|
||||
*
|
||||
* @param fileUrl 文件URL
|
||||
* @return 已连接的 HttpURLConnection
|
||||
* @throws IOException 连接异常
|
||||
*/
|
||||
private static HttpURLConnection openSafeConnection(String fileUrl) throws IOException {
|
||||
int maxRedirects = 5;
|
||||
String currentUrl = fileUrl;
|
||||
for (int i = 0; i <= maxRedirects; i++) {
|
||||
// 每一跳(含初始 URL)都重新做 SSRF 校验,拦截 loopback / link-local / 云元数据地址
|
||||
SsrfFileTypeFilter.checkSsrfHttpUrl(currentUrl);
|
||||
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(currentUrl).openConnection();
|
||||
// 关键:禁止 JDK 自动跟随重定向,否则会绕过上面的校验
|
||||
conn.setInstanceFollowRedirects(false);
|
||||
conn.setConnectTimeout(5 * 1000);
|
||||
conn.setReadTimeout(30 * 1000);
|
||||
// 防止屏蔽程序
|
||||
conn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)");
|
||||
conn.connect();
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code >= 300 && code < 400) {
|
||||
String location = conn.getHeaderField("Location");
|
||||
conn.disconnect();
|
||||
if (oConvertUtils.isEmpty(location)) {
|
||||
throw new JeecgBootException("非法重定向:Location 为空");
|
||||
}
|
||||
// 处理相对路径跳转,解析为绝对地址后回到循环顶部再次校验
|
||||
currentUrl = new URL(new URL(currentUrl), location).toString();
|
||||
continue;
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
throw new JeecgBootException("非法URL:重定向次数过多");
|
||||
}
|
||||
//update-end---author:wangshuai ---date:2026-06-17 for:【issues/9681】修复SSRF重定向绕过漏洞(CWE-918),禁止自动跳转并逐跳校验-----------
|
||||
|
||||
//update-begin---author:liusq ---date:2026-06-29 for:【issues/9725】uploadImgByHttp 复用安全连接,修复SSRF重定向绕过漏洞(CWE-918)-----------
|
||||
/**
|
||||
* 安全地从网络下载资源为字节数组:内部复用 {@link #openSafeConnection(String)},
|
||||
* 禁止自动跟随重定向,并对初始 URL 及每一跳重定向目标都做 SSRF 校验。
|
||||
*
|
||||
* @param fileUrl 文件URL
|
||||
* @return 文件字节数组
|
||||
* @throws IOException 连接或读取异常
|
||||
*/
|
||||
public static byte[] download2BytesFromNet(String fileUrl) throws IOException {
|
||||
HttpURLConnection conn = openSafeConnection(fileUrl);
|
||||
try {
|
||||
int responseCode = conn.getResponseCode();
|
||||
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||
throw new IOException("HTTP请求失败,响应码: " + responseCode);
|
||||
}
|
||||
try (InputStream inputStream = conn.getInputStream();
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
|
||||
byte[] buffer = new byte[4096];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, bytesRead);
|
||||
}
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
//update-end---author:liusq ---date:2026-06-29 for:【issues/9725】uploadImgByHttp 复用安全连接,修复SSRF重定向绕过漏洞(CWE-918)-----------
|
||||
|
||||
/**
|
||||
* 下载网络资源到磁盘
|
||||
*
|
||||
@ -148,12 +220,10 @@ public class FileDownloadUtils {
|
||||
SsrfFileTypeFilter.checkSsrfHttpUrl(fileUrl);
|
||||
//update-end---author:zhangdaihao ---date:2026-04-15 for:【issues/9553】下载网络资源前增加SSRF校验-----------
|
||||
try {
|
||||
URL url = new URL(fileUrl);
|
||||
URLConnection conn = url.openConnection();
|
||||
// 设置超时间为3秒
|
||||
conn.setConnectTimeout(3 * 1000);
|
||||
// 防止屏蔽程序
|
||||
conn.setRequestProperty("User-Agent", "Mozilla/4.0 (compatible; MSIE 5.0; Windows NT; DigExt)");
|
||||
//update-begin---author:wangshuai ---date:2026-06-17 for:【issues/9681】修复SSRF重定向绕过漏洞,改用禁止自动跳转并逐跳校验的安全连接-----------
|
||||
// 安全打开连接:内部已对初始 URL 及每一跳重定向目标做 SSRF 校验,并禁止自动跟随重定向
|
||||
HttpURLConnection conn = openSafeConnection(fileUrl);
|
||||
//update-end---author:wangshuai ---date:2026-06-17 for:【issues/9681】修复SSRF重定向绕过漏洞,改用禁止自动跳转并逐跳校验的安全连接-----------
|
||||
// 确保目录存在
|
||||
File file = ensureDestFileDir(storePath);
|
||||
try (InputStream inStream = conn.getInputStream();
|
||||
@ -279,10 +349,10 @@ public class FileDownloadUtils {
|
||||
//update-begin---author:zhangdaihao ---date:2026-04-15 for:【issues/9553】修复二次SSRF漏洞,对HTTP下载URL进行安全校验-----------
|
||||
SsrfFileTypeFilter.checkSsrfHttpUrl(fileUrl);
|
||||
//update-end---author:zhangdaihao ---date:2026-04-15 for:【issues/9553】修复二次SSRF漏洞,对HTTP下载URL进行安全校验-----------
|
||||
URL url = new URL(fileUrl);
|
||||
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setConnectTimeout(5000); // 连接超时5秒
|
||||
connection.setReadTimeout(30000); // 读取超时30秒
|
||||
//update-begin---author:wangshuai ---date:2026-06-17 for:【issues/9681】修复SSRF重定向绕过漏洞,改用禁止自动跳转并逐跳校验的安全连接-----------
|
||||
// 安全打开连接:内部已对初始 URL 及每一跳重定向目标做 SSRF 校验,并禁止自动跟随重定向
|
||||
HttpURLConnection connection = openSafeConnection(fileUrl);
|
||||
//update-end---author:wangshuai ---date:2026-06-17 for:【issues/9681】修复SSRF重定向绕过漏洞,改用禁止自动跳转并逐跳校验的安全连接-----------
|
||||
return connection.getInputStream();
|
||||
} else {
|
||||
// 处理本地文件:直接读取文件系统
|
||||
|
||||
@ -62,17 +62,21 @@ public class SpringContextUtils implements ApplicationContextAware {
|
||||
//1.微服务情况下,获取gateway的basePath
|
||||
String basePath = request.getHeader(ServiceNameConstants.X_GATEWAY_BASE_PATH);
|
||||
if(oConvertUtils.isNotEmpty(basePath)){
|
||||
return basePath;
|
||||
}else{
|
||||
String domain = url.delete(url.length() - request.getRequestURI().length(), url.length()).toString();
|
||||
//2.【兼容】SSL认证之后,request.getScheme()获取不到https的问题
|
||||
// https://blog.csdn.net/weixin_34376986/article/details/89767950
|
||||
String scheme = request.getHeader(CommonConstant.X_FORWARDED_SCHEME);
|
||||
if(scheme!=null && !request.getScheme().equals(scheme)){
|
||||
domain = domain.replace(request.getScheme(),scheme);
|
||||
//update-begin---author:wangshuai ---date:20260616 for:【issues/9695】校验X_GATEWAY_BASE_PATH防止SSRF header注入-----------
|
||||
String validated = CommonUtils.validateGatewayBasePathForDomain(basePath);
|
||||
if(validated != null){
|
||||
return validated;
|
||||
}
|
||||
return domain;
|
||||
//update-end---author:wangshuai ---date:20260616 for:【issues/9695】校验X_GATEWAY_BASE_PATH防止SSRF header注入-----------
|
||||
}
|
||||
String domain = url.delete(url.length() - request.getRequestURI().length(), url.length()).toString();
|
||||
//2.【兼容】SSL认证之后,request.getScheme()获取不到https的问题
|
||||
// https://blog.csdn.net/weixin_34376986/article/details/89767950
|
||||
String scheme = request.getHeader(CommonConstant.X_FORWARDED_SCHEME);
|
||||
if(scheme!=null && !request.getScheme().equals(scheme)){
|
||||
domain = domain.replace(request.getScheme(),scheme);
|
||||
}
|
||||
return domain;
|
||||
}
|
||||
|
||||
public static String getOrigin(){
|
||||
|
||||
@ -124,7 +124,10 @@ public class SqlInjectionUtil {
|
||||
checkSqlAnnotation(value);
|
||||
// 转为小写进行后续比较
|
||||
value = value.toLowerCase().trim();
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-06-16 for:【issue/9677】修复换行符绕过SQL注入检测-----------
|
||||
value = value.replaceAll("\\s+", " ");
|
||||
//update-end---author:wangshuai ---date:2026-06-16 for:【issue/9677】修复换行符绕过SQL注入检测-----------
|
||||
|
||||
// 二、SQL注入检测存在绕过风险 (普通文本校验)
|
||||
//https://gitee.com/jeecg/jeecg-boot/issues/I4NZGE
|
||||
String[] xssArr = XSS_STR.split("\\|");
|
||||
@ -149,7 +152,9 @@ public class SqlInjectionUtil {
|
||||
|
||||
// 四、SQL注入检测存在绕过风险 (正则校验)
|
||||
for (String regularOriginal : XSS_REGULAR_STR_ARRAY) {
|
||||
String regular = ".*" + regularOriginal + ".*";
|
||||
//update-begin---author:wangshuai ---date:2026-06-16 for:【issue/9677】正则加DOTALL模式,防止换行绕过-----------
|
||||
String regular = "(?s).*" + regularOriginal + ".*";
|
||||
//update-end---author:wangshuai ---date:2026-06-16 for:【issue/9677】正则加DOTALL模式,防止换行绕过-----------
|
||||
if (Pattern.matches(regular, value)) {
|
||||
log.error(SqlInjectionUtil.SQL_INJECTION_KEYWORD_TIP, regularOriginal);
|
||||
log.error(SqlInjectionUtil.SQL_INJECTION_TIP_VARIABLE, value);
|
||||
@ -274,7 +279,10 @@ public class SqlInjectionUtil {
|
||||
// 一、校验sql注释 不允许有sql注释
|
||||
checkSqlAnnotation(value);
|
||||
value = value.toLowerCase().trim();
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-06-16 for:【issue/9677】修复换行符绕过SQL注入检测-----------
|
||||
value = value.replaceAll("\\s+", " ");
|
||||
//update-end---author:wangshuai ---date:2026-06-16 for:【issue/9677】修复换行符绕过SQL注入检测-----------
|
||||
|
||||
// 二、SQL注入检测存在绕过风险 (普通文本校验)
|
||||
for (int i = 0; i < xssArr.length; i++) {
|
||||
if (isExistSqlInjectKeyword(value, xssArr[i])) {
|
||||
@ -294,7 +302,9 @@ public class SqlInjectionUtil {
|
||||
|
||||
// 三、SQL注入检测存在绕过风险 (正则校验)
|
||||
for (String regularOriginal : XSS_REGULAR_STR_ARRAY) {
|
||||
String regular = ".*" + regularOriginal + ".*";
|
||||
//update-begin---author:wangshuai ---date:2026-06-16 for:【issue/9677】正则加DOTALL模式,防止换行绕过-----------
|
||||
String regular = "(?s).*" + regularOriginal + ".*";
|
||||
//update-end---author:wangshuai ---date:2026-06-16 for:【issue/9677】正则加DOTALL模式,防止换行绕过-----------
|
||||
if (Pattern.matches(regular, value)) {
|
||||
log.error(SqlInjectionUtil.SQL_INJECTION_KEYWORD_TIP, regularOriginal);
|
||||
log.error(SqlInjectionUtil.SQL_INJECTION_TIP_VARIABLE, value);
|
||||
@ -318,7 +328,10 @@ public class SqlInjectionUtil {
|
||||
// 一、校验sql注释 不允许有sql注释
|
||||
checkSqlAnnotation(value);
|
||||
value = value.toLowerCase().trim();
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-06-16 for:【issue/9677】修复换行符绕过SQL注入检测-----------
|
||||
value = value.replaceAll("\\s+", " ");
|
||||
//update-end---author:wangshuai ---date:2026-06-16 for:【issue/9677】修复换行符绕过SQL注入检测-----------
|
||||
|
||||
// 二、SQL注入检测存在绕过风险 (普通文本校验)
|
||||
for (int i = 0; i < xssArr.length; i++) {
|
||||
if (isExistSqlInjectKeyword(value, xssArr[i])) {
|
||||
@ -338,7 +351,9 @@ public class SqlInjectionUtil {
|
||||
|
||||
// 三、SQL注入检测存在绕过风险 (正则校验)
|
||||
for (String regularOriginal : XSS_REGULAR_STR_ARRAY) {
|
||||
String regular = ".*" + regularOriginal + ".*";
|
||||
//update-begin---author:wangshuai ---date:2026-06-16 for:【issue/9677】正则加DOTALL模式,防止换行绕过-----------
|
||||
String regular = "(?s).*" + regularOriginal + ".*";
|
||||
//update-end---author:wangshuai ---date:2026-06-16 for:【issue/9677】正则加DOTALL模式,防止换行绕过-----------
|
||||
if (Pattern.matches(regular, value)) {
|
||||
log.error(SqlInjectionUtil.SQL_INJECTION_KEYWORD_TIP, regularOriginal);
|
||||
log.error(SqlInjectionUtil.SQL_INJECTION_TIP_VARIABLE, value);
|
||||
|
||||
@ -95,6 +95,8 @@ public class DbTypeUtils {
|
||||
return DataBaseConstant.DB_TYPE_DB2;
|
||||
}else if(DbType.HSQL.equals(dbType)){
|
||||
return DataBaseConstant.DB_TYPE_HSQL;
|
||||
}else if(DbType.DM.equals(dbType)){
|
||||
return DataBaseConstant.DB_TYPE_DM;
|
||||
}else if(dbTypeIsOracle(dbType)){
|
||||
return DataBaseConstant.DB_TYPE_ORACLE;
|
||||
}else if(dbTypeIsSqlServer(dbType)){
|
||||
|
||||
@ -56,8 +56,10 @@ public class DynamicDBUtil {
|
||||
dataSource.setTestOnBorrow(false);
|
||||
dataSource.setTestOnReturn(false);
|
||||
dataSource.setBreakAfterAcquireFailure(true);
|
||||
//设置超时时间60秒
|
||||
dataSource.setLoginTimeout(60);
|
||||
// TCP建连超时 3s,防止目标库不可达时连接线程挂住
|
||||
dataSource.setConnectTimeout(3000);
|
||||
// 登录超时 5s(原为60s,过长会导致线程被占住;不影响SQL执行阶段)
|
||||
dataSource.setLoginTimeout(5);
|
||||
dataSource.setConnectionErrorRetryAttempts(0);
|
||||
dataSource.setUsername(dbUser);
|
||||
dataSource.setMaxWait(30000);
|
||||
|
||||
@ -11,10 +11,15 @@ import java.net.InetAddress;
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.net.UnknownHostException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* @Description: 校验文件敏感后缀
|
||||
@ -187,6 +192,10 @@ public class SsrfFileTypeFilter {
|
||||
if (!isAllowExtension) {
|
||||
throw new JeecgBootException("上传失败,存在非法文件类型:" + suffix);
|
||||
}
|
||||
//3. SVG文件内容安全校验(issues/9693)
|
||||
if ("svg".equalsIgnoreCase(suffix)) {
|
||||
checkSvgSafety(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -210,9 +219,12 @@ public class SsrfFileTypeFilter {
|
||||
Iterator<String> keyIter = FILE_TYPE_MAP.keySet().iterator();
|
||||
while (keyIter.hasNext()) {
|
||||
String key = keyIter.next();
|
||||
// 验证前5个字符比较
|
||||
if (key.toLowerCase().startsWith(fileTypeHex.toLowerCase().substring(0, 5))
|
||||
|| fileTypeHex.toLowerCase().substring(0, 5).startsWith(key.toLowerCase())) {
|
||||
//update-begin---author:lsq ---date:2026-05-26 for:修复SVG文件被误判为php的问题(<?xml与<?php前2.5字节相同,扩大比较长度到10位避免误判)-----------
|
||||
// 验证前10个字符比较(5字节),避免<?xml与<?php因前5位hex相同而误判
|
||||
int compareLen = Math.min(10, Math.min(key.length(), fileTypeHex.length()));
|
||||
if (key.toLowerCase().startsWith(fileTypeHex.toLowerCase().substring(0, compareLen))
|
||||
|| fileTypeHex.toLowerCase().substring(0, compareLen).startsWith(key.toLowerCase())) {
|
||||
//update-end---author:lsq ---date:2026-05-26 for:修复SVG文件被误判为php的问题(<?xml与<?php前2.5字节相同,扩大比较长度到10位避免误判)-----------
|
||||
fileExtendName = FILE_TYPE_MAP.get(key);
|
||||
break;
|
||||
}
|
||||
@ -367,5 +379,69 @@ public class SsrfFileTypeFilter {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* SVG 危险标签黑名单(标签名统一小写比较)
|
||||
*/
|
||||
private static final Set<String> SVG_DANGEROUS_TAGS = new HashSet<>(Arrays.asList(
|
||||
"script", "foreignobject", "iframe", "object", "embed", "applet",
|
||||
"form", "input", "textarea", "button", "select",
|
||||
"link", "meta", "base", "import",
|
||||
"handler", "set", "animate", "animatemotion", "animatetransform"
|
||||
));
|
||||
|
||||
/**
|
||||
* SVG 危险属性正则:匹配事件属性(on*="...")和 javascript: 协议
|
||||
*/
|
||||
private static final Pattern SVG_EVENT_ATTR_PATTERN = Pattern.compile(
|
||||
"\\bon\\w+\\s*=", Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
private static final Pattern SVG_JS_PROTOCOL_PATTERN = Pattern.compile(
|
||||
"javascript\\s*:", Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
/**
|
||||
* HTML entity 编码的 javascript 协议(javascript: 等变体)
|
||||
*/
|
||||
private static final Pattern SVG_ENTITY_JS_PATTERN = Pattern.compile(
|
||||
"&#\\d+;|&#x[0-9a-f]+;", Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
/**
|
||||
* 校验 SVG 文件内容是否安全,防止存储型 XSS(issues/9693)。
|
||||
* 采用文本扫描方式检测危险标签、事件属性和 javascript: 协议。
|
||||
*
|
||||
* @param file 上传的 SVG 文件
|
||||
*/
|
||||
private static void checkSvgSafety(MultipartFile file) throws Exception {
|
||||
String originalContent;
|
||||
try (InputStream is = file.getInputStream()) {
|
||||
originalContent = new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
String content = originalContent.toLowerCase();
|
||||
// 检测危险标签
|
||||
for (String tag : SVG_DANGEROUS_TAGS) {
|
||||
if (content.contains("<" + tag + ">") || content.contains("<" + tag + " ")
|
||||
|| content.contains("<" + tag + "/") || content.contains("<" + tag + "\t")
|
||||
|| content.contains("<" + tag + "\n") || content.contains("<" + tag + "\r")) {
|
||||
throw new JeecgBootException("上传失败,SVG文件包含不安全的标签:<" + tag + ">");
|
||||
}
|
||||
}
|
||||
// 检测事件属性(onclick、onload、onerror、onbegin 等)
|
||||
if (SVG_EVENT_ATTR_PATTERN.matcher(originalContent).find()) {
|
||||
throw new JeecgBootException("上传失败,SVG文件包含不安全的事件属性");
|
||||
}
|
||||
// 检测 javascript: 协议
|
||||
if (SVG_JS_PROTOCOL_PATTERN.matcher(originalContent).find()) {
|
||||
throw new JeecgBootException("上传失败,SVG文件包含不安全的javascript协议");
|
||||
}
|
||||
// 检测 HTML entity 编码(防止 javascript: 等绕过)
|
||||
if (SVG_ENTITY_JS_PATTERN.matcher(originalContent).find()) {
|
||||
throw new JeecgBootException("上传失败,SVG文件包含不安全的编码内容");
|
||||
}
|
||||
// 检测 DOCTYPE/ENTITY 声明(防止 XML Bomb / Billion Laughs DoS 攻击)
|
||||
if (content.contains("<!doctype") || content.contains("<!entity")) {
|
||||
throw new JeecgBootException("上传失败,SVG文件包含不安全的DOCTYPE/ENTITY声明");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -43,6 +43,9 @@ public class oConvertUtils {
|
||||
if ("".equals(object)) {
|
||||
return (true);
|
||||
}
|
||||
if ("null".equals(object)) {
|
||||
return (true);
|
||||
}
|
||||
if (CommonConstant.STRING_NULL.equals(object)) {
|
||||
return (true);
|
||||
}
|
||||
|
||||
@ -10,6 +10,11 @@ import org.springframework.stereotype.Component;
|
||||
@ConfigurationProperties(prefix = "jeecg.ai-chat")
|
||||
public class AiChatConfig {
|
||||
|
||||
/**
|
||||
* 默认聊天模型名称(用于判断是否支持Tool Calling等)
|
||||
*/
|
||||
private String model;
|
||||
|
||||
/**
|
||||
* skills配置文件路径
|
||||
*/
|
||||
|
||||
@ -14,10 +14,12 @@ import io.micrometer.prometheusmetrics.PrometheusMeterRegistry;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.config.filter.SvgSecurityFilter;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Conditional;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
@ -36,7 +38,6 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
@ -48,6 +49,9 @@ import java.util.concurrent.TimeUnit;
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class WebMvcConfiguration implements WebMvcConfigurer {
|
||||
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss");
|
||||
|
||||
@Resource
|
||||
JeecgBaseConfig jeecgBaseConfig;
|
||||
@ -133,16 +137,58 @@ public class WebMvcConfiguration implements WebMvcConfigurer {
|
||||
//默认的处理日期时间格式
|
||||
objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
|
||||
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
|
||||
javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
|
||||
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
|
||||
javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
|
||||
javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DATE_TIME_FORMATTER));
|
||||
javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DATE_FORMATTER));
|
||||
javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(TIME_FORMATTER));
|
||||
javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DATE_TIME_FORMATTER));
|
||||
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DATE_FORMATTER));
|
||||
javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(TIME_FORMATTER));
|
||||
objectMapper.registerModule(javaTimeModule);
|
||||
return objectMapper;
|
||||
}
|
||||
|
||||
//update-begin---author:scott ---date:20260813 for:Spring Boot 4默认Jackson 3统一Java时间格式------------
|
||||
/**
|
||||
* 为 Spring Boot 4 默认的 Jackson 3 JsonMapper 注册 Java 时间序列化格式。
|
||||
*/
|
||||
@Bean
|
||||
public tools.jackson.databind.JacksonModule jackson3JavaTimeModule() {
|
||||
tools.jackson.databind.module.SimpleModule javaTimeModule = new tools.jackson.databind.module.SimpleModule("jeecgJavaTimeModule");
|
||||
javaTimeModule.addSerializer(LocalDateTime.class, new tools.jackson.databind.ext.javatime.ser.LocalDateTimeSerializer(DATE_TIME_FORMATTER));
|
||||
javaTimeModule.addSerializer(LocalDate.class, new tools.jackson.databind.ext.javatime.ser.LocalDateSerializer(DATE_FORMATTER));
|
||||
javaTimeModule.addSerializer(LocalTime.class, new tools.jackson.databind.ext.javatime.ser.LocalTimeSerializer(TIME_FORMATTER));
|
||||
return javaTimeModule;
|
||||
}
|
||||
//update-end---author:scott ---date:20260813 for:Spring Boot 4默认Jackson 3统一Java时间格式------------
|
||||
|
||||
// /**
|
||||
// * SpringBootAdmin的Httptrace不见了
|
||||
// * https://blog.csdn.net/u013810234/article/details/110097201
|
||||
// */
|
||||
// @Bean
|
||||
// public InMemoryHttpTraceRepository getInMemoryHttpTrace(){
|
||||
// return new InMemoryHttpTraceRepository();
|
||||
// }
|
||||
|
||||
|
||||
//update-begin---author:liusq ---date:20260525 for:【QQYUN-15536】修复上传SVG文件通过静态资源路径触发存储型XSS-----------
|
||||
/**
|
||||
* 注册 SVG 安全响应头过滤器
|
||||
* <p>对所有 .svg 请求的响应自动追加 Content-Security-Policy:sandbox 和 X-Content-Type-Options:nosniff,
|
||||
* 阻止浏览器以顶层文档方式渲染已上传 SVG 时执行内嵌脚本(存储型 XSS)。</p>
|
||||
*
|
||||
* @see SvgSecurityFilter
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<SvgSecurityFilter> svgSecurityFilterRegistration() {
|
||||
FilterRegistrationBean<SvgSecurityFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(new SvgSecurityFilter());
|
||||
registration.addUrlPatterns("/*");
|
||||
registration.setName("svgSecurityFilter");
|
||||
return registration;
|
||||
}
|
||||
//update-end---author:liusq ---date:20260525 for:【QQYUN-15536】修复上传SVG文件通过静态资源路径触发存储型XSS-----------
|
||||
|
||||
/**
|
||||
* 在Bean初始化完成后立即配置PrometheusMeterRegistry,避免在Meter注册后才配置MeterFilter
|
||||
* for [QQYUN-12558]【监控】系统监控的头两个tab不好使,接口404
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
package org.jeecg.config.filter;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 【QQYUN-15536】修复上传SVG文件通过静态资源路径触发存储型XSS
|
||||
* 对 .svg 后缀的响应自动追加以下安全响应头,阻止浏览器在顶层文档渲染 SVG 时执行内嵌脚本
|
||||
* Content-Security-Policy: sandbox —— 沙箱化页面,禁止脚本、表单提交、同源操作等
|
||||
* X-Content-Type-Options: nosniff —— 禁止 MIME 类型嗅探,防止绕过 Content-Type 限制
|
||||
* 对通过 {@code <img src="xxx.svg">} 方式内嵌的 SVG 无任何影响(子资源加载不受此类头部限制)
|
||||
* @author liusq
|
||||
* @date 2026/05/25
|
||||
* @see <a href="https://github.com/jeecgboot/JeecgBoot/issues/9646">issues/9646</a>
|
||||
*/
|
||||
@Slf4j
|
||||
public class SvgSecurityFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final String SVG_SUFFIX = ".svg";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
//update-begin---author:liusq ---date:2026-05-26 for:【QQYUN-15536】补充URL编码绕过防御:getServletPath()由Servlet容器解码,可防止%2E等编码绕过-----------
|
||||
// 使用 getServletPath() 而非 getRequestURI():前者由 Servlet 容器自动 URL 解码,
|
||||
// 后者返回原始未解码路径,攻击者可用 %2e%73%76%67 绕过 endsWith(".svg") 检测
|
||||
String uri = request.getServletPath();
|
||||
//update-end---author:liusq ---date:2026-05-26 for:【QQYUN-15536】补充URL编码绕过防御:getServletPath()由Servlet容器解码,可防止%2E等编码绕过-----------
|
||||
if (uri != null && uri.toLowerCase().endsWith(SVG_SUFFIX)) {
|
||||
// 沙箱化限制:即使浏览器以顶层文档方式访问 SVG,内嵌脚本也无法执行
|
||||
response.setHeader("Content-Security-Policy", "sandbox");
|
||||
// 禁止 MIME 类型嗅探,强制浏览器遵守声明的 Content-Type,防止绕过
|
||||
response.setHeader("X-Content-Type-Options", "nosniff");
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
}
|
||||
@ -55,7 +55,7 @@ public class LowCodeModeInterceptor implements HandlerInterceptor {
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
CommonAPI commonAPI = null;
|
||||
log.info("低代码模式,拦截请求路径:" + request.getRequestURI());
|
||||
log.debug("低代码模式,拦截请求路径:" + request.getRequestURI());
|
||||
|
||||
//1、验证是否开启低代码开发模式控制
|
||||
if (jeecgBaseConfig == null) {
|
||||
|
||||
@ -1,16 +1,12 @@
|
||||
package org.jeecg.config.mybatis;
|
||||
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.toolkit.JdbcUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import me.zhyd.oauth.log.Log;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.LongValue;
|
||||
import org.jeecg.common.config.TenantContext;
|
||||
@ -19,13 +15,13 @@ import org.jeecg.common.constant.TenantConstant;
|
||||
import org.jeecg.common.util.SpringContextUtils;
|
||||
import org.jeecg.common.util.TokenUtils;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.config.mybatis.interceptor.MultiDataSourcePaginationInnerInterceptor;
|
||||
import org.mybatis.spring.annotation.MapperScan;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@ -38,8 +34,6 @@ import java.util.List;
|
||||
@Configuration
|
||||
@MapperScan(value={"org.jeecg.**.mapper*"})
|
||||
public class MybatisPlusSaasConfig {
|
||||
@Autowired
|
||||
private DataSource dataSource;
|
||||
|
||||
/**
|
||||
* 是否开启系统模块的租户隔离
|
||||
@ -90,6 +84,10 @@ public class MybatisPlusSaasConfig {
|
||||
|
||||
|
||||
@Bean
|
||||
// 顺序需低于 OnlineCgformDataSourceMybatisInterceptor(其为 LOWEST_PRECEDENCE),使分页插件处于数据源切换拦截器的内层,
|
||||
// 保证 Online 物理表查询时数据源已 push、分页插件 beforeQuery 探测连接能路由到正确的数据源
|
||||
// author:zhangdaiscott date:2026-07-09 for:修复Online配置多数据源导出excel数据为空
|
||||
@Order(Ordered.LOWEST_PRECEDENCE - 1)
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
// 先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor
|
||||
@ -127,27 +125,12 @@ public class MybatisPlusSaasConfig {
|
||||
return true;
|
||||
}
|
||||
}));
|
||||
//update-begin-author:zyf date:20220425 for:【VUEN-606】注入动态表名适配拦截器解决多表名问题
|
||||
// 注入动态表名适配拦截器解决多表名问题
|
||||
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor());
|
||||
//update-end-author:zyf date:20220425 for:【VUEN-606】注入动态表名适配拦截器解决多表名问题
|
||||
|
||||
//update-begin---author:scott ---date:2025-08-02 for:【issues/8666】升级mybatisPlus后SqlServer分页使用OFFSET ? ROWS FETCH NEXT ? ROWS ONLY,导致online报表报错---
|
||||
DbType dbType = null;
|
||||
try {
|
||||
dbType = JdbcUtils.getDbType(dataSource.getConnection().getMetaData().getURL());
|
||||
log.info("当前数据库类型: {}", dbType);
|
||||
} catch (SQLException e) {
|
||||
Log.error(e.getMessage(), e);
|
||||
}
|
||||
if (dbType!=null && (dbType == DbType.SQL_SERVER || dbType == DbType.SQL_SERVER2005)) {
|
||||
// 如果是SQL Server则覆盖为2005分页方式
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.SQL_SERVER2005));
|
||||
} else {
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
|
||||
}
|
||||
//update-end---author:scott ---date::2025-08-02 for:【issues/8666】升级mybatisPlus后SqlServer分页使用OFFSET ? ROWS FETCH NEXT ? ROWS ONLY,导致online报表报错---
|
||||
|
||||
//【jeecg-boot/issues/3847】增加@Version乐观锁支持
|
||||
//update-begin---author:scott ---date:2026-07-09 for:【LHZP-9、LHZP-8】使用按连接实时探测方言的分页拦截器,兼容主库/从库不同数据库类型(多数据源);SQL Server 走 2005 方言并做 ORDER BY 去重(保留 issues/8666 修复)---
|
||||
interceptor.addInnerInterceptor(new MultiDataSourcePaginationInnerInterceptor());
|
||||
//update-end---author:scott ---date:2026-07-09 for:【LHZP-9、LHZP-8】使用按连接实时探测方言的分页拦截器,兼容主库/从库不同数据库类型(多数据源);SQL Server 走 2005 方言并做 ORDER BY 去重(保留 issues/8666 修复)---
|
||||
// 增加@Version乐观锁支持
|
||||
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
|
||||
return interceptor;
|
||||
}
|
||||
|
||||
@ -0,0 +1,130 @@
|
||||
package org.jeecg.config.mybatis.interceptor;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.core.toolkit.PluginUtils;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.DialectFactory;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.dialects.IDialect;
|
||||
import com.baomidou.mybatisplus.extension.toolkit.JdbcUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
|
||||
import net.sf.jsqlparser.statement.Statement;
|
||||
import net.sf.jsqlparser.statement.select.OrderByElement;
|
||||
import net.sf.jsqlparser.statement.select.PlainSelect;
|
||||
import net.sf.jsqlparser.statement.select.SetOperationList;
|
||||
import org.apache.ibatis.executor.Executor;
|
||||
import org.apache.ibatis.mapping.BoundSql;
|
||||
import org.apache.ibatis.mapping.MappedStatement;
|
||||
import org.apache.ibatis.session.ResultHandler;
|
||||
import org.apache.ibatis.session.RowBounds;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 支持动态多数据源的分页拦截器
|
||||
*
|
||||
* <p>原生 {@link PaginationInnerInterceptor} 一旦用固定 {@link DbType} 构造,
|
||||
* {@code findIDialect()} 会缓存该方言并对所有数据源生效。当主库与 Online 表单/报表
|
||||
* 使用的从数据源类型不一致时(例如主库 SQL Server、从库 MySQL),会用主库方言分页,
|
||||
* 生成对方数据库无法识别的分页 SQL 而报错。</p>
|
||||
*
|
||||
* <p>本子类做两件事,均按当前连接实时探测数据库类型({@link JdbcUtils#getDbType(Executor)}
|
||||
* 内部按 URL 缓存,开销极小),从而正确适配多数据源:</p>
|
||||
* <ol>
|
||||
* <li>{@link #findIDialect(Executor)}:让不同类型的数据源各自使用正确的分页方言,
|
||||
* 其中 SQL Server 统一映射为 {@link DbType#SQL_SERVER2005} 方言,以保留
|
||||
* issues/8666 对旧版 SQL Server 分页的兼容处理;</li>
|
||||
* <li>{@link #beforeQuery}:仅当探测到 SQL Server 时,对 ORDER BY 去重
|
||||
* (保留首次出现、维持排序优先级),解决 QueryGenerator + 手动 orderBy 叠加导致
|
||||
* 重复列 error 169 的问题;其它数据库不做处理,避免无谓的 SQL 解析开销。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @author scott
|
||||
* @date 2026-07-09
|
||||
*/
|
||||
@Slf4j
|
||||
public class MultiDataSourcePaginationInnerInterceptor extends PaginationInnerInterceptor {
|
||||
|
||||
@Override
|
||||
protected IDialect findIDialect(Executor executor) {
|
||||
if (isSqlServer(executor)) {
|
||||
// SQL Server 统一走 2005 方言(ROW_NUMBER 分页),兼容旧版 SQL Server
|
||||
return DialectFactory.getDialect(DbType.SQL_SERVER2005);
|
||||
}
|
||||
return DialectFactory.getDialect(JdbcUtils.getDbType(executor));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter,
|
||||
RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
|
||||
// 仅 SQL Server 需要 ORDER BY 去重(error 169),其它库跳过以免无谓的 SQL 解析开销
|
||||
if (isSqlServer(executor)) {
|
||||
dedupOrderBy(boundSql);
|
||||
}
|
||||
super.beforeQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按当前连接判断是否为 SQL Server
|
||||
*/
|
||||
private boolean isSqlServer(Executor executor) {
|
||||
DbType dbType = JdbcUtils.getDbType(executor);
|
||||
return dbType == DbType.SQL_SERVER || dbType == DbType.SQL_SERVER2005;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Server ORDER BY 去重:移除 ORDER BY 中重复的列(保留首次出现,维持排序优先级)。
|
||||
* SQL Server 不允许 ORDER BY 中出现重复列(error 169),而项目中有多处在
|
||||
* QueryGenerator.initQueryWrapper() 之后又手动调了 orderByDesc/orderByAsc。
|
||||
*/
|
||||
private void dedupOrderBy(BoundSql boundSql) {
|
||||
String originalSql = boundSql.getSql();
|
||||
if (originalSql == null || originalSql.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// 快速跳过非 SELECT 语句
|
||||
String upper = originalSql.trim().toUpperCase();
|
||||
if (!upper.startsWith("SELECT") && !upper.startsWith("(SELECT")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Statement stmt = CCJSqlParserUtil.parse(originalSql);
|
||||
List<OrderByElement> orderByElements = null;
|
||||
if (stmt instanceof PlainSelect) {
|
||||
orderByElements = ((PlainSelect) stmt).getOrderByElements();
|
||||
} else if (stmt instanceof SetOperationList) {
|
||||
orderByElements = ((SetOperationList) stmt).getOrderByElements();
|
||||
}
|
||||
if (orderByElements == null || orderByElements.size() <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 去重:保留首次出现,维持顺序
|
||||
List<OrderByElement> deduped = new ArrayList<>(orderByElements.size());
|
||||
LinkedHashSet<String> seen = new LinkedHashSet<>();
|
||||
for (OrderByElement element : orderByElements) {
|
||||
// toString() 包含表达式 + ASC/DESC + NULLS FIRST/LAST,完整作为去重 key
|
||||
if (seen.add(element.toString())) {
|
||||
deduped.add(element);
|
||||
}
|
||||
}
|
||||
if (deduped.size() == orderByElements.size()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 回写去重后的 ORDER BY
|
||||
if (stmt instanceof PlainSelect) {
|
||||
((PlainSelect) stmt).setOrderByElements(deduped);
|
||||
} else {
|
||||
((SetOperationList) stmt).setOrderByElements(deduped);
|
||||
}
|
||||
PluginUtils.mpBoundSql(boundSql).sql(stmt.toString());
|
||||
log.debug("SQL Server ORDER BY 去重: {} 个重复列已移除", orderByElements.size() - deduped.size());
|
||||
} catch (Exception e) {
|
||||
// 解析失败时保持原始 SQL,不中断查询
|
||||
log.warn("SQL Server ORDER BY 去重解析失败,使用原始 SQL: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -138,6 +138,9 @@ public class ShiroConfig {
|
||||
|
||||
filterChainDefinitionMap.put("/sys/annountCement/show/**", "anon");
|
||||
|
||||
//Chat2BI分享页(页面自带独立登录)
|
||||
filterChainDefinitionMap.put("/jimu/chat2bi/chat", "anon");
|
||||
|
||||
//积木报表排除
|
||||
filterChainDefinitionMap.put("/jmreport/**", "anon");
|
||||
filterChainDefinitionMap.put("/**/*.js.map", "anon");
|
||||
@ -225,6 +228,7 @@ public class ShiroConfig {
|
||||
registration.addUrlPatterns("/test/ai/chat/send");
|
||||
registration.addUrlPatterns("/airag/flow/run");
|
||||
registration.addUrlPatterns("/airag/flow/debug");
|
||||
registration.addUrlPatterns("/airag/flow/code/generate");
|
||||
registration.addUrlPatterns("/airag/chat/send");
|
||||
registration.addUrlPatterns("/airag/app/debug");
|
||||
registration.addUrlPatterns("/airag/app/prompt/generate");
|
||||
@ -326,7 +330,6 @@ public class ShiroConfig {
|
||||
public IRedisManager redisManager() {
|
||||
log.info("===============(2)创建RedisManager,连接Redis..");
|
||||
IRedisManager manager;
|
||||
//update-begin---author:scott ---date:2026-07-07 for:【Spring Boot 4.0 升级】恢复 Sentinel 哨兵模式支持,API 兼容-----------
|
||||
// sentinel cluster redis(【issues/5569】shiro集成 redis 不支持 sentinel 方式部署的redis集群 #5569)
|
||||
if (Objects.nonNull(redisProperties)
|
||||
&& Objects.nonNull(redisProperties.getSentinel())
|
||||
@ -339,7 +342,6 @@ public class ShiroConfig {
|
||||
|
||||
return sentinelManager;
|
||||
}
|
||||
//update-end---author:scott ---date:2026-07-07 for:【Spring Boot 4.0 升级】恢复 Sentinel 哨兵模式支持,API 兼容-----------
|
||||
|
||||
// redis 单机支持,在集群为空,或者集群无机器时候使用 add by jzyadmin@163.com
|
||||
if (lettuceConnectionFactory.getClusterConfiguration() == null || lettuceConnectionFactory.getClusterConfiguration().getClusterNodes().isEmpty()) {
|
||||
|
||||
@ -33,7 +33,7 @@ public class SignAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||
log.info("签名拦截器 Interceptor request URI = " + request.getRequestURI());
|
||||
log.debug("签名拦截器 Interceptor request URI = " + request.getRequestURI());
|
||||
|
||||
try {
|
||||
// 调用验证逻辑
|
||||
|
||||
@ -12,4 +12,10 @@ public class DomainUrl {
|
||||
private String pc;
|
||||
|
||||
private String app;
|
||||
|
||||
/**
|
||||
* 后端自代理 baseUrl:相对路径 originUrl 转发时使用,
|
||||
* 解决 Docker / K8s NodePort / 反向代理等入站端口与监听端口不一致问题。
|
||||
*/
|
||||
private String back;
|
||||
}
|
||||
|
||||
@ -33,6 +33,13 @@ public class Firewall {
|
||||
*/
|
||||
private Boolean enableLoginCaptcha = true;
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-06-29 for:【QQYUN-16619】三级等保密码强度开关-----------
|
||||
/**
|
||||
* 是否开启三级等保强密码校验(true 开启强密码模式,false 使用简单密码规则)
|
||||
*/
|
||||
private Boolean enableStrongPwd = false;
|
||||
//update-end---author:wangshuai ---date:2026-06-29 for:【QQYUN-16619】三级等保密码强度开关-----------
|
||||
|
||||
// /**
|
||||
// * 表字典安全模式(white:白名单——配置了白名单的表才能通过表字典方式访问,black:黑名单——配置了黑名单的表不允许表字典方式访问)
|
||||
// */
|
||||
@ -86,4 +93,14 @@ public class Firewall {
|
||||
public void setIsConcurrent(Boolean isConcurrent) {
|
||||
this.isConcurrent = isConcurrent;
|
||||
}
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-06-29 for:【QQYUN-16619】三级等保密码强度开关-----------
|
||||
public Boolean getEnableStrongPwd() {
|
||||
return enableStrongPwd;
|
||||
}
|
||||
|
||||
public void setEnableStrongPwd(Boolean enableStrongPwd) {
|
||||
this.enableStrongPwd = enableStrongPwd;
|
||||
}
|
||||
//update-end---author:wangshuai ---date:2026-06-29 for:【QQYUN-16619】三级等保密码强度开关-----------
|
||||
}
|
||||
|
||||
@ -0,0 +1,165 @@
|
||||
package org.jeecg.test.security;
|
||||
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* SsrfFileTypeFilter.checkSsrfHttpUrl SSRF 防护单元测试
|
||||
*
|
||||
* 覆盖:
|
||||
* - 【issues/9672】RFC1918 私网地址(10.x / 172.16-31.x / 192.168.x)应放行,兼容企业内网 MinIO/OSS
|
||||
* - 回归:loopback / link-local 仍拦截
|
||||
* - 回归:公网 URL 正常放行
|
||||
* - 协议校验、空值校验
|
||||
*
|
||||
* @author wangshuai
|
||||
* @date 2026-06-16
|
||||
*/
|
||||
@ExtendWith(PrintTestResultExtension.class)
|
||||
public class Issue9672_SsrfFileTypeFilterTest {
|
||||
|
||||
@Nested
|
||||
@DisplayName("【issues/9672】RFC1918 私网地址应放行(兼容内网 MinIO/OSS)")
|
||||
class Issue9672_PrivateNetwork {
|
||||
|
||||
@Test
|
||||
@DisplayName("10.0.0.0/8 段应放行(内网 MinIO 等服务)")
|
||||
void shouldAllow_10Network() {
|
||||
String url = "http://10.0.0.1/secret.txt";
|
||||
assertDoesNotThrow(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 放行(RFC1918 私网)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("172.16.0.0/12 段应放行(内网 MinIO 等服务)")
|
||||
void shouldAllow_172_16Network() {
|
||||
String url = "http://172.16.0.1:9000/bucket/file.pdf";
|
||||
assertDoesNotThrow(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 放行(RFC1918 私网)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("192.168.0.0/16 段应放行(内网 MinIO 等服务)")
|
||||
void shouldAllow_192_168Network() {
|
||||
String url = "http://192.168.1.100/internal/data.txt";
|
||||
assertDoesNotThrow(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 放行(RFC1918 私网)");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("回归:loopback / link-local 仍拦截")
|
||||
class Regression_LoopbackLinkLocal {
|
||||
|
||||
@Test
|
||||
@DisplayName("127.0.0.1 应被拦截")
|
||||
void shouldBlock_loopback_ipv4() {
|
||||
String url = "http://127.0.0.1/etc/passwd";
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
assertTrue(ex.getMessage().contains("本机或链路本地地址"));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 拦截: " + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("localhost 应被拦截")
|
||||
void shouldBlock_localhost() {
|
||||
String url = "http://localhost:8080/admin";
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 拦截: " + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("169.254.169.254 云元数据应被拦截")
|
||||
void shouldBlock_cloudMetadata() {
|
||||
String url = "http://169.254.169.254/latest/meta-data/";
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 拦截(云元数据): " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("公网 URL 正常放行")
|
||||
class PublicUrl_AllowThrough {
|
||||
|
||||
@Test
|
||||
@DisplayName("HTTPS 公网 URL 应放行")
|
||||
void shouldAllow_publicHttps() {
|
||||
String url = "https://www.baidu.com/index.html";
|
||||
assertDoesNotThrow(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 放行(公网)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("HTTP 公网 URL 应放行")
|
||||
void shouldAllow_publicHttp() {
|
||||
String url = "http://cdn.example.com/file.pdf";
|
||||
assertDoesNotThrow(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 放行(公网)");
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("协议与格式校验")
|
||||
class ProtocolAndFormat {
|
||||
|
||||
@Test
|
||||
@DisplayName("空 URL 应拦截")
|
||||
void shouldBlock_emptyUrl() {
|
||||
assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl(""));
|
||||
assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl(null));
|
||||
System.out.println(" [SSRF校验] 空值 \"\" / null -> 拦截");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非 http/https 协议应拦截")
|
||||
void shouldBlock_nonHttpProtocol() {
|
||||
for (String url : new String[]{"file:///etc/passwd", "ftp://192.168.1.1/data", "gopher://127.0.0.1:6379/_INFO"}) {
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 拦截(非 http/https): " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("格式错误的 URL 应拦截")
|
||||
void shouldBlock_malformedUrl() {
|
||||
String url = "http://";
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 拦截(格式错误): " + ex.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无法解析的主机名应拦截")
|
||||
void shouldBlock_unresolvableHost() {
|
||||
String host = "this-host-does-not-exist-12345.invalid";
|
||||
String url = "http://" + host + "/test";
|
||||
// 某些网络环境存在通配 DNS(把任意不存在域名解析到一个真实公网地址),
|
||||
// 此时该 host 并非"无法解析",本用例前提不成立,跳过以免环境误报。
|
||||
boolean resolvable;
|
||||
try {
|
||||
java.net.InetAddress.getByName(host);
|
||||
resolvable = true;
|
||||
} catch (java.net.UnknownHostException e) {
|
||||
resolvable = false;
|
||||
}
|
||||
org.junit.jupiter.api.Assumptions.assumeFalse(resolvable,
|
||||
"当前网络存在通配 DNS," + host + " 被解析为真实地址,跳过该用例");
|
||||
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl(url));
|
||||
System.out.println(" [SSRF校验] " + url + " -> 拦截(主机无法解析): " + ex.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,274 @@
|
||||
package org.jeecg.test.security;
|
||||
|
||||
import org.jeecg.common.exception.JeecgSqlInjectionException;
|
||||
import org.jeecg.common.util.SqlInjectionUtil;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/**
|
||||
* 【issue/9677】SQL注入换行符绕过修复 — 单元测试
|
||||
*
|
||||
* 漏洞:攻击者用换行符(\n)替代空格,绕过关键词黑名单和正则检测
|
||||
* 例如 "1\nand\nsleep(2)" 绕过 "and " 和 "sleep\\s*\\(" 的检测
|
||||
*
|
||||
* 修复:在所有检测入口对输入做 \\s+ → 空格 的归一化,正则加 (?s) DOTALL 模式
|
||||
*
|
||||
* @author wangshuai
|
||||
* @date 2026-06-16
|
||||
*/
|
||||
@ExtendWith(PrintTestResultExtension.class)
|
||||
public class Issue9677_SqlInjectionNewlineBypassTest {
|
||||
|
||||
// ========== 漏洞一:SQL注入换行绕过 ==========
|
||||
|
||||
@Nested
|
||||
@DisplayName("filterContent — 换行绕过检测")
|
||||
class FilterContentNewlineBypass {
|
||||
|
||||
@Test
|
||||
@DisplayName("正常空格形式 'and sleep(2)' 被拦截(回归)")
|
||||
void normalSpaceBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.filterContent("1 and sleep(2)", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符形式 '1\\nand\\nsleep(2)' 应被拦截")
|
||||
void newlineAndSleepBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.filterContent("1\nand\nsleep(2)", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("制表符形式 '1\\tand\\tsleep(2)' 应被拦截")
|
||||
void tabAndSleepBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.filterContent("1\tand\tsleep(2)", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回车换行混合 '1\\r\\nand\\r\\nsleep(2)' 应被拦截")
|
||||
void crlfAndSleepBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.filterContent("1\r\nand\r\nsleep(2)", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符形式 '1\\nselect\\n1' 应被拦截")
|
||||
void newlineSelectBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.filterContent("1\nselect\n1", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符形式 '1\\nor\\n1=1' 应被拦截")
|
||||
void newlineOrBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.filterContent("1\nor\n1=1", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符+正则函数 'benchmark\\n(1000,md5(1))' 应被拦截")
|
||||
void newlineBenchmarkBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.filterContent("1\nand\nbenchmark\n(1000,md5(1))", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符+show tables应被拦截")
|
||||
void newlineShowTablesBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.filterContent("1\nand\nshow\ntables", null));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("specialFilterContentForDictSql — 换行绕过检测")
|
||||
class DictSqlNewlineBypass {
|
||||
|
||||
@Test
|
||||
@DisplayName("正常空格形式 'and sleep(5)' 被拦截(回归)")
|
||||
void normalSpaceBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1 and sleep(5)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符形式 '1\\nand\\nsleep(5)' 应被拦截")
|
||||
void newlineSleepBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1\nand\nsleep(5)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符+select 绕过应被拦截")
|
||||
void newlineSelectBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1\nunion\nselect\npassword\nfrom\nsys_user"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符+database()应被拦截")
|
||||
void newlineDatabaseFuncBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1\nand\ndatabase()='jeecg'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("制表符+extractvalue应被拦截")
|
||||
void tabExtractvalueBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1\tand\textractvalue(1,concat(0x7e))"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多种空白混合应被拦截")
|
||||
void mixedWhitespaceBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1\n\tand \nsleep(5)"));
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("specialFilterContentForOnlineReport — 换行绕过检测")
|
||||
class OnlineReportNewlineBypass {
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符+insert绕过应被拦截")
|
||||
void newlineInsertBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForOnlineReport("1\ninsert\ninto\nsys_user"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符+delete绕过应被拦截")
|
||||
void newlineDeleteBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForOnlineReport("1\ndelete\nfrom\nsys_user"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("换行符+drop绕过应被拦截")
|
||||
void newlineDropBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForOnlineReport("1;\ndrop\ntable\nsys_user"));
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 合法输入不被误拦截 ==========
|
||||
|
||||
@Nested
|
||||
@DisplayName("合法输入回归 — 确保不误拦截")
|
||||
class LegitimateInputs {
|
||||
|
||||
@Test
|
||||
@DisplayName("简单条件通过")
|
||||
void simpleConditionPasses() {
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.filterContent("status=1", null));
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForDictSql("status=1"));
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForOnlineReport("status=1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("含引号的字符串值通过")
|
||||
void quotedValuePasses() {
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForDictSql("dept_id='10001'"));
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForDictSql("create_time > '2026-01-01'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空/null值通过")
|
||||
void blankPasses() {
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.filterContent((String) "", null));
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.filterContent((String) null, null));
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForDictSql(""));
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForDictSql(null));
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForOnlineReport(""));
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForOnlineReport(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("字典条件中含like的合法查询通过")
|
||||
void likePasses() {
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForDictSql("name like '%张%'"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("含数字比较的条件通过")
|
||||
void numericComparisonPasses() {
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForDictSql("age >= 18"));
|
||||
assertDoesNotThrow(() -> SqlInjectionUtil.specialFilterContentForDictSql("level != 0"));
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 既有攻击回归 ==========
|
||||
|
||||
@Nested
|
||||
@DisplayName("既有攻击向量回归 — 确保已有修复不被破坏")
|
||||
class ExistingAttackRegression {
|
||||
|
||||
@Test
|
||||
@DisplayName("【#9523】时间盲注 sleep/benchmark/pg_sleep/waitfor delay 仍拦截")
|
||||
void timeBlindStillBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1 and sleep(5)"));
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1 and benchmark(1000000,md5(1))"));
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1 and pg_sleep(5)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("【#9524】(extractvalue/(updatexml 报错注入仍拦截")
|
||||
void errorBasedStillBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql(
|
||||
"id=1 and (updatexml(1,concat(0x7e,(user())),1))"));
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql(
|
||||
"id=1 and (extractvalue(1,concat(0x7e,(user()))))"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("【#9571】database()/version()/ascii() 信息泄露函数仍拦截")
|
||||
void booleanBlindStillBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1 and database()='jeecg-boot'"));
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1 and version() like '8%'"));
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=ascii('a')"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("【#9572】select(/insert(/delete( 紧贴形式仍拦截")
|
||||
void keywordParenStillBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql(
|
||||
"id=(select(id)from(sys_user)where(username='admin'))"));
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("x=insert(1,2,3,'a')"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("SQL注释 -- 和 /**/ 仍拦截")
|
||||
void sqlCommentStillBlocked() {
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1-- "));
|
||||
assertThrows(RuntimeException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id=1 /*comment*/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("information_schema 仍拦截")
|
||||
void infoSchemaStillBlocked() {
|
||||
assertThrows(JeecgSqlInjectionException.class,
|
||||
() -> SqlInjectionUtil.specialFilterContentForDictSql("id in (select 1 from information_schema.tables)"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,222 @@
|
||||
package org.jeecg.test.security;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.FileDownloadUtils;
|
||||
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.times;
|
||||
|
||||
/**
|
||||
* 【issues/9681】SSRF 重定向绕过漏洞修复 (CWE-918) — 单元测试
|
||||
*
|
||||
* ── 漏洞 ──────────────────────────────────────────────────────────────────────
|
||||
* 修复前:download2DiskFromNet / getDownInputStream 仅在打开连接前对 fileUrl 做了
|
||||
* 一次 SsrfFileTypeFilter.checkSsrfHttpUrl(...),随后用 HttpURLConnection 打开连接,
|
||||
* 而 JDK 默认 followRedirects=true 会【自动跟随 3xx 重定向】。
|
||||
*
|
||||
* 攻击者:传入公网地址 http://attacker.com/x(首检通过),其服务器返回
|
||||
* 302 Location: http://127.0.0.1:xxx/... 或 http://169.254.169.254/latest/meta-data/。
|
||||
* JDK 自动跟随该跳且【不再复检】,于是访问到内网/云元数据 —— SSRF 成立。
|
||||
* 根因:被校验的 URL 与最终被访问的 URL 不一致(重定向逃逸)。
|
||||
*
|
||||
* ── 修复 ──────────────────────────────────────────────────────────────────────
|
||||
* openSafeConnection(...):setInstanceFollowRedirects(false) 关闭自动跳转,改为手动
|
||||
* 循环(最多 5 跳),对【初始 URL 及每一跳重定向目标】都重新 checkSsrfHttpUrl,
|
||||
* 相对 Location 解析为绝对地址后再校验,超过次数抛异常。
|
||||
*
|
||||
* ── 测试策略 ──────────────────────────────────────────────────────────────────
|
||||
* 1. 漏洞根因(用真实过滤器,无网络):证明"公网首检通过、内网目标会被拦",
|
||||
* 从而说明单次前置校验对重定向无效,必须逐跳复检。
|
||||
* 2. 修复行为(mockStatic + 本地 HttpServer):本地测试服在 127.0.0.1,会被真实
|
||||
* SSRF 过滤器拦截,故用 mockStatic 放行测试服,专注断言"每一跳都调用了
|
||||
* checkSsrfHttpUrl"以及"某一跳校验失败会整体抛异常 / 不会自动跟随"。
|
||||
*
|
||||
* @author wangshuai
|
||||
* @date 2026-06-17
|
||||
*/
|
||||
@ExtendWith(PrintTestResultExtension.class)
|
||||
public class Issue9681_SsrfRedirectBypassTest {
|
||||
|
||||
private static HttpServer server;
|
||||
private static String base;
|
||||
|
||||
@BeforeAll
|
||||
static void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
|
||||
// 302 相对跳转 /redirect -> /target,验证"相对 Location 解析 + 逐跳复检"
|
||||
server.createContext("/redirect", ex -> {
|
||||
ex.getResponseHeaders().add("Location", "/target");
|
||||
ex.sendResponseHeaders(302, -1);
|
||||
ex.close();
|
||||
});
|
||||
// 最终 200 资源
|
||||
server.createContext("/target", ex -> {
|
||||
byte[] body = "OK-BODY".getBytes(StandardCharsets.UTF_8);
|
||||
ex.sendResponseHeaders(200, body.length);
|
||||
ex.getResponseBody().write(body);
|
||||
ex.close();
|
||||
});
|
||||
// 直接 200,无重定向
|
||||
server.createContext("/direct", ex -> {
|
||||
byte[] body = "DIRECT".getBytes(StandardCharsets.UTF_8);
|
||||
ex.sendResponseHeaders(200, body.length);
|
||||
ex.getResponseBody().write(body);
|
||||
ex.close();
|
||||
});
|
||||
// 无限自跳,验证"最大重定向次数"保护
|
||||
server.createContext("/loop", ex -> {
|
||||
ex.getResponseHeaders().add("Location", "/loop");
|
||||
ex.sendResponseHeaders(302, -1);
|
||||
ex.close();
|
||||
});
|
||||
server.start();
|
||||
base = "http://127.0.0.1:" + server.getAddress().getPort();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopServer() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** 反射调用 private static FileDownloadUtils.openSafeConnection,并解包反射异常。 */
|
||||
private HttpURLConnection openSafeConnection(String url) throws Throwable {
|
||||
Method m = FileDownloadUtils.class.getDeclaredMethod("openSafeConnection", String.class);
|
||||
m.setAccessible(true);
|
||||
try {
|
||||
return (HttpURLConnection) m.invoke(null, url);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 一、漏洞根因:单次前置校验无法防住重定向(真实过滤器,无网络)
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("漏洞根因 —— 为什么单次前置校验会被重定向绕过")
|
||||
class RootCause {
|
||||
|
||||
@Test
|
||||
@DisplayName("攻击者提供的公网 URL 首检通过(旧逻辑只校验这一次)")
|
||||
void publicUrlPassesFirstCheck() {
|
||||
assertDoesNotThrow(() -> SsrfFileTypeFilter.checkSsrfHttpUrl("http://cdn.example.com/x"));
|
||||
System.out.println("[publicUrlPassesFirstCheck] 公网 http://cdn.example.com/x 首检通过(攻击者入口)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重定向目标(内网/元数据)若被复检则会拦截 —— 旧逻辑因自动跟随而漏检")
|
||||
void redirectTargetWouldBeBlockedIfRechecked() {
|
||||
// 这两个地址正是攻击者用 302 跳过去的目标;旧逻辑 JDK 自动跟随、不复检 → 绕过
|
||||
JeecgBootException e1 = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl("http://127.0.0.1:6379/"));
|
||||
JeecgBootException e2 = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl("http://169.254.169.254/latest/meta-data/"));
|
||||
System.out.println("[redirectTargetWouldBeBlockedIfRechecked] 127.0.0.1:6379 复检 -> " + e1.getMessage());
|
||||
System.out.println("[redirectTargetWouldBeBlockedIfRechecked] 169.254.169.254 复检 -> " + e2.getMessage());
|
||||
System.out.println("[redirectTargetWouldBeBlockedIfRechecked] 结论:校验的URL≠实际访问的URL,必须逐跳复检");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 二、修复行为:openSafeConnection 逐跳复检 + 关闭自动跳转
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("修复行为 —— openSafeConnection 对每一跳重定向都复检")
|
||||
class FixBehavior {
|
||||
|
||||
@Test
|
||||
@DisplayName("发生重定向时,初始 URL 与重定向目标都各被 checkSsrfHttpUrl 校验一次")
|
||||
void everyHopIsValidated() throws Throwable {
|
||||
java.util.List<String> checked = new java.util.ArrayList<>();
|
||||
try (MockedStatic<SsrfFileTypeFilter> mocked = mockStatic(SsrfFileTypeFilter.class)) {
|
||||
// 默认放行(void 方法 mock 后即 no-op),让本地测试服可达;同时记录每一跳被校验的 URL
|
||||
mocked.when(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(anyString()))
|
||||
.thenAnswer(inv -> { checked.add(inv.getArgument(0)); return null; });
|
||||
|
||||
HttpURLConnection conn = openSafeConnection(base + "/redirect");
|
||||
|
||||
System.out.println("[everyHopIsValidated] 被 SSRF 复检的每一跳 = " + checked);
|
||||
System.out.println("[everyHopIsValidated] 最终响应码 = " + conn.getResponseCode());
|
||||
|
||||
assertEquals(200, conn.getResponseCode(), "应手动跟随到最终 200 资源");
|
||||
|
||||
// 关键断言:初始跳与重定向目标都被复检 —— 证明没有走 JDK 自动跟随
|
||||
mocked.verify(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(base + "/redirect"), times(1));
|
||||
mocked.verify(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(base + "/target"), times(1));
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重定向目标被判定为危险地址时,整体抛 JeecgBootException(绕过被堵死)")
|
||||
void blockedRedirectTargetThrows() {
|
||||
try (MockedStatic<SsrfFileTypeFilter> mocked = mockStatic(SsrfFileTypeFilter.class)) {
|
||||
// 模拟:初始公网放行,但重定向目标(/target,代表内网)校验失败
|
||||
mocked.when(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(anyString())).thenAnswer(inv -> {
|
||||
String u = inv.getArgument(0);
|
||||
if (u.contains("/target")) {
|
||||
throw new JeecgBootException("非法URL:禁止访问本机或链路本地地址");
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> openSafeConnection(base + "/redirect"));
|
||||
System.out.println("[blockedRedirectTargetThrows] 重定向到内网被拦截 -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("禁止访问"),
|
||||
"应在跟随重定向前就因目标地址校验失败而中断");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无重定向的直连资源:仅校验一次并正常返回 200")
|
||||
void directResourceValidatedOnce() throws Throwable {
|
||||
try (MockedStatic<SsrfFileTypeFilter> mocked = mockStatic(SsrfFileTypeFilter.class)) {
|
||||
HttpURLConnection conn = openSafeConnection(base + "/direct");
|
||||
System.out.println("[directResourceValidatedOnce] 直连无重定向,响应码 = " + conn.getResponseCode());
|
||||
assertEquals(200, conn.getResponseCode());
|
||||
mocked.verify(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(base + "/direct"), times(1));
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重定向次数超过上限(5)时抛异常,防止恶意死循环跳转")
|
||||
void tooManyRedirectsThrows() {
|
||||
try (MockedStatic<SsrfFileTypeFilter> mocked = mockStatic(SsrfFileTypeFilter.class)) {
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> openSafeConnection(base + "/loop"));
|
||||
System.out.println("[tooManyRedirectsThrows] 死循环跳转被阻断 -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("重定向次数过多"));
|
||||
// 初始 + 5 次重定向 = 共 6 次校验(i 从 0 到 5)
|
||||
mocked.verify(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(anyString()), times(6));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
package org.jeecg.test.security;
|
||||
|
||||
import org.jeecg.common.constant.ServiceNameConstants;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.CommonUtils;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 【issues/9695】OpenAPI 转发 SSRF 漏洞修复 (CWE-918) — 单元测试
|
||||
*
|
||||
* ── 漏洞是怎么引起的 ──────────────────────────────────────────────────────────
|
||||
* OpenApiController#call(/openapi/call/{path}) 是一个"服务端转发"接口:它读取数据库里
|
||||
* 配置的 originUrl,由【服务器】自己发起 restTemplate.exchange(...) 去请求该地址,并把
|
||||
* 内部用户的 X-Access-Token 一并带上。
|
||||
*
|
||||
* 当 originUrl 是相对路径(如 /house/list)时,需要补一个 baseUrl 拼成完整地址。
|
||||
* baseUrl 由 CommonUtils.getBaseUrl(request) 计算,而该方法为兼容微服务网关,会直接信任
|
||||
* 请求头 X_GATEWAY_BASE_PATH 作为 base。
|
||||
*
|
||||
* 问题就在这里:X_GATEWAY_BASE_PATH 是【客户端可控的请求头】。修复前,攻击者只要发:
|
||||
* POST /openapi/call/xxx
|
||||
* X_GATEWAY_BASE_PATH: http://169.254.169.254 (或 http://attacker.com)
|
||||
* 服务器就会把转发目标拼成 http://169.254.169.254/<originUrl>,并由服务器主动访问,
|
||||
* 从而探测云元数据 / 内网服务 / 把携带内部 token 的请求打到攻击者站点 —— 典型 SSRF。
|
||||
* 根因:把"客户端可控的 header"当成"可信的服务端 base 地址"使用。
|
||||
*
|
||||
* ── 修复 ──────────────────────────────────────────────────────────────────────
|
||||
* 1) CommonUtils.validateGatewayBasePath(header):对 X_GATEWAY_BASE_PATH 做白名单校验——
|
||||
* 仅允许 http/https、禁止 userInfo(防 http://victim@evil 混淆)、host 不能为空,
|
||||
* 并【从解析后的 URI 组件重新拼接】返回,过滤掉注入字符;非法则返回 null 直接忽略该头。
|
||||
* 2) CommonUtils.checkInternalUrl(url):relative-path 转发场景下,再校验解析出的 baseUrl
|
||||
* host 必须是内网地址(回环/局域网/链路本地),解析到公网地址则抛 JeecgBootException。
|
||||
* SpringContextUtils.getDomain() 走同一套 validateGatewayBasePathForDomain 校验。
|
||||
*
|
||||
* ── 测试策略 ──────────────────────────────────────────────────────────────────
|
||||
* 校验逻辑都收敛在 CommonUtils 的 public static 方法里,可纯单测、无需 Spring / 无需联网
|
||||
* (全部使用 URI 解析与字面量 IP,不触发 DNS)。三组用例分别覆盖:
|
||||
* 一、入口复现:getBaseUrl 对恶意 X_GATEWAY_BASE_PATH 头不再信任;
|
||||
* 二、header 白名单:validateGatewayBasePath 的放行/拒绝边界;
|
||||
* 三、SSRF 兜底:checkInternalUrl 拦截公网地址、放行内网地址。
|
||||
*
|
||||
* @author wangshuai
|
||||
* @date 2026-06-17
|
||||
*/
|
||||
@ExtendWith(PrintTestResultExtension.class)
|
||||
public class Issue9695_OpenApiSsrfHeaderInjectionTest {
|
||||
|
||||
/** 构造带指定 X_GATEWAY_BASE_PATH 头的 mock 请求。 */
|
||||
private MockHttpServletRequest requestWithGatewayHeader(String headerValue) {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
// MockHttpServletRequest 默认 scheme=http, serverName=localhost, serverPort=80
|
||||
if (headerValue != null) {
|
||||
req.addHeader(ServiceNameConstants.X_GATEWAY_BASE_PATH, headerValue);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 一、入口复现:复刻 OpenApiController#call 对相对路径的两层防御
|
||||
// 第 1 层 getBaseUrl(...) —— header 格式白名单(协议/userInfo/host)
|
||||
// 第 2 层 checkInternalUrl(base) —— 解析出的 baseUrl 必须是内网地址
|
||||
// 注意:单看 getBaseUrl 并不足以挡住 http://attacker.com(格式合法),
|
||||
// 真正挡住公网外联的是随后那一句 checkInternalUrl —— 两层缺一不可。
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("漏洞入口 —— 复刻 controller 对相对路径转发的两层防御")
|
||||
class GetBaseUrlEntry {
|
||||
|
||||
/** 复刻 OpenApiController#call 中相对路径分支:getBaseUrl 后再 checkInternalUrl。 */
|
||||
private String resolveBaseUrlAsController(String gatewayHeader) {
|
||||
String baseUrl = CommonUtils.getBaseUrl(requestWithGatewayHeader(gatewayHeader));
|
||||
CommonUtils.checkInternalUrl(baseUrl); // controller 在拼接前的强制校验
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("【漏洞复现】注入公网地址 http://attacker.com:格式校验放过,但被 checkInternalUrl 拦死")
|
||||
void maliciousPublicGatewayHeaderBlockedByInternalCheck() {
|
||||
// getBaseUrl 只做格式白名单,attacker.com 格式合法 → 会被原样返回
|
||||
String baseUrl = CommonUtils.getBaseUrl(requestWithGatewayHeader("http://attacker.com"));
|
||||
assertEquals("http://attacker.com", baseUrl,
|
||||
"getBaseUrl 仅校验头部格式,不负责拦公网(这一层挡不住)");
|
||||
|
||||
// 真正的 SSRF 防线是 controller 紧接着的 checkInternalUrl
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> resolveBaseUrlAsController("http://attacker.com"));
|
||||
System.out.println("[maliciousPublicGatewayHeaderBlockedByInternalCheck] -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("内网"),
|
||||
"controller 完整流程下,注入公网网关头最终被 checkInternalUrl 拦截");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("【漏洞复现】带 userInfo 的混淆头(http://localhost@evil.com)在第 1 层就被忽略 → 回落本机")
|
||||
void userInfoObfuscationHeaderIsIgnored() {
|
||||
// userInfo 混淆地址连格式白名单都过不了 → validateGatewayBasePath 返回 null → 忽略该头
|
||||
String baseUrl = resolveBaseUrlAsController("http://localhost@evil.com");
|
||||
System.out.println("[userInfoObfuscationHeaderIsIgnored] 注入 http://localhost@evil.com -> baseUrl=" + baseUrl);
|
||||
|
||||
assertFalse(baseUrl.contains("evil.com"), "userInfo 混淆地址不得被采纳");
|
||||
assertTrue(baseUrl.startsWith("http://localhost"), "非法头被忽略后回落到当前服务自身地址");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("合法的内网网关头被采纳(保证微服务网关正常功能不被误伤)")
|
||||
void legitInternalGatewayHeaderIsAccepted() {
|
||||
String baseUrl = resolveBaseUrlAsController("http://127.0.0.1:8080/jeecg-boot");
|
||||
System.out.println("[legitInternalGatewayHeaderIsAccepted] baseUrl=" + baseUrl);
|
||||
|
||||
assertEquals("http://127.0.0.1:8080/jeecg-boot", baseUrl,
|
||||
"合法内网 http 网关头应通过两层校验并被规范化采纳");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 二、header 白名单:validateGatewayBasePath 的放行/拒绝边界
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("网关头白名单 —— validateGatewayBasePathForDomain")
|
||||
class GatewayHeaderWhitelist {
|
||||
|
||||
@Test
|
||||
@DisplayName("合法 http/https 地址:从 URI 组件重新拼接后返回")
|
||||
void validHttpUrlPasses() {
|
||||
assertEquals("http://10.0.0.5:9999/jeecg-boot",
|
||||
CommonUtils.validateGatewayBasePathForDomain("http://10.0.0.5:9999/jeecg-boot"));
|
||||
assertEquals("https://gateway.internal",
|
||||
CommonUtils.validateGatewayBasePathForDomain("https://gateway.internal"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非 http(s) 协议(file/gopher/...)一律拒绝 → 返回 null")
|
||||
void nonHttpSchemeRejected() {
|
||||
assertNull(CommonUtils.validateGatewayBasePathForDomain("file:///etc/passwd"));
|
||||
assertNull(CommonUtils.validateGatewayBasePathForDomain("gopher://127.0.0.1:6379/_xxx"));
|
||||
assertNull(CommonUtils.validateGatewayBasePathForDomain("ftp://host/x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("含 userInfo 的混淆地址拒绝 → 返回 null")
|
||||
void userInfoRejected() {
|
||||
assertNull(CommonUtils.validateGatewayBasePathForDomain("http://trusted.com@evil.com/"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空值 / 无 host / 非法字符 一律返回 null")
|
||||
void emptyOrMalformedReturnsNull() {
|
||||
assertNull(CommonUtils.validateGatewayBasePathForDomain(null));
|
||||
assertNull(CommonUtils.validateGatewayBasePathForDomain(""));
|
||||
assertNull(CommonUtils.validateGatewayBasePathForDomain("not a url"));
|
||||
assertNull(CommonUtils.validateGatewayBasePathForDomain("/only/path"));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 三、SSRF 兜底:checkInternalUrl 仅放行内网地址
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("SSRF 兜底 —— checkInternalUrl 仅允许内网地址")
|
||||
class CheckInternalUrl {
|
||||
|
||||
@Test
|
||||
@DisplayName("内网地址(回环/局域网)放行,不抛异常")
|
||||
void internalAddressesPass() {
|
||||
// 字面量 IP,不触发 DNS,离线可重复执行
|
||||
assertDoesNotThrow(() -> CommonUtils.checkInternalUrl("http://127.0.0.1:8080/jeecg-boot"));
|
||||
assertDoesNotThrow(() -> CommonUtils.checkInternalUrl("http://10.0.0.8/api"));
|
||||
assertDoesNotThrow(() -> CommonUtils.checkInternalUrl("http://192.168.1.20:9999/x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("【漏洞复现】公网地址被拦截 → 抛 JeecgBootException,堵死 SSRF 外联")
|
||||
void publicAddressIsBlocked() {
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> CommonUtils.checkInternalUrl("http://8.8.8.8/latest/meta-data/"));
|
||||
System.out.println("[publicAddressIsBlocked] 公网 8.8.8.8 被拦截 -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("内网"), "应明确提示仅允许内网地址");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("host 为空的非法 URL 被拦截")
|
||||
void emptyHostBlocked() {
|
||||
assertThrows(JeecgBootException.class, () -> CommonUtils.checkInternalUrl("/no/host/url"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,214 @@
|
||||
package org.jeecg.test.security;
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.FileDownloadUtils;
|
||||
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.times;
|
||||
|
||||
/**
|
||||
* 【issues/9725】uploadImgByHttp SSRF 重定向绕过漏洞修复 (CWE-918) — 单元测试
|
||||
*
|
||||
* ── 漏洞 ──────────────────────────────────────────────────────────────────────
|
||||
* 入口:POST /sys/common/uploadImgByHttp → HttpFileToMultipartFileUtil.httpFileToMultipartFile,
|
||||
* 修复前其内部 downloadImageData 仅在下载前对 fileUrl 做了一次 SsrfFileTypeFilter.checkSsrfHttpUrl,
|
||||
* 随后用 HttpURLConnection 打开连接,而 JDK 默认 followRedirects=true 会【自动跟随 3xx 重定向】且不再复检。
|
||||
*
|
||||
* 攻击者:传入公网地址 http://attacker.com/x(首检通过),其服务器返回
|
||||
* 302 Location: http://127.0.0.1:xxx/... 或 http://169.254.169.254/latest/meta-data/,
|
||||
* JDK 自动跟随该跳且不复检 → 访问到内网/云元数据,SSRF 成立。
|
||||
*
|
||||
* ── 修复 ──────────────────────────────────────────────────────────────────────
|
||||
* httpFileToMultipartFile 改为复用 FileDownloadUtils.download2BytesFromNet,其内部走
|
||||
* openSafeConnection(...):setInstanceFollowRedirects(false) 关闭自动跳转,改为手动循环(最多 5 跳),
|
||||
* 对【初始 URL 及每一跳重定向目标】都重新 checkSsrfHttpUrl,相对 Location 解析为绝对地址后再校验,
|
||||
* 超过次数抛异常;最终非 200 响应码抛 IOException。
|
||||
*
|
||||
* ── 测试策略 ──────────────────────────────────────────────────────────────────
|
||||
* 1. 漏洞根因(真实过滤器,无网络):证明"公网首检通过、内网目标会被拦",说明单次前置校验对重定向无效。
|
||||
* 2. 修复行为(mockStatic + 本地 HttpServer):本地测试服在 127.0.0.1 会被真实过滤器拦截,故用
|
||||
* mockStatic 放行测试服,专注断言"每一跳都复检""某跳失败整体抛异常""非200抛IOException""死循环被阻断"。
|
||||
*
|
||||
* @author wangshuai
|
||||
* @date 2026-06-29
|
||||
*/
|
||||
@ExtendWith(PrintTestResultExtension.class)
|
||||
public class Issue9725_UploadImgByHttpSsrfTest {
|
||||
|
||||
private static HttpServer server;
|
||||
private static String base;
|
||||
|
||||
@BeforeAll
|
||||
static void startServer() throws IOException {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
|
||||
// 302 相对跳转 /redirect -> /target,验证"相对 Location 解析 + 逐跳复检"
|
||||
server.createContext("/redirect", ex -> {
|
||||
ex.getResponseHeaders().add("Location", "/target");
|
||||
ex.sendResponseHeaders(302, -1);
|
||||
ex.close();
|
||||
});
|
||||
// 最终 200 资源
|
||||
server.createContext("/target", ex -> {
|
||||
byte[] body = "OK-BODY".getBytes(StandardCharsets.UTF_8);
|
||||
ex.sendResponseHeaders(200, body.length);
|
||||
ex.getResponseBody().write(body);
|
||||
ex.close();
|
||||
});
|
||||
// 直接 200,无重定向
|
||||
server.createContext("/direct", ex -> {
|
||||
byte[] body = "DIRECT".getBytes(StandardCharsets.UTF_8);
|
||||
ex.sendResponseHeaders(200, body.length);
|
||||
ex.getResponseBody().write(body);
|
||||
ex.close();
|
||||
});
|
||||
// 无限自跳,验证"最大重定向次数"保护
|
||||
server.createContext("/loop", ex -> {
|
||||
ex.getResponseHeaders().add("Location", "/loop");
|
||||
ex.sendResponseHeaders(302, -1);
|
||||
ex.close();
|
||||
});
|
||||
// 404,验证非 200 响应码抛 IOException
|
||||
server.createContext("/notfound", ex -> {
|
||||
ex.sendResponseHeaders(404, -1);
|
||||
ex.close();
|
||||
});
|
||||
server.start();
|
||||
base = "http://127.0.0.1:" + server.getAddress().getPort();
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
static void stopServer() {
|
||||
if (server != null) {
|
||||
server.stop(0);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 一、漏洞根因:单次前置校验无法防住重定向(真实过滤器,无网络)
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("漏洞根因 —— 为什么单次前置校验会被重定向绕过")
|
||||
class RootCause {
|
||||
|
||||
@Test
|
||||
@DisplayName("攻击者提供的公网 URL 首检通过(旧逻辑只校验这一次)")
|
||||
void publicUrlPassesFirstCheck() {
|
||||
assertDoesNotThrow(() -> SsrfFileTypeFilter.checkSsrfHttpUrl("http://cdn.example.com/poc.png"));
|
||||
System.out.println("[publicUrlPassesFirstCheck] 公网 http://cdn.example.com/poc.png 首检通过(攻击者入口)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重定向目标(内网/元数据)若被复检则会拦截 —— 旧逻辑因自动跟随而漏检")
|
||||
void redirectTargetWouldBeBlockedIfRechecked() {
|
||||
// 这两个地址正是攻击者用 302 跳过去的目标;旧逻辑 JDK 自动跟随、不复检 → 绕过
|
||||
JeecgBootException e1 = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl("http://127.0.0.1:6379/"));
|
||||
JeecgBootException e2 = assertThrows(JeecgBootException.class,
|
||||
() -> SsrfFileTypeFilter.checkSsrfHttpUrl("http://169.254.169.254/latest/meta-data/"));
|
||||
System.out.println("[redirectTargetWouldBeBlockedIfRechecked] 127.0.0.1:6379 复检 -> " + e1.getMessage());
|
||||
System.out.println("[redirectTargetWouldBeBlockedIfRechecked] 169.254.169.254 复检 -> " + e2.getMessage());
|
||||
System.out.println("[redirectTargetWouldBeBlockedIfRechecked] 结论:校验的URL≠实际访问的URL,必须逐跳复检");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 二、修复行为:download2BytesFromNet 逐跳复检 + 关闭自动跳转
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("修复行为 —— download2BytesFromNet 对每一跳重定向都复检")
|
||||
class FixBehavior {
|
||||
|
||||
@Test
|
||||
@DisplayName("发生重定向时,初始 URL 与重定向目标都各被 checkSsrfHttpUrl 校验一次,并返回最终资源字节")
|
||||
void everyHopIsValidatedAndBytesReturned() throws Exception {
|
||||
try (MockedStatic<SsrfFileTypeFilter> mocked = mockStatic(SsrfFileTypeFilter.class)) {
|
||||
// 默认放行(void 方法 mock 后即 no-op),让本地测试服可达
|
||||
byte[] bytes = FileDownloadUtils.download2BytesFromNet(base + "/redirect");
|
||||
|
||||
System.out.println("[everyHopIsValidatedAndBytesReturned] 最终下载内容 = " + new String(bytes, StandardCharsets.UTF_8));
|
||||
assertArrayEquals("OK-BODY".getBytes(StandardCharsets.UTF_8), bytes, "应手动跟随到最终 200 资源并返回其字节");
|
||||
|
||||
// 关键断言:初始跳与重定向目标都被复检 —— 证明没有走 JDK 自动跟随
|
||||
mocked.verify(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(base + "/redirect"), times(1));
|
||||
mocked.verify(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(base + "/target"), times(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重定向目标被判定为危险地址时,整体抛 JeecgBootException(绕过被堵死)")
|
||||
void blockedRedirectTargetThrows() {
|
||||
try (MockedStatic<SsrfFileTypeFilter> mocked = mockStatic(SsrfFileTypeFilter.class)) {
|
||||
// 模拟:初始公网放行,但重定向目标(/target,代表内网)校验失败
|
||||
mocked.when(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(anyString())).thenAnswer(inv -> {
|
||||
String u = inv.getArgument(0);
|
||||
if (u.contains("/target")) {
|
||||
throw new JeecgBootException("非法URL:禁止访问本机或链路本地地址");
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> FileDownloadUtils.download2BytesFromNet(base + "/redirect"));
|
||||
System.out.println("[blockedRedirectTargetThrows] 重定向到内网被拦截 -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("禁止访问"),
|
||||
"应在跟随重定向前就因目标地址校验失败而中断");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无重定向的直连资源:仅校验一次并正常返回字节")
|
||||
void directResourceValidatedOnce() throws Exception {
|
||||
try (MockedStatic<SsrfFileTypeFilter> mocked = mockStatic(SsrfFileTypeFilter.class)) {
|
||||
byte[] bytes = FileDownloadUtils.download2BytesFromNet(base + "/direct");
|
||||
System.out.println("[directResourceValidatedOnce] 直连无重定向,内容 = " + new String(bytes, StandardCharsets.UTF_8));
|
||||
assertArrayEquals("DIRECT".getBytes(StandardCharsets.UTF_8), bytes);
|
||||
mocked.verify(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(base + "/direct"), times(1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("最终响应码非 200(如 404)时抛 IOException")
|
||||
void non200ResponseThrowsIOException() {
|
||||
try (MockedStatic<SsrfFileTypeFilter> mocked = mockStatic(SsrfFileTypeFilter.class)) {
|
||||
IOException ex = assertThrows(IOException.class,
|
||||
() -> FileDownloadUtils.download2BytesFromNet(base + "/notfound"));
|
||||
System.out.println("[non200ResponseThrowsIOException] 非200被拒 -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("HTTP请求失败"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重定向次数超过上限(5)时抛异常,防止恶意死循环跳转")
|
||||
void tooManyRedirectsThrows() {
|
||||
try (MockedStatic<SsrfFileTypeFilter> mocked = mockStatic(SsrfFileTypeFilter.class)) {
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> FileDownloadUtils.download2BytesFromNet(base + "/loop"));
|
||||
System.out.println("[tooManyRedirectsThrows] 死循环跳转被阻断 -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("重定向次数过多"));
|
||||
// 初始 + 5 次重定向 = 共 6 次校验(i 从 0 到 5)
|
||||
mocked.verify(() -> SsrfFileTypeFilter.checkSsrfHttpUrl(anyString()), times(6));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
package org.jeecg.test.security;
|
||||
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.CommonUtils;
|
||||
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 【issues/9726】OpenAPI originUrl 存储型 SSRF 漏洞修复 (CWE-918) — 单元测试
|
||||
*
|
||||
* ── 漏洞是怎么引起的 ──────────────────────────────────────────────────────────
|
||||
* OpenApiController#call(/openapi/call/{path}) 是一个"服务端转发"接口:它读取数据库里
|
||||
* 配置的 originUrl,由【服务器】自己发起 restTemplate.exchange(...) 去请求该地址。
|
||||
* originUrl 由管理员通过 POST /openapi/add、PUT /openapi/edit 写入并持久化(存储型)。
|
||||
*
|
||||
* 自 issues/9590 起,originUrl 允许填写完整的 http(s)://host URL(用于微服务跨模块调用,
|
||||
* 如部署在 erp 7003 的接口)。修复前 validOriginUrl 的【完整URL分支】只校验了协议——
|
||||
* 禁止 file/ftp/gopher/jar/netdoc,却【完全不限制 host】。于是管理员可存入:
|
||||
* originUrl = http://169.254.169.254/latest/meta-data/ (云元数据)
|
||||
* originUrl = http://127.0.0.1:6379/ (本机 redis 等内网服务)
|
||||
* originUrl = http://attacker.com/ (任意公网,把服务器当代理)
|
||||
* 再以合法 OpenAPI 凭证触发 /openapi/call/{path},服务器即主动访问该地址 —— 存储型 SSRF。
|
||||
* 根因:完整URL分支"校验协议、放过主机",被校验的内容覆盖不到真正危险的 host。
|
||||
*
|
||||
* ── 修复 ──────────────────────────────────────────────────────────────────────
|
||||
* 在 validOriginUrl 完整URL分支补两套互补的 host 校验(两者交集才放行):
|
||||
* 1) SsrfFileTypeFilter.checkSsrfHttpUrl(url):拦回环(127.x/::1)与链路本地
|
||||
* (169.254.x,含云元数据 169.254.169.254);放行 RFC1918 与公网。
|
||||
* 2) CommonUtils.checkInternalUrl(url):拦公网地址;放行回环/局域网/链路本地。
|
||||
* 取交集后【仅 RFC1918 内网地址可通过】——既保留微服务跨模块内网调用,又同时堵死
|
||||
* 回环、链路本地/元数据、公网三类 SSRF 目标。单独任一方法都不足以覆盖(见各自反例)。
|
||||
*
|
||||
* ── 测试策略 ──────────────────────────────────────────────────────────────────
|
||||
* 校验逻辑都在 public static 方法里,纯单测、无需 Spring / 无需联网(全部用字面量 IP,
|
||||
* 不触发 DNS)。下面用 validateFullOriginUrl 复刻 controller 完整URL分支的两步校验,
|
||||
* 对四类目标分别断言放行/拦截。
|
||||
*
|
||||
* @author liusq
|
||||
* @date 2026-06-29
|
||||
*/
|
||||
@ExtendWith(PrintTestResultExtension.class)
|
||||
public class Issue9726_OpenApiOriginUrlSsrfTest {
|
||||
|
||||
/**
|
||||
* 复刻 OpenApiController#validOriginUrl 完整URL分支在修复后执行的两步 host 校验。
|
||||
* 任一步抛 JeecgBootException 即视为该 URL 被拦截。
|
||||
*/
|
||||
private void validateFullOriginUrl(String originUrl) {
|
||||
SsrfFileTypeFilter.checkSsrfHttpUrl(originUrl); // 拦回环 + 链路本地(含元数据)
|
||||
CommonUtils.checkInternalUrl(originUrl); // 拦公网
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 一、漏洞复现:四类危险目标在修复后都被拦截
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("漏洞复现 —— 修复后存入完整URL的各类 SSRF 目标均被拦死")
|
||||
class MaliciousTargetsBlocked {
|
||||
|
||||
@Test
|
||||
@DisplayName("【元数据】云元数据端点 169.254.169.254 被拦截(checkSsrfHttpUrl 兜底)")
|
||||
void cloudMetadataBlocked() {
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> validateFullOriginUrl("http://169.254.169.254/latest/meta-data/"));
|
||||
System.out.println("[cloudMetadataBlocked] 169.254.169.254 -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("链路本地") || ex.getMessage().contains("本机"),
|
||||
"云元数据属链路本地地址,应被 checkSsrfHttpUrl 拦截");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("【回环】本机服务 127.0.0.1:6379 被拦截(checkSsrfHttpUrl 兜底)")
|
||||
void loopbackBlocked() {
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> validateFullOriginUrl("http://127.0.0.1:6379/"));
|
||||
System.out.println("[loopbackBlocked] 127.0.0.1:6379 -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("本机") || ex.getMessage().contains("链路本地"),
|
||||
"回环地址应被 checkSsrfHttpUrl 拦截");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("【公网】任意公网地址 8.8.8.8 被拦截(checkInternalUrl 兜底)")
|
||||
void publicAddressBlocked() {
|
||||
JeecgBootException ex = assertThrows(JeecgBootException.class,
|
||||
() -> validateFullOriginUrl("http://8.8.8.8/x"));
|
||||
System.out.println("[publicAddressBlocked] 8.8.8.8 -> " + ex.getMessage());
|
||||
assertTrue(ex.getMessage().contains("内网"),
|
||||
"公网地址应被 checkInternalUrl 拦截,仅允许内网");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 二、合法功能不被误伤:微服务跨模块内网(RFC1918)调用放行
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("合法功能 —— 微服务跨模块内网(RFC1918)地址正常放行")
|
||||
class LegitInternalTargetsPass {
|
||||
|
||||
@Test
|
||||
@DisplayName("RFC1918 内网地址(10/172.16/192.168)放行,不抛异常")
|
||||
void rfc1918AddressesPass() {
|
||||
assertDoesNotThrow(() -> validateFullOriginUrl("http://10.0.0.8:7003/erp/order/list"));
|
||||
assertDoesNotThrow(() -> validateFullOriginUrl("http://172.16.5.20:8080/api"));
|
||||
assertDoesNotThrow(() -> validateFullOriginUrl("http://192.168.1.30:9999/house/list"));
|
||||
System.out.println("[rfc1918AddressesPass] RFC1918 内网地址全部放行,微服务跨模块调用不受影响");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================================================================
|
||||
// 三、为什么必须两套校验【组合】—— 单独任一方法都有漏网之鱼
|
||||
// ==================================================================
|
||||
|
||||
@Nested
|
||||
@DisplayName("互补性证明 —— 单独任一校验都不足以覆盖全部 SSRF 目标")
|
||||
class WhyBothChecksNeeded {
|
||||
|
||||
@Test
|
||||
@DisplayName("仅 checkInternalUrl 不够:它把链路本地视为内网,会放过云元数据 169.254.169.254")
|
||||
void internalCheckAloneLeaksMetadata() {
|
||||
// checkInternalUrl 单独使用时,链路本地地址被当作"内网"放行 —— 这正是必须叠加
|
||||
// checkSsrfHttpUrl 的原因
|
||||
assertDoesNotThrow(() -> CommonUtils.checkInternalUrl("http://169.254.169.254/latest/meta-data/"));
|
||||
System.out.println("[internalCheckAloneLeaksMetadata] 仅 checkInternalUrl 会放过云元数据 → 需 checkSsrfHttpUrl 兜底");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("仅 checkSsrfHttpUrl 不够:它放行公网,会把服务器当作访问任意公网的代理")
|
||||
void ssrfCheckAloneLeaksPublic() {
|
||||
// checkSsrfHttpUrl 单独使用时,公网地址被放行 —— 这正是必须叠加 checkInternalUrl 的原因
|
||||
assertDoesNotThrow(() -> SsrfFileTypeFilter.checkSsrfHttpUrl("http://8.8.8.8/x"));
|
||||
System.out.println("[ssrfCheckAloneLeaksPublic] 仅 checkSsrfHttpUrl 会放过公网地址 → 需 checkInternalUrl 兜底");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package org.jeecg.test.security;
|
||||
|
||||
import org.junit.jupiter.api.extension.ExtensionContext;
|
||||
import org.junit.jupiter.api.extension.TestWatcher;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 通用测试结果打印扩展:每个用例执行结束后自动打印一行结果,
|
||||
* 解决"测试通过时控制台静默、看起来像没跑"的困惑。
|
||||
*
|
||||
* 用法:在测试类上加 @ExtendWith(PrintTestResultExtension.class)。
|
||||
* 输出形如: [PASS] 发生重定向时,初始 URL 与重定向目标都各被 checkSsrfHttpUrl 校验一次
|
||||
*
|
||||
* 注:状态标记用 ASCII,避免 Windows GBK 控制台乱码;用例名(@DisplayName 中文)
|
||||
* 在 IDE 的 UTF-8 控制台可正常显示。
|
||||
*
|
||||
* @author wangshuai
|
||||
* @date 2026-06-17
|
||||
*/
|
||||
public class PrintTestResultExtension implements TestWatcher {
|
||||
|
||||
@Override
|
||||
public void testSuccessful(ExtensionContext context) {
|
||||
System.out.println(" [PASS] " + context.getDisplayName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void testFailed(ExtensionContext context, Throwable cause) {
|
||||
System.out.println(" [FAIL] " + context.getDisplayName()
|
||||
+ " -> " + (cause == null ? "" : cause.getMessage()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void testAborted(ExtensionContext context, Throwable cause) {
|
||||
System.out.println(" [ABORTED] " + context.getDisplayName());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void testDisabled(ExtensionContext context, Optional<String> reason) {
|
||||
System.out.println(" [SKIP] " + context.getDisplayName()
|
||||
+ reason.map(r -> " (" + r + ")").orElse(""));
|
||||
}
|
||||
}
|
||||
@ -31,10 +31,9 @@
|
||||
</repositories>
|
||||
|
||||
<properties>
|
||||
<kotlin.version>2.2.0</kotlin.version>
|
||||
<liteflow.version>2.15.0</liteflow.version>
|
||||
<apache-tika.version>3.3.1</apache-tika.version>
|
||||
<langchain4j-community-bom.version>1.17.2-beta27</langchain4j-community-bom.version>
|
||||
<liteflow.version>2.15.0</liteflow.version>
|
||||
</properties>
|
||||
|
||||
<dependencyManagement>
|
||||
@ -72,27 +71,11 @@
|
||||
<artifactId>jeecg-system-cloud-api</artifactId>
|
||||
</dependency>-->
|
||||
|
||||
<!-- aiflow依赖 -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-aiflow-boot4</artifactId>
|
||||
<version>3.9.2</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>commons-io</groupId>
|
||||
<artifactId>commons-io</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>commons-beanutils</groupId>
|
||||
<artifactId>commons-beanutils</artifactId>
|
||||
</exclusion>
|
||||
<exclusion>
|
||||
<groupId>com.yomahub</groupId>
|
||||
<artifactId>liteflow-script-python</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
<version>3.9.5</version>
|
||||
</dependency>
|
||||
|
||||
<!-- begin 注意:这几个依赖体积较大,每个约50MB。若发布时需要使用,请将 <scope>provided</scope> 删除 -->
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
@ -106,46 +89,8 @@
|
||||
<version>${liteflow.version}</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yomahub</groupId>
|
||||
<artifactId>liteflow-script-groovy</artifactId>
|
||||
<version>${liteflow.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- end 注意:这几个依赖体积较大,每个约50MB。若发布时需要使用,请将 <scope>provided</scope> 删除 -->
|
||||
|
||||
<!-- aiflow 脚本依赖 -->
|
||||
<dependency>
|
||||
<groupId>com.yomahub</groupId>
|
||||
<artifactId>liteflow-script-python</artifactId>
|
||||
<version>${liteflow.version}</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yomahub</groupId>
|
||||
<artifactId>liteflow-script-kotlin</artifactId>
|
||||
<version>${liteflow.version}</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-scripting-jsr223</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.yomahub</groupId>
|
||||
<artifactId>liteflow-script-aviator</artifactId>
|
||||
<version>${liteflow.version}</version>
|
||||
<scope>runtime</scope>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<artifactId>aviator</artifactId>
|
||||
<groupId>com.googlecode.aviator</groupId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<!-- aiflow 脚本依赖 -->
|
||||
|
||||
<!-- langChain4j model support -->
|
||||
<dependency>
|
||||
@ -262,13 +207,6 @@
|
||||
<dependency>
|
||||
<groupId>com.deepoove</groupId>
|
||||
<artifactId>poi-tl</artifactId>
|
||||
<version>1.12.2</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<artifactId>poi-ooxml</artifactId>
|
||||
<groupId>org.apache.poi</groupId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<!-- jsoup HTML parser library @ https://jsoup.org/ -->
|
||||
<dependency>
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
package org.jeecg.modules.airag.api;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.airag.api.IAiragBaseApi;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
@ -54,6 +60,74 @@ public class AiragBaseApiImpl implements IAiragBaseApi {
|
||||
return knowledgeDoc.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String checkKnowledgeDocsVectorizeStatus(String documentIds) {
|
||||
if (oConvertUtils.isEmpty(documentIds)) {
|
||||
return "COMPLETED";
|
||||
}
|
||||
List<String> idList = new ArrayList<>();
|
||||
for (String id : documentIds.split(",")) {
|
||||
String trimmed = id == null ? "" : id.trim();
|
||||
if (oConvertUtils.isNotEmpty(trimmed)) {
|
||||
idList.add(trimmed);
|
||||
}
|
||||
}
|
||||
if (idList.isEmpty()) {
|
||||
return "COMPLETED";
|
||||
}
|
||||
List<AiragKnowledgeDoc> docs = airagKnowledgeDocService.listByIds(idList);
|
||||
boolean hasFailed = false;
|
||||
boolean hasProcessing = false;
|
||||
Set<String> foundIds = new HashSet<>();
|
||||
for (AiragKnowledgeDoc doc : docs) {
|
||||
foundIds.add(doc.getId());
|
||||
String status = doc.getStatus();
|
||||
if (LLMConsts.KNOWLEDGE_DOC_STATUS_FAILED.equals(status)) {
|
||||
hasFailed = true;
|
||||
} else if (!LLMConsts.KNOWLEDGE_DOC_STATUS_COMPLETE.equals(status)) {
|
||||
hasProcessing = true;
|
||||
}
|
||||
}
|
||||
// 未查到的文档视为失败(可能被删除)
|
||||
for (String id : idList) {
|
||||
if (!foundIds.contains(id)) {
|
||||
hasFailed = true;
|
||||
}
|
||||
}
|
||||
if (hasProcessing) {
|
||||
return hasFailed ? "PROCESSING_WITH_FAIL" : "PROCESSING";
|
||||
}
|
||||
return hasFailed ? "COMPLETED_WITH_FAIL" : "COMPLETED";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String knowledgeWriteFileDocument(String knowledgeId, String title, String filePath, String segmentConfig) {
|
||||
AssertUtils.assertNotEmpty("知识库ID不能为空", knowledgeId);
|
||||
AssertUtils.assertNotEmpty("文件地址不能为空", filePath);
|
||||
AiragKnowledgeDoc knowledgeDoc = new AiragKnowledgeDoc();
|
||||
knowledgeDoc.setKnowledgeId(knowledgeId);
|
||||
knowledgeDoc.setTitle(title);
|
||||
knowledgeDoc.setType(LLMConsts.KNOWLEDGE_DOC_TYPE_FILE);
|
||||
// 文件类型文档将 filePath 放入 metadata,复用知识库文档功能的存储约定
|
||||
JSONObject metadata;
|
||||
if (oConvertUtils.isNotEmpty(segmentConfig)) {
|
||||
metadata = JSONObject.parseObject(segmentConfig);
|
||||
} else {
|
||||
metadata = new JSONObject();
|
||||
}
|
||||
metadata.put("filePath", filePath);
|
||||
knowledgeDoc.setMetadata(metadata.toJSONString());
|
||||
Result<?> result = airagKnowledgeDocService.editDocument(knowledgeDoc);
|
||||
if (!result.isSuccess()) {
|
||||
throw new JeecgBootBizTipException(result.getMessage());
|
||||
}
|
||||
if (knowledgeDoc.getId() == null) {
|
||||
throw new JeecgBootBizTipException("知识库文档ID为空");
|
||||
}
|
||||
log.info("[AI-KNOWLEDGE] 文件文档写入完成,知识库:{}, 文档ID:{}, 文件:{}", knowledgeId, knowledgeDoc.getId(), filePath);
|
||||
return knowledgeDoc.getId();
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private IAiragAppService airagAppService;
|
||||
|
||||
|
||||
@ -27,6 +27,10 @@ public class AiAppConsts {
|
||||
*/
|
||||
public static final String DEFAULT_APP_ID = "default";
|
||||
|
||||
/**
|
||||
* 未保存应用的调试应用id
|
||||
*/
|
||||
public static final String DEBUG_APP_ID = "__DEBUG_APP";
|
||||
|
||||
/**
|
||||
* 应用类型:简单聊天
|
||||
|
||||
@ -118,22 +118,22 @@ public class Prompts {
|
||||
"4. 语气专业、清晰、指令性强。\n" +
|
||||
"5. 说明内容请使用中文。\n\n";
|
||||
|
||||
/**
|
||||
* 变量生成提示词
|
||||
*/
|
||||
public static final String GENERATE_VAR_PART = "## 任务:生成变量使用指南\n" +
|
||||
"### 输入信息\n" +
|
||||
"**变量列表**:\n" +
|
||||
"%s\n" +
|
||||
"### 要求\n" +
|
||||
"1. 请生成一段**变量使用指南**。\n" +
|
||||
"2. **遍历生成**:请遍历【输入信息】中的所有变量,为**每一个**变量生成一条具体的使用指南。\n" +
|
||||
"3. **格式要求**:请仿照以下句式,根据变量的实际含义生成(确保包含{{变量名}}):\n" +
|
||||
" 例如:针对name变量 -> “回复问题时,请称呼你的用户为{{name}}。”\n" +
|
||||
" 例如:针对age变量 -> “用户的年龄是{{age}},请在对话中适时使用。”\n" +
|
||||
" 例如:针对其他变量 -> “用户的[变量描述]是{{[变量名]}},请在对话中适时使用。”\n" +
|
||||
"4. **通用更新指令**:请在变量指南的最后,单独生成一条指令,明确指示AI:“当从用户对话中获取到上述变量(<列出所有变量名,用顿号分隔>)的**新信息**时,**必须立即调用** `update_variable` 工具进行存储。**注意**:调用前请检查上下文,如果已调用过该工具或变量值未改变,**严禁**重复调用。”\n" +
|
||||
"5. **保留原文**:如果输入信息中包含具体的行为指令(如“回复问题时,请称呼你的用户为{{name}}”),请在生成的指南中**直接引用原文**,不要进行改写或格式化,以免改变用户的原意。\n\n";
|
||||
// /**
|
||||
// * 变量生成提示词
|
||||
// */
|
||||
// public static final String GENERATE_VAR_PART = "## 任务:生成变量使用指南\n" +
|
||||
// "### 输入信息\n" +
|
||||
// "**变量列表**:\n" +
|
||||
// "%s\n" +
|
||||
// "### 要求\n" +
|
||||
// "1. 请生成一段**变量使用指南**。\n" +
|
||||
// "2. **遍历生成**:请遍历【输入信息】中的所有变量,为**每一个**变量生成一条具体的使用指南。\n" +
|
||||
// "3. **格式要求**:请仿照以下句式,根据变量的实际含义生成(确保包含{{变量名}}):\n" +
|
||||
// " 例如:针对name变量 -> “回复问题时,请称呼你的用户为{{name}}。”\n" +
|
||||
// " 例如:针对age变量 -> “用户的年龄是{{age}},请在对话中适时使用。”\n" +
|
||||
// " 例如:针对其他变量 -> “用户的[变量描述]是{{[变量名]}},请在对话中适时使用。”\n" +
|
||||
// "4. **通用更新指令**:请在变量指南的最后,单独生成一条指令,明确指示AI:“当从用户对话中获取到上述变量(<列出所有变量名,用顿号分隔>)的**新信息**时,**必须立即调用** `update_variable` 工具进行存储。**注意**:调用前请检查上下文,如果已调用过该工具或变量值未改变,**严禁**重复调用。”\n" +
|
||||
// "5. **保留原文**:如果输入信息中包含具体的行为指令(如“回复问题时,请称呼你的用户为{{name}}”),请在生成的指南中**直接引用原文**,不要进行改写或格式化,以免改变用户的原意。\n\n";
|
||||
|
||||
/**
|
||||
* 记忆库生成提示词
|
||||
|
||||
@ -10,8 +10,8 @@ import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.jeecg.common.system.query.QueryGenerator;
|
||||
import org.jeecg.common.util.AssertUtils;
|
||||
import org.jeecg.common.util.TokenUtils;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.config.mybatis.MybatisPlusSaasConfig;
|
||||
import org.jeecg.config.shiro.IgnoreAuth;
|
||||
import org.jeecg.modules.airag.app.consts.AiAppConsts;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
import org.jeecg.modules.airag.app.service.IAiragAppService;
|
||||
@ -54,6 +54,7 @@ public class AiragAppController extends JeecgController<AiragApp, IAiragAppServi
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/list")
|
||||
@RequiresPermissions("airag:app:list")
|
||||
public Result<IPage<AiragApp>> queryPageList(AiragApp airagApp,
|
||||
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
|
||||
@ -114,26 +115,52 @@ public class AiragAppController extends JeecgController<AiragApp, IAiragAppServi
|
||||
return Result.OK("保存完成!", airagApp.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制应用
|
||||
*
|
||||
* @param id 原应用ID
|
||||
* @param request HTTP请求
|
||||
* @return 新应用ID
|
||||
* @author scott
|
||||
* @since 2026-08-06 【LHZP-1512】AI应用增加复制功能
|
||||
*/
|
||||
@PostMapping(value = "/copy")
|
||||
@RequiresPermissions("airag:app:edit")
|
||||
public Result<String> copy(@RequestParam(name = "id") String id, HttpServletRequest request) {
|
||||
AssertUtils.assertNotEmpty("id必须填写", id);
|
||||
String copiedAppId = airagAppService.copyApp(id, TokenUtils.getTenantIdByRequest(request));
|
||||
return Result.OK("复制成功", copiedAppId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布应用
|
||||
*
|
||||
* @return
|
||||
*/
|
||||
@RequestMapping(value = "/release", method = RequestMethod.POST)
|
||||
public Result<String> release(@RequestParam(name = "id") String id, @RequestParam(name = "release") Boolean release) {
|
||||
public Result<String> release(@RequestParam(name = "id") String id,
|
||||
@RequestParam(name = "release") Boolean release,
|
||||
HttpServletRequest request) {
|
||||
AssertUtils.assertNotEmpty("id必须填写", id);
|
||||
if (release == null) {
|
||||
release = true;
|
||||
}
|
||||
AiragApp airagApp = new AiragApp();
|
||||
airagApp.setId(id);
|
||||
if (release) {
|
||||
airagApp.setStatus(AiAppConsts.STATUS_RELEASE);
|
||||
} else {
|
||||
airagApp.setStatus(AiAppConsts.STATUS_ENABLE);
|
||||
AiragApp app = airagAppService.getById(id);
|
||||
if (app == null) {
|
||||
return Result.error("应用不存在");
|
||||
}
|
||||
airagAppService.updateById(airagApp);
|
||||
return Result.OK(release ? "发布成功" : "取消发布成功");
|
||||
// SaaS 多租户隔离:禁止跨租户发布/取消发布
|
||||
if (MybatisPlusSaasConfig.OPEN_SYSTEM_TENANT_CONTROL) {
|
||||
String currentTenantId = TokenUtils.getTenantIdByRequest(request);
|
||||
if (oConvertUtils.isEmpty(app.getTenantId()) || !app.getTenantId().equals(currentTenantId)) {
|
||||
return Result.error("操作失败,不能操作其他租户的AI应用!");
|
||||
}
|
||||
}
|
||||
String shareToken = airagAppService.releaseApp(id, release);
|
||||
// result 固定返回 shareToken(取消发布为 null),避免与提示文案混用
|
||||
Result<String> result = Result.OK(shareToken);
|
||||
result.setMessage(release ? "发布成功" : "取消发布成功");
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -166,7 +193,6 @@ public class AiragAppController extends JeecgController<AiragApp, IAiragAppServi
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<AiragApp> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
AiragApp airagApp = airagAppService.getById(id);
|
||||
|
||||
@ -3,15 +3,20 @@ package org.jeecg.modules.airag.app.controller;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.constant.CommonConstant;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.CommonUtils;
|
||||
import org.jeecg.config.shiro.IgnoreAuth;
|
||||
import org.jeecg.modules.airag.app.service.IAiragChatService;
|
||||
import org.jeecg.modules.airag.app.service.impl.AiragChatRateLimitService;
|
||||
import org.jeecg.modules.airag.app.vo.AiDrawGenerateVo;
|
||||
import org.jeecg.modules.airag.app.vo.AiWriteGenerateVo;
|
||||
import org.jeecg.modules.airag.app.vo.ChatConversation;
|
||||
import org.jeecg.modules.airag.app.vo.ChatSendParams;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventData;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventFlowData;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
@ -19,6 +24,8 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
|
||||
|
||||
/**
|
||||
@ -35,6 +42,9 @@ public class AiragChatController {
|
||||
@Autowired
|
||||
IAiragChatService chatService;
|
||||
|
||||
@Autowired
|
||||
AiragChatRateLimitService rateLimitService;
|
||||
|
||||
@Value(value = "${jeecg.path.upload}")
|
||||
private String uploadpath;
|
||||
|
||||
@ -54,8 +64,21 @@ public class AiragChatController {
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@PostMapping(value = "/send")
|
||||
public SseEmitter send(@RequestBody ChatSendParams chatSendParams) {
|
||||
return chatService.send(chatSendParams);
|
||||
public SseEmitter send(@RequestBody ChatSendParams chatSendParams, HttpServletRequest request) {
|
||||
try {
|
||||
rateLimitService.checkSendLimit(request);
|
||||
} catch (Exception e) {
|
||||
log.warn("AI聊天发送接口限流: {}", e.getMessage());
|
||||
return buildErrorEmitter(e.getMessage());
|
||||
}
|
||||
//update-begin---author:scott ---date:20260721 for:【issues/9787】匿名访问校验失败按SSE错误协议返回-----------
|
||||
try {
|
||||
return chatService.send(chatSendParams);
|
||||
} catch (JeecgBootException e) {
|
||||
log.warn("AI聊天发送接口访问校验失败: {}", e.getMessage());
|
||||
return buildErrorEmitter(e.getMessage());
|
||||
}
|
||||
//update-end---author:scott ---date:20260721 for:【issues/9787】匿名访问校验失败按SSE错误协议返回-----------
|
||||
}
|
||||
|
||||
/**
|
||||
@ -73,9 +96,45 @@ public class AiragChatController {
|
||||
public SseEmitter sendByGet(@RequestParam("content") String content,
|
||||
@RequestParam(value = "conversationId", required = false) String conversationId,
|
||||
@RequestParam(value = "topicId", required = false) String topicId,
|
||||
@RequestParam(value = "appId", required = false) String appId) {
|
||||
@RequestParam(value = "appId", required = false) String appId,
|
||||
@RequestParam(value = "shareToken", required = false) String shareToken,
|
||||
HttpServletRequest request) {
|
||||
try {
|
||||
rateLimitService.checkSendLimit(request);
|
||||
} catch (Exception e) {
|
||||
log.warn("AI聊天发送接口限流: {}", e.getMessage());
|
||||
return buildErrorEmitter(e.getMessage());
|
||||
}
|
||||
ChatSendParams chatSendParams = new ChatSendParams(content, conversationId, topicId, appId);
|
||||
return chatService.send(chatSendParams);
|
||||
chatSendParams.setShareToken(shareToken);
|
||||
//update-begin---author:scott ---date:20260721 for:【issues/9787】兼容GET发送接口的匿名访问校验错误协议-----------
|
||||
try {
|
||||
return chatService.send(chatSendParams);
|
||||
} catch (JeecgBootException e) {
|
||||
log.warn("AI聊天GET发送接口访问校验失败: {}", e.getMessage());
|
||||
return buildErrorEmitter(e.getMessage());
|
||||
}
|
||||
//update-end---author:scott ---date:20260721 for:【issues/9787】兼容GET发送接口的匿名访问校验错误协议-----------
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造聊天错误 SSE 响应
|
||||
*
|
||||
* @param errorMessage 错误消息
|
||||
* @return SseEmitter
|
||||
*/
|
||||
private SseEmitter buildErrorEmitter(String errorMessage) {
|
||||
SseEmitter emitter = new SseEmitter(0L);
|
||||
String requestId = UUID.randomUUID().toString();
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR);
|
||||
eventData.setData(EventFlowData.builder().success(false).message(errorMessage).build());
|
||||
try {
|
||||
emitter.send(SseEmitter.event().data(JSONObject.toJSONString(eventData)));
|
||||
} catch (Exception e) {
|
||||
log.error("发送聊天错误SSE事件失败", e);
|
||||
}
|
||||
emitter.complete();
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -87,8 +146,9 @@ public class AiragChatController {
|
||||
*/
|
||||
@IgnoreAuth
|
||||
@GetMapping(value = "/init")
|
||||
public Result<?> initChat(@RequestParam(name = "id", required = true) String id) {
|
||||
return chatService.initChat(id);
|
||||
public Result<?> initChat(@RequestParam(name = "id", required = true) String id,
|
||||
@RequestParam(name = "shareToken", required = false) String shareToken) {
|
||||
return chatService.initChat(id, shareToken);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -245,6 +305,14 @@ public class AiragChatController {
|
||||
@IgnoreAuth
|
||||
@PostMapping(value = "/upload")
|
||||
public Result<?> upload(HttpServletRequest request, HttpServletResponse response) throws Exception {
|
||||
rateLimitService.checkUploadLimit(request);
|
||||
// 匿名上传必须携带已发布应用的分享令牌;登录用户放行
|
||||
try {
|
||||
chatService.validateAnonymousShareAccess(request.getParameter("appId"), request.getParameter("shareToken"));
|
||||
} catch (JeecgBootException e) {
|
||||
return Result.error(e.getMessage());
|
||||
}
|
||||
|
||||
String bizPath = "airag";
|
||||
|
||||
MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
|
||||
|
||||
@ -159,6 +159,14 @@ public class AiragApp implements Serializable {
|
||||
@Schema(description = "状态")
|
||||
private java.lang.String status;
|
||||
|
||||
/**
|
||||
* 分享令牌(匿名聊天凭证,发布时生成,取消发布清空)
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】应用级分享令牌
|
||||
*/
|
||||
@Schema(description = "分享令牌")
|
||||
private java.lang.String shareToken;
|
||||
|
||||
/**
|
||||
* 元数据
|
||||
|
||||
@ -13,6 +13,28 @@ import java.util.List;
|
||||
*/
|
||||
public interface IAiragAppService extends IService<AiragApp> {
|
||||
|
||||
/**
|
||||
* 发布/取消发布应用,并维护分享令牌
|
||||
*
|
||||
* @param id 应用ID
|
||||
* @param release true=发布,false=取消发布
|
||||
* @return 发布成功时返回 shareToken;取消发布返回 null
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】应用级分享令牌
|
||||
*/
|
||||
String releaseApp(String id, boolean release);
|
||||
|
||||
/**
|
||||
* 复制应用
|
||||
*
|
||||
* @param id 原应用ID
|
||||
* @param currentTenantId 当前租户ID
|
||||
* @return 新应用ID
|
||||
* @author scott
|
||||
* @since 2026-08-06 【LHZP-1512】AI应用增加复制功能
|
||||
*/
|
||||
String copyApp(String id, String currentTenantId);
|
||||
|
||||
/**
|
||||
* 生成提示词
|
||||
* @param prompt
|
||||
|
||||
@ -98,11 +98,23 @@ public interface IAiragChatService {
|
||||
* 初始化聊天(忽略租户)
|
||||
* [QQYUN-12113]分享之后的聊天,应用、模型、知识库不根据租户查询
|
||||
* @param appId
|
||||
* @param shareToken 分享令牌(匿名访问必填)
|
||||
* @return
|
||||
* @author chenrui
|
||||
* @date 2025/4/21 14:17
|
||||
*/
|
||||
Result<?> initChat(String appId);
|
||||
Result<AiragAppShareInfoVO> initChat(String appId, String shareToken);
|
||||
|
||||
/**
|
||||
* 匿名分享访问校验(登录用户直接放行)。
|
||||
* 用于 upload 等匿名写接口,与 send/init 共用同一套规则。
|
||||
*
|
||||
* @param appId 应用ID
|
||||
* @param shareToken 分享令牌
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】匿名上传补分享令牌校验
|
||||
*/
|
||||
void validateAnonymousShareAccess(String appId, String shareToken);
|
||||
|
||||
/**
|
||||
* 继续接收消息
|
||||
|
||||
@ -18,6 +18,7 @@ import org.jeecg.common.system.vo.LoginUser;
|
||||
import org.jeecg.common.util.AssertUtils;
|
||||
import org.jeecg.common.util.UUIDGenerator;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.config.mybatis.MybatisPlusSaasConfig;
|
||||
import org.jeecg.modules.airag.app.consts.AiAppConsts;
|
||||
import org.jeecg.modules.airag.app.consts.Prompts;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
@ -34,9 +35,11 @@ import org.jeecg.modules.airag.common.vo.event.EventFlowData;
|
||||
import org.jeecg.modules.airag.common.vo.event.EventMessageData;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledge;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeService;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
@ -44,6 +47,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
@ -58,6 +62,8 @@ import java.util.stream.Collectors;
|
||||
@Service
|
||||
public class AiragAppServiceImpl extends ServiceImpl<AiragAppMapper, AiragApp> implements IAiragAppService {
|
||||
|
||||
private static final String COPY_NAME_SUFFIX = "-复制";
|
||||
|
||||
@Autowired
|
||||
IAIChatHandler aiChatHandler;
|
||||
|
||||
@ -67,6 +73,80 @@ public class AiragAppServiceImpl extends ServiceImpl<AiragAppMapper, AiragApp> i
|
||||
@Autowired
|
||||
private RedisTemplate redisTemplate;
|
||||
|
||||
/**
|
||||
* 发布/取消发布应用,并维护分享令牌。
|
||||
* 发布时生成随机 shareToken;取消发布时清空。
|
||||
*
|
||||
* @param id 应用ID
|
||||
* @param release true=发布,false=取消发布
|
||||
* @return 发布成功时返回 shareToken;取消发布返回 null
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】应用级分享令牌
|
||||
*/
|
||||
@Override
|
||||
public String releaseApp(String id, boolean release) {
|
||||
AssertUtils.assertNotEmpty("id必须填写", id);
|
||||
if (release) {
|
||||
String shareToken = java.util.UUID.randomUUID().toString().replace("-", "");
|
||||
boolean updated = this.lambdaUpdate()
|
||||
.eq(AiragApp::getId, id)
|
||||
.set(AiragApp::getStatus, AiAppConsts.STATUS_RELEASE)
|
||||
.set(AiragApp::getShareToken, shareToken)
|
||||
.update();
|
||||
if (!updated) {
|
||||
throw new JeecgBootBizTipException("发布失败,应用不存在或已被删除");
|
||||
}
|
||||
return shareToken;
|
||||
}
|
||||
boolean updated = this.lambdaUpdate()
|
||||
.eq(AiragApp::getId, id)
|
||||
.set(AiragApp::getStatus, AiAppConsts.STATUS_ENABLE)
|
||||
.set(AiragApp::getShareToken, null)
|
||||
.update();
|
||||
if (!updated) {
|
||||
throw new JeecgBootBizTipException("取消发布失败,应用不存在或已被删除");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制应用
|
||||
*
|
||||
* @param id 原应用ID
|
||||
* @param currentTenantId 当前租户ID
|
||||
* @return 新应用ID
|
||||
* @author scott
|
||||
* @since 2026-08-06 【LHZP-1512】AI应用增加复制功能
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public String copyApp(String id, String currentTenantId) {
|
||||
AssertUtils.assertNotEmpty("id必须填写", id);
|
||||
AiragApp sourceApp = this.getById(id);
|
||||
if (sourceApp == null) {
|
||||
throw new JeecgBootBizTipException("复制失败,应用不存在或已被删除");
|
||||
}
|
||||
if (MybatisPlusSaasConfig.OPEN_SYSTEM_TENANT_CONTROL && (oConvertUtils.isEmpty(sourceApp.getTenantId()) || !sourceApp.getTenantId().equals(currentTenantId))) {
|
||||
throw new JeecgBootBizTipException("复制AI应用失败,不能复制其他租户的AI应用!");
|
||||
}
|
||||
|
||||
AiragApp copiedApp = new AiragApp();
|
||||
BeanUtils.copyProperties(sourceApp, copiedApp);
|
||||
copiedApp.setId(null)
|
||||
.setName(sourceApp.getName() + COPY_NAME_SUFFIX)
|
||||
.setCreateBy(null)
|
||||
.setCreateTime(null)
|
||||
.setUpdateBy(null)
|
||||
.setUpdateTime(null)
|
||||
.setSysOrgCode(null)
|
||||
.setStatus(AiAppConsts.STATUS_ENABLE)
|
||||
.setShareToken(null);
|
||||
if (!this.save(copiedApp)) {
|
||||
throw new JeecgBootBizTipException("复制应用失败");
|
||||
}
|
||||
return copiedApp.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object generatePrompt(String prompt, boolean blocking) {
|
||||
AssertUtils.assertNotEmpty("请输入提示词", prompt);
|
||||
@ -96,71 +176,53 @@ public class AiragAppServiceImpl extends ServiceImpl<AiragAppMapper, AiragApp> i
|
||||
if(oConvertUtils.isEmpty(variables) && oConvertUtils.isEmpty(memoryId)){
|
||||
throw new JeecgBootBizTipException("请先添加变量或者记忆后再次重试!");
|
||||
}
|
||||
// 构建变量描述
|
||||
StringBuilder variablesDesc = new StringBuilder();
|
||||
// 1. 解析变量列表,本地拼接变量使用指南(无 LLM 介入,结果确定)
|
||||
String variableGuide = "";
|
||||
if (oConvertUtils.isNotEmpty(variables)) {
|
||||
List<AppVariableVo> variableList = JSONArray.parseArray(variables, AppVariableVo.class);
|
||||
if (variableList != null && !variableList.isEmpty()) {
|
||||
for (AppVariableVo var : variableList) {
|
||||
if (var.getEnable() != null && !var.getEnable()) {
|
||||
continue;
|
||||
}
|
||||
String name = var.getName();
|
||||
if (oConvertUtils.isNotEmpty(var.getAction())) {
|
||||
String action = var.getAction();
|
||||
if (oConvertUtils.isNotEmpty(name)) {
|
||||
try {
|
||||
// 使用正则替换未被{{}}包裹的变量名
|
||||
String regex = "(?<!\\{\\{)\\b" + Pattern.quote(name) + "\\b(?!\\}\\})";
|
||||
action = action.replaceAll(regex, "{{" + name + "}}");
|
||||
} catch (Exception e) {
|
||||
log.warn("变量名替换异常: name={}", name, e);
|
||||
}
|
||||
}
|
||||
variablesDesc.append(action).append("\n");
|
||||
} else {
|
||||
variablesDesc.append("- {{").append(name).append("}}");
|
||||
if (oConvertUtils.isNotEmpty(var.getDescription())) {
|
||||
variablesDesc.append(": ").append(var.getDescription());
|
||||
}
|
||||
variablesDesc.append("\n");
|
||||
}
|
||||
}
|
||||
variableGuide = buildVariableGuide(variableList);
|
||||
}
|
||||
boolean hasMemory = oConvertUtils.isNotEmpty(memoryId);
|
||||
|
||||
// 2. 没有记忆库 → 直接返回拼接好的变量指南
|
||||
if (!hasMemory) {
|
||||
if (oConvertUtils.isEmpty(variableGuide)) {
|
||||
throw new JeecgBootBizTipException("请先添加启用的变量或者记忆后再次重试!");
|
||||
}
|
||||
}
|
||||
|
||||
// 构建Prompt
|
||||
StringBuilder promptBuilder = new StringBuilder(Prompts.GENERATE_GUIDE_HEADER);
|
||||
if (!variablesDesc.isEmpty()) {
|
||||
promptBuilder.append(String.format(Prompts.GENERATE_VAR_PART, variablesDesc.toString()));
|
||||
}
|
||||
|
||||
// 构建记忆状态描述
|
||||
if (oConvertUtils.isNotEmpty(memoryId)) {
|
||||
String memoryDescr = "";
|
||||
AiragKnowledge memory = airagKnowledgeService.getById(memoryId);
|
||||
if (memory != null && oConvertUtils.isNotEmpty(memory.getDescr())) {
|
||||
memoryDescr += "记忆库描述:" + memory.getDescr();
|
||||
if (blocking) {
|
||||
return Result.OK("success", variableGuide);
|
||||
}
|
||||
promptBuilder.append(String.format(Prompts.GENERATE_MEMORY_PART, memoryDescr));
|
||||
return streamLocalGuideOnly(variableGuide);
|
||||
}
|
||||
|
||||
String prompt = promptBuilder.toString();
|
||||
|
||||
List<ChatMessage> messages = List.of(new UserMessage(prompt));
|
||||
|
||||
// 3. 有记忆库 → 仅记忆库部分走 LLM,变量部分作为前置本地内容
|
||||
String memoryDescr = "";
|
||||
AiragKnowledge memory = airagKnowledgeService.getById(memoryId);
|
||||
if (memory != null && oConvertUtils.isNotEmpty(memory.getDescr())) {
|
||||
memoryDescr = "记忆库描述:" + memory.getDescr();
|
||||
}
|
||||
String memoryPrompt = Prompts.GENERATE_GUIDE_HEADER + String.format(Prompts.GENERATE_MEMORY_PART, memoryDescr);
|
||||
List<ChatMessage> messages = List.of(new UserMessage(memoryPrompt));
|
||||
AIChatParams params = new AIChatParams();
|
||||
params.setTemperature(0.7);
|
||||
|
||||
if(blocking){
|
||||
String promptValue = aiChatHandler.completionsByDefaultModel(messages, params);
|
||||
if (promptValue == null || promptValue.isEmpty()) {
|
||||
// 4. 阻塞模式:拼接本地变量指南 + LLM 同步生成的记忆库指南
|
||||
if (blocking) {
|
||||
String memoryPart = aiChatHandler.completionsByDefaultModel(messages, params);
|
||||
if (memoryPart == null || memoryPart.isEmpty()) {
|
||||
return Result.error("生成失败");
|
||||
}
|
||||
return Result.OK("success", promptValue);
|
||||
}else{
|
||||
return startSseChat(messages, params);
|
||||
StringBuilder combined = new StringBuilder();
|
||||
if (oConvertUtils.isNotEmpty(variableGuide)) {
|
||||
combined.append(variableGuide).append("\n");
|
||||
}
|
||||
combined.append(memoryPart);
|
||||
return Result.OK("success", combined.toString());
|
||||
}
|
||||
|
||||
// 5. SSE 流式模式:先 emit 本地变量指南,再衔接 LLM 流式输出记忆库指南
|
||||
String localPrefix = oConvertUtils.isNotEmpty(variableGuide) ? variableGuide + "\n" : "";
|
||||
return startSseChatWithLocalPrefix(localPrefix, messages, params);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -171,13 +233,22 @@ public class AiragAppServiceImpl extends ServiceImpl<AiragAppMapper, AiragApp> i
|
||||
*/
|
||||
private SseEmitter startSseChat(List<ChatMessage> messages, AIChatParams params) {
|
||||
SseEmitter emitter = new SseEmitter(-0L);
|
||||
// 异步运行(流式)
|
||||
String requestId = UUIDGenerator.generate();
|
||||
startLLMStream(emitter, requestId, messages, params);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在已有 SseEmitter 上启动 LLM 流式响应。
|
||||
*
|
||||
* <p>从原 startSseChat 中抽出可复用的子段,使前置本地内容(变量使用指南)能与 LLM 流式记忆库指南共用同一个 emitter 与 requestId。</p>
|
||||
*/
|
||||
private void startLLMStream(SseEmitter emitter, String requestId, List<ChatMessage> messages, AIChatParams params) {
|
||||
TokenStream tokenStream = aiChatHandler.chatByDefaultModel(messages, params);
|
||||
/**
|
||||
* 是否正在思考
|
||||
*/
|
||||
AtomicBoolean isThinking = new AtomicBoolean(false);
|
||||
String requestId = UUIDGenerator.generate();
|
||||
// ai聊天响应逻辑
|
||||
tokenStream.onPartialResponse((String resMessage) -> {
|
||||
// 兼容推理模型
|
||||
@ -242,7 +313,6 @@ public class AiragAppServiceImpl extends ServiceImpl<AiragAppMapper, AiragApp> i
|
||||
closeSSE(emitter, eventData);
|
||||
})
|
||||
.start();
|
||||
return emitter;
|
||||
}
|
||||
//update-end---author:wangshuai---date:2026-01-05---for:【QQYUN-14479】增加一个开启记忆的按钮。下面为提示词和记忆,将记忆提示词单独拆分---
|
||||
|
||||
@ -260,6 +330,154 @@ public class AiragAppServiceImpl extends ServiceImpl<AiragAppMapper, AiragApp> i
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼接「变量使用指南」(无需 LLM,全部本地拼接)。
|
||||
*
|
||||
* <p>结果以 Markdown 表格展示「变量名 / 变量描述 / 当前值」,「当前值」一列使用 {{变量名}} 占位符,
|
||||
* 运行时由系统提示词渲染替换为真实值。表格下方原文保留用户自定义的 action 行为指令,
|
||||
* 最后附带固定的「变量更新协议」,强约束 LLM 在新会话中不要把已有当前值当作新信息而误调用 update_variable。</p>
|
||||
*
|
||||
* @param variableList AI 应用的变量列表
|
||||
* @return 拼接好的变量使用指南文本;若没有启用的变量则返回空字符串
|
||||
*/
|
||||
private String buildVariableGuide(List<AppVariableVo> variableList) {
|
||||
if (variableList == null || variableList.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
List<AppVariableVo> enabledVars = variableList.stream()
|
||||
.filter(v -> v.getEnable() == null || v.getEnable())
|
||||
.filter(v -> oConvertUtils.isNotEmpty(v.getName()))
|
||||
.toList();
|
||||
if (enabledVars.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("## 变量使用指南\n\n");
|
||||
sb.append("> ⚠️ **重要**:下表展示了当前用户已设置的变量值。若「当前值」一列非空,表示用户在历史交互中已设置过该变量,请直接采信并使用,**严禁**假装用户从未设置过而忽略或反问。\n\n");
|
||||
|
||||
// 变量状态总览表格
|
||||
sb.append("| 变量名 | 变量描述 | 当前值 |\n");
|
||||
sb.append("| --- | --- | --- |\n");
|
||||
for (AppVariableVo var : enabledVars) {
|
||||
String name = var.getName();
|
||||
String desc = oConvertUtils.isNotEmpty(var.getDescription()) ? var.getDescription() : "-";
|
||||
// 防止 | 和换行破坏 markdown 表格结构
|
||||
desc = desc.replace("|", "\\|").replace("\n", " ");
|
||||
sb.append("| ").append(name).append(" | ").append(desc)
|
||||
.append(" | {{").append(name).append("}} |\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
|
||||
// 用户自定义的 action 行为指令(原文保留,不改写以避免偏离用户原意)
|
||||
List<String> actions = new ArrayList<>();
|
||||
for (AppVariableVo var : enabledVars) {
|
||||
if (oConvertUtils.isNotEmpty(var.getAction())) {
|
||||
String action = var.getAction();
|
||||
String name = var.getName();
|
||||
try {
|
||||
// 使用正则替换未被{{}}包裹的变量名
|
||||
String regex = "(?<!\\{\\{)\\b" + Pattern.quote(name) + "\\b(?!\\}\\})";
|
||||
action = action.replaceAll(regex, "{{" + name + "}}");
|
||||
} catch (Exception e) {
|
||||
log.warn("变量名替换异常: name={}", name, e);
|
||||
}
|
||||
actions.add(action);
|
||||
}
|
||||
}
|
||||
if (!actions.isEmpty()) {
|
||||
sb.append("**行为指令**:\n");
|
||||
for (String act : actions) {
|
||||
sb.append(act).append("\n");
|
||||
}
|
||||
sb.append("\n");
|
||||
}
|
||||
|
||||
// 变量更新协议(写死的强约束,防止 LLM 在新会话误调用 update_variable)
|
||||
String varNames = enabledVars.stream()
|
||||
.map(AppVariableVo::getName)
|
||||
.collect(Collectors.joining("、"));
|
||||
sb.append("**变量更新协议(必读)**:\n");
|
||||
sb.append("1. **何时调用 `update_variable`**:仅当用户在**本次对话中**主动提供了上述变量(")
|
||||
.append(varNames)
|
||||
.append(")的**新值**,且该新值与「变量状态总览」中的「当前值」**不一致**时,才必须立即调用 `update_variable`。\n");
|
||||
sb.append("2. **用户质疑/纠正场景必须更新**:若用户对表中已有的「当前值」明确表达质疑、否认或纠正(例如“我不是X,是Y”、“你记错了,我叫Z”、“现在应该改成…”、“这个值不对”),并给出**新值**,则**必须立即**调用 `update_variable` 以新值覆盖旧值——这正是合法的更新场景,不要因下文「严禁误触发」而拒绝执行。\n");
|
||||
sb.append("3. **严禁误触发**:\n");
|
||||
sb.append(" - 即使是**新会话刚开启**的第一轮对话,「变量状态总览」中已有的当前值也**不是新信息**——它来自历史会话/已存在的配置,**严禁**将其当作“用户刚刚告诉我的内容”而调用 `update_variable`。\n");
|
||||
sb.append(" - 用户未主动表达变更意图、也未对当前值提出质疑/纠正时,**严禁**主动调用。\n");
|
||||
sb.append(" - 同一变量在同一轮对话中已调用过、或新值与当前值相同时,**严禁**重复调用。\n");
|
||||
sb.append("4. **调用前自检三问**:(a) 是否用户本轮主动新提供,或对当前值提出了质疑/纠正?(b) 是否与「当前值」不同?(c) 本轮是否未调过?三者同时为是才允许调用。\n");
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅本地内容的 SSE 流式输出(无 LLM 调用)。
|
||||
*
|
||||
* <p>用于「只有变量、无记忆库」场景:把后端拼接好的变量指南按行 emit 出去,
|
||||
* 然后发送 MESSAGE_END 关闭 SSE。运行在异步线程中以保持 controller 即时返回 emitter。</p>
|
||||
*/
|
||||
private SseEmitter streamLocalGuideOnly(String localText) {
|
||||
SseEmitter emitter = new SseEmitter(-0L);
|
||||
String requestId = UUIDGenerator.generate();
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
emitGuideLines(emitter, requestId, localText);
|
||||
EventData endEvent = new EventData(requestId, null, EventData.EVENT_MESSAGE_END);
|
||||
closeSSE(emitter, endEvent);
|
||||
} catch (Exception e) {
|
||||
log.error("本地变量指南流式输出失败", e);
|
||||
EventData errEvent = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR);
|
||||
errEvent.setData(EventFlowData.builder().success(false).message(e.getMessage()).build());
|
||||
closeSSE(emitter, errEvent);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 前置本地内容 + 后续 LLM 流式的复合 SSE 输出。
|
||||
*
|
||||
* <p>用于「变量 + 记忆库」场景:先 emit 后端拼接好的变量指南,再启动 LLM 流式生成记忆库指南,
|
||||
* 共用同一个 SseEmitter 和 requestId,前端体验为连续流式输出。</p>
|
||||
*/
|
||||
private SseEmitter startSseChatWithLocalPrefix(String localPrefix, List<ChatMessage> messages, AIChatParams params) {
|
||||
SseEmitter emitter = new SseEmitter(-0L);
|
||||
String requestId = UUIDGenerator.generate();
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
if (oConvertUtils.isNotEmpty(localPrefix)) {
|
||||
emitGuideLines(emitter, requestId, localPrefix);
|
||||
}
|
||||
// 衔接 LLM 流式输出(共用 emitter / requestId,由 LLM 流的 onCompleteResponse 发送 MESSAGE_END)
|
||||
startLLMStream(emitter, requestId, messages, params);
|
||||
} catch (Exception e) {
|
||||
log.error("前置本地内容 + LLM 流式输出失败", e);
|
||||
EventData errEvent = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR);
|
||||
errEvent.setData(EventFlowData.builder().success(false).message(e.getMessage()).build());
|
||||
closeSSE(emitter, errEvent);
|
||||
}
|
||||
});
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按行 emit SSE 消息(不发送 MESSAGE_END,由调用方决定何时结束)。
|
||||
*/
|
||||
private void emitGuideLines(SseEmitter emitter, String requestId, String text) throws IOException {
|
||||
String[] lines = text.split("\n", -1);
|
||||
int total = lines.length;
|
||||
for (int i = 0; i < total; i++) {
|
||||
String chunk = (i == total - 1) ? lines[i] : lines[i] + "\n";
|
||||
if (chunk.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_MESSAGE);
|
||||
EventMessageData messageEventData = EventMessageData.builder().message(chunk).build();
|
||||
eventData.setData(messageEventData);
|
||||
emitter.send(SseEmitter.event().data(JSONObject.toJSONString(eventData)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写作列表
|
||||
|
||||
@ -0,0 +1,100 @@
|
||||
package org.jeecg.modules.airag.app.service.impl;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.IpUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* AI聊天匿名访问限流服务
|
||||
* 用于防止 /airag/chat/send、/airag/chat/upload 被恶意刷接口
|
||||
*
|
||||
* @author scott
|
||||
* @date 2026-07-20
|
||||
*/
|
||||
@Service
|
||||
public class AiragChatRateLimitService {
|
||||
|
||||
@Autowired
|
||||
private RedisTemplate redisTemplate;
|
||||
|
||||
/**
|
||||
* 每会话每分钟最多发送消息次数
|
||||
*/
|
||||
@Value("${jeecg.airag.rate-limit.send-per-session-per-minute:20}")
|
||||
private int sendPerSessionPerMinute;
|
||||
|
||||
/**
|
||||
* 每 IP 每分钟最多发送消息次数
|
||||
*/
|
||||
@Value("${jeecg.airag.rate-limit.send-per-ip-per-minute:60}")
|
||||
private int sendPerIpPerMinute;
|
||||
|
||||
/**
|
||||
* 每会话每小时最多上传文件次数
|
||||
*/
|
||||
@Value("${jeecg.airag.rate-limit.upload-per-session-per-hour:20}")
|
||||
private int uploadPerSessionPerHour;
|
||||
|
||||
private static final DateTimeFormatter MINUTE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmm");
|
||||
private static final DateTimeFormatter HOUR_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHH");
|
||||
|
||||
/**
|
||||
* 校验 /airag/chat/send 调用频次
|
||||
*
|
||||
* @param request HttpServletRequest
|
||||
*/
|
||||
public void checkSendLimit(HttpServletRequest request) {
|
||||
String sessionId = request.getSession().getId();
|
||||
String clientIp = IpUtils.getIpAddr(request);
|
||||
String minute = LocalDateTime.now().format(MINUTE_FORMAT);
|
||||
|
||||
checkLimit("airag:rate:send:session:" + sessionId + ":" + minute, sendPerSessionPerMinute, 120,
|
||||
"发送消息过于频繁,请稍后再试");
|
||||
checkLimit("airag:rate:send:ip:" + clientIp + ":" + minute, sendPerIpPerMinute, 120,
|
||||
"发送消息过于频繁,请稍后再试");
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 /airag/chat/upload 调用频次
|
||||
*
|
||||
* @param request HttpServletRequest
|
||||
*/
|
||||
public void checkUploadLimit(HttpServletRequest request) {
|
||||
String sessionId = request.getSession().getId();
|
||||
String clientIp = IpUtils.getIpAddr(request);
|
||||
String hour = LocalDateTime.now().format(HOUR_FORMAT);
|
||||
|
||||
checkLimit("airag:rate:upload:session:" + sessionId + ":" + hour, uploadPerSessionPerHour, 7200,
|
||||
"上传文件过于频繁,请稍后再试");
|
||||
checkLimit("airag:rate:upload:ip:" + clientIp + ":" + hour, uploadPerSessionPerHour, 7200,
|
||||
"上传文件过于频繁,请稍后再试");
|
||||
}
|
||||
|
||||
/**
|
||||
* 固定窗口计数限流
|
||||
*
|
||||
* @param key Redis key
|
||||
* @param limit 窗口上限
|
||||
* @param expireSeconds key 过期时间(秒)
|
||||
*/
|
||||
private void checkLimit(String key, int limit, int expireSeconds, String errorMessage) {
|
||||
Long count = redisTemplate.opsForValue().increment(key, 1);
|
||||
if (count == null) {
|
||||
return;
|
||||
}
|
||||
if (count == 1) {
|
||||
redisTemplate.expire(key, expireSeconds, TimeUnit.SECONDS);
|
||||
}
|
||||
if (count > limit) {
|
||||
throw new JeecgBootException(errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,7 @@ import dev.langchain4j.model.output.FinishReason;
|
||||
import dev.langchain4j.service.TokenStream;
|
||||
import dev.langchain4j.service.tool.ToolExecutor;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.io.FilenameUtils;
|
||||
import org.apache.tika.parser.AutoDetectParser;
|
||||
@ -25,6 +26,7 @@ import org.jeecg.common.system.api.ISysBaseAPI;
|
||||
import org.jeecg.common.system.util.JwtUtil;
|
||||
import org.jeecg.common.util.*;
|
||||
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import org.jeecg.common.util.oss.OssBootUtil;
|
||||
import org.jeecg.config.AiChatConfig;
|
||||
import org.jeecg.config.AiRagConfigBean;
|
||||
import org.jeecg.config.JeecgBaseConfig;
|
||||
@ -53,14 +55,17 @@ import org.jeecg.modules.airag.flow.helper.JeecgTagHelper;
|
||||
import org.jeecg.modules.airag.flow.service.IAiragFlowService;
|
||||
import org.jeecg.modules.airag.flow.vo.api.FlowRunParams;
|
||||
import org.jeecg.modules.airag.flow.vo.tool.ToolExecutionVo;
|
||||
import org.jeecg.modules.airag.llm.consts.FlowPluginContent;
|
||||
import org.jeecg.modules.airag.llm.consts.LLMConsts;
|
||||
import org.jeecg.modules.airag.llm.document.TikaDocumentParser;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.flow.handler.BraveSearchToolBuilder;
|
||||
import org.jeecg.modules.airag.llm.handler.AIChatHandler;
|
||||
import org.jeecg.modules.airag.llm.handler.JeecgToolsProvider;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragFlowPluginService;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeDocService;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeService;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@ -78,6 +83,7 @@ import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
||||
@ -89,8 +95,12 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class AiragChatServiceImpl implements IAiragChatService {
|
||||
|
||||
private final ImageGenerationToolBuilder imageGenerationToolBuilder;
|
||||
private final ImageGenerationContentAssembler imageGenerationContentAssembler;
|
||||
|
||||
@Autowired
|
||||
IAIChatHandler aiChatHandler;
|
||||
|
||||
@ -151,6 +161,10 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
if (oConvertUtils.isNotEmpty(chatSendParams.getAppId())) {
|
||||
app = airagAppMapper.getByIdIgnoreTenant(chatSendParams.getAppId());
|
||||
}
|
||||
//update-begin---author:scott ---date:20260721 for:【issues/9787】AI聊天匿名接口安全加固:匿名必须指定已发布应用+分享令牌,禁止回退默认应用-----------
|
||||
// 匿名访问校验:必须指定已发布的应用并携带分享令牌,禁止回退默认应用(issues/9787)
|
||||
checkAnonymousShareAccess(app, oConvertUtils.isNotEmpty(chatSendParams.getAppId()), chatSendParams.getShareToken());
|
||||
//update-end---author:scott ---date:20260721 for:【issues/9787】AI聊天匿名接口安全加固:匿名必须指定已发布应用+分享令牌,禁止回退默认应用-----------
|
||||
//update-begin---author:wangshuai---date:2025-12-10---for:【QQYUN-14127】【AI】AI应用门户---
|
||||
ChatConversation chatConversation = getOrCreateChatConversation(app, conversationId, chatSendParams.getSessionType());
|
||||
//update-end---author:wangshuai---date:2025-12-10---for:【QQYUN-14127】【AI】AI应用门户---
|
||||
@ -184,7 +198,10 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
// 获取会话信息
|
||||
String topicId = oConvertUtils.getString(appDebugParams.getTopicId(), UUIDGenerator.generate());
|
||||
AiragApp app = appDebugParams.getApp();
|
||||
app.setId("__DEBUG_APP");
|
||||
//update-begin---author:scott ---date:20260811 for:【LHZP-1619】应用预览与流程调试共用真实应用变量-----------
|
||||
app.setId(resolveDebugAppId(app.getId()));
|
||||
saveVariables(app);
|
||||
//update-end---author:scott ---date:20260811 for:【LHZP-1619】应用预览与流程调试共用真实应用变量-----------
|
||||
//update-begin---author:wangshuai---date:2025-12-10---for:【QQYUN-14127】【AI】AI应用门户---
|
||||
ChatConversation chatConversation = getOrCreateChatConversation(app, topicId, "");
|
||||
//update-end---author:wangshuai---date:2025-12-10---for:【QQYUN-14127】【AI】AI应用门户---
|
||||
@ -203,6 +220,15 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
return emitter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已保存应用调试时保留真实应用id,未保存应用使用调试应用id。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-11 【LHZP-1619】应用预览与流程调试共用真实应用变量
|
||||
*/
|
||||
static String resolveDebugAppId(String appId) {
|
||||
return oConvertUtils.isEmpty(appId) ? AiAppConsts.DEBUG_APP_ID : appId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> stop(String requestId) {
|
||||
@ -343,7 +369,8 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
// 合并工具调用相关的消息
|
||||
List<MessageHistory> messages = chatConversation.getMessages();
|
||||
if (oConvertUtils.isObjectNotEmpty(messages)) {
|
||||
messages = mergeToolMessages(messages, showToolProcess);
|
||||
Map<String, String> toolDisplayNames = showToolProcess ? getFlowToolDisplayNames(chatApp) : Collections.emptyMap();
|
||||
messages = mergeToolMessages(messages, showToolProcess, toolDisplayNames);
|
||||
}
|
||||
result.put("messages", messages);
|
||||
result.put("flowInputs", chatConversation.getFlowInputs());
|
||||
@ -362,9 +389,10 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
*
|
||||
* @param histories 历史消息列表
|
||||
* @param showToolProcess 是否显示工具调用过程
|
||||
* @param toolDisplayNames 工具展示名称
|
||||
* @return 合并后的历史消息列表
|
||||
*/
|
||||
private List<MessageHistory> mergeToolMessages(List<MessageHistory> histories, boolean showToolProcess) {
|
||||
private List<MessageHistory> mergeToolMessages(List<MessageHistory> histories, boolean showToolProcess, Map<String, String> toolDisplayNames) {
|
||||
List<MessageHistory> mergedMessages = new ArrayList<>();
|
||||
if (oConvertUtils.isObjectEmpty(histories)) {
|
||||
return mergedMessages;
|
||||
@ -443,6 +471,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
}
|
||||
String toolResult = message.getToolExecutionResult();
|
||||
ToolExecutionVo vo = ToolExecutionVo.build(toolId, request.getName(), request.getArguments(), toolResult);
|
||||
fillToolDisplayName(vo, toolDisplayNames);
|
||||
String execTag = JeecgTagHelper.createTag(JeecgTagHelper.TAG_JEECG_TOOL_EXEC, JSON.toJSONString(vo));
|
||||
mergeMsg.accept(currentAiMsg, execTag);
|
||||
}
|
||||
@ -454,6 +483,45 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
return mergedMessages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用流程工具的展示名称
|
||||
*
|
||||
* @param app AI应用
|
||||
* @return 工具名与流程名映射
|
||||
* @author scott
|
||||
* @since 2026-08-04 LHZP-1610
|
||||
*/
|
||||
private Map<String, String> getFlowToolDisplayNames(AiragApp app) {
|
||||
if (app == null || oConvertUtils.isEmpty(app.getFlowId())) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
List<String> flowIds = Arrays.asList(app.getFlowId().split(SymbolConstant.COMMA));
|
||||
List<AiragFlow> flows = airagFlowService.listByIds(flowIds);
|
||||
if (CollectionUtils.isEmpty(flows)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
return flows.stream().collect(Collectors.toMap(
|
||||
flow -> FlowPluginContent.FLOW_TOOL_NAME_PREFIX + flow.getId(),
|
||||
AiragFlow::getName,
|
||||
(first, _second) -> first
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 补充工具展示名称
|
||||
*
|
||||
* @param vo 工具执行记录
|
||||
* @param toolDisplayNames 工具展示名称
|
||||
* @author scott
|
||||
* @since 2026-08-04 LHZP-1610
|
||||
*/
|
||||
private void fillToolDisplayName(ToolExecutionVo vo, Map<String, String> toolDisplayNames) {
|
||||
if (vo == null || toolDisplayNames == null || toolDisplayNames.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
vo.setDisplayName(toolDisplayNames.get(vo.getName()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> clearMessage(String conversationId, String sessionType) {
|
||||
AssertUtils.assertNotEmpty("请先选择会话", conversationId);
|
||||
@ -474,8 +542,11 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> initChat(String appId) {
|
||||
public Result<AiragAppShareInfoVO> initChat(String appId, String shareToken) {
|
||||
AiragApp app = airagAppMapper.getByIdIgnoreTenant(appId);
|
||||
//update-begin---author:scott ---date:20260721 for:【issues/9787】init匿名访问校验:应用必须已发布且令牌匹配,同时防止app为空导致空指针-----------
|
||||
checkAnonymousShareAccess(app, true, shareToken);
|
||||
//update-end---author:scott ---date:20260721 for:【issues/9787】init匿名访问校验:应用必须已发布且令牌匹配,同时防止app为空导致空指针-----------
|
||||
//update-begin---author:chenrui ---date:20251106 for:[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
|
||||
if(AiAppConsts.APP_TYPE_CHAT_FLOW.equalsIgnoreCase(app.getType())) {
|
||||
AiragFlow flow = airagFlowService.getById(app.getFlowId());
|
||||
@ -521,7 +592,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
}
|
||||
//update-end---author:chenrui ---date:202501XX for:在initChat接口中返回模型供应商信息,避免前端多次调用模型查询接口------------
|
||||
|
||||
return Result.ok(app);
|
||||
return Result.ok(buildShareInfoVO(app));
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -566,7 +637,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
break;
|
||||
} else {
|
||||
// 主线程未结束, 未超时, 休眠一会再查
|
||||
log.warn("[AI应用]继续接收-等待消息更新: {}", requestId);
|
||||
log.debug("[AI应用]继续接收-等待消息更新: {}", requestId);
|
||||
Thread.sleep(500);
|
||||
}
|
||||
}
|
||||
@ -996,10 +1067,12 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
//update-begin---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手,支持多模态能力- 文档---
|
||||
appendMessage(messages, userMessage, chatConversation, topicId, sendParams.getFiles(), sendParams.getContent());
|
||||
//update-end---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手,支持多模态能力- 文档---
|
||||
// 绘画AI逻辑:当开启生成绘画时调用
|
||||
if (oConvertUtils.isObjectNotEmpty(sendParams.getEnableDraw()) && sendParams.getEnableDraw()) {
|
||||
//update-begin---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
// 手动开启时强制纯生图;未开启时由应用聊天模型通过生图工具自动判断并支持图文混排
|
||||
if (Boolean.TRUE.equals(sendParams.getEnableDraw())) {
|
||||
return genImageChat(emitter,sendParams,requestId,messages,chatConversation,topicId);
|
||||
}
|
||||
//update-end---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
/* 这里应该是有几种情况:
|
||||
* 1. 非ai应用:获取默认模型->开始聊天
|
||||
* 2. AI应用-聊天助手(ChatAssistant):从应用信息组装模型和提示词->开始聊天
|
||||
@ -1332,6 +1405,28 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
airagVariableService.addUpdateVariableTool(aiApp,username,aiChatParams);
|
||||
}
|
||||
|
||||
//update-begin---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
Function<String, List<String>> generatedImageGenerator = null;
|
||||
//update-begin---author:scott ---date:20260810 for:图片类插件与应用内置绘画能力互斥---
|
||||
boolean imageRelatedPluginEnabled = imageGenerationToolBuilder.hasImageRelatedPlugin(aiChatParams);
|
||||
if (imageRelatedPluginEnabled) {
|
||||
log.info("[AI-CHAT]应用已启用图片类插件,跳过内置绘画模型注册, appId:{}", aiApp.getId());
|
||||
} else if (imageGenerationToolBuilder.hasExplicitImageRequest(aiApp, sendParams.getContent())) {
|
||||
//update-end---author:scott ---date:20260810 for:图片类插件与应用内置绘画能力互斥---
|
||||
appendMessage(messages, SystemMessage.from(imageGenerationToolBuilder.buildPlacementInstruction(sendParams.getContent())), chatConversation, topicId);
|
||||
generatedImageGenerator = articleContent -> imageGenerationToolBuilder.generateForRequest(
|
||||
aiApp, sendParams.getContent(), articleContent, this::uploadImage);
|
||||
} else {
|
||||
Map<ToolSpecification, ToolExecutor> imageTools = imageGenerationToolBuilder.buildTools(aiApp, this::uploadImage);
|
||||
if (!imageTools.isEmpty()) {
|
||||
if (aiChatParams.getTools() == null) {
|
||||
aiChatParams.setTools(new HashMap<>());
|
||||
}
|
||||
aiChatParams.getTools().putAll(imageTools);
|
||||
}
|
||||
}
|
||||
//update-end---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
|
||||
//update-begin---author:wangshuai---date:2026-03-18---for:【QQYUN-14935】Langchain4j 新版支持 Agent Skills,重新定义 Java AI 应用的能力边界---
|
||||
// 封装skills及上下文信息
|
||||
fillSkillsParams(aiChatParams);
|
||||
@ -1341,7 +1436,9 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
printChatDuration(requestId, "构造应用自定义参数完成");
|
||||
// 发消息
|
||||
//update-begin---author:wangshuai---date:2025-12-10---for:【QQYUN-14127】【AI】AI应用门户---
|
||||
sendWithDefault(requestId, chatConversation, topicId, modelId, messages, aiChatParams, sendParams.getSessionType());
|
||||
//update-begin---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
sendWithDefault(requestId, chatConversation, topicId, modelId, messages, aiChatParams, sendParams.getSessionType(), generatedImageGenerator);
|
||||
//update-end---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
//update-end---author:wangshuai---date:2025-12-10---for:【QQYUN-14127】【AI】AI应用门户---
|
||||
}
|
||||
|
||||
@ -1428,6 +1525,18 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
* @date 2025/2/25 19:24
|
||||
*/
|
||||
private void sendWithDefault(String requestId, ChatConversation chatConversation, String topicId, String modelId, List<ChatMessage> messages, AIChatParams aiChatParams, String sessionType) {
|
||||
//update-begin---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
sendWithDefault(requestId, chatConversation, topicId, modelId, messages, aiChatParams, sessionType, null);
|
||||
//update-end---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理可延迟合并图片的流式聊天响应。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private void sendWithDefault(String requestId, ChatConversation chatConversation, String topicId, String modelId, List<ChatMessage> messages, AIChatParams aiChatParams, String sessionType, Function<String, List<String>> generatedImageGenerator) {
|
||||
// 调用ai聊天
|
||||
if (null == aiChatParams) {
|
||||
aiChatParams = new AIChatParams();
|
||||
@ -1524,11 +1633,17 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
}
|
||||
}
|
||||
final boolean finalShowToolProcess = showToolProcess;
|
||||
final Map<String, String> toolDisplayNames = finalShowToolProcess ? getFlowToolDisplayNames(chatConversation.getApp()) : Collections.emptyMap();
|
||||
|
||||
//update-begin---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型单轮只调用变量工具时兜底写入记忆库-----------
|
||||
Set<String> executedToolNames = ConcurrentHashMap.newKeySet();
|
||||
//update-end---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型单轮只调用变量工具时兜底写入记忆库-----------
|
||||
|
||||
/**
|
||||
* 是否正在思考
|
||||
*/
|
||||
AtomicBoolean isThinking = new AtomicBoolean(false);
|
||||
boolean deferTextResponse = generatedImageGenerator != null;
|
||||
// ai聊天响应逻辑
|
||||
chatStream.onPartialResponse((String resMessage) -> {
|
||||
//update-begin---author:wangshuai---date:2025-11-07---for:[issues/8506]/[issues/8260]/[issues/8166]新增推理模型的支持---
|
||||
@ -1538,15 +1653,23 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
isThinking.set(false);
|
||||
}
|
||||
//update-end---author:wangshuai---date:2025-11-07---for:[issues/8506]/[issues/8260]/[issues/8166]新增推理模型的支持---
|
||||
send2Client.accept(resMessage, EventData.EVENT_MESSAGE);
|
||||
//update-begin---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
if (!deferTextResponse) {
|
||||
send2Client.accept(resMessage, EventData.EVENT_MESSAGE);
|
||||
}
|
||||
//update-end---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
}).beforeToolExecution(beforeToolExecution -> {
|
||||
// 监听工具执行请求(根据配置决定是否发送给前端)
|
||||
if (finalShowToolProcess) {
|
||||
ToolExecutionVo vo = ToolExecutionVo.build(beforeToolExecution);
|
||||
fillToolDisplayName(vo, toolDisplayNames);
|
||||
String execTag = JeecgTagHelper.createTag(JeecgTagHelper.TAG_JEECG_TOOL_EXEC, JSON.toJSONString(vo));
|
||||
send2Client.accept(execTag, EventData.EVENT_TOOL_EXEC_BEFORE);
|
||||
}
|
||||
}).onToolExecuted((toolExecution) -> {
|
||||
//update-begin---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型单轮只调用变量工具时兜底写入记忆库-----------
|
||||
executedToolNames.add(toolExecution.request().name());
|
||||
//update-end---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型单轮只调用变量工具时兜底写入记忆库-----------
|
||||
// 打印工具执行结果
|
||||
log.debug("[AI应用]工具执行结果: toolName={}, toolId={}, result={}",
|
||||
toolExecution.request().name(),
|
||||
@ -1561,9 +1684,14 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
// 根据配置决定是否将工具调用过程发送给前端
|
||||
if (finalShowToolProcess) {
|
||||
ToolExecutionVo vo = ToolExecutionVo.build(toolExecution);
|
||||
fillToolDisplayName(vo, toolDisplayNames);
|
||||
String execTag = JeecgTagHelper.createTag(JeecgTagHelper.TAG_JEECG_TOOL_EXEC, JSON.toJSONString(vo));
|
||||
send2Client.accept(execTag, EventData.EVENT_TOOL_EXEC_DONE);
|
||||
send2Client.accept(execTag, EventData.EVENT_MESSAGE);
|
||||
//update-begin---author:scott ---date:20260810 for:AI应用思考过程隐藏工具执行原始数据-----------
|
||||
if (!isThinking.get()) {
|
||||
send2Client.accept(execTag, EventData.EVENT_MESSAGE);
|
||||
}
|
||||
//update-end---author:scott ---date:20260810 for:AI应用思考过程隐藏工具执行原始数据-----------
|
||||
}
|
||||
}).onIntermediateResponse((chatResponse) -> {
|
||||
// 中间响应:包含tool_calls的AI消息
|
||||
@ -1598,6 +1726,9 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
}).onCompleteResponse((responseMessage) -> {
|
||||
// 打印流程耗时日志
|
||||
printChatDuration(requestId, "LLM输出消息完成");
|
||||
//update-begin---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型单轮只调用变量工具时兜底写入记忆库-----------
|
||||
saveMemoryAfterVariableUpdate(chatConversation.getApp(), messages, executedToolNames);
|
||||
//update-end---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型单轮只调用变量工具时兜底写入记忆库-----------
|
||||
AiragLocalCache.remove(AiragConsts.CACHE_TYPE_SSE_SEND_TIME, requestId);
|
||||
// for [QQYUN-9234] MCP服务连接关闭 - 聊天完成时关闭MCP连接
|
||||
finalAiChatParams.closeMcpConnections();
|
||||
@ -1614,6 +1745,14 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
if (FinishReason.STOP.equals(finishReason) || null == finishReason) {
|
||||
// 正常结束
|
||||
EventData eventData = new EventData(requestId, null, EventData.EVENT_MESSAGE_END, chatConversation.getId(), topicId);
|
||||
//update-begin---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
if (deferTextResponse) {
|
||||
String sanitizedText = imageGenerationContentAssembler.removeGeneratedImageMarkdown(aiMessage == null ? null : aiMessage.text());
|
||||
String mixedContent = imageGenerationContentAssembler.mergeGeneratedImages(sanitizedText, generatedImageGenerator.apply(sanitizedText));
|
||||
send2Client.accept(mixedContent, EventData.EVENT_MESSAGE);
|
||||
aiMessage = imageGenerationContentAssembler.replaceAiMessageContent(aiMessage, mixedContent);
|
||||
}
|
||||
//update-end---author:scott ---date:20260810 for:AI应用支持智能识别和图文混合生成-----------
|
||||
appendMessage(messages, aiMessage, chatConversation, topicId);
|
||||
// 保存会话
|
||||
//update-begin---author:wangshuai---date:2025-12-10---for:【QQYUN-14127】【AI】AI应用门户---
|
||||
@ -1807,6 +1946,140 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 智普模型仅调用变量工具时,将本轮用户信息兜底写入记忆库。
|
||||
*
|
||||
* @param aiApp AI应用
|
||||
* @param messages 本轮消息
|
||||
* @param executedToolNames 已执行工具名称
|
||||
* @author wangshuai
|
||||
* @since 2026-08-04 LHZP-1591
|
||||
*/
|
||||
private void saveMemoryAfterVariableUpdate(AiragApp aiApp, List<ChatMessage> messages, Set<String> executedToolNames) {
|
||||
if (aiApp == null || oConvertUtils.isEmpty(aiApp.getMemoryId())
|
||||
|| (!AiAppConsts.IZ_OPEN_MEMORY.equals(aiApp.getIzOpenMemory()) && aiApp.getIzOpenMemory() != null)
|
||||
|| !executedToolNames.contains("update_variable") || executedToolNames.contains("add_memory")) {
|
||||
return;
|
||||
}
|
||||
String userContent = messages.stream()
|
||||
.filter(UserMessage.class::isInstance)
|
||||
.map(UserMessage.class::cast)
|
||||
.reduce((first, second) -> second)
|
||||
.map(userMessage -> userMessage.contents().stream()
|
||||
.filter(TextContent.class::isInstance)
|
||||
.map(TextContent.class::cast)
|
||||
.map(TextContent::text)
|
||||
.collect(Collectors.joining("\n")))
|
||||
.orElse("");
|
||||
if (oConvertUtils.isEmpty(userContent)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
AiragKnowledgeDoc memoryDoc = new AiragKnowledgeDoc();
|
||||
memoryDoc.setKnowledgeId(aiApp.getMemoryId());
|
||||
memoryDoc.setTitle(userContent.length() > 20 ? userContent.substring(0, 20) : userContent);
|
||||
memoryDoc.setContent(userContent);
|
||||
memoryDoc.setType(LLMConsts.KNOWLEDGE_DOC_TYPE_TEXT);
|
||||
IAiragKnowledgeDocService knowledgeDocService = SpringContextUtils.getBean(IAiragKnowledgeDocService.class);
|
||||
knowledgeDocService.editDocument(memoryDoc);
|
||||
log.info("[AI应用][LHZP-1591] 变量更新后兜底写入记忆库成功, memoryId={}", aiApp.getMemoryId());
|
||||
} catch (Exception e) {
|
||||
log.error("[AI应用][LHZP-1591] 变量更新后兜底写入记忆库失败, memoryId={}", aiApp.getMemoryId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 匿名访问安全校验(issues/9787)
|
||||
* 匿名用户必须指定一个"已发布"的AI应用并携带正确的分享令牌,禁止回退默认应用、禁止访问未发布/不存在的应用;
|
||||
* 登录用户不受任何影响。
|
||||
*
|
||||
* @param app 按appId查询到的应用(可能为空)
|
||||
* @param hasAppId 请求是否携带了appId
|
||||
* @param shareToken 分享令牌
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】AI聊天匿名接口安全加固
|
||||
*/
|
||||
private void checkAnonymousShareAccess(AiragApp app, boolean hasAppId, String shareToken) {
|
||||
// 登录用户走原有逻辑,直接放行
|
||||
if (oConvertUtils.isNotEmpty(getUsername(null))) {
|
||||
return;
|
||||
}
|
||||
// 匿名:必须携带appId,禁止回退默认应用刷默认模型额度
|
||||
if (!hasAppId) {
|
||||
throw new JeecgBootException("请通过分享链接访问");
|
||||
}
|
||||
// 匿名:必须携带分享令牌
|
||||
if (oConvertUtils.isEmpty(shareToken)) {
|
||||
throw new JeecgBootException("请通过分享链接访问");
|
||||
}
|
||||
// 匿名:应用必须存在、已发布且令牌匹配(统一文案,不区分具体原因,防止探测)
|
||||
if (app == null
|
||||
|| !AiAppConsts.STATUS_RELEASE.equals(app.getStatus())
|
||||
|| !shareToken.equals(app.getShareToken())) {
|
||||
throw new JeecgBootException("分享链接无效或已取消发布");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateAnonymousShareAccess(String appId, String shareToken) {
|
||||
AiragApp app = null;
|
||||
if (oConvertUtils.isNotEmpty(appId)) {
|
||||
app = airagAppMapper.getByIdIgnoreTenant(appId);
|
||||
}
|
||||
checkAnonymousShareAccess(app, oConvertUtils.isNotEmpty(appId), shareToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据白名单key:聊天页仅需这些配置(issues/9787)
|
||||
*/
|
||||
private static final Set<String> SHARE_METADATA_KEYS = new HashSet<>(Arrays.asList(
|
||||
AiAppConsts.APP_METADATA_FLOW_INPUTS, "multiSession", "izDraw", "defaultSelect", "drawModelId", "modelInfo"));
|
||||
|
||||
/**
|
||||
* 构建分享视图对象:只保留前端聊天页必需字段(issues/9787)
|
||||
*
|
||||
* @param app 应用实体
|
||||
* @return 分享视图对象
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】init接口返回最小化VO
|
||||
*/
|
||||
private AiragAppShareInfoVO buildShareInfoVO(AiragApp app) {
|
||||
AiragAppShareInfoVO vo = new AiragAppShareInfoVO();
|
||||
vo.setId(app.getId());
|
||||
vo.setShareToken(app.getShareToken());
|
||||
vo.setName(app.getName());
|
||||
vo.setDescr(app.getDescr());
|
||||
vo.setIcon(app.getIcon());
|
||||
vo.setType(app.getType());
|
||||
vo.setPrologue(app.getPrologue());
|
||||
vo.setPresetQuestion(app.getPresetQuestion());
|
||||
vo.setQuickCommand(app.getQuickCommand());
|
||||
vo.setMetadata(filterShareMetadata(app.getMetadata()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 元数据按白名单key过滤重组,不原样透传(issues/9787)
|
||||
*
|
||||
* @param metadata 原元数据JSON串
|
||||
* @return 过滤后的元数据JSON串
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】init接口返回最小化VO
|
||||
*/
|
||||
private String filterShareMetadata(String metadata) {
|
||||
if (oConvertUtils.isEmpty(metadata)) {
|
||||
return null;
|
||||
}
|
||||
JSONObject source = JSONObject.parseObject(metadata);
|
||||
JSONObject target = new JSONObject();
|
||||
for (String key : SHARE_METADATA_KEYS) {
|
||||
if (source.containsKey(key)) {
|
||||
target.put(key, source.get(key));
|
||||
}
|
||||
}
|
||||
return target.toJSONString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户名
|
||||
*
|
||||
@ -2167,6 +2440,17 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
private File ensureLocalFile(String fileRef, String fileName) {
|
||||
String uploadpath = jeecgBaseConfig.getPath().getUpload();
|
||||
if (LLMConsts.WEB_PATTERN.matcher(fileRef).matches()) {
|
||||
//update-begin---author:wangshuai ---date:2026-06-16 for:【issues/9672】匿名请求禁止远程URL文件引用,防止SSRF攻击-----------
|
||||
String currentUser = null;
|
||||
try {
|
||||
HttpServletRequest req = SpringContextUtils.getHttpServletRequest();
|
||||
currentUser = JwtUtil.getUserNameByToken(req);
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (currentUser == null) {
|
||||
checkAnonymousFileRef(fileRef);
|
||||
}
|
||||
//update-end---author:wangshuai ---date:2026-06-16 for:【issues/9672】匿名请求禁止远程URL文件引用,防止SSRF攻击-----------
|
||||
//update-begin---author:wangshuai ---date:2026-04-13 for:【issues/9519】AI附件处理路径遍历漏洞:下载文件名做安全过滤,临时目录隔离---
|
||||
// 远程下载:使用 FilenameUtils.getName 剥离任何路径分隔符,再次校验防止 ..
|
||||
String safeFileName = FilenameUtils.getName(fileName);
|
||||
@ -2255,4 +2539,53 @@ public class AiragChatServiceImpl implements IAiragChatService {
|
||||
sendWithFlow(requestId, AiAppConsts.ARTICLE_WRITER_FLOW_ID, chatConversation, topicId, new ArrayList<>(), sendParams);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-06-16 for:【issues/9672】匿名请求文件引用安全校验-----------
|
||||
/**
|
||||
* 匿名请求文件引用校验:只允许访问已配置存储中 airag/ 目录下的文件
|
||||
*/
|
||||
private void checkAnonymousFileRef(String fileRef) {
|
||||
String airag = "airag/";
|
||||
String relativePath = extractStorageRelativePath(fileRef);
|
||||
if (relativePath == null || !relativePath.startsWith(airag)) {
|
||||
log.warn("匿名请求文件路径不在允许范围内: {}", fileRef);
|
||||
throw new JeecgBootException("匿名聊天不支持远程文件引用,请直接上传文件");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从完整URL中提取存储服务的相对路径,非已配置存储的URL返回null
|
||||
*/
|
||||
private String extractStorageRelativePath(String url) {
|
||||
if (oConvertUtils.isEmpty(url)) {
|
||||
return null;
|
||||
}
|
||||
// 匹配 OSS staticDomain(如 https://jeecgdev.oss-cn-beijing.aliyuncs.com)
|
||||
String ossDomain = OssBootUtil.getStaticDomain();
|
||||
if (oConvertUtils.isNotEmpty(ossDomain) && url.toLowerCase().startsWith(ossDomain.toLowerCase())) {
|
||||
String path = url.substring(ossDomain.length());
|
||||
return path.startsWith("/") ? path.substring(1) : path;
|
||||
}
|
||||
// 匹配 OSS endpoint(如 oss-cn-beijing.aliyuncs.com)
|
||||
String ossEndpoint = OssBootUtil.getEndPoint();
|
||||
if (oConvertUtils.isNotEmpty(ossEndpoint) && url.toLowerCase().contains(ossEndpoint.toLowerCase())) {
|
||||
int idx = url.indexOf(ossEndpoint);
|
||||
String afterEndpoint = url.substring(idx + ossEndpoint.length());
|
||||
int slashIdx = afterEndpoint.indexOf('/');
|
||||
return slashIdx >= 0 ? afterEndpoint.substring(slashIdx + 1) : null;
|
||||
}
|
||||
// 匹配 MinIO URL(如 http://192.168.1.100:9000/)
|
||||
String minioUrl = MinioUtil.getMinioUrl();
|
||||
if (oConvertUtils.isNotEmpty(minioUrl) && url.toLowerCase().startsWith(minioUrl.toLowerCase())) {
|
||||
String path = url.substring(minioUrl.length());
|
||||
if (path.startsWith("/")) {
|
||||
path = path.substring(1);
|
||||
}
|
||||
// 跳过 bucket 名称
|
||||
int slashIdx = path.indexOf('/');
|
||||
return slashIdx >= 0 ? path.substring(slashIdx + 1) : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
//update-end---author:wangshuai ---date:2026-06-16 for:【issues/9672】匿名请求文件引用安全校验-----------
|
||||
}
|
||||
|
||||
@ -0,0 +1,153 @@
|
||||
package org.jeecg.modules.airag.app.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
|
||||
import dev.langchain4j.data.message.AiMessage;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 图片生成内容组装组件,负责清理模型内容并将真实图片插入文章。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
@Component("imageGenerationContentAssembler")
|
||||
public class ImageGenerationContentAssembler {
|
||||
|
||||
private static final Pattern MARKDOWN_IMAGE_PATTERN = Pattern.compile("!\\[[^\\]]*\\]\\([^\\r\\n]*\\)");
|
||||
private static final Pattern IMAGE_PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{IMAGE_\\d+}}");
|
||||
private static final Pattern PARAGRAPH_BOUNDARY_PATTERN = Pattern.compile("(?:\\r?\\n){2,}");
|
||||
private static final int GENERATED_ARTICLE_IMAGE_WIDTH = 720;
|
||||
|
||||
/**
|
||||
* 将组装后的内容写回AI消息,同时保留思考过程和工具调用信息。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
public AiMessage replaceAiMessageContent(AiMessage aiMessage, String content) {
|
||||
if (aiMessage == null) {
|
||||
return AiMessage.from(content);
|
||||
}
|
||||
return AiMessage.builder()
|
||||
.text(content)
|
||||
.thinking(aiMessage.thinking())
|
||||
.toolExecutionRequests(aiMessage.toolExecutionRequests())
|
||||
.attributes(aiMessage.attributes())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除文本模型自行生成的图片标签,防止展示无效或虚构地址。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
public String removeGeneratedImageMarkdown(String content) {
|
||||
if (oConvertUtils.isEmpty(content)) {
|
||||
return "";
|
||||
}
|
||||
return MARKDOWN_IMAGE_PATTERN.matcher(content).replaceAll("").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将真实生成的图片替换到正文占位符或结构化插入位置。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
public String mergeGeneratedImages(String content, List<String> imageUrls) {
|
||||
if (CollectionUtils.isEmpty(imageUrls)) {
|
||||
return removeGeneratedImagePlaceholders(content) + "\n\n> 配图生成失败:绘画模型未返回有效图片。";
|
||||
}
|
||||
String mergedContent = content;
|
||||
List<String> unplacedImages = new ArrayList<>();
|
||||
for (int i = 0; i < imageUrls.size(); i++) {
|
||||
String placeholder = "{{IMAGE_" + (i + 1) + "}}";
|
||||
String imageMarkdown = " + " =" + GENERATED_ARTICLE_IMAGE_WIDTH + ")";
|
||||
int placeholderIndex = mergedContent.indexOf(placeholder);
|
||||
if (placeholderIndex >= 0) {
|
||||
mergedContent = mergedContent.substring(0, placeholderIndex)
|
||||
+ imageMarkdown
|
||||
+ mergedContent.substring(placeholderIndex + placeholder.length());
|
||||
} else {
|
||||
unplacedImages.add(imageMarkdown);
|
||||
}
|
||||
}
|
||||
if (!unplacedImages.isEmpty()) {
|
||||
mergedContent = distributeImagesByArticleStructure(mergedContent, unplacedImages);
|
||||
}
|
||||
return removeGeneratedImagePlaceholders(mergedContent);
|
||||
}
|
||||
|
||||
private String removeGeneratedImagePlaceholders(String content) {
|
||||
if (oConvertUtils.isEmpty(content)) {
|
||||
return "";
|
||||
}
|
||||
return IMAGE_PLACEHOLDER_PATTERN.matcher(content).replaceAll("").trim();
|
||||
}
|
||||
|
||||
private String distributeImagesByArticleStructure(String content, List<String> imageMarkdownList) {
|
||||
if (oConvertUtils.isEmpty(content)) {
|
||||
return String.join("\n\n", imageMarkdownList);
|
||||
}
|
||||
List<Integer> insertionPoints = findArticleInsertionPoints(content);
|
||||
if (insertionPoints.isEmpty()) {
|
||||
return content + "\n\n" + String.join("\n\n", imageMarkdownList);
|
||||
}
|
||||
|
||||
List<Integer> selectedPoints = new ArrayList<>(imageMarkdownList.size());
|
||||
for (int i = 0; i < imageMarkdownList.size(); i++) {
|
||||
int target = content.length() * (i + 1) / (imageMarkdownList.size() + 1);
|
||||
Integer selectedPoint = insertionPoints.stream()
|
||||
.filter(point -> !selectedPoints.contains(point))
|
||||
.min(Comparator.comparingInt(point -> Math.abs(point - target)))
|
||||
.orElse(null);
|
||||
if (selectedPoint != null) {
|
||||
selectedPoints.add(selectedPoint);
|
||||
}
|
||||
}
|
||||
|
||||
StringBuilder result = new StringBuilder(content);
|
||||
for (int i = selectedPoints.size() - 1; i >= 0; i--) {
|
||||
result.insert(selectedPoints.get(i), "\n\n" + imageMarkdownList.get(i) + "\n\n");
|
||||
}
|
||||
if (selectedPoints.size() < imageMarkdownList.size()) {
|
||||
result.append("\n\n")
|
||||
.append(String.join("\n\n", imageMarkdownList.subList(selectedPoints.size(), imageMarkdownList.size())));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private List<Integer> findArticleInsertionPoints(String content) {
|
||||
List<Integer> insertionPoints = new ArrayList<>();
|
||||
Matcher matcher = PARAGRAPH_BOUNDARY_PATTERN.matcher(content);
|
||||
int minPosition = content.length() / 10;
|
||||
int maxPosition = content.length() * 9 / 10;
|
||||
while (matcher.find()) {
|
||||
int position = matcher.start();
|
||||
if (position >= minPosition && position <= maxPosition && isBodyParagraphEnd(content, position)) {
|
||||
insertionPoints.add(position);
|
||||
}
|
||||
}
|
||||
return insertionPoints;
|
||||
}
|
||||
|
||||
private boolean isBodyParagraphEnd(String content, int position) {
|
||||
String before = content.substring(0, position).stripTrailing();
|
||||
int lineStart = Math.max(before.lastIndexOf('\n'), before.lastIndexOf('\r')) + 1;
|
||||
String previousLine = before.substring(lineStart).trim();
|
||||
return !previousLine.isEmpty()
|
||||
&& !previousLine.matches("^#{1,6}\\s+.*")
|
||||
&& !previousLine.matches("^[-*+]\\s+.*")
|
||||
&& !previousLine.matches("^\\d+[.)、]\\s+.*")
|
||||
&& !previousLine.startsWith("|")
|
||||
&& !previousLine.startsWith("```");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,423 @@
|
||||
package org.jeecg.modules.airag.app.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSONArray;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import dev.langchain4j.agent.tool.ToolSpecification;
|
||||
import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
|
||||
import dev.langchain4j.service.tool.ToolExecutor;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.airag.app.entity.AiragApp;
|
||||
import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
|
||||
import org.jeecg.modules.airag.llm.consts.LLMConsts;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragMcp;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragMcpMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* AI应用图片生成工具构建器。(AI应用配置的绘画模型 做成工具注入到应用中)
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ImageGenerationToolBuilder {
|
||||
|
||||
private static final String TOOL_NAME = "generate_images";
|
||||
private static final String DRAW_ENABLED = "1";
|
||||
private static final String METADATA_DRAW_ENABLED = "izDraw";
|
||||
private static final String METADATA_DRAW_MODEL_ID = "drawModelId";
|
||||
private static final int DEFAULT_IMAGE_COUNT = 1;
|
||||
private static final int MAX_IMAGE_COUNT = 4;
|
||||
private static final String IMAGE_TYPE_REGEX = "(?:图片|图像|配图|插图|海报|照片|插画)";
|
||||
private static final String IMAGE_QUANTITY_REGEX = "(?:张(?:\\s*" + IMAGE_TYPE_REGEX + ")?|个\\s*" + IMAGE_TYPE_REGEX + ")";
|
||||
private static final Pattern EXPLICIT_IMAGE_REQUEST_PATTERN = Pattern.compile(
|
||||
"(?:生成|制作|创建|绘制|画|设计|添加|配).{0,20}" + IMAGE_TYPE_REGEX
|
||||
+ "|(?:\\d+|[一二两三四])\\s*" + IMAGE_QUANTITY_REGEX
|
||||
);
|
||||
private static final Pattern IMAGE_COUNT_PATTERN = Pattern.compile("(\\d+|[一二两三四])\\s*" + IMAGE_QUANTITY_REGEX);
|
||||
private static final Pattern IMAGE_PLUGIN_SUBJECT_PATTERN = Pattern.compile(
|
||||
"(?:^|[^a-z0-9])(?:image|images|photo|photos|picture|pictures|illustration|illustrations)(?:$|[^a-z0-9])"
|
||||
+ "|图片|图像|照片|配图|插图|海报|插画|图库",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
private static final Pattern IMAGE_PLUGIN_ACTION_PATTERN = Pattern.compile(
|
||||
"(?:^|[^a-z0-9])(?:search|find|generate|create|draw|render|design|produce|retrieve|fetch|get|query|list|recommend)(?:$|[^a-z0-9])"
|
||||
+ "|搜索|查询|生成|绘制|创建|设计|获取|推荐",
|
||||
Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
private static final Map<String, Integer> CHINESE_IMAGE_COUNTS = Map.of(
|
||||
"一", 1,
|
||||
"二", 2,
|
||||
"两", 2,
|
||||
"三", 3,
|
||||
"四", 4
|
||||
);
|
||||
|
||||
private final IAIChatHandler aiChatHandler;
|
||||
private final AiragMcpMapper airagMcpMapper;
|
||||
|
||||
//update-begin---author:scott ---date:20260810 for:图片类插件与应用内置绘画能力互斥---
|
||||
/**
|
||||
* 判断当前应用是否已启用图片搜索或图片生成类插件。
|
||||
*
|
||||
* @param params AI聊天参数
|
||||
* @return 是否存在图片类插件
|
||||
* @author scott
|
||||
* @since 2026-08-10 图片类插件与应用内置绘画能力互斥
|
||||
*/
|
||||
public boolean hasImageRelatedPlugin(AIChatParams params) {
|
||||
if (params == null) {
|
||||
return false;
|
||||
}
|
||||
if (hasImageRelatedTool(params.getTools())) {
|
||||
return true;
|
||||
}
|
||||
List<String> pluginIds = params.getPluginIds();
|
||||
if (oConvertUtils.isObjectEmpty(pluginIds)) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
List<String> distinctPluginIds = pluginIds.stream()
|
||||
.filter(oConvertUtils::isNotEmpty)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
if (distinctPluginIds.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return airagMcpMapper.selectBatchIds(distinctPluginIds).stream()
|
||||
.filter(plugin -> !LLMConsts.STATUS_DISABLE.equals(plugin.getStatus()))
|
||||
.anyMatch(this::isImageRelatedPlugin);
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI-CHAT]识别图片类插件失败,保留应用内置绘画能力: {}", e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasImageRelatedTool(Map<ToolSpecification, ToolExecutor> tools) {
|
||||
return tools != null && tools.keySet().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.anyMatch(tool -> isImageRelatedCapability(tool.name(), tool.description()));
|
||||
}
|
||||
|
||||
private boolean isImageRelatedPlugin(AiragMcp plugin) {
|
||||
if (plugin == null) {
|
||||
return false;
|
||||
}
|
||||
if (isImageRelatedCapability(plugin.getName(), plugin.getDescr())) {
|
||||
return true;
|
||||
}
|
||||
if (oConvertUtils.isEmpty(plugin.getTools())) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
JSONArray tools = JSONArray.parseArray(plugin.getTools());
|
||||
if (tools == null) {
|
||||
return false;
|
||||
}
|
||||
return tools.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(tool -> JSONObject.parseObject(tool.toString()))
|
||||
.anyMatch(tool -> isImageRelatedCapability(tool.getString("name"), tool.getString("description")));
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI-CHAT]插件[{}]工具定义解析失败,跳过图片能力识别: {}", plugin.getName(), e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isImageRelatedCapability(String name, String description) {
|
||||
String capability = oConvertUtils.getString(name) + " " + oConvertUtils.getString(description);
|
||||
capability = capability.replaceAll("([a-z0-9])([A-Z])", "$1 $2");
|
||||
return IMAGE_PLUGIN_SUBJECT_PATTERN.matcher(capability).find()
|
||||
&& IMAGE_PLUGIN_ACTION_PATTERN.matcher(capability).find();
|
||||
}
|
||||
//update-end---author:scott ---date:20260810 for:图片类插件与应用内置绘画能力互斥---
|
||||
|
||||
/**
|
||||
* 判断用户是否明确要求生成图片。
|
||||
*
|
||||
* @param app AI应用
|
||||
* @param content 用户消息
|
||||
* @return 是否需要由后端确保生成图片
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
public boolean hasExplicitImageRequest(AiragApp app, String content) {
|
||||
return oConvertUtils.isNotEmpty(resolveDrawModelId(app))
|
||||
&& oConvertUtils.isNotEmpty(content)
|
||||
&& EXPLICIT_IMAGE_REQUEST_PATTERN.matcher(content).find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户消息中指定的数量生成配图。
|
||||
*
|
||||
* @param app AI应用
|
||||
* @param content 用户消息
|
||||
* @param articleContent 已生成的文章正文
|
||||
* @param imageUploader 图片上传函数
|
||||
* @return 已上传的图片地址
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
public List<String> generateForRequest(AiragApp app, String content, String articleContent,
|
||||
Function<Map<String, Object>, String> imageUploader) {
|
||||
String drawModelId = resolveDrawModelId(app);
|
||||
if (oConvertUtils.isEmpty(drawModelId) || oConvertUtils.isEmpty(content)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
return generateImages(drawModelId, content, articleContent, resolveImageCount(content), imageUploader);
|
||||
} catch (Exception e) {
|
||||
log.error("[AI-CHAT]自动配图生成失败", e);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建正文配图占位要求,由文本模型决定图片在文章中的语义位置。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
public String buildPlacementInstruction(String content) {
|
||||
int count = resolveImageCount(content);
|
||||
StringBuilder placeholders = new StringBuilder();
|
||||
for (int i = 1; i <= count; i++) {
|
||||
if (i > 1) {
|
||||
placeholders.append("、");
|
||||
}
|
||||
placeholders.append("{{IMAGE_").append(i).append("}}");
|
||||
}
|
||||
return "用户已明确要求生成" + count + "张配图。请正常完成文字正文,并根据上下文在最合适的位置各插入一次占位符"
|
||||
+ placeholders + "。只允许输出这些精确占位符,禁止自行编造图片URL、Markdown图片标签、配图标题或‘此处插入图片’等其他占位文字;不要声称无法生成图片。";
|
||||
}
|
||||
|
||||
/**
|
||||
* 为已开启绘画能力的应用构建图片生成工具。
|
||||
*
|
||||
* @param app AI应用
|
||||
* @param imageUploader 图片上传函数
|
||||
* @return 图片生成工具
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
public Map<ToolSpecification, ToolExecutor> buildTools(AiragApp app, Function<Map<String, Object>, String> imageUploader) {
|
||||
String drawModelId = resolveDrawModelId(app);
|
||||
if (oConvertUtils.isEmpty(drawModelId)) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
ToolSpecification specification = ToolSpecification.builder()
|
||||
.name(TOOL_NAME)
|
||||
.description("You have real image generation capability through this tool. You MUST call it when the user requests images or visual content; do not claim that you cannot generate images. For requests combining text and images, compose the requested text and include every Markdown image returned by this tool in the final answer. The count parameter must match the number of images requested by the user.")
|
||||
.parameters(JsonObjectSchema.builder()
|
||||
.addStringProperty("prompt", "A complete, detailed image-generation prompt. Required.")
|
||||
.addNumberProperty("count", "Number of distinct images requested by the user. Default 1, maximum 4.")
|
||||
.required("prompt")
|
||||
.build())
|
||||
.build();
|
||||
|
||||
ToolExecutor executor = (request, memoryId) -> execute(drawModelId, request.arguments(), imageUploader);
|
||||
Map<ToolSpecification, ToolExecutor> tools = new HashMap<>(1);
|
||||
tools.put(specification, executor);
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行模型主动调用的图片生成工具。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private String execute(String drawModelId, String arguments, Function<Map<String, Object>, String> imageUploader) {
|
||||
try {
|
||||
JSONObject args = JSONObject.parseObject(arguments);
|
||||
String prompt = args == null ? null : args.getString("prompt");
|
||||
if (oConvertUtils.isEmpty(prompt)) {
|
||||
return buildError("图片生成提示词不能为空");
|
||||
}
|
||||
int count = normalizeCount(args.getInteger("count"));
|
||||
List<String> imageUrls = generateImages(drawModelId, prompt, null, count, imageUploader);
|
||||
if (imageUrls.isEmpty()) {
|
||||
return buildError("图片生成失败,未返回有效图片");
|
||||
}
|
||||
return toMarkdown(imageUrls);
|
||||
} catch (Exception e) {
|
||||
log.error("[AI-CHAT]图片生成工具调用失败", e);
|
||||
return buildError("图片生成失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按数量逐张生成并上传图片,单张失败时继续处理后续图片。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private List<String> generateImages(String drawModelId, String prompt, String articleContent, int count,
|
||||
Function<Map<String, Object>, String> imageUploader) {
|
||||
List<String> imageUrls = new ArrayList<>(count);
|
||||
AIChatParams params = new AIChatParams();
|
||||
for (int i = 0; i < count && imageUrls.size() < count; i++) {
|
||||
String imagePrompt = buildImagePrompt(prompt, articleContent, i, count);
|
||||
List<Map<String, Object>> generatedImages;
|
||||
try {
|
||||
generatedImages = aiChatHandler.imageGenerate(drawModelId, imagePrompt, params);
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI-CHAT]第{}张配图生成失败,继续生成其他配图: {}", i + 1, e.getMessage());
|
||||
continue;
|
||||
}
|
||||
if (generatedImages == null) {
|
||||
continue;
|
||||
}
|
||||
for (Map<String, Object> generatedImage : generatedImages) {
|
||||
String imageUrl = imageUploader.apply(generatedImage);
|
||||
if (oConvertUtils.isNotEmpty(imageUrl)) {
|
||||
imageUrls.add(imageUrl);
|
||||
}
|
||||
if (imageUrls.size() >= count) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return imageUrls;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据正文局部内容构造只返回图片的绘画提示词。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private String buildImagePrompt(String userRequest, String articleContent, int imageIndex, int count) {
|
||||
String visualContext = extractVisualContext(articleContent, imageIndex, count);
|
||||
StringBuilder prompt = new StringBuilder();
|
||||
prompt.append("Generate exactly one high-quality editorial illustration for an article. Return the image only. ")
|
||||
.append("Do not answer with text, do not write an article, and do not return Markdown. ")
|
||||
.append("Use a clean horizontal 16:9 composition and avoid long paragraphs of text inside the image.\n");
|
||||
if (oConvertUtils.isNotEmpty(visualContext)) {
|
||||
prompt.append("Visual context for this illustration: ").append(visualContext).append("\n");
|
||||
} else {
|
||||
prompt.append("Visual subject: ").append(userRequest).append("\n");
|
||||
}
|
||||
prompt.append("This is illustration ").append(imageIndex + 1).append(" of ").append(count)
|
||||
.append(". Make its scene and composition visibly different from the other illustrations.");
|
||||
return prompt.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取图片占位符附近或文章均分位置附近的视觉上下文。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private String extractVisualContext(String articleContent, int imageIndex, int count) {
|
||||
if (oConvertUtils.isEmpty(articleContent)) {
|
||||
return "";
|
||||
}
|
||||
String placeholder = "{{IMAGE_" + (imageIndex + 1) + "}}";
|
||||
int center = articleContent.indexOf(placeholder);
|
||||
if (center < 0) {
|
||||
center = articleContent.length() * (imageIndex + 1) / (count + 1);
|
||||
}
|
||||
int start = Math.max(0, center - 500);
|
||||
int end = Math.min(articleContent.length(), center + 500);
|
||||
return articleContent.substring(start, end)
|
||||
.replaceAll("\\{\\{IMAGE_\\d+}}", " ")
|
||||
.replaceAll("[`#>*_|]", " ")
|
||||
.replaceAll("\\s+", " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取应用配置的绘画模型。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private String resolveDrawModelId(AiragApp app) {
|
||||
if (app == null || oConvertUtils.isEmpty(app.getMetadata())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JSONObject metadata = JSONObject.parseObject(app.getMetadata());
|
||||
if (!DRAW_ENABLED.equals(metadata.getString(METADATA_DRAW_ENABLED))) {
|
||||
return null;
|
||||
}
|
||||
return metadata.getString(METADATA_DRAW_MODEL_ID);
|
||||
} catch (Exception e) {
|
||||
log.warn("[AI-CHAT]应用绘画配置解析失败,跳过图片生成工具: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将图片数量限制在系统允许范围内。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private int normalizeCount(Integer count) {
|
||||
if (count == null || count < DEFAULT_IMAGE_COUNT) {
|
||||
return DEFAULT_IMAGE_COUNT;
|
||||
}
|
||||
return Math.min(count, MAX_IMAGE_COUNT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从用户消息中解析图片数量。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private int resolveImageCount(String content) {
|
||||
Matcher matcher = IMAGE_COUNT_PATTERN.matcher(content);
|
||||
if (!matcher.find()) {
|
||||
return DEFAULT_IMAGE_COUNT;
|
||||
}
|
||||
String countText = matcher.group(1);
|
||||
if (countText.matches("\\d+")) {
|
||||
return normalizeCount(Integer.parseInt(countText));
|
||||
}
|
||||
return normalizeCount(CHINESE_IMAGE_COUNTS.get(countText));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将真实图片地址转换为Markdown图片标签。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private String toMarkdown(List<String> imageUrls) {
|
||||
return imageUrls.stream()
|
||||
.map(url -> "")
|
||||
.collect(Collectors.joining("\n\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造图片工具的结构化错误结果。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 AI应用支持智能识别和图文混合生成
|
||||
*/
|
||||
private String buildError(String message) {
|
||||
JSONObject error = new JSONObject();
|
||||
error.put("error", message);
|
||||
return error.toJSONString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,71 @@
|
||||
package org.jeecg.modules.airag.app.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* @Description: AI应用分享信息(/airag/chat/init 返回给聊天页的视图对象)。
|
||||
* 只暴露前端聊天页必需的字段,避免泄露 prompt、tenantId、modelId 等内部配置。
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】init接口返回最小化VO,避免泄露prompt等内部配置
|
||||
*/
|
||||
@Data
|
||||
public class AiragAppShareInfoVO implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 应用ID(send 时回传)
|
||||
*/
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 分享令牌(send 时回传,匿名访问必填)
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】分享VO回传shareToken
|
||||
*/
|
||||
private String shareToken;
|
||||
|
||||
/**
|
||||
* 应用名称(聊天页标题)
|
||||
*/
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 应用描述
|
||||
*/
|
||||
private String descr;
|
||||
|
||||
/**
|
||||
* 应用图标
|
||||
*/
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* 应用类型(chat / chatFlow)
|
||||
*/
|
||||
private String type;
|
||||
|
||||
/**
|
||||
* 开场白
|
||||
*/
|
||||
private String prologue;
|
||||
|
||||
/**
|
||||
* 预设问题
|
||||
*/
|
||||
private String presetQuestion;
|
||||
|
||||
/**
|
||||
* 快捷指令
|
||||
*/
|
||||
private String quickCommand;
|
||||
|
||||
/**
|
||||
* 元数据(不原样透传,仅保留白名单 key 重新组装:
|
||||
* flowInputs、multiSession、izDraw、defaultSelect、drawModelId、modelInfo)
|
||||
*/
|
||||
private String metadata;
|
||||
}
|
||||
@ -42,6 +42,14 @@ public class ChatSendParams {
|
||||
*/
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
* 分享令牌(匿名访问必填)
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】匿名发送携带分享令牌
|
||||
*/
|
||||
private String shareToken;
|
||||
|
||||
/**
|
||||
* 图片列表
|
||||
*/
|
||||
|
||||
@ -182,6 +182,11 @@ public interface FlowPluginContent {
|
||||
*/
|
||||
String PLUGIN_DESC = "调用工作流";
|
||||
|
||||
/**
|
||||
* 流程工具名称前缀
|
||||
*/
|
||||
String FLOW_TOOL_NAME_PREFIX = "flow_";
|
||||
|
||||
/**
|
||||
* 插件请求地址
|
||||
*/
|
||||
|
||||
@ -43,6 +43,29 @@ public class LLMConsts {
|
||||
*/
|
||||
public static final String MODEL_TYPE_IMAGE = "IMAGE";
|
||||
|
||||
/**
|
||||
* OpenAI兼容协议供应商
|
||||
*/
|
||||
public static final String MODEL_PROVIDER_OPENAI = "OPENAI";
|
||||
public static final String MODEL_PROVIDER_KIMI = "KIMI";
|
||||
public static final String MODEL_PROVIDER_MINIMAX = "MINIMAX";
|
||||
public static final String MODEL_PROVIDER_VOLCENGINE = "VOLCENGINE";
|
||||
|
||||
/**
|
||||
* 将OpenAI兼容供应商转换为模型工厂支持的协议类型
|
||||
*
|
||||
* @param provider 供应商
|
||||
* @return 模型工厂协议类型
|
||||
*/
|
||||
public static String normalizeLlmProvider(String provider) {
|
||||
if (MODEL_PROVIDER_KIMI.equalsIgnoreCase(provider)
|
||||
|| MODEL_PROVIDER_MINIMAX.equalsIgnoreCase(provider)
|
||||
|| MODEL_PROVIDER_VOLCENGINE.equalsIgnoreCase(provider)) {
|
||||
return MODEL_PROVIDER_OPENAI;
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* 向量模型:默认维度
|
||||
*/
|
||||
@ -134,6 +157,76 @@ public class LLMConsts {
|
||||
}
|
||||
//update-end---author:scott ---date:20260429 for:[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash,流程中调用出现异常------------
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-06-29 for:【issues/9727】不支持Tool Calling的模型(如deepseek-r1系列)发送工具调用时报错-----------
|
||||
/**
|
||||
* 判断指定模型是否不支持 Tool Calling(工具调用/函数调用)。
|
||||
* 包括:
|
||||
* - deepseek-r1 系列(Ollama 命名:deepseek-r1、deepseek-r1:14b、deepseek-r1:7b 等)
|
||||
* - deepseek-reasoner(DeepSeek API 命名)
|
||||
* - 其他已知不支持 tools 的推理模型,后续在此追加
|
||||
*
|
||||
* @param modelName 模型名(大小写不敏感、首尾空白容错,支持 Ollama 的 name:tag 格式)
|
||||
* @return true=不支持工具调用;false=支持或未知
|
||||
*/
|
||||
public static boolean isToolCallingUnsupported(String modelName) {
|
||||
if (modelName == null || modelName.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String name = modelName.trim().toLowerCase();
|
||||
//去掉 :tag 部分(如 deepseek-r1:14b → deepseek-r1)
|
||||
String baseName = name.contains(":") ? name.substring(0, name.indexOf(":")) : name;
|
||||
return "deepseek-reasoner".equals(baseName)
|
||||
|| "deepseek-r1".equalsIgnoreCase(baseName)
|
||||
|| baseName.startsWith("deepseek-r1-");
|
||||
}
|
||||
//update-end---author:wangshuai ---date:2026-06-29 for:【issues/9727】不支持Tool Calling的模型(如Ollama的deepseek-r1系列)发送工具调用时报错-----------
|
||||
|
||||
//update-begin---author:claude ---date:2026-08-07 for:Kimi k3 等模型采样参数需固定(temperature=1/topP=0.95/presencePenalty=0/frequencyPenalty=0),传其他值报 "invalid temperature: only 1 is allowed for this model"-----------
|
||||
/**
|
||||
* 采样参数需固定的模型集合。
|
||||
* 这类模型(如 Kimi k3)请求中 temperature 不为 1 就直接 400 拒绝:
|
||||
* {"error":{"message":"invalid temperature: only 1 is allowed for this model"}}
|
||||
* 且官方推荐固定 temperature=1、topP=0.95、presencePenalty=0、frequencyPenalty=0。
|
||||
* 后续新增同类模型时在此追加。
|
||||
*/
|
||||
public static final Set<String> FIXED_SAMPLING_PARAM_MODELS = new HashSet<>(Arrays.asList(
|
||||
"k3"
|
||||
));
|
||||
|
||||
/**
|
||||
* 判断指定模型的采样参数是否需要固定(temperature=1/topP=0.95/presencePenalty=0/frequencyPenalty=0)。
|
||||
* 匹配规则:大小写不敏感,先精确匹配,再兼容 "-" 后缀变体(如 k3-256、k3-256k),
|
||||
* 同时兼容厂商前缀(如 moonshot/k3)和 Ollama 的 name:tag 格式
|
||||
*
|
||||
* @param modelName 模型名(大小写不敏感、首尾空白容错)
|
||||
* @return true=采样参数需固定;false=无此限制或未知
|
||||
*/
|
||||
public static boolean isFixedSamplingParamModel(String modelName) {
|
||||
if (modelName == null || modelName.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String name = modelName.trim().toLowerCase();
|
||||
// 去掉厂商前缀(如 moonshot/k3 → k3)
|
||||
if (name.contains("/")) {
|
||||
name = name.substring(name.lastIndexOf("/") + 1);
|
||||
}
|
||||
// 去掉 :tag 部分(如 k3:latest → k3)
|
||||
if (name.contains(":")) {
|
||||
name = name.substring(0, name.indexOf(":"));
|
||||
}
|
||||
if (FIXED_SAMPLING_PARAM_MODELS.contains(name)) {
|
||||
return true;
|
||||
}
|
||||
// 兼容带 "-" 后缀的变体(如 k3-256、k3-256k)
|
||||
for (String fixedModel : FIXED_SAMPLING_PARAM_MODELS) {
|
||||
if (name.startsWith(fixedModel + "-")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//update-end---author:claude ---date:2026-08-07 for:Kimi k3 等模型采样参数需固定(temperature=1/topP=0.95/presencePenalty=0/frequencyPenalty=0),传其他值报 "invalid temperature: only 1 is allowed for this model"-----------
|
||||
|
||||
/**
|
||||
* 知识库类型:知识库
|
||||
*/
|
||||
|
||||
@ -29,6 +29,21 @@ public class AiragBaseApiController implements IAiragBaseApi {
|
||||
return airagBaseApi.knowledgeWriteTextDocument(knowledgeId, title, content, segmentConfig);
|
||||
}
|
||||
|
||||
@PostMapping("/airag/api/knowledgeWriteFileDocument")
|
||||
public String knowledgeWriteFileDocument(
|
||||
@RequestParam("knowledgeId") String knowledgeId,
|
||||
@RequestParam("title") String title,
|
||||
@RequestParam("filePath") String filePath,
|
||||
@RequestParam(value = "segmentConfig", required = false) String segmentConfig
|
||||
) {
|
||||
return airagBaseApi.knowledgeWriteFileDocument(knowledgeId, title, filePath, segmentConfig);
|
||||
}
|
||||
|
||||
@PostMapping("/airag/api/checkKnowledgeDocsVectorizeStatus")
|
||||
public String checkKnowledgeDocsVectorizeStatus(@RequestParam("documentIds") String documentIds) {
|
||||
return airagBaseApi.checkKnowledgeDocsVectorizeStatus(documentIds);
|
||||
}
|
||||
|
||||
@PostMapping("/airag/api/getChatVariable")
|
||||
public String getChatVariable(
|
||||
@RequestParam("appId") String appId,
|
||||
|
||||
@ -61,6 +61,7 @@ public class AiragKnowledgeController {
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/list")
|
||||
@RequiresPermissions("airag:knowledge:list")
|
||||
public Result<IPage<AiragKnowledge>> queryPageList(AiragKnowledge airagKnowledge,
|
||||
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
|
||||
@ -193,6 +194,7 @@ public class AiragKnowledgeController {
|
||||
* @date 2025/2/18 18:37
|
||||
*/
|
||||
@GetMapping(value = "/doc/list")
|
||||
@RequiresPermissions("airag:knowledge:doc:list")
|
||||
public Result<IPage<AiragKnowledgeDoc>> queryDocumentPageList(AiragKnowledgeDoc airagKnowledgeDoc,
|
||||
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
|
||||
|
||||
@ -22,6 +22,7 @@ import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
import org.jeecg.modules.airag.llm.consts.LLMConsts;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.llm.handler.AIChatHandler;
|
||||
import org.jeecg.modules.airag.llm.handler.AiragModelTestParamsResolver;
|
||||
import org.jeecg.modules.airag.llm.handler.EmbeddingHandler;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragModelService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@ -62,10 +63,16 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/list")
|
||||
@RequiresPermissions("airag:model:list")
|
||||
public Result<IPage<AiragModel>> queryPageList(AiragModel airagModel, @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, HttpServletRequest req) {
|
||||
QueryWrapper<AiragModel> queryWrapper = QueryGenerator.initQueryWrapper(airagModel, req.getParameterMap());
|
||||
Page<AiragModel> page = new Page<AiragModel>(pageNo, pageSize);
|
||||
IPage<AiragModel> pageList = airagModelService.page(page, queryWrapper);
|
||||
//update-begin---author:scott ---date:20260506 for:【issues/9600】低权限用户可获取 LLM API Key——列表场景禁止回传 credential,避免有 airag:model:list 的角色一次性拖走所有模型的密钥。编辑场景密钥仍由 /queryById 单条返回-----------
|
||||
if (pageList != null && pageList.getRecords() != null) {
|
||||
pageList.getRecords().forEach(m -> m.setCredential(null));
|
||||
}
|
||||
//update-end---author:scott ---date:20260506 for:【issues/9600】低权限用户可获取 LLM API Key——列表场景禁止回传 credential,避免有 airag:model:list 的角色一次性拖走所有模型的密钥。编辑场景密钥仍由 /queryById 单条返回-----------
|
||||
return Result.OK(pageList);
|
||||
}
|
||||
|
||||
@ -90,6 +97,21 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
|
||||
return Result.OK("添加成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制AI模型配置
|
||||
*
|
||||
* @param id 原模型ID
|
||||
* @return 复制结果
|
||||
* @author scott
|
||||
* @since 2026-08-06 LHZP-1552 AI模型配置增加复制功能
|
||||
*/
|
||||
@PostMapping(value = "/copy/{id}")
|
||||
@RequiresPermissions("airag:model:add")
|
||||
public Result<String> copy(@PathVariable("id") String id) {
|
||||
airagModelService.copyModel(id);
|
||||
return Result.OK("复制成功!");
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑
|
||||
*
|
||||
@ -134,6 +156,7 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/queryById")
|
||||
@RequiresPermissions("airag:model:queryById")
|
||||
public Result<AiragModel> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
AiragModel airagModel = airagModelService.getById(id);
|
||||
if (airagModel == null) {
|
||||
@ -142,6 +165,24 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
|
||||
return Result.OK(airagModel);
|
||||
}
|
||||
|
||||
//update-begin---author:scott ---date:20260506 for:【issues/9600】低权限用户可获取 LLM API Key——新增无权限的简化单条接口,返回模型基础信息但脱敏 credential,避免泄露 API Key-----------
|
||||
/**
|
||||
* 通过id查询(简化版,无需权限),返回模型基础信息但脱敏 credential
|
||||
*
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@GetMapping(value = "/detail")
|
||||
public Result<AiragModel> queryByIdSimple(@RequestParam(name = "id", required = true) String id) {
|
||||
AiragModel airagModel = airagModelService.getById(id);
|
||||
if (airagModel == null) {
|
||||
return Result.error("未找到对应数据");
|
||||
}
|
||||
airagModel.setCredential(null);
|
||||
return Result.OK(airagModel);
|
||||
}
|
||||
//update-end---author:scott ---date:20260506 for:【issues/9600】低权限用户可获取 LLM API Key——新增无权限的简化单条接口,返回模型基础信息但脱敏 credential,避免泄露 API Key-----------
|
||||
|
||||
/**
|
||||
* 导出excel
|
||||
*
|
||||
@ -149,6 +190,7 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
|
||||
* @param airagModel
|
||||
*/
|
||||
@RequestMapping(value = "/exportXls")
|
||||
@RequiresPermissions("airag:model:exportXls")
|
||||
public ModelAndView exportXls(HttpServletRequest request, AiragModel airagModel) {
|
||||
return super.exportXls(request, airagModel, AiragModel.class, "AiRag模型配置");
|
||||
}
|
||||
@ -161,11 +203,13 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
|
||||
* @return
|
||||
*/
|
||||
@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
|
||||
@RequiresPermissions("airag:model:importExcel")
|
||||
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
|
||||
return super.importExcel(request, response, AiragModel.class);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/test")
|
||||
@RequiresPermissions("airag:model:test")
|
||||
public Result<?> test(@RequestBody AiragModel airagModel) {
|
||||
// 验证 模型名称/模型类型/基础模型
|
||||
AssertUtils.assertNotEmpty("模型名称不能为空", airagModel.getName());
|
||||
@ -183,6 +227,9 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
|
||||
//update-begin---author:wangshuai---date:2026-01-07---for:【QQYUN-12145】【AI】AI 绘画创作---=
|
||||
}else if(LLMConsts.MODEL_TYPE_IMAGE.equals(airagModel.getModelType())){
|
||||
AIChatParams aiChatParams = new AIChatParams();
|
||||
//update-begin---author:scott ---date:20260810 for:图片模型测试连接使用快速参数-----------
|
||||
AiragModelTestParamsResolver.applyImageTestParams(aiChatParams, airagModel.getProvider(), airagModel.getModelName());
|
||||
//update-end---author:scott ---date:20260810 for:图片模型测试连接使用快速参数-----------
|
||||
//update-begin---author:wangshuai---date:2026-03-02---for:兼容图生图模型测试---
|
||||
String modelName = airagModel.getModelName();
|
||||
if(ImageEditEnum.isImageEditModel(modelName)){
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
package org.jeecg.modules.airag.llm.document;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jsoup.Connection;
|
||||
import org.jsoup.Jsoup;
|
||||
import org.jsoup.nodes.Document;
|
||||
import org.jsoup.nodes.Element;
|
||||
@ -9,6 +13,7 @@ import org.jsoup.nodes.TextNode;
|
||||
import org.jsoup.select.Elements;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
|
||||
/**
|
||||
* 网页解析器,使用Jsoup爬取网页并转换为Markdown格式
|
||||
@ -34,6 +39,11 @@ public class WebPageParser {
|
||||
*/
|
||||
private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
|
||||
/**
|
||||
* 最大重定向次数
|
||||
*/
|
||||
private static final int MAX_REDIRECTS = 5;
|
||||
|
||||
/**
|
||||
* 爬取网页并转换为Markdown
|
||||
*
|
||||
@ -42,12 +52,46 @@ public class WebPageParser {
|
||||
* @throws IOException 网络请求失败时抛出
|
||||
*/
|
||||
public String parseToMarkdown(String url) throws IOException {
|
||||
Document doc = Jsoup.connect(url)
|
||||
.userAgent(USER_AGENT)
|
||||
.timeout(TIMEOUT_MS)
|
||||
.maxBodySize(MAX_BODY_SIZE)
|
||||
.followRedirects(true)
|
||||
.get();
|
||||
//update-begin---author:zhangdaihao ---date:2026-08-06 for:【issues/9808】AI知识库Web文档抓取SSRF漏洞修复,禁止自动跳转并逐跳校验-----------
|
||||
String currentUrl = url;
|
||||
Connection.Response response = null;
|
||||
for (int i = 0; i <= MAX_REDIRECTS; i++) {
|
||||
// 每一跳(含初始 URL)都重新做 SSRF 校验,拦截 loopback / link-local / 云元数据地址
|
||||
SsrfFileTypeFilter.checkSsrfHttpUrl(currentUrl);
|
||||
|
||||
response = Jsoup.connect(currentUrl)
|
||||
.userAgent(USER_AGENT)
|
||||
.timeout(TIMEOUT_MS)
|
||||
.maxBodySize(MAX_BODY_SIZE)
|
||||
// 允许任意 content-type(如 302 无 Content-Type),避免校验重定向前被 Jsoup 拦截
|
||||
.ignoreContentType(true)
|
||||
// 关键:禁止 Jsoup 自动跟随重定向,否则 SSRF 校验会被绕过
|
||||
.followRedirects(false)
|
||||
.execute();
|
||||
|
||||
int status = response.statusCode();
|
||||
if (status >= 300 && status < 400) {
|
||||
String location = response.header("Location");
|
||||
if (oConvertUtils.isEmpty(location)) {
|
||||
throw new JeecgBootException("非法重定向:Location 为空");
|
||||
}
|
||||
// 相对 Location 解析为绝对地址后回到循环顶部再次校验
|
||||
currentUrl = new URL(response.url(), location).toString();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (response == null) {
|
||||
throw new JeecgBootException("非法URL:请求失败");
|
||||
}
|
||||
int finalStatus = response.statusCode();
|
||||
if (finalStatus >= 300 && finalStatus < 400) {
|
||||
throw new JeecgBootException("非法URL:重定向次数过多");
|
||||
}
|
||||
|
||||
Document doc = response.parse();
|
||||
//update-end---author:zhangdaihao ---date:2026-08-06 for:【issues/9808】AI知识库Web文档抓取SSRF漏洞修复,禁止自动跳转并逐跳校验-----------
|
||||
|
||||
// 移除脚本、样式、导航、页脚等无关元素
|
||||
doc.select("script, style, nav, footer, header, iframe, noscript, svg, form, button, input, select, textarea, .sidebar, .nav, .menu, .footer, .header, .ad, .advertisement, .comment, .comments").remove();
|
||||
|
||||
@ -112,7 +112,6 @@ public class AiragModel implements Serializable {
|
||||
/**
|
||||
* 凭证信息
|
||||
*/
|
||||
@Excel(name = "凭证信息", width = 15)
|
||||
@Schema(description = "凭证信息")
|
||||
private String credential;
|
||||
/**
|
||||
|
||||
@ -16,7 +16,6 @@ import org.jeecg.common.util.AssertUtils;
|
||||
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.config.AiChatConfig;
|
||||
import org.jeecg.config.AiRagConfigBean;
|
||||
import org.jeecg.modules.airag.common.consts.AiragConsts;
|
||||
import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
|
||||
@ -26,6 +25,7 @@ import org.jeecg.modules.airag.llm.entity.AiragMcp;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragMcpMapper;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
|
||||
import org.jeecg.config.AiRagConfigBean;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
@ -297,7 +297,7 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
params = new AIChatParams();
|
||||
}
|
||||
|
||||
params.setProvider(airagModel.getProvider());
|
||||
params.setProvider(LLMConsts.normalizeLlmProvider(airagModel.getProvider()));
|
||||
params.setModelName(airagModel.getModelName());
|
||||
params.setBaseUrl(airagModel.getBaseUrl());
|
||||
if (oConvertUtils.isObjectNotEmpty(airagModel.getCredential())) {
|
||||
@ -359,10 +359,30 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
|
||||
//deepseek-reasoner 推理模型不支持插件tool
|
||||
String modelName = airagModel.getModelName();
|
||||
if(!LLMConsts.DEEPSEEK_REASONER.equals(modelName)){
|
||||
//update-begin---author:wangshuai ---date:2026-06-29 for:【issues/9727】不支持Tool Calling的模型(如Ollama的deepseek-r1系列)发送工具调用时报错-----------
|
||||
// modelName为空时从系统默认配置获取
|
||||
if(oConvertUtils.isEmpty(modelName) && oConvertUtils.isNotEmpty(aiChatConfig.getModel())){
|
||||
modelName = aiChatConfig.getModel();
|
||||
}
|
||||
//update-begin---author:claude ---date:2026-08-07 for:Kimi k3 等模型采样参数需固定,覆盖用户配置避免 400 报错 "invalid temperature: only 1 is allowed for this model"-----------
|
||||
if (LLMConsts.isFixedSamplingParamModel(modelName)) {
|
||||
log.info("[AI-CHAT] mergeParams modelName={} 采样参数固定,覆盖用户配置 temperature={}→1, topP={}→0.95, presencePenalty={}→0, frequencyPenalty={}→0",
|
||||
modelName, params.getTemperature(), params.getTopP(), params.getPresencePenalty(), params.getFrequencyPenalty());
|
||||
params.setTemperature(1.0);
|
||||
params.setTopP(0.95);
|
||||
params.setPresencePenalty(0.0);
|
||||
params.setFrequencyPenalty(0.0);
|
||||
}
|
||||
//update-end---author:claude ---date:2026-08-07 for:Kimi k3 等模型采样参数需固定,覆盖用户配置避免 400 报错 "invalid temperature: only 1 is allowed for this model"-----------
|
||||
if(!LLMConsts.isToolCallingUnsupported(modelName)){
|
||||
// 插件/MCP处理
|
||||
buildPlugins(params);
|
||||
} else {
|
||||
// 不支持Tool Calling的模型,清除上游已注入的默认工具,兼容旧版Ollama
|
||||
params.setTools(null);
|
||||
params.setPluginIds(null);
|
||||
}
|
||||
//update-end---author:wangshuai ---date:2026-06-29 for:【issues/9727】不支持Tool Calling的模型(如Ollama的deepseek-r1系列)发送工具调用时报错-----------
|
||||
|
||||
//update-begin---author:scott ---date:20260429 for:[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash,流程中调用出现异常------------
|
||||
// 仅对 DeepSeek 推理模型(deepseek-reasoner/deepseek-v4-flash 等)开启思考过程的捕获与回传,
|
||||
@ -409,6 +429,12 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
if (airagMcp == null) {
|
||||
continue;
|
||||
}
|
||||
//update-begin---author:scott ---date:20260803 for:【LHZP-1497】插件禁用后仍可被AI调用:禁用插件不构建工具,防止LLM调用禁用插件---
|
||||
if (LLMConsts.STATUS_DISABLE.equals(airagMcp.getStatus())) {
|
||||
log.warn("插件[{}]已禁用,跳过工具构建", airagMcp.getName());
|
||||
continue;
|
||||
}
|
||||
//update-end---author:scott ---date:20260803 for:【LHZP-1497】插件禁用后仍可被AI调用:禁用插件不构建工具,防止LLM调用禁用插件---
|
||||
|
||||
String category = airagMcp.getCategory();
|
||||
if (oConvertUtils.isEmpty(category)) {
|
||||
@ -419,20 +445,30 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
if ("mcp".equalsIgnoreCase(category)) {
|
||||
// MCP类型:构建McpToolProviderWrapper(包含连接引用用于后续关闭)
|
||||
// for [QQYUN-9234] MCP服务连接关闭
|
||||
McpToolProviderWrapper wrapper = buildMcpToolProviderWrapper(
|
||||
airagMcp.getName(),
|
||||
airagMcp.getType(),
|
||||
airagMcp.getEndpoint(),
|
||||
airagMcp.getHeaders(),
|
||||
aiRagConfigBean.getAllowSensitiveNodes()
|
||||
);
|
||||
if (wrapper != null) {
|
||||
mcpToolProviders.add(wrapper.getMcpToolProvider());
|
||||
mcpToolProviderWrappers.add(wrapper);
|
||||
//update-begin---author:wangshuai ---date:20260804 for:【LHZP-1501】配置的MCP报错,导致的右侧的会话直接调用大模型失败了---
|
||||
try {
|
||||
McpToolProviderWrapper wrapper = buildMcpToolProviderWrapper(
|
||||
airagMcp.getName(),
|
||||
airagMcp.getType(),
|
||||
airagMcp.getEndpoint(),
|
||||
airagMcp.getHeaders(),
|
||||
aiRagConfigBean.getAllowSensitiveNodes()
|
||||
);
|
||||
if (wrapper != null) {
|
||||
mcpToolProviders.add(wrapper.getMcpToolProvider());
|
||||
mcpToolProviderWrappers.add(wrapper);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("MCP插件[{}]初始化失败,已跳过该插件,不影响本次聊天。endpoint={}",
|
||||
airagMcp.getName(), airagMcp.getEndpoint(), e);
|
||||
}
|
||||
//update-end---author:wangshuai ---date:20260804 for:【LHZP-1501】配置的MCP报错,导致的右侧的会话直接调用大模型失败了---
|
||||
} else if ("plugin".equalsIgnoreCase(category)) {
|
||||
// 插件类型:构建ToolSpecification和ToolExecutor
|
||||
Map<ToolSpecification, ToolExecutor> tools = PluginToolBuilder.buildTools(airagMcp, params.getCurrentHttpRequest());
|
||||
//update-begin---author:scott ---date:20260810 for:插件禁用后立即阻止已加载会话继续调用---
|
||||
Map<ToolSpecification, ToolExecutor> tools = PluginToolBuilder.buildTools(airagMcp, params.getCurrentHttpRequest(),
|
||||
() -> isPluginAvailable(pluginId));
|
||||
//update-end---author:scott ---date:20260810 for:插件禁用后立即阻止已加载会话继续调用---
|
||||
if (tools != null && !tools.isEmpty()) {
|
||||
pluginTools.putAll(tools);
|
||||
}
|
||||
@ -460,6 +496,19 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询插件执行时的最新状态,已删除或已禁用均视为不可用。
|
||||
*
|
||||
* @param pluginId 插件ID
|
||||
* @return 是否允许继续执行
|
||||
* @author scott
|
||||
* @since 2026-08-10 插件禁用后立即阻止已加载会话继续调用
|
||||
*/
|
||||
private boolean isPluginAvailable(String pluginId) {
|
||||
AiragMcp latestPlugin = airagMcpMapper.selectById(pluginId);
|
||||
return latestPlugin != null && !LLMConsts.STATUS_DISABLE.equals(latestPlugin.getStatus());
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserMessage buildUserMessage(String content, List<String> images) {
|
||||
AssertUtils.assertNotEmpty("请输入消息内容", content);
|
||||
@ -602,6 +651,9 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
byte[] fileContent;
|
||||
if (matcher.matches()) {
|
||||
// 来源于网络
|
||||
//update-begin---author:zhangdaihao ---date:2026-08-06 for:【issues/9805】AI图生图imageUrl未校验导致SSRF,增加URL安全校验-----------
|
||||
SsrfFileTypeFilter.checkSsrfHttpUrl(imageUrl);
|
||||
//update-end---author:zhangdaihao ---date:2026-08-06 for:【issues/9805】AI图生图imageUrl未校验导致SSRF,增加URL安全校验-----------
|
||||
java.net.URL url = new java.net.URL(imageUrl);
|
||||
java.net.URLConnection conn = url.openConnection();
|
||||
conn.setConnectTimeout(5000);
|
||||
@ -633,7 +685,7 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
originalImageBase64List.add(Base64.getEncoder().encodeToString(fileContent));
|
||||
} catch (Exception e) {
|
||||
log.error("图片读取失败: {}", imageUrl, e);
|
||||
throw new JeecgBootException("图片读取失败: " + imageUrl);
|
||||
throw new JeecgBootException("图片读取失败: " + imageUrl, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -664,7 +716,9 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
|
||||
if (oConvertUtils.isNotEmpty(exceptionMsg)) {
|
||||
// 1.工具调用消息序列不完整
|
||||
if (exceptionMsg.contains("messages with role 'tool' must be a response to a preceeding message with 'tool_calls'")) {
|
||||
//update-begin---author:scott ---date:20260810 for:修正匹配串拼写与大小写(原"preceeding"为拼写错误,且DeepSeek实际返回以"Messages"开头),该友好提示此前永不命中-----------
|
||||
if (exceptionMsg.toLowerCase().contains("must be a response to a preceding message with 'tool_calls'")) {
|
||||
//update-end---author:scott ---date:20260810 for:修正匹配串拼写与大小写,该友好提示此前永不命中-----------
|
||||
errMsg = "消息序列不完整,可能是因为历史消息数量设置过小导致工具调用上下文丢失。建议增加历史消息数量后重试。";
|
||||
log.error("AI模型调用异常: 工具调用消息序列不完整,建议增加历史消息数量。异常详情: {}", exceptionMsg, e);
|
||||
return new JeecgBootException(errMsg);
|
||||
|
||||
@ -0,0 +1,115 @@
|
||||
package org.jeecg.modules.airag.llm.handler;
|
||||
|
||||
import org.jeecg.ai.factory.AiModelFactory;
|
||||
import org.jeecg.ai.handler.LLMHandler;
|
||||
import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* AI模型测试连接参数解析器。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-10 【无号】图片模型测试连接参数迁移至Airag模块
|
||||
*/
|
||||
public final class AiragModelTestParamsResolver {
|
||||
|
||||
private static final int TEST_IMAGE_COUNT = 1;
|
||||
private static final String QWEN_TEST_IMAGE_SIZE = "512*512";
|
||||
private static final String DALL_E_2_TEST_IMAGE_SIZE = "256x256";
|
||||
private static final String OPENAI_TEST_IMAGE_SIZE = "1024x1024";
|
||||
private static final String LOW_IMAGE_QUALITY = "low";
|
||||
private static final String PROMPT_EXTEND_PARAM = "prompt_extend";
|
||||
|
||||
private AiragModelTestParamsResolver() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用图片模型测试连接专用参数。
|
||||
*
|
||||
* @param params AI调用参数
|
||||
* @param provider 供应商
|
||||
* @param modelName 模型名称
|
||||
* @author scott
|
||||
* @since 2026-08-10 【无号】图片模型测试连接参数迁移至Airag模块
|
||||
*/
|
||||
public static void applyImageTestParams(AIChatParams params, String provider, String modelName) {
|
||||
Objects.requireNonNull(params, "AI调用参数不能为空");
|
||||
params.setImageCount(TEST_IMAGE_COUNT);
|
||||
params.setImageSize(resolveTestImageSize(provider, modelName));
|
||||
params.setImageQuality(resolveTestImageQuality(provider, modelName));
|
||||
params.setExtraParams(resolveTestImageExtraParams(provider, modelName));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取测试连接专用的最小图片尺寸。
|
||||
*
|
||||
* @param provider 供应商
|
||||
* @param modelName 模型名称
|
||||
* @return 测试图片尺寸,null表示使用供应商默认值
|
||||
* @author scott
|
||||
* @since 2026-08-10 【无号】图片模型测试连接参数迁移至Airag模块
|
||||
*/
|
||||
static String resolveTestImageSize(String provider, String modelName) {
|
||||
if (provider == null || provider.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String model = normalizeModelName(modelName);
|
||||
if (AiModelFactory.AIMODEL_TYPE_OPENAI.equalsIgnoreCase(provider)) {
|
||||
return model.startsWith("dall-e-2") ? DALL_E_2_TEST_IMAGE_SIZE : OPENAI_TEST_IMAGE_SIZE;
|
||||
}
|
||||
if (AiModelFactory.AIMODEL_TYPE_QWEN.equalsIgnoreCase(provider)
|
||||
&& (LLMHandler.isQwenImage3Model(provider, modelName) || model.contains("turbo"))) {
|
||||
return QWEN_TEST_IMAGE_SIZE;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取测试连接专用的最低图片质量。
|
||||
*
|
||||
* @param provider 供应商
|
||||
* @param modelName 模型名称
|
||||
* @return 测试图片质量,null表示使用供应商默认值
|
||||
* @author scott
|
||||
* @since 2026-08-10 【无号】图片模型测试连接参数迁移至Airag模块
|
||||
*/
|
||||
static String resolveTestImageQuality(String provider, String modelName) {
|
||||
String model = normalizeModelName(modelName);
|
||||
if (AiModelFactory.AIMODEL_TYPE_OPENAI.equalsIgnoreCase(provider) && model.startsWith("gpt-image")) {
|
||||
return LOW_IMAGE_QUALITY;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取测试连接专用扩展参数。
|
||||
*
|
||||
* @param provider 供应商
|
||||
* @param modelName 模型名称
|
||||
* @return 测试扩展参数,null表示无需设置
|
||||
* @author scott
|
||||
* @since 2026-08-10 【无号】图片模型测试连接参数迁移至Airag模块
|
||||
*/
|
||||
static Map<String, Object> resolveTestImageExtraParams(String provider, String modelName) {
|
||||
if (LLMHandler.isQwenImage3Model(provider, modelName)) {
|
||||
return Collections.singletonMap(PROMPT_EXTEND_PARAM, false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化模型名称。
|
||||
*
|
||||
* @param modelName 模型名称
|
||||
* @return 小写模型名称
|
||||
* @author scott
|
||||
* @since 2026-08-10 【无号】图片模型测试连接参数迁移至Airag模块
|
||||
*/
|
||||
private static String normalizeModelName(String modelName) {
|
||||
return modelName == null ? "" : modelName.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@ -772,6 +772,9 @@ public class EmbeddingHandler implements IEmbeddingHandler {
|
||||
if (!matcher.matches()) {
|
||||
throw new JeecgBootException("网页URL格式不正确,请以http://或https://开头");
|
||||
}
|
||||
//update-begin---author:zhangdaihao ---date:2026-08-06 for:【issues/9808】AI知识库Web文档抓取SSRF漏洞修复,抓取前调用SSRF校验器-----------
|
||||
SsrfFileTypeFilter.checkSsrfHttpUrl(website);
|
||||
//update-end---author:zhangdaihao ---date:2026-08-06 for:【issues/9808】AI知识库Web文档抓取SSRF漏洞修复,抓取前调用SSRF校验器-----------
|
||||
|
||||
try {
|
||||
WebPageParser webPageParser = new WebPageParser();
|
||||
|
||||
@ -22,6 +22,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.BooleanSupplier;
|
||||
|
||||
/**
|
||||
* 插件工具构建器
|
||||
@ -34,6 +35,8 @@ import java.util.*;
|
||||
@Slf4j
|
||||
public class PluginToolBuilder {
|
||||
|
||||
private static final String PLUGIN_UNAVAILABLE_MESSAGE = "插件已禁用或删除,本次不再调用该工具。请继续完成剩余任务。";
|
||||
|
||||
/**
|
||||
* 从插件配置构建工具Map
|
||||
*
|
||||
@ -41,6 +44,21 @@ public class PluginToolBuilder {
|
||||
* @return Map<ToolSpecification, ToolExecutor>
|
||||
*/
|
||||
public static Map<ToolSpecification, ToolExecutor> buildTools(AiragMcp airagMcp, HttpServletRequest currentHttpRequest) {
|
||||
return buildTools(airagMcp, currentHttpRequest, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从插件配置构建工具Map,并在执行HTTP请求前检查插件是否仍可用。
|
||||
*
|
||||
* @param airagMcp 插件配置
|
||||
* @param currentHttpRequest 当前请求
|
||||
* @param availableChecker 插件实时可用状态检查器,为空时不检查
|
||||
* @return Map<ToolSpecification, ToolExecutor>
|
||||
* @author scott
|
||||
* @since 2026-08-10 插件禁用后立即阻止已加载会话继续调用
|
||||
*/
|
||||
public static Map<ToolSpecification, ToolExecutor> buildTools(AiragMcp airagMcp, HttpServletRequest currentHttpRequest,
|
||||
BooleanSupplier availableChecker) {
|
||||
Map<ToolSpecification, ToolExecutor> tools = new HashMap<>();
|
||||
if (airagMcp == null || oConvertUtils.isEmpty(airagMcp.getTools())) {
|
||||
return tools;
|
||||
@ -79,10 +97,9 @@ public class PluginToolBuilder {
|
||||
if (toolConfig == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
ToolSpecification spec = buildToolSpecification(toolConfig);
|
||||
ToolExecutor executor = buildToolExecutor(toolConfig, baseUrl, headersMap, isNeedSign);
|
||||
ToolExecutor executor = buildToolExecutor(toolConfig, baseUrl, headersMap, isNeedSign, airagMcp.getName(), availableChecker);
|
||||
if (spec != null && executor != null) {
|
||||
tools.put(spec, executor);
|
||||
}
|
||||
@ -150,6 +167,12 @@ public class PluginToolBuilder {
|
||||
if (param == null) {
|
||||
continue;
|
||||
}
|
||||
//update-begin---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型无法将AI应用信息写入记忆库-----------
|
||||
// 服务端固定参数不暴露给模型,由工具执行器按 defaultValue 自动注入
|
||||
if (Boolean.TRUE.equals(param.getBoolean("hidden"))) {
|
||||
continue;
|
||||
}
|
||||
//update-end---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型无法将AI应用信息写入记忆库-----------
|
||||
String paramName = param.getString("name");
|
||||
String paramDesc = param.getString("description");
|
||||
String paramType = param.getString("type");
|
||||
@ -193,9 +216,11 @@ public class PluginToolBuilder {
|
||||
/**
|
||||
* 构建ToolExecutor
|
||||
*/
|
||||
private static ToolExecutor buildToolExecutor(JSONObject toolConfig, String baseUrl, Map<String, String> defaultHeaders, boolean isNeedSign) {
|
||||
private static ToolExecutor buildToolExecutor(JSONObject toolConfig, String baseUrl, Map<String, String> defaultHeaders, boolean isNeedSign,
|
||||
String pluginName, BooleanSupplier availableChecker) {
|
||||
String path = toolConfig.getString("path");
|
||||
String method = toolConfig.getString("method");
|
||||
String toolName = toolConfig.getString("name");
|
||||
JSONArray parameters = toolConfig.getJSONArray("parameters");
|
||||
|
||||
if (oConvertUtils.isEmpty(path) || oConvertUtils.isEmpty(method)) {
|
||||
@ -205,6 +230,12 @@ public class PluginToolBuilder {
|
||||
|
||||
return (toolExecutionRequest, memoryId) -> {
|
||||
try {
|
||||
//update-begin---author:scott ---date:20260810 for:插件禁用后立即阻止已加载会话继续调用---
|
||||
if (availableChecker != null && !availableChecker.getAsBoolean()) {
|
||||
log.warn("插件[{}]已禁用或删除,终止工具[{}]调用", pluginName, toolName);
|
||||
return PLUGIN_UNAVAILABLE_MESSAGE;
|
||||
}
|
||||
//update-end---author:scott ---date:20260810 for:插件禁用后立即阻止已加载会话继续调用---
|
||||
// 解析AI传入的参数
|
||||
JSONObject args = JSONObject.parseObject(toolExecutionRequest.arguments());
|
||||
|
||||
|
||||
@ -14,4 +14,13 @@ import java.util.List;
|
||||
*/
|
||||
public interface IAiragModelService extends IService<AiragModel> {
|
||||
|
||||
/**
|
||||
* 复制AI模型配置
|
||||
*
|
||||
* @param id 原模型ID
|
||||
* @author scott
|
||||
* @since 2026-08-06 LHZP-1552 AI模型配置增加复制功能
|
||||
*/
|
||||
void copyModel(String id);
|
||||
|
||||
}
|
||||
|
||||
@ -55,7 +55,7 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
|
||||
log.info("开始构建流程插件");
|
||||
// 1. 查询所有启用的流程
|
||||
LambdaQueryWrapper<AiragFlow> queryWrapper = new LambdaQueryWrapper<>();
|
||||
queryWrapper.eq(AiragFlow::getStatus, FlowConsts.FLOW_STATUS_ENABLE);
|
||||
queryWrapper.in(AiragFlow::getStatus, FlowConsts.FLOW_STATUS_ENABLE, FlowConsts.FLOW_STATUS_RELEASE);
|
||||
queryWrapper.in(AiragFlow::getId, Arrays.asList(flowIds.split(SymbolConstant.COMMA)));
|
||||
List<AiragFlow> flows = airagFlowService.list(queryWrapper);
|
||||
HttpServletRequest httpServletRequest = SpringContextUtils.getHttpServletRequest();
|
||||
@ -84,13 +84,13 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
|
||||
for (AiragFlow flow : flows) {
|
||||
try {
|
||||
|
||||
SubFlowResult subFlow = new SubFlowResult(flow);
|
||||
SubFlowResult flowVo = new SubFlowResult(flow);
|
||||
// 获取入参参数
|
||||
JSONArray parameter = getInputParameter(flow, subFlow);
|
||||
JSONArray parameter = getInputParameter(flow, flowVo);
|
||||
// 获取出参参数
|
||||
JSONArray outParams = getOutputParameter(flow, subFlow);
|
||||
JSONArray outParams = getOutputParameter(flow, flowVo);
|
||||
// name必须符合 ^[a-zA-Z0-9_-]+$
|
||||
String validToolName = "flow_" + flow.getId();
|
||||
String validToolName = FlowPluginContent.FLOW_TOOL_NAME_PREFIX + flow.getId();
|
||||
// 将原始名称拼接到描述中
|
||||
String description = flow.getName();
|
||||
if (oConvertUtils.isNotEmpty(flow.getDescr())) {
|
||||
@ -195,10 +195,11 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
|
||||
* 获取参数
|
||||
*
|
||||
* @param flow
|
||||
* @param subFlow
|
||||
* @param flowVo
|
||||
*/
|
||||
private JSONArray getInputParameter(AiragFlow flow, SubFlowResult subFlow) {
|
||||
private JSONArray getInputParameter(AiragFlow flow, SubFlowResult flowVo) {
|
||||
JSONArray parameters = new JSONArray();
|
||||
/*
|
||||
String metadata = flow.getMetadata();
|
||||
if (oConvertUtils.isNotEmpty(metadata)) {
|
||||
JSONObject jsonObject = JSONObject.parseObject(metadata);
|
||||
@ -213,18 +214,23 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
|
||||
parameters.addAll(jsonArray);
|
||||
}
|
||||
}
|
||||
//需要获取子流程的参数,子流程的参数是单独封装的,否则在流程执行的时候会报错缺少参数
|
||||
List<FlowNodeConfig.NodeParam> inputParams = subFlow.getInputParams();
|
||||
*/
|
||||
// 仅使用开始节点的入参(flowVo.getInputParams()):其 name 取真实字段名(field),可正确作为请求体的key。
|
||||
// 不再叠加 metadata.inputs
|
||||
// 直接作为参数会导致请求体的key错误(取不到字段),且与开始节点入参重复(同一字段出现两次)。
|
||||
List<FlowNodeConfig.NodeParam> inputParams = flowVo.getInputParams();
|
||||
if (inputParams != null) {
|
||||
for (FlowNodeConfig.NodeParam param : inputParams) {
|
||||
String field = param.getField();
|
||||
// 历史记录、图片由聊天服务在直连流程时自动注入,作为工具入参暴露给模型只会成为噪音且无法被有效填写
|
||||
if (FlowConsts.FLOW_INPUT_PARAM_HISTORY.equals(field) || FlowConsts.FLOW_INPUT_PARAM_IMAGES.equals(field)) {
|
||||
continue;
|
||||
}
|
||||
JSONObject p = new JSONObject();
|
||||
// 参数名
|
||||
p.put(FlowPluginContent.NAME, param.getField());
|
||||
String paramDesc = param.getName();
|
||||
if (oConvertUtils.isEmpty(paramDesc)) {
|
||||
paramDesc = param.getField();
|
||||
}
|
||||
// 参数描述
|
||||
String paramDesc = oConvertUtils.getString(param.getName(), param.getField());
|
||||
p.put(FlowPluginContent.DESCRIPTION, paramDesc);
|
||||
// 类型
|
||||
p.put(FlowPluginContent.TYPE, oConvertUtils.getString(param.getType(), FlowPluginContent.TYPE_STRING));
|
||||
@ -241,7 +247,7 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
|
||||
/**
|
||||
* 构建返回值
|
||||
*/
|
||||
private JSONArray getOutputParameter(AiragFlow flow, SubFlowResult subFlow) {
|
||||
private JSONArray getOutputParameter(AiragFlow flow, SubFlowResult flowVo) {
|
||||
JSONArray parameters = new JSONArray();
|
||||
String metadata = flow.getMetadata();
|
||||
if (oConvertUtils.isNotEmpty(metadata)) {
|
||||
@ -251,7 +257,7 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
|
||||
parameters.addAll(jsonArray);
|
||||
}
|
||||
}
|
||||
// List<FlowNodeConfig.NodeParam> outputParams = subFlow.getOutputParams();
|
||||
// List<FlowNodeConfig.NodeParam> outputParams = flowVo.getOutputParams();
|
||||
// if (outputParams != null) {
|
||||
// for (FlowNodeConfig.NodeParam param : outputParams) {
|
||||
// JSONObject p = new JSONObject();
|
||||
|
||||
@ -32,6 +32,7 @@ import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@ -408,7 +409,12 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
|
||||
Files.createDirectories(targetDir);
|
||||
}
|
||||
|
||||
try (ZipFile zipFile = new ZipFile(zipFilePath.toFile())) {
|
||||
//update-begin---author:wangshuai ---date:2026-06-25 for:【QQYUN-16635】知识库上传压缩包报错,中文zip文件名乱码,指定GBK回退编码(条目带UTF-8标志仍用UTF-8,兼容两种压缩包)-----------
|
||||
try (ZipFile zipFile = ZipFile.builder()
|
||||
.setFile(zipFilePath.toFile())
|
||||
.setCharset(Charset.forName("GBK"))
|
||||
.get()) {
|
||||
//update-end---author:wangshuai ---date:2026-06-25 for:【QQYUN-16635】知识库上传压缩包报错,中文zip文件名乱码,指定GBK回退编码(条目带UTF-8标志仍用UTF-8,兼容两种压缩包)-----------
|
||||
Enumeration<ZipArchiveEntry> entries = zipFile.getEntries();
|
||||
|
||||
while (entries.hasMoreElements()) {
|
||||
@ -463,7 +469,12 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
|
||||
if (normalizedName.startsWith("__MACOSX/")) {
|
||||
return true;
|
||||
}
|
||||
String fileName = Paths.get(normalizedName).getFileName().toString();
|
||||
//update-begin---author:wangshuai ---date:2026-06-25 for:【QQYUN-16635】用字符串截取取文件名,避免文件名含非法字符时 Paths.get 抛 InvalidPathException-----------
|
||||
// 去掉末尾的目录分隔符再取最后一段,避免对目录项(如 "知识库/")解析为空
|
||||
String trimmed = normalizedName.endsWith("/") ? normalizedName.substring(0, normalizedName.length() - 1) : normalizedName;
|
||||
int slashIdx = trimmed.lastIndexOf('/');
|
||||
String fileName = slashIdx >= 0 ? trimmed.substring(slashIdx + 1) : trimmed;
|
||||
//update-end---author:wangshuai ---date:2026-06-25 for:【QQYUN-16635】用字符串截取取文件名,避免文件名含非法字符时 Paths.get 抛 InvalidPathException-----------
|
||||
return fileName.startsWith("._") || fileName.equals(".DS_Store");
|
||||
}
|
||||
//update-end---author:scott ---date:2026-04-16 for:【issues/9551】macOS压缩包隐藏文件过滤-----------
|
||||
|
||||
@ -136,6 +136,10 @@ public class AiragKnowledgeServiceImpl extends ServiceImpl<AiragKnowledgeMapper,
|
||||
knowIdParam.put(FlowPluginContent.LOCATION, FlowPluginContent.LOCATION_BODY);
|
||||
knowIdParam.put(FlowPluginContent.REQUIRED, true);
|
||||
knowIdParam.put(FlowPluginContent.DEFAULT_VALUE, knowId);
|
||||
//update-begin---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型无法将AI应用信息写入记忆库-----------
|
||||
// 记忆库ID由服务端注入,避免模型因无法获知必填ID而放弃工具调用
|
||||
knowIdParam.put("hidden", true);
|
||||
//update-end---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型无法将AI应用信息写入记忆库-----------
|
||||
parameters.add(knowIdParam);
|
||||
|
||||
// 内容参数
|
||||
@ -199,6 +203,10 @@ public class AiragKnowledgeServiceImpl extends ServiceImpl<AiragKnowledgeMapper,
|
||||
knowIdParam.put(FlowPluginContent.LOCATION, FlowPluginContent.LOCATION_BODY);
|
||||
knowIdParam.put(FlowPluginContent.REQUIRED, true);
|
||||
knowIdParam.put(FlowPluginContent.DEFAULT_VALUE, knowId);
|
||||
//update-begin---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型无法将AI应用信息写入记忆库-----------
|
||||
// 记忆库ID由服务端注入,避免模型因无法获知必填ID而放弃工具调用
|
||||
knowIdParam.put("hidden", true);
|
||||
//update-end---author:wangshuai ---date:20260804 for:【LHZP-1591】智普模型无法将AI应用信息写入记忆库-----------
|
||||
parameters.add(knowIdParam);
|
||||
|
||||
// 查询内容参数
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
package org.jeecg.modules.airag.llm.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import org.jeecg.common.util.AssertUtils;
|
||||
import org.jeecg.modules.airag.llm.entity.AiragModel;
|
||||
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
|
||||
import org.jeecg.modules.airag.llm.service.IAiragModelService;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* @Description: AiRag模型配置
|
||||
@ -15,5 +18,25 @@ import org.springframework.stereotype.Service;
|
||||
@Service
|
||||
public class AiragModelServiceImpl extends ServiceImpl<AiragModelMapper, AiragModel> implements IAiragModelService {
|
||||
|
||||
private static final String COPY_NAME_SUFFIX = "-复制";
|
||||
private static final int INACTIVE_FLAG = 0;
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-08-06 LHZP-1552 AI模型配置增加复制功能
|
||||
*/
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void copyModel(String id) {
|
||||
AiragModel sourceModel = this.getById(id);
|
||||
AssertUtils.assertNotEmpty("模型不存在", sourceModel);
|
||||
AiragModel copiedModel = new AiragModel();
|
||||
BeanUtils.copyProperties(sourceModel, copiedModel, "id", "createBy", "createTime", "updateBy", "updateTime", "sysOrgCode", "tenantId");
|
||||
copiedModel.setName(sourceModel.getName() + COPY_NAME_SUFFIX);
|
||||
copiedModel.setActivateFlag(INACTIVE_FLAG);
|
||||
this.save(copiedModel);
|
||||
}
|
||||
|
||||
}
|
||||
@ -56,7 +56,28 @@ public class AiragPromptsController extends JeecgController<AiragPrompts, IAirag
|
||||
IPage<AiragPrompts> pageList = airagPromptsService.page(page, queryWrapper);
|
||||
return Result.OK(pageList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页列表查询
|
||||
*
|
||||
* @param airagPrompts
|
||||
* @param pageNo
|
||||
* @param pageSize
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@Operation(summary="airag_prompts-回收站分页列表查询")
|
||||
@GetMapping(value = "/recycleBinList")
|
||||
//update-begin---author:chenrui ---date:2026-04-07 for:【QQYUN-14643】修复回收站查询逻辑,绕过@TableLogic过滤-----------
|
||||
public Result<IPage<AiragPrompts>> recycleBinList(AiragPrompts airagPrompts,
|
||||
@RequestParam(name="pageNo", defaultValue="1") Integer pageNo,
|
||||
@RequestParam(name="pageSize", defaultValue="10") Integer pageSize,
|
||||
HttpServletRequest req) {
|
||||
Page<AiragPrompts> page = new Page<>(pageNo, pageSize);
|
||||
IPage<AiragPrompts> pageList = airagPromptsService.recycleBinPage(page);
|
||||
return Result.OK(pageList);
|
||||
}
|
||||
//update-end---author:chenrui ---date:2026-04-07 for:【QQYUN-14643】修复回收站查询逻辑,绕过@TableLogic过滤-----------
|
||||
|
||||
/**
|
||||
* 添加
|
||||
*
|
||||
@ -131,6 +152,25 @@ public class AiragPromptsController extends JeecgController<AiragPrompts, IAirag
|
||||
}
|
||||
return Result.OK(airagPrompts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从回收站取回(支持多个id,逗号分割)
|
||||
*/
|
||||
@Operation(summary = "提示词-从回收站取回")
|
||||
@PutMapping(value = "/revertRecycleBin")
|
||||
public Result<?> revertRecycleBin(@RequestParam(name = "ids", required = true) String ids) {
|
||||
airagPromptsService.revertRecycleBin(Arrays.asList(ids.split(",")));
|
||||
return Result.OK("已从回收站取回!");
|
||||
}
|
||||
/**
|
||||
* 从回收站彻底删除(支持多个id,逗号分割)
|
||||
*/
|
||||
@Operation(summary = "提示词-从回收站彻底删除")
|
||||
@DeleteMapping(value = "/deleteRecycleBin")
|
||||
public Result<?> deleteRecycleBin(@RequestParam(name = "ids", required = true) String ids) {
|
||||
airagPromptsService.deleteRecycleBin(Arrays.asList(ids.split(",")));
|
||||
return Result.OK("从回收站彻底删除!");
|
||||
}
|
||||
/**
|
||||
* 构造器调试
|
||||
*
|
||||
|
||||
@ -41,10 +41,6 @@ public class AiragPrompts implements Serializable {
|
||||
@Excel(name = "提示词名称", width = 15)
|
||||
@Schema(description = "提示词名称")
|
||||
private java.lang.String name;
|
||||
/**提示词名称*/
|
||||
@Excel(name = "提示key", width = 15)
|
||||
@Schema(description = "提示key")
|
||||
private java.lang.String promptKey;
|
||||
/**提示词功能描述*/
|
||||
@Excel(name = "提示词功能描述", width = 15)
|
||||
@Schema(description = "提示词功能描述")
|
||||
|
||||
@ -2,7 +2,11 @@ package org.jeecg.modules.airag.prompts.mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.apache.ibatis.annotations.Delete;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
import org.jeecg.modules.airag.prompts.entity.AiragPrompts;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
|
||||
@ -14,4 +18,22 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
*/
|
||||
public interface AiragPromptsMapper extends BaseMapper<AiragPrompts> {
|
||||
|
||||
//update-begin---author:chenrui ---date:2026-04-07 for:【QQYUN-14643】实现回收站取回和彻底删除-----------
|
||||
/**
|
||||
* 查询回收站分页列表(del_flag=1),绕过 @TableLogic 自动过滤
|
||||
*/
|
||||
@Select("SELECT * FROM airag_prompts WHERE del_flag = 1")
|
||||
IPage<AiragPrompts> selectRecycleBinPage(IPage<AiragPrompts> page);
|
||||
|
||||
/**
|
||||
* 从回收站取回(将 del_flag 置为 0),绕过 @TableLogic 自动过滤
|
||||
*/
|
||||
void revertRecycleBin(@Param("ids") List<String> ids);
|
||||
|
||||
/**
|
||||
* 从回收站彻底删除(物理删除),绕过 @TableLogic 自动过滤
|
||||
*/
|
||||
void deleteRecycleBin(@Param("ids") List<String> ids);
|
||||
//update-end---author:chenrui ---date:2026-04-07 for:【QQYUN-14643】实现回收站取回和彻底删除-----------
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="org.jeecg.modules.airag.prompts.mapper.AiragPromptsMapper">
|
||||
<!-- 更新被逻辑删除的用户 -->
|
||||
<update id="revertRecycleBin">
|
||||
UPDATE
|
||||
airag_prompts
|
||||
SET
|
||||
del_flag = 0
|
||||
WHERE
|
||||
del_flag = 1
|
||||
AND id IN
|
||||
<foreach collection="ids" item="promptId" open="(" close=")" separator="," >
|
||||
#{promptId}
|
||||
</foreach>
|
||||
</update>
|
||||
|
||||
<!-- 彻底删除被逻辑删除的用户 -->
|
||||
<delete id="deleteRecycleBin">
|
||||
DELETE FROM airag_prompts WHERE del_flag = 1 AND id IN
|
||||
<foreach collection="ids" item="promptId" open="(" close=")" separator="," >
|
||||
#{promptId}
|
||||
</foreach>
|
||||
</delete>
|
||||
|
||||
|
||||
</mapper>
|
||||
@ -1,11 +1,15 @@
|
||||
package org.jeecg.modules.airag.prompts.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.modules.airag.prompts.entity.AiragPrompts;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import org.jeecg.modules.airag.prompts.vo.AiragExperimentVo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Description: airag_prompts
|
||||
* @Author: jeecg-boot
|
||||
@ -15,4 +19,21 @@ import org.jeecg.modules.airag.prompts.vo.AiragExperimentVo;
|
||||
public interface IAiragPromptsService extends IService<AiragPrompts> {
|
||||
|
||||
Result<?> promptExperiment(AiragExperimentVo experimentVo, HttpServletRequest request);
|
||||
|
||||
//update-begin---author:chenrui ---date:2026-04-07 for:【QQYUN-14643】实现回收站取回和彻底删除-----------
|
||||
/**
|
||||
* 查询回收站分页列表
|
||||
*/
|
||||
IPage<AiragPrompts> recycleBinPage(Page<AiragPrompts> page);
|
||||
|
||||
/**
|
||||
* 从回收站取回(恢复 del_flag = 0)
|
||||
*/
|
||||
void revertRecycleBin(List<String> ids);
|
||||
|
||||
/**
|
||||
* 从回收站彻底删除(物理删除)
|
||||
*/
|
||||
void deleteRecycleBin(List<String> ids);
|
||||
//update-end---author:chenrui ---date:2026-04-07 for:【QQYUN-14643】实现回收站取回和彻底删除-----------
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import com.alibaba.fastjson2.JSON;
|
||||
import com.alibaba.fastjson2.JSONArray;
|
||||
import com.alibaba.fastjson2.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import dev.langchain4j.data.message.ChatMessage;
|
||||
import dev.langchain4j.data.message.SystemMessage;
|
||||
import dev.langchain4j.data.message.UserMessage;
|
||||
@ -60,6 +62,23 @@ public class AiragPromptsServiceImpl extends ServiceImpl<AiragPromptsMapper, Air
|
||||
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
|
||||
);
|
||||
|
||||
//update-begin---author:chenrui ---date:2026-04-07 for:【QQYUN-14643】实现回收站取回和彻底删除-----------
|
||||
@Override
|
||||
public IPage<AiragPrompts> recycleBinPage(Page<AiragPrompts> page) {
|
||||
return this.baseMapper.selectRecycleBinPage(page);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revertRecycleBin(List<String> ids) {
|
||||
this.baseMapper.revertRecycleBin(ids);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void deleteRecycleBin(List<String> ids) {
|
||||
this.baseMapper.deleteRecycleBin(ids);
|
||||
}
|
||||
//update-end---author:chenrui ---date:2026-04-07 for:【QQYUN-14643】实现回收站取回和彻底删除-----------
|
||||
|
||||
/**
|
||||
* 提示词实验
|
||||
* @param experimentVo
|
||||
@ -80,7 +99,7 @@ public class AiragPromptsServiceImpl extends ServiceImpl<AiragPromptsMapper, Air
|
||||
|
||||
try {
|
||||
//1.查询提示词
|
||||
AiragPrompts airagPrompts = this.baseMapper.selectOne(new LambdaQueryWrapper<AiragPrompts>().eq(AiragPrompts::getPromptKey, promptKey));
|
||||
AiragPrompts airagPrompts = this.baseMapper.selectOne(new LambdaQueryWrapper<AiragPrompts>().eq(AiragPrompts::getId, promptKey));
|
||||
AssertUtils.assertNotEmpty("未找到指定的提示词", airagPrompts);
|
||||
String modelParam = airagPrompts.getModelParam();
|
||||
// 过滤提示词变量
|
||||
|
||||
@ -16,7 +16,7 @@ import java.util.Map;
|
||||
public class AiragExperimentVo implements Serializable {
|
||||
private static final long serialVersionUID = 1L;
|
||||
/**
|
||||
* 提示词
|
||||
* 提示词Id
|
||||
*/
|
||||
private String promptKey;
|
||||
/**
|
||||
|
||||
@ -133,8 +133,7 @@ public class VideoGenerationServiceImpl implements IVideoGenerationService {
|
||||
AiChatConfig.ModelConfig config = aiChatConfig.getAiModelVideo();
|
||||
String apiKey = config.getApiKey();
|
||||
String model = config.getModel();
|
||||
String apiHost = config.getApiHost();
|
||||
String baseUrl = apiHost.endsWith("/") ? apiHost.substring(0, apiHost.length() - 1) : apiHost;
|
||||
String baseUrl = getVideoApiBaseUrl(config);
|
||||
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("model", model);
|
||||
@ -218,8 +217,7 @@ public class VideoGenerationServiceImpl implements IVideoGenerationService {
|
||||
public VideoTaskResultVo queryTask(String taskId) {
|
||||
AiChatConfig.ModelConfig config = aiChatConfig.getAiModelVideo();
|
||||
String apiKey = config.getApiKey();
|
||||
String apiHost = config.getApiHost();
|
||||
String baseUrl = apiHost.endsWith("/") ? apiHost.substring(0, apiHost.length() - 1) : apiHost;
|
||||
String baseUrl = getVideoApiBaseUrl(config);
|
||||
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
@ -486,14 +484,24 @@ public class VideoGenerationServiceImpl implements IVideoGenerationService {
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频模型 API 基础地址并校验配置。
|
||||
*/
|
||||
private String getVideoApiBaseUrl(AiChatConfig.ModelConfig config) {
|
||||
String apiHost = config.getApiHost();
|
||||
if (apiHost == null || apiHost.isBlank()) {
|
||||
throw new IllegalStateException("视频模型 api-host 未配置,请检查 jeecg.ai-chat.ai-model-video.api-host");
|
||||
}
|
||||
return apiHost.endsWith("/") ? apiHost.substring(0, apiHost.length() - 1) : apiHost;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用智谱GLM生成旁白文案
|
||||
*/
|
||||
private String generateNarration(String videoPrompt) throws IOException, InterruptedException {
|
||||
AiChatConfig.ModelConfig config = aiChatConfig.getAiModelVideo();
|
||||
String apiKey = config.getApiKey();
|
||||
String apiHost = config.getApiHost();
|
||||
String baseUrl = apiHost.endsWith("/") ? apiHost.substring(0, apiHost.length() - 1) : apiHost;
|
||||
String baseUrl = getVideoApiBaseUrl(config);
|
||||
|
||||
JSONObject body = new JSONObject();
|
||||
body.put("model", "glm-4-flash");
|
||||
|
||||
@ -57,6 +57,9 @@ public class VoiceApiHelper {
|
||||
public void generateAudio(String text, Path audioPath, String voice, double speed) throws IOException, InterruptedException {
|
||||
AiChatConfig.VoiceModelConfig config = aiChatConfig.getAiModelVoice();
|
||||
String apiHost = config.getApiHost();
|
||||
if (apiHost == null || apiHost.isBlank()) {
|
||||
throw new IllegalStateException("语音模型 api-host 未配置,请检查 jeecg.ai-chat.ai-model-voice.api-host");
|
||||
}
|
||||
String url = apiHost.endsWith("/") ? apiHost + "audio/speech" : apiHost + "/audio/speech";
|
||||
|
||||
JSONObject body = new JSONObject();
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
package org.jeecg.modules.airag.app.controller;
|
||||
|
||||
import org.jeecg.common.exception.JeecgBootException;
|
||||
import org.jeecg.modules.airag.app.service.IAiragChatService;
|
||||
import org.jeecg.modules.airag.app.service.impl.AiragChatRateLimitService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.filter.CharacterEncodingFilter;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.asyncDispatch;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* AI聊天接口错误协议测试。
|
||||
*
|
||||
* @author scott
|
||||
* @since 2026-07-21 【issues/9787】无效分享链接错误提示
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class AiragChatControllerTest {
|
||||
|
||||
@Mock
|
||||
private IAiragChatService chatService;
|
||||
|
||||
@Mock
|
||||
private AiragChatRateLimitService rateLimitService;
|
||||
|
||||
@InjectMocks
|
||||
private AiragChatController controller;
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.addFilters(new CharacterEncodingFilter(StandardCharsets.UTF_8.name(), true))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReturnSseErrorWhenShareAccessValidationFails() throws Exception {
|
||||
when(chatService.send(any())).thenThrow(new JeecgBootException("分享链接无效或已取消发布"));
|
||||
|
||||
MvcResult result = mockMvc.perform(post("/airag/chat/send")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"content\":\"hello\",\"appId\":\"invalid-app\"}"))
|
||||
.andExpect(request().asyncStarted())
|
||||
.andReturn();
|
||||
|
||||
mockMvc.perform(asyncDispatch(result))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("\"event\":\"ERROR\"")))
|
||||
.andExpect(content().string(org.hamcrest.Matchers.containsString("分享链接无效或已取消发布")));
|
||||
}
|
||||
}
|
||||
@ -38,6 +38,29 @@ public interface IAiragBaseApi {
|
||||
@RequestParam(value = "segmentConfig", required = false) String segmentConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* 知识库写入文件文档(支持自定义分段策略)
|
||||
*
|
||||
* @param knowledgeId 知识库ID
|
||||
* @param title 文档标题
|
||||
* @param filePath 文件URL
|
||||
* @param segmentConfig 【可选】分段策略配置JSON
|
||||
* @return 新增的文档ID
|
||||
*/
|
||||
@PostMapping("/airag/api/knowledgeWriteFileDocument")
|
||||
String knowledgeWriteFileDocument(
|
||||
@RequestParam("knowledgeId") String knowledgeId,
|
||||
@RequestParam("title") String title,
|
||||
@RequestParam("filePath") String filePath,
|
||||
@RequestParam(value = "segmentConfig", required = false) String segmentConfig
|
||||
);
|
||||
|
||||
/**
|
||||
* 批量查询知识库文档向量化状态
|
||||
*/
|
||||
@PostMapping("/airag/api/checkKnowledgeDocsVectorizeStatus")
|
||||
String checkKnowledgeDocsVectorizeStatus(@RequestParam("documentIds") String documentIds);
|
||||
|
||||
/**
|
||||
* 读取会话变量
|
||||
*/
|
||||
|
||||
@ -13,6 +13,16 @@ public class AiragBaseApiFallback implements IAiragBaseApi {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String knowledgeWriteFileDocument(String knowledgeId, String title, String filePath, String segmentConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String checkKnowledgeDocsVectorizeStatus(String documentIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getChatVariable(String appId, String username, String name) {
|
||||
return null;
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
package org.jeecg.common.online.api.factory;
|
||||
|
||||
import org.springframework.cloud.openfeign.FallbackFactory;
|
||||
import org.jeecg.common.online.api.IOnlineBaseExtApi;
|
||||
import org.jeecg.common.online.api.fallback.OnlineBaseExtApiFallback;
|
||||
import org.springframework.cloud.openfeign.FallbackFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
|
||||
@ -19,6 +19,25 @@ public interface IAiragBaseApi {
|
||||
*/
|
||||
String knowledgeWriteTextDocument(String knowledgeId, String title, String content, String segmentConfig);
|
||||
|
||||
/**
|
||||
* 知识库写入文件文档(支持自定义分段策略)
|
||||
*
|
||||
* @param knowledgeId 知识库ID
|
||||
* @param title 文档标题
|
||||
* @param filePath 文件URL(与知识库文档功能 metadata.filePath 字段含义一致)
|
||||
* @param segmentConfig 【可选】分段策略配置JSON
|
||||
* @return 新增的文档ID
|
||||
*/
|
||||
String knowledgeWriteFileDocument(String knowledgeId, String title, String filePath, String segmentConfig);
|
||||
|
||||
/**
|
||||
* 批量查询知识库文档向量化状态
|
||||
*
|
||||
* @param documentIds 文档ID列表(英文逗号拼接字符串)
|
||||
* @return 状态码:COMPLETED / COMPLETED_WITH_FAIL / PROCESSING / PROCESSING_WITH_FAIL
|
||||
*/
|
||||
String checkKnowledgeDocsVectorizeStatus(String documentIds);
|
||||
|
||||
/**
|
||||
* 读取会话变量
|
||||
*
|
||||
|
||||
@ -7,7 +7,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 【Online】online表单对外接口
|
||||
* 表单设计器【Online】翻译API接口
|
||||
*
|
||||
* @author sunjianlei
|
||||
*/
|
||||
|
||||
@ -1,67 +1,67 @@
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-module-system</artifactId>
|
||||
<version>3.9.3</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<parent>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-module-system</artifactId>
|
||||
<version>3.9.3</version>
|
||||
</parent>
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<artifactId>jeecg-system-biz</artifactId>
|
||||
<artifactId>jeecg-system-biz</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-system-local-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate</groupId>
|
||||
<artifactId>hibernate-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-online</artifactId>
|
||||
</dependency>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-system-local-api</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.hibernate</groupId>
|
||||
<artifactId>hibernate-core</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-online</artifactId>
|
||||
</dependency>
|
||||
|
||||
|
||||
<!-- 企业微信/钉钉 api -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework</groupId>
|
||||
<artifactId>weixin4j</artifactId>
|
||||
</dependency>
|
||||
<!-- 积木报表 -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimureport-spring-boot4-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- 企业微信/钉钉 api -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework</groupId>
|
||||
<artifactId>weixin4j</artifactId>
|
||||
</dependency>
|
||||
<!-- 积木报表 -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimureport-spring-boot4-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- 积木chat2bi -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimuchatbi-spring-boot4-starter</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
<!-- 积木报表 csv excel ES JSON mongodbSQL redis支持包
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<!-- 积木报表 csv excel ES JSON mongodbSQL redis支持包
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimureport-nosql-starter3</artifactId>
|
||||
</dependency>-->
|
||||
<!-- 后台导出接口Echart图表支持包,按需引入
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimureport-echarts-starter</artifactId>
|
||||
</dependency>-->
|
||||
</dependency>-->
|
||||
<!-- 后台导出接口Echart图表支持包,按需引入
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimureport-echarts-starter</artifactId>
|
||||
</dependency>-->
|
||||
<!-- 积木BI -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.jimureport</groupId>
|
||||
<artifactId>jimubi-spring-boot4-starter</artifactId>
|
||||
</dependency>
|
||||
<!-- AI大模型管理 -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-boot-module-airag</artifactId>
|
||||
<version>${jeecgboot.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<!-- AI大模型管理 -->
|
||||
<dependency>
|
||||
<groupId>org.jeecgframework.boot3</groupId>
|
||||
<artifactId>jeecg-boot-module-airag</artifactId>
|
||||
<version>${jeecgboot.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@ -77,13 +77,14 @@ public class DictTableWhiteListHandlerImpl implements IDictTableWhiteListHandler
|
||||
// 遍历当前sql中的所有表名,如果有其中一个表或表的字段不在白名单中,则不通过
|
||||
for (Map.Entry<String, SelectSqlInfo> entry : parsedMap.entrySet()) {
|
||||
SelectSqlInfo sqlInfo = entry.getValue();
|
||||
String tableName = entry.getKey();
|
||||
if (sqlInfo.isSelectAll()) {
|
||||
log.warn("查询语句中包含 * 字段,暂时先通过");
|
||||
// select * 只有整表字段已授权时才允许;dev 模式沿用自动补充白名单逻辑
|
||||
this.checkWhiteList(tableName, Collections.singleton(SymbolConstant.ASTERISK));
|
||||
continue;
|
||||
}
|
||||
Set<String> queryFields = sqlInfo.getAllRealSelectFields();
|
||||
// 校验表名和字段是否允许查询
|
||||
String tableName = entry.getKey();
|
||||
if (!this.checkWhiteList(tableName, queryFields)) {
|
||||
return false;
|
||||
}
|
||||
@ -169,6 +170,10 @@ public class DictTableWhiteListHandlerImpl implements IDictTableWhiteListHandler
|
||||
// 统一转成小写
|
||||
allowFieldStr = allowFieldStr.toLowerCase();
|
||||
Set<String> allowFields = new HashSet<>(Arrays.asList(allowFieldStr.split(",")));
|
||||
// 配置 * 代表允许查询当前表的所有字段
|
||||
if (allowFields.contains(SymbolConstant.ASTERISK)) {
|
||||
return true;
|
||||
}
|
||||
// 需要合并的字段
|
||||
Set<String> waitMergerFields = new HashSet<>();
|
||||
for (String field : queryFields) {
|
||||
|
||||
@ -7,6 +7,7 @@ import org.jeecgframework.codegenerate.database.CodegenDatasourceConfig;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
|
||||
/**
|
||||
* @Description: 代码生成器,自定义DB配置
|
||||
@ -21,6 +22,7 @@ import org.springframework.context.annotation.Configuration;
|
||||
* @ConditionalOnMissingClass("org.jeecg.config.init.CodeGenerateDbConfig")
|
||||
*/
|
||||
@Slf4j
|
||||
@Lazy(false)
|
||||
@Configuration
|
||||
public class CodeGenerateDbConfig {
|
||||
@Value("${spring.datasource.dynamic.datasource.master.url:}")
|
||||
|
||||
@ -8,6 +8,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.ApplicationListener;
|
||||
import org.springframework.context.annotation.Conditional;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@ -17,6 +18,7 @@ import org.springframework.stereotype.Component;
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@Lazy(false)
|
||||
@Conditional(JeecgCloudCondition.class)
|
||||
public class SystemInitListener implements ApplicationListener<ApplicationReadyEvent>, Ordered {
|
||||
|
||||
|
||||
@ -6,6 +6,10 @@ import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
|
||||
/**
|
||||
* @Description: TomcatFactoryConfig
|
||||
* @author: scott
|
||||
@ -28,6 +32,16 @@ public class TomcatFactoryConfig {
|
||||
connector.setProperty("relaxedPathChars", "[]{}");
|
||||
connector.setProperty("relaxedQueryChars", "[]{}");
|
||||
});
|
||||
//update-begin---author:scott ---date:20260710 for:【issues】升级Tomcat11后work目录生成在项目目录问题-----------
|
||||
// 自定义Bean覆盖了SpringBoot自动配置,yml里的basedir不生效,Tomcat11默认落到./work,需手动指定系统临时目录
|
||||
try {
|
||||
File tomcatTmpDir = Files.createTempDirectory("tomcat.jeecg.").toFile();
|
||||
tomcatTmpDir.deleteOnExit();
|
||||
factory.setBaseDirectory(tomcatTmpDir);
|
||||
} catch (IOException e) {
|
||||
// ignore, fallback to tomcat default
|
||||
}
|
||||
//update-end---author:scott ---date:20260710 for:【issues】升级Tomcat11后work目录生成在项目目录问题-----------
|
||||
return factory;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,48 +0,0 @@
|
||||
//package org.jeecg.config.init;
|
||||
//
|
||||
//import io.undertow.UndertowOptions;
|
||||
//import io.undertow.server.DefaultByteBufferPool;
|
||||
//import io.undertow.server.handlers.BlockingHandler;
|
||||
//import io.undertow.websockets.jsr.WebSocketDeploymentInfo;
|
||||
//import org.jeecg.modules.monitor.actuator.undertow.CustomUndertowMetricsHandler;
|
||||
//import org.springframework.beans.factory.annotation.Autowired;
|
||||
//import org.springframework.boot.web.embedded.undertow.UndertowServletWebServerFactory;
|
||||
//import org.springframework.boot.web.server.WebServerFactoryCustomizer;
|
||||
//import org.springframework.context.annotation.Configuration;
|
||||
//
|
||||
///**
|
||||
// * Undertow配置
|
||||
// *
|
||||
// * 解决启动提示: WARN io.undertow.websockets.jsr:68 - UT026010: Buffer pool was not set on WebSocketDeploymentInfo, the default pool will be used
|
||||
// */
|
||||
//@Configuration
|
||||
//public class UndertowConfiguration implements WebServerFactoryCustomizer<UndertowServletWebServerFactory> {
|
||||
//
|
||||
// /**
|
||||
// * 自定义undertow监控指标工具类
|
||||
// * for [QQYUN-11902]tomcat 替换undertow 这里的功能还没修改
|
||||
// */
|
||||
// @Autowired
|
||||
// private CustomUndertowMetricsHandler customUndertowMetricsHandler;
|
||||
//
|
||||
// @Override
|
||||
// public void customize(UndertowServletWebServerFactory factory) {
|
||||
// // 设置 Undertow 服务器参数(底层网络配置)
|
||||
// factory.addBuilderCustomizers(builder -> {
|
||||
// builder.setServerOption(UndertowOptions.MAX_HEADER_SIZE, 65536); // header 最大64KB
|
||||
// builder.setServerOption(UndertowOptions.MAX_PARAMETERS, 10000); // 最大参数数
|
||||
// });
|
||||
// factory.addDeploymentInfoCustomizers(deploymentInfo -> {
|
||||
//
|
||||
// WebSocketDeploymentInfo webSocketDeploymentInfo = new WebSocketDeploymentInfo();
|
||||
//
|
||||
// // 设置合理的参数
|
||||
// webSocketDeploymentInfo.setBuffers(new DefaultByteBufferPool(true, 8192));
|
||||
//
|
||||
// deploymentInfo.addServletContextAttribute("io.undertow.websockets.jsr.WebSocketDeploymentInfo", webSocketDeploymentInfo);
|
||||
//
|
||||
// // 添加自定义 监控 handler
|
||||
// deploymentInfo.addInitialHandlerChainWrapper(next -> new BlockingHandler(customUndertowMetricsHandler.wrap(next)));
|
||||
// });
|
||||
// }
|
||||
//}
|
||||
@ -2,6 +2,7 @@ package org.jeecg.config.jimureport;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.system.util.JwtUtil;
|
||||
import org.jeecg.common.system.vo.ComboModel;
|
||||
import org.jeecg.common.system.vo.DictModel;
|
||||
import org.jeecg.common.system.vo.SysUserCacheInfo;
|
||||
import org.jeecg.common.util.RedisUtil;
|
||||
@ -9,6 +10,9 @@ import org.jeecg.common.util.TokenUtils;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.jmreport.api.JmReportTokenServiceI;
|
||||
import org.jeecg.modules.jmreport.common.vo.JmDictModel;
|
||||
import org.jeecg.modules.jmreport.common.vo.JmRoleModel;
|
||||
import org.jeecg.modules.jmreport.common.vo.JmUserModel;
|
||||
import org.jeecg.modules.jmreport.desreport.model.JmPage;
|
||||
import org.jeecg.modules.system.service.impl.SysBaseApiImpl;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
@ -79,8 +83,12 @@ public class JimuReportTokenService implements JmReportTokenServiceI {
|
||||
}
|
||||
//设置账号名
|
||||
map.put(SYS_USER_CODE, userInfo.getSysUserCode());
|
||||
//设置用户名称(中文名)
|
||||
map.put(sysUserName, userInfo.getSysUserName());
|
||||
//设置部门编码
|
||||
map.put(SYS_ORG_CODE, userInfo.getSysOrgCode());
|
||||
//设置用户拥有的所有部门编码
|
||||
map.put("sysMultiOrgCode", userInfo.getSysMultiOrgCode());
|
||||
// 将所有信息存放至map 解析sql/api会根据map的键值解析
|
||||
return map;
|
||||
}
|
||||
@ -127,4 +135,37 @@ public class JimuReportTokenService implements JmReportTokenServiceI {
|
||||
}
|
||||
return dictItems;
|
||||
}
|
||||
|
||||
@Override
|
||||
public JmPage<JmRoleModel> getRoleList(String token, String keyword, Integer pageNo, Integer pageSize) {
|
||||
List<ComboModel> allRoles = sysBaseApi.queryAllRole();
|
||||
List<JmRoleModel> list = allRoles.stream()
|
||||
.filter(r -> oConvertUtils.isEmpty(keyword) || (r.getTitle() != null && r.getTitle().contains(keyword)) || (r.getRoleCode() != null && r.getRoleCode().contains(keyword)))
|
||||
.map(r -> new JmRoleModel(r.getRoleCode(), r.getTitle()))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
return buildPage(list, pageNo, pageSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
public JmPage<JmUserModel> getUserList(String token, String keyword, Integer pageNo, Integer pageSize) {
|
||||
List<ComboModel> allUsers = sysBaseApi.queryAllUserBackCombo();
|
||||
List<JmUserModel> list = allUsers.stream()
|
||||
.filter(u -> oConvertUtils.isEmpty(keyword) || (u.getUsername() != null && u.getUsername().contains(keyword)) || (u.getTitle() != null && u.getTitle().contains(keyword)))
|
||||
.map(u -> new JmUserModel(u.getUsername(), u.getTitle()))
|
||||
.collect(java.util.stream.Collectors.toList());
|
||||
return buildPage(list, pageNo, pageSize);
|
||||
}
|
||||
|
||||
private <T> JmPage<T> buildPage(List<T> list, Integer pageNo, Integer pageSize) {
|
||||
pageNo = (pageNo == null || pageNo < 1) ? 1 : pageNo;
|
||||
pageSize = (pageSize == null || pageSize < 1) ? 10 : pageSize;
|
||||
JmPage<T> page = new JmPage<>();
|
||||
page.setPageNo(pageNo);
|
||||
page.setPageSize(pageSize);
|
||||
page.setTotal(list.size());
|
||||
int fromIndex = (pageNo - 1) * pageSize;
|
||||
int toIndex = Math.min(fromIndex + pageSize, list.size());
|
||||
page.setRecords(fromIndex >= list.size() ? Collections.emptyList() : list.subList(fromIndex, toIndex));
|
||||
return page;
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import org.jeecg.modules.message.entity.MsgParams;
|
||||
import org.jeecg.modules.message.entity.SysMessageTemplate;
|
||||
import org.jeecg.modules.message.service.ISysMessageTemplateService;
|
||||
import org.jeecg.modules.message.util.PushMsgUtil;
|
||||
import org.jeecgframework.poi.excel.entity.ImportParams;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@ -25,6 +26,8 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.multipart.MultipartHttpServletRequest;
|
||||
import org.springframework.web.servlet.ModelAndView;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
@ -149,7 +152,21 @@ public class SysMessageTemplateController extends JeecgController<SysMessageTemp
|
||||
*/
|
||||
@PostMapping(value = "/importExcel")
|
||||
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
|
||||
return super.importExcel(request, response, SysMessageTemplate.class);
|
||||
MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
|
||||
Map<String, MultipartFile> fileMap = multipartRequest.getFileMap();
|
||||
for (Map.Entry<String, MultipartFile> entry : fileMap.entrySet()) {
|
||||
ImportParams params = new ImportParams();
|
||||
params.setTitleRows(2);
|
||||
params.setHeadRows(1);
|
||||
params.setNeedSave(true);
|
||||
try {
|
||||
return sysMessageTemplateService.importExcelCheckTemplateCode(entry.getValue(), params);
|
||||
} catch (Exception e) {
|
||||
log.error(e.getMessage(), e);
|
||||
return Result.error("文件导入失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
return Result.error("文件导入失败:未找到上传文件");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -115,11 +115,11 @@ public enum RangeDateEnum {
|
||||
return null;
|
||||
}
|
||||
// 开始时间00:00:00 结束时间23:59:59
|
||||
calendar1.set(Calendar.HOUR, 0);
|
||||
calendar1.set(Calendar.HOUR_OF_DAY, 0);
|
||||
calendar1.set(Calendar.MINUTE, 0);
|
||||
calendar1.set(Calendar.SECOND, 0);
|
||||
calendar1.set(Calendar.MILLISECOND, 0);
|
||||
calendar2.set(Calendar.HOUR, 23);
|
||||
calendar2.set(Calendar.HOUR_OF_DAY, 23);
|
||||
calendar2.set(Calendar.MINUTE, 59);
|
||||
calendar2.set(Calendar.SECOND, 59);
|
||||
calendar2.set(Calendar.MILLISECOND, 999);
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
package org.jeecg.modules.message.handle.impl;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.dto.message.MessageDTO;
|
||||
import org.jeecg.modules.message.handle.ISendMsgHandle;
|
||||
import org.jeecg.modules.system.service.impl.ThirdAppFeishuServiceImpl;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 发飞书消息模板
|
||||
*
|
||||
* @author jeecg-boot
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("fsSendMsgHandle")
|
||||
public class FsSendMsgHandle implements ISendMsgHandle {
|
||||
|
||||
@Autowired
|
||||
private ThirdAppFeishuServiceImpl feishuService;
|
||||
|
||||
@Override
|
||||
public void sendMsg(String esReceiver, String esTitle, String esContent) {
|
||||
MessageDTO messageDTO = new MessageDTO();
|
||||
messageDTO.setToUser(esReceiver);
|
||||
messageDTO.setTitle(esTitle);
|
||||
messageDTO.setContent(esContent);
|
||||
messageDTO.setToAll(false);
|
||||
sendMessage(messageDTO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendMessage(MessageDTO messageDTO) {
|
||||
feishuService.sendMessage(messageDTO, true);
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,19 +1,105 @@
|
||||
package org.jeecg.modules.message.handle.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.jeecg.common.api.dto.message.MessageDTO;
|
||||
import org.jeecg.common.constant.enums.DySmsEnum;
|
||||
import org.jeecg.common.util.DySmsHelper;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.message.handle.ISendMsgHandle;
|
||||
import org.jeecg.modules.system.entity.SysUser;
|
||||
import org.jeecg.modules.system.mapper.SysUserMapper;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @Description: 短信发送
|
||||
* @Description: 短信发送处理
|
||||
* @author: jeecg-boot
|
||||
*/
|
||||
@Slf4j
|
||||
@Component("smsSendMsgHandle")
|
||||
public class SmsSendMsgHandle implements ISendMsgHandle {
|
||||
|
||||
@Override
|
||||
public void sendMsg(String esReceiver, String esTitle, String esContent) {
|
||||
// TODO Auto-generated method stub
|
||||
log.info("发短信");
|
||||
}
|
||||
@Autowired
|
||||
private SysUserMapper sysUserMapper;
|
||||
// 流程短信模版code
|
||||
private final String BPM_SMS_TEMPLATE_CODE = "bpm_sms";
|
||||
|
||||
@Override
|
||||
public void sendMsg(String esReceiver, String esTitle, String esContent) {
|
||||
log.info("发短信,接收人: {},标题: {},内容: {}", esReceiver, esTitle, esContent);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendMessage(MessageDTO messageDTO) {
|
||||
String toUser = messageDTO.getToUser();
|
||||
if (oConvertUtils.isEmpty(toUser)) {
|
||||
log.error("短信发送失败:接收人为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据模板编码获取阿里云短信模板配置
|
||||
String templateCode = messageDTO.getTemplateCode();
|
||||
DySmsEnum dySmsEnum = null;
|
||||
if (dySmsEnum == null) {
|
||||
log.warn("未找到精确匹配的短信模板,templateCode: {}");
|
||||
return;
|
||||
}
|
||||
// 解析短信模板参数(消息内容经过FreeMarker解析后应为JSON格式的模板参数)
|
||||
JSONObject templateParamJson = new JSONObject();
|
||||
try {
|
||||
String bpmTitle = messageDTO.getTitle();
|
||||
if (oConvertUtils.isEmpty(bpmTitle)) {
|
||||
log.error("短信发送失败:消息标题为空");
|
||||
return;
|
||||
}
|
||||
templateParamJson.put("bpmTitle", bpmTitle);
|
||||
} catch (Exception e) {
|
||||
log.error("短信发送失败:消息内容不是有效的JSON格式,content: {}", messageDTO.getContent(), e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询接收用户的手机号
|
||||
String[] usernames = toUser.split(",");
|
||||
LambdaQueryWrapper<SysUser> query = new LambdaQueryWrapper<SysUser>()
|
||||
.in(SysUser::getUsername, usernames)
|
||||
.isNotNull(SysUser::getPhone)
|
||||
.ne(SysUser::getPhone, "");
|
||||
List<SysUser> users = sysUserMapper.selectList(query);
|
||||
|
||||
if (users == null || users.isEmpty()) {
|
||||
log.warn("短信发送失败:未找到有效的用户手机号,接收人: {}", toUser);
|
||||
return;
|
||||
}
|
||||
|
||||
// 逐个发送短信
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
for (SysUser user : users) {
|
||||
String phone = user.getPhone();
|
||||
if (oConvertUtils.isEmpty(phone)) {
|
||||
log.warn("用户 {} 未设置手机号,跳过短信发送", user.getUsername());
|
||||
failCount++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
templateParamJson.put("realname", oConvertUtils.getString(user.getRealname(),user.getUsername()));
|
||||
boolean success = DySmsHelper.sendSms(phone, templateParamJson, dySmsEnum);
|
||||
if (success) {
|
||||
log.info("短信发送成功,接收人: {},手机号: {}", user.getUsername(), phone);
|
||||
successCount++;
|
||||
} else {
|
||||
log.error("短信发送失败(API返回失败),接收人: {},手机号: {}", user.getUsername(), phone);
|
||||
failCount++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("短信发送异常,接收人: {},手机号: {}", user.getUsername(), phone, e);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
log.info("短信发送完成,成功: {},失败: {},总数: {}", successCount, failCount, users.size());
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,8 +2,11 @@ package org.jeecg.modules.message.service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.base.service.JeecgService;
|
||||
import org.jeecg.modules.message.entity.SysMessageTemplate;
|
||||
import org.jeecgframework.poi.excel.entity.ImportParams;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
/**
|
||||
* @Description: 消息模板
|
||||
@ -19,4 +22,14 @@ public interface ISysMessageTemplateService extends JeecgService<SysMessageTempl
|
||||
* @return
|
||||
*/
|
||||
List<SysMessageTemplate> selectByCode(String code);
|
||||
|
||||
/**
|
||||
* 导入消息模板并校验模板编码。
|
||||
*
|
||||
* @param file 导入文件
|
||||
* @param params 导入参数
|
||||
* @return 导入结果
|
||||
* @throws Exception 导入异常
|
||||
*/
|
||||
Result<?> importExcelCheckTemplateCode(MultipartFile file, ImportParams params) throws Exception;
|
||||
}
|
||||
|
||||
@ -1,11 +1,21 @@
|
||||
package org.jeecg.modules.message.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.base.service.impl.JeecgServiceImpl;
|
||||
import org.jeecg.common.util.ImportExcelUtil;
|
||||
import org.jeecg.modules.message.entity.SysMessageTemplate;
|
||||
import org.jeecg.modules.message.mapper.SysMessageTemplateMapper;
|
||||
import org.jeecg.modules.message.service.ISysMessageTemplateService;
|
||||
import org.jeecgframework.poi.excel.ExcelImportUtil;
|
||||
import org.jeecgframework.poi.excel.entity.ImportParams;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@ -25,4 +35,56 @@ public class SysMessageTemplateServiceImpl extends JeecgServiceImpl<SysMessageTe
|
||||
public List<SysMessageTemplate> selectByCode(String code) {
|
||||
return sysMessageTemplateMapper.selectByCode(code);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Result<?> importExcelCheckTemplateCode(MultipartFile file, ImportParams params) throws Exception {
|
||||
List<SysMessageTemplate> messageTemplates;
|
||||
try (InputStream inputStream = file.getInputStream()) {
|
||||
messageTemplates = ExcelImportUtil.importExcel(inputStream, SysMessageTemplate.class, params);
|
||||
}
|
||||
List<String> errorMessages = new ArrayList<>();
|
||||
int successLines = 0;
|
||||
for (int i = 0; i < messageTemplates.size(); i++) {
|
||||
SysMessageTemplate messageTemplate = messageTemplates.get(i);
|
||||
messageTemplate.setCreateBy(null);
|
||||
messageTemplate.setCreateTime(null);
|
||||
messageTemplate.setUpdateTime(null);
|
||||
messageTemplate.setUpdateBy(null);
|
||||
int excelRowNumber = params.getTitleRows() + params.getHeadRows() + i + 1;
|
||||
if (isTemplateCodeExists(messageTemplate.getTemplateCode())) {
|
||||
errorMessages.add(excelRowNumber + "_模板编码【" + messageTemplate.getTemplateCode() + "】重复,忽略导入。");
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (save(messageTemplate)) {
|
||||
successLines++;
|
||||
} else {
|
||||
errorMessages.add(excelRowNumber + "_数据保存失败,忽略导入。");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (isDuplicateKeyException(e)) {
|
||||
errorMessages.add(excelRowNumber + "_模板编码【" + messageTemplate.getTemplateCode() + "】重复,忽略导入。");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
return ImportExcelUtil.imporReturnRes(errorMessages.size(), successLines, errorMessages);
|
||||
}
|
||||
|
||||
private boolean isTemplateCodeExists(String templateCode) {
|
||||
return templateCode != null && count(Wrappers.<SysMessageTemplate>lambdaQuery()
|
||||
.eq(SysMessageTemplate::getTemplateCode, templateCode)) > 0;
|
||||
}
|
||||
|
||||
private boolean isDuplicateKeyException(Exception exception) {
|
||||
Throwable cause = exception;
|
||||
while (cause != null) {
|
||||
if (cause instanceof DuplicateKeyException) {
|
||||
return true;
|
||||
}
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package org.jeecg.modules.openapi.controller;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.jeecg.common.system.query.QueryGenerator;
|
||||
@ -30,6 +31,7 @@ public class OpenApiAuthController extends JeecgController<OpenApiAuth, OpenApiA
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_auth:list")
|
||||
@GetMapping(value = "/list")
|
||||
public Result<?> queryPageList(OpenApiAuth openApiAuth, @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, HttpServletRequest req) {
|
||||
@ -45,6 +47,7 @@ public class OpenApiAuthController extends JeecgController<OpenApiAuth, OpenApiA
|
||||
* @param openApiAuth
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_auth:add")
|
||||
@PostMapping(value = "/add")
|
||||
public Result<?> add(@RequestBody OpenApiAuth openApiAuth) {
|
||||
service.save(openApiAuth);
|
||||
@ -57,6 +60,7 @@ public class OpenApiAuthController extends JeecgController<OpenApiAuth, OpenApiA
|
||||
* @param openApiAuth
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_auth:edit")
|
||||
@PutMapping(value = "/edit")
|
||||
public Result<?> edit(@RequestBody OpenApiAuth openApiAuth) {
|
||||
service.updateById(openApiAuth);
|
||||
@ -70,6 +74,7 @@ public class OpenApiAuthController extends JeecgController<OpenApiAuth, OpenApiA
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_auth:delete")
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<?> delete(@RequestParam(name = "id", required = true) String id) {
|
||||
service.removeById(id);
|
||||
@ -82,6 +87,7 @@ public class OpenApiAuthController extends JeecgController<OpenApiAuth, OpenApiA
|
||||
* @param ids
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_auth:deleteBatch")
|
||||
@DeleteMapping(value = "/deleteBatch")
|
||||
public Result<?> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
|
||||
|
||||
@ -95,6 +101,7 @@ public class OpenApiAuthController extends JeecgController<OpenApiAuth, OpenApiA
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_auth:queryById")
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<?> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
OpenApiAuth openApiAuth = service.getById(id);
|
||||
@ -105,6 +112,7 @@ public class OpenApiAuthController extends JeecgController<OpenApiAuth, OpenApiA
|
||||
* 生成AKSK
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_auth:genAKSK")
|
||||
@GetMapping("genAKSK")
|
||||
public Result<String[]> genAKSK() {
|
||||
return Result.ok(AKSKGenerator.genAKSKPair());
|
||||
|
||||
@ -7,6 +7,8 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.google.common.collect.Lists;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.constant.CommonConstant;
|
||||
import org.jeecg.common.exception.JeecgBootBizTipException;
|
||||
@ -15,7 +17,9 @@ import org.jeecg.common.system.query.QueryGenerator;
|
||||
import org.jeecg.common.system.util.JwtUtil;
|
||||
import org.jeecg.common.util.CommonUtils;
|
||||
import org.jeecg.common.util.RedisUtil;
|
||||
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.config.JeecgBaseConfig;
|
||||
import org.jeecg.modules.openapi.entity.OpenApi;
|
||||
import org.jeecg.modules.openapi.entity.OpenApiAuth;
|
||||
import org.jeecg.modules.openapi.entity.OpenApiHeader;
|
||||
@ -26,7 +30,6 @@ import org.jeecg.modules.openapi.service.OpenApiService;
|
||||
import org.jeecg.modules.openapi.swagger.*;
|
||||
import org.jeecg.modules.system.entity.SysUser;
|
||||
import org.jeecg.modules.system.service.ISysUserService;
|
||||
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@ -36,7 +39,6 @@ import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.util.UriComponentsBuilder;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.net.URI;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
@ -56,6 +58,8 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
private ISysUserService sysUserService;
|
||||
@Autowired
|
||||
private OpenApiAuthService openApiAuthService;
|
||||
@Autowired
|
||||
private JeecgBaseConfig jeecgBaseConfig;
|
||||
|
||||
/**
|
||||
* 分页列表查询
|
||||
@ -81,7 +85,7 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
* @param openApi
|
||||
* @return
|
||||
*/
|
||||
@RequiresRoles({"admin"})
|
||||
@RequiresPermissions("openapi:open_api:add")
|
||||
@PostMapping(value = "/add")
|
||||
public Result<?> add(@RequestBody OpenApi openApi) {
|
||||
if (openApi == null) {
|
||||
@ -98,7 +102,7 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
* @param openApi
|
||||
* @return
|
||||
*/
|
||||
@RequiresRoles({"admin"})
|
||||
@RequiresPermissions("openapi:open_api:edit")
|
||||
@PutMapping(value = "/edit")
|
||||
public Result<?> edit(@RequestBody OpenApi openApi) {
|
||||
if (openApi == null) {
|
||||
@ -116,7 +120,7 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@RequiresRoles({"admin"})
|
||||
@RequiresPermissions("openapi:open_api:delete")
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<?> delete(@RequestParam(name = "id", required = true) String id) {
|
||||
service.removeById(id);
|
||||
@ -129,7 +133,7 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
* @param ids
|
||||
* @return
|
||||
*/
|
||||
@RequiresRoles({"admin"})
|
||||
@RequiresPermissions("openapi:open_api:deleteBatch")
|
||||
@DeleteMapping(value = "/deleteBatch")
|
||||
public Result<?> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
|
||||
|
||||
@ -154,7 +158,7 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
* @param path
|
||||
* @return
|
||||
*/
|
||||
@RequestMapping(value = "/call/{path}", method = {RequestMethod.GET,RequestMethod.POST})
|
||||
@RequestMapping(value = "/call/{path}", method = {RequestMethod.GET,RequestMethod.POST,RequestMethod.DELETE,RequestMethod.PUT,RequestMethod.PATCH,RequestMethod.HEAD,RequestMethod.OPTIONS,RequestMethod.TRACE})
|
||||
public Result<?> call(@PathVariable String path, @RequestBody(required = false) String json, HttpServletRequest request) {
|
||||
OpenApi openApi = service.findByPath(path);
|
||||
if (Objects.isNull(openApi)) {
|
||||
@ -184,20 +188,47 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
httpHeaders.put("X-Access-Token", Lists.newArrayList(token));
|
||||
httpHeaders.put("Content-Type",Lists.newArrayList("application/json"));
|
||||
HttpEntity<String> httpEntity = new HttpEntity<>(json, httpHeaders);
|
||||
//update-begin---author:scott ---date:20260429 for:【issues/9590】微服务nginx部署openApi接口访问不到-----------
|
||||
//update-begin---author:scott ---date:20260430 for:【issues/9590】微服务nginx部署openApi接口访问不到-----------
|
||||
// originUrl 支持两种形式:
|
||||
// 1) 相对路径(如 /house/houseTest/list):拼接当前请求的 baseUrl;
|
||||
// 1) 相对路径(如 /house/houseTest/list):默认拼接当前请求的 baseUrl;
|
||||
// 使用 CommonUtils.getBaseUrl(request)(而非 RestUtil.getBaseUrl()),
|
||||
// 可读取 X-Gateway-Base-Path 请求头,兼容微服务网关下的真实 base path
|
||||
// 可读取 X-Gateway-Base-Path 请求头,兼容微服务网关下的真实 base path。
|
||||
// Docker / K8s NodePort / 反向代理等入站端口与容器监听端口不一致时,
|
||||
// 可通过配置 jeecg.domainUrl.back 显式指定本机自代理地址。
|
||||
// 2) 完整URL(http(s)://host:port/path):直接使用,适用于微服务模式下接口部署在其他微服务模块(如 erp 7003)的场景
|
||||
String lowerUrl = url.toLowerCase();
|
||||
//update-begin---author:zhang ---date:20260806 for:【issues/9794】OpenAPI 相对 originUrl 自代理使用入站 Host/port,Docker 端口映射时 Connection refused-----------
|
||||
if (!lowerUrl.startsWith("http://") && !lowerUrl.startsWith("https://")) {
|
||||
url = CommonUtils.getBaseUrl(request) + url;
|
||||
String baseUrl = null;
|
||||
if (jeecgBaseConfig.getDomainUrl() != null && oConvertUtils.isNotEmpty(jeecgBaseConfig.getDomainUrl().getBack())) {
|
||||
baseUrl = jeecgBaseConfig.getDomainUrl().getBack();
|
||||
} else {
|
||||
baseUrl = CommonUtils.getBaseUrl(request);
|
||||
}
|
||||
CommonUtils.checkInternalUrl(baseUrl);
|
||||
url = baseUrl + url;
|
||||
}
|
||||
//update-end---author:scott ---date:20260429 for:【issues/9590】微服务nginx部署openApi接口访问不到-----------
|
||||
//update-end---author:zhang ---date:20260806 for:【issues/9794】OpenAPI 相对 originUrl 自代理使用入站 Host/port,Docker 端口映射时 Connection refused-----------
|
||||
//update-end---author:wangshuai ---date:20260616 for:【issues/9695】修复SSRF漏洞,校验baseUrl必须为内网地址-----------
|
||||
//update-end---author:scott ---date:20260430 for:【issues/9590】微服务nginx部署openApi接口访问不到-----------
|
||||
|
||||
// 将 originUrl 中的路径占位符 {key} 替换为调用方传入的 query 参数值,
|
||||
// 替换过的 key 收集到 pathVarKeys,后续不再重复追加到 query string
|
||||
Set<String> pathVarKeys = new HashSet<>();
|
||||
if (url.contains("{")) {
|
||||
for (Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
|
||||
String placeholder = "{" + entry.getKey() + "}";
|
||||
if (url.contains(placeholder) && entry.getValue() != null && entry.getValue().length > 0) {
|
||||
url = url.replace(placeholder, entry.getValue()[0]);
|
||||
pathVarKeys.add(entry.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url);
|
||||
if (HttpMethod.GET.matches(method)
|
||||
|| HttpMethod.DELETE.matches(method)
|
||||
|| HttpMethod.HEAD.matches(method)
|
||||
|| HttpMethod.OPTIONS.matches(method)
|
||||
|| HttpMethod.TRACE.matches(method)) {
|
||||
//拼接参数
|
||||
@ -207,6 +238,10 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
if (params.size()>0) {
|
||||
Map<String, OpenApiParam> openApiParamMap = params.stream().collect(Collectors.toMap(p -> p.getParamKey(), p -> p, (e, r) -> e));
|
||||
request.getParameterMap().forEach((k, v) -> {
|
||||
// 已作为路径参数替换的 key 不再追加到 query string
|
||||
if (pathVarKeys.contains(k)) {
|
||||
return;
|
||||
}
|
||||
OpenApiParam openApiParam = openApiParamMap.get(k);
|
||||
if (Objects.nonNull(openApiParam)) {
|
||||
if(v==null&&StrUtil.isNotEmpty(openApiParam.getDefaultValue())){
|
||||
@ -257,7 +292,7 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
} catch (Exception e) {
|
||||
throw new JeecgBootBizTipException("原始接口路径包含非法字符");
|
||||
}
|
||||
//update-begin---author:scott ---date:20260429 for:【issues/9590】微服务nginx部署openApi接口访问不到-----------
|
||||
//update-begin---author:scott ---date:20260430 for:【issues/9590】微服务nginx部署openApi接口访问不到-----------
|
||||
// 微服务部署时,OpenAPI 配置的接口可能位于其他微服务模块(如 erp 7003),允许 originUrl 直接配置完整 http(s) URL
|
||||
String lower = decoded.toLowerCase();
|
||||
boolean isFullHttpUrl = lower.startsWith("http://") || lower.startsWith("https://");
|
||||
@ -279,21 +314,47 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
|| afterScheme.contains("jar:") || afterScheme.contains("netdoc:")) {
|
||||
throw new JeecgBootBizTipException("原始接口路径不允许嵌套 file/ftp/gopher/jar/netdoc 等协议");
|
||||
}
|
||||
//update-begin---author:liusq ---date:2026-06-29 for:【issues/9726】修复存储型SSRF,完整URL必须校验host,仅放行内网地址-----------
|
||||
// 仅校验协议无法阻止 SSRF:完整URL的host可指向回环/内网/云元数据。这里复用两套互补校验,
|
||||
// 交集只放行 RFC1918 内网(兼容微服务跨模块调用,如 erp:7003),拦死回环、链路本地、公网:
|
||||
// 1) checkSsrfHttpUrl:拦回环(127.x/::1)与链路本地(169.254.x,含云元数据 169.254.169.254)
|
||||
SsrfFileTypeFilter.checkSsrfHttpUrl(decoded);
|
||||
// 2) checkInternalUrl:拦公网地址,host 必须解析为内网(回环/局域网/链路本地)
|
||||
CommonUtils.checkInternalUrl(decoded);
|
||||
//update-end---author:liusq ---date:2026-06-29 for:【issues/9726】修复存储型SSRF,完整URL必须校验host,仅放行内网地址-----------
|
||||
}
|
||||
if (decoded.contains("..")) {
|
||||
throw new JeecgBootBizTipException("原始接口路径不能包含 ..");
|
||||
}
|
||||
//update-end---author:scott ---date:20260429 for:【issues/9590】微服务nginx部署openApi接口访问不到-----------
|
||||
//update-end---author:scott ---date:20260430 for:【issues/9590】微服务nginx部署openApi接口访问不到-----------
|
||||
}
|
||||
|
||||
@GetMapping("/json")
|
||||
public SwaggerModel swaggerModel() {
|
||||
public SwaggerModel swaggerModel(HttpServletRequest request) {
|
||||
// 从当前请求动态解析 host/basePath/scheme,兼容网关和反向代理场景
|
||||
String baseUrl = CommonUtils.getBaseUrl(request);
|
||||
String host;
|
||||
// 优先取 request.getContextPath():
|
||||
// - 单体模式 server.servlet.context-path=/jeecg-boot → basePath=/jeecg-boot
|
||||
// - 微服务网关模式 system 容器无 context-path → basePath=""(request 实际 path 为 "/openapi/json",会被 uri.getPath() 覆盖为空)
|
||||
// 修复【issues/微服务下 OpenAPI 文档 URL 误带 /jeecg-boot】:原实现硬编码默认 "/jeecg-boot",
|
||||
// 导致微服务网关(gateway:9999)下文档 Request URL 始终带 /jeecg-boot 而实际网关无该前缀,curl 报 404。
|
||||
String basePath = request.getContextPath() == null ? "" : request.getContextPath();
|
||||
try {
|
||||
java.net.URI uri = new java.net.URI(baseUrl);
|
||||
host = uri.getPort() > 0 ? uri.getHost() + ":" + uri.getPort() : uri.getHost();
|
||||
if (uri.getPath() != null && !uri.getPath().isEmpty()) {
|
||||
basePath = uri.getPath();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
host = request.getServerName() + ":" + request.getServerPort();
|
||||
}
|
||||
|
||||
SwaggerModel swaggerModel = new SwaggerModel();
|
||||
swaggerModel.setSwagger("2.0");
|
||||
swaggerModel.setInfo(swaggerInfo());
|
||||
swaggerModel.setHost("jeecg.com");
|
||||
swaggerModel.setBasePath("/jeecg-boot");
|
||||
swaggerModel.setHost(host);
|
||||
swaggerModel.setBasePath(basePath);
|
||||
swaggerModel.setSchemes(Lists.newArrayList("http", "https"));
|
||||
|
||||
SwaggerTag swaggerTag = new SwaggerTag();
|
||||
@ -320,13 +381,13 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
parameters(operation, openApi);
|
||||
|
||||
// body入参
|
||||
if (StringUtils.hasText(openApi.getBody())) {
|
||||
if (StringUtils.hasText(openApi.getRequestBody())) {
|
||||
SwaggerDefinition definition = new SwaggerDefinition();
|
||||
definition.setType("object");
|
||||
Map<String, SwaggerDefinitionProperties> definitionProperties = new HashMap<>();
|
||||
definition.setProperties(definitionProperties);
|
||||
if (openApi.getBody()!=null){
|
||||
JSONObject jsonObject = JSONObject.parseObject(openApi.getBody());
|
||||
if (openApi.getRequestBody()!=null){
|
||||
JSONObject jsonObject = JSONObject.parseObject(openApi.getRequestBody());
|
||||
if (jsonObject.size()>0){
|
||||
for (Map.Entry<String, Object> properties : jsonObject.entrySet()) {
|
||||
SwaggerDefinitionProperties swaggerDefinitionProperties = new SwaggerDefinitionProperties();
|
||||
@ -426,13 +487,17 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
private void parameters(SwaggerOperation operation, OpenApi openApi) {
|
||||
List<SwaggerOperationParameter> parameters = new ArrayList<>();
|
||||
if (openApi.getParamsJson()!=null) {
|
||||
String originUrl = openApi.getOriginUrl() != null ? openApi.getOriginUrl() : "";
|
||||
List<OpenApiParam> openApiParams = JSON.parseArray(openApi.getParamsJson(), OpenApiParam.class);
|
||||
for (OpenApiParam openApiParam : openApiParams) {
|
||||
SwaggerOperationParameter parameter = new SwaggerOperationParameter();
|
||||
parameter.setIn("path");
|
||||
// 外部调用统一使用 query 传参;若该参数在原始接口路径中以 {key} 形式出现,加备注说明
|
||||
boolean isPathVar = originUrl.contains("{" + openApiParam.getParamKey() + "}");
|
||||
parameter.setIn("query");
|
||||
parameter.setName(openApiParam.getParamKey());
|
||||
parameter.setRequired(openApiParam.getRequired() == 1);
|
||||
parameter.setDescription(openApiParam.getNote());
|
||||
String desc = openApiParam.getNote() != null ? openApiParam.getNote() : "";
|
||||
parameter.setDescription(isPathVar ? desc + (desc.isEmpty() ? "" : " ") + "[路径参数]" : desc);
|
||||
parameters.add(parameter);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package org.jeecg.modules.openapi.controller;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.jeecg.common.system.query.QueryGenerator;
|
||||
@ -29,6 +30,7 @@ public class OpenApiLogController extends JeecgController<OpenApiLog, OpenApiLog
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_log:list")
|
||||
@GetMapping(value = "/list")
|
||||
public Result<?> queryPageList(OpenApiLog OpenApiLog, @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
|
||||
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, HttpServletRequest req) {
|
||||
@ -44,6 +46,7 @@ public class OpenApiLogController extends JeecgController<OpenApiLog, OpenApiLog
|
||||
* @param OpenApiLog
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_log:add")
|
||||
@PostMapping(value = "/add")
|
||||
public Result<?> add(@RequestBody OpenApiLog OpenApiLog) {
|
||||
service.save(OpenApiLog);
|
||||
@ -56,6 +59,7 @@ public class OpenApiLogController extends JeecgController<OpenApiLog, OpenApiLog
|
||||
* @param OpenApiLog
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_log:edit")
|
||||
@PutMapping(value = "/edit")
|
||||
public Result<?> edit(@RequestBody OpenApiLog OpenApiLog) {
|
||||
service.updateById(OpenApiLog);
|
||||
@ -69,6 +73,7 @@ public class OpenApiLogController extends JeecgController<OpenApiLog, OpenApiLog
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_log:delete")
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<?> delete(@RequestParam(name = "id", required = true) String id) {
|
||||
service.removeById(id);
|
||||
@ -81,6 +86,7 @@ public class OpenApiLogController extends JeecgController<OpenApiLog, OpenApiLog
|
||||
* @param ids
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_log:deleteBatch")
|
||||
@DeleteMapping(value = "/deleteBatch")
|
||||
public Result<?> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
|
||||
|
||||
@ -94,6 +100,7 @@ public class OpenApiLogController extends JeecgController<OpenApiLog, OpenApiLog
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("openapi:open_api_log:queryById")
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<?> queryById(@RequestParam(name = "id", required = true) String id) {
|
||||
OpenApiLog OpenApiLog = service.getById(id);
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package org.jeecg.modules.openapi.controller;
|
||||
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
import org.jeecg.modules.openapi.entity.OpenApiPermission;
|
||||
@ -10,11 +11,13 @@ import org.springframework.web.bind.annotation.*;
|
||||
@RequestMapping("/openapi/permission")
|
||||
public class OpenApiPermissionController extends JeecgController<OpenApiPermission, OpenApiPermissionService> {
|
||||
|
||||
@RequiresPermissions("openapi:open_api_permission:add")
|
||||
@PostMapping("add")
|
||||
public Result add(@RequestBody OpenApiPermission openApiPermission) {
|
||||
service.add(openApiPermission);
|
||||
return Result.ok("保存成功");
|
||||
}
|
||||
@RequiresPermissions("openapi:open_api_permission:getOpenApi")
|
||||
@GetMapping("/getOpenApi")
|
||||
public Result<?> getOpenApi( String apiAuthId) {
|
||||
return service.getOpenApi(apiAuthId);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user