v3.9.5开源发版 java

This commit is contained in:
JEECG 2026-08-21 17:02:22 +08:00
parent 2d401c923d
commit a2be896f75
225 changed files with 10041 additions and 1106 deletions

58
jeecg-boot/.ignore Normal file
View 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

View File

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

View File

@ -17,7 +17,9 @@ public class DataLogDTO {
private String type;
private String createName;
private String createBy;
private String createName;
public DataLogDTO(){

View File

@ -573,6 +573,11 @@ public interface CommonConstant {
*/
String WECHAT_ENTERPRISE = "WECHAT_ENTERPRISE";
/**
* 飞书
*/
String FEISHU = "FEISHU";
/**
* 系统默认租户id 0
*/

View File

@ -9,7 +9,7 @@ package org.jeecg.common.constant;
public interface PasswordConstant {
/**
* 导入用户默认密码
* 导入用户默认密码 (重置密码)
*/
String DEFAULT_PASSWORD = "123456";
}

View File

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

View File

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

View File

@ -108,13 +108,16 @@ public class JeecgBootExceptionHandler {
/**
* 处理静态资源不存在异常Spring Boot 3.2+
* WebSocket路径被当作静态资源请求时会触发此异常降级为debug日志避免刷屏
* Source MapWebSocket路径被当作静态资源请求时会触发此异常降级为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 forsys_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 forsys_log.method字段长度1000过长导致Data truncation异常吞掉原始错误-----------
}
}
// 请求地址

View File

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

View File

@ -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 forQQYUN-15535敏感字段加@QueryConditionIgnore注解后跳过查询条件构建-----------
if (isQueryConditionIgnoreField(searchObj.getClass(), name)) {
continue;
}
//update-end---author:liusq ---date:2026-05-25 forQQYUN-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 forPR#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 forPR#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 forQQYUN-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 forQQYUN-15535检测字段是否标注@QueryConditionIgnore支持父类字段查找-----------
/**
* 获取请求对应的数据权限规则 TODO 相同列权限多个 有问题
@ -1033,7 +1062,7 @@ public class QueryGenerator {
}
/**
* mysql 模糊查询之特殊字符下划线 _\
* mysqlsqlserver 模糊查询特殊字符转义
*
* @param value:
* @Return: java.lang.String
@ -1046,6 +1075,10 @@ public class QueryGenerator {
value = value.replace(str, "\\" + str);
}
}
// update-begin--author:wangshuai---date:20260820---forLHZP-1165系统管理字典 编码查询 输入br_ branch_的也查出来了
} else if (DataBaseConstant.DB_TYPE_SQLSERVER.equals(getDbType())) {
value = value.replace("[", "[[]").replace("%", "[%]").replace("_", "[_]");
// update-end--author:wangshuai---date:20260820---forLHZP-1165系统管理字典 编码查询 输入br_ branch_的也查出来了
}
return value;
}

View File

@ -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 forissues/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 forissues/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 forissues/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 forissues/9695校验X_GATEWAY_BASE_PATH防止SSRF header注入-----------
/**
* 递归合并 fastJSON 对象
*

View File

@ -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 forissues/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 forissues/9681修复SSRF重定向绕过漏洞(CWE-918)禁止自动跳转并逐跳校验-----------
//update-begin---author:liusq ---date:2026-06-29 forissues/9725uploadImgByHttp 复用安全连接修复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 forissues/9725uploadImgByHttp 复用安全连接修复SSRF重定向绕过漏洞(CWE-918)-----------
/**
* 下载网络资源到磁盘
*
@ -148,12 +220,10 @@ public class FileDownloadUtils {
SsrfFileTypeFilter.checkSsrfHttpUrl(fileUrl);
//update-end---author:zhangdaihao ---date:2026-04-15 forissues/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 forissues/9681修复SSRF重定向绕过漏洞改用禁止自动跳转并逐跳校验的安全连接-----------
// 安全打开连接内部已对初始 URL 及每一跳重定向目标做 SSRF 校验并禁止自动跟随重定向
HttpURLConnection conn = openSafeConnection(fileUrl);
//update-end---author:wangshuai ---date:2026-06-17 forissues/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 forissues/9553修复二次SSRF漏洞对HTTP下载URL进行安全校验-----------
SsrfFileTypeFilter.checkSsrfHttpUrl(fileUrl);
//update-end---author:zhangdaihao ---date:2026-04-15 forissues/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 forissues/9681修复SSRF重定向绕过漏洞改用禁止自动跳转并逐跳校验的安全连接-----------
// 安全打开连接内部已对初始 URL 及每一跳重定向目标做 SSRF 校验并禁止自动跟随重定向
HttpURLConnection connection = openSafeConnection(fileUrl);
//update-end---author:wangshuai ---date:2026-06-17 forissues/9681修复SSRF重定向绕过漏洞改用禁止自动跳转并逐跳校验的安全连接-----------
return connection.getInputStream();
} else {
// 处理本地文件直接读取文件系统

View File

@ -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 forissues/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 forissues/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(){

View File

@ -124,7 +124,10 @@ public class SqlInjectionUtil {
checkSqlAnnotation(value);
// 转为小写进行后续比较
value = value.toLowerCase().trim();
//update-begin---author:wangshuai ---date:2026-06-16 forissue/9677修复换行符绕过SQL注入检测-----------
value = value.replaceAll("\\s+", " ");
//update-end---author:wangshuai ---date:2026-06-16 forissue/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 forissue/9677正则加DOTALL模式防止换行绕过-----------
String regular = "(?s).*" + regularOriginal + ".*";
//update-end---author:wangshuai ---date:2026-06-16 forissue/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 forissue/9677修复换行符绕过SQL注入检测-----------
value = value.replaceAll("\\s+", " ");
//update-end---author:wangshuai ---date:2026-06-16 forissue/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 forissue/9677正则加DOTALL模式防止换行绕过-----------
String regular = "(?s).*" + regularOriginal + ".*";
//update-end---author:wangshuai ---date:2026-06-16 forissue/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 forissue/9677修复换行符绕过SQL注入检测-----------
value = value.replaceAll("\\s+", " ");
//update-end---author:wangshuai ---date:2026-06-16 forissue/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 forissue/9677正则加DOTALL模式防止换行绕过-----------
String regular = "(?s).*" + regularOriginal + ".*";
//update-end---author:wangshuai ---date:2026-06-16 forissue/9677正则加DOTALL模式防止换行绕过-----------
if (Pattern.matches(regular, value)) {
log.error(SqlInjectionUtil.SQL_INJECTION_KEYWORD_TIP, regularOriginal);
log.error(SqlInjectionUtil.SQL_INJECTION_TIP_VARIABLE, value);

View File

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

View File

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

View File

@ -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 协议&#106;avascript: 等变体
*/
private static final Pattern SVG_ENTITY_JS_PATTERN = Pattern.compile(
"&#\\d+;|&#x[0-9a-f]+;", Pattern.CASE_INSENSITIVE
);
/**
* 校验 SVG 文件内容是否安全防止存储型 XSSissues/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 + ">");
}
}
// 检测事件属性onclickonloadonerroronbegin
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 编码防止 &#106;avascript: 等绕过
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声明");
}
}
}

View File

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

View File

@ -10,6 +10,11 @@ import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "jeecg.ai-chat")
public class AiChatConfig {
/**
* 默认聊天模型名称用于判断是否支持Tool Calling等
*/
private String model;
/**
* skills配置文件路径
*/

View File

@ -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 forSpring 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 forSpring 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 forQQYUN-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 forQQYUN-15536修复上传SVG文件通过静态资源路径触发存储型XSS-----------
/**
* 在Bean初始化完成后立即配置PrometheusMeterRegistry避免在Meter注册后才配置MeterFilter
* for [QQYUN-12558]监控系统监控的头两个tab不好使接口404

View File

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

View File

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

View File

@ -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 forissues/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 forissues/8666升级mybatisPlus后SqlServer分页使用OFFSET ROWS FETCH NEXT ROWS ONLY导致online报表报错---
//jeecg-boot/issues/3847增加@Version乐观锁支持
//update-begin---author:scott ---date:2026-07-09 forLHZP-9LHZP-8使用按连接实时探测方言的分页拦截器兼容主库/从库不同数据库类型多数据源SQL Server 2005 方言并做 ORDER BY 去重保留 issues/8666 修复---
interceptor.addInnerInterceptor(new MultiDataSourcePaginationInnerInterceptor());
//update-end---author:scott ---date:2026-07-09 forLHZP-9LHZP-8使用按连接实时探测方言的分页拦截器兼容主库/从库不同数据库类型多数据源SQL Server 2005 方言并做 ORDER BY 去重保留 issues/8666 修复---
// 增加@Version乐观锁支持
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}

View File

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

View File

@ -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 forSpring Boot 4.0 升级恢复 Sentinel 哨兵模式支持API 兼容-----------
// sentinel cluster redisissues/5569shiro集成 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 forSpring Boot 4.0 升级恢复 Sentinel 哨兵模式支持API 兼容-----------
// redis 单机支持在集群为空或者集群无机器时候使用 add by jzyadmin@163.com
if (lettuceConnectionFactory.getClusterConfiguration() == null || lettuceConnectionFactory.getClusterConfiguration().getClusterNodes().isEmpty()) {

View File

@ -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 {
// 调用验证逻辑

View File

@ -12,4 +12,10 @@ public class DomainUrl {
private String pc;
private String app;
/**
* 后端自代理 baseUrl相对路径 originUrl 转发时使用
* 解决 Docker / K8s NodePort / 反向代理等入站端口与监听端口不一致问题
*/
private String back;
}

View File

@ -33,6 +33,13 @@ public class Firewall {
*/
private Boolean enableLoginCaptcha = true;
//update-begin---author:wangshuai ---date:2026-06-29 forQQYUN-16619三级等保密码强度开关-----------
/**
* 是否开启三级等保强密码校验true 开启强密码模式false 使用简单密码规则
*/
private Boolean enableStrongPwd = false;
//update-end---author:wangshuai ---date:2026-06-29 forQQYUN-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 forQQYUN-16619三级等保密码强度开关-----------
public Boolean getEnableStrongPwd() {
return enableStrongPwd;
}
public void setEnableStrongPwd(Boolean enableStrongPwd) {
this.enableStrongPwd = enableStrongPwd;
}
//update-end---author:wangshuai ---date:2026-06-29 forQQYUN-16619三级等保密码强度开关-----------
}

View File

@ -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/9672RFC1918 私网地址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());
}
}
}

View File

@ -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/9677SQL注入换行符绕过修复 单元测试
*
* 漏洞攻击者用换行符(\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)"));
}
}
}

View File

@ -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/9681SSRF 重定向绕过漏洞修复 (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));
}
}
}
}

View File

@ -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/9695OpenAPI 转发 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"));
}
}
}

View File

@ -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/9725uploadImgByHttp 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));
}
}
}
}

View File

@ -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/9726OpenAPI originUrl 存储型 SSRF 漏洞修复 (CWE-918) 单元测试
*
* 漏洞是怎么引起的
* OpenApiController#call(/openapi/call/{path}) 是一个"服务端转发"接口它读取数据库里
* 配置的 originUrl服务器自己发起 restTemplate.exchange(...) 去请求该地址
* originUrl 由管理员通过 POST /openapi/addPUT /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 兜底");
}
}
}

View File

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

View File

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

View File

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

View File

@ -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";
/**
* 应用类型:简单聊天

View File

@ -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";
/**
* 记忆库生成提示词

View File

@ -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-1512AI应用增加复制功能
*/
@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);

View File

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

View File

@ -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;
/**
* 元数据

View File

@ -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-1512AI应用增加复制功能
*/
String copyApp(String id, String currentTenantId);
/**
* 生成提示词
* @param prompt

View File

@ -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);
/**
* 继续接收消息

View File

@ -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-1512AI应用增加复制功能
*/
@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)));
}
}
/**
* 写作列表

View File

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

View File

@ -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 forissues/9787AI聊天匿名接口安全加固匿名必须指定已发布应用+分享令牌禁止回退默认应用-----------
// 匿名访问校验必须指定已发布的应用并携带分享令牌禁止回退默认应用issues/9787
checkAnonymousShareAccess(app, oConvertUtils.isNotEmpty(chatSendParams.getAppId()), chatSendParams.getShareToken());
//update-end---author:scott ---date:20260721 forissues/9787AI聊天匿名接口安全加固匿名必须指定已发布应用+分享令牌禁止回退默认应用-----------
//update-begin---author:wangshuai---date:2025-12-10---for:QQYUN-14127AIAI应用门户---
ChatConversation chatConversation = getOrCreateChatConversation(app, conversationId, chatSendParams.getSessionType());
//update-end---author:wangshuai---date:2025-12-10---for:QQYUN-14127AIAI应用门户---
@ -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 forLHZP-1619应用预览与流程调试共用真实应用变量-----------
app.setId(resolveDebugAppId(app.getId()));
saveVariables(app);
//update-end---author:scott ---date:20260811 forLHZP-1619应用预览与流程调试共用真实应用变量-----------
//update-begin---author:wangshuai---date:2025-12-10---for:QQYUN-14127AIAI应用门户---
ChatConversation chatConversation = getOrCreateChatConversation(app, topicId, "");
//update-end---author:wangshuai---date:2025-12-10---for:QQYUN-14127AIAI应用门户---
@ -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 forissues/9787init匿名访问校验应用必须已发布且令牌匹配同时防止app为空导致空指针-----------
checkAnonymousShareAccess(app, true, shareToken);
//update-end---author:scott ---date:20260721 forissues/9787init匿名访问校验应用必须已发布且令牌匹配同时防止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-14261AIAI助手支持多模态能力- 文档---
appendMessage(messages, userMessage, chatConversation, topicId, sendParams.getFiles(), sendParams.getContent());
//update-end---author:wangshuai---date:2026-01-09---for:QQYUN-14261AIAI助手支持多模态能力- 文档---
// 绘画AI逻辑当开启生成绘画时调用
if (oConvertUtils.isObjectNotEmpty(sendParams.getEnableDraw()) && sendParams.getEnableDraw()) {
//update-begin---author:scott ---date:20260810 forAI应用支持智能识别和图文混合生成-----------
// 手动开启时强制纯生图未开启时由应用聊天模型通过生图工具自动判断并支持图文混排
if (Boolean.TRUE.equals(sendParams.getEnableDraw())) {
return genImageChat(emitter,sendParams,requestId,messages,chatConversation,topicId);
}
//update-end---author:scott ---date:20260810 forAI应用支持智能识别和图文混合生成-----------
/* 这里应该是有几种情况:
* 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 forAI应用支持智能识别和图文混合生成-----------
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 forAI应用支持智能识别和图文混合生成-----------
//update-begin---author:wangshuai---date:2026-03-18---for:QQYUN-14935Langchain4j 新版支持 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-14127AIAI应用门户---
sendWithDefault(requestId, chatConversation, topicId, modelId, messages, aiChatParams, sendParams.getSessionType());
//update-begin---author:scott ---date:20260810 forAI应用支持智能识别和图文混合生成-----------
sendWithDefault(requestId, chatConversation, topicId, modelId, messages, aiChatParams, sendParams.getSessionType(), generatedImageGenerator);
//update-end---author:scott ---date:20260810 forAI应用支持智能识别和图文混合生成-----------
//update-end---author:wangshuai---date:2025-12-10---for:QQYUN-14127AIAI应用门户---
}
@ -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 forAI应用支持智能识别和图文混合生成-----------
sendWithDefault(requestId, chatConversation, topicId, modelId, messages, aiChatParams, sessionType, null);
//update-end---author:scott ---date:20260810 forAI应用支持智能识别和图文混合生成-----------
}
/**
* 处理可延迟合并图片的流式聊天响应
*
* @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 forLHZP-1591智普模型单轮只调用变量工具时兜底写入记忆库-----------
Set<String> executedToolNames = ConcurrentHashMap.newKeySet();
//update-end---author:wangshuai ---date:20260804 forLHZP-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 forAI应用支持智能识别和图文混合生成-----------
if (!deferTextResponse) {
send2Client.accept(resMessage, EventData.EVENT_MESSAGE);
}
//update-end---author:scott ---date:20260810 forAI应用支持智能识别和图文混合生成-----------
}).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 forLHZP-1591智普模型单轮只调用变量工具时兜底写入记忆库-----------
executedToolNames.add(toolExecution.request().name());
//update-end---author:wangshuai ---date:20260804 forLHZP-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 forAI应用思考过程隐藏工具执行原始数据-----------
if (!isThinking.get()) {
send2Client.accept(execTag, EventData.EVENT_MESSAGE);
}
//update-end---author:scott ---date:20260810 forAI应用思考过程隐藏工具执行原始数据-----------
}
}).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 forLHZP-1591智普模型单轮只调用变量工具时兜底写入记忆库-----------
saveMemoryAfterVariableUpdate(chatConversation.getApp(), messages, executedToolNames);
//update-end---author:wangshuai ---date:20260804 forLHZP-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 forAI应用支持智能识别和图文混合生成-----------
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 forAI应用支持智能识别和图文混合生成-----------
appendMessage(messages, aiMessage, chatConversation, topicId);
// 保存会话
//update-begin---author:wangshuai---date:2025-12-10---for:QQYUN-14127AIAI应用门户---
@ -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/9787AI聊天匿名接口安全加固
*/
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/9787init接口返回最小化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/9787init接口返回最小化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 forissues/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 forissues/9672匿名请求禁止远程URL文件引用防止SSRF攻击-----------
//update-begin---author:wangshuai ---date:2026-04-13 forissues/9519AI附件处理路径遍历漏洞下载文件名做安全过滤临时目录隔离---
// 远程下载使用 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 forissues/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 forissues/9672匿名请求文件引用安全校验-----------
}

View File

@ -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 = "![](" + imageUrls.get(i) + " =" + 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("```");
}
}

View File

@ -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 -> "![](" + 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();
}
}

View File

@ -0,0 +1,71 @@
package org.jeecg.modules.airag.app.vo;
import lombok.Data;
import java.io.Serializable;
/**
* @Description: AI应用分享信息/airag/chat/init 返回给聊天页的视图对象
* 只暴露前端聊天页必需的字段避免泄露 prompttenantIdmodelId 等内部配置
* @author scott
* @since 2026-07-21 issues/9787init接口返回最小化VO避免泄露prompt等内部配置
*/
@Data
public class AiragAppShareInfoVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 应用IDsend 时回传
*/
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 重新组装
* flowInputsmultiSessionizDrawdefaultSelectdrawModelIdmodelInfo
*/
private String metadata;
}

View File

@ -42,6 +42,14 @@ public class ChatSendParams {
*/
private String appId;
/**
* 分享令牌匿名访问必填
*
* @author scott
* @since 2026-07-21 issues/9787匿名发送携带分享令牌
*/
private String shareToken;
/**
* 图片列表
*/

View File

@ -182,6 +182,11 @@ public interface FlowPluginContent {
*/
String PLUGIN_DESC = "调用工作流";
/**
* 流程工具名称前缀
*/
String FLOW_TOOL_NAME_PREFIX = "flow_";
/**
* 插件请求地址
*/

View File

@ -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 forissues/9727不支持Tool Calling的模型如deepseek-r1系列发送工具调用时报错-----------
/**
* 判断指定模型是否不支持 Tool Calling工具调用/函数调用
* 包括
* - deepseek-r1 系列Ollama 命名deepseek-r1deepseek-r1:14bdeepseek-r1:7b
* - deepseek-reasonerDeepSeek 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 forissues/9727不支持Tool Calling的模型如Ollama的deepseek-r1系列发送工具调用时报错-----------
//update-begin---author:claude ---date:2026-08-07 forKimi 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=1topP=0.95presencePenalty=0frequencyPenalty=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-256k3-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-256k3-256k
for (String fixedModel : FIXED_SAMPLING_PARAM_MODELS) {
if (name.startsWith(fixedModel + "-")) {
return true;
}
}
return false;
}
//update-end---author:claude ---date:2026-08-07 forKimi k3 等模型采样参数需固定(temperature=1/topP=0.95/presencePenalty=0/frequencyPenalty=0)传其他值报 "invalid temperature: only 1 is allowed for this model"-----------
/**
* 知识库类型知识库
*/

View File

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

View File

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

View File

@ -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 forissues/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 forissues/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 forissues/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 forissues/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-12145AIAI 绘画创作---=
}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)){

View File

@ -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 forissues/9808AI知识库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 forissues/9808AI知识库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();

View File

@ -112,7 +112,6 @@ public class AiragModel implements Serializable {
/**
* 凭证信息
*/
@Excel(name = "凭证信息", width = 15)
@Schema(description = "凭证信息")
private String credential;
/**

View File

@ -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 forissues/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 forKimi 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 forKimi 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 forissues/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 forLHZP-1497插件禁用后仍可被AI调用禁用插件不构建工具防止LLM调用禁用插件---
if (LLMConsts.STATUS_DISABLE.equals(airagMcp.getStatus())) {
log.warn("插件[{}]已禁用,跳过工具构建", airagMcp.getName());
continue;
}
//update-end---author:scott ---date:20260803 forLHZP-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 forLHZP-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 forLHZP-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 forissues/9805AI图生图imageUrl未校验导致SSRF增加URL安全校验-----------
SsrfFileTypeFilter.checkSsrfHttpUrl(imageUrl);
//update-end---author:zhangdaihao ---date:2026-08-06 forissues/9805AI图生图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);

View File

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

View File

@ -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 forissues/9808AI知识库Web文档抓取SSRF漏洞修复抓取前调用SSRF校验器-----------
SsrfFileTypeFilter.checkSsrfHttpUrl(website);
//update-end---author:zhangdaihao ---date:2026-08-06 forissues/9808AI知识库Web文档抓取SSRF漏洞修复抓取前调用SSRF校验器-----------
try {
WebPageParser webPageParser = new WebPageParser();

View File

@ -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&lt;ToolSpecification, ToolExecutor&gt;
* @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 forLHZP-1591智普模型无法将AI应用信息写入记忆库-----------
// 服务端固定参数不暴露给模型由工具执行器按 defaultValue 自动注入
if (Boolean.TRUE.equals(param.getBoolean("hidden"))) {
continue;
}
//update-end---author:wangshuai ---date:20260804 forLHZP-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());

View File

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

View File

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

View File

@ -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 forQQYUN-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 forQQYUN-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 forQQYUN-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 forQQYUN-16635用字符串截取取文件名避免文件名含非法字符时 Paths.get InvalidPathException-----------
return fileName.startsWith("._") || fileName.equals(".DS_Store");
}
//update-end---author:scott ---date:2026-04-16 forissues/9551macOS压缩包隐藏文件过滤-----------

View File

@ -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 forLHZP-1591智普模型无法将AI应用信息写入记忆库-----------
// 记忆库ID由服务端注入避免模型因无法获知必填ID而放弃工具调用
knowIdParam.put("hidden", true);
//update-end---author:wangshuai ---date:20260804 forLHZP-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 forLHZP-1591智普模型无法将AI应用信息写入记忆库-----------
// 记忆库ID由服务端注入避免模型因无法获知必填ID而放弃工具调用
knowIdParam.put("hidden", true);
//update-end---author:wangshuai ---date:20260804 forLHZP-1591智普模型无法将AI应用信息写入记忆库-----------
parameters.add(knowIdParam);
// 查询内容参数

View File

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

View File

@ -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 forQQYUN-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 forQQYUN-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("从回收站彻底删除!");
}
/**
* 构造器调试
*

View File

@ -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 = "提示词功能描述")

View File

@ -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 forQQYUN-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 forQQYUN-14643实现回收站取回和彻底删除-----------
}

View File

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

View File

@ -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 forQQYUN-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 forQQYUN-14643实现回收站取回和彻底删除-----------
}

View File

@ -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 forQQYUN-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 forQQYUN-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();
// 过滤提示词变量

View File

@ -16,7 +16,7 @@ import java.util.Map;
public class AiragExperimentVo implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 提示词
* 提示词Id
*/
private String promptKey;
/**

View File

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

View File

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

View File

@ -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("分享链接无效或已取消发布")));
}
}

View File

@ -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);
/**
* 读取会话变量
*/

View File

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

View File

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

View File

@ -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);
/**
* 读取会话变量
*

View File

@ -7,7 +7,7 @@ import java.util.List;
import java.util.Map;
/**
* Onlineonline表单对外接口
* 表单设计器Online翻译API接口
*
* @author sunjianlei
*/

View File

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

View File

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

View File

@ -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:}")

View File

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

View File

@ -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 forissues升级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 forissues升级Tomcat11后work目录生成在项目目录问题-----------
return factory;
}
}

View File

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

View File

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

View File

@ -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("文件导入失败:未找到上传文件");
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 forissues/9590微服务nginx部署openApi接口访问不到-----------
//update-begin---author:scott ---date:20260430 forissues/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) 完整URLhttp(s)://host:port/path直接使用适用于微服务模式下接口部署在其他微服务模块 erp 7003的场景
String lowerUrl = url.toLowerCase();
//update-begin---author:zhang ---date:20260806 forissues/9794OpenAPI 相对 originUrl 自代理使用入站 Host/portDocker 端口映射时 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 forissues/9590微服务nginx部署openApi接口访问不到-----------
//update-end---author:zhang ---date:20260806 forissues/9794OpenAPI 相对 originUrl 自代理使用入站 Host/portDocker 端口映射时 Connection refused-----------
//update-end---author:wangshuai ---date:20260616 forissues/9695修复SSRF漏洞校验baseUrl必须为内网地址-----------
//update-end---author:scott ---date:20260430 forissues/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 forissues/9590微服务nginx部署openApi接口访问不到-----------
//update-begin---author:scott ---date:20260430 forissues/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 forissues/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 forissues/9726修复存储型SSRF完整URL必须校验host仅放行内网地址-----------
}
if (decoded.contains("..")) {
throw new JeecgBootBizTipException("原始接口路径不能包含 ..");
}
//update-end---author:scott ---date:20260429 forissues/9590微服务nginx部署openApi接口访问不到-----------
//update-end---author:scott ---date:20260430 forissues/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);
}
}

View File

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

View File

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