2 Commits

593 changed files with 65079 additions and 4305 deletions

57
jeecg-boot/.claudeignore Normal file
View File

@@ -0,0 +1,57 @@
# 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/

142
jeecg-boot/CLAUDE.md Normal file
View File

@@ -0,0 +1,142 @@
# CLAUDE.md
> You should always answer questions in Simplified Chinese first, unless the user explicitly requests another language.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
JeecgBoot 3.9.2 — a Java low-code development platform built on **Spring Boot 3.5.5**, **Java 17** (also supports 21, 24). It runs as a monolithic app by default, with an optional Spring Cloud microservices mode. Uses `jakarta` namespace (not `javax`) throughout.
## Build & Run Commands
```bash
# Full build (tests are skipped by default via surefire config)
mvn clean package
# Build with tests
mvn clean package -DskipTests=false
# Run the standalone application (port 8080, context-path: /jeecg-boot)
cd jeecg-module-system/jeecg-system-start
mvn spring-boot:run
# Build a specific module (with dependencies)
mvn clean package -pl jeecg-boot-base-core -am
# Run a single test class
mvn test -DskipTests=false -pl <module> -Dtest=<TestClassName>
# Build with microservices modules included
mvn clean package -P SpringCloud
# Docker startup
./start-docker-compose.sh # or start-docker-compose.bat on Windows
```
## Module Architecture
```
jeecg-boot-parent (root pom)
├── jeecg-boot-base-core # Core framework: Shiro/JWT auth, MyBatis-Plus config,
│ # common utilities, AOP aspects, base controllers
├── jeecg-module-system # System management (users, roles, permissions, dicts, menus)
│ ├── jeecg-system-api # API interfaces (local-api vs cloud-api for mono/micro switch)
│ │ ├── jeecg-system-local-api # Direct method calls (monolithic)
│ │ └── jeecg-system-cloud-api # Feign clients (microservices)
│ ├── jeecg-system-biz # Business logic, entities, mappers, services
│ └── jeecg-system-start # Main entry point (JeecgSystemApplication), all configs
├── jeecg-boot-module # Business feature modules
│ ├── jeecg-module-demo # Demo/example code
│ ├── jeecg-boot-module-airag # AI/RAG integration
│ ├── jeecg-boot-module-easyoa # Simple OA module
│ ├── jeecg-boot-module-joa-flowable # OA + Flowable workflow
│ ├── jeecg-boot-module-pay # Payment module
│ └── jeecg-boot-module-wps # WPS document integration
└── jeecg-boot-platform # Low-code platform modules
├── jeecg-boot-module-bpm-flowable # BPM workflow engine
├── jeecg-boot-module-airag-flow # AI RAG flow
├── jeecg-boot-module-bigscreen # Big screen/dashboard designer
├── jeecg-boot-module-desform # Form designer
├── jeecg-boot-module-drag # Drag-and-drop report designer
├── jeecg-boot-module-lowapp # Low-code application engine
├── jeecg-boot-module-mindesflow-flowable # Simple flow designer
└── jeecg-boot-module-online # Online code generator & forms
```
Optional microservices modules (activated via `-P SpringCloud`):
- `jeecg-server-cloud/` — Gateway (port 9999), Nacos (8848), cloud service starters, monitoring (9111), XXL-Job (9080), Sentinel (9000)
## Key Technology Stack
| Layer | Technology |
|-------|-----------|
| ORM | MyBatis-Plus 3.5.12 (`BaseMapper<T>`, `ServiceImpl<M,T>`) |
| Auth | Apache Shiro 2.0.5 + JWT 4.5.0, Redis-backed sessions |
| DB Pool | Druid 1.2.24 with dynamic datasource support |
| DB Migration | Flyway (scripts in `jeecg-system-start/src/main/resources/flyway/sql/mysql/`) |
| JSON | FastJSON 2 |
| Excel | AutoPoi (`autopoi-spring-boot-3-starter`) |
| API Docs | Knife4j 4.5.0 (OpenAPI v3, `@Schema` annotations) |
| Scheduled Jobs | Quartz (JDBC store, clustered) |
| File Storage | MinIO / Aliyun OSS / Qiniu (controlled by `jeecg.uploadType` config) |
| Microservices | Spring Cloud 2025.0.0 + Alibaba (Nacos, Gateway, Sentinel) |
## Code Conventions & Patterns
**Package structure:** `org.jeecg.modules.<module-name>.{controller,entity,mapper,mapper.xml,service,service.impl,vo}`
**Naming conventions:**
- Entities: `Sys` prefix for system entities (e.g., `SysUser`, `SysRole`). Use `@TableName`, `@TableId(type = IdType.ASSIGN_ID)`
- Controllers: `<Entity>Controller extends JeecgController<Entity, IService>` — base class provides standard CRUD + Excel import/export
- Services: Interface `I<Entity>Service extends IService<Entity>`, impl `<Entity>ServiceImpl extends ServiceImpl<Mapper, Entity>`
- Mappers: `<Entity>Mapper extends BaseMapper<Entity>`, with XML in `mapper/xml/`
**Common annotations on entities:** `@Data`, `@EqualsAndHashCode(callSuper = false)`, `@Accessors(chain = true)`, `@TableName`
**API response wrapper:** `Result<T>` (from `org.jeecg.common.api.vo.Result`) — use `Result.OK(data)`, `Result.OK(msg, data)`, `Result.error(msg)`. The `result` field holds data, `success`/`code`/`message` hold status.
**Auto query building:** `QueryGenerator.initQueryWrapper(entity, request.getParameterMap())` auto-builds `QueryWrapper` from HTTP request params, supporting fuzzy match, range queries, etc.
**Monolithic ↔ Microservices switch:** The `jeecg-system-api` module has two implementations (`local-api` for direct calls, `cloud-api` for Feign). Switching is done by changing the dependency in the startup module, not by modifying business code.
**代码修改痕迹日志:** 所有新增或修改的代码块必须用 `update-begin` / `update-end` 注释包裹,格式如下:
```java
//update-begin---author:作者 ---date:YYYY-MM-DD for【bug号/需求号】修改说明-----------
// 新增或修改的代码
//update-end---author:作者 ---date:YYYY-MM-DD for【bug号/需求号】修改说明-----------
```
规则:
- `author` 填实际修改人,`date` 填修改日期(格式 `YYYY-MM-DD``for` 填 bug 号或需求号 + 简要说明
- 新增方法:`update-begin` 放在方法声明前,`update-end` 放在方法结束 `}`
- 修改已有方法中的代码:`update-begin` / `update-end` 只包裹被修改的代码段,不包裹整个方法
- 用户未提供 bug 号时,需要主动询问
## Database
**Supported:** MySQL 8.0+ (default), PostgreSQL, Oracle 11g+, SQL Server 2017+, MariaDB, DM8 (达梦), KingBase ES. Database-specific configs are in `application-{dbtype}.yml` profiles.
**Initial setup:** Import `db/jeecgboot-mysql-5.7.sql` for the base schema. Flyway handles incremental migrations (scripts organized by date folders like `202512/`).
**Flyway note:** In dev mode, `spring.main.lazy-initialization=true` is enabled for startup speed, which can interfere with Flyway auto-config. Flyway auto-config is explicitly excluded and managed separately.
## Configuration
Main config files are in `jeecg-module-system/jeecg-system-start/src/main/resources/`:
- `application.yml` — profile selector (active profile set by Maven: dev/test/prod/docker)
- `application-dev.yml` — development config (port 8080, lazy-init enabled)
- Dev environment requires: MySQL, Redis. Optional: MongoDB, RabbitMQ
Key config namespace: `jeecg.*` in YAML controls platform features (upload type, firewall settings, AI config, MinIO, shiro excludes, etc.).
## Docker Services (docker-compose.yml)
MySQL (port 13306), Redis, PostgreSQL+pgvector, MongoDB, and the application container (port 8080).
## Online 低代码模块 (jeecg-boot-module-online)
Online 模块采用**元数据驱动**架构,通过数据库配置表(`onl_cgform_*`)实现运行时 CRUD无需生成代码。配置存在数据库中而非文件系统Claude Code 无法直接读取具体表单配置,需用户提供 JSON 导出或截图。
**完整的配置 Schema、控件类型、默认值语法、增强机制等详见: [online-form-schema.md](online-form-schema.md)**

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-parent</artifactId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>jeecg-boot-base-core</artifactId>
@@ -377,7 +377,7 @@
<!-- chatgpt -->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-starter-chatgpt</artifactId>
<artifactId>jeecg-boot-starter-ai</artifactId>
</dependency>
<!-- 腾讯云 -->
<dependency>

View File

@@ -2,10 +2,8 @@ package org.jeecg.common.aspect;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.parser.Feature;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
@@ -23,7 +21,11 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.beans.PropertyDescriptor;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.text.SimpleDateFormat;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -44,9 +46,6 @@ public class DictAspect {
@Autowired
public RedisTemplate redisTemplate;
@Autowired
private ObjectMapper objectMapper;
private static final String JAVA_UTIL_DATE = "java.util.Date";
/**
@@ -113,19 +112,25 @@ public class DictAspect {
log.debug(" __ 进入字典翻译切面 DictAspect —— " );
for (Object record : records) {
String json="{}";
try {
//解决@JsonFormat注解解析不了的问题详见SysAnnouncement类的@JsonFormat
json = objectMapper.writeValueAsString(record);
} catch (JsonProcessingException e) {
log.error("json解析失败"+e.getMessage(),e);
}
// 代码逻辑说明: 【issues/3303】restcontroller返回json数据后key顺序错乱 -----
JSONObject item = JSONObject.parseObject(json, Feature.OrderedField);
//for (Field field : record.getClass().getDeclaredFields()) {
// 遍历所有字段把字典Code取出来放到 map 里
//update-begin---author:scott ---date:2026-04-15 for【issues/9543】改用反射直接读取字段构建 JSONObject避免 ObjectMapper 对循环引用实体进行全量序列化导致 OOM合并字典字段收集逻辑为同一次循环避免对 getAllFields 遍历两遍;保留 【issues/#3629】@JsonFormat 的 Date 格式化兼容;保留 【issues/3303】字段顺序LinkedHashMap-----------
JSONObject item = new JSONObject(true);
for (Field field : oConvertUtils.getAllFields(record)) {
if (Modifier.isStatic(field.getModifiers())) {
continue;
}
//update-begin---author:scott ---date:2026-04-16 for【issues/9543】优先通过 getter 方法读取字段值(兼容实体重写 getter 的场景getter 不存在时 fallback 到直接读字段-----------
Object fieldValue = getFieldValue(record, field);
//update-end---author:scott ---date:2026-04-16 for【issues/9543】优先通过 getter 方法读取字段值(兼容实体重写 getter 的场景getter 不存在时 fallback 到直接读字段-----------
// 解决@JsonFormat注解解析不了的问题详见SysAnnouncement类的@JsonFormat
if (fieldValue instanceof Date) {
JsonFormat jsonFormat = field.getAnnotation(JsonFormat.class);
if (jsonFormat != null && oConvertUtils.isNotEmpty(jsonFormat.pattern())) {
fieldValue = new SimpleDateFormat(jsonFormat.pattern()).format((Date) fieldValue);
}
}
item.put(field.getName(), fieldValue);
// 遍历所有字段把字典Code取出来放到 map 里
String value = item.getString(field.getName());
if (oConvertUtils.isEmpty(value)) {
continue;
@@ -154,6 +159,7 @@ public class DictAspect {
// item.put(field.getName(), aDate.format(new Date((Long) item.get(field.getName()))));
//}
}
//update-end---author:scott ---date:2026-04-15 for【issues/9543】改用反射直接读取字段构建 JSONObject避免 ObjectMapper 对循环引用实体进行全量序列化导致 OOM合并字典字段收集逻辑为同一次循环避免对 getAllFields 遍历两遍;保留 【issues/#3629】@JsonFormat 的 Date 格式化兼容;保留 【issues/3303】字段顺序LinkedHashMap-----------
items.add(item);
}
@@ -417,6 +423,30 @@ public class DictAspect {
return textValue.toString();
}
//update-begin---author:scott ---date:2026-04-16 for【issues/9543】优先通过 getter 方法读取字段值(兼容实体重写 getter 的场景getter 不存在时 fallback 到直接读字段-----------
/**
* 优先通过 PropertyDescriptor 获取 getter 方法读取字段值,兼容实体重写 getter 的场景;
* getter 不存在或调用异常时 fallback 到直接反射读字段。
*/
private Object getFieldValue(Object record, Field field) {
try {
PropertyDescriptor pd = new PropertyDescriptor(field.getName(), record.getClass());
Method readMethod = pd.getReadMethod();
if (readMethod != null) {
return readMethod.invoke(record);
}
} catch (Exception ignored) {
}
try {
field.setAccessible(true);
return field.get(record);
} catch (IllegalAccessException e) {
log.error("反射读取字段失败: " + field.getName(), e);
return null;
}
}
//update-end---author:scott ---date:2026-04-16 for【issues/9543】优先通过 getter 方法读取字段值(兼容实体重写 getter 的场景getter 不存在时 fallback 到直接读字段-----------
/**
* 检测返回结果集中是否包含Dict注解
* @param records

View File

@@ -27,7 +27,8 @@ public enum FileTypeEnum {
mp4(".mp4","video","视频"),
zip(".zip","zip","压缩包"),
pdf(".pdf","pdf","pdf"),
mp3(".mp3","mp3","语音");
mp3(".mp3","mp3","语音"),
wav(".wav","wav","语音");
private String type;
private String value;

View File

@@ -23,7 +23,11 @@ public enum UniPushTypeEnum {
/**
* 系统消息
*/
SYS_MSG("system", "系统消息", "收到一条系统通告");
SYS_MSG("system", "系统消息", "收到一条系统通告"),
/**
* 协同工作
*/
COLLABORATION_MSG("collaboration", "系统消息", "收到一条协同工作消息");
/**
* 业务类型(chat:聊天 bpm_task:流程 bpm_cc:流程抄送)

View File

@@ -34,6 +34,7 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
import java.util.Map;
import java.util.stream.Collectors;
@@ -105,6 +106,23 @@ public class JeecgBootExceptionHandler {
return Result.error(404, "路径不存在,请检查路径是否正确");
}
/**
* 处理静态资源不存在异常Spring Boot 3.2+
* WebSocket路径被当作静态资源请求时会触发此异常降级为debug日志避免刷屏
*/
@ExceptionHandler(NoResourceFoundException.class)
public Result<?> handleNoResourceFoundException(NoResourceFoundException e, HttpServletRequest request) {
String uri = request.getRequestURI();
// WebSocket路径的非upgrade请求降级为debug日志
if (uri.contains("Socket/") || uri.contains("websocket/") || uri.contains("Websocket/")) {
log.debug("WebSocket路径被当作静态资源请求: {}", uri);
} else {
log.error(e.getMessage(), e);
addSysLog(e);
}
return Result.error(404, "路径不存在,请检查路径是否正确");
}
@ExceptionHandler(DuplicateKeyException.class)
public Result<?> handleDuplicateKeyException(DuplicateKeyException e){
log.error(e.getMessage(), e);

View File

@@ -135,7 +135,11 @@ public class QueryGenerator {
//权限规则自定义SQL表达式
for (String c : ruleMap.keySet()) {
if(oConvertUtils.isNotEmpty(c) && c.startsWith(SQL_RULES_COLUMN)){
queryWrapper.and(i ->i.apply(getSqlRuleValue(ruleMap.get(c).getRuleValue())));
// update-begin---author:sunjianlei ---date:20260331 for【#9434】修复 QueryGenerator 自定义权限规则逻辑存在 SQL 注入漏洞
String sqlRule = getSqlRuleValue(ruleMap.get(c).getRuleValue());
SqlInjectionUtil.filterContent(sqlRule, null);
queryWrapper.and(i ->i.apply(sqlRule));
// update-end-----author:sunjianlei ---date:20260331 for【#9434】修复 QueryGenerator 自定义权限规则逻辑存在 SQL 注入漏洞
}
}
@@ -165,26 +169,23 @@ public class QueryGenerator {
//区间查询
doIntervalQuery(queryWrapper, parameterMap, type, name, column);
//判断单值 参数带不同标识字符串 走不同的查询
//TODO 这种前后带逗号的支持分割后模糊查询(多选字段查询生效) 示例:,1,3,
// update-begin--author:claude--date:20260330--for:【issues/9265】多选字段查询精确匹配避免值1匹配到值10
//多选字段查询生效 示例:,1,3, 使用精确边界匹配(兼容所有数据库)
if (null != value && value.toString().startsWith(COMMA) && value.toString().endsWith(COMMA)) {
String multiLikeval = value.toString().replace(",,", COMMA);
String[] vals = multiLikeval.substring(1, multiLikeval.length()).split(COMMA);
final String field = oConvertUtils.camelToUnderline(column);
if(vals.length>1) {
queryWrapper.and(j -> {
log.info("---查询过滤器Query规则---field:{}, rule:{}, value:{}", field, "like", vals[0]);
j = j.like(field,vals[0]);
for (int k=1;k<vals.length;k++) {
j = j.or().like(field,vals[k]);
log.info("---查询过滤器Query规则 .or()---field:{}, rule:{}, value:{}", field, "like", vals[k]);
}
//return j;
});
}else {
log.info("---查询过滤器Query规则---field:{}, rule:{}, value:{}", field, "like", vals[0]);
queryWrapper.and(j -> j.like(field,vals[0]));
}
}else {
queryWrapper.and(j -> {
log.info("---查询过滤器Query规则(多选精确匹配)---field:{}, rule:{}, value:{}", field, "multi_select", vals[0]);
j = j.eq(field, vals[0]).or().likeRight(field, vals[0] + ",").or().like(field, "," + vals[0] + ",").or().likeLeft(field, "," + vals[0]);
for (int k = 1; k < vals.length; k++) {
log.info("---查询过滤器Query规则(多选精确匹配) .or()---field:{}, rule:{}, value:{}", field, "multi_select", vals[k]);
j = j.or().eq(field, vals[k]).or().likeRight(field, vals[k] + ",").or().like(field, "," + vals[k] + ",").or().likeLeft(field, "," + vals[k]);
}
});
}
// update-end--author:claude--date:20260330--for:【issues/9265】多选字段查询精确匹配避免值1匹配到值10
else {
// 代码逻辑说明: [TV360X-378]增加自定义字段查询规则功能------------
QueryRuleEnum rule;
if(null != customRuleMap && customRuleMap.containsKey(name)) {
@@ -576,10 +577,16 @@ public class QueryGenerator {
value = val.substring(1, val.length() - 1);
//mysql 模糊查询之特殊字符下划线 _、\
value = specialStrConvert(value.toString());
} else if (rule == QueryRuleEnum.LEFT_LIKE || rule == QueryRuleEnum.NE) {
} else if (rule == QueryRuleEnum.LEFT_LIKE) {
value = val.substring(1);
//mysql 模糊查询之特殊字符下划线 _、\
value = specialStrConvert(value.toString());
//update-begin---author:scott ---date:20260416 for【PR#9322】修复NE规则与LEFT_LIKE共用substring(1)导致ID首位字符丢失-----------
} else if (rule == QueryRuleEnum.NE) {
if (val.startsWith(QueryRuleEnum.NE.getValue())) {
value = val.substring(1);
}
//update-end---author:scott ---date:20260416 for【PR#9322】修复NE规则与LEFT_LIKE共用substring(1)导致ID首位字符丢失-----------
} else if (rule == QueryRuleEnum.RIGHT_LIKE) {
value = val.substring(0, val.length() - 1);
//mysql 模糊查询之特殊字符下划线 _、\
@@ -754,6 +761,7 @@ public class QueryGenerator {
queryWrapper.notLikeRight(name, value);
break;
// 代码逻辑说明: [TV360X-378]下拉多框根据条件查询不出来:增加自定义字段查询规则功能------------
// update-begin--author:claude--date:20260330--for:【issues/9265】LIKE_WITH_OR多选查询精确匹配避免值1匹配到值10
case LIKE_WITH_OR:
final String nameFinal = name;
Object[] vals;
@@ -769,14 +777,15 @@ public class QueryGenerator {
vals = new Object[]{value};
}
queryWrapper.and(j -> {
log.info("---查询过滤器Query规则---field:{}, rule:{}, value:{}", nameFinal, "like", vals[0]);
j = j.like(nameFinal, vals[0]);
log.info("---查询过滤器Query规则(多选精确匹配)---field:{}, rule:{}, value:{}", nameFinal, "multi_select", vals[0]);
j = j.eq(nameFinal, vals[0]).or().likeRight(nameFinal, vals[0] + ",").or().like(nameFinal, "," + vals[0] + ",").or().likeLeft(nameFinal, "," + vals[0]);
for (int k = 1; k < vals.length; k++) {
j = j.or().like(nameFinal, vals[k]);
log.info("---查询过滤器Query规则 .or()---field:{}, rule:{}, value:{}", nameFinal, "like", vals[k]);
log.info("---查询过滤器Query规则(多选精确匹配) .or()---field:{}, rule:{}, value:{}", nameFinal, "multi_select", vals[k]);
j = j.or().eq(nameFinal, vals[k]).or().likeRight(nameFinal, vals[k] + ",").or().like(nameFinal, "," + vals[k] + ",").or().likeLeft(nameFinal, "," + vals[k]);
}
});
break;
// update-end--author:claude--date:20260330--for:【issues/9265】LIKE_WITH_OR多选查询精确匹配避免值1匹配到值10
default:
log.info("--查询规则未匹配到---");
break;
@@ -984,7 +993,11 @@ public class QueryGenerator {
PropertyDescriptor[] origDescriptors = PropertyUtils.getPropertyDescriptors(clazz);
for (String c : ruleMap.keySet()) {
if(oConvertUtils.isNotEmpty(c) && c.startsWith(SQL_RULES_COLUMN)){
queryWrapper.and(i ->i.apply(getSqlRuleValue(ruleMap.get(c).getRuleValue())));
// update-begin---author:sunjianlei ---date:20260331 for【#9434】修复 QueryGenerator 自定义权限规则逻辑存在 SQL 注入漏洞
String sqlRule = getSqlRuleValue(ruleMap.get(c).getRuleValue());
SqlInjectionUtil.filterContent(sqlRule, null);
queryWrapper.and(i ->i.apply(sqlRule));
// update-end-----author:sunjianlei ---date:20260331 for【#9434】修复 QueryGenerator 自定义权限规则逻辑存在 SQL 注入漏洞
}
}
String name, column;

View File

@@ -144,7 +144,9 @@ public class ResourceUtil {
*/
private static void processEnumClass(String classname) {
try {
Class<?> clazz = Class.forName(classname);
//update-begin---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
Class<?> clazz = Class.forName(classname, true, Thread.currentThread().getContextClassLoader());
//update-end---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
EnumDict enumDict = clazz.getAnnotation(EnumDict.class);
if (enumDict != null) {

View File

@@ -15,6 +15,8 @@ public class SysDepartModel {
private String departNameEn;
/**缩写*/
private String departNameAbbr;
/**机构/部门路径名称*/
private String departPathName;
/**排序*/
private Integer departOrder;
/**描述*/
@@ -74,6 +76,14 @@ public class SysDepartModel {
this.departNameAbbr = departNameAbbr;
}
public String getDepartPathName() {
return departPathName;
}
public void setDepartPathName(String departPathName) {
this.departPathName = departPathName;
}
public Integer getDepartOrder() {
return departOrder;
}

View File

@@ -26,6 +26,8 @@ import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.SQLException;
@@ -61,7 +63,18 @@ public class CommonUtils {
//update-end---author:wangshuai---date:2026-01-08---for:【QQYUN-14535】ai生成图片的后缀不一致的导致不展示---
try {
if(CommonConstant.UPLOAD_TYPE_LOCAL.equals(uploadType)){
File file = new File(basePath + File.separator + bizPath + File.separator );
//update-begin---author:wangshuai---date:2026-03-30---for:【issues/9435】uploadOnlineImage路径遍历漏洞修复---
// 1. 使用已有的路径遍历检查
SsrfFileTypeFilter.checkPathTraversal(bizPath);
// 2. 标准化路径并校验是否在basePath范围内
Path root = Paths.get(basePath).toAbsolutePath().normalize();
Path targetDir = root.resolve(bizPath).toAbsolutePath().normalize();
if (!targetDir.startsWith(root)) {
log.error("检测到路径遍历攻击!非法 bizPath: {}", bizPath);
throw new SecurityException("Illegal access to path outside of base directory.");
}
File file = targetDir.toFile();
//update-end---author:wangshuai---date:2026-03-30---for:【issues/9435】uploadOnlineImage路径遍历漏洞修复---
if (!file.exists()) {
file.mkdirs();// 创建文件根目录
}
@@ -159,7 +172,14 @@ public class CommonUtils {
SsrfFileTypeFilter.checkUploadFileType(mf, bizPath);
String fileName = null;
File file = new File(uploadpath + File.separator + bizPath + File.separator );
//update-begin---author:liusq ---date:2026-03-30 for【issues/9428】修复uploadLocal bizPath路径遍历漏洞(CWE-22)-----------
// 路径遍历校验规范化后确保目标目录在uploadpath内
File uploadDir = new File(uploadpath).getCanonicalFile();
File file = new File(uploadpath + File.separator + bizPath + File.separator).getCanonicalFile();
if (!file.toPath().startsWith(uploadDir.toPath())) {
throw new JeecgBootException("非法业务路径,禁止访问上传目录之外的路径: " + bizPath);
}
//update-end---author:liusq ---date:2026-03-30 for【issues/9428】修复uploadLocal bizPath路径遍历漏洞(CWE-22)-----------
if (!file.exists()) {
// 创建文件根目录
file.mkdirs();
@@ -198,8 +218,14 @@ public class CommonUtils {
}
/**
* 统一全局上传 带桶
* @Return: java.lang.String
* 统一全局上传(支持自定义桶)
* 根据 uploadType 自动选择 MinIO 或 阿里云OSS 进行文件上传
*
* @param file 待上传的文件
* @param bizPath 业务路径,作为文件存储的目录前缀(如 "upload/images"
* @param uploadType 上传方式:{@link CommonConstant#UPLOAD_TYPE_MINIO} 使用MinIO其他使用阿里云OSS
* @param customBucket 自定义桶名称,为空则使用各存储服务的默认桶
* @return 文件访问URL上传失败返回空字符串
*/
public static String upload(MultipartFile file, String bizPath, String uploadType, String customBucket) {
String url = "";
@@ -368,7 +394,7 @@ public class CommonUtils {
}else{
baseDomainPath = scheme + "://" + serverName + ":" + serverPort + contextPath ;
}
log.info("-----Common getBaseUrl----- : " + baseDomainPath);
log.debug("-----获取当前服务 BaseUrl----- : " + baseDomainPath);
return baseDomainPath;
}

View File

@@ -131,6 +131,22 @@ public class FileDownloadUtils {
* @date 2024/1/19 10:09
*/
public static String download2DiskFromNet(String fileUrl, String storePath) {
//update-begin---author:liusq ---date:2026-03-30 for【issues/9437】修复download2DiskFromNet storePath路径遍历漏洞(CWE-22)-----------
// 路径遍历校验:拦截 ../ 等遍历字符,并确保规范化路径与原始路径一致
SsrfFileTypeFilter.checkPathTraversal(storePath);
try {
String canonicalPath = new File(storePath).getCanonicalPath();
String absolutePath = new File(storePath).getAbsolutePath();
if (!canonicalPath.equals(absolutePath)) {
throw new JeecgBootException("非法存储路径,路径包含遍历字符: " + storePath);
}
} catch (IOException e) {
throw new JeecgBootException("存储路径校验失败: " + storePath, e);
}
//update-end---author:liusq ---date:2026-03-30 for【issues/9437】修复download2DiskFromNet storePath路径遍历漏洞(CWE-22)-----------
//update-begin---author:zhangdaihao ---date:2026-04-15 for【issues/9553】下载网络资源前增加SSRF校验-----------
SsrfFileTypeFilter.checkSsrfHttpUrl(fileUrl);
//update-end---author:zhangdaihao ---date:2026-04-15 for【issues/9553】下载网络资源前增加SSRF校验-----------
try {
URL url = new URL(fileUrl);
URLConnection conn = url.openConnection();
@@ -260,6 +276,9 @@ public class FileDownloadUtils {
try {
// 处理HTTP URL通过网络下载
if (oConvertUtils.isNotEmpty(fileUrl) && fileUrl.startsWith(CommonConstant.STR_HTTP)) {
//update-begin---author:zhangdaihao ---date:2026-04-15 for【issues/9553】修复二次SSRF漏洞对HTTP下载URL进行安全校验-----------
SsrfFileTypeFilter.checkSsrfHttpUrl(fileUrl);
//update-end---author:zhangdaihao ---date:2026-04-15 for【issues/9553】修复二次SSRF漏洞对HTTP下载URL进行安全校验-----------
URL url = new URL(fileUrl);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setConnectTimeout(5000); // 连接超时5秒

View File

@@ -73,8 +73,20 @@ public class FillRuleUtil {
if (formData == null) {
formData = new JSONObject();
}
// 通过反射执行配置的类里的方法
IFillRuleHandler ruleHandler = (IFillRuleHandler) Class.forName(ruleClass).newInstance();
// 包路径白名单校验,防止任意类加载漏洞
if (!ruleClass.startsWith("org.jeecg.")) {
log.error("检测到非法填值规则类加载尝试: {}", ruleClass);
throw new SecurityException("不允许加载非 org.jeecg 包路径下的填值规则类: " + ruleClass);
}
// 通过反射执行配置的类里的方法(先加载类并校验接口,再实例化)
//update-begin---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
Class<?> clazz = Class.forName(ruleClass, true, Thread.currentThread().getContextClassLoader());
//update-end---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
if (!IFillRuleHandler.class.isAssignableFrom(clazz)) {
throw new IllegalArgumentException("" + ruleClass + " 未实现 IFillRuleHandler 接口");
}
IFillRuleHandler ruleHandler = (IFillRuleHandler) clazz.getDeclaredConstructor().newInstance();
return ruleHandler.execute(params, formData);
} catch (Exception e) {
e.printStackTrace();

View File

@@ -152,11 +152,13 @@ public class MinioUtil {
}
/**
* 获取文件外链
* @param bucketName
* @param objectName
* @param expires
* @return
* 获取私有桶文件的预签名访问URL带过期时间
* 通过MinIO预签名机制生成临时GET链接无需公开桶即可让外部访问文件
*
* @param bucketName 桶名称
* @param objectName 文件对象路径(如 "eoafile/2026/04/test.pdf"
* @param expires 链接有效期,单位:秒(注意不是天)
* @return 预签名URL失败返回null
*/
public static String getObjectUrl(String bucketName, String objectName, Integer expires) {
initMinio(minioUrl, minioName,minioPass);
@@ -195,10 +197,13 @@ public class MinioUtil {
}
/**
* 上传文件到minio
* @param stream
* @param relativePath
* @return
* 通过输入流上传文件到MinIO默认桶
* 若桶不存在会自动创建,上传成功后关闭输入流
*
* @param stream 文件输入流
* @param relativePath 文件在桶中的相对路径(如 "upload/2026/04/test.pdf"
* @return 文件完整访问URL格式minioUrl + bucketName + "/" + relativePath
* @throws Exception 桶操作或上传过程中的异常
*/
public static String upload(InputStream stream,String relativePath) throws Exception {
initMinio(minioUrl, minioName,minioPass);

View File

@@ -9,7 +9,9 @@ public class MyClassLoader extends ClassLoader {
public static Class getClassByScn(String className) {
Class myclass = null;
try {
myclass = Class.forName(className);
//update-begin---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
myclass = Class.forName(className, true, Thread.currentThread().getContextClassLoader());
//update-end---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
} catch (ClassNotFoundException e) {
e.printStackTrace();
throw new RuntimeException(className+" not found!");

View File

@@ -8,6 +8,8 @@ import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
import java.util.Map;
@@ -235,7 +237,7 @@ public class RestUtil {
}
// 发送请求
HttpEntity<String> request = new HttpEntity<>(body, headers);
return RT.exchange(url, method, request, responseType);
return RT.exchange(URI.create(url), method, request, responseType);
}
/**
@@ -308,7 +310,7 @@ public class RestUtil {
// 发送请求
HttpEntity<String> request = new HttpEntity<>(body, headers);
return restTemplate.exchange(url, method, request, responseType);
return restTemplate.exchange(URI.create(url), method, request, responseType);
}
/**
@@ -341,7 +343,10 @@ public class RestUtil {
Object object = source.get(key);
if (object != null) {
if (!StringUtils.isEmpty(object.toString())) {
value = object.toString();
//update-begin---author:sjlei---date:20260414 for【jeecg-ai#17】修复工具节点参数值含{}时URI模板展开报错-----------
// URL 编码参数值,防止值中含 {}、空格等特殊字符导致 URI 解析异常
value = URLEncoder.encode(object.toString(), StandardCharsets.UTF_8);
//update-end-----author:sjlei---date:20260414 for【jeecg-ai#17】修复工具节点参数值含{}时URI模板展开报错-----------
}
}
urlVariables.append("&").append(key).append("=").append(value);

View File

@@ -33,8 +33,10 @@ public class SqlInjectionUtil {
private static String specialReportXssStr = "exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|gtid_subset|gtid_subtract|insert |alter |delete |grant |update |drop |master |truncate |declare |--";
/**
* 字典专用—sql注入关键词
*
* @updateBy: sunjianlei 20260331 加上 substring 注入检测
*/
private static String specialDictSqlXssStr = "exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|gtid_subset|gtid_subtract|insert |select |delete |update |drop |count |chr |mid |master |truncate |char |declare |;|+|--";
private static String specialDictSqlXssStr = "exec |peformance_schema|information_schema|extractvalue|updatexml|geohash|gtid_subset|gtid_subtract|insert |select |delete |update |drop |count |chr |mid |master |truncate |char |declare |;|+|--|substring |substring(";
/**
* 完整匹配的key不需要考虑前空格
*/
@@ -62,6 +64,27 @@ public class SqlInjectionUtil {
"show\\s+databases",
"sleep\\(\\d*\\)",
"sleep\\(.*\\)",
// update-begin---author:sjlei---date:20260413 for【#9523】修复 SQL 注入漏洞
// 时间盲注函数(#9523MySQL BENCHMARK、PostgreSQL pg_sleep、SQL Server WAITFOR DELAY
"benchmark\\s*\\(",
"pg_sleep\\s*\\(",
"waitfor\\s+delay",
// update-end-----author:sjlei---date:20260413 for【#9523】修复 SQL 注入漏洞
// update-begin---author:zhangdaihao---date:20260427 for【issue/9571】修复字典/Online报表 boolean-blind 信息泄露
// 通过 case-when + database()/version() 等函数 + LIKE 前缀枚举进行字符级数据提取(绕过 select/union 黑名单),
"database\\s*\\(",
"version\\s*\\(",
"current_user\\s*\\(",
"current_database\\s*\\(",
"current_schema\\s*\\(",
"session_user\\s*\\(",
"system_user\\s*\\(",
"ascii\\s*\\(",
"unhex\\s*\\(",
"load_file\\s*\\(",
"into\\s+outfile",
"into\\s+dumpfile",
// update-end-----author:zhangdaihao---date:20260427 for【issue/9571】修复字典/Online报表 boolean-blind 信息泄露
};
/**
* sql注释的正则
@@ -146,7 +169,20 @@ public class SqlInjectionUtil {
private static boolean isExistSqlInjectKeyword(String sql, String keyword) {
if (sql.startsWith(keyword.trim())) {
return true;
} else if (sql.contains(keyword)) {
}
// update-begin---author:zhangdaihao---date:20260427 for【issue/9572】修复 SQL 黑名单 keyword( 紧贴形式绕过
// 原来对带 trailing space 的关键字(如 "select ")只能匹配 "select " 形式,
// 导致 id=(select(id)from(sys_user)where(...)) 的 select( 形式绕过检测。
// 这里补充:对带 trailing space 的关键字,额外检测 trimmedKeyword + "(" 形式。
// FULL_MATCHING_KEYWRODS;、+、--)保持原匹配逻辑不变。
if (keyword.endsWith(" ") && !FULL_MATCHING_KEYWRODS.contains(keyword)) {
String trimmedKeyword = keyword.trim();
if (sql.contains(trimmedKeyword + "(")) {
return true;
}
}
// update-end-----author:zhangdaihao---date:20260427 for【issue/9572】修复 SQL 黑名单 keyword( 紧贴形式绕过
if (sql.contains(keyword)) {
// 需要匹配的sql注入关键词
String matchingText = " " + keyword;
if(FULL_MATCHING_KEYWRODS.contains(keyword)){
@@ -156,6 +192,18 @@ public class SqlInjectionUtil {
if (sql.contains(matchingText)) {
return true;
} else {
// update-begin---author:sjlei---date:20260413 for【#9524】修复 SQL 注入漏洞
// 检测关键词前紧跟非字母分隔符的情况,原来只检测前置空格,
// 导致 (updatexml(、(extractvalue( 等写法绕过检测(#9524
String[] sqlTokenPrefixes = {"(", ",", "=", "!", "<", ">"};
for (String prefix : sqlTokenPrefixes) {
if (sql.contains(prefix + keyword)) {
return true;
}
}
// update-end-----author:sjlei---date:20260413 for【#9524】修复 SQL 注入漏洞
// 检测编码空格绕过(%09 %0A %0D 等可替代空格的字符)
String regularStr = "\\s+\\S+" + keyword;
List<String> resultFindAll = ReUtil.findAll(regularStr, sql, 0, new ArrayList<String>());
for (String res : resultFindAll) {

View File

@@ -8,6 +8,7 @@ import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.system.vo.DynamicDataSourceModel;
import org.jeecg.common.util.ReflectHelper;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.common.util.security.JdbcSecurityUtil;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
@@ -42,7 +43,10 @@ public class DynamicDBUtil {
if (oConvertUtils.isEmpty(url) || !url.toLowerCase().startsWith("jdbc:")) {
throw new JeecgBootException("数据源URL配置格式不正确");
}
// 纵深防御: 连接建立时二次校验 URL 和驱动安全性
JdbcSecurityUtil.validate(url);
JdbcSecurityUtil.validateDriver(driverClassName);
String dbUser = dbSource.getDbUsername();
String dbPassword = dbSource.getDbPassword();
dataSource.setDriverClassName(driverClassName);

View File

@@ -7,6 +7,10 @@ import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
@@ -305,6 +309,50 @@ public class SsrfFileTypeFilter {
}
}
//update-begin---author:zhangdaihao ---date:2026-04-15 for【issues/9553】修复二次SSRF漏洞对HTTP下载URL进行安全校验-----------
/**
* 校验HTTP(S) URL防止SSRF攻击最小化拦截只挡真正危险的目标
* 规则:
* 1. 仅允许 http / https 协议;
* 2. 解析主机IP拒绝 loopback127.x / ::1和 link-local169.254.x含云元数据 169.254.169.254 / fe80:
* 注意RFC1918 私网段10/172.16/192.168)允许通过,兼容企业内网 MinIO/OSS/文件服务等合法用途。
*
* @param fileUrl HTTP(S) URL
*/
public static void checkSsrfHttpUrl(String fileUrl) {
if (StringUtils.isBlank(fileUrl)) {
throw new JeecgBootException("非法URL地址为空");
}
URI uri;
try {
uri = new URI(fileUrl);
} catch (URISyntaxException e) {
throw new JeecgBootException("非法URL格式错误");
}
String scheme = uri.getScheme();
if (scheme == null || !(scheme.equalsIgnoreCase("http") || scheme.equalsIgnoreCase("https"))) {
throw new JeecgBootException("非法URL仅允许 http / https 协议");
}
String host = uri.getHost();
if (StringUtils.isBlank(host)) {
throw new JeecgBootException("非法URL主机名为空");
}
// 去掉 IPv6 的中括号
if (host.startsWith("[") && host.endsWith("]")) {
host = host.substring(1, host.length() - 1);
}
try {
for (InetAddress addr : InetAddress.getAllByName(host)) {
if (addr.isLoopbackAddress() || addr.isLinkLocalAddress()) {
throw new JeecgBootException("非法URL禁止访问本机或链路本地地址 " + addr.getHostAddress());
}
}
} catch (UnknownHostException e) {
throw new JeecgBootException("非法URL主机名无法解析");
}
}
//update-end---author:zhangdaihao ---date:2026-04-15 for【issues/9553】修复二次SSRF漏洞对HTTP下载URL进行安全校验-----------
/**
* 批量校验文件路径安全性(逗号分隔的多个文件路径)
* @param files 逗号分隔的文件路径

View File

@@ -249,10 +249,12 @@ public class OssBootUtil {
}
/**
* 获取文件流
* @param objectName
* @param bucket
* @return
* 获取指定桶(私有桶)中的文件流
* 通过OSS SDK直接读取文件内容支持指定自定义桶名如 "eoafile"),为空则使用默认桶
*
* @param objectName 文件对象路径(如 "eoafile/2026/04/test.pdf",会自动替换前缀)
* @param bucket 自定义桶名称,为空则使用默认桶
* @return 文件输入流失败返回null
*/
public static InputStream getOssFile(String objectName,String bucket){
InputStream inputStream = null;
@@ -282,11 +284,13 @@ public class OssBootUtil {
//}
/**
* 获取文件外链
* @param bucketName
* @param objectName
* @param expires
* @return
* 获取私有桶文件的预签名访问URL带过期时间
* 通过OSS预签名机制生成临时访问链接无需公开桶即可让外部下载/预览文件
*
* @param bucketName 桶名称(如 "eoafile"
* @param objectName 文件对象路径(会自动替换前缀)
* @param expires 链接过期时间点Date类型如1天后过期
* @return 预签名URL字符串文件不存在或失败返回null
*/
public static String getObjectUrl(String bucketName, String objectName, Date expires) {
initOss(endPoint, accessKeyId, accessKeySecret);
@@ -322,10 +326,12 @@ public class OssBootUtil {
/**
* 上传文件到oss
* @param stream
* @param relativePath
* @return
* 通过输入流上传文件到阿里云OSS默认桶
* 上传后设置桶为公开读权限返回文件完整访问URL
*
* @param stream 文件输入流
* @param relativePath 文件在桶中的相对路径(如 "upload/2026/04/test.pdf"
* @return 文件完整访问URL优先使用staticDomain否则拼接 bucketName.endPoint
*/
public static String upload(InputStream stream, String relativePath) {
String filePath = null;

View File

@@ -4,43 +4,132 @@ import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.util.oConvertUtils;
/**
* jdbc连接校验
* JDBC连接安全校验工具类
*
* 修复说明:
* 原实现仅检查 URL 中 '?' 之后的参数,且黑名单仅包含 5 个 PostgreSQL 参数。
* 存在以下安全隐患:
* 1. MySQL 危险参数 (allowLoadLocalInfile, autoDeserialize 等) 未覆盖
* 2. H2 使用 ';' 分隔参数,完全绕过 '?' 检查
* 3. MySQL multi-host 语法 '(host,param=val)' 和 address-block 语法不使用 '?'
*
* 修复方案: 对 URL 全文做 toLowerCase() + contains() 匹配,
* 覆盖所有参数分隔符格式 (?, ;, (), address=),并扩展黑名单覆盖全部主流驱动。
*
* @Author taoYan
* @Date 2022/8/10 18:15
**/
*/
public class JdbcSecurityUtil {
/**
* 连接驱动漏洞 最新版本修复后可删除相应的key
* postgreauthenticationPluginClassName, sslhostnameverifier, socketFactory, sslfactory, sslpasswordcallback
* https://github.com/pgjdbc/pgjdbc/security/advisories/GHSA-v7wg-cpwc-24m4
*
* 全驱动危险参数黑名单 (全小写,用于 contains 匹配)
*
* 使用 URL 全文 contains 匹配策略,覆盖所有参数分隔符:
* - 标准格式: ?key=value&key=value
* - H2 格式: ;KEY=value
* - MySQL multi-host: (host,key=value)
* - MySQL address-block: address=(key=value)
*/
public static final String[] notAllowedProps = new String[]{"authenticationPluginClassName", "sslhostnameverifier", "socketFactory", "sslfactory", "sslpasswordcallback"};
private static final String[] UNSAFE_PARAMS = {
// === MySQL / MariaDB ===
// 文件读取相关
"allowloadlocalinfile", // LOAD DATA LOCAL INFILE
"allowurlinlocalinfile", // 通过 URL 读取远程文件
"allowloadlocalinfileinpath", // 指定路径文件读取
// 反序列化相关
"autodeserialize", // 启用反序列化
"queryinterceptors", // 查询拦截器 (反序列化触发点)
"statementinterceptors", // 语句拦截器 (反序列化触发点)
"detectcustomcollations", // 自定义排序规则检测 (反序列化触发点)
// 配合攻击
"maxallowedpacket", // 突破数据包大小限制
// === PostgreSQL ===
// https://github.com/pgjdbc/pgjdbc/security/advisories/GHSA-v7wg-cpwc-24m4
"socketfactory", // 任意类实例化 RCE
"socketfactoryarg", // socketFactory 构造参数
"sslfactory", // SSL 工厂类加载
"sslhostnameverifier", // SSL 主机名验证器类加载
"sslpasswordcallback", // SSL 密码回调类加载
"authenticationpluginclassname", // 认证插件类加载
"jaasapplicationname", // JAAS 认证攻击
// === H2 ===
"init=", // 连接初始化 SQL (带 '=' 防止匹配到正常单词 'init')
"runscript", // 远程/本地 SQL 脚本加载
"trace_level_system_out", // 系统信息泄露
};
/**
* 校验sql是否有特定的key
* @param jdbcUrl
* @return
* 允许的 JDBC 驱动类名白名单
*/
public static void validate(String jdbcUrl){
if(oConvertUtils.isEmpty(jdbcUrl)){
private static final String[] ALLOWED_DRIVERS = {
// MySQL 数据库
"com.mysql.jdbc.Driver",
// MySQL5.7+ 数据库
"com.mysql.cj.jdbc.Driver",
// Oracle
"oracle.jdbc.OracleDriver",
"oracle.jdbc.driver.OracleDriver",
// SQLServer 数据库
"com.microsoft.sqlserver.jdbc.SQLServerDriver",
// marialDB 数据库
"org.mariadb.jdbc.Driver",
// postgresql 数据库
"org.postgresql.Driver",
// 达梦 数据库
"dm.jdbc.driver.DmDriver",
// 人大金仓 数据库
"com.kingbase8.Driver",
// 神通 数据库
"com.oscar.Driver",
// SQLite 数据库
"org.sqlite.JDBC",
// DB2 数据库
"com.ibm.db2.jcc.DB2Driver",
// Hsqldb 数据库
"org.hsqldb.jdbc.JDBCDriver",
// Derby 数据库
"org.apache.derby.jdbc.ClientDriver",
// H2 数据库
"org.h2.Driver",
};
/**
* 校验 JDBC URL 是否包含危险参数
*
* @param jdbcUrl JDBC 连接地址
* @throws JeecgBootException 包含危险参数时抛出
*/
public static void validate(String jdbcUrl) {
if (oConvertUtils.isEmpty(jdbcUrl)) {
return;
}
String urlConcatChar = "?";
if(jdbcUrl.indexOf(urlConcatChar)<0){
return;
}
String argString = jdbcUrl.substring(jdbcUrl.indexOf(urlConcatChar)+1);
String[] keyAndValues = argString.split("&");
for(String temp: keyAndValues){
String key = temp.split("=")[0];
for(String prop: notAllowedProps){
if(prop.equalsIgnoreCase(key)){
throw new JeecgBootException("连接地址有安全风险,【"+key+"");
}
String lowerUrl = jdbcUrl.toLowerCase();
for (String unsafeParam : UNSAFE_PARAMS) {
if (lowerUrl.contains(unsafeParam)) {
throw new JeecgBootException("连接地址有安全风险,包含不安全参数【" + unsafeParam + "");
}
}
}
}
/**
* 校验驱动类名是否在白名单中
*
* @param driverClassName JDBC 驱动类名
* @throws JeecgBootException 驱动不在白名单时抛出
*/
public static void validateDriver(String driverClassName) {
if (oConvertUtils.isEmpty(driverClassName)) {
throw new JeecgBootException("数据库驱动类名不能为空");
}
for (String allowed : ALLOWED_DRIVERS) {
if (allowed.equals(driverClassName)) {
return;
}
}
throw new JeecgBootException("不支持的数据库驱动【" + driverClassName + "】,如需支持请联系管理员");
}
}

View File

@@ -0,0 +1,100 @@
package org.jeecg.config;
import lombok.Data;
import org.jeecg.ai.factory.AiModelFactory;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component("jeecgAiChatConfig")
@ConfigurationProperties(prefix = "jeecg.ai-chat")
public class AiChatConfig {
/**
* skills配置文件路径
*/
private String skillsDir;
/**
* shell命令行配置文件路径
*/
private String skillsShellDir;
/**
* AI绘图(文生图)
*/
private ModelConfig aiModelDraw = new ModelConfig();
/**
* AI图生(图绘画)
*/
private ModelConfig aiModelPicDraw = new ModelConfig();
/**
* AI语音
*/
private VoiceModelConfig aiModelVoice = new VoiceModelConfig();
/**
* AI视频
*/
private VideoModelConfig aiModelVideo = new VideoModelConfig();
/**
* AI默认向量模型
*/
private ModelConfig aiModelEmbed = new ModelConfig();
@Data
public static class ModelConfig {
/**
* 使用的模型
*/
private String model;
/**
* api秘钥
*/
private String apiKey;
/**
* api域名
*/
private String apiHost;
/**
* 超时时间
*/
private int timeout = 60;
/**
* 供应商
*/
private String provider = AiModelFactory.AIMODEL_TYPE_QWEN;
}
@Data
public static class VideoModelConfig extends ModelConfig {
/**
* ffmpeg 可执行文件路径,为空时自动查找
*/
private String ffmpegPath;
/**
* edge-tts 可执行文件路径,为空时自动查找
*/
private String edgeTtsPath;
}
@Data
public static class VoiceModelConfig extends ModelConfig {
/**
* 默认声色
*/
private String voice = "alloy";
/**
* 默认倍速范围0.25~4.0
*/
private double speed = 1.0;
/**
* 默认音量增益(dB)
*/
private double volume = 0.0;
}
}

View File

@@ -21,4 +21,29 @@ public class AiRagConfigBean {
* stdio mpc命令行功能开启sqlAI流程SQL节点开启
*/
private String allowSensitiveNodes = "";
//update-begin---author:wangshuai ---date:2026-04-15 forBrave Search配置迁移到AiRagConfigBean去掉enabled字段apiKey为空即不启用-----------
/**
* Brave Search 联网检索配置
*/
private BraveSearchConfig braveSearch = new BraveSearchConfig();
@Data
public static class BraveSearchConfig {
/** Brave Search API Key为空时联网检索不生效 */
private String apiKey;
/** API 端点,默认官方地址 */
private String endpoint = "https://api.search.brave.com/res/v1/web/search";
/** 默认返回结果条数,最大 20 */
private Integer count = 10;
/** 请求超时秒数 */
private Integer timeout = 15;
/**
* 搜索结果缓存时长(分钟)。
* 大于 0 时开启缓存,相同参数的查询直接返回缓存结果,不重复调用 API。
* 设为 0 或不配置则关闭缓存。
*/
private Integer cacheExpireMinutes = 60;
}
//update-end---author:wangshuai ---date:2026-04-15 forBrave Search配置迁移到AiRagConfigBean去掉enabled字段apiKey为空即不启用-----------
}

View File

@@ -6,7 +6,7 @@
<parent>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-module</artifactId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<artifactId>jeecg-boot-module-airag</artifactId>
@@ -41,14 +41,14 @@
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-bom</artifactId>
<version>1.9.1</version>
<version>1.12.2</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-bom</artifactId>
<version>1.9.1-beta17</version>
<version>1.12.1-beta21</version>
<type>pom</type>
<scope>import</scope>
</dependency>
@@ -75,7 +75,7 @@
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-aiflow</artifactId>
<version>3.9.1-beta1</version>
<version>3.9.2-beta</version>
<exclusions>
<exclusion>
<groupId>commons-io</groupId>
@@ -103,7 +103,7 @@
<groupId>com.yomahub</groupId>
<artifactId>liteflow-script-graaljs</artifactId>
<version>${liteflow.version}</version>
<scope>compile</scope>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.yomahub</groupId>
@@ -160,6 +160,10 @@
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-ollama</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-google-ai-gemini</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-community-zhipu-ai</artifactId>
@@ -233,12 +237,29 @@
<artifactId>tika-parser-text-module</artifactId>
<version>${apache-tika.version}</version>
</dependency>
<!--skills-->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-skills</artifactId>
</dependency>
<!--命令模式-->
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-experimental-skills-shell</artifactId>
<version>1.12.2-beta22</version>
</dependency>
<!-- word模版引擎 -->
<dependency>
<groupId>com.deepoove</groupId>
<artifactId>poi-tl</artifactId>
<version>1.12.2</version>
</dependency>
<!-- jsoup HTML parser library @ https://jsoup.org/ -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.22.1</version>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,107 @@
package org.jeecg.modules.airag.api;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.airag.api.IAiragBaseApi;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.exception.JeecgBootBizTipException;
import org.jeecg.common.util.AssertUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.airag.app.entity.AiragApp;
import org.jeecg.modules.airag.app.service.IAiragAppService;
import org.jeecg.modules.airag.app.service.IAiragVariableService;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeDocService;
import org.jeecg.modules.airag.prompts.entity.AiragPrompts;
import org.jeecg.modules.airag.prompts.service.IAiragPromptsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
/**
* airag baseAPI 实现类
*/
@Slf4j
@Primary
@Service("airagBaseApiImpl")
public class AiragBaseApiImpl implements IAiragBaseApi {
@Autowired
private IAiragKnowledgeDocService airagKnowledgeDocService;
@Override
public String knowledgeWriteTextDocument(String knowledgeId, String title, String content, String segmentConfig) {
AssertUtils.assertNotEmpty("知识库ID不能为空", knowledgeId);
AssertUtils.assertNotEmpty("写入内容不能为空", content);
AiragKnowledgeDoc knowledgeDoc = new AiragKnowledgeDoc();
knowledgeDoc.setKnowledgeId(knowledgeId);
knowledgeDoc.setTitle(title);
knowledgeDoc.setType(LLMConsts.KNOWLEDGE_DOC_TYPE_TEXT);
knowledgeDoc.setContent(content);
// 将分段策略配置写入文档的metadata中EmbeddingHandler会从中读取分段配置
if (oConvertUtils.isNotEmpty(segmentConfig)) {
knowledgeDoc.setMetadata(segmentConfig);
}
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());
return knowledgeDoc.getId();
}
@Autowired
private IAiragAppService airagAppService;
@Autowired
private IAiragVariableService airagVariableService;
@Autowired
private IAiragPromptsService airagPromptsService;
@Override
public String getChatVariable(String appId, String username, String name) {
return airagVariableService.getVariable(username, appId, name);
}
@Override
public void setChatVariable(String appId, String username, String name, String value) {
AssertUtils.assertNotEmpty("应用ID不能为空", appId);
AssertUtils.assertNotEmpty("用户名不能为空", username);
AssertUtils.assertNotEmpty("变量名不能为空", name);
airagVariableService.updateVariable(username, appId, name, value != null ? value : "");
}
@Override
public String getMemoryIdByAppId(String appId) {
if (oConvertUtils.isEmpty(appId)) {
return null;
}
LambdaQueryWrapper<AiragApp> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(AiragApp::getId, appId)
.eq(AiragApp::getIzOpenMemory, 1)
.isNotNull(AiragApp::getMemoryId)
.ne(AiragApp::getMemoryId, "")
.select(AiragApp::getMemoryId);
AiragApp app = airagAppService.getOne(queryWrapper);
return app != null ? app.getMemoryId() : null;
}
@Override
public String getPromptContent(String promptId) {
if (oConvertUtils.isEmpty(promptId)) {
return null;
}
AiragPrompts prompt = airagPromptsService.getById(promptId);
if (prompt == null) {
log.warn("[AiragBaseApi]提示词不存在promptId={}", promptId);
return null;
}
return prompt.getContent();
}
}

View File

@@ -64,4 +64,27 @@ public class AiAppConsts {
* AI写作redis请求前缀
*/
public static final String ARTICLE_WRITER_KEY = "airag:chat:article:write:{}";
/**
* ai绘画类型: 绘图
*/
public static final String AI_DRAW_TYPE_DRAW = "draw";
/**
* ai绘画类型: 换脸
*/
public static final String AI_DRAW_TYPE_FACE = "face";
/**
* ai绘画类型: 混图
*/
public static final String AI_DRAW_TYPE_MIX = "mix";
/**
* ai绘画 会话redis请求前缀
*/
public static final String POSTER_TASK_PREFIX = "airag:poster:task:";
/** 任务结果在 Redis 中保留 1 小时 */
public static final long POSTER_TASK_TTL = 3600L;
}

View File

@@ -173,4 +173,9 @@ public class Prompts {
*/
public static final String AI_TOUCHE_PROMPT = "请针对如下内容:[{}] 进行润色。 回复格式:{},语气:{},语言:{},长度:{}。";
/**
* ai绘画提示词
*/
public static final String AI_DRAW_PROMPT = "风格:{},视角:{},人物镜头:{},灯光:{},图片尺寸:{};";
}

View File

@@ -23,8 +23,10 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.List;
import java.util.stream.Collectors;
import jakarta.servlet.http.HttpServletRequest;
import org.jeecg.common.system.vo.DictModel;
/**
* @Description: AI应用
@@ -62,6 +64,25 @@ public class AiragAppController extends JeecgController<AiragApp, IAiragAppServi
return Result.OK(pageList);
}
/**
* 字典列表查询(不分页,按创建时间倒序)
*
* @param airagApp 支持通过实体字段动态过滤,如 type 等
* @param req HTTP请求
* @return 应用字典列表
*/
@GetMapping(value = "/listDict")
public Result<List<DictModel>> listDict(AiragApp airagApp, HttpServletRequest req) {
QueryWrapper<AiragApp> queryWrapper = QueryGenerator.initQueryWrapper(airagApp, req.getParameterMap());
queryWrapper.select("id", "name");
queryWrapper.orderByDesc("create_time");
List<AiragApp> list = airagAppService.list(queryWrapper);
List<DictModel> dictList = list.stream()
.map(app -> new DictModel(app.getId(), app.getName()))
.collect(Collectors.toList());
return Result.OK(dictList);
}
/**
* 新增或编辑
*
@@ -70,10 +91,24 @@ public class AiragAppController extends JeecgController<AiragApp, IAiragAppServi
*/
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
@RequiresPermissions("airag:app:edit")
public Result<String> edit(@RequestBody AiragApp airagApp) {
public Result<String> edit(@RequestBody AiragApp airagApp, HttpServletRequest request) {
AssertUtils.assertNotEmpty("参数异常", airagApp);
AssertUtils.assertNotEmpty("请输入应用名称", airagApp.getName());
AssertUtils.assertNotEmpty("请选择应用类型", airagApp.getType());
//update-begin---author:zhangdaihao ---date:20260415 for[issues/9462]AI应用edit接口跨租户数据写入漏洞------------
//SaaS多租户隔离禁止跨租户写入防止通过请求体伪造tenantId污染其他租户数据
if (MybatisPlusSaasConfig.OPEN_SYSTEM_TENANT_CONTROL) {
String currentTenantId = TokenUtils.getTenantIdByRequest(request);
if (airagApp.getId() != null && !airagApp.getId().isEmpty()) {
AiragApp dbApp = airagAppService.getById(airagApp.getId());
if (dbApp == null || !dbApp.getTenantId().equals(currentTenantId)) {
return Result.error("保存AI应用失败不能修改其他租户的AI应用");
}
}
//强制使用当前登录租户,忽略客户端传入值
airagApp.setTenantId(currentTenantId);
}
//update-end---author:zhangdaihao ---date:20260415 for[issues/9462]AI应用edit接口跨租户数据写入漏洞------------
airagApp.setStatus(AiAppConsts.STATUS_ENABLE);
airagAppService.saveOrUpdate(airagApp);
return Result.OK("保存完成!", airagApp.getId());

View File

@@ -8,6 +8,7 @@ import org.jeecg.common.constant.CommonConstant;
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.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;
@@ -266,11 +267,31 @@ public class AiragChatController {
* @return
*/
@PostMapping("/genAiPoster")
public Result<String> genAiPoster(@RequestBody ChatSendParams chatSendParams){
String imageUrl = chatService.genAiPoster(chatSendParams);
public Result<String> genAiPoster(@RequestBody AiDrawGenerateVo aiDrawGenerateVo){
String imageUrl = chatService.genAiPoster(aiDrawGenerateVo);
return Result.OK(imageUrl);
}
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 异步提交AI海报生成任务立即返回taskId
*/
@PostMapping("/genAiPosterAsync")
public Result<String> genAiPosterAsync(@RequestBody AiDrawGenerateVo aiDrawGenerateVo) {
String taskId = chatService.genAiPosterAsync(aiDrawGenerateVo);
return Result.OK(taskId);
}
/**
* 查询AI海报异步任务结果
* status: pending / success / failed
*/
@GetMapping("/getAiPosterResult/{taskId}")
public Result<?> getAiPosterResult(@PathVariable String taskId) {
return chatService.getAiPosterResult(taskId);
}
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 生成ai写作

View File

@@ -0,0 +1,36 @@
package org.jeecg.modules.airag.app.enums;
/**
* @Description: 图像编辑枚举
*
* @author: wangshuai
* @date: 2026/2/28 16:52
*/
public enum ImageEditEnum {
WANX2_1_IMAGEEDIT("wanx2.1-imageedit"),
WAN2_5_I2I_PREVIEW("wan2.5-i2i-preview");
private final String modelName;
ImageEditEnum(String modelName) {
this.modelName = modelName;
}
public String getModelName() {
return modelName;
}
/**
* 检查模型名称是否是图像编辑模型
* @param modelName 模型名称
* @return 是否是图像编辑模型
*/
public static boolean isImageEditModel(String modelName) {
for (ImageEditEnum model : values()) {
if (model.getModelName().equals(modelName)) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,57 @@
package org.jeecg.modules.airag.app.enums;
import org.apache.commons.lang3.StringUtils;
/**
* @Description: 图片大小比例枚举
*
* @author: wangshuai
* @date: 2026/2/4 19:55
*/
public enum ImageSizeEnum {
SIZE_1024_1024("1024*1024", "1:1"),
SIZE_1280_720("1280*720", "16:9"),
SIZE_720_1280("720*1280", "9:16"),
SIZE_1024_768("1024*768", "4:3"),
SIZE_768_1024("768*1024", "3:4");
ImageSizeEnum(String size, String ratio) {
this.size = size;
this.ratio = ratio;
}
/**
* 大小
*/
private String size;
/**
* 比例
*/
private String ratio;
public String getSize() {
return size;
}
public String getRatio() {
return ratio;
}
/**
* 根据size获取ratio
*
* @param size
* @return
*/
public static String getRatioBySize(String size) {
if (StringUtils.isBlank(size)) {
return "1:1";
}
for (ImageSizeEnum e : ImageSizeEnum.values()) {
if (e.size.equals(size)) {
return e.ratio;
}
}
return "1:1";
}
}

View File

@@ -1,10 +1,7 @@
package org.jeecg.modules.airag.app.service;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.airag.app.vo.AiWriteGenerateVo;
import org.jeecg.modules.airag.app.vo.AppDebugParams;
import org.jeecg.modules.airag.app.vo.ChatConversation;
import org.jeecg.modules.airag.app.vo.ChatSendParams;
import org.jeecg.modules.airag.app.vo.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
/**
@@ -126,10 +123,26 @@ public interface IAiragChatService {
/**
* 生成海报图片
* @param chatSendParams
* @param aiDrawGenerateVo
* @return
*/
String genAiPoster(ChatSendParams chatSendParams);
String genAiPoster(AiDrawGenerateVo aiDrawGenerateVo);
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 异步生成海报图片立即返回taskId
* @param aiDrawGenerateVo
* @return taskId
*/
String genAiPosterAsync(AiDrawGenerateVo aiDrawGenerateVo);
/**
* 查询异步海报任务结果
* @param taskId
* @return Resultdata为图片URL成功或status=pending/failed
*/
Result<?> getAiPosterResult(String taskId);
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 生成ai创作

View File

@@ -33,6 +33,16 @@ public interface IAiragVariableService {
*/
void initVariable(String userId, String appId, String name, String defaultValue);
/**
* 获取变量值
*
* @param username 用户名
* @param appId 应用ID
* @param name 变量名
* @return 变量值不存在返回null
*/
String getVariable(String username, String appId, String name);
/**
* 添加变量更新工具
*

View File

@@ -1,6 +1,7 @@
package org.jeecg.modules.airag.app.service.impl;
import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import dev.langchain4j.agent.tool.ToolExecutionRequest;
@@ -19,21 +20,23 @@ import org.jeecg.common.api.vo.Result;
import org.jeecg.common.constant.SymbolConstant;
import org.jeecg.common.exception.JeecgBootBizTipException;
import org.jeecg.common.exception.JeecgBootException;
import java.nio.file.Paths;
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.config.AiChatConfig;
import org.jeecg.config.AiRagConfigBean;
import org.jeecg.config.JeecgBaseConfig;
import org.jeecg.config.vo.Path;
import org.jeecg.modules.airag.app.consts.AiAppConsts;
import org.jeecg.modules.airag.app.consts.Prompts;
import org.jeecg.modules.airag.app.entity.AiragApp;
import org.jeecg.modules.airag.app.enums.ImageSizeEnum;
import org.jeecg.modules.airag.app.mapper.AiragAppMapper;
import org.jeecg.modules.airag.app.service.IAiragChatService;
import org.jeecg.modules.airag.app.service.IAiragVariableService;
import org.jeecg.modules.airag.app.vo.AiWriteGenerateVo;
import org.jeecg.modules.airag.app.vo.AppDebugParams;
import org.jeecg.modules.airag.app.vo.ChatConversation;
import org.jeecg.modules.airag.app.vo.ChatSendParams;
import org.jeecg.modules.airag.app.vo.*;
import org.jeecg.modules.airag.common.consts.AiragConsts;
import org.jeecg.modules.airag.common.handler.AIChatParams;
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
@@ -43,13 +46,17 @@ import org.jeecg.modules.airag.common.vo.MessageHistory;
import org.jeecg.modules.airag.common.vo.event.EventData;
import org.jeecg.modules.airag.common.vo.event.EventFlowData;
import org.jeecg.modules.airag.common.vo.event.EventMessageData;
import org.jeecg.modules.airag.flow.context.JeecgFlowContext;
import org.jeecg.modules.airag.flow.consts.FlowConsts;
import org.jeecg.modules.airag.flow.entity.AiragFlow;
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.LLMConsts;
import org.jeecg.modules.airag.llm.document.TikaDocumentParser;
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;
@@ -60,7 +67,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundValueOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import com.baomidou.mybatisplus.core.toolkit.CollectionUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.ByteArrayOutputStream;
@@ -70,6 +77,7 @@ import java.io.InputStream;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiConsumer;
import java.util.stream.Collectors;
@@ -117,6 +125,12 @@ public class AiragChatServiceImpl implements IAiragChatService {
@Autowired
JeecgBaseConfig jeecgBaseConfig;
@Autowired
AiChatConfig aiChatConfig;
@Autowired
AiRagConfigBean aiRagConfigBean;
/**
* 重新接收消息
@@ -193,6 +207,13 @@ public class AiragChatServiceImpl implements IAiragChatService {
@Override
public Result<?> stop(String requestId) {
AssertUtils.assertNotEmpty("requestId不能为空", requestId);
// 设置流程上下文的停止标志通知正在执行的LLM节点停止输出
JeecgFlowContext flowContext = AiragLocalCache.get(AiragConsts.CACHE_TYPE_FLOW_CONTEXT, requestId);
if (flowContext != null) {
flowContext.setStopped(true);
AiragLocalCache.remove(AiragConsts.CACHE_TYPE_FLOW_CONTEXT, requestId);
log.info("[AI-CHAT]已设置流程停止标志, requestId:{}", requestId);
}
// 从缓存中获取对应的SseEmitter
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
if (emitter != null) {
@@ -307,26 +328,22 @@ public class AiragChatServiceImpl implements IAiragChatService {
//update-begin---author:chenrui ---date:20251106 for[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
// 返回消息列表和会话设置信息
Map<String, Object> result = new HashMap<>();
// 过滤掉工具调用相关的消息(前端不需要展示
// 解析是否显示工具调用过程默认为true
boolean showToolProcess = true;
AiragApp chatApp = chatConversation.getApp();
if (chatApp != null && oConvertUtils.isNotEmpty(chatApp.getMetadata())) {
try {
JSONObject appMetadataJson = JSONObject.parseObject(chatApp.getMetadata());
if (appMetadataJson != null && "0".equals(appMetadataJson.getString("showToolProcess"))) {
showToolProcess = false;
}
} catch (Exception ignored) {
}
}
// 合并工具调用相关的消息
List<MessageHistory> messages = chatConversation.getMessages();
if (oConvertUtils.isObjectNotEmpty(messages)) {
messages = messages.stream()
.filter(msg -> !AiragConsts.MESSAGE_ROLE_TOOL.equals(msg.getRole()))
.map(msg -> {
// 克隆消息对象,移除工具执行请求信息(前端不需要)
MessageHistory displayMsg = MessageHistory.builder()
.conversationId(msg.getConversationId())
.topicId(msg.getTopicId())
.role(msg.getRole())
.content(msg.getContent())
.images(msg.getImages())
.files(msg.getFiles())
.datetime(msg.getDatetime())
.build();
// 不设置toolExecutionRequests和toolExecutionResult
return displayMsg;
})
.collect(Collectors.toList());
messages = mergeToolMessages(messages, showToolProcess);
}
result.put("messages", messages);
result.put("flowInputs", chatConversation.getFlowInputs());
@@ -339,6 +356,104 @@ public class AiragChatServiceImpl implements IAiragChatService {
//update-end---author:chenrui ---date:20251106 for[issues/8545]新建AI应用的时候只能选择没有自定义参数的AI流程------------
}
/**
* 合并工具调用相关的历史记录生成带有工具执行标签的AI消息
*
* @param histories 历史消息列表
* @param showToolProcess 是否显示工具调用过程
* @return 合并后的历史消息列表
*/
private List<MessageHistory> mergeToolMessages(List<MessageHistory> histories, boolean showToolProcess) {
List<MessageHistory> mergedMessages = new ArrayList<>();
if (oConvertUtils.isObjectEmpty(histories)) {
return mergedMessages;
}
// 缓存工具请求,便于后续快速匹配
Map<String, MessageHistory.ToolExecutionRequestHistory> requestCache = new HashMap<>();
// 当前正在合并的AI消息
MessageHistory currentAiMsg = null;
// 合并AI消息
BiConsumer<MessageHistory, Object> mergeMsg = (cacheMsg, obj) -> {
String currContent;
if (obj instanceof MessageHistory) {
MessageHistory currMsg = (MessageHistory) obj;
currContent = currMsg.getContent();
// 合并图片
if (CollectionUtils.isNotEmpty(currMsg.getImages())) {
List<MessageHistory.ImageHistory> images = CollectionUtils.isEmpty(cacheMsg.getImages()) ? new ArrayList<>() : cacheMsg.getImages();
images.addAll(currMsg.getImages());
cacheMsg.setImages(images);
}
// 合并文件
if (CollectionUtils.isNotEmpty(currMsg.getFiles())) {
List<MessageHistory.FileHistory> files = CollectionUtils.isEmpty(cacheMsg.getImages()) ? new ArrayList<>() : cacheMsg.getFiles();
files.addAll(currMsg.getFiles());
cacheMsg.setFiles(files);
}
} else {
currContent = obj.toString();
}
cacheMsg.setContent(cacheMsg.getContent() + currContent);
};
// 遍历所有消息,根据类型的不同做出不同处理
for (MessageHistory message : histories) {
// 用户消息原样保留,不参与合并
if (AiragConsts.MESSAGE_ROLE_USER.equals(message.getRole())) {
if (currentAiMsg != null) {
mergedMessages.add(currentAiMsg);
currentAiMsg = null;
}
mergedMessages.add(message);
continue;
}
// 从当前AI消息开始向后合并工具调用与连续AI消息
if (AiragConsts.MESSAGE_ROLE_AI.equals(message.getRole())) {
if (currentAiMsg == null) {
currentAiMsg = MessageHistory.builder()
.conversationId(message.getConversationId())
.topicId(message.getTopicId())
.role(message.getRole())
.content("")
.images(message.getImages())
.files(message.getFiles())
.datetime(message.getDatetime())
.build();
}
mergeMsg.accept(currentAiMsg, message);
List<MessageHistory.ToolExecutionRequestHistory> toolReqs = message.getToolExecutionRequests();
if (CollectionUtils.isNotEmpty(toolReqs)) {
for (MessageHistory.ToolExecutionRequestHistory request : toolReqs) {
if (request != null) {
// 使用工具调用id作为唯一键方便快速匹配结果
requestCache.put(request.getId(), request);
}
}
}
continue;
}
if (AiragConsts.MESSAGE_ROLE_TOOL.equals(message.getRole())) {
if (currentAiMsg == null || !showToolProcess) {
continue;
}
String toolId = message.getContent();
MessageHistory.ToolExecutionRequestHistory request = requestCache.get(toolId);
if (request == null) {
continue;
}
String toolResult = message.getToolExecutionResult();
ToolExecutionVo vo = ToolExecutionVo.build(toolId, request.getName(), request.getArguments(), toolResult);
String execTag = JeecgTagHelper.createTag(JeecgTagHelper.TAG_JEECG_TOOL_EXEC, JSON.toJSONString(vo));
mergeMsg.accept(currentAiMsg, execTag);
}
}
// 避免最后一条消息没有放入列表
if (currentAiMsg != null) {
mergedMessages.add(currentAiMsg);
}
return mergedMessages;
}
@Override
public Result<?> clearMessage(String conversationId, String sessionType) {
AssertUtils.assertNotEmpty("请先选择会话", conversationId);
@@ -713,6 +828,8 @@ public class AiragChatServiceImpl implements IAiragChatService {
break;
case AiragConsts.MESSAGE_ROLE_AI:
// 重建AI消息包括工具执行请求
// 获取内容如果为空则使用空字符串AiMessage不允许null
String aiContent = oConvertUtils.getString(history.getContent(), "");
if (oConvertUtils.isObjectNotEmpty(history.getToolExecutionRequests())) {
// 有工具执行请求重建带工具调用的AiMessage
List<ToolExecutionRequest> toolRequests = history.getToolExecutionRequests().stream()
@@ -722,9 +839,9 @@ public class AiragChatServiceImpl implements IAiragChatService {
.arguments(toolReq.getArguments())
.build())
.collect(Collectors.toList());
chatMessage = AiMessage.from(history.getContent(), toolRequests);
chatMessage = AiMessage.from(aiContent, toolRequests);
} else {
chatMessage = new AiMessage(history.getContent());
chatMessage = new AiMessage(aiContent);
}
break;
case AiragConsts.MESSAGE_ROLE_TOOL:
@@ -735,7 +852,10 @@ public class AiragChatServiceImpl implements IAiragChatService {
.name("unknown") // 工具名称在重建时不重要因为主要用于AI理解结果
.arguments("{}")
.build();
chatMessage = ToolExecutionResultMessage.from(recreatedRequest, history.getToolExecutionResult());
//update-begin---author:scott ---date:20260416 for【PR#9539】修复通义千问API不接受null消息内容-----------
String toolResult = history.getToolExecutionResult() != null ? history.getToolExecutionResult() : "";
chatMessage = ToolExecutionResultMessage.from(recreatedRequest, toolResult);
//update-end---author:scott ---date:20260416 for【PR#9539】修复通义千问API不接受null消息内容-----------
break;
}
if (null == chatMessage) {
@@ -765,7 +885,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
private void appendMessage(List<ChatMessage> messages, ChatMessage message, ChatConversation chatConversation, String topicId, List<String> files, String saveContent) {
if (message.type().equals(ChatMessageType.SYSTEM)) {
if (message instanceof SystemMessage) {
// 系统消息,放到消息列表最前面,并且不记录历史
messages.add(0, message);
return;
@@ -778,18 +898,18 @@ public class AiragChatServiceImpl implements IAiragChatService {
}
// 消息记录
MessageHistory historyMessage = MessageHistory.builder().conversationId(chatConversation.getId()).topicId(topicId).datetime(DateUtils.now()).build();
if (message.type().equals(ChatMessageType.USER)) {
if (message instanceof UserMessage) {
historyMessage.setRole(AiragConsts.MESSAGE_ROLE_USER);
StringBuilder textContent = new StringBuilder();
List<MessageHistory.ImageHistory> images = new ArrayList<>();
List<Content> contents = ((UserMessage) message).contents();
contents.forEach(content -> {
if (content.type().equals(ContentType.IMAGE)) {
if (content instanceof ImageContent) {
ImageContent imageContent = (ImageContent) content;
Image image = imageContent.image();
MessageHistory.ImageHistory imageMessage = MessageHistory.ImageHistory.from(image.url(), image.base64Data(), image.mimeType());
images.add(imageMessage);
} else if (content.type().equals(ContentType.TEXT)) {
} else if (content instanceof TextContent) {
textContent.append(((TextContent) content).text()).append("\n");
}
});
@@ -809,10 +929,12 @@ public class AiragChatServiceImpl implements IAiragChatService {
historyMessage.setFiles(fileHistories);
}
//update-end---author:wangshuai---date:2026-01-12---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else if (message.type().equals(ChatMessageType.AI)) {
} else if (message instanceof AiMessage) {
historyMessage.setRole(AiragConsts.MESSAGE_ROLE_AI);
AiMessage aiMessage = (AiMessage) message;
historyMessage.setContent(aiMessage.text());
//update-begin---author:scott ---date:20260416 for【PR#9539】修复通义千问API不接受null消息内容-----------
historyMessage.setContent(aiMessage.text() != null ? aiMessage.text() : "");
//update-end---author:scott ---date:20260416 for【PR#9539】修复通义千问API不接受null消息内容-----------
// 处理工具执行请求
if (oConvertUtils.isObjectNotEmpty(aiMessage.toolExecutionRequests())) {
List<MessageHistory.ToolExecutionRequestHistory> toolRequests = new ArrayList<>();
@@ -825,7 +947,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
}
historyMessage.setToolExecutionRequests(toolRequests);
}
} else if (message.type().equals(ChatMessageType.TOOL_EXECUTION_RESULT)) {
} else if (message instanceof ToolExecutionResultMessage) {
// 工具执行结果消息
historyMessage.setRole(AiragConsts.MESSAGE_ROLE_TOOL);
ToolExecutionResultMessage toolMessage = (ToolExecutionResultMessage) message;
@@ -941,7 +1063,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
drawModelId = JSONObject.parseObject(metadata).getString("drawModelId");
}
}
AssertUtils.assertNotEmpty("请选择绘画模型", drawModelId);
//AssertUtils.assertNotEmpty("请选择绘画模型", drawModelId);
try {
List<String> images = sendParams.getImages();
List<Map<String, Object>> imageList;
@@ -1000,6 +1122,14 @@ public class AiragChatServiceImpl implements IAiragChatService {
flowRunParams.setFlowId(flowId);
flowRunParams.setConversationId(chatConversation.getId());
flowRunParams.setTopicId(topicId);
// 传入应用id变量节点需要
if (chatConversation.getApp() != null) {
flowRunParams.setAppId(chatConversation.getApp().getId());
}
// 传入记忆库id记忆节点需要
if (chatConversation.getApp() != null) {
flowRunParams.setMemoryId(chatConversation.getApp().getMemoryId());
}
// 支持流式
flowRunParams.setResponseMode(FlowConsts.FLOW_RESPONSE_MODE_STREAMING);
Map<String, Object> flowInputParams = new HashMap<>();
@@ -1138,6 +1268,14 @@ public class AiragChatServiceImpl implements IAiragChatService {
if (metadata.containsKey("maxTokens")) {
aiChatParams.setMaxTokens(metadata.getInteger("maxTokens"));
}
//update-begin---wangshuai---date:20260401 for【issues/9455】AI应用中设定的RAG参数未生效------------
if (metadata.containsKey("topNumber")) {
aiChatParams.setTopNumber(metadata.getInteger("topNumber"));
}
if (metadata.containsKey("similarity")) {
aiChatParams.setSimilarity(metadata.getDouble("similarity"));
}
//update-end---author:wangshuai ---date:20260401 for【issues/9455】AI应用中设定的RAG参数未生效------------
if (metadata.containsKey(FlowConsts.FLOW_NODE_OPTION_TIME_OUT)) {
aiChatParams.setTimeout(oConvertUtils.getInt(metadata.getInteger(FlowConsts.FLOW_NODE_OPTION_TIME_OUT), 300));
}
@@ -1163,9 +1301,9 @@ public class AiragChatServiceImpl implements IAiragChatService {
}
}
//流程不为空,构建插件
//流程不为空,构建插件(携带应用上下文参数,供变量/记忆节点使用)
if(oConvertUtils.isNotEmpty(flowId)){
Map<String, Object> result = airagFlowPluginService.getFlowsToPlugin(flowId);
Map<String, Object> result = airagFlowPluginService.getFlowsToPlugin(flowId, aiApp.getId(), memoryId);
this.addPluginToParams(aiChatParams, result);
}
@@ -1194,6 +1332,11 @@ public class AiragChatServiceImpl implements IAiragChatService {
airagVariableService.addUpdateVariableTool(aiApp,username,aiChatParams);
}
//update-begin---author:wangshuai---date:2026-03-18---for:【QQYUN-14935】Langchain4j 新版支持 Agent Skills重新定义 Java AI 应用的能力边界---
// 封装skills及上下文信息
fillSkillsParams(aiChatParams);
//update-end---author:wangshuai---date:2026-03-18---for:【QQYUN-14935】Langchain4j 新版支持 Agent Skills重新定义 Java AI 应用的能力边界---
// 打印流程耗时日志
printChatDuration(requestId, "构造应用自定义参数完成");
// 发消息
@@ -1228,6 +1371,49 @@ public class AiragChatServiceImpl implements IAiragChatService {
}
}
/**
* 封装skills参数及上下文信息
* 当配置了skillsPath时将skills路径设置到参数中并将Token、后台地址、租户ID拼接到用户消息后面
*
* @param aiChatParams AI聊天参数
*/
private void fillSkillsParams(AIChatParams aiChatParams) {
if (oConvertUtils.isEmpty(aiChatConfig.getSkillsDir()) && oConvertUtils.isEmpty(aiChatConfig.getSkillsShellDir())) {
log.info("[Skills] skillsPath OR shellSkillsDir is empty, skip skills loading");
return;
}
if (oConvertUtils.isNotEmpty(aiChatConfig.getSkillsDir())){
aiChatParams.setSkillsDir(aiChatConfig.getSkillsDir());
log.info("[Skills] skillsDir set to: {}", aiChatParams.getSkillsDir());
}
if (oConvertUtils.isNotEmpty((aiChatConfig.getSkillsShellDir()))){
aiChatParams.setSkillsShellDir(aiChatConfig.getSkillsShellDir());
log.info("[Skills] shellSkillsDir set to: {}", aiChatParams.getSkillsShellDir());
}
// 注入运行时上下文Token、后台API地址、租户ID供Skills使用
try {
HttpServletRequest request = SpringContextUtils.getHttpServletRequest();
String token = TokenUtils.getTokenByRequest(request);
String tenantId = request.getHeader("X-Tenant-Id");
// 从当前请求构造后台API地址
String apiBase = CommonUtils.getBaseUrl(request);
StringBuilder context = new StringBuilder();
context.append("以下信息由系统自动注入Skill执行时可直接使用\n");
context.append("- **API_BASE**: `").append(apiBase).append("`\n");
if (oConvertUtils.isNotEmpty(token)) {
context.append("- **X-Access-Token**: `").append(token).append("`\n");
}
if (oConvertUtils.isNotEmpty(tenantId)) {
context.append("- **X-Tenant-Id**: `").append(tenantId).append("`\n");
}
aiChatParams.setSkillsContext(context.toString());
log.info("[Skills] context injected, apiBase: {}", apiBase);
} catch (Exception e) {
log.warn("[Skills] Failed to inject context: {}", e.getMessage());
}
}
/**
* 处理聊天
* 向大模型发送消息并接受响应
@@ -1246,22 +1432,41 @@ public class AiragChatServiceImpl implements IAiragChatService {
if (null == aiChatParams) {
aiChatParams = new AIChatParams();
}
HttpServletRequest httpRequest = SpringContextUtils.getHttpServletRequest();
// 如果是默认app,加载系统默认工具
if(chatConversation.getApp().getId().equals(AiAppConsts.DEFAULT_APP_ID)){
aiChatParams.setTools(jeecgToolsProvider.getDefaultTools());
// Security fix: 仅已登录用户可加载敏感业务工具(add_user,grant_user_roles等),匿名用户仍可正常使用AI聊天
String currentUser = getUsername(httpRequest);
if (oConvertUtils.isNotEmpty(currentUser)) {
aiChatParams.setTools(jeecgToolsProvider.getDefaultTools());
}
}
//update-begin---author:wangshuai ---date:2026-04-15 forBrave Search配置迁移到AiRagConfigBean仅在联网搜索开启时注入工具-----------
// Brave Search 联网检索工具:前端 enableSearch=true 且 apiKey 已配置时才注入
if (Boolean.TRUE.equals(aiChatParams.getEnableSearch())) {
Map<ToolSpecification, ToolExecutor> braveTools = BraveSearchToolBuilder.buildTools(aiRagConfigBean.getBraveSearch());
if (!braveTools.isEmpty()) {
Map<ToolSpecification, ToolExecutor> existing = aiChatParams.getTools();
if (existing == null) {
existing = new HashMap<>();
}
existing.putAll(braveTools);
aiChatParams.setTools(existing);
}
}
//update-end---author:wangshuai ---date:2026-04-15 forBrave Search配置迁移到AiRagConfigBean仅在联网搜索开启时注入工具-----------
if(CollectionUtils.isEmpty(aiChatParams.getKnowIds())){
aiChatParams.setKnowIds(chatConversation.getApp().getKnowIds());
} else {
aiChatParams.getKnowIds().addAll(chatConversation.getApp().getKnowIds());
}
aiChatParams.setMaxMsgNumber(oConvertUtils.getInt(chatConversation.getApp().getMsgNum(), 5));
aiChatParams.setCurrentHttpRequest(SpringContextUtils.getHttpServletRequest());
HttpServletRequest httpRequest = SpringContextUtils.getHttpServletRequest();
aiChatParams.setCurrentHttpRequest(httpRequest);
// for [QQYUN-9234] MCP服务连接关闭 - 保存参数引用用于在回调中关闭MCP连接
final AIChatParams finalAiChatParams = aiChatParams;
TokenStream chatStream;
try {
aiChatParams.setTimeout(5*30*1000);
// 打印流程耗时日志
printChatDuration(requestId, "开始向LLM发送消息");
if (oConvertUtils.isNotEmpty(modelId)) {
@@ -1280,7 +1485,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
return;
}
String errMsg = "调用大模型接口失败,详情请查看后台日志。";
if(e instanceof JeecgBootException){
if(e instanceof JeecgBootException || e instanceof JeecgBootBizTipException){
errMsg = e.getMessage();
}
EventData eventData = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR, chatConversation.getId(), topicId);
@@ -1288,6 +1493,38 @@ public class AiragChatServiceImpl implements IAiragChatService {
closeSSE(emitter, eventData);
throw new JeecgBootBizTipException("调用大模型接口失败:" + e.getMessage());
}
// 发送消息给前端
BiConsumer<String, String> send2Client = (resMessage, eventType) -> {
eventType = oConvertUtils.isNotEmpty(eventType) ? eventType : EventData.EVENT_MESSAGE;
EventData eventData = new EventData(requestId, null, eventType, chatConversation.getId(), topicId);
EventMessageData messageEventData = EventMessageData.builder().message(resMessage).build();
eventData.setData(messageEventData);
eventData.setRequestId(requestId);
// sse
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
if (null == emitter) {
log.warn("[AI应用]接收LLM返回会话已关闭");
return;
}
sendMessage2Client(emitter, eventData);
};
// 解析是否显示工具调用过程默认为true
boolean showToolProcess = true;
String appMetadataStr = chatConversation.getApp().getMetadata();
if (oConvertUtils.isNotEmpty(appMetadataStr)) {
try {
JSONObject appMetadataJson = JSONObject.parseObject(appMetadataStr);
if (appMetadataJson != null && "0".equals(appMetadataJson.getString("showToolProcess"))) {
showToolProcess = false;
}
} catch (Exception ignored) {
}
}
final boolean finalShowToolProcess = showToolProcess;
/**
* 是否正在思考
*/
@@ -1301,22 +1538,19 @@ public class AiragChatServiceImpl implements IAiragChatService {
isThinking.set(false);
}
//update-end---author:wangshuai---date:2025-11-07---for:[issues/8506]/[issues/8260]/[issues/8166]新增推理模型的支持---
EventData eventData = new EventData(requestId, null, EventData.EVENT_MESSAGE, chatConversation.getId(), topicId);
EventMessageData messageEventData = EventMessageData.builder().message(resMessage).build();
eventData.setData(messageEventData);
eventData.setRequestId(requestId);
// sse
SseEmitter emitter = AiragLocalCache.get(AiragConsts.CACHE_TYPE_SSE, requestId);
if (null == emitter) {
log.warn("[AI应用]接收LLM返回会话已关闭");
return;
send2Client.accept(resMessage, EventData.EVENT_MESSAGE);
}).beforeToolExecution(beforeToolExecution -> {
// 监听工具执行请求(根据配置决定是否发送给前端)
if (finalShowToolProcess) {
ToolExecutionVo vo = ToolExecutionVo.build(beforeToolExecution);
String execTag = JeecgTagHelper.createTag(JeecgTagHelper.TAG_JEECG_TOOL_EXEC, JSON.toJSONString(vo));
send2Client.accept(execTag, EventData.EVENT_TOOL_EXEC_BEFORE);
}
sendMessage2Client(emitter, eventData);
}).onToolExecuted((toolExecution) -> {
// 打印工具执行结果
log.debug("[AI应用]工具执行结果: toolName={}, toolId={}, result={}",
toolExecution.request().name(),
toolExecution.request().id(),
toolExecution.request().name(),
toolExecution.request().id(),
toolExecution.result());
// 将工具执行结果存储到消息历史中
ToolExecutionResultMessage toolResultMessage = ToolExecutionResultMessage.from(
@@ -1324,6 +1558,13 @@ public class AiragChatServiceImpl implements IAiragChatService {
toolExecution.result()
);
appendMessage(messages, toolResultMessage, chatConversation, topicId);
// 根据配置决定是否将工具调用过程发送给前端
if (finalShowToolProcess) {
ToolExecutionVo vo = ToolExecutionVo.build(toolExecution);
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);
}
}).onIntermediateResponse((chatResponse) -> {
// 中间响应包含tool_calls的AI消息
AiMessage aiMessage = chatResponse.aiMessage();
@@ -1422,14 +1663,20 @@ public class AiragChatServiceImpl implements IAiragChatService {
//update-end---author:chenrui ---date:20250425 for[QQYUN-12203]AI 聊天,超时或者服务器报错,给个友好提示------------
} else {
errMsg = "调用大模型接口失败,详情请查看后台日志。";
boolean isFindErrorMsg = false;
// 根据常见异常关键字做细致翻译
for (Map.Entry<String, String> entry : AIChatHandler.MODEL_ERROR_MAP.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (error.getMessage().contains(key)) {
errMsg = value;
isFindErrorMsg = true;
}
}
String message = error.getMessage();
if(!isFindErrorMsg && message.contains("error")) {
errMsg = JSONObject.parseObject(message).get("error").toString();
}
EventData eventData = new EventData(requestId, null, EventData.EVENT_FLOW_ERROR, chatConversation.getId(), topicId);
eventData.setData(EventFlowData.builder().success(false).message(errMsg).build());
closeSSE(emitter, eventData);
@@ -1693,29 +1940,87 @@ public class AiragChatServiceImpl implements IAiragChatService {
/**
* ai海报生成
*
* @param chatSendParams
* @param aiDrawGenerateVo
* @return
*/
@Override
public String genAiPoster(ChatSendParams chatSendParams) {
AssertUtils.assertNotEmpty("请选择绘画模型", chatSendParams.getDrawModelId());
AssertUtils.assertNotEmpty("请填写提示词", chatSendParams.getContent());
public String genAiPoster(AiDrawGenerateVo aiDrawGenerateVo) {
AssertUtils.assertNotEmpty("请选择绘画模型", aiDrawGenerateVo.getDrawModelId());
AssertUtils.assertNotEmpty("请填写提示词", aiDrawGenerateVo.getContent());
AIChatParams aiChatParams = new AIChatParams();
if(oConvertUtils.isNotEmpty(chatSendParams.getImageSize())){
aiChatParams.setImageSize(chatSendParams.getImageSize());
//update-begin---author:wangshuai---date:2026-02-05---for:【QQYUN-14568】AI绘画功能---
if(oConvertUtils.isNotEmpty(aiDrawGenerateVo.getImageSize())){
aiChatParams.setImageSize(aiDrawGenerateVo.getImageSize());
}
String image= chatSendParams.getImageUrl();
//aiChatParams.setNegativePrompt("面部扭曲,特征丢失,边缘模糊,比例失调,模糊,多余的手指");
//绘图
if(AiAppConsts.AI_DRAW_TYPE_DRAW.equals(aiDrawGenerateVo.getType())){
String format = StrUtil.format(Prompts.AI_DRAW_PROMPT, aiDrawGenerateVo.getStyle(), aiDrawGenerateVo.getVisualAngle(), aiDrawGenerateVo.getCharacterShot(), aiDrawGenerateVo.getLighting(), ImageSizeEnum.getRatioBySize(aiDrawGenerateVo.getImageSize()));
aiDrawGenerateVo.setContent(format + aiDrawGenerateVo.getContent());
}
if((AiAppConsts.AI_DRAW_TYPE_FACE.equals(aiDrawGenerateVo.getType()) || AiAppConsts.AI_DRAW_TYPE_MIX.equals(aiDrawGenerateVo.getType())) && oConvertUtils.isNotEmpty(aiDrawGenerateVo.getImageSize())){
aiDrawGenerateVo.setContent(aiDrawGenerateVo.getContent() + "比例:" + ImageSizeEnum.getRatioBySize(aiDrawGenerateVo.getImageSize()));
}
String image= aiDrawGenerateVo.getImageUrl();
//update-end---author:wangshuai---date:2026-02-05---for:【QQYUN-14568】AI绘画功能---
List<Map<String, Object>> imageList = new ArrayList<>();
if(oConvertUtils.isEmpty(image)) {
//生成图片
imageList = aiChatHandler.imageGenerate(chatSendParams.getDrawModelId(), chatSendParams.getContent(), aiChatParams);
imageList = aiChatHandler.imageGenerate(aiDrawGenerateVo.getDrawModelId(), aiDrawGenerateVo.getContent(), aiChatParams);
} else {
//图生图
imageList = aiChatHandler.imageEdit(chatSendParams.getDrawModelId(), chatSendParams.getContent(), Arrays.asList(image.split(SymbolConstant.COMMA)), aiChatParams);
imageList = aiChatHandler.imageEdit(aiDrawGenerateVo.getDrawModelId(), aiDrawGenerateVo.getContent(), Arrays.asList(image.split(SymbolConstant.COMMA)), aiChatParams);
}
return imageList.stream().map(this::uploadImage).collect(Collectors.joining("\n"));
}
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
@Override
public String genAiPosterAsync(AiDrawGenerateVo aiDrawGenerateVo) {
AssertUtils.assertNotEmpty("请选择绘画模型", aiDrawGenerateVo.getDrawModelId());
AssertUtils.assertNotEmpty("请填写提示词", aiDrawGenerateVo.getContent());
String taskId = java.util.UUID.randomUUID().toString().replace("-", "");
// 写入 pending 状态
JSONObject task = new JSONObject();
task.put("status", "pending");
redisUtil.set(AiAppConsts.POSTER_TASK_PREFIX + taskId, task.toJSONString(), AiAppConsts.POSTER_TASK_TTL);
// 异步执行生成
SSE_THREAD_POOL.execute(() -> {
JSONObject result = new JSONObject();
try {
String imageUrl = genAiPoster(aiDrawGenerateVo);
result.put("status", "success");
result.put("imageUrl", imageUrl);
} catch (Exception e) {
log.error("[AI海报]异步生成失败 taskId={}", taskId, e);
result.put("status", "failed");
result.put("message", e.getMessage());
}
redisUtil.set(AiAppConsts.POSTER_TASK_PREFIX + taskId, result.toJSONString(), AiAppConsts.POSTER_TASK_TTL);
});
return taskId;
}
@Override
public Result<?> getAiPosterResult(String taskId) {
Object val = redisUtil.get(AiAppConsts.POSTER_TASK_PREFIX + taskId);
if (val == null) {
return Result.error("任务不存在或已过期");
}
JSONObject task = JSONObject.parseObject(val.toString());
String status = task.getString("status");
if ("success".equals(status)) {
return Result.OK(task.getString("imageUrl"));
}
if ("failed".equals(status)) {
return Result.error(task.getString("message"));
}
// pending
return Result.OK("pending", null);
}
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】AI海报生成改为异步支持切换菜单后重新获取结果-----------
/**
* 上传图片
*
@@ -1738,6 +2043,13 @@ public class AiragChatServiceImpl implements IAiragChatService {
data = Base64.getDecoder().decode(value);
} else {
//下载网络图片
//update-begin---author:zhangdaihao ---date:20260427 for[issues/9579]AI海报图片下载 SSRF 校验,拒绝 loopback/link-local------------
// genAiPoster -> uploadImage -> getDownInputStream攻击者可通过 imageUrl 触发服务端访问 localhost / 云元数据等敏感目标;
// 沿用与 #9553 一致的基础 SSRF 校验(拒绝 loopback / link-local保留对企业内网 MinIO/OSS 的兼容。
if (oConvertUtils.isNotEmpty(value) && value.toLowerCase().startsWith("http")) {
SsrfFileTypeFilter.checkSsrfHttpUrl(value);
}
//update-end-----author:zhangdaihao ---date:20260427 for[issues/9579]AI海报图片下载 SSRF 校验,拒绝 loopback/link-local------------
InputStream inputStream = FileDownloadUtils.getDownInputStream(value, "");
if (inputStream != null) {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
@@ -1796,7 +2108,7 @@ public class AiragChatServiceImpl implements IAiragChatService {
* @return
*/
private String parseFilesToText(List<String> files) {
if (com.baomidou.mybatisplus.core.toolkit.CollectionUtils.isEmpty(files)) {
if (CollectionUtils.isEmpty(files)) {
return "";
}
StringBuilder sb = new StringBuilder();
@@ -1855,16 +2167,37 @@ 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-04-13 for【issues/9519】AI附件处理路径遍历漏洞下载文件名做安全过滤临时目录隔离---
// 远程下载:使用 FilenameUtils.getName 剥离任何路径分隔符,再次校验防止 ..
String safeFileName = FilenameUtils.getName(fileName);
SsrfFileTypeFilter.checkPathTraversal(safeFileName);
//update-end---author:wangshuai ---date:2026-04-13 for【issues/9519】AI附件处理路径遍历漏洞下载文件名做安全过滤临时目录隔离---
String tempDir = uploadpath + File.separator + "chat" + File.separator + UUID.randomUUID() + File.separator;
File dir = new File(tempDir);
if (!dir.exists() && !dir.mkdirs()) {
return null;
}
String tempFilePath = tempDir + fileName;
String tempFilePath = tempDir + safeFileName;
//update-begin---author:zhangdaihao ---date:20260427 for[issues/9578]AI附件下载 SSRF 校验,拒绝 loopback/link-local------------
// /airag/chat/send 端点为 @IgnoreAuth 无认证AI 聊天解析附件存在 SSRF 风险;
// 沿用与 #9553 一致的基础 SSRF 校验(拒绝 loopback / link-local保留对企业内网 MinIO/OSS 的兼容。
SsrfFileTypeFilter.checkSsrfHttpUrl(fileRef);
//update-end-----author:zhangdaihao ---date:20260427 for[issues/9578]AI附件下载 SSRF 校验,拒绝 loopback/link-local------------
FileDownloadUtils.download2DiskFromNet(fileRef, tempFilePath);
return new File(tempFilePath);
}
return new File(uploadpath + File.separator + fileRef);
//update-begin---author:wangshuai ---date:2026-04-13 for【issues/9519】AI附件处理路径遍历漏洞规范化路径并强制校验沙箱范围---
// 本地附件1) 先做字符级路径遍历检查2) 规范化路径后必须仍在 uploadpath 下,阻止 ../ 逃逸
java.nio.file.Path root = Paths.get(uploadpath).toAbsolutePath().normalize();
SsrfFileTypeFilter.checkPathTraversal(fileRef);
String relativePath = fileRef.replaceAll("^[\\\\/]+", "");
java.nio.file.Path target = root.resolve(relativePath).toAbsolutePath().normalize();
if (!target.startsWith(root)) {
log.error("检测到路径遍历攻击! fileRef: {}, 解析后: {}", relativePath, target);
throw new JeecgBootException("文件路径包含非法字符");
}
return target.toFile();
//update-end---author:wangshuai ---date:2026-04-13 for【issues/9519】AI附件处理路径遍历漏洞规范化路径并强制校验沙箱范围---
}
//================================================= end【QQYUN-14261】【AI】AI助手支持多模态能力- 文档========================================
@@ -1887,7 +2220,9 @@ public class AiragChatServiceImpl implements IAiragChatService {
content = StrUtil.format(Prompts.AI_WRITER_PROMPT, aiWriteGenerateVo.getPrompt(), aiWriteGenerateVo.getFormat(), aiWriteGenerateVo.getTone(), aiWriteGenerateVo.getLanguage(), aiWriteGenerateVo.getLength());
} else if(reply.equals(aiWriteGenerateVo.getActiveMode())){
//回复
content = StrUtil.format(Prompts.AI_REPLY_PROMPT, aiWriteGenerateVo.getPrompt(), aiWriteGenerateVo.getOriginalContent(), aiWriteGenerateVo.getFormat(), aiWriteGenerateVo.getTone(), aiWriteGenerateVo.getLanguage(), aiWriteGenerateVo.getLength());
//update-begin---author:wangshuai ---date:2026-04-20 for【QQYUN-15179】ai写作 生成的内容不对,应该是以回复来生成,而不是内容-----------
content = StrUtil.format(Prompts.AI_REPLY_PROMPT, aiWriteGenerateVo.getOriginalContent(), aiWriteGenerateVo.getPrompt(), aiWriteGenerateVo.getFormat(), aiWriteGenerateVo.getTone(), aiWriteGenerateVo.getLanguage(), aiWriteGenerateVo.getLength());
//update-end---author:wangshuai ---date:2026-04-20 for【QQYUN-15179】ai写作 生成的内容不对,应该是以回复来生成,而不是内容-----------
} else {
content = StrUtil.format(Prompts.AI_TOUCHE_PROMPT, aiWriteGenerateVo.getPrompt(), aiWriteGenerateVo.getFormat(), aiWriteGenerateVo.getTone(), aiWriteGenerateVo.getLanguage(), aiWriteGenerateVo.getLength());
}

View File

@@ -3,6 +3,7 @@ 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.JsonArraySchema;
import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
import dev.langchain4j.service.tool.ToolExecutor;
import lombok.extern.slf4j.Slf4j;
@@ -53,6 +54,24 @@ public class AiragVariableServiceImpl implements IAiragVariableService {
redisTemplate.opsForHash().putIfAbsent(key, name, defaultValue != null ? defaultValue : "");
}
/**
* 获取变量值
*
* @param username 用户名
* @param appId 应用ID
* @param name 变量名
* @return 变量值不存在返回null
*/
@Override
public String getVariable(String username, String appId, String name) {
if (oConvertUtils.isEmpty(username) || oConvertUtils.isEmpty(appId) || oConvertUtils.isEmpty(name)) {
return null;
}
String key = CACHE_PREFIX + appId + ":" + username;
Object value = redisTemplate.opsForHash().get(key, name);
return value != null ? String.valueOf(value) : null;
}
/**
* 追加提示词
*
@@ -147,7 +166,9 @@ public class AiragVariableServiceImpl implements IAiragVariableService {
}
//工具描述
StringBuilder descriptionBuilder = new StringBuilder("更新应用变量的值。仅当检测到变量的新值与当前值不一致时调用。如果已调用过或值未变,请勿重复调用。");
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
StringBuilder descriptionBuilder = new StringBuilder("批量更新应用变量的值。请将本次对话中所有需要更新的变量一次性传入updates数组无需多次调用。仅当变量新值与当前值确实不同时才调用本工具。");
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
if (variableList != null && !variableList.isEmpty()) {
descriptionBuilder.append("\n\n可用变量列表");
for (AppVariableVo var : variableList) {
@@ -159,17 +180,30 @@ public class AiragVariableServiceImpl implements IAiragVariableService {
descriptionBuilder.append(": ").append(var.getDescription());
}
}
descriptionBuilder.append("\n\n注意variableName必须是上述列表中的名称之一。");
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
descriptionBuilder.append("\n\n注意variableName必须是上述列表中的名称之一且本工具每轮对话只需调用一次。");
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
}
// A: 参数改为批量数组,一次可更新多个变量
JsonObjectSchema itemSchema = JsonObjectSchema.builder()
.addStringProperty("variableName", "变量名称(必须是可用变量列表中的名称之一)")
.addStringProperty("value", "变量新值")
.required("variableName", "value")
.build();
//构建更新变量的工具
ToolSpecification spec = ToolSpecification.builder()
.name("update_variable")
.description(descriptionBuilder.toString())
.parameters(JsonObjectSchema.builder()
.addStringProperty("variableName", "变量名称")
.addStringProperty("value", "变量值")
.required("variableName", "value")
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
.addProperty("updates", JsonArraySchema.builder()
.description("需要更新的变量列表,可包含多个变量")
.items(itemSchema)
.build())
.required("updates")
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
.build())
.build();
@@ -177,15 +211,38 @@ public class AiragVariableServiceImpl implements IAiragVariableService {
ToolExecutor executor = (toolExecutionRequest, memoryId) -> {
try {
JSONObject args = JSONObject.parseObject(toolExecutionRequest.arguments());
String name = args.getString("variableName");
String value = args.getString("value");
JSONArray updates = args.getJSONArray("updates");
IAiragVariableService variableService = SpringContextUtils.getBean(IAiragVariableService.class);
//更新变量值
variableService.updateVariable(username, aiApp.getId(), name, value);
return "变量 " + name + " 已更新为: " + value;
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
// B: 返回结构化JSONLLM可明确感知"已全部完成"
JSONObject updatedMap = new JSONObject();
if (updates != null) {
for (int i = 0; i < updates.size(); i++) {
JSONObject item = updates.getJSONObject(i);
String name = item.getString("variableName");
String value = item.getString("value");
if (oConvertUtils.isNotEmpty(name)) {
variableService.updateVariable(username, aiApp.getId(), name, value);
updatedMap.put(name, value);
}
}
}
JSONObject result = new JSONObject();
result.put("success", true);
result.put("updated", updatedMap);
result.put("count", updatedMap.size());
result.put("message", "已成功更新 " + updatedMap.size() + " 个变量,无需再次调用");
return result.toJSONString();
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
} catch (Exception e) {
log.error("更新变量失败", e);
return "更新变量失败: " + e.getMessage();
//update-begin---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
JSONObject error = new JSONObject();
error.put("success", false);
error.put("message", "更新变量失败: " + e.getMessage());
return error.toJSONString();
//update-end---author:wangshuai ---date:2026-04-21 for【AI变量】支持批量更新变量返回结构化结果避免LLM重复调用-----------
}
};

View File

@@ -0,0 +1,58 @@
package org.jeecg.modules.airag.app.vo;
import lombok.Data;
/**
* @Description: AI绘画
*
* @author: wangshuai
* @date: 2026/2/4 18:57
*/
@Data
public class AiDrawGenerateVo {
/**
* 绘画模型的id
*/
private String drawModelId;
/**
* 图片尺寸
*/
private String imageSize;
/**
* 一张图片或者多张图片,多张图片用逗号分隔
*/
private String imageUrl;
/**
* 用户输入的聊天内容
*/
private String content;
/**
* 风格
*/
private String style;
/**
* 视角
*/
private String visualAngle;
/**
* 人物镜头
*/
private String characterShot;
/**
* 灯光
*/
private String lighting;
/**
* 类型 poster: 海报draw绘图face 换脸mix 混图
*/
private String type;
}

View File

@@ -88,11 +88,52 @@ public class LLMConsts {
*/
public static final String KNOWLEDGE_DOC_METADATA_SOURCES_PATH = "sourcesPath";
/**
* 知识库:文档元数据:网页URL
*/
public static final String KNOWLEDGE_DOC_METADATA_WEBSITE = "website";
/**
* DEEPSEEK推理模型
*/
public static final String DEEPSEEK_REASONER = "deepseek-reasoner";
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
/**
* DEEPSEEK 推理模型(返回 reasoning_content 字段、在多轮工具调用中要求把 reasoning_content 回传)集合,
* 后续 DeepSeek 新增推理模型时在此追加;非推理模型(如 deepseek-chat)不要加入。
* 触发场景:仅当对话存在工具调用导致的多轮请求时才会出现 "reasoning_content must be passed back" 错误,
* 单轮 Q&A(如 AI 应用聊天无工具)不会触发,但开启 sendThinking 也无副作用。
*/
public static final Set<String> DEEPSEEK_THINKING_MODELS = new HashSet<>(Arrays.asList(
"deepseek-reasoner",
"deepseek-v4-flash",
"deepseek-v4-pro"
));
/**
* 判断指定模型名是否为 DeepSeek 推理模型(返回 reasoning_content 字段)
* 匹配规则:先做大小写不敏感的精确匹配,再做关键字包含匹配(reasoner/v4-flash/v4-pro)
* 以兼容带版本后缀的变体(如 deepseek-v4-flash-0428)
*
* @param modelName 模型名(大小写不敏感、首尾空白容错)
* @return true=推理模型false=非推理模型或空
*/
public static boolean isDeepSeekThinkingModel(String modelName) {
if (modelName == null || modelName.trim().isEmpty()) {
return false;
}
String name = modelName.trim().toLowerCase();
if (DEEPSEEK_THINKING_MODELS.contains(name)) {
return true;
}
// 兼容带版本后缀或厂商前缀的变体
return name.contains("reasoner")
|| name.contains("v4-flash")
|| name.contains("v4-pro");
}
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
/**
* 知识库类型:知识库
*/
@@ -118,4 +159,63 @@ public class LLMConsts {
*/
public static final int CHAT_FILE_MAX_COUNT = 3;
/**
* 知识库是否开启默认分段策略
*/
public static final String ENABLE_SEGMENT = "enableSegment";
/**
* 文档分段策略:使用知识库默认分段策略
*/
public static final String USE_KNOWLEDGE_DEFAULT = "useKnowledgeDefault";
/**
* 分段策略
*/
public static final String SEGMENT_STRATEGY = "segmentStrategy";
/**
* 分段策略auto 自动分段与清洗
*/
public static final String SEGMENT_STRATEGY_AUTO = "auto";
/**
* 分段策略custom 自定义
*/
public static final String SEGMENT_STRATEGY_CUSTOM = "custom";
/**
* 分段长度
*/
public static final String MAX_SEGMENT = "maxSegment";
/**
* 重叠率 0-90%
*/
public static final String OVERLAP = "overlap";
/**
* 分段标识符(\\n:换行,\\n\\n:2个换行。:中文句号,!:中文叹号,?:中文问号,. :英文句号,! :英文叹号,? :英文问号custom:自定义)
*/
public static final String SEPARATOR = "separator";
/**
* 分段标识符自定义
*/
public static final String CUSTOM_SEPARATOR = "customSeparator";
/**
* 文本预处理规则cleanSpaces替换掉连续的空格、换行符和制表符removeUrlsEmails删除所有 URL 和电子邮箱地址)
*/
public static final String TEXT_RULES = "textRules";
/**
* 替换掉连续的空格、换行符和制表符
*/
public static final String TEXT_RULES_CLEAN_SPACES = "cleanSpaces";
/**
* 删除所有URL和电子邮箱地址
*/
public static final String TEXT_RULES_REMOVE_URLS_EMAILS = "removeUrlsEmails";
}

View File

@@ -1,7 +1,7 @@
package org.jeecg.modules.airag.llm.controller;
import org.jeecg.common.airag.api.IAiragBaseApi;
import org.jeecg.modules.airag.llm.service.impl.AiragBaseApiImpl;
import org.jeecg.modules.airag.api.AiragBaseApiImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -23,9 +23,39 @@ public class AiragBaseApiController implements IAiragBaseApi {
public String knowledgeWriteTextDocument(
@RequestParam("knowledgeId") String knowledgeId,
@RequestParam("title") String title,
@RequestParam("content") String content
@RequestParam("content") String content,
@RequestParam(value = "segmentConfig", required = false) String segmentConfig
) {
return airagBaseApi.knowledgeWriteTextDocument(knowledgeId, title, content);
return airagBaseApi.knowledgeWriteTextDocument(knowledgeId, title, content, segmentConfig);
}
@PostMapping("/airag/api/getChatVariable")
public String getChatVariable(
@RequestParam("appId") String appId,
@RequestParam("username") String username,
@RequestParam("name") String name
) {
return airagBaseApi.getChatVariable(appId, username, name);
}
@PostMapping("/airag/api/setChatVariable")
public void setChatVariable(
@RequestParam("appId") String appId,
@RequestParam("username") String username,
@RequestParam("name") String name,
@RequestParam("value") String value
) {
airagBaseApi.setChatVariable(appId, username, name, value);
}
@PostMapping("/airag/api/getMemoryIdByAppId")
public String getMemoryIdByAppId(@RequestParam("appId") String appId) {
return airagBaseApi.getMemoryIdByAppId(appId);
}
@PostMapping("/airag/api/getPromptContent")
public String getPromptContent(@RequestParam("promptId") String promptId) {
return airagBaseApi.getPromptContent(promptId);
}
}

View File

@@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
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;
@@ -42,6 +43,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @return
*/
@Operation(summary = "MCP-分页列表查询")
@RequiresPermissions("airag:mcp:list")
@GetMapping(value = "/list")
public Result<IPage<AiragMcp>> queryPageList(AiragMcp airagMcp,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@@ -61,6 +63,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @return
*/
@Operation(summary = "MCP-保存")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/save")
public Result<String> save(@RequestBody AiragMcp airagMcp) {
return airagMcpService.edit(airagMcp);
@@ -77,6 +80,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @date 2025/10/21 10:54
*/
@Operation(summary = "MCP-保存并同步")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/saveAndSync")
public Result<?> saveAndSync(@RequestBody AiragMcp airagMcp) {
Result<String> saveResult = airagMcpService.edit(airagMcp);
@@ -99,6 +103,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @date 2025/10/20 20:09
*/
@Operation(summary = "MCP-同步MCP信息")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/sync/{id}")
public Result<?> sync(@PathVariable(name = "id", required = true) String id) {
return airagMcpService.sync(id);
@@ -114,6 +119,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @date 2025/10/20 20:13
*/
@Operation(summary = "MCP-启用/禁用MCP信息")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/status/{id}/{action}")
public Result<?> toggleStatus(@PathVariable(name = "id",required = true) String id,
@PathVariable(name = "action", required = true) String action) {
@@ -129,6 +135,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @date 2025/10/30
*/
@Operation(summary = "MCP-保存插件工具")
@RequiresPermissions("airag:mcp:save")
@PostMapping(value = "/saveTools")
public Result<String> saveTools(@RequestBody SaveToolsDTO dto) {
return airagMcpService.saveTools(dto.getId(), dto.getTools());
@@ -141,6 +148,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @return
*/
@Operation(summary = "MCP-通过id删除")
@RequiresPermissions("airag:mcp:delete")
@DeleteMapping(value = "/delete")
public Result<String> delete(@RequestParam(name = "id", required = true) String id) {
airagMcpService.removeById(id);
@@ -154,6 +162,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @return
*/
@Operation(summary = "MCP-通过id查询")
//@RequiresPermissions("airag:mcp:queryById")
@GetMapping(value = "/queryById")
public Result<AiragMcp> queryById(@RequestParam(name = "id", required = true) String id) {
AiragMcp airagMcp = airagMcpService.getById(id);
@@ -169,7 +178,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @param request
* @param airagMcp
*/
// @RequiresPermissions("llm:airag_mcp:exportXls")
@RequiresPermissions("airag:mcp:export")
@RequestMapping(value = "/exportXls")
public ModelAndView exportXls(HttpServletRequest request, AiragMcp airagMcp) {
return super.exportXls(request, airagMcp, AiragMcp.class, "MCP");
@@ -182,7 +191,7 @@ public class AiragMcpController extends JeecgController<AiragMcp, IAiragMcpServi
* @param response
* @return
*/
// @RequiresPermissions("llm:airag_mcp:importExcel")
@RequiresPermissions("airag:mcp:import")
@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
return super.importExcel(request, response, AiragMcp.class);

View File

@@ -17,6 +17,7 @@ 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.modules.airag.app.enums.ImageEditEnum;
import org.jeecg.modules.airag.common.handler.AIChatParams;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import org.jeecg.modules.airag.llm.entity.AiragModel;
@@ -29,7 +30,9 @@ import org.springframework.web.servlet.ModelAndView;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Collections;
/**
@@ -82,8 +85,6 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
// 默认未激活
if(oConvertUtils.isObjectEmpty(airagModel.getActivateFlag())){
airagModel.setActivateFlag(0);
} else {
airagModel.setActivateFlag(1);
}
airagModelService.save(airagModel);
return Result.OK("添加成功!");
@@ -170,6 +171,8 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
AssertUtils.assertNotEmpty("模型名称不能为空", airagModel.getName());
AssertUtils.assertNotEmpty("模型类型不能为空", airagModel.getModelType());
AssertUtils.assertNotEmpty("基础模型不能为空", airagModel.getModelName());
//测试连接默认为已激活状态
airagModel.setActivateFlag(1);
try {
if(LLMConsts.MODEL_TYPE_LLM.equals(airagModel.getModelType())){
aiChatHandler.completions(airagModel, Collections.singletonList(UserMessage.from("To test whether it can be successfully called, simply return success")), null);
@@ -180,7 +183,16 @@ public class AiragModelController extends JeecgController<AiragModel, IAiragMode
//update-begin---author:wangshuai---date:2026-01-07---for:【QQYUN-12145】【AI】AI 绘画创作---=
}else if(LLMConsts.MODEL_TYPE_IMAGE.equals(airagModel.getModelType())){
AIChatParams aiChatParams = new AIChatParams();
aiChatHandler.imageGenerate(airagModel, "To test whether it can be successfully called, simply return success", aiChatParams);
//update-begin---author:wangshuai---date:2026-03-02---for:兼容图生图模型测试---
String modelName = airagModel.getModelName();
if(ImageEditEnum.isImageEditModel(modelName)){
List<String> images = new ArrayList<>();
images.add("https://jeecgdev.oss-cn-beijing.aliyuncs.com/upload/test/jeecg_1772268161540.jpg");
aiChatHandler.imageEdit(airagModel, "Generate a picture of a cartoon cat", images,aiChatParams);
}else{
aiChatHandler.imageGenerate(airagModel, "Generate a picture of a cartoon cat", aiChatParams);
}
//update-end---author:wangshuai---date:2026-03-02---for:兼容图生图模型测试---
}
//update-end---author:wangshuai---date:2026-01-07---for:【QQYUN-12145】【AI】AI 绘画创作---
}catch (Exception e){

View File

@@ -71,26 +71,25 @@ public class TikaDocumentParser {
public Document parse(File file) {
AssertUtils.assertNotEmpty("请选择文件", file);
try {
// 使用 Tika 自动检测 MIME 类型
String fileName = file.getName().toLowerCase();
//后缀
String ext = FilenameUtils.getExtension(fileName);
if (fileName.endsWith(".txt")
|| fileName.endsWith(".md")
|| fileName.endsWith(".pdf")) {
// 用于解析(使用FileInputStream避免file.toPath()在Linux非UTF-8环境下中文文件名报错)
InputStream isForParsing = new FileInputStream(file);
// 使用 Tika 自动检测 MIME 类型
String fileName = file.getName().toLowerCase();
//后缀
String ext = FilenameUtils.getExtension(fileName);
if (fileName.endsWith(".txt")
|| fileName.endsWith(".md")
|| fileName.endsWith(".pdf")) {
try (InputStream isForParsing = new FileInputStream(file)) {
return extractByTika(isForParsing);
//update-begin---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else if (FILE_SUFFIX.contains(ext.toLowerCase())) {
return parseDocExcelPdfUsingApachePoi(file);
//update-end---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else {
throw new IllegalArgumentException("不支持的文件格式: " + FilenameUtils.getExtension(fileName));
} catch (IOException e) {
throw new RuntimeException(e);
}
} catch (IOException e) {
throw new RuntimeException(e);
//update-begin---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else if (FILE_SUFFIX.contains(ext.toLowerCase())) {
return parseDocExcelPdfUsingApachePoi(file);
//update-end---author:wangshuai---date:2026-01-09---for:【QQYUN-14261】【AI】AI助手支持多模态能力- 文档---
} else {
throw new IllegalArgumentException("不支持的文件格式: " + FilenameUtils.getExtension(fileName));
}
}

View File

@@ -0,0 +1,254 @@
package org.jeecg.modules.airag.llm.document;
import lombok.extern.slf4j.Slf4j;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import org.jsoup.select.Elements;
import java.io.IOException;
/**
* 网页解析器使用Jsoup爬取网页并转换为Markdown格式
*
* @author sjlei
* @date 2026/3/19
*/
@Slf4j
public class WebPageParser {
/**
* 请求超时时间(毫秒)
*/
private static final int TIMEOUT_MS = 15000;
/**
* 最大body大小(5MB)
*/
private static final int MAX_BODY_SIZE = 5 * 1024 * 1024;
/**
* User-Agent
*/
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";
/**
* 爬取网页并转换为Markdown
*
* @param url 网页URL
* @return Markdown格式的文本内容
* @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();
// 移除脚本、样式、导航、页脚等无关元素
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();
// 优先提取正文区域
Element body = extractMainContent(doc);
StringBuilder markdown = new StringBuilder();
// 提取页面标题
String title = doc.title();
if (title != null && !title.trim().isEmpty()) {
markdown.append("# ").append(title.trim()).append("\n\n");
}
// 将HTML转为Markdown
convertToMarkdown(body, markdown);
return cleanMarkdown(markdown.toString());
}
/**
* 提取正文区域优先使用article/main标签否则使用body
*/
private Element extractMainContent(Document doc) {
// 按优先级尝试获取正文容器
String[] selectors = {"article", "main", "[role=main]", ".content", ".post-content", ".article-content", ".entry-content", "#content"};
for (String selector : selectors) {
Elements elements = doc.select(selector);
if (!elements.isEmpty() && elements.first().text().length() > 100) {
return elements.first();
}
}
return doc.body() != null ? doc.body() : doc;
}
/**
* 递归将HTML元素转换为Markdown
*/
private void convertToMarkdown(Element element, StringBuilder sb) {
for (Node child : element.childNodes()) {
if (child instanceof TextNode) {
String text = ((TextNode) child).text().trim();
if (!text.isEmpty()) {
sb.append(text);
}
} else if (child instanceof Element) {
Element el = (Element) child;
String tagName = el.tagName().toLowerCase();
switch (tagName) {
case "h1":
sb.append("\n\n# ").append(el.text().trim()).append("\n\n");
break;
case "h2":
sb.append("\n\n## ").append(el.text().trim()).append("\n\n");
break;
case "h3":
sb.append("\n\n### ").append(el.text().trim()).append("\n\n");
break;
case "h4":
sb.append("\n\n#### ").append(el.text().trim()).append("\n\n");
break;
case "h5":
sb.append("\n\n##### ").append(el.text().trim()).append("\n\n");
break;
case "h6":
sb.append("\n\n###### ").append(el.text().trim()).append("\n\n");
break;
case "p":
sb.append("\n\n");
convertToMarkdown(el, sb);
sb.append("\n\n");
break;
case "br":
sb.append("\n");
break;
case "strong":
case "b":
sb.append("**").append(el.text().trim()).append("**");
break;
case "em":
case "i":
sb.append("*").append(el.text().trim()).append("*");
break;
case "code":
sb.append("`").append(el.text()).append("`");
break;
case "pre":
sb.append("\n\n```\n").append(el.text()).append("\n```\n\n");
break;
case "a":
String href = el.attr("abs:href");
String linkText = el.text().trim();
if (!linkText.isEmpty() && !href.isEmpty()) {
sb.append("[").append(linkText).append("](").append(href).append(")");
} else if (!linkText.isEmpty()) {
sb.append(linkText);
}
break;
case "img":
String src = el.attr("abs:src");
String alt = el.attr("alt");
// 只保留http(s)开头的真实图片URL过滤掉base64内联图片
if (!src.isEmpty() && src.startsWith("http")) {
sb.append("![").append(alt != null ? alt : "").append("](").append(src).append(")");
}
break;
case "ul":
sb.append("\n");
for (Element li : el.children()) {
if ("li".equals(li.tagName())) {
sb.append("- ").append(li.text().trim()).append("\n");
}
}
sb.append("\n");
break;
case "ol":
sb.append("\n");
int idx = 1;
for (Element li : el.children()) {
if ("li".equals(li.tagName())) {
sb.append(idx++).append(". ").append(li.text().trim()).append("\n");
}
}
sb.append("\n");
break;
case "blockquote":
String[] lines = el.text().trim().split("\n");
sb.append("\n");
for (String line : lines) {
sb.append("> ").append(line).append("\n");
}
sb.append("\n");
break;
case "table":
convertTableToMarkdown(el, sb);
break;
case "hr":
sb.append("\n\n---\n\n");
break;
case "div":
case "section":
case "span":
case "figure":
case "figcaption":
convertToMarkdown(el, sb);
break;
default:
convertToMarkdown(el, sb);
break;
}
}
}
}
/**
* 将HTML表格转为Markdown表格
*/
private void convertTableToMarkdown(Element table, StringBuilder sb) {
Elements rows = table.select("tr");
if (rows.isEmpty()) {
return;
}
sb.append("\n\n");
boolean headerDone = false;
for (Element row : rows) {
Elements cells = row.select("th, td");
if (cells.isEmpty()) {
continue;
}
sb.append("|");
for (Element cell : cells) {
sb.append(" ").append(cell.text().trim()).append(" |");
}
sb.append("\n");
// 在第一行后添加分隔线
if (!headerDone) {
sb.append("|");
for (int i = 0; i < cells.size(); i++) {
sb.append(" --- |");
}
sb.append("\n");
headerDone = true;
}
}
sb.append("\n");
}
/**
* 清理Markdown文本去除多余空行、首尾空白
*/
private String cleanMarkdown(String markdown) {
// 去除连续3个以上换行为2个换行
markdown = markdown.replaceAll("\n{3,}", "\n\n");
// 去除行首尾空白(保留换行)
markdown = markdown.replaceAll("(?m)^[ \t]+|[ \t]+$", "");
return markdown.trim();
}
}

View File

@@ -109,4 +109,11 @@ public class AiragKnowledge implements Serializable {
@Excel(name="类型(knowledge知识 memory 记忆)", width = 15)
@Schema(description = "类型(knowledge知识 memory 记忆)")
private java.lang.String type;
/**
* 元数据
*/
@Excel(name = "元数据", width = 15)
@Schema(description = "元数据")
private java.lang.String metadata;
}

View File

@@ -3,7 +3,6 @@ package org.jeecg.modules.airag.llm.handler;
import com.alibaba.fastjson.JSONObject;
import dev.langchain4j.agent.tool.ToolSpecification;
import dev.langchain4j.data.message.*;
import dev.langchain4j.exception.InvalidRequestException;
import dev.langchain4j.exception.ToolExecutionException;
import dev.langchain4j.mcp.McpToolProvider;
import dev.langchain4j.rag.query.router.QueryRouter;
@@ -11,10 +10,13 @@ import dev.langchain4j.service.TokenStream;
import dev.langchain4j.service.tool.ToolExecutor;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.ai.handler.LLMHandler;
import org.jeecg.common.exception.JeecgBootBizTipException;
import org.jeecg.common.exception.JeecgBootException;
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;
@@ -24,7 +26,6 @@ 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;
@@ -66,6 +67,9 @@ public class AIChatHandler implements IAIChatHandler {
@Value(value = "${jeecg.path.upload:}")
private String uploadpath;
@Autowired
private AiChatConfig aiChatConfig;
/**
* 问答
*
@@ -99,7 +103,7 @@ public class AIChatHandler implements IAIChatHandler {
AssertUtils.assertNotEmpty("请选择模型", modelId);
AiragModel airagModel = airagModelMapper.getByIdIgnoreTenant(modelId);
AssertUtils.assertSame("模型未激活,请先在[AI模型配置]中[测试激活]模型", airagModel.getActivateFlag(), 1);
//AssertUtils.assertSame("模型未激活,请先在[AI模型配置]中[测试激活]模型", airagModel.getActivateFlag(), 1);
return completions(airagModel, messages, params);
}
@@ -115,39 +119,22 @@ public class AIChatHandler implements IAIChatHandler {
*/
public String completions(AiragModel airagModel, List<ChatMessage> messages, AIChatParams params) {
params = mergeParams(airagModel, params);
String resp;
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
messages = injectThinkingPlaceholderIfNeeded(messages, airagModel.getModelName());
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
String resp = null;
try {
resp = llmHandler.completions(messages, params);
} catch (ToolExecutionException | InvalidRequestException e) {
log.error(e.getMessage(), e);
} catch (ToolExecutionException e) {
// 工具调用执行失败:先用 matchErrorMsg 翻译 cause再拼装友好提示
String causeMsg = e.getCause() != null ? e.getCause().getMessage() : e.getMessage();
causeMsg = matchErrorMsg(causeMsg, causeMsg);
log.error("AI工具执行异常 - {}", causeMsg, e);
return "";
} catch (Exception e) {
// langchain4j 异常友好提示
String errMsg = "调用大模型接口失败,详情请查看后台日志。";
if (oConvertUtils.isNotEmpty(e.getMessage())) {
String exceptionMsg = e.getMessage();
// 检查是否是工具调用消息序列不完整的异常
if (exceptionMsg.contains("messages with role 'tool' must be a response to a preceeding message with 'tool_calls'")) {
errMsg = "消息序列不完整,可能是因为历史消息数量设置过小导致工具调用上下文丢失。建议增加历史消息数量后重试。";
log.error("AI模型调用异常: 工具调用消息序列不完整,建议增加历史消息数量。异常详情: {}", exceptionMsg, e);
throw new JeecgBootException(errMsg);
}
// 根据常见异常关键字做细致翻译
for (Map.Entry<String, String> entry : MODEL_ERROR_MAP.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (exceptionMsg.contains(key)) {
errMsg = value;
break;
}
}
}
log.error("AI模型调用异常: {}", errMsg, e);
throw new JeecgBootException(errMsg);
throw translateLlmException(e, "调用大模型接口失败,详情请查看后台日志。");
}
if (resp.contains("</think>")
if (resp != null && resp.contains("</think>")
&& (null == params.getNoThinking() || params.getNoThinking())) {
String[] thinkSplit = resp.split("</think>");
resp = thinkSplit[thinkSplit.length - 1];
@@ -199,7 +186,13 @@ public class AIChatHandler implements IAIChatHandler {
AssertUtils.assertNotEmpty("请选择模型", modelId);
AiragModel airagModel = airagModelMapper.getByIdIgnoreTenant(modelId);
AssertUtils.assertSame("模型未激活,请先在[AI模型配置]中[测试激活]模型", airagModel.getActivateFlag(), 1);
//update-begin---author:wangshuai---date:2026-03-02---for:【QQYUN-14781】实现一个AI模型未激活或者不可用的情况直接使用平台底层配置的默认模型---
//未激活的模型走默认模型
if(null == airagModel || airagModel.getActivateFlag() == 0){
log.warn("模型未激活,采用默认模型");
return chatByDefaultModel(messages,params);
}
//update-end---author:wangshuai---date:2026-03-02---for:【QQYUN-14781】实现一个AI模型未激活或者不可用的情况直接使用平台底层配置的默认模型---
return chat(airagModel, messages, params);
}
@@ -215,9 +208,64 @@ public class AIChatHandler implements IAIChatHandler {
*/
private TokenStream chat(AiragModel airagModel, List<ChatMessage> messages, AIChatParams params) {
params = mergeParams(airagModel, params);
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
messages = injectThinkingPlaceholderIfNeeded(messages, airagModel.getModelName());
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
return llmHandler.chat(messages, params);
}
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
/**
* 当目标模型是 DeepSeek 推理模型(deepseek-v4-flash 等)时,
* 为历史 AI 消息(从 MessageHistory 重建出来的、thinking 字段为空的 AiMessage)注入占位 thinking。
*
* 原因DeepSeek 推理模型校验请求时要求每条 assistant 历史消息携带 reasoning_content 字段;
* langchain4j 的 sendThinking 仅在 AiMessage.thinking() 非空时才会注入 reasoning_content
* 而历史持久化层(MessageHistory)目前没有保存 reasoning_content重建出的 AiMessage thinking 始终为 null
* 导致 DeepSeek 返回 "The reasoning_content in the thinking mode must be passed back to the API."
*
* 临时方案:注入占位字符串让 langchain4j 通过 isNullOrEmpty 校验、把 reasoning_content 字段带上,
* 后续若 MessageHistory 升级支持持久化 reasoning_content 可去掉该兜底。
*
* @param messages 原始消息列表(可能为 null/空)
* @param modelName 目标模型名(用于判定是否需要注入)
* @return 处理后的新消息列表(若无需处理则原样返回)
* @author scott
* @date 2026-04-29
*/
private static List<ChatMessage> injectThinkingPlaceholderIfNeeded(List<ChatMessage> messages, String modelName) {
if (messages == null || messages.isEmpty()
|| !LLMConsts.isDeepSeekThinkingModel(modelName)) {
return messages;
}
List<ChatMessage> result = new ArrayList<>(messages.size());
int injected = 0;
for (ChatMessage msg : messages) {
if (msg instanceof AiMessage) {
AiMessage aiMsg = (AiMessage) msg;
if (oConvertUtils.isEmpty(aiMsg.thinking())) {
AiMessage rebuilt = AiMessage.builder()
.text(aiMsg.text())
// 占位:让 langchain4j 把 reasoning_content 字段带上以满足 DeepSeek 校验
.thinking("...")
.toolExecutionRequests(aiMsg.toolExecutionRequests())
.attributes(aiMsg.attributes())
.build();
result.add(rebuilt);
injected++;
continue;
}
}
result.add(msg);
}
if (injected > 0) {
log.info("[AI-CHAT][issues/9585] 为 DeepSeek 推理模型[{}]的 {} 条历史 AI 消息注入了占位 thinking",
modelName, injected);
}
return result;
}
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
/**
* 使用默认模型聊天
*
@@ -256,6 +304,11 @@ public class AIChatHandler implements IAIChatHandler {
JSONObject modelCredential = JSONObject.parseObject(airagModel.getCredential());
params.setApiKey(oConvertUtils.getString(modelCredential.getString("apiKey"), null));
params.setSecretKey(oConvertUtils.getString(modelCredential.getString("secretKey"), null));
boolean httpVersionOne = false;
if(modelCredential.containsKey("httpVersionOne")){
httpVersionOne = modelCredential.getInteger("httpVersionOne") == 1;
}
params.setIzHttpVersionOne(httpVersionOne);
}
if (oConvertUtils.isObjectNotEmpty(airagModel.getModelParams())) {
JSONObject modelParams = JSONObject.parseObject(airagModel.getModelParams());
@@ -280,6 +333,11 @@ public class AIChatHandler implements IAIChatHandler {
if (oConvertUtils.isObjectEmpty(params.getEnableSearch())) {
params.setEnableSearch(modelParams.getBoolean("enableSearch"));
}
//update-begin---author:wangshuai---date:2026-03-20---for:【issues/8】保存激活qwen-vl-ocr模型报错---
if (oConvertUtils.isObjectEmpty(params.getExtraParams()) && modelParams.containsKey("extraParams")) {
params.setExtraParams(modelParams.getObject("extraParams", Map.class));
}
//update-end---author:wangshuai---date:2026-03-20---for:【issues/8】保存激活qwen-vl-ocr模型报错---
}
// RAG
@@ -306,6 +364,28 @@ public class AIChatHandler implements IAIChatHandler {
buildPlugins(params);
}
//update-begin---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
// 仅对 DeepSeek 推理模型(deepseek-reasoner/deepseek-v4-flash 等)开启思考过程的捕获与回传,
// 否则 deepseek-v4-flash 在工具调用多轮对话中会返回:
// "The reasoning_content in the thinking mode must be passed back to the API."
// 注意:不要对 deepseek-chat 等非推理模型开启,避免无意义的请求字段污染。
boolean isDsThinking = LLMConsts.isDeepSeekThinkingModel(modelName);
log.info("[AI-CHAT][issues/9585] mergeParams provider={}, modelName={}, isDeepSeekThinkingModel={}, params.returnThinking={}, params.sendThinking={}",
airagModel.getProvider(), modelName, isDsThinking, params.getReturnThinking(), params.getSendThinking());
if (isDsThinking) {
// returnThinking: 把响应中的 reasoning_content 解析到 AiMessage.thinking
if (null == params.getReturnThinking()) {
params.setReturnThinking(true);
}
// sendThinking: 把 AiMessage.thinking 以 reasoning_content 字段回传到下次请求
if (null == params.getSendThinking()) {
params.setSendThinking(true);
}
log.info("[AI-CHAT][issues/9585] mergeParams after-fix returnThinking={}, sendThinking={}",
params.getReturnThinking(), params.getSendThinking());
}
//update-end---author:scott ---date:20260429 for[issues/9585]DeepSeek大模型切换为新发布deepseek-v4-flash流程中调用出现异常------------
return params;
}
@@ -435,7 +515,7 @@ public class AIChatHandler implements IAIChatHandler {
@Override
public List<Map<String, Object>> imageGenerate(String modelId, String messages, AIChatParams params) {
AssertUtils.assertNotEmpty("至少发送一条消息", messages);
AssertUtils.assertNotEmpty("请选择图片大模型", modelId);
//AssertUtils.assertNotEmpty("请选择图片大模型", modelId);
AiragModel airagModel = airagModelMapper.getByIdIgnoreTenant(modelId);
return this.imageGenerate(airagModel, messages, params);
}
@@ -449,59 +529,63 @@ public class AIChatHandler implements IAIChatHandler {
* @return
*/
public List<Map<String, Object>> imageGenerate(AiragModel airagModel, String messages, AIChatParams params) {
if(airagModel == null || (airagModel.getActivateFlag()!=null && airagModel.getActivateFlag() == 0)){
if (airagModel != null && oConvertUtils.isNotEmpty(airagModel.getId())) {
log.warn("模型未激活,采用默认文生图模型");
}
//判断是否配置了默认模型
if(aiChatConfig == null || oConvertUtils.isEmpty(aiChatConfig.getAiModelDraw().getApiKey())){
throw new JeecgBootBizTipException("当前系统未配置默认图像模型请前往yml中配置默认模型");
}
airagModel = this.getDefaultDrawModel(aiChatConfig.getAiModelDraw());
}
params = mergeParams(airagModel, params);
try {
return llmHandler.imageGenerate(messages, params);
} catch (Exception e) {
String errMsg = "调用绘画AI接口失败详情请查看后台日志。";
if (oConvertUtils.isNotEmpty(e.getMessage())) {
// 根据常见异常关键字做细致翻译
for (Map.Entry<String, String> entry : MODEL_ERROR_MAP.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (e.getMessage().contains(key)) {
errMsg = value;
break;
}
}
}
log.error("AI模型调用异常: {}", errMsg, e);
throw new JeecgBootException(errMsg);
throw translateLlmException(e, "调用绘画AI接口失败详情请查看后台日志。");
}
}
/**
* 图生图
*
* @param modelId
* @param messages
* @param images
* @param params
* @return
*/
@Override
public List<Map<String, Object>> imageEdit(String modelId, String messages, List<String> images, AIChatParams params) {
AssertUtils.assertNotEmpty("至少发送一条消息", messages);
AiragModel airagModel = airagModelMapper.getByIdIgnoreTenant(modelId);
return this.imageEdit(airagModel, messages, images, params);
}
/**
* 图生图
*
* @param images
* @param params
* @return
*/
public List<Map<String, Object>> imageEdit(AiragModel airagModel,String messages, List<String> images, AIChatParams params) {
if(null == airagModel || airagModel.getActivateFlag() == 0){
if (airagModel != null && oConvertUtils.isNotEmpty(airagModel.getId())) {
log.warn("模型未激活,采用默认图生图模型");
}
//判断是否配置了默认模型
if(aiChatConfig == null || oConvertUtils.isEmpty(aiChatConfig.getAiModelPicDraw().getApiKey())){
throw new JeecgBootBizTipException("当前系统未配置默认图像模型请前往yml中配置默认模型");
}
airagModel = this.getDefaultDrawModel(aiChatConfig.getAiModelPicDraw());
}
params = mergeParams(airagModel, params);
List<String> originalImageBase64List = getFirstImageBase64(images);
try {
return llmHandler.imageEdit(messages, originalImageBase64List, params);
} catch (Exception e) {
String errMsg = "调用绘画AI接口失败详情请查看后台日志。";
if (oConvertUtils.isNotEmpty(e.getMessage())) {
// 根据常见异常关键字做细致翻译
for (Map.Entry<String, String> entry : MODEL_ERROR_MAP.entrySet()) {
String key = entry.getKey();
String value = entry.getValue();
if (errMsg.contains(key)) {
errMsg = value;
break;
}
}
}
log.error("AI模型调用异常: {}", errMsg, e);
throw new JeecgBootException(errMsg);
throw translateLlmException(e, "调用绘画AI接口失败详情请查看后台日志。");
}
}
@@ -534,11 +618,18 @@ public class AIChatHandler implements IAIChatHandler {
fileContent = buffer.toByteArray();
}
} else {
//update-begin---author:liusq ---date:2026-03-30 for【issues/9431】修复getFirstImageBase64路径遍历漏洞(CWE-22)-----------
// 本地文件
String filePath = uploadpath + File.separator + imageUrl;
SsrfFileTypeFilter.checkPathTraversal(filePath);
Path path = Paths.get(filePath);
fileContent = Files.readAllBytes(path);
// 路径遍历校验规范化后确保文件在uploadpath目录内
File uploadDir = new File(uploadpath).getCanonicalFile();
File targetFile = new File(filePath).getCanonicalFile();
if (!targetFile.toPath().startsWith(uploadDir.toPath())) {
throw new JeecgBootException("非法文件路径,禁止访问上传目录之外的文件: " + imageUrl);
}
fileContent = Files.readAllBytes(targetFile.toPath());
//update-end---author:liusq ---date:2026-03-30 for【issues/9431】修复getFirstImageBase64路径遍历漏洞(CWE-22)-----------
}
originalImageBase64List.add(Base64.getEncoder().encodeToString(fileContent));
} catch (Exception e) {
@@ -550,4 +641,57 @@ public class AIChatHandler implements IAIChatHandler {
return originalImageBase64List;
}
//================================================= end 【QQYUN-12145】【AI】AI 绘画创作 ========================================
/**
* 将 LLM 调用异常统一翻译为友好的 JeecgBootException。
* <p>
* 处理优先级:
* <ol>
* <li>请求超时timeout→ 排队提示</li>
* <li>工具调用上下文丢失messages with role 'tool'…)→ 友好提示</li>
* <li>{@link IAIChatHandler#MODEL_ERROR_MAP} 中的关键字匹配 → 对应中文提示</li>
* <li>兜底 → defaultMsg 参数</li>
* </ol>
*
* @param e 原始异常
* @param defaultMsg 兜底提示语
* @return 封装后的 JeecgBootException供调用方直接 throw
* @author chenrui
* @date 2025/3/5
*/
private JeecgBootException translateLlmException(Exception e, String defaultMsg) {
String exceptionMsg = e.getMessage();
String errMsg = defaultMsg;
if (oConvertUtils.isNotEmpty(exceptionMsg)) {
// 1.工具调用消息序列不完整
if (exceptionMsg.contains("messages with role 'tool' must be a response to a preceeding message with 'tool_calls'")) {
errMsg = "消息序列不完整,可能是因为历史消息数量设置过小导致工具调用上下文丢失。建议增加历史消息数量后重试。";
log.error("AI模型调用异常: 工具调用消息序列不完整,建议增加历史消息数量。异常详情: {}", exceptionMsg, e);
return new JeecgBootException(errMsg);
}
// 2.根据常见异常关键字做细致翻译(大小写不敏感)
errMsg = matchErrorMsg(exceptionMsg, errMsg);
}
log.error("AI模型调用异常: {}", errMsg, e);
return new JeecgBootException(errMsg);
}
/**
* 获取默认图像模型
*
* @return
*/
private AiragModel getDefaultDrawModel(AiChatConfig.ModelConfig aiModelDraw) {
AiragModel airagModel = new AiragModel();
airagModel.setModelName(aiModelDraw.getModel());
airagModel.setBaseUrl(aiModelDraw.getApiHost());
airagModel.setProvider(aiModelDraw.getProvider());
JSONObject credentialObject = new JSONObject();
credentialObject.put("apiKey",aiModelDraw.getApiKey());
airagModel.setCredential(credentialObject.toJSONString());
return airagModel;
}
}

View File

@@ -5,9 +5,7 @@ import org.apache.commons.lang.ArrayUtils;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.regex.Pattern;
/**
* @Description: 命令行执行工具类
@@ -35,6 +33,42 @@ public class CommandExecUtil {
return execCommand(command.split(" "), args);
}
/**
* 禁止在参数中出现的 Shell 元字符(适用于 Windows cmd 和 Unix shell
*/
private static final Pattern SHELL_INJECTION_PATTERN =
Pattern.compile("[&|;<>`$!\\\\\\r\\n]");
/**
* 禁止文件名中出现的危险字符(防止通过文件名注入命令)
*/
private static final Pattern FILENAME_INJECTION_PATTERN =
Pattern.compile("[&|;<>`$!\"'\\r\\n]");
/**
* 校验单个命令参数,拒绝包含 Shell 注入字符的参数
*
* @param arg 待校验参数
* @throws IllegalArgumentException 若参数包含危险字符
*/
public static void validateArg(String arg) {
if (arg != null && SHELL_INJECTION_PATTERN.matcher(arg).find()) {
throw new IllegalArgumentException("命令参数包含非法字符,已拒绝执行: " + arg);
}
}
/**
* 校验文件路径,拒绝包含危险字符(防止文件名注入)
*
* @param filePath 待校验文件路径
* @throws IllegalArgumentException 若文件路径包含危险字符
*/
public static void validateFilePath(String filePath) {
if (filePath != null && FILENAME_INJECTION_PATTERN.matcher(filePath).find()) {
throw new IllegalArgumentException("文件路径包含非法字符,已拒绝处理: " + filePath);
}
}
/**
* 执行命令行
*
@@ -50,22 +84,17 @@ public class CommandExecUtil {
}
if (null != args && args.length > 0) {
// 校验每一个用户可控的参数,防止命令注入
for (String arg : args) {
validateArg(arg);
}
command = (String[]) ArrayUtils.addAll(command, args);
}
// windows系统处理文件夹空格问题
if (System.getProperty("os.name").toLowerCase().startsWith("windows")) {
List<String> commandNew = new ArrayList<>(command.length + 2);
commandNew.addAll(Arrays.asList("cmd.exe", "/c"));
for (String tempCommand : command) {
if (tempCommand.contains(" ")) {
tempCommand = "\"" + tempCommand.replaceAll("\"", "'") + "\"";
}
commandNew.add(tempCommand);
}
command = commandNew.toArray(new String[0]);
}
// 直接使用 ProcessBuilder不经过系统 Shell防止 Shell 注入)
// 注意:不再使用 cmd.exe /c 或 /bin/sh -c参数数组由 JVM 直接传递给操作系统
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(false);
Process process = null;
try {

View File

@@ -27,6 +27,10 @@ import org.apache.tika.parser.AutoDetectParser;
import org.jeecg.ai.factory.AiModelFactory;
import org.jeecg.ai.factory.AiModelOptions;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.util.filter.SsrfFileTypeFilter;
import java.nio.file.Path;
import java.nio.file.Paths;
import org.jeecg.config.AiChatConfig;
import org.jeecg.common.system.util.JwtUtil;
import org.jeecg.common.util.*;
import org.jeecg.modules.airag.common.handler.IEmbeddingHandler;
@@ -35,12 +39,14 @@ import org.jeecg.modules.airag.llm.config.EmbedStoreConfigBean;
import org.jeecg.modules.airag.llm.config.KnowConfigBean;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import org.jeecg.modules.airag.llm.document.TikaDocumentParser;
import org.jeecg.modules.airag.llm.document.WebPageParser;
import org.jeecg.modules.airag.llm.entity.AiragKnowledge;
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
import org.jeecg.modules.airag.llm.entity.AiragModel;
import org.jeecg.modules.airag.llm.mapper.AiragKnowledgeMapper;
import org.jeecg.modules.airag.llm.mapper.AiragModelMapper;
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeService;
import org.jeecg.modules.airag.llm.splitter.CustomDocumentSplitter;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
@@ -88,6 +94,9 @@ public class EmbeddingHandler implements IEmbeddingHandler {
@Autowired
KnowConfigBean knowConfigBean;
@Autowired(required = false)
private AiChatConfig aiChatConfig;
/**
* 默认分段长度
*/
@@ -140,6 +149,13 @@ public class EmbeddingHandler implements IEmbeddingHandler {
*/
private static final Pattern PATTERN_MD_IMAGE = Pattern.compile("!\\[(.*?)]\\((.*?)\\)");
//update-begin---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格向量化分段时被截断修复-----------
/**
* 正则匹配: HTML表格完整块跨行大小写不敏感
*/
private static final Pattern PATTERN_HTML_TABLE = Pattern.compile("(?is)<table\\b.*?</table>");
//update-end---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格向量化分段时被截断修复-----------
/**
* 向量化文档
*
@@ -167,7 +183,9 @@ public class EmbeddingHandler implements IEmbeddingHandler {
content = parseFile(doc);
break;
case KNOWLEDGE_DOC_TYPE_WEB:
// TODO author: chenrui for:读取网站内容 date:2025/2/18
content = parseWebPage(doc);
// 将解析的网页内容回写到文档,便于后续查看
doc.setContent(content);
break;
}
}
@@ -185,7 +203,7 @@ public class EmbeddingHandler implements IEmbeddingHandler {
// 删除旧数据
embeddingStore.removeAll(metadataKey(EMBED_STORE_METADATA_DOCID).isEqualTo(doc.getId()));
// 分段器
DocumentSplitter splitter = DocumentSplitters.recursive(DEFAULT_SEGMENT_SIZE, DEFAULT_OVERLAP_SIZE);
DocumentSplitter splitter = createDocumentSplitter(doc);
// 分段并存储
EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder()
.documentSplitter(splitter)
@@ -215,17 +233,155 @@ public class EmbeddingHandler implements IEmbeddingHandler {
}
//update-end---author:wangshuai---date:2025-12-26---for:【QQYUN-14265】【AI】支持记忆---
Document from = Document.from(content, metadata);
//update-begin---author:jeecg---date:2026-02-26---for:[#9374]【AI知识库】千帆向量报错添加异常处理防止空指针
try {
ingestor.ingest(from);
} catch (Exception e) {
log.error("向量存储失败,请检查向量模型配置是否正确", e);
throw new JeecgBootException("向量存储失败:" + e.getMessage());
//update-begin---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格分段时被截断保留完整表格块-----------
boolean hasHtmlTable = content != null && PATTERN_HTML_TABLE.matcher(content).find();
if (hasHtmlTable) {
try {
List<TextSegment> segments = splitDocumentPreservingHtmlTables(from, splitter);
List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
embeddingStore.addAll(embeddings, segments);
} catch (Exception e) {
log.error("向量存储失败,请检查向量模型配置是否正确", e);
throw new JeecgBootException("向量存储失败:" + e.getMessage());
}
} else {
//update-begin---author:jeecg---date:2026-02-26---for:[#9374]【AI知识库】千帆向量报错添加异常处理防止空指针
try {
ingestor.ingest(from);
} catch (Exception e) {
log.error("向量存储失败,请检查向量模型配置是否正确", e);
throw new JeecgBootException("向量存储失败:" + e.getMessage());
}
//update-end---author:jeecg---date:2026-02-26---for:[#9374]【AI知识库】千帆向量报错添加异常处理防止空指针
}
//update-end---author:jeecg---date:2026-02-26---for:[#9374]【AI知识库】千帆向量报错添加异常处理防止空指针
//update-end---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格分段时被截断保留完整表格块-----------
return metadata.toMap();
}
/**
* 创建分段器
*
* @param doc
* @return
*/
private DocumentSplitter createDocumentSplitter(AiragKnowledgeDoc doc) {
DocumentSplitter splitter = null;
int maxSegment = DEFAULT_SEGMENT_SIZE;
int overlapSize = DEFAULT_OVERLAP_SIZE;
if (oConvertUtils.isNotEmpty(doc.getMetadata())) {
try {
JSONObject json = JSONObject.parseObject(doc.getMetadata());
//update-begin---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
// 文档使用知识库默认分段策略:读取知识库自身的 metadata 来决定分段方式
Boolean useKnowledgeDefault = json.getBoolean(LLMConsts.USE_KNOWLEDGE_DEFAULT);
if (Boolean.TRUE.equals(useKnowledgeDefault)) {
if (oConvertUtils.isNotEmpty(doc.getKnowledgeId())) {
AiragKnowledge knowledge = airagKnowledgeMapper.selectById(doc.getKnowledgeId());
if (knowledge != null && oConvertUtils.isNotEmpty(knowledge.getMetadata())) {
// 用知识库的 metadata 覆盖,后续逻辑统一处理
json = JSONObject.parseObject(knowledge.getMetadata());
} else {
// 知识库没有配置分段策略,使用默认分段器
return DocumentSplitters.recursive(maxSegment, overlapSize);
}
} else {
return DocumentSplitters.recursive(maxSegment, overlapSize);
}
}
//update-end---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
Object segmentStrategy = json.get(LLMConsts.SEGMENT_STRATEGY);
//update-begin---author:wangshuai ---date:2026-04-09 for【issue/9418】AI知识库上传文件太大向量化失败-----------
// 1. 不论策略是auto还是custom优先使用前端传入的分段大小和重叠度
Integer sizeObj = json.getInteger(LLMConsts.MAX_SEGMENT);
if (sizeObj != null && sizeObj > 0) {
maxSegment = sizeObj;
}
Double overlapObj = json.getDouble(LLMConsts.OVERLAP);
if (overlapObj != null && overlapObj >= 0) {
double rate = overlapObj / 100;
overlapSize = (int) (maxSegment * rate);
}
if(segmentStrategy != null && LLMConsts.SEGMENT_STRATEGY_CUSTOM.equals(segmentStrategy.toString())){
//update-end---author:wangshuai ---date:2026-04-09 for【issue/9418】AI知识库上传文件太大向量化失败-----------
String splitChar = json.getString(LLMConsts.SEPARATOR);
if (oConvertUtils.isNotEmpty(splitChar)) {
//自定义
if(LLMConsts.SEGMENT_STRATEGY_CUSTOM.equals(splitChar)){
splitChar = oConvertUtils.getString(json.getString(LLMConsts.CUSTOM_SEPARATOR),"\n");
}
// 处理转义字符
splitChar = splitChar.replace("\\n", "\n").replace("\\t", "\t").replace("\\r", "\r");
String textRules = json.getString(LLMConsts.TEXT_RULES);
splitter = new CustomDocumentSplitter(textRules, splitChar, maxSegment, overlapSize);
}
}
} catch (Exception e) {
log.warn("解析自定义分词配置失败: {}", e.getMessage());
}
}
if (splitter == null) {
splitter = DocumentSplitters.recursive(maxSegment, overlapSize);
}
return splitter;
}
//update-begin---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格分段时被截断新增保留表格完整性的分段辅助方法-----------
/**
* 按 HTML 表格边界分段:表格块完整保留,表格外文本交给 splitter 正常分段
*/
public static List<TextSegment> splitDocumentPreservingHtmlTables(Document document, DocumentSplitter splitter) {
String text = document.text();
Metadata metadata = document.metadata();
List<TextSegment> result = new ArrayList<>();
Matcher matcher = PATTERN_HTML_TABLE.matcher(text);
int lastEnd = 0;
while (matcher.find()) {
String before = text.substring(lastEnd, matcher.start());
if (!before.isBlank()) {
appendSplitText(before, metadata, splitter, result);
}
appendSegment(matcher.group(), metadata, result);
lastEnd = matcher.end();
}
String remaining = text.substring(lastEnd);
if (!remaining.isBlank()) {
appendSplitText(remaining, metadata, splitter, result);
}
reindexSegments(result);
return result;
}
/**
* 将非表格文本交给 splitter 分段后追加到 result
*/
public static void appendSplitText(String text, Metadata metadata, DocumentSplitter splitter, List<TextSegment> result) {
List<TextSegment> segments = splitter.split(Document.from(text, metadata));
result.addAll(segments);
}
/**
* 将文本作为单个完整段追加到 result不经过分段器用于保留完整表格块
*/
public static void appendSegment(String text, Metadata metadata, List<TextSegment> result) {
result.add(TextSegment.from(text, metadata));
}
/**
* 为分段列表的 metadata 写入从 0 开始的连续 index供检索时标识顺序
*/
public static void reindexSegments(List<TextSegment> segments) {
for (int i = 0; i < segments.size(); i++) {
segments.get(i).metadata().put("index", String.valueOf(i));
}
}
//update-end---author:wangshuai ---date:2026-04-20 for【issues/9551】HTML表格分段时被截断新增保留表格完整性的分段辅助方法-----------
/**
* 向量查询(多知识库)
*
@@ -465,7 +621,7 @@ public class EmbeddingHandler implements IEmbeddingHandler {
}
/**
* 查询向量模型数据
* 查询向量模型数据,若未指定或不存在则回退到 yml 中配置的默认向量模型
*
* @param modelId
* @return
@@ -473,10 +629,43 @@ public class EmbeddingHandler implements IEmbeddingHandler {
* @date 2025/2/20 20:08
*/
private AiragModel getEmbedModelData(String modelId) {
AssertUtils.assertNotEmpty("向量模型不能为空", modelId);
AiragModel model = airagModelMapper.getByIdIgnoreTenant(modelId);
AssertUtils.assertNotEmpty("向量模型不存在", model);
AssertUtils.assertEquals("仅支持向量模型", LLMConsts.MODEL_TYPE_EMBED, model.getModelType());
//update-begin---author:wangshuai---date:2026-03-09---for:【QQYUN-14645】添加默认向量模型---
if (oConvertUtils.isNotEmpty(modelId)) {
AiragModel model = airagModelMapper.getByIdIgnoreTenant(modelId);
if (model != null) {
AssertUtils.assertEquals("仅支持向量模型", LLMConsts.MODEL_TYPE_EMBED, model.getModelType());
// 判断模型是否已激活,未激活则回退到默认模型
if (model.getActivateFlag() != null && model.getActivateFlag() == 1) {
return model;
}
log.warn("向量模型[{}]未激活,尝试使用 yml 中配置的默认向量模型", modelId);
}
}
// 回退到 yml 默认向量模型
if (aiChatConfig != null && oConvertUtils.isNotEmpty(aiChatConfig.getAiModelEmbed().getApiKey())) {
log.info("使用 yml 中配置的默认向量模型: {}", aiChatConfig.getAiModelEmbed().getModel());
return buildDefaultEmbedModel(aiChatConfig.getAiModelEmbed());
}
AssertUtils.assertNotEmpty("向量模型不能为空,请先配置向量模型或在 yml 中设置默认向量模型(jeecg.ai-chat.ai-model-embed)", modelId);
return null;
//update-end---author:wangshuai---date:2026-03-09---for:【QQYUN-14645】添加默认向量模型---
}
/**
* 根据 yml 配置构建默认向量模型对象
*
* @param embedConfig yml 中的向量模型配置
* @return AiragModel
*/
private AiragModel buildDefaultEmbedModel(AiChatConfig.ModelConfig embedConfig) {
AiragModel model = new AiragModel();
model.setModelName(embedConfig.getModel());
model.setBaseUrl(embedConfig.getApiHost());
model.setProvider(embedConfig.getProvider());
model.setModelType(LLMConsts.MODEL_TYPE_EMBED);
JSONObject credential = new JSONObject();
credential.put("apiKey", embedConfig.getApiKey());
model.setCredential(credential.toJSONString());
return model;
}
@@ -556,12 +745,50 @@ public class EmbeddingHandler implements IEmbeddingHandler {
JSONObject modelCredential = JSONObject.parseObject(model.getCredential());
modelOpBuilder.apiKey(oConvertUtils.getString(modelCredential.getString("apiKey"), null));
modelOpBuilder.secretKey(oConvertUtils.getString(modelCredential.getString("secretKey"), null));
if(modelCredential.containsKey("httpVersionOne")){
modelOpBuilder.izHttpVersionOne(modelCredential.getInteger("httpVersionOne") == 1);
}
}
modelOpBuilder.topNumber(5);
modelOpBuilder.similarity(0.75);
return modelOpBuilder.build();
}
/**
* 解析网页内容使用Jsoup爬取并转换为Markdown
*
* @param doc 知识库文档metadata中需包含website字段
* @return Markdown格式的网页内容
* @date 2026/3/19
*/
private String parseWebPage(AiragKnowledgeDoc doc) {
String metadata = doc.getMetadata();
AssertUtils.assertNotEmpty("请先配置网页URL", metadata);
JSONObject metadataJson = JSONObject.parseObject(metadata);
String website = metadataJson.getString(LLMConsts.KNOWLEDGE_DOC_METADATA_WEBSITE);
AssertUtils.assertNotEmpty("请先配置网页URL", website);
Matcher matcher = LLMConsts.WEB_PATTERN.matcher(website);
if (!matcher.matches()) {
throw new JeecgBootException("网页URL格式不正确请以http://或https://开头");
}
try {
WebPageParser webPageParser = new WebPageParser();
String content = webPageParser.parseToMarkdown(website);
if (oConvertUtils.isEmpty(content)) {
throw new JeecgBootException("网页内容为空请检查URL是否可访问");
}
log.info("网页解析成功, URL: {}, 内容长度: {}", website, content.length());
return content;
} catch (JeecgBootException e) {
throw e;
} catch (Exception e) {
log.error("网页解析失败, URL: {}, 错误: {}", website, e.getMessage(), e);
throw new JeecgBootException("网页解析失败: " + e.getMessage());
}
}
/**
* 解析文件
*
@@ -669,9 +896,21 @@ public class EmbeddingHandler implements IEmbeddingHandler {
return ;
}
String command = "magic-pdf";
// 安全校验:拒绝文件名/路径中含有 Shell 注入字符的文件,防止命令注入
try {
CommandExecUtil.validateFilePath(docFile.getAbsolutePath());
CommandExecUtil.validateFilePath(docFile.getName());
} catch (IllegalArgumentException e) {
log.error("文件路径包含非法字符,拒绝执行 MinerU 解析: {}", e.getMessage());
throw new JeecgBootException("文件名包含非法字符,无法处理该文件");
}
// 使用 String[] 数组构建命令,避免 split(" ") 带来的参数边界问题
String[] command;
if (oConvertUtils.isNotEmpty(knowConfigBean.getCondaEnv())) {
command = "conda run -n " + knowConfigBean.getCondaEnv() + " " + command;
command = new String[]{"conda", "run", "-n", knowConfigBean.getCondaEnv(), "magic-pdf"};
} else {
command = new String[]{"magic-pdf"};
}
String outputPath = docFile.getParentFile().getAbsolutePath();
@@ -682,7 +921,7 @@ public class EmbeddingHandler implements IEmbeddingHandler {
try {
String execLog = CommandExecUtil.execCommand(command, args);
log.info("执行命令行:" + command + " args:" + Arrays.toString(args) + "\n log::" + execLog);
log.info("执行命令行:" + Arrays.toString(command) + " args:" + Arrays.toString(args) + "\n log::" + execLog);
// 如果成功,替换文件路径和静态资源路径
String fileBaseName = FilenameUtils.getBaseName(docFile.getName());
String newFileDir = outputPath + File.separator + fileBaseName + File.separator + "auto" + File.separator ;
@@ -724,8 +963,22 @@ public class EmbeddingHandler implements IEmbeddingHandler {
FileDownloadUtils.download2DiskFromNet(filePath, tempFilePath);
filePath = tempFilePath;
} else {
//本地文件
filePath = uploadpath + File.separator + filePath;
//update-begin---author:wangshuai---date:2026-03-30---for:【issues/9424】CommandExecUtil 命令执行过程中存在疑似路径遍历漏洞/【issues/9425】EmbeddingHandler 知识库解析过程中疑似存在路径遍历漏洞---
// 1. 路径遍历检查:拒绝 .. 和 %2e 等绕过手段
SsrfFileTypeFilter.checkPathTraversal(filePath);
// 2. 标准化路径并校验是否在 uploadpath 范围内
Path root = Paths.get(uploadpath).toAbsolutePath().normalize();
//update-begin---author:wangshuai ---date:2026-04-13 forzip文件 filePath 以 \ 或 / 开头在Windows下被Path.resolve当成驱动器根路径导致误判路径遍历先剥掉前导分隔符-----------
// 去除前导分隔符,保证作为相对路径 resolve 到 uploadpath 之下
String relativePath = filePath.replaceAll("^[\\\\/]+", "");
Path target = root.resolve(relativePath).toAbsolutePath().normalize();
//update-end---author:wangshuai ---date:2026-04-13 forzip文件 filePath 以 \ 或 / 开头在Windows下被Path.resolve当成驱动器根路径导致误判路径遍历先剥掉前导分隔符-----------
if (!target.startsWith(root)) {
log.error("检测到路径遍历攻击! filePath: {}, 解析后: {}", filePath, target);
throw new JeecgBootException("文件路径包含非法字符");
}
filePath = target.toString();
//update-end---author:wangshuai---date:2026-03-30---for:【issues/9424】CommandExecUtil 命令执行过程中存在疑似路径遍历漏洞/【issues/9425】EmbeddingHandler 知识库解析过程中疑似存在路径遍历漏洞---
}
return filePath;
}

View File

@@ -277,7 +277,15 @@ public class PluginToolBuilder {
Object value = args.get(paramName);
if (value != null) {
url = url.replace("{" + paramName + "}", value.toString());
//update-begin---author:wangshuai---date:2026-03-30---for:【issues/9421】buildUrl路径遍历漏洞修复---
String paramValue = value.toString();
// 防止路径遍历注入:拒绝包含 ..、/ 、\ 的路径参数
if (paramValue.contains("..") || paramValue.contains("/") || paramValue.contains("\\")
|| paramValue.toLowerCase().contains("%2e") || paramValue.toLowerCase().contains("%2f")) {
throw new IllegalArgumentException("Path参数包含非法字符: " + paramName);
}
url = url.replace("{" + paramName + "}", paramValue);
//update-end---author:wangshuai---date:2026-03-30---for:【issues/9421】buildUrl路径遍历漏洞修复---
}
}
}

View File

@@ -16,4 +16,13 @@ public interface IAiragFlowPluginService {
* @param flowIds 多个流程id
*/
Map<String, Object> getFlowsToPlugin(String flowIds);
/**
* 获取流程插件(携带应用上下文参数)
*
* @param flowIds 多个流程id
* @param appId 应用ID变量节点需要
* @param memoryId 记忆库ID记忆节点需要
*/
Map<String, Object> getFlowsToPlugin(String flowIds, String appId, String memoryId);
}

View File

@@ -1,45 +0,0 @@
package org.jeecg.modules.airag.llm.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.airag.api.IAiragBaseApi;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.exception.JeecgBootBizTipException;
import org.jeecg.common.util.AssertUtils;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import org.jeecg.modules.airag.llm.entity.AiragKnowledgeDoc;
import org.jeecg.modules.airag.llm.service.IAiragKnowledgeDocService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
/**
* airag baseAPI 实现类
*/
@Slf4j
@Primary
@Service("airagBaseApiImpl")
public class AiragBaseApiImpl implements IAiragBaseApi {
@Autowired
private IAiragKnowledgeDocService airagKnowledgeDocService;
@Override
public String knowledgeWriteTextDocument(String knowledgeId, String title, String content) {
AssertUtils.assertNotEmpty("知识库ID不能为空", knowledgeId);
AssertUtils.assertNotEmpty("写入内容不能为空", content);
AiragKnowledgeDoc knowledgeDoc = new AiragKnowledgeDoc();
knowledgeDoc.setKnowledgeId(knowledgeId);
knowledgeDoc.setTitle(title);
knowledgeDoc.setType(LLMConsts.KNOWLEDGE_DOC_TYPE_TEXT);
knowledgeDoc.setContent(content);
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());
return knowledgeDoc.getId();
}
}

View File

@@ -24,6 +24,8 @@ import org.jeecg.modules.airag.llm.service.IAiragFlowPluginService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
@@ -39,8 +41,17 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
@Autowired
private IAiragFlowService airagFlowService;
@Override
public Map<String, Object> getFlowsToPlugin(String flowIds, String appId, String memoryId) {
return doGetFlowsToPlugin(flowIds, appId, memoryId);
}
@Override
public Map<String, Object> getFlowsToPlugin(String flowIds) {
return doGetFlowsToPlugin(flowIds, null, null);
}
private Map<String, Object> doGetFlowsToPlugin(String flowIds, String appId, String memoryId) {
log.info("开始构建流程插件");
// 1. 查询所有启用的流程
LambdaQueryWrapper<AiragFlow> queryWrapper = new LambdaQueryWrapper<>();
@@ -85,8 +96,11 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
if (oConvertUtils.isNotEmpty(flow.getDescr())) {
description += " : " + flow.getDescr();
}
// 构建插件请求路径(携带应用上下文参数)
String pluginPath = FlowPluginContent.PLUGIN_REQUEST_URL + flow.getId();
pluginPath = appendContextParams(pluginPath, appId, memoryId);
//构造工具参数
String flowTool = buildParameter(parameter, outParams, flow.getId(), tool.getTools(), validToolName, description);
String flowTool = buildParameter(parameter, outParams, pluginPath, tool.getTools(), validToolName, description);
tool.setTools(flowTool);
toolCount++;
} catch (Exception e) {
@@ -125,17 +139,17 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
*
* @param parameter
* @param outParams
* @param flowId
* @param pluginPath 插件请求路径已包含appId等上下文参数
* @param tools
* @param description
* @param name
*/
private String buildParameter(JSONArray parameter, JSONArray outParams, String flowId, String tools, String name, String description) {
private String buildParameter(JSONArray parameter, JSONArray outParams, String pluginPath, String tools, String name, String description) {
JSONArray paramArray = new JSONArray();
JSONObject parameterObject = new JSONObject();
parameterObject.put(FlowPluginContent.NAME, name);
parameterObject.put(FlowPluginContent.DESCRIPTION, description);
parameterObject.put(FlowPluginContent.PATH, FlowPluginContent.PLUGIN_REQUEST_URL + flowId);
parameterObject.put(FlowPluginContent.PATH, pluginPath);
parameterObject.put(FlowPluginContent.METHOD, FlowPluginContent.POST);
parameterObject.put(FlowPluginContent.ENABLED, true);
parameterObject.put(FlowPluginContent.PARAMETERS, parameter);
@@ -149,6 +163,34 @@ public class AiragFlowPluginServiceImpl implements IAiragFlowPluginService {
return paramArray.toJSONString();
}
/**
* 将应用上下文参数追加到插件请求路径中
*
* @param path 原始路径
* @param appId 应用ID
* @param memoryId 记忆库ID
* @return 追加查询参数后的路径
*/
private String appendContextParams(String path, String appId, String memoryId) {
StringBuilder sb = new StringBuilder(path);
boolean hasParam = false;
if (oConvertUtils.isNotEmpty(appId)) {
sb.append("?appId=").append(urlEncode(appId));
hasParam = true;
}
if (oConvertUtils.isNotEmpty(memoryId)) {
sb.append(hasParam ? "&" : "?").append("memoryId=").append(urlEncode(memoryId));
}
return sb.toString();
}
/**
* URL编码
*/
private String urlEncode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
/**
* 获取参数
*

View File

@@ -308,6 +308,20 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
throw new JeecgBootException("请上传zip压缩包");
}
String uploadedZipPath = CommonUtils.uploadLocal(zipFile, bizPath, uploadpath);
//update-begin---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
// 判断知识库是否配置了默认分段策略
boolean knowledgeHasDefaultSegment = false;
AiragKnowledge knowledge = airagKnowledgeMapper.selectById(knowId);
if (knowledge != null && oConvertUtils.isNotEmpty(knowledge.getMetadata())) {
try {
JSONObject kmeta = JSONObject.parseObject(knowledge.getMetadata());
knowledgeHasDefaultSegment = Boolean.TRUE.equals(kmeta.getBoolean(LLMConsts.ENABLE_SEGMENT));
} catch (Exception ignore) {}
}
final boolean useKnowledgeDefault = knowledgeHasDefaultSegment;
//update-end---author:wangshuai ---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
// 解压缩文件
List<AiragKnowledgeDoc> docList = new ArrayList<>();
AtomicInteger fileCount = new AtomicInteger(0);
@@ -338,6 +352,12 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
JSONObject metadata = new JSONObject();
metadata.put(LLMConsts.KNOWLEDGE_DOC_METADATA_FILEPATH, relativePath);
metadata.put(LLMConsts.KNOWLEDGE_DOC_METADATA_SOURCES_PATH, sourcesPath);
//update-begin---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
// 知识库有默认分段策略,文档标记使用知识库默认
if (useKnowledgeDefault) {
metadata.put(LLMConsts.USE_KNOWLEDGE_DEFAULT, true);
}
//update-end---wangshuai---date:20260414 for【QQYUN-14932】创建知识库时可以创建一个分段策略知识库里面的文档默认使用知识库的分段策略------------
doc.setMetadata(metadata.toJSONString());
docList.add(doc);
});
@@ -398,6 +418,13 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
throw new IOException("解压文件数量超限可能是zip bomb攻击");
}
//update-begin---author:scott ---date:2026-04-16 for【issues/9551】macOS压缩包隐藏文件过滤-----------
if (shouldSkipZipEntry(entry.getName())) {
log.info("跳过压缩包中的隐藏文件: {}", entry.getName());
continue;
}
//update-end---author:scott ---date:2026-04-16 for【issues/9551】macOS压缩包隐藏文件过滤-----------
Path newPath = safeResolve(targetDir, entry.getName());
if (entry.isDirectory()) {
@@ -424,6 +451,23 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl<AiragKnowledgeDocM
}
}
//update-begin---author:scott ---date:2026-04-16 for【issues/9551】macOS压缩包隐藏文件过滤-----------
/**
* 过滤压缩包中的系统隐藏文件,例如 macOS 自动生成的 __MACOSX 和 ._ 文件。
*/
static boolean shouldSkipZipEntry(String entryName) {
if (oConvertUtils.isEmpty(entryName)) {
return true;
}
String normalizedName = entryName.replace("\\", "/");
if (normalizedName.startsWith("__MACOSX/")) {
return true;
}
String fileName = Paths.get(normalizedName).getFileName().toString();
return fileName.startsWith("._") || fileName.equals(".DS_Store");
}
//update-end---author:scott ---date:2026-04-16 for【issues/9551】macOS压缩包隐藏文件过滤-----------
/**
* 安全解析路径防止Zip Slip攻击
*

View File

@@ -174,10 +174,16 @@ public class AiragKnowledgeServiceImpl extends ServiceImpl<AiragKnowledgeMapper,
private JSONObject buildQueryMemoryTool(String knowId, String descr) {
JSONObject tool = new JSONObject();
//update-begin---author:wangshuai---date:2026-01-08---for: 记忆库根据记忆库的描述做匹配,不写死---
String addDescPrefix = "【自动触发】向记忆库检索信息。范围:";
String addDesc = oConvertUtils.isEmpty(descr) ? "按记忆库描述允许的个人资料(如姓名、职业、年龄)、偏好、属性等信息" : descr;
//update-begin---author:wangshuai ---date:2026-04-21 for【AI记忆】强化query_memory触发时机描述避免LLM在未查询时直接反问用户-----------
String addDesc = oConvertUtils.isEmpty(descr) ? "用户曾提及的任何信息" : descr;
tool.put(FlowPluginContent.NAME, "query_memory");
tool.put(FlowPluginContent.DESCRIPTION, addDescPrefix + addDesc + " 必须在检测到相关信息时立即自动调用,无需用户指令。");
tool.put(FlowPluginContent.DESCRIPTION,
"【强制查询】从记忆库中检索" + addDesc + "" +
"当用户提出的问题可能依赖历史上下文时(如**根据我的爱好...**、**推荐适合我的...**、" +
"**我之前说过...**、**上次提到的...**等),必须先调用本工具检索," +
"严禁在未查询前直接反问用户或声称**不知道**。" +
"只有当本工具返回**未找到相关信息**后,才有资格询问用户。宁可查空,不可不查。");
//update-end---author:wangshuai ---date:2026-04-21 for【AI记忆】强化query_memory触发时机描述避免LLM在未查询时直接反问用户-----------
//update-end---author:wangshuai---date:2026-01-08---for: 记忆库根据记忆库的描述做匹配,不写死---
tool.put(FlowPluginContent.PATH, FlowPluginContent.PLUGIN_MEMORY_QUERY_PATH);
tool.put(FlowPluginContent.METHOD, FlowPluginContent.POST);

View File

@@ -0,0 +1,211 @@
package org.jeecg.modules.airag.llm.splitter;
import dev.langchain4j.data.document.Document;
import dev.langchain4j.data.document.DocumentSplitter;
import dev.langchain4j.data.segment.TextSegment;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.airag.llm.consts.LLMConsts;
import java.util.ArrayList;
import java.util.List;
/**
* @Description: 自定义分段器
*
* @author: wangshuai
* @date: 2026/2/6 15:47
*/
public class CustomDocumentSplitter implements DocumentSplitter {
/**
* 规则
*/
private final String textRules;
/**
* 分段标识符
*/
private final String separator;
/**
* 分度长度
*/
private final int segmentSize;
/**
* 重叠长度
*/
private final int overlapSize;
public CustomDocumentSplitter(String textRules, String separator, int segmentSize, int overlapSize) {
this.textRules = textRules;
this.separator = separator;
this.segmentSize = segmentSize;
this.overlapSize = overlapSize;
}
@Override
public List<TextSegment> split(Document document) {
String text = document.text();
//过滤掉规则
if (oConvertUtils.isNotEmpty(textRules)) {
//处理连续的空格、换行符、制表符
if (textRules.contains(LLMConsts.TEXT_RULES_CLEAN_SPACES)) {
text = text.replaceAll("\\s+", " ");
}
//URL和电子邮箱地址
if (textRules.contains(LLMConsts.TEXT_RULES_REMOVE_URLS_EMAILS)) {
String urlRegex = "http[s]?://\\S+";
String emailRegex = "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,6}";
text = text.replaceAll(urlRegex, "").replaceAll(emailRegex, "");
}
}
if (oConvertUtils.isEmpty(text)) {
return new ArrayList<>();
}
//根据定义的分词进行分割
String[] parts = text.split(java.util.regex.Pattern.quote(separator));
//存放TextSegment的集合
List<TextSegment> segments = new ArrayList<>();
//存放文本的集合
List<String> currentBuffer = new ArrayList<>();
int currentLength = 0;
for (int i = 0; i < parts.length; i++) {
String part = parts[i];
if (oConvertUtils.isEmpty(part)) {
continue;
}
// 如果不是第一部分根据索引判断说明之前有分隔符补到当前part开头
if (i > 0) {
part = separator + part;
}
//文本长度
int partLen = part.length();
// 预计长度 = 当前长度 + 文本长度 (分隔符已包含在part中)
int projectedLen = currentLength + partLen;
//判断分隔长度
if (projectedLen <= segmentSize) {
//分隔长度小于自定义的分割长度
currentBuffer.add(part);
currentLength = projectedLen;
} else {
// 1. 保存当前分段
if (!currentBuffer.isEmpty()) {
flushAndOverlap(segments, currentBuffer, document, true);
// 分隔符已包含在元素中,直接求和
currentLength = currentBuffer.stream().mapToInt(String::length).sum();
}
// 3. 处理当前part
// 检查加上当前part是否超过限制
int newProjectedLen = currentLength + partLen;
if (newProjectedLen <= segmentSize) {
currentBuffer.add(part);
currentLength = newProjectedLen;
} else {
// part太长需要切分
int offset = 0;
//截取的长度小于文本长度,跳出循环
while (offset < partLen) {
// 计算当前分段剩余可用空间
// space = 最大分段长度 - 当前已用长度
int space = segmentSize - currentLength;
if (space <= 0) {
// Buffer满可能是重叠导致的强制刷新
flushAndOverlap(segments, currentBuffer, document, true);
currentLength = currentBuffer.stream().mapToInt(String::length).sum();
// 刷新后重新计算剩余空间
space = segmentSize - currentLength;
// 如果重叠本身就超长即space <= 0则清空Buffer以避免死循环并重置space为整个分段长度
if (space <= 0) {
currentBuffer.clear();
currentLength = 0;
space = segmentSize;
}
}
// 计算本次能截取的长度取剩余空间和剩余part长度的较小值
int take = Math.min(space, partLen - offset);
String chunk = part.substring(offset, offset + take);
currentBuffer.add(chunk);
currentLength += take;
offset += take;
// 如果还没处理完part说明填满了buffer需要flush
if (offset < partLen) {
flushAndOverlap(segments, currentBuffer, document, false);
currentLength = currentBuffer.stream().mapToInt(String::length).sum();
}
}
}
}
}
// 处理剩余部分
if (!currentBuffer.isEmpty()) {
String segmentText = String.join("", currentBuffer).trim();
if (oConvertUtils.isNotEmpty(segmentText)) {
segments.add(TextSegment.from(segmentText, document.metadata()));
}
}
return segments;
}
/**
* 将当前buffer内容保存为segment并处理重叠部分
* @param segments 结果集合
* @param buffer 当前文本buffer
* @param document 原始文档(用于元数据)
*/
private void flushAndOverlap(List<TextSegment> segments, List<String> buffer, Document document, boolean enableOverlap) {
if (buffer.isEmpty()) {
return;
}
// 保存当前分段
String segmentText = String.join("", buffer).trim();
if (oConvertUtils.isEmpty(segmentText)) {
buffer.clear();
return;
}
segments.add(TextSegment.from(segmentText, document.metadata()));
if (!enableOverlap) {
buffer.clear();
return;
}
// 处理重叠 (保留buffer末尾部分)
List<String> newBuffer = new ArrayList<>();
int newLen = 0;
// 倒序遍历查找可保留的末尾部分
for (int j = buffer.size() - 1; j >= 0; j--) {
String p = buffer.get(j);
int pLen = p.length();
//update-begin---author:wangshuai ---date:2026-04-09 for【issue/9418】修复重叠率失效问题当某个part本身超过overlapSize时取其尾部子串保证重叠不为0-----------
if (newLen + pLen <= overlapSize) {
// 整段可以放入重叠区
newBuffer.add(0, p);
newLen += pLen;
} else {
// 剩余可用空间
int remaining = overlapSize - newLen;
if (remaining > 0) {
// 取该元素的尾部子串,保证重叠区不为空
newBuffer.add(0, p.substring(pLen - remaining));
}
// 已填满重叠区,停止
//update-end---author:wangshuai ---date:2026-04-09 for【issue/9418】修复重叠率失效问题当某个part本身超过overlapSize时取其尾部子串保证重叠不为0-----------
break;
}
}
// 更新buffer为仅包含重叠部分
buffer.clear();
buffer.addAll(newBuffer);
}
}

View File

@@ -0,0 +1,91 @@
package org.jeecg.modules.airag.video.controller;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.airag.video.service.IVideoGenerationService;
import org.jeecg.modules.airag.video.vo.VideoGenerateVo;
import org.jeecg.modules.airag.video.vo.VideoTaskResultVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* AI视频生成Controller
*/
@Slf4j
@RestController
@RequestMapping("/airag/video")
public class VideoGenerationController {
@Autowired
private IVideoGenerationService videoGenerationService;
/**
* 提交视频生成任务
*/
@PostMapping("/submit")
public Result<VideoTaskResultVo> submitTask(@RequestBody VideoGenerateVo vo) {
VideoTaskResultVo result = videoGenerationService.submitTask(vo);
if ("FAIL".equals(result.getStatus())) {
return Result.error(result.getMessage());
}
return Result.OK(result);
}
/**
* 查询视频生成任务状态
*/
@GetMapping("/query/{taskId}")
public Result<VideoTaskResultVo> queryTask(@PathVariable String taskId) {
VideoTaskResultVo result = videoGenerationService.queryTask(taskId);
return Result.OK(result);
}
/**
* 为已完成的视频添加AI配音
* 流程:生成旁白文案 → TTS语音合成 → FFmpeg合并视频和音频
*/
@PostMapping("/voiceover")
public Result<VideoTaskResultVo> addVoiceover(@RequestBody VideoGenerateVo vo) {
if (vo.getTaskId() == null || vo.getTaskId().isBlank()) {
return Result.error("taskId不能为空");
}
if (vo.getPrompt() == null || vo.getPrompt().isBlank()) {
return Result.error("prompt不能为空");
}
VideoTaskResultVo result = videoGenerationService.addVoiceover(vo.getTaskId(), vo.getPrompt());
if ("FAIL".equals(result.getStatus())) {
return Result.error(result.getMessage());
}
return Result.OK(result);
}
/**
* 获取预设提示词
*/
@GetMapping("/prompts")
public Result<Map<String, List<String>>> getPresetPrompts() {
return Result.OK(videoGenerationService.getPresetPrompts());
}
/**
* 查询当前用户的视频生成记录
*/
@GetMapping("/listByUser")
public Result<List<JSONObject>> getVideoRecords(@RequestParam String userId) {
List<JSONObject> records = videoGenerationService.getVideoRecords(userId);
return Result.OK(records);
}
/**
* 删除视频生成记录
*/
@DeleteMapping("/deleteVideoRecord")
public Result<String> deleteVideoRecord(@RequestParam String userId, @RequestParam String recordId) {
boolean deleted = videoGenerationService.deleteVideoRecord(userId, recordId);
return deleted ? Result.OK("删除成功") : Result.error("记录不存在");
}
}

View File

@@ -0,0 +1,48 @@
package org.jeecg.modules.airag.video.service;
import com.alibaba.fastjson2.JSONObject;
import com.alibaba.fastjson2.JSONObject;
import org.jeecg.modules.airag.video.vo.VideoGenerateVo;
import org.jeecg.modules.airag.video.vo.VideoTaskResultVo;
import java.util.List;
import java.util.Map;
/**
* AI视频生成服务接口
*/
public interface IVideoGenerationService {
/**
* 提交视频生成任务
*/
VideoTaskResultVo submitTask(VideoGenerateVo vo);
/**
* 查询任务状态
*/
VideoTaskResultVo queryTask(String taskId);
/**
* 为已完成的视频添加AI配音
* @param taskId 视频任务ID
* @param prompt 原始提示词(用于生成旁白)
* @return 包含配音视频URL的结果
*/
VideoTaskResultVo addVoiceover(String taskId, String prompt);
/**
* 获取预设提示词
*/
Map<String, List<String>> getPresetPrompts();
/**
* 查询用户视频生成记录列表
*/
List<JSONObject> getVideoRecords(String userId);
/**
* 删除用户视频生成记录
*/
boolean deleteVideoRecord(String userId, String recordId);
}

View File

@@ -0,0 +1,686 @@
package org.jeecg.modules.airag.video.service.impl;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.RedisUtil;
import org.springframework.data.redis.core.RedisTemplate;
import org.jeecg.config.AiChatConfig;
import org.jeecg.modules.airag.voice.util.VoiceApiHelper;
import org.jeecg.modules.airag.video.service.IVideoGenerationService;
import org.jeecg.modules.airag.video.vo.VideoGenerateVo;
import org.jeecg.modules.airag.video.vo.VideoTaskResultVo;
import org.jeecg.config.JeecgBaseConfig;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.util.*;
/**
* AI视频生成服务实现
*/
@Slf4j
@Service
public class VideoGenerationServiceImpl implements IVideoGenerationService {
/** apiHost 从 yml 配置 jeecg.ai-chat.ai-model-video.api-host 读取,不再硬编码 */
private static final String REDIS_KEY_PREFIX = "airag:video:";
private static final Map<String, List<String>> PRESET_PROMPTS = new LinkedHashMap<>();
static {
PRESET_PROMPTS.put("通用演示", List.of(
"一只金毛犬在金色的沙滩上奔跑,海浪轻轻拍打着岸边,阳光明媚,慢动作镜头",
"航拍壮丽的山脉全景,云雾缭绕在山峰之间,镜头缓缓推进",
"樱花树下,花瓣随风飘落,一条小溪静静流淌,春日午后的宁静氛围"
));
PRESET_PROMPTS.put("产品营销", List.of(
"一杯咖啡被缓缓倒入透明玻璃杯中,咖啡与牛奶融合形成美丽的纹理,微距特写",
"一款高端智能手表在旋转展示台上缓缓旋转,灯光打在表面上反射出金属光泽,黑色背景",
"一双运动鞋踩入水洼溅起水花,慢动作特写,动感活力的画面"
));
PRESET_PROMPTS.put("教育培训", List.of(
"地球从太空视角缓缓旋转,可以看到大气层和云层的细节,星空背景",
"一本书的书页被风吹动快速翻动,文字和插图若隐若现,知识流动的意象",
"显微镜下的细胞分裂过程,色彩鲜明的科学可视化风格"
));
PRESET_PROMPTS.put("创意设计", List.of(
"一座未来主义的城市在日落时分,霓虹灯光倒映在雨水的路面上,赛博朋克风格",
"水墨在水中缓缓扩散,形成抽象的山水画意境,中国风艺术效果",
"星空下的极光在天空中舞动,色彩绚烂,延时摄影效果"
));
}
@Autowired
private AiChatConfig aiChatConfig;
@Autowired
private JeecgBaseConfig jeecgBaseConfig;
@Autowired
private VoiceApiHelper voiceApiHelper;
@Resource
private RedisUtil redisUtil;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/** 实际使用的ffmpeg路径优先yml配置其次自动查找 */
private String ffmpegPath;
/** 实际使用的edge-tts路径优先yml配置其次自动查找 */
private String edgeTtsPath;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
@PostConstruct
public void init() {
// 从yml配置读取若为空则自动查找
AiChatConfig.VideoModelConfig videoConfig = aiChatConfig.getAiModelVideo();
String configFfmpeg = videoConfig.getFfmpegPath();
String configEdgeTts = videoConfig.getEdgeTtsPath();
if (configFfmpeg != null && !configFfmpeg.isBlank()) {
this.ffmpegPath = configFfmpeg;
} else {
this.ffmpegPath = findCommand(new String[]{"-version"}, "ffmpeg", "ffmpeg.exe", "C:/tools/ffmpeg/ffmpeg.exe");
}
if (configEdgeTts != null && !configEdgeTts.isBlank()) {
this.edgeTtsPath = configEdgeTts;
} else {
this.edgeTtsPath = findCommand(new String[]{"--version"}, "edge-tts", "" +
"", "D:/ProgramFiles/miniconda3/Scripts/edge-tts.exe");
}
log.info("=== AI视频配音工具检测 ===");
if (ffmpegPath != null) {
log.info(" ffmpeg : 已找到 -> {}", ffmpegPath);
} else {
log.warn(" ffmpeg : 未安装,视频配音功能将不可用");
}
if (edgeTtsPath != null) {
log.info(" edge-tts : 已找到 -> {}", edgeTtsPath);
} else {
log.warn(" edge-tts : 未安装,视频配音功能将不可用");
}
if (isToolsAvailable()) {
log.info(" 视频配音功能: 已启用");
} else {
log.warn(" 视频配音功能: 已禁用(缺少依赖工具,调用配音接口将直接返回无声视频)");
}
log.info("===========================");
}
@Override
public VideoTaskResultVo submitTask(VideoGenerateVo vo) {
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;
JSONObject body = new JSONObject();
body.put("model", model);
body.put("prompt", vo.getPrompt());
//生成质量
body.put("quality", "quality");
// 前端传递 izAiAudio1=使用AI合成音效0=不使用
boolean aiAutoAudio = vo.getIzAiAudio() != null && vo.getIzAiAudio() == 1;
if (model.contains("vidu2")) {
// vidu2系列模型使用 aspect_ratio 参数,将 size 转为宽高比
if (vo.getSize() != null) {
body.put("aspect_ratio", convertSizeToAspectRatio(vo.getSize()));
}
} else {
if (vo.getSize() != null) {
body.put("size", vo.getSize());
}
if (vo.getFps() != null) {
body.put("fps", vo.getFps());
}
if (vo.getDuration() != null) {
body.put("duration", vo.getDuration());
}
if (aiAutoAudio) {
body.put("with_audio", true);
}
}
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/videos/generations"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toJSONString()))
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
log.info("视频生成任务提交响应: status={}, body={}", response.statusCode(), response.body());
if (response.statusCode() != 200) {
VideoTaskResultVo result = new VideoTaskResultVo();
result.setStatus("FAIL");
String errorMsg = "提交任务失败,状态码: " + response.statusCode();
try {
JSONObject errorJson = JSON.parseObject(response.body());
JSONObject errorObj = errorJson.getJSONObject("error");
if (errorObj != null && errorObj.getString("message") != null) {
errorMsg += "" + errorObj.getString("message");
}
} catch (Exception ignored) {
}
result.setMessage(errorMsg);
return result;
}
JSONObject respJson = JSON.parseObject(response.body());
String taskId = respJson.getString("id");
// 缓存prompt到Redis供视频完成后自动生成语音使用
redisUtil.set(REDIS_KEY_PREFIX + "prompt:" + taskId, vo.getPrompt(), 86400);
// 缓存是否AI自动生成音效标记
redisUtil.set(REDIS_KEY_PREFIX + "aiAutoAudio:" + taskId, String.valueOf(aiAutoAudio), 86400);
VideoTaskResultVo result = new VideoTaskResultVo();
result.setTaskId(taskId);
result.setStatus("PROCESSING");
return result;
} catch (Exception e) {
log.error("提交视频生成任务异常", e);
VideoTaskResultVo result = new VideoTaskResultVo();
result.setStatus("FAIL");
result.setMessage("提交任务异常: " + e.getMessage());
return result;
}
}
@Override
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;
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/async-result/" + taskId))
.header("Authorization", "Bearer " + apiKey)
.GET()
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
log.error("查询视频任务HTTP失败: taskId={}, httpStatus={}", taskId, response.statusCode());
VideoTaskResultVo result = new VideoTaskResultVo();
result.setTaskId(taskId);
result.setStatus("FAIL");
result.setMessage("查询任务失败,状态码: " + response.statusCode());
return result;
}
JSONObject respJson = JSON.parseObject(response.body());
String taskStatus = respJson.getString("task_status");
log.info("查询视频任务: taskId={}, 任务状态={}", taskId, taskStatus);
VideoTaskResultVo result = new VideoTaskResultVo();
result.setTaskId(taskId);
if ("SUCCESS".equals(taskStatus)) {
result.setStatus("SUCCESS");
JSONArray videoResult = respJson.getJSONArray("video_result");
if (videoResult != null && !videoResult.isEmpty()) {
JSONObject firstVideo = videoResult.getJSONObject(0);
String remoteVideoUrl = firstVideo.getString("url");
String remoteCoverUrl = firstVideo.getString("cover_image_url");
log.info("视频生成完成: taskId={}, videoUrl={}", taskId, remoteVideoUrl);
// 删除prompt缓存只有删除成功的请求才执行后续处理防止并发轮询重复存储
String promptKey = REDIS_KEY_PREFIX + "prompt:" + taskId;
String aiAutoAudioKey = REDIS_KEY_PREFIX + "aiAutoAudio:" + taskId;
//update-begin---author:wangshuai---date:2026-03-23---for:【QQYUN-14960】【AI生成视频】报错了实际视频已经生成完了---
Object cachedPrompt = redisTemplate.opsForValue().get(promptKey);
if (cachedPrompt != null) {
redisTemplate.delete(promptKey);
}
String prompt = cachedPrompt != null ? cachedPrompt.toString() : null;
// 判断是否采用AI自动生成音效
Object cachedAiAutoAudio = redisTemplate.opsForValue().get(aiAutoAudioKey);
if (cachedAiAutoAudio != null) {
redisTemplate.delete(aiAutoAudioKey);
}
//update-end---author:wangshuai---date:2026-03-23---for:【QQYUN-14960】【AI生成视频】报错了实际视频已经生成完了---
boolean aiAutoAudio = cachedAiAutoAudio != null && "true".equals(cachedAiAutoAudio.toString());
// 下载视频和封面到本地,避免远程链接失效
if (remoteCoverUrl != null && !remoteCoverUrl.isBlank()) {
String localCoverPath = downloadToLocal(remoteCoverUrl, "cover_" + taskId + ".jpg");
result.setCoverUrl(localCoverPath);
}
String localVideoPath = downloadToLocal(remoteVideoUrl, "video_" + taskId + ".mp4");
result.setVideoUrl(localVideoPath);
// 仅在prompt缓存存在时执行一次getAndDelete保证只有一个请求能获取到
if (prompt != null && !prompt.isBlank()) {
if (aiAutoAudio) {
log.info("AI自动生成音效模式跳过autoAddVoiceover: taskId={}", taskId);
} else {
log.info("非AI自动音效模式开始autoAddVoiceover: taskId={}, prompt={}", taskId, prompt);
autoAddVoiceover(taskId, prompt, localVideoPath, result);
}
// 存入Redis记录
saveVideoToRedis(result, prompt);
} else {
log.info("未检测到prompt缓存已处理过或旧任务跳过自动配音: taskId={}", taskId);
}
}
} else if ("FAIL".equals(taskStatus)) {
result.setStatus("FAIL");
result.setMessage(respJson.getString("message"));
log.error("视频生成失败: taskId={}, message={}", taskId, result.getMessage());
} else {
result.setStatus("PROCESSING");
log.info("视频生成中: taskId={}, 等待完成...", taskId);
}
return result;
} catch (Exception e) {
log.error("查询视频任务异常, taskId={}", taskId, e);
VideoTaskResultVo result = new VideoTaskResultVo();
result.setTaskId(taskId);
result.setStatus("FAIL");
result.setMessage("查询任务异常: " + e.getMessage());
return result;
}
}
@Override
public VideoTaskResultVo addVoiceover(String taskId, String prompt) {
// 1. 先查询任务状态获取视频URL
VideoTaskResultVo queryResult = queryTask(taskId);
if (!"SUCCESS".equals(queryResult.getStatus())) {
queryResult.setMessage("视频任务尚未完成,当前状态: " + queryResult.getStatus());
return queryResult;
}
String videoUrl = queryResult.getVideoUrl();
if (videoUrl == null || videoUrl.isBlank()) {
queryResult.setStatus("FAIL");
queryResult.setMessage("视频URL为空");
return queryResult;
}
// 2. 检测依赖工具是否可用,不可用则直接返回无声视频
if (!isToolsAvailable()) {
log.warn("服务器未安装 edge-tts 或 ffmpeg跳过配音直接返回无声视频。" +
"edge-tts={}, ffmpeg={}", edgeTtsPath, ffmpegPath);
queryResult.setMessage("服务器未安装配音依赖edge-tts/ffmpeg返回原始无声视频");
return queryResult;
}
try {
String uploadPath = jeecgBaseConfig.getPath().getUpload();
String bizPath = "ai_video";
Path outputDir = Paths.get(uploadPath, bizPath);
Files.createDirectories(outputDir);
String timestamp = String.valueOf(System.currentTimeMillis());
// 3. 下载无声视频
Path silentVideo = downloadFile(videoUrl, outputDir.resolve("silent_" + timestamp + ".mp4"));
log.info("无声视频已下载: {}", silentVideo);
// 4. 生成旁白文案
String narration = generateNarration(prompt);
log.info("旁白文案: {}", narration);
// 5. TTS生成语音
Path audioPath = outputDir.resolve("voiceover_" + timestamp + ".mp3");
generateTtsAudio(narration, audioPath);
log.info("语音已生成: {}", audioPath);
// 6. FFmpeg合成有声视频
String finalFileName = "video_" + timestamp + ".mp4";
Path finalVideo = outputDir.resolve(finalFileName);
mergeVideoAudio(silentVideo, audioPath, finalVideo);
log.info("合成视频已生成: {}", finalVideo);
// 7. 清理临时文件
Files.deleteIfExists(silentVideo);
Files.deleteIfExists(audioPath);
// 8. 返回结果(使用相对路径,通过静态资源映射访问)
String dbPath = bizPath + "/" + finalFileName;
queryResult.setVoiceoverVideoUrl(dbPath);
queryResult.setNarration(narration);
return queryResult;
} catch (Exception e) {
log.error("添加配音异常, taskId={},降级返回无声视频", taskId, e);
queryResult.setMessage("配音处理异常(已降级返回无声视频): " + e.getMessage());
return queryResult;
}
}
/**
* 检测 edge-tts 和 ffmpeg 是否可用
*/
private boolean isToolsAvailable() {
return edgeTtsPath != null && ffmpegPath != null;
}
@Override
public Map<String, List<String>> getPresetPrompts() {
return PRESET_PROMPTS;
}
@Override
public List<JSONObject> getVideoRecords(String userId) {
String redisKey = REDIS_KEY_PREFIX + userId;
List<Object> list = redisUtil.lGet(redisKey, 0, -1);
if (list == null || list.isEmpty()) {
return Collections.emptyList();
}
return list.stream()
.map(item -> JSONObject.parseObject(item.toString()))
.collect(java.util.stream.Collectors.toList());
}
@Override
public boolean deleteVideoRecord(String userId, String recordId) {
String redisKey = REDIS_KEY_PREFIX + userId;
List<Object> list = redisUtil.lGet(redisKey, 0, -1);
if (list == null || list.isEmpty()) {
return false;
}
for (Object item : list) {
JSONObject obj = JSONObject.parseObject(item.toString());
if (recordId.equals(obj.getString("id"))) {
redisUtil.lRemove(redisKey, 1, item);
return true;
}
}
return false;
}
/**
* 将视频生成记录存入Redis
*
* @param result 视频结果
* @param prompt 用户原始prompt
*/
private void saveVideoToRedis(VideoTaskResultVo result, String prompt) {
try {
LoginUser loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
if (loginUser == null) {
log.warn("未获取到登录用户跳过Redis存储");
return;
}
String redisKey = REDIS_KEY_PREFIX + loginUser.getId();
JSONObject record = new JSONObject();
record.put("id", UUID.randomUUID().toString().replace("-", ""));
record.put("taskId", result.getTaskId());
record.put("videoUrl", result.getVideoUrl());
record.put("coverUrl", result.getCoverUrl());
record.put("status", result.getStatus());
record.put("content", prompt);
record.put("createTime", java.time.LocalDateTime.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
redisUtil.lSet(redisKey, record.toJSONString());
log.info("视频记录已存入Redis列表: key={}", redisKey);
} catch (Exception e) {
log.warn("视频记录存入Redis失败不影响主流程", e);
}
}
/**
* 将远程文件下载到本地上传目录返回相对路径ai_video/xxx
*/
private String downloadToLocal(String remoteUrl, String fileName) {
try {
String uploadPath = jeecgBaseConfig.getPath().getUpload();
String bizPath = "video";
Path outputDir = Paths.get(uploadPath, bizPath);
Files.createDirectories(outputDir);
Path localFile = outputDir.resolve(fileName);
downloadFile(remoteUrl, localFile);
log.info("远程文件已下载到本地: {} -> {}", remoteUrl, localFile);
return bizPath + "/" + fileName;
} catch (Exception e) {
log.warn("下载远程文件到本地失败返回原始URL: {}", remoteUrl, e);
return remoteUrl;
}
}
/**
* 下载文件到本地
*/
private Path downloadFile(String url, Path outputPath) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.timeout(Duration.ofMinutes(5))
.build();
httpClient.send(request, HttpResponse.BodyHandlers.ofFile(outputPath));
return outputPath;
}
/**
* 调用智谱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;
JSONObject body = new JSONObject();
body.put("model", "glm-4-flash");
JSONArray messages = new JSONArray();
JSONObject systemMsg = new JSONObject();
systemMsg.put("role", "system");
systemMsg.put("content", "你是一位专业的视频旁白撰写者。根据用户给出的视频画面描述,"
+ "撰写一段简短的旁白配音文案30-50字语言优美、富有感染力适合作为视频解说词。"
+ "只输出旁白文案本身,不要加引号或其他说明。");
JSONObject userMsg = new JSONObject();
userMsg.put("role", "user");
userMsg.put("content", "视频画面:" + videoPrompt);
messages.add(systemMsg);
messages.add(userMsg);
body.put("messages", messages);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/chat/completions"))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toJSONString()))
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new RuntimeException("旁白生成失败,状态码: " + response.statusCode());
}
JSONObject respJson = JSON.parseObject(response.body());
return respJson.getJSONArray("choices")
.getJSONObject(0)
.getJSONObject("message")
.getString("content")
.trim();
}
/**
* 使用 edge-tts 将文本转为语音
*/
private void generateTtsAudio(String text, Path audioPath) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder(
edgeTtsPath,
"--voice", "zh-CN-YunyangNeural",
"--text", text,
"--write-media", audioPath.toAbsolutePath().toString()
);
pb.redirectErrorStream(true);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
if (exitCode != 0) {
log.error("edge-tts 执行失败: {}", output);
throw new RuntimeException("edge-tts 执行失败,退出码: " + exitCode);
}
}
/**
* 视频生成成功后,自动生成语音并合并(参照 addVoiceover 逻辑)
* 使用 TTS APIVoiceApiHelper生成语音ffmpeg 合并
* 失败时降级返回无声视频,不影响主流程
*
* @param taskId 任务ID
* @param prompt 用户原始prompt
* @param localVideoPath 本地无声视频相对路径(如 video/video_xxx.mp4
* @param result 结果对象,成功时更新 videoUrl 和 narration
*/
private void autoAddVoiceover(String taskId, String prompt, String localVideoPath, VideoTaskResultVo result) {
log.info(">>> autoAddVoiceover 开始: taskId={}, ffmpeg={}, localVideoPath={}", taskId, ffmpegPath, localVideoPath);
if (ffmpegPath == null) {
log.info("ffmpeg不可用跳过自动配音: taskId={}", taskId);
return;
}
try {
String uploadPath = jeecgBaseConfig.getPath().getUpload();
String bizPath = "video";
Path outputDir = Paths.get(uploadPath, bizPath);
Files.createDirectories(outputDir);
String timestamp = String.valueOf(System.currentTimeMillis());
// 1. 生成旁白文案
log.info("自动配音第1步-生成旁白文案: taskId={}", taskId);
String narration = generateNarration(prompt);
log.info("自动配音旁白文案: {}", narration);
// 2. TTS API 生成语音
Path audioPath = outputDir.resolve("auto_voice_" + taskId + "_" + timestamp + ".wav");
log.info("自动配音第2步-TTS生成语音: taskId={}, audioPath={}", taskId, audioPath);
voiceApiHelper.generateAudio(narration, audioPath);
log.info("自动配音语音已生成: {}, 文件存在={}", audioPath, Files.exists(audioPath));
// 3. FFmpeg 合并视频和音频
Path silentVideoFile = outputDir.resolve("video_" + taskId + ".mp4");
log.info("自动配音第3步-FFmpeg合并: silentVideo={} (存在={})", silentVideoFile, Files.exists(silentVideoFile));
String mergedFileName = "video_voiced_" + taskId + "_" + timestamp + ".mp4";
Path mergedVideo = outputDir.resolve(mergedFileName);
mergeVideoAudio(silentVideoFile, audioPath, mergedVideo);
log.info("自动配音合成完成: {}, 文件存在={}", mergedVideo, Files.exists(mergedVideo));
// 4. 更新结果为有声视频
result.setVideoUrl(bizPath + "/" + mergedFileName);
result.setNarration(narration);
// 5. 清理临时音频文件
Files.deleteIfExists(audioPath);
log.info(">>> autoAddVoiceover 完成: taskId={}, videoUrl={}", taskId, result.getVideoUrl());
} catch (Exception e) {
log.error(">>> autoAddVoiceover 失败,降级返回无声视频: taskId={}", taskId, e);
}
}
/**
* 使用 FFmpeg 合并视频和音频
*/
private void mergeVideoAudio(Path videoPath, Path audioPath, Path outputPath) throws IOException, InterruptedException {
ProcessBuilder pb = new ProcessBuilder(
ffmpegPath,
"-i", videoPath.toAbsolutePath().toString(),
"-i", audioPath.toAbsolutePath().toString(),
"-c:v", "copy",
"-c:a", "aac",
"-shortest",
"-y",
outputPath.toAbsolutePath().toString()
);
pb.redirectErrorStream(true);
Process process = pb.start();
String output = new String(process.getInputStream().readAllBytes());
int exitCode = process.waitFor();
if (exitCode != 0) {
log.error("FFmpeg 合成失败: {}", output);
throw new RuntimeException("FFmpeg 合成失败,退出码: " + exitCode);
}
}
/**
* 将 size如 "1920x1080")转为 vidu2 的 aspect_ratio 格式(如 "16:9"
* 支持的比例16:9、9:16、1:1无法识别时默认 16:9
*/
private String convertSizeToAspectRatio(String size) {
if (size == null || size.isBlank()) {
return "16:9";
}
// 已经是比例格式则直接返回
if (size.matches("\\d+:\\d+")) {
return size;
}
// 解析 WxH 格式
String[] parts = size.toLowerCase().split("x");
if (parts.length == 2) {
try {
int w = Integer.parseInt(parts[0].trim());
int h = Integer.parseInt(parts[1].trim());
if (w == h) {
return "1:1";
} else if (w > h) {
return "16:9";
} else {
return "9:16";
}
} catch (NumberFormatException ignored) {
}
}
return "16:9";
}
/**
* 查找可用的命令路径找不到返回null
*/
private static String findCommand(String[] versionFlag, String... candidates) {
for (String path : candidates) {
try {
List<String> cmd = new ArrayList<>();
cmd.add(path);
cmd.addAll(List.of(versionFlag));
Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start();
p.getInputStream().readAllBytes();
p.waitFor();
if (p.exitValue() == 0) return path;
} catch (Exception ignored) {}
}
return null;
}
}

View File

@@ -0,0 +1,44 @@
package org.jeecg.modules.airag.video.vo;
import lombok.Data;
/**
* AI视频生成请求VO
*/
@Data
public class VideoGenerateVo {
/**
* 视频描述提示词
*/
private String prompt;
/**
* 场景分类(通用演示/产品营销/教育培训/创意设计)
*/
private String category;
/**
* 视频任务ID用于添加配音时传入
*/
private String taskId;
/**
* 视频尺寸,如 "1920x1080"、"720x480"
*/
private String size;
/**
* 视频帧率
*/
private Integer fps;
/**
* 视频时长(秒)
*/
private Integer duration;
/**
* 是否为ai合成音效
*/
private Integer izAiAudio;
}

View File

@@ -0,0 +1,44 @@
package org.jeecg.modules.airag.video.vo;
import lombok.Data;
/**
* AI视频生成任务结果VO
*/
@Data
public class VideoTaskResultVo {
/**
* 任务ID
*/
private String taskId;
/**
* 任务状态: PROCESSING, SUCCESS, FAIL
*/
private String status;
/**
* 视频下载URLSUCCESS时有值
*/
private String videoUrl;
/**
* 视频封面URL如有
*/
private String coverUrl;
/**
* 带配音的视频URL
*/
private String voiceoverVideoUrl;
/**
* 旁白文案
*/
private String narration;
/**
* 错误信息FAIL时有值
*/
private String message;
}

View File

@@ -0,0 +1,91 @@
package org.jeecg.modules.airag.voice.controller;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.airag.voice.service.IVoiceService;
import org.jeecg.modules.airag.voice.vo.VoiceGenerateVo;
import org.jeecg.modules.airag.voice.vo.VoiceResultVo;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import com.alibaba.fastjson2.JSONObject;
import java.util.List;
/**
* 文生语音控制器
*/
@Slf4j
@RestController
@RequestMapping("/airag/voice")
public class VoiceController {
@Autowired
private IVoiceService voiceService;
/**
* 文本生成语音
*/
@PostMapping("/generate")
public Result<VoiceResultVo> generate(@RequestBody VoiceGenerateVo vo) {
// 参数校验
if (vo.getContent() == null || vo.getContent().isBlank()) {
return Result.error("合成文本不能为空");
}
if (vo.getSpeed() != null && (vo.getSpeed() < 0.25 || vo.getSpeed() > 4.0)) {
return Result.error("倍速范围须在0.25~4.0之间");
}
try {
VoiceResultVo result = voiceService.textToSpeech(vo);
return Result.OK(result);
} catch (Exception e) {
log.error("文生语音失败", e);
return Result.error("语音生成失败: " + e.getMessage());
}
}
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
/**
* 异步提交语音生成任务,立即返回 taskId
*/
@PostMapping("/generateAsync")
public Result<String> generateAsync(@RequestBody VoiceGenerateVo vo) {
if (vo.getContent() == null || vo.getContent().isBlank()) {
return Result.error("合成文本不能为空");
}
if (vo.getSpeed() != null && (vo.getSpeed() < 0.25 || vo.getSpeed() > 4.0)) {
return Result.error("倍速范围须在0.25~4.0之间");
}
String taskId = voiceService.generateAsync(vo);
return Result.OK(taskId);
}
/**
* 查询异步语音任务结果
*/
@GetMapping("/queryTask/{taskId}")
public Result<?> queryVoiceTask(@PathVariable String taskId) {
return voiceService.getVoiceTaskResult(taskId);
}
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
/**
* 查询当前用户的语音生成记录
*/
@GetMapping("/listByUser")
public Result<List<JSONObject>> getVoiceRecords(@RequestParam String userId) {
List<JSONObject> records = voiceService.getVoiceRecords(userId);
return Result.OK(records);
}
/**
* 删除语音生成记录
*/
@DeleteMapping("/deleteVoiceRecord")
public Result<String> deleteVoiceRecord(@RequestParam String userId, @RequestParam String recordId) {
boolean deleted = voiceService.deleteVoiceRecord(userId, recordId);
return deleted ? Result.OK("删除成功") : Result.error("记录不存在");
}
}

View File

@@ -0,0 +1,50 @@
package org.jeecg.modules.airag.voice.service;
import com.alibaba.fastjson2.JSONObject;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.airag.voice.vo.VoiceGenerateVo;
import org.jeecg.modules.airag.voice.vo.VoiceResultVo;
import java.util.List;
/**
* 文生语音服务接口
*/
public interface IVoiceService {
/**
* 文本转语音
* @param vo 请求参数
* @return 生成结果
*/
VoiceResultVo textToSpeech(VoiceGenerateVo vo);
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
/**
* 异步提交语音生成任务,立即返回 taskId
* @param vo 请求参数
* @return taskId
*/
String generateAsync(VoiceGenerateVo vo);
/**
* 查询异步语音任务结果
* @param taskId 任务ID
* @return 结果pending / success / failed
*/
Result<?> getVoiceTaskResult(String taskId);
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
/**
* 查询用户语音生成记录列表
* @return 记录列表
*/
List<JSONObject> getVoiceRecords(String userId);
/**
* 删除用户语音生成记录
* @param userId 用户ID
* @param recordId 记录ID
* @return 是否删除成功
*/
boolean deleteVoiceRecord(String userId, String recordId);
}

View File

@@ -0,0 +1,219 @@
package org.jeecg.modules.airag.voice.service.impl;
import com.alibaba.fastjson2.JSONObject;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.common.util.RedisUtil;
import org.jeecg.config.AiChatConfig;
import org.jeecg.config.JeecgBaseConfig;
import org.jeecg.modules.airag.voice.util.VoiceApiHelper;
import org.jeecg.modules.airag.voice.service.IVoiceService;
import org.jeecg.modules.airag.voice.vo.VoiceGenerateVo;
import org.jeecg.modules.airag.voice.vo.VoiceResultVo;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 文生语音服务实现智谱AI TTS
*/
@Slf4j
@Service
public class VoiceServiceImpl implements IVoiceService {
private static final String REDIS_KEY_PREFIX = "airag:voice:";
private static final String VOICE_TASK_PREFIX = "airag:voice:task:";
private static final long VOICE_TASK_TTL = 3600L;
@Autowired
private AiChatConfig aiChatConfig;
@Autowired
private JeecgBaseConfig jeecgBaseConfig;
@Autowired
private VoiceApiHelper ttsApiHelper;
@Resource
private RedisUtil redisUtil;
@Override
public VoiceResultVo textToSpeech(VoiceGenerateVo vo) {
LoginUser loginUser = null;
try {
loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
} catch (Exception e) {
log.warn("获取登录用户失败", e);
}
return textToSpeechWithUser(vo, loginUser);
}
/**
* 核心 TTS 逻辑,接受显式传入的 loginUser兼容同步调用和异步线程
*/
private VoiceResultVo textToSpeechWithUser(VoiceGenerateVo vo, LoginUser loginUser) {
AiChatConfig.VoiceModelConfig config = aiChatConfig.getAiModelVoice();
// 合并参数前端传值优先未传则取yml默认值
String voice = vo.getVoice() != null ? vo.getVoice() : config.getVoice();
double speed = vo.getSpeed() != null ? vo.getSpeed() : config.getSpeed();
try {
// 准备输出目录
String uploadPath = jeecgBaseConfig.getPath().getUpload();
String bizPath = "voice";
Path outputDir = Paths.get(uploadPath, bizPath);
Files.createDirectories(outputDir);
String fileName = "voice_" + System.currentTimeMillis() + ".wav";
Path audioFile = outputDir.resolve(fileName);
// 调用公共TTS API生成音频
ttsApiHelper.generateAudio(vo.getContent(), audioFile, voice, speed);
// 返回结果
String voiceUrl = bizPath + "/" + fileName;
VoiceResultVo result = new VoiceResultVo();
result.setVoiceUrl(voiceUrl);
result.setFileName(fileName);
// 存入Redis
saveToRedis(vo, fileName, voiceUrl, loginUser);
return result;
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
log.error("语音生成异常", e);
throw new RuntimeException("语音生成异常: " + e.getMessage(), e);
}
}
//update-begin---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
@Override
public String generateAsync(VoiceGenerateVo vo) {
String taskId = UUID.randomUUID().toString().replace("-", "");
String taskKey = VOICE_TASK_PREFIX + taskId;
JSONObject pending = new JSONObject();
pending.put("status", "pending");
redisUtil.set(taskKey, pending.toJSONString(), VOICE_TASK_TTL);
// 在异步线程执行前先获取登录用户,避免子线程中 Shiro 上下文丢失
LoginUser loginUser = null;
try {
loginUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
} catch (Exception e) {
log.warn("异步语音任务获取登录用户失败", e);
}
final LoginUser capturedUser = loginUser;
CompletableFuture.runAsync(() -> {
JSONObject result = new JSONObject();
try {
VoiceResultVo voiceResult = textToSpeechWithUser(vo, capturedUser);
result.put("status", "success");
result.put("voiceUrl", voiceResult.getVoiceUrl());
result.put("fileName", voiceResult.getFileName());
} catch (Exception e) {
log.error("异步语音生成失败: taskId={}", taskId, e);
result.put("status", "failed");
result.put("message", e.getMessage());
}
redisUtil.set(taskKey, result.toJSONString(), VOICE_TASK_TTL);
});
return taskId;
}
@Override
public Result<?> getVoiceTaskResult(String taskId) {
Object val = redisUtil.get(VOICE_TASK_PREFIX + taskId);
if (val == null) {
return Result.error("任务不存在或已过期");
}
JSONObject task = JSONObject.parseObject(val.toString());
String status = task.getString("status");
if ("success".equals(status)) {
JSONObject data = new JSONObject();
data.put("voiceUrl", task.getString("voiceUrl"));
data.put("fileName", task.getString("fileName"));
return Result.OK(data);
}
if ("failed".equals(status)) {
return Result.error(task.getString("message"));
}
return Result.OK("pending", null);
}
//update-end---author:wangshuai ---date:2026-04-15 for【QQYUN-14568】语音生成改为异步支持切换菜单后重新获取结果-----------
@Override
public List<JSONObject> getVoiceRecords(String userId) {
String redisKey = REDIS_KEY_PREFIX + userId;
List<Object> list = redisUtil.lGet(redisKey, 0, -1);
if (list == null || list.isEmpty()) {
return Collections.emptyList();
}
return list.stream()
.map(item -> JSONObject.parseObject(item.toString()))
.collect(java.util.stream.Collectors.toList());
}
@Override
public boolean deleteVoiceRecord(String userId, String recordId) {
String redisKey = REDIS_KEY_PREFIX + userId;
List<Object> list = redisUtil.lGet(redisKey, 0, -1);
if (list == null || list.isEmpty()) {
return false;
}
for (Object item : list) {
String json = item.toString();
JSONObject obj = JSONObject.parseObject(json);
if (recordId.equals(obj.getString("id"))) {
redisUtil.lRemove(redisKey, 1, item);
return true;
}
}
return false;
}
/**
* 将语音生成记录存入Redis
*/
private void saveToRedis(VoiceGenerateVo vo, String fileName, String voiceUrl, LoginUser loginUser) {
try {
if (loginUser == null) {
log.warn("未获取到登录用户跳过Redis存储");
return;
}
String redisKey = REDIS_KEY_PREFIX + loginUser.getId();
JSONObject record = new JSONObject();
record.put("id", UUID.randomUUID().toString().replace("-", ""));
record.put("content", vo.getContent());
record.put("voice", vo.getVoice());
record.put("speed", vo.getSpeed());
record.put("volume", vo.getVolume());
record.put("createTime", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
record.put("fileName", fileName);
record.put("voiceUrl", voiceUrl);
redisUtil.lSet(redisKey, record.toJSONString());
log.info("语音记录已存入Redis列表: key={}", redisKey);
} catch (Exception e) {
log.warn("语音记录存入Redis失败不影响主流程", e);
}
}
}

View File

@@ -0,0 +1,91 @@
package org.jeecg.modules.airag.voice.util;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.config.AiChatConfig;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
/**
* @Description: <p>统一封装 语音 HTTP API 调用逻辑,供语音模块和视频模块复用</p>
*
* @author: wangshuai
* @date: 2026/3/13 16:19
*/
@Slf4j
@Component
public class VoiceApiHelper {
@Autowired
private AiChatConfig aiChatConfig;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build();
/**
* 调用 TTS API 生成语音文件使用yml默认voice和speed
*
* @param text 要转换的文本
* @param audioPath 音频输出路径
*/
public void generateAudio(String text, Path audioPath) throws IOException, InterruptedException {
AiChatConfig.VoiceModelConfig config = aiChatConfig.getAiModelVoice();
generateAudio(text, audioPath, config.getVoice(), config.getSpeed());
}
/**
* 调用 TTS API 生成语音文件自定义voice和speed
*
* @param text 要转换的文本
* @param audioPath 音频输出路径
* @param voice 声色
* @param speed 语速
*/
public void generateAudio(String text, Path audioPath, String voice, double speed) throws IOException, InterruptedException {
AiChatConfig.VoiceModelConfig config = aiChatConfig.getAiModelVoice();
String apiHost = config.getApiHost();
String url = apiHost.endsWith("/") ? apiHost + "audio/speech" : apiHost + "/audio/speech";
JSONObject body = new JSONObject();
body.put("model", config.getModel());
body.put("input", text);
body.put("voice", voice);
body.put("speed", speed);
body.put("response_format", "wav");
log.info("TTS请求: url={}, model={}, voice={}, speed={}, textLength={}", url, config.getModel(), voice, speed, text.length());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + config.getApiKey())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toJSONString()))
.timeout(Duration.ofSeconds(config.getTimeout()))
.build();
HttpResponse<InputStream> response = httpClient.send(request, HttpResponse.BodyHandlers.ofInputStream());
if (response.statusCode() != 200) {
String errorBody = new String(response.body().readAllBytes());
log.error("TTS API调用失败: status={}, body={}", response.statusCode(), errorBody);
throw new RuntimeException("TTS API调用失败状态码: " + response.statusCode() + "" + errorBody);
}
try (InputStream is = response.body()) {
Files.copy(is, audioPath, StandardCopyOption.REPLACE_EXISTING);
}
log.info("TTS语音已生成: {}", audioPath);
}
}

View File

@@ -0,0 +1,26 @@
package org.jeecg.modules.airag.voice.vo;
import lombok.Data;
/**
* 文生语音请求VO
*/
@Data
public class VoiceGenerateVo {
/**
* 待合成文本(必填)
*/
private String content;
/**
* 声色不传用yml默认值
*/
private String voice;
/**
* 倍速范围0.25~4.0
*/
private Double speed;
/**
* 音量增益(dB)
*/
private Double volume;
}

View File

@@ -0,0 +1,18 @@
package org.jeecg.modules.airag.voice.vo;
import lombok.Data;
/**
* 文生语音响应VO
*/
@Data
public class VoiceResultVo {
/**
* 生成的音频文件相对路径
*/
private String voiceUrl;
/**
* 文件名
*/
private String fileName;
}

View File

@@ -15,8 +15,8 @@ import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.util.AssertUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.airag.wordtpl.dto.WordTplGenDTO;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.service.IEoaWordTemplateService;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
import org.jeecg.modules.airag.wordtpl.service.IAigcWordTemplateService;
import org.jeecg.modules.airag.wordtpl.utils.WordTplUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -37,12 +37,12 @@ import java.util.Arrays;
* @Version: V1.0
*/
@Tag(name = "word模版管理")
@RestController("eoaWordTemplateController")
@RestController("aigcWordTemplateController")
@RequestMapping("/airag/word")
@Slf4j
public class EoaWordTemplateController extends JeecgController<EoaWordTemplate, IEoaWordTemplateService> {
public class AigcWordTemplateController extends JeecgController<AigcWordTemplate, IAigcWordTemplateService> {
@Autowired
private IEoaWordTemplateService eoaWordTemplateService;
private IAigcWordTemplateService eoaWordTemplateService;
@Autowired
WordTplUtils wordTplUtils;
@@ -58,13 +58,13 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
*/
@Operation(summary = "word模版管理-分页列表查询")
@GetMapping(value = "/list")
public Result<IPage<EoaWordTemplate>> queryPageList(EoaWordTemplate eoaWordTemplate,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<EoaWordTemplate> queryWrapper = QueryGenerator.initQueryWrapper(eoaWordTemplate, req.getParameterMap());
Page<EoaWordTemplate> page = new Page<EoaWordTemplate>(pageNo, pageSize);
IPage<EoaWordTemplate> pageList = eoaWordTemplateService.page(page, queryWrapper);
public Result<IPage<AigcWordTemplate>> queryPageList(AigcWordTemplate eoaWordTemplate,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<AigcWordTemplate> queryWrapper = QueryGenerator.initQueryWrapper(eoaWordTemplate, req.getParameterMap());
Page<AigcWordTemplate> page = new Page<AigcWordTemplate>(pageNo, pageSize);
IPage<AigcWordTemplate> pageList = eoaWordTemplateService.page(page, queryWrapper);
return Result.OK(pageList);
}
@@ -78,10 +78,10 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
@Operation(summary = "word模版管理-添加")
// @RequiresPermissions("wordtpl:template:add")
@PostMapping(value = "/add")
public Result<String> add(@RequestBody EoaWordTemplate eoaWordTemplate) {
public Result<String> add(@RequestBody AigcWordTemplate eoaWordTemplate) {
AssertUtils.assertNotEmpty("参数异常", eoaWordTemplate);
AssertUtils.assertNotEmpty("模版名称不能为空", eoaWordTemplate.getName());
boolean isCodeExists = eoaWordTemplateService.exists(Wrappers.lambdaQuery(EoaWordTemplate.class).eq(EoaWordTemplate::getCode, eoaWordTemplate.getCode()));
boolean isCodeExists = eoaWordTemplateService.exists(Wrappers.lambdaQuery(AigcWordTemplate.class).eq(AigcWordTemplate::getCode, eoaWordTemplate.getCode()));
AssertUtils.assertFalse("模版编码已存在", isCodeExists);
eoaWordTemplateService.save(eoaWordTemplate);
return Result.OK("添加成功!");
@@ -97,7 +97,7 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
@Operation(summary = "word模版管理-编辑")
// @RequiresPermissions("wordtpl:template:edit")
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
public Result<String> edit(@RequestBody EoaWordTemplate eoaWordTemplate) {
public Result<String> edit(@RequestBody AigcWordTemplate eoaWordTemplate) {
AssertUtils.assertNotEmpty("参数异常", eoaWordTemplate);
AssertUtils.assertNotEmpty("模版名称不能为空", eoaWordTemplate.getName());
// 避免编辑时修改编码
@@ -145,8 +145,8 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
//@AutoLog(value = "word模版管理-通过id查询")
@Operation(summary = "word模版管理-通过id查询")
@GetMapping(value = "/queryById")
public Result<EoaWordTemplate> queryById(@RequestParam(name = "id", required = true) String id) {
EoaWordTemplate eoaWordTemplate = eoaWordTemplateService.getById(id);
public Result<AigcWordTemplate> queryById(@RequestParam(name = "id", required = true) String id) {
AigcWordTemplate eoaWordTemplate = eoaWordTemplateService.getById(id);
if (eoaWordTemplate == null) {
return Result.error("未找到对应数据");
}
@@ -164,7 +164,7 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
@GetMapping(value = "/download")
public void downloadTemplate(@RequestParam(name = "id", required = true) String id, HttpServletResponse response) {
AssertUtils.assertNotEmpty("请先选择模版", id);
EoaWordTemplate template = eoaWordTemplateService.getById(id);
AigcWordTemplate template = eoaWordTemplateService.getById(id);
try (ByteArrayOutputStream wordTemplateOut = new ByteArrayOutputStream();
BufferedOutputStream bos = new BufferedOutputStream(response.getOutputStream());) {
wordTplUtils.generateWordTemplate(template, wordTemplateOut);
@@ -195,7 +195,7 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
public Result<?> parseWOrdFile(@RequestParam("file") MultipartFile file) {
try {
InputStream inputStream = file.getInputStream();
EoaWordTemplate eoaWordTemplate = wordTplUtils.parseWordFile(inputStream);
AigcWordTemplate eoaWordTemplate = wordTplUtils.parseWordFile(inputStream);
log.info("解析的模版信息: {}", eoaWordTemplate);
return Result.OK("解析成功", eoaWordTemplate);
} catch (Exception e) {
@@ -214,13 +214,13 @@ public class EoaWordTemplateController extends JeecgController<EoaWordTemplate,
@PostMapping(value = "/generate/word")
public void generateWord(@RequestBody WordTplGenDTO wordTplGenDTO, HttpServletResponse response) {
AssertUtils.assertNotEmpty("参数异常", wordTplGenDTO);
EoaWordTemplate template ;
AigcWordTemplate template ;
if (oConvertUtils.isNotEmpty(wordTplGenDTO.getTemplateId())) {
template = eoaWordTemplateService.getById(wordTplGenDTO.getTemplateId());
}else{
AssertUtils.assertNotEmpty("请先选择模版", wordTplGenDTO.getTemplateCode());
template = eoaWordTemplateService.getOne(Wrappers.lambdaQuery(EoaWordTemplate.class)
.eq(EoaWordTemplate::getCode, wordTplGenDTO.getTemplateCode()));
template = eoaWordTemplateService.getOne(Wrappers.lambdaQuery(AigcWordTemplate.class)
.eq(AigcWordTemplate::getCode, wordTplGenDTO.getTemplateCode()));
}
AssertUtils.assertNotEmpty("未找到对应的模版", template);

View File

@@ -25,7 +25,7 @@ import java.util.Date;
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
@Schema(description = "word模版管理")
public class EoaWordTemplate implements Serializable {
public class AigcWordTemplate implements Serializable {
private static final long serialVersionUID = 1L;
/**

View File

@@ -1,7 +1,7 @@
package org.jeecg.modules.airag.wordtpl.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
/**
* @Description: word模版管理
@@ -9,6 +9,6 @@ import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
* @Date: 2025-07-04
* @Version: V1.0
*/
public interface EoaWordTemplateMapper extends BaseMapper<EoaWordTemplate> {
public interface AigcWordTemplateMapper extends BaseMapper<AigcWordTemplate> {
}

View File

@@ -0,0 +1,5 @@
<?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.wordtpl.mapper.AigcWordTemplateMapper">
</mapper>

View File

@@ -2,17 +2,17 @@ package org.jeecg.modules.airag.wordtpl.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.airag.wordtpl.dto.WordTplGenDTO;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
import java.io.ByteArrayOutputStream;
/**
/**aigc_word_template
* @Description: word模版管理
* @Author: jeecg-boot
* @Date: 2025-07-04
* @Version: V1.0
*/
public interface IEoaWordTemplateService extends IService<EoaWordTemplate> {
public interface IAigcWordTemplateService extends IService<AigcWordTemplate> {
/**
* 通过模版生成word文档

View File

@@ -8,9 +8,9 @@ import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.system.util.JwtUtil;
import org.jeecg.common.util.AssertUtils;
import org.jeecg.modules.airag.wordtpl.dto.WordTplGenDTO;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.mapper.EoaWordTemplateMapper;
import org.jeecg.modules.airag.wordtpl.service.IEoaWordTemplateService;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
import org.jeecg.modules.airag.wordtpl.mapper.AigcWordTemplateMapper;
import org.jeecg.modules.airag.wordtpl.service.IAigcWordTemplateService;
import org.jeecg.modules.airag.wordtpl.utils.WordTplUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -25,8 +25,8 @@ import java.util.Map;
* @Version: V1.0
*/
@Slf4j
@Service("eoaWordTemplateService")
public class EoaWordTemplateServiceImpl extends ServiceImpl<EoaWordTemplateMapper, EoaWordTemplate> implements IEoaWordTemplateService {
@Service("aigcWordTemplateService")
public class AigcWordTemplateServiceImpl extends ServiceImpl<AigcWordTemplateMapper, AigcWordTemplate> implements IAigcWordTemplateService {
/**
* 内置的系统变量键列表
@@ -50,7 +50,7 @@ public class EoaWordTemplateServiceImpl extends ServiceImpl<EoaWordTemplateMappe
AssertUtils.assertNotEmpty("模版ID不能为空", wordTplGenDTO.getTemplateId());
String templateId = wordTplGenDTO.getTemplateId();
// 生成word模版 date:2025/7/10
EoaWordTemplate template = getById(templateId);
AigcWordTemplate template = getById(templateId);
ByteArrayOutputStream wordTemplateOut = new ByteArrayOutputStream();
wordTplUtils.generateWordTemplate(template, wordTemplateOut);
//根据word模版和数据生成word文件

View File

@@ -12,7 +12,7 @@ import org.jeecg.common.util.CommonUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.airag.wordtpl.consts.WordTitleEnum;
import org.jeecg.modules.airag.wordtpl.dto.*;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.entity.AigcWordTemplate;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.*;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@@ -57,7 +57,7 @@ public class WordTplUtils {
* @author chenrui
* @date 2025/7/9 11:14
*/
public void generateWordTemplate(EoaWordTemplate template, ByteArrayOutputStream outputStream) {
public void generateWordTemplate(AigcWordTemplate template, ByteArrayOutputStream outputStream) {
AssertUtils.assertNotEmpty("模版数据不能为空", template);
XWPFDocument doc = new XWPFDocument();
@@ -100,7 +100,7 @@ public class WordTplUtils {
* @author chenrui
* @date 2025/7/10 17:52
*/
private static void renderHeaderAndFooter(EoaWordTemplate template, XWPFDocument doc) {
private static void renderHeaderAndFooter(AigcWordTemplate template, XWPFDocument doc) {
//页眉
JSONArray header = JSON.parseArray(template.getHeader());
if (oConvertUtils.isObjectNotEmpty(header)) {
@@ -172,7 +172,7 @@ public class WordTplUtils {
* @author chenrui
* @date 2025/7/4 14:00
*/
private void renderDocumentBody(XWPFDocument doc, EoaWordTemplate template) {
private void renderDocumentBody(XWPFDocument doc, AigcWordTemplate template) {
// TODO author: chenrui for:整理图表???? date:2025/7/4
@@ -337,9 +337,9 @@ public class WordTplUtils {
}
}
public EoaWordTemplate parseWordFile(InputStream wordFileIs) throws Exception {
public AigcWordTemplate parseWordFile(InputStream wordFileIs) throws Exception {
AssertUtils.assertNotEmpty("请上传word文档", wordFileIs);
EoaWordTemplate template = new EoaWordTemplate();
AigcWordTemplate template = new AigcWordTemplate();
XWPFDocument xwpfDocument = new XWPFDocument(wordFileIs);
CTSectPr sectPr = xwpfDocument.getDocument().getBody().getSectPr();
if (sectPr != null) {
@@ -969,7 +969,7 @@ public class WordTplUtils {
public static void main(String[] args) {
EoaWordTemplate template = new EoaWordTemplate();
AigcWordTemplate template = new AigcWordTemplate();
template.setHeight(1123);
template.setWidth(794);
template.setPaperDirection("vertical");

View File

@@ -709,12 +709,20 @@ public class WordUtil {
throw new JeecgBootException(e);
}
} else {
//update-begin---author:liusq ---date:2026-03-30 for[issues/9429]【安全漏洞】修复WordUtil.addImage路径遍历漏洞(CWE-22)-----------
String uploadPath = SpringContextUtils.getApplicationContext()
.getEnvironment()
.getProperty("jeecg.path.upload", "");
// 将本地图片读取到 InputStream
String filePath = uploadPath + File.separator + imageUrl;
in = new FileInputStream(filePath);
// 路径遍历校验规范化后确保文件在uploadPath目录内
File uploadDir = new File(uploadPath).getCanonicalFile();
File targetFile = new File(filePath).getCanonicalFile();
if (!targetFile.toPath().startsWith(uploadDir.toPath())) {
throw new JeecgBootException("非法文件路径,禁止访问上传目录之外的文件: " + imageUrl);
}
in = new FileInputStream(targetFile);
//update-end---author:liusq ---date:2026-03-30 for[issues/9429]【安全漏洞】修复WordUtil.addImage路径遍历漏洞(CWE-22)-----------
}
XWPFRun run = paragraph.createRun();

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>jeecg-boot-module</artifactId>
<groupId>org.jeecgframework.boot3</groupId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>jeecg-boot-module</artifactId>
<groupId>org.jeecgframework.boot3</groupId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>jeecg-boot-module</artifactId>
<groupId>org.jeecgframework.boot3</groupId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>
@@ -24,5 +24,10 @@
<artifactId>jeecg-system-biz</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-module-print</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>jeecg-boot-parent</artifactId>
<groupId>org.jeecgframework.boot3</groupId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -7,7 +7,7 @@
<parent>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-parent</artifactId>
<version>3.9.1</version>
<version>3.9.2</version>
<relativePath>../pom.xml</relativePath>
</parent>

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>jeecg-system-api</artifactId>
<groupId>org.jeecgframework.boot3</groupId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -20,11 +20,12 @@ import org.springframework.web.bind.annotation.RequestParam;
public interface IAiragBaseApi {
/**
* 知识库写入文本文档
* 知识库写入文本文档(支持自定义分段策略)
*
* @param knowledgeId 知识库ID
* @param title 文档标题
* @param content 文档内容
* @param knowledgeId 知识库ID
* @param title 文档标题
* @param content 文档内容
* @param segmentConfig 【可选】分段策略配置JSON
* @return 新增的文档ID
* @author sjlei
* @date 2025-12-30
@@ -33,7 +34,41 @@ public interface IAiragBaseApi {
String knowledgeWriteTextDocument(
@RequestParam("knowledgeId") String knowledgeId,
@RequestParam("title") String title,
@RequestParam("content") String content
@RequestParam("content") String content,
@RequestParam(value = "segmentConfig", required = false) String segmentConfig
);
/**
* 读取会话变量
*/
@PostMapping("/airag/api/getChatVariable")
String getChatVariable(
@RequestParam("appId") String appId,
@RequestParam("username") String username,
@RequestParam("name") String name
);
/**
* 设置会话变量
*/
@PostMapping("/airag/api/setChatVariable")
void setChatVariable(
@RequestParam("appId") String appId,
@RequestParam("username") String username,
@RequestParam("name") String name,
@RequestParam("value") String value
);
/**
* 根据应用ID查询记忆库ID
*/
@PostMapping("/airag/api/getMemoryIdByAppId")
String getMemoryIdByAppId(@RequestParam("appId") String appId);
/**
* 根据提示词ID查询提示词内容
*/
@PostMapping("/airag/api/getPromptContent")
String getPromptContent(@RequestParam("promptId") String promptId);
}

View File

@@ -9,7 +9,26 @@ public class AiragBaseApiFallback implements IAiragBaseApi {
private Throwable cause;
@Override
public String knowledgeWriteTextDocument(String knowledgeId, String title, String content) {
public String knowledgeWriteTextDocument(String knowledgeId, String title, String content, String segmentConfig) {
return null;
}
@Override
public String getChatVariable(String appId, String username, String name) {
return null;
}
@Override
public void setChatVariable(String appId, String username, String name, String value) {
}
@Override
public String getMemoryIdByAppId(String appId) {
return null;
}
@Override
public String getPromptContent(String promptId) {
return null;
}

View File

@@ -13,7 +13,7 @@ import java.util.List;
import java.util.Map;
/**
* @Description: 【Online】Feign API接口
* @Description: 【Online】online表单对外 Feign API接口
*
* @ConditionalOnMissingClass("org.jeecg.modules.online.cgform.service.impl.OnlineBaseExtApiImpl") => 有实现类的时候不实例化Feign接口
* @author: jeecg-boot

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

@@ -15,11 +15,11 @@ import org.jeecg.common.system.api.factory.SysBaseAPIFallbackFactory;
import org.jeecg.common.system.vo.*;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -130,7 +130,16 @@ public interface ISysBaseAPI extends CommonAPI {
*/
@GetMapping("/sys/api/getDepartParentIdsByDepIds")
Set<String> getDepartParentIdsByDepIds(@RequestParam("depIds") Set<String> depIds);
/**
* 8.4 通过 userIds 查询部门ID列表
*
* @param userIds
* @return key = userId; value = 用户拥有的部门ID列表
*/
@GetMapping("/sys/api/getDepartIdsByUserIds")
Map<String, List<String>> getDepartIdsByUserIds(@RequestParam("userIds") Collection<String> userIds);
/**
* 9通过用户账号查询部门 name
* @param username
@@ -912,4 +921,18 @@ public interface ISysBaseAPI extends CommonAPI {
*/
@PostMapping("/sys/api/uniPushMsgToUser")
void uniPushMsgToUser(@RequestBody PushMessageDTO pushMessageDTO);
/**
* 根据用户名查询用户主部门信息。
* <p>
* 逻辑取用户的主岗位mainDepPostId再查询该岗位节点在 sys_depart 中的父节点,
* 父节点即为用户的主部门,返回其信息。
* <p>
*
* @param username 用户账号
* @return 主部门信息,若用户未配置主岗位则返回 {@code null}
*/
@GetMapping("/sys/api/queryMainDepartByUsername")
SysDepartModel queryMainDepartByUsername(@RequestParam("username") String username);
}

View File

@@ -14,6 +14,7 @@ import org.jeecg.common.system.api.ISysBaseAPI;
import org.jeecg.common.system.vo.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -89,6 +90,11 @@ public class SysBaseAPIFallback implements ISysBaseAPI {
return null;
}
@Override
public Map<String, List<String>> getDepartIdsByUserIds(Collection<String> userIds) {
return Map.of();
}
@Override
public List<String> getDepartNamesByUsername(String username) {
return null;
@@ -517,6 +523,11 @@ public class SysBaseAPIFallback implements ISysBaseAPI {
}
@Override
public SysDepartModel queryMainDepartByUsername(String username) {
return null;
}
@Override
public String getDepartPathNameByOrgCode(String orgCode, String depId) {
return "";

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>jeecg-system-api</artifactId>
<groupId>org.jeecgframework.boot3</groupId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -9,15 +9,52 @@ package org.jeecg.common.airag.api;
public interface IAiragBaseApi {
/**
* 知识库写入文本文档
* 知识库写入文本文档(支持自定义分段策略)
*
* @param knowledgeId 知识库ID
* @param title 文档标题
* @param content 文档内容
* @param knowledgeId 知识库ID
* @param title 文档标题
* @param content 文档内容
* @param segmentConfig 【可选】分段策略配置JSON包含 segmentStrategy/separator/customSeparator/maxSegment/overlap/textRules
* @return 新增的文档ID
* @author sjlei
* @date 2025-12-30
*/
String knowledgeWriteTextDocument(String knowledgeId, String title, String content);
String knowledgeWriteTextDocument(String knowledgeId, String title, String content, String segmentConfig);
/**
* 读取会话变量
*
* @param appId 应用ID
* @param username 用户名
* @param name 变量名
* @return 变量值不存在时返回null
*/
String getChatVariable(String appId, String username, String name);
/**
* 设置会话变量
*
* @param appId 应用ID
* @param username 用户名
* @param name 变量名
* @param value 变量值
*/
void setChatVariable(String appId, String username, String name, String value);
/**
* 根据应用ID查询记忆库ID
* 当应用开启了记忆功能(izOpenMemory=1)时返回memoryId否则返回null
*
* @param appId 应用ID
* @return 记忆库ID未开启记忆功能时返回null
*/
String getMemoryIdByAppId(String appId);
/**
* 根据提示词ID查询提示词内容
* 供 LLM 节点关联模式在运行时动态加载提示词内容
*
* @param promptId 提示词表主键ID
* @return 提示词内容提示词不存在时返回null
*/
String getPromptContent(String promptId);
}

View File

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

View File

@@ -13,6 +13,7 @@ import org.jeecg.common.system.vo.*;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -121,6 +122,14 @@ public interface ISysBaseAPI extends CommonAPI {
*/
Set<String> getDepartParentIdsByDepIds(Set<String> depIds);
/**
* 8.4 通过 userIds 查询部门ID列表
*
* @param userIds
* @return key = userId; value = 用户拥有的部门ID列表
*/
Map<String, List<String>> getDepartIdsByUserIds(Collection<String> userIds);
/**
* 9通过用户账号查询部门 name
* @param username
@@ -644,4 +653,17 @@ public interface ISysBaseAPI extends CommonAPI {
* @param pushMessageDTO 推送消息
*/
void uniPushMsgToUser(PushMessageDTO pushMessageDTO);
/**
* 根据用户名查询用户主部门信息。
* <p>
* 逻辑取用户的主岗位mainDepPostId再查询该岗位节点在 sys_depart 中的父节点,
* 父节点即为用户的主部门,返回其信息。
* <p>
*
* @param username 用户账号
* @return 主部门信息,若用户未配置主岗位则返回 {@code null}
*/
SysDepartModel queryMainDepartByUsername(String username);
}

View File

@@ -5,7 +5,7 @@
<parent>
<artifactId>jeecg-module-system</artifactId>
<groupId>org.jeecgframework.boot3</groupId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>

View File

@@ -4,7 +4,7 @@
<parent>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-module-system</artifactId>
<version>3.9.1</version>
<version>3.9.2</version>
</parent>
<modelVersion>4.0.0</modelVersion>
@@ -21,15 +21,10 @@
</dependency>
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>hibernate-re</artifactId>
<artifactId>jeecg-online</artifactId>
</dependency>
<!-- AI大模型管理-->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-module-airag</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<!-- 企业微信/钉钉 api -->
<dependency>
<groupId>org.jeecgframework</groupId>
@@ -43,7 +38,7 @@
<!-- 积木报表 csv excel ES JSON mongodbSQL redis支持包
<dependency>
<groupId>org.jeecgframework.jimureport</groupId>
<artifactId>jimureport-nosql-starter</artifactId>
<artifactId>jimureport-nosql-starter3</artifactId>
</dependency>-->
<!-- 后台导出接口Echart图表支持包按需引入
<dependency>
@@ -61,21 +56,6 @@
<artifactId>jeecg-boot-module-airag</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-messaging</artifactId>
</dependency>
<dependency>
<groupId>org.apache.pdfbox</groupId>
<artifactId>pdfbox</artifactId>
<version>2.0.30</version>
</dependency>
<!-- 原生模板图片识别等打印扩展(与 jeecg-module-print 共用包名时需与本模块控制器二选一加载,此处引入服务 Bean -->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-module-print</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -1,74 +0,0 @@
package org.jeecg.modules.airag;
import com.alibaba.fastjson.JSON;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.util.DateUtils;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.airag.flow.component.enhance.IAiRagEnhanceJava;
import org.jeecg.modules.airag.wordtpl.entity.EoaWordTemplate;
import org.jeecg.modules.airag.wordtpl.service.IEoaWordTemplateService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Collections;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* @Description: JavaAIFlow增强节点:生成在线word文档
* @Author: chenrui
* @Date: 2025-08-06 16:39
*/
@Slf4j
@Component("jeecgDemoAiWordGen")
public class TestAiGenWordEnhance implements IAiRagEnhanceJava {
@Autowired
IEoaWordTemplateService eoaWordTemplateService;
@Override
public Map<String, Object> process(Map<String, Object> inputParams) {
Object resp = inputParams.get("resp");
String respStr = String.valueOf(resp);
log.info("AI生成word响应内容:{}", respStr);
if(oConvertUtils.isEmpty(respStr)){
throw new JeecgBootException("AI生成内容失败。请稍后再试或查看后台日志。");
}
String mainStr = null;
Matcher matcher = Pattern.compile("\\[.*]", Pattern.DOTALL).matcher(respStr);
if (matcher.find()) {
mainStr = matcher.group();
// 替换中文双引号为英文双引号
mainStr = mainStr.replaceAll("[“”]", "\"");
// 替换 NBSP 为普通空格
mainStr = mainStr.replaceAll("\\u00A0", " ");
log.info("生成word json:{}", mainStr);
// 校验是否为合法 JSON 字符串
try {
JSON.parse(mainStr);
} catch (Exception e) {
log.error(e.getMessage(), e);
throw new JeecgBootException("AI生成的内容不是合法的 JSON 字符串,请稍后再试或优化提示词。");
}
}else{
throw new JeecgBootException("AI生成的内容不是合法的 JSON 字符串,请稍后再试或优化提示词。");
}
EoaWordTemplate template = new EoaWordTemplate();
String dateFormat = DateUtils.formatDate();
template.setName("AI生成的简历_"+dateFormat);
template.setCode("AI_GEN_"+System.currentTimeMillis());
template.setHeader("[]");
template.setFooter("[]");
template.setMain(mainStr);
template.setWidth(794);
template.setHeight(1123);
template.setMargins("[100,120,100,120]");
template.setPaperDirection("vertical");
eoaWordTemplateService.save(template);
return Collections.singletonMap("result","success");
}
}

View File

@@ -18,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -196,6 +197,17 @@ public class SystemApiController {
return sysBaseApi.getDepartParentIdsByDepIds(depIds);
}
/**
* 通过 userIds 查询部门ID列表
*
* @param userIds
* @return key = userId; value = 用户拥有的部门ID列表
*/
@GetMapping("/getDepartIdsByUserIds")
Map<String, List<String>> getDepartIdsByUserIds(@RequestParam("userIds") Collection<String> userIds) {
return sysBaseApi.getDepartIdsByUserIds(userIds);
}
/**
* 通过用户账号查询部门 name
* @param username
@@ -1124,4 +1136,20 @@ public class SystemApiController {
public void uniPushMsgToUser(@RequestBody PushMessageDTO pushMessageDTO){
sysBaseApi.uniPushMsgToUser(pushMessageDTO);
}
/**
* 根据用户名查询用户主部门信息。
* <p>
* 逻辑取用户的主岗位mainDepPostId再查询该岗位节点在 sys_depart 中的父节点,
* 父节点即为用户的主部门,返回其信息。
* <p>
*
* @param username 用户账号
* @return 主部门信息,若用户未配置主岗位则返回 {@code null}
*/
@GetMapping("/queryMainDepartByUsername")
SysDepartModel queryMainDepartByUsername(@RequestParam("username") String username) {
return sysBaseApi.queryMainDepartByUsername(username);
}
}

View File

@@ -70,10 +70,16 @@ public final class XmlUtils {
*/
public static XMLReader getXmlReader() {
try {
final XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader();
//update-begin---author:wangshuai---date:2026-03-30---for:【issues/9422】XmlUtils.extractCustomAttributes可能存在疑似的外部实体依赖漏洞---
final SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
spf.setFeature("http://xml.org/sax/features/external-general-entities", false);
spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
spf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
final XMLReader reader = spf.newSAXParser().getXMLReader();
//update-end---author:wangshuai---date:2026-03-30---for:【issues/9422】XmlUtils.extractCustomAttributes可能存在疑似的外部实体依赖漏洞---
reader.setFeature("http://xml.org/sax/features/namespaces", true);
reader.setFeature("http://xml.org/sax/features/namespace-prefixes", false);
reader.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
return reader;
} catch (final Exception e) {
throw new RuntimeException("Unable to create XMLReader", e);
@@ -196,6 +202,12 @@ public final class XmlUtils {
spf.setNamespaceAware(true);
spf.setValidating(false);
try {
//update-begin---author:wangshuai---date:2026-03-30---for:【issues/9422】XmlUtils.extractCustomAttributes可能存在疑似的外部实体依赖漏洞---
spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
spf.setFeature("http://xml.org/sax/features/external-general-entities", false);
spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
spf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
//update-end---author:wangshuai---date:2026-03-30---for:【issues/9422】XmlUtils.extractCustomAttributes可能存在疑似的外部实体依赖漏洞---
final SAXParser saxParser = spf.newSAXParser();
final XMLReader xmlReader = saxParser.getXMLReader();
final CustomAttributeHandler handler = new CustomAttributeHandler();

View File

@@ -9,11 +9,13 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.google.common.collect.Lists;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.exception.JeecgBootBizTipException;
import org.jeecg.common.system.base.controller.JeecgController;
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.RestUtil;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.openapi.entity.OpenApi;
import org.jeecg.modules.openapi.entity.OpenApiAuth;
import org.jeecg.modules.openapi.entity.OpenApiHeader;
@@ -24,6 +26,7 @@ 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;
@@ -78,8 +81,13 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
* @param openApi
* @return
*/
@RequiresRoles({"admin"})
@PostMapping(value = "/add")
public Result<?> add(@RequestBody OpenApi openApi) {
if (openApi == null) {
return Result.error("请求参数不能为空");
}
validOriginUrl(openApi.getOriginUrl());
service.save(openApi);
return Result.ok("添加成功!");
}
@@ -90,8 +98,13 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
* @param openApi
* @return
*/
@RequiresRoles({"admin"})
@PutMapping(value = "/edit")
public Result<?> edit(@RequestBody OpenApi openApi) {
if (openApi == null) {
return Result.error("请求参数不能为空");
}
validOriginUrl(openApi.getOriginUrl());
service.updateById(openApi);
return Result.ok("修改成功!");
@@ -103,6 +116,7 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
* @param id
* @return
*/
@RequiresRoles({"admin"})
@DeleteMapping(value = "/delete")
public Result<?> delete(@RequestParam(name = "id", required = true) String id) {
service.removeById(id);
@@ -115,6 +129,7 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
* @param ids
* @return
*/
@RequiresRoles({"admin"})
@DeleteMapping(value = "/deleteBatch")
public Result<?> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
@@ -159,6 +174,8 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
}
String url = openApi.getOriginUrl();
// 校验原始接口路径是否合法
validOriginUrl(url);
String method = openApi.getRequestMethod();
String appkey = request.getHeader("appkey");
OpenApiAuth openApiAuth = openApiAuthService.getByAppkey(appkey);
@@ -167,7 +184,17 @@ 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);
url = RestUtil.getBaseUrl() + url;
//update-begin---author:scott ---date:20260429 for【issues/9590】微服务nginx部署openApi接口访问不到-----------
// originUrl 支持两种形式:
// 1) 相对路径(如 /house/houseTest/list拼接当前请求的 baseUrl
// 使用 CommonUtils.getBaseUrl(request)(而非 RestUtil.getBaseUrl()
// 可读取 X-Gateway-Base-Path 请求头,兼容微服务网关下的真实 base path
// 2) 完整URLhttp(s)://host:port/path直接使用适用于微服务模式下接口部署在其他微服务模块如 erp 7003的场景
String lowerUrl = url.toLowerCase();
if (!lowerUrl.startsWith("http://") && !lowerUrl.startsWith("https://")) {
url = CommonUtils.getBaseUrl(request) + url;
}
//update-end---author:scott ---date:20260429 for【issues/9590】微服务nginx部署openApi接口访问不到-----------
UriComponentsBuilder builder = UriComponentsBuilder.fromHttpUrl(url);
if (HttpMethod.GET.matches(method)
|| HttpMethod.DELETE.matches(method)
@@ -213,6 +240,51 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
return token;
}
/**
* 校验原始接口路径是否合法:
* - 相对路径:必须以 / 开头,不允许 // 和 .. 防止路径穿越
* - 完整URL仅允许 http/https 协议,禁止 file/ftp/gopher/jar/netdoc 等其它协议(用于微服务模式跨模块调用)
*/
private void validOriginUrl(String originUrl) {
if (oConvertUtils.isEmpty(originUrl)) {
throw new JeecgBootBizTipException("原始接口路径不能为空");
}
String decoded;
try {
decoded = java.net.URLDecoder.decode(originUrl, "UTF-8");
// 二次解码,防止 %252f 这类双重编码绕过
decoded = java.net.URLDecoder.decode(decoded, "UTF-8");
} catch (Exception e) {
throw new JeecgBootBizTipException("原始接口路径包含非法字符");
}
//update-begin---author:scott ---date:20260429 for【issues/9590】微服务nginx部署openApi接口访问不到-----------
// 微服务部署时OpenAPI 配置的接口可能位于其他微服务模块(如 erp 7003允许 originUrl 直接配置完整 http(s) URL
String lower = decoded.toLowerCase();
boolean isFullHttpUrl = lower.startsWith("http://") || lower.startsWith("https://");
if (!isFullHttpUrl) {
if (!decoded.startsWith("/")) {
throw new JeecgBootBizTipException("原始接口路径必须以 / 开头,或填写完整的 http(s) URL");
}
if (decoded.startsWith("//") || decoded.startsWith("/\\")) {
throw new JeecgBootBizTipException("原始接口路径不能以 // 或 /\\ 开头");
}
if (lower.contains("://") || lower.startsWith("file:") || lower.startsWith("ftp:") || lower.startsWith("gopher:")
|| lower.startsWith("jar:") || lower.startsWith("netdoc:")) {
throw new JeecgBootBizTipException("原始接口路径仅支持相对路径或 http(s) 完整URL");
}
} else {
// 即便是完整URL也禁止其它危险协议防止 http://x@file:/... 之类的绕过场景)
String afterScheme = lower.substring(lower.indexOf("://") + 3);
if (afterScheme.contains("file:") || afterScheme.contains("ftp:") || afterScheme.contains("gopher:")
|| afterScheme.contains("jar:") || afterScheme.contains("netdoc:")) {
throw new JeecgBootBizTipException("原始接口路径不允许嵌套 file/ftp/gopher/jar/netdoc 等协议");
}
}
if (decoded.contains("..")) {
throw new JeecgBootBizTipException("原始接口路径不能包含 ..");
}
//update-end---author:scott ---date:20260429 for【issues/9590】微服务nginx部署openApi接口访问不到-----------
}
@GetMapping("/json")
public SwaggerModel swaggerModel() {
@@ -382,7 +454,7 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
SwaggerInfo info = new SwaggerInfo();
info.setDescription("OpenAPI 接口列表");
info.setVersion("3.9.1");
info.setVersion("3.9.2");
info.setTitle("OpenAPI 接口列表");
info.setTermsOfService("https://jeecg.com");

View File

@@ -43,9 +43,16 @@ public class OpenApi implements Serializable {
private String requestUrl;
/**
* IP 名单
* IP 名单
*/
private String blackList;
private String whiteList;
//update-begin---author:scott ---date:20260417 for【PR/9083】OpenAPI新增白名单备注字段-----------
/**
* 白名单备注说明
*/
private String comment;
//update-end---author:scott ---date:20260417 for【PR/9083】OpenAPI新增白名单备注字段-----------
/**
* 请求头json
*/

View File

@@ -4,6 +4,7 @@ import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.exception.JeecgBootException;
import org.jeecg.common.util.IpUtils;
import org.jeecg.modules.openapi.entity.OpenApi;
import org.jeecg.modules.openapi.entity.OpenApiAuth;
import org.jeecg.modules.openapi.entity.OpenApiLog;
@@ -20,6 +21,7 @@ import java.security.MessageDigest;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
/**
* @date 2024/12/19 16:55
@@ -38,7 +40,7 @@ public class ApiAuthFilter implements Filter {
Date callTime = new Date();
HttpServletRequest request = (HttpServletRequest)servletRequest;
String ip = request.getRemoteAddr();
String ip = IpUtils.getIpAddr(request);
String appkey = request.getHeader("appkey");
String signature = request.getHeader("signature");
@@ -46,8 +48,8 @@ public class ApiAuthFilter implements Filter {
OpenApi openApi = findOpenApi(request);
// IP 名单核验
checkBlackList(openApi, ip);
// IP 名单核验
checkWhiteList(openApi, ip);
// 签名核验
checkSignValid(appkey, signature, timestamp);
@@ -80,22 +82,108 @@ public class ApiAuthFilter implements Filter {
this.openApiPermissionService = applicationContext.getBean(OpenApiPermissionService.class);
}
//update-begin---author:scott ---date:20260416 for【PR/9083】OpenAPI白名单增强支持CIDR网段和通配符匹配-----------
/**
* IP 名单核验
* IP 名单核验支持精确IP、CIDR网段如192.168.1.0/24、通配符如10.2.3.*
* @param openApi
* @param ip
*/
protected void checkBlackList(OpenApi openApi, String ip) {
if (!StringUtils.hasText(openApi.getBlackList())) {
protected void checkWhiteList(OpenApi openApi, String ip) {
if (!StringUtils.hasText(openApi.getWhiteList())) {
return;
}
List<String> blackList = Arrays.asList(openApi.getBlackList().split(","));
if (blackList.contains(ip)) {
throw new JeecgBootException("目标接口限制IP[" + ip + "]进行访问IP已记录请停止访问");
List<String> whiteList = Arrays.stream(openApi.getWhiteList().split("[,\\n]"))
.map(String::trim)
.filter(StringUtils::hasText)
.collect(Collectors.toList());
for (String item : whiteList) {
if (isIpMatch(ip, item)) {
return;
}
}
throw new JeecgBootException("IP[" + ip + "]不在白名单中,禁止访问");
}
/**
* IP匹配支持精确匹配、CIDR网段匹配、通配符匹配
* @param ip 客户端IP
* @param pattern 白名单条目IP/CIDR/通配符)
* @return 是否匹配
*/
private boolean isIpMatch(String ip, String pattern) {
if (!ip.contains(".") || !pattern.contains(".")) {
return ip.equals(pattern);
}
if (pattern.contains("/")) {
return isCidrMatch(ip, pattern);
}
if (pattern.contains("*")) {
return isWildcardMatch(ip, pattern);
}
return ip.equals(pattern);
}
/**
* CIDR网段匹配仅IPv4如 192.168.1.0/24
*/
private boolean isCidrMatch(String ip, String cidr) {
String[] parts = cidr.split("/");
if (parts.length != 2) {
return false;
}
try {
long ipLong = ipToLong(ip);
long cidrLong = ipToLong(parts[0]);
int prefixLength = Integer.parseInt(parts[1]);
if (prefixLength < 0 || prefixLength > 32) {
return false;
}
long mask = prefixLength == 0 ? 0 : (-1L << (32 - prefixLength));
return (ipLong & mask) == (cidrLong & mask);
} catch (Exception e) {
log.warn("CIDR匹配解析失败: cidr={}, ip={}", cidr, ip);
return false;
}
}
/**
* 通配符匹配,如 10.2.3.*
*/
private boolean isWildcardMatch(String ip, String pattern) {
String[] ipParts = ip.split("\\.");
String[] patternParts = pattern.split("\\.");
if (ipParts.length != 4 || patternParts.length != 4) {
return false;
}
for (int i = 0; i < 4; i++) {
if ("*".equals(patternParts[i])) {
continue;
}
if (!ipParts[i].equals(patternParts[i])) {
return false;
}
}
return true;
}
/**
* IPv4地址转long
*/
private long ipToLong(String ip) {
String[] parts = ip.split("\\.");
if (parts.length != 4) {
throw new IllegalArgumentException("非法IPv4地址: " + ip);
}
long result = 0;
for (int i = 0; i < 4; i++) {
result = (result << 8) | (Integer.parseInt(parts[i]) & 0xFF);
}
return result;
}
//update-end---author:scott ---date:20260416 for【PR/9083】OpenAPI白名单增强支持CIDR网段和通配符匹配-----------
/**
* 签名验证
* @param appkey

View File

@@ -32,6 +32,7 @@ public class OssFileController {
private IOssFileService ossFileService;
@ResponseBody
@RequiresPermissions("system:ossFile:list")
@GetMapping("/list")
public Result<IPage<OssFile>> queryPageList(OssFile file,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@@ -63,6 +64,7 @@ public class OssFileController {
}
@ResponseBody
@RequiresPermissions("system:ossFile:delete")
@DeleteMapping("/delete")
public Result delete(@RequestParam(name = "id") String id) {
Result result = new Result();

View File

@@ -1,344 +0,0 @@
package org.jeecg.modules.print.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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.print.PrintService;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.print.PageFormat;
import java.awt.print.Printable;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.print.entity.PrintTemplate;
import org.jeecg.modules.print.service.IPrintTemplateService;
import org.jeecg.modules.print.support.PrintServerEnvironmentService;
import org.jeecg.modules.print.support.PrintServerPdfJobService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.jeecg.modules.print.ai.INativePrintTemplateImageAnalyzeService;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import com.alibaba.fastjson.JSON;
/**
* 打印模板维护Hiprint
*/
@Slf4j
@Tag(name = "打印模板")
@RestController
@RequestMapping("/print/template")
public class PrintTemplateController extends JeecgController<PrintTemplate, IPrintTemplateService> {
@Autowired private PrintServerEnvironmentService printServerEnvironmentService;
@Autowired private PrintServerPdfJobService printServerPdfJobService;
@Autowired
private INativePrintTemplateImageAnalyzeService nativePrintTemplateImageAnalyzeService;
/**
* STOMP 实时通知:广播打印模板变更到 /topic/sync/print-templates。
* 直接用 SimpMessagingTemplate 内联推送,避免 jeecg-system-biz核心模块
* 反向依赖 jeecg-module-xslmes业务模块造成的循环依赖。
* 消息体格式与 MesXslStompNotifyService.publishPrintTemplateChanged 完全一致,
* 桌面端订阅方无需任何改动。
*/
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Operation(summary = "打印模板-分页列表")
@GetMapping(value = "/list")
@RequiresPermissions("print:template:list")
public Result<IPage<PrintTemplate>> list(
PrintTemplate query,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<PrintTemplate> qw = QueryGenerator.initQueryWrapper(query, req.getParameterMap());
qw.orderByDesc("create_time");
Page<PrintTemplate> page = new Page<>(pageNo, pageSize);
return Result.OK(service.page(page, qw));
}
@AutoLog(value = "打印模板-添加")
@Operation(summary = "打印模板-添加")
@PostMapping(value = "/add")
@RequiresPermissions("print:template:add")
public Result<String> add(@RequestBody PrintTemplate entity) {
if (StringUtils.isBlank(entity.getTemplateCode())) {
return Result.error("模板编码不能为空");
}
if (service.getByCode(entity.getTemplateCode()) != null) {
return Result.error("模板编码已存在");
}
if (StringUtils.isBlank(entity.getTemplateJson())) {
entity.setTemplateJson("{}");
}
service.save(entity);
publishPrintTemplateChanged("add", entity.getId());
return Result.OK("添加成功");
}
@AutoLog(value = "打印模板-编辑")
@Operation(summary = "打印模板-编辑")
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
@RequiresPermissions("print:template:edit")
public Result<String> edit(@RequestBody PrintTemplate entity) {
PrintTemplate db = service.getById(entity.getId());
if (db == null) {
return Result.error("记录不存在");
}
if (StringUtils.isNotBlank(entity.getTemplateCode())
&& !entity.getTemplateCode().equals(db.getTemplateCode())) {
if (service.getByCode(entity.getTemplateCode()) != null) {
return Result.error("模板编码已存在");
}
}
service.updateById(entity);
publishPrintTemplateChanged("edit", entity.getId());
return Result.OK("修改成功");
}
@AutoLog(value = "打印模板-保存JSON")
@Operation(summary = "打印模板-保存模板JSON")
@PostMapping(value = "/saveJson")
@RequiresPermissions("print:template:edit")
public Result<String> saveJson(@RequestBody Map<String, String> body) {
String id = body.get("id");
String templateJson = body.get("templateJson");
if (StringUtils.isBlank(id)) {
return Result.error("id 不能为空");
}
if (templateJson == null) {
return Result.error("templateJson 不能为空");
}
PrintTemplate db = service.getById(id);
if (db == null) {
return Result.error("记录不存在");
}
db.setTemplateJson(templateJson);
service.updateById(db);
return Result.OK("保存成功");
}
@AutoLog(value = "打印模板-删除")
@Operation(summary = "打印模板-删除")
@DeleteMapping(value = "/delete")
@RequiresPermissions("print:template:delete")
public Result<String> delete(@RequestParam(name = "id") String id) {
service.removeById(id);
publishPrintTemplateChanged("delete", id);
return Result.OK("删除成功");
}
@AutoLog(value = "打印模板-批量删除")
@Operation(summary = "打印模板-批量删除")
@DeleteMapping(value = "/deleteBatch")
@RequiresPermissions("print:template:delete")
public Result<String> deleteBatch(@RequestParam(name = "ids") String ids) {
if (StringUtils.isBlank(ids)) {
return Result.error("参数 ids 不能为空");
}
List<String> idList = java.util.Arrays.asList(ids.split(","));
service.removeByIds(idList);
idList.forEach(id -> publishPrintTemplateChanged("delete", id.trim()));
return Result.OK("批量删除成功");
}
@Operation(summary = "打印模板-通过id查询")
@GetMapping(value = "/queryById")
@RequiresPermissions("print:template:list")
public Result<PrintTemplate> queryById(@RequestParam(name = "id") String id) {
return Result.OK(service.getById(id));
}
@AutoLog(value = "打印模板-图片分析生成原生JSON")
@Operation(summary = "打印模板-上传图片分析为原生模板JSON前端传 imageBase64可接 OpenAI 兼容视觉模型)")
@PostMapping(value = "/analyzeImageForNative")
@RequiresPermissions("print:template:edit")
public Result<Map<String, Object>> analyzeImageForNative(@RequestBody Map<String, String> body) {
try {
String imageBase64 = body == null ? null : body.get("imageBase64");
if (StringUtils.isBlank(imageBase64)) {
return Result.error("imageBase64 不能为空");
}
String filename = body.get("filename");
String mime = body.get("mime");
byte[] bytes = decodeImageBase64(imageBase64);
return Result.OK(nativePrintTemplateImageAnalyzeService.analyzeBytes(bytes, mime, filename));
} catch (Exception e) {
log.error("图片分析失败", e);
return Result.error("图片分析失败:" + e.getMessage());
}
}
private static byte[] decodeImageBase64(String imageBase64) {
String s = StringUtils.trimToEmpty(imageBase64);
int comma = s.indexOf(',');
if (s.startsWith("data:") && comma > 0) {
s = s.substring(comma + 1);
}
return Base64.getDecoder().decode(s.replaceAll("\\s", ""));
}
@Operation(summary = "打印模板-通过编码查询")
@GetMapping(value = "/queryByCode")
@RequiresPermissions("print:template:list")
public Result<PrintTemplate> queryByCode(@RequestParam(name = "code") String code) {
PrintTemplate t = service.getByCode(code);
if (t == null) {
return Result.error("未找到模板: " + code);
}
return Result.OK(t);
}
@Operation(summary = "打印模板-查询可用打印机")
@GetMapping(value = "/queryPrinters")
@RequiresPermissions("print:template:list")
public Result<Map<String, Object>> queryPrinters() {
return Result.OK(printServerEnvironmentService.buildPrinterQueryResult());
}
@AutoLog(value = "打印模板-服务端直打")
@Operation(summary = "打印模板-服务端直打")
@PostMapping(value = "/directPrint")
@RequiresPermissions("print:template:list")
public Result<String> directPrint(@RequestBody Map<String, Object> body) {
String templateCode = String.valueOf(body.getOrDefault("templateCode", "")).trim();
String printerName = String.valueOf(body.getOrDefault("printerName", "")).trim();
Object dataJsonObj = body.get("dataJson");
String dataJsonText = dataJsonObj == null ? "" : String.valueOf(dataJsonObj);
if (StringUtils.isBlank(templateCode)) {
return Result.error("templateCode 不能为空");
}
if (StringUtils.isBlank(dataJsonText)) {
return Result.error("dataJson 不能为空");
}
PrintTemplate tpl = service.getByCode(templateCode);
if (tpl == null) {
return Result.error("模板不存在: " + templateCode);
}
try {
PrintService target = printServerPdfJobService.resolvePrintService(printerName);
if (StringUtils.isNotBlank(printerName)
&& !"__system_default__".equals(printerName)
&& target != null
&& !printerName.equalsIgnoreCase(String.valueOf(target.getName()).trim())) {
return Result.error("未找到指定打印机: " + printerName);
}
if (target == null) {
return Result.error("未找到可用打印机,请检查服务器打印机配置");
}
// 说明:当前接口实现的是服务端直打(纯文本)。若需按 hiprint 模板渲染版式,建议接入独立渲染服务。
String content =
"QH-MES 快速打印\n模板编号: "
+ templateCode
+ "\n模板名称: "
+ String.valueOf(tpl.getTemplateName())
+ "\n\n数据JSON:\n"
+ dataJsonText
+ "\n";
final String[] lines = content.replace("\r\n", "\n").split("\n", -1);
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(target);
job.setJobName("QH-MES-" + templateCode);
job.setPrintable(
new Printable() {
@Override
public int print(Graphics graphics, PageFormat pageFormat, int pageIndex) throws PrinterException {
Graphics2D g2 = (Graphics2D) graphics;
g2.translate(pageFormat.getImageableX(), pageFormat.getImageableY());
g2.setFont(new Font("Microsoft YaHei", Font.PLAIN, 10));
int lineHeight = g2.getFontMetrics().getHeight() + 2;
int maxLinesPerPage = Math.max(1, (int) (pageFormat.getImageableHeight() / lineHeight));
int start = pageIndex * maxLinesPerPage;
if (start >= lines.length) {
return Printable.NO_SUCH_PAGE;
}
int end = Math.min(lines.length, start + maxLinesPerPage);
int y = g2.getFontMetrics().getAscent();
for (int i = start; i < end; i += 1) {
g2.drawString(lines[i], 0, y);
y += lineHeight;
}
return Printable.PAGE_EXISTS;
}
});
job.print();
return Result.OK("已提交到服务器打印机: " + target.getName());
} catch (Exception e) {
log.error("服务端直打失败", e);
return Result.error("服务端直打失败: " + e.getMessage());
}
}
@AutoLog(value = "打印模板-PDF后端打印")
@Operation(summary = "打印模板-PDF后端打印")
@PostMapping(value = "/directPrintPdf")
@RequiresPermissions("print:template:list")
public Result<String> directPrintPdf(@RequestBody Map<String, Object> body) {
String templateCode = String.valueOf(body.getOrDefault("templateCode", "")).trim();
String printerName = String.valueOf(body.getOrDefault("printerName", "")).trim();
String pdfBase64 = String.valueOf(body.getOrDefault("pdfBase64", "")).trim();
String fileName = String.valueOf(body.getOrDefault("fileName", "")).trim();
if (StringUtils.isBlank(templateCode)) {
return Result.error("templateCode 不能为空");
}
return printServerPdfJobService.submitPdfBase64(printerName, pdfBase64, fileName, templateCode);
}
// ═══════════════════════════ 桌面端免密接口 ═══════════════════════════
@Operation(summary = "打印模板-免密通过编码查询(桌面端)")
@GetMapping(value = "/anon/queryByCode")
public Result<PrintTemplate> anonQueryByCode(@RequestParam(name = "code") String code) {
PrintTemplate t = service.getByCode(code);
if (t == null) {
return Result.error("未找到模板: " + code);
}
return Result.OK(t);
}
@Operation(summary = "打印模板-免密分页列表(桌面端)")
@GetMapping(value = "/anon/list")
public Result<IPage<PrintTemplate>> anonList(PrintTemplate query,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "100") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<PrintTemplate> qw = QueryGenerator.initQueryWrapper(query, req.getParameterMap());
qw.orderByAsc("template_code");
Page<PrintTemplate> page = new Page<>(pageNo, pageSize);
return Result.OK(service.page(page, qw));
}
/**
* 广播打印模板变更事件到 /topic/sync/print-templates桌面端订阅同步刷新本地缓存。
* 消息体格式 = MesXslStompNotifyService.publishPrintTemplateChanged 的输出,
* 内联实现避免反向依赖业务模块。
*/
private void publishPrintTemplateChanged(String action, String templateId) {
try {
Map<String, Object> event = new HashMap<>();
event.put("cmd", "PRINT_TEMPLATE_CHANGED");
event.put("action", action);
event.put("templateId", templateId);
event.put("timestamp", System.currentTimeMillis());
messagingTemplate.convertAndSend("/topic/sync/print-templates", JSON.toJSONString(event));
} catch (Exception e) {
log.debug("广播 STOMP 事件失败 [PRINT_TEMPLATE_CHANGED]: {}", e.getMessage());
}
}
}

View File

@@ -21,6 +21,7 @@ import javax.print.attribute.HashPrintRequestAttributeSet;
import javax.print.attribute.standard.JobName;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.jeecg.common.api.vo.Result;
@@ -61,7 +62,7 @@ public class PrintServerPdfJobService {
if (tryPrintPdfBytesWithDocFlavor(target, pdfBytes, printJobName)) {
return Result.OK("已提交PDF到服务器打印机: " + resolvedPrinterLabel);
}
try (PDDocument document = PDDocument.load(new ByteArrayInputStream(pdfBytes))) {
try (PDDocument document = Loader.loadPDF(pdfBytes)) {
PDFRenderer renderer = new PDFRenderer(document);
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(target);

View File

@@ -174,9 +174,22 @@ public class QuartzJobServiceImpl extends ServiceImpl<QuartzJobMapper, QuartzJob
}
}
/**
* 安全加载Job类仅允许 org.jeecg. 包下的类,且必须实现 org.quartz.Job 接口
*/
private static Job getClass(String classname) throws Exception {
Class<?> class1 = Class.forName(classname);
return (Job) class1.newInstance();
// 包名白名单校验防止任意类实例化导致RCE
if (classname == null || !classname.startsWith("org.jeecg.")) {
throw new IllegalArgumentException("非法的任务类名:" + classname + ",仅允许 org.jeecg 包下的Job类");
}
//update-begin---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
Class<?> clazz = Class.forName(classname, true, Thread.currentThread().getContextClassLoader());
//update-end---author:scott ---date:20260416 for【PR#9538】Class.forName使用上下文类加载器增强部署兼容性-----------
// 校验是否实现了 org.quartz.Job 接口
if (!Job.class.isAssignableFrom(clazz)) {
throw new IllegalArgumentException("非法的任务类:" + classname + ",必须实现 org.quartz.Job 接口");
}
return (Job) clazz.getDeclaredConstructor().newInstance();
}
}

Some files were not shown because too many files have changed in this diff Show More