mirror of
https://github.com/jeecgboot/JeecgBoot.git
synced 2026-04-30 05:38:31 +00:00
v3.9.2 王炸!大版本后端
This commit is contained in:
parent
11b1ab81d7
commit
06955e4ad5
57
jeecg-boot/.claudeignore
Normal file
57
jeecg-boot/.claudeignore
Normal 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/
|
||||
152
jeecg-boot/CLAUDE.md
Normal file
152
jeecg-boot/CLAUDE.md
Normal file
@ -0,0 +1,152 @@
|
||||
# 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 Paths
|
||||
|
||||
| Project | Path |
|
||||
|-------------------------------|----------------------------------------------------------|
|
||||
| Frontend (Vue3 + TS + Vite) | `E:\workspace-cc-jeecg\jeecgboot-vue3-2026` |
|
||||
| Backend (Spring Boot 3.5.5) | `E:\workspace-cc-jeecg\jeecg-boot-framework-2026` |
|
||||
| Base Starter (底层共通依赖) | `E:\workspace-cc-jeecg\1jeecg-boot-starter-springboot3` |
|
||||
| 底层针对poi封装的excel工具项目 | `E:\workspace-cc-jeecg\1autopoi-github` |
|
||||
| Technical Docs (Docusaurus) | `E:\workspace-cc-jeecg\jeecgboot_docus_site` |
|
||||
|
||||
## Project Overview
|
||||
|
||||
JeecgBoot 3.9.1 — 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)**
|
||||
@ -32,6 +32,19 @@ services:
|
||||
networks:
|
||||
- jeecg-boot
|
||||
|
||||
jeecg-boot-pgvector:
|
||||
image: registry.cn-hangzhou.aliyuncs.com/jeecgdocker/pgvector
|
||||
container_name: jeecg-boot-pgvector
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: vector_db
|
||||
# ports:
|
||||
# - 5432:5432
|
||||
restart: always
|
||||
networks:
|
||||
- jeecg-boot
|
||||
|
||||
# jeecg-boot-rabbitmq:
|
||||
# image: rabbitmq:3.7.7-management
|
||||
## ports:
|
||||
@ -64,19 +77,6 @@ services:
|
||||
volumes:
|
||||
- ./config:/jeecg-boot/config
|
||||
|
||||
jeecg-boot-pgvector:
|
||||
image: registry.cn-hangzhou.aliyuncs.com/jeecgdocker/pgvector
|
||||
container_name: jeecg-boot-pgvector
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: vector_db
|
||||
# ports:
|
||||
# - 5432:5432
|
||||
restart: always
|
||||
networks:
|
||||
- jeecg-boot
|
||||
|
||||
networks:
|
||||
jeecg-boot:
|
||||
name: jeecg_boot
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -249,7 +249,7 @@ public interface CommonConstant {
|
||||
String UPLOAD_TYPE_OSS = "alioss";
|
||||
|
||||
/**
|
||||
* 文档上传自定义桶名称
|
||||
* 文档上传自定义桶名称 (私有加密桶名)—— 知识库功能
|
||||
*/
|
||||
String UPLOAD_CUSTOM_BUCKET = "eoafile";
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -23,7 +23,11 @@ public enum UniPushTypeEnum {
|
||||
/**
|
||||
* 系统消息
|
||||
*/
|
||||
SYS_MSG("system", "系统消息", "收到一条系统通告");
|
||||
SYS_MSG("system", "系统消息", "收到一条系统通告"),
|
||||
/**
|
||||
* 协同工作
|
||||
*/
|
||||
COLLABORATION_MSG("collaboration", "系统消息", "收到一条协同工作消息");
|
||||
|
||||
/**
|
||||
* 业务类型(chat:聊天 bpm_task:流程 bpm_cc:流程抄送)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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秒
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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!");
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 注入漏洞
|
||||
// 时间盲注函数(#9523):MySQL 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) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,拒绝 loopback(127.x / ::1)和 link-local(169.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 逗号分隔的文件路径
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
* postgre:authenticationPluginClassName, 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 + "】,如需支持请联系管理员");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -21,4 +21,29 @@ public class AiRagConfigBean {
|
||||
* stdio mpc命令行功能开启,sql:AI流程SQL节点开启
|
||||
*/
|
||||
private String allowSensitiveNodes = "";
|
||||
|
||||
//update-begin---author:wangshuai ---date:2026-04-15 for:Brave 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 for:Brave Search配置迁移到AiRagConfigBean,去掉enabled字段,apiKey为空即不启用-----------
|
||||
}
|
||||
|
||||
@ -93,43 +93,40 @@ public class MybatisPlusSaasConfig {
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
// 先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor
|
||||
// 条件注册租户拦截器:当需要租户隔离时才添加
|
||||
if (OPEN_SYSTEM_TENANT_CONTROL) {
|
||||
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||||
@Override
|
||||
public Expression getTenantId() {
|
||||
String tenantId = TenantContext.getTenant();
|
||||
//如果通过线程获取租户ID为空,则通过当前请求的request获取租户(shiro排除拦截器的请求会获取不到租户ID)
|
||||
if (oConvertUtils.isEmpty(tenantId)) {
|
||||
try {
|
||||
tenantId = TokenUtils.getTenantIdByRequest(SpringContextUtils.getHttpServletRequest());
|
||||
} catch (Exception e) {
|
||||
//e.printStackTrace();
|
||||
}
|
||||
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {
|
||||
@Override
|
||||
public Expression getTenantId() {
|
||||
String tenantId = TenantContext.getTenant();
|
||||
//如果通过线程获取租户ID为空,则通过当前请求的request获取租户(shiro排除拦截器的请求会获取不到租户ID)
|
||||
if(oConvertUtils.isEmpty(tenantId)){
|
||||
try {
|
||||
tenantId = TokenUtils.getTenantIdByRequest(SpringContextUtils.getHttpServletRequest());
|
||||
} catch (Exception e) {
|
||||
//e.printStackTrace();
|
||||
}
|
||||
if (oConvertUtils.isEmpty(tenantId)) {
|
||||
tenantId = "0";
|
||||
}
|
||||
return new LongValue(tenantId);
|
||||
}
|
||||
if(oConvertUtils.isEmpty(tenantId)){
|
||||
tenantId = "0";
|
||||
}
|
||||
return new LongValue(tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTenantIdColumn() {
|
||||
return TenantConstant.TENANT_ID_TABLE;
|
||||
}
|
||||
@Override
|
||||
public String getTenantIdColumn(){
|
||||
return TenantConstant.TENANT_ID_TABLE;
|
||||
}
|
||||
|
||||
// 返回 true 表示不走租户逻辑
|
||||
@Override
|
||||
public boolean ignoreTable(String tableName) {
|
||||
for (String temp : TENANT_TABLE) {
|
||||
if (temp.equalsIgnoreCase(tableName)) {
|
||||
return false;
|
||||
}
|
||||
// 返回 true 表示不走租户逻辑
|
||||
@Override
|
||||
public boolean ignoreTable(String tableName) {
|
||||
for(String temp: TENANT_TABLE){
|
||||
if(temp.equalsIgnoreCase(tableName)){
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}));
|
||||
//update-begin-author:zyf date:20220425 for:【VUEN-606】注入动态表名适配拦截器解决多表名问题
|
||||
interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor());
|
||||
//update-end-author:zyf date:20220425 for:【VUEN-606】注入动态表名适配拦截器解决多表名问题
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
|
||||
}
|
||||
|
||||
@ -173,4 +173,9 @@ public class Prompts {
|
||||
*/
|
||||
public static final String AI_TOUCHE_PROMPT = "请针对如下内容:[{}] 进行润色。 回复格式:{},语气:{},语言:{},长度:{}。";
|
||||
|
||||
/**
|
||||
* ai绘画提示词
|
||||
*/
|
||||
public static final String AI_DRAW_PROMPT = "风格:{},视角:{},人物镜头:{},灯光:{},图片尺寸:{};";
|
||||
|
||||
}
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -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写作
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
@ -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 Result,data为图片URL(成功)或status=pending/failed
|
||||
*/
|
||||
Result<?> getAiPosterResult(String taskId);
|
||||
//update-end---author:wangshuai ---date:2026-04-15 for:【QQYUN-14568】AI海报生成改为异步,支持切换菜单后重新获取结果-----------
|
||||
|
||||
/**
|
||||
* 生成ai创作
|
||||
|
||||
@ -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);
|
||||
|
||||
/**
|
||||
* 添加变量更新工具
|
||||
*
|
||||
|
||||
@ -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 for:Brave 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 for:Brave 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());
|
||||
}
|
||||
|
||||
@ -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: 返回结构化JSON,LLM可明确感知"已全部完成"
|
||||
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重复调用-----------
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -88,6 +88,11 @@ public class LLMConsts {
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_METADATA_SOURCES_PATH = "sourcesPath";
|
||||
|
||||
/**
|
||||
* 知识库:文档元数据:网页URL
|
||||
*/
|
||||
public static final String KNOWLEDGE_DOC_METADATA_WEBSITE = "website";
|
||||
|
||||
/**
|
||||
* DEEPSEEK推理模型
|
||||
*/
|
||||
@ -118,4 +123,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";
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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){
|
||||
|
||||
@ -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(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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,12 @@ 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.modules.airag.common.consts.AiragConsts;
|
||||
import org.jeecg.modules.airag.common.handler.AIChatParams;
|
||||
import org.jeecg.modules.airag.common.handler.IAIChatHandler;
|
||||
@ -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,19 @@ public class AIChatHandler implements IAIChatHandler {
|
||||
*/
|
||||
public String completions(AiragModel airagModel, List<ChatMessage> messages, AIChatParams params) {
|
||||
params = mergeParams(airagModel, params);
|
||||
String resp;
|
||||
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 +183,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);
|
||||
}
|
||||
|
||||
@ -256,6 +246,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 +275,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
|
||||
@ -435,7 +435,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 +449,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 +538,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 +561,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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 for:zip文件 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 for:zip文件 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;
|
||||
}
|
||||
|
||||
@ -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路径遍历漏洞修复---
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取参数
|
||||
*
|
||||
|
||||
@ -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攻击
|
||||
*
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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("记录不存在");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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");
|
||||
|
||||
// 前端传递 izAiAudio:1=使用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 API(VoiceApiHelper)生成语音,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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 视频下载URL(SUCCESS时有值)
|
||||
*/
|
||||
private String videoUrl;
|
||||
|
||||
/**
|
||||
* 视频封面URL(如有)
|
||||
*/
|
||||
private String coverUrl;
|
||||
|
||||
/**
|
||||
* 带配音的视频URL
|
||||
*/
|
||||
private String voiceoverVideoUrl;
|
||||
|
||||
/**
|
||||
* 旁白文案
|
||||
*/
|
||||
private String narration;
|
||||
|
||||
/**
|
||||
* 错误信息(FAIL时有值)
|
||||
*/
|
||||
private String message;
|
||||
}
|
||||
@ -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("记录不存在");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
@ -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> {
|
||||
|
||||
}
|
||||
@ -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>
|
||||
@ -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文档
|
||||
@ -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文件
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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 "";
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
}
|
||||
|
||||
@ -9,12 +9,12 @@ import dev.langchain4j.agent.tool.ToolSpecification;
|
||||
import dev.langchain4j.model.chat.request.json.JsonObjectSchema;
|
||||
import dev.langchain4j.service.tool.ToolExecutor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.jeecg.common.constant.CommonConstant;
|
||||
import org.jeecg.common.util.PasswordUtil;
|
||||
import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.airag.llm.handler.JeecgToolsProvider;
|
||||
import org.jeecg.modules.base.service.BaseCommonService;
|
||||
import org.jeecg.modules.system.controller.SysUserController;
|
||||
import org.jeecg.modules.system.entity.SysRole;
|
||||
import org.jeecg.modules.system.entity.SysUser;
|
||||
import org.jeecg.modules.system.mapper.SysUserMapper;
|
||||
@ -50,18 +50,31 @@ public class JeecgBizToolsProvider implements JeecgToolsProvider {
|
||||
@Autowired
|
||||
private org.jeecg.modules.system.service.ISysUserService sysUserService;
|
||||
|
||||
public Map<ToolSpecification, ToolExecutor> getDefaultTools(){
|
||||
public Map<ToolSpecification, ToolExecutor> getDefaultTools() {
|
||||
Map<ToolSpecification, ToolExecutor> tools = new HashMap<>();
|
||||
JeecgLlmTools userTool = queryUserTool();
|
||||
tools.put(userTool.getToolSpecification(), userTool.getToolExecutor());
|
||||
JeecgLlmTools addUser = addUserTool();
|
||||
tools.put(addUser.getToolSpecification(), addUser.getToolExecutor());
|
||||
// 新增:查询所有角色
|
||||
JeecgLlmTools queryRoles = queryAllRolesTool();
|
||||
tools.put(queryRoles.getToolSpecification(), queryRoles.getToolExecutor());
|
||||
// 新增:给用户授予角色
|
||||
JeecgLlmTools grantRoles = grantUserRolesTool();
|
||||
tools.put(grantRoles.getToolSpecification(), grantRoles.getToolExecutor());
|
||||
|
||||
if (SecurityUtils.getSubject().isPermitted("system:user:list")) {
|
||||
JeecgLlmTools userTool = queryUserTool();
|
||||
tools.put(userTool.getToolSpecification(), userTool.getToolExecutor());
|
||||
}
|
||||
|
||||
if (SecurityUtils.getSubject().isPermitted("system:user:add")) {
|
||||
JeecgLlmTools addUser = addUserTool();
|
||||
tools.put(addUser.getToolSpecification(), addUser.getToolExecutor());
|
||||
}
|
||||
|
||||
if (SecurityUtils.getSubject().isPermitted("system:role:list")) {
|
||||
// 新增:查询所有角色
|
||||
JeecgLlmTools queryRoles = queryAllRolesTool();
|
||||
tools.put(queryRoles.getToolSpecification(), queryRoles.getToolExecutor());
|
||||
}
|
||||
|
||||
if (SecurityUtils.getSubject().isPermitted("system:user:addUserRole")) {
|
||||
// 新增:给用户授予角色
|
||||
JeecgLlmTools grantRoles = grantUserRolesTool();
|
||||
tools.put(grantRoles.getToolSpecification(), grantRoles.getToolExecutor());
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
@ -91,7 +104,15 @@ public class JeecgBizToolsProvider implements JeecgToolsProvider {
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
|
||||
// 在主线程(Shiro上下文可用)提前检查权限
|
||||
final boolean hasAddPermission = SecurityUtils.getSubject().isPermitted("system:user:add");
|
||||
|
||||
ToolExecutor toolExecutor = (toolExecutionRequest, memoryId) -> {
|
||||
// 权限校验(使用提前捕获的结果,避免在异步线程中调用 Shiro)
|
||||
if (!hasAddPermission) {
|
||||
return "无权限:您没有添加用户的权限(system:user:add)";
|
||||
}
|
||||
JSONObject arguments = JSONObject.parseObject(toolExecutionRequest.arguments());
|
||||
arguments.put("confirmPassword",arguments.get("password"));
|
||||
arguments.put("userIdentity",1);
|
||||
@ -147,7 +168,15 @@ public class JeecgBizToolsProvider implements JeecgToolsProvider {
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
|
||||
// 在主线程(Shiro上下文可用)提前检查权限
|
||||
final boolean hasListPermission = SecurityUtils.getSubject().isPermitted("system:user:list");
|
||||
|
||||
ToolExecutor toolExecutor = (toolExecutionRequest, memoryId) -> {
|
||||
// 权限校验(使用提前捕获的结果,避免在异步线程中调用 Shiro)
|
||||
if (!hasListPermission) {
|
||||
return "无权限:您没有查询用户列表的权限";
|
||||
}
|
||||
SysUser args = JSONObject.parseObject(toolExecutionRequest.arguments(), SysUser.class);
|
||||
QueryWrapper<SysUser> qw = new QueryWrapper<>();
|
||||
if (StringUtils.isNotBlank(args.getUsername())) {
|
||||
@ -190,7 +219,15 @@ public class JeecgBizToolsProvider implements JeecgToolsProvider {
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
|
||||
// 在主线程(Shiro上下文可用)提前检查权限
|
||||
final boolean hasRoleListPermission = SecurityUtils.getSubject().isPermitted("system:role:list");
|
||||
|
||||
ToolExecutor exec = (toolExecutionRequest, memoryId) -> {
|
||||
// 权限校验(使用提前捕获的结果,避免在异步线程中调用 Shiro)
|
||||
if (!hasRoleListPermission) {
|
||||
return "无权限:您没有查询角色列表的权限";
|
||||
}
|
||||
// 做租户隔离查询(若开启)
|
||||
SysRole sysRole = JSONObject.parseObject(toolExecutionRequest.arguments(), SysRole.class);
|
||||
QueryWrapper<SysRole> qw = Wrappers.query();
|
||||
@ -234,7 +271,16 @@ public class JeecgBizToolsProvider implements JeecgToolsProvider {
|
||||
.build()
|
||||
)
|
||||
.build();
|
||||
|
||||
// 在主线程(Shiro上下文可用)提前检查权限
|
||||
final boolean hasGrantPermission = SecurityUtils.getSubject().isPermitted("system:user:addUserRole")
|
||||
|| SecurityUtils.getSubject().isPermitted("system:user:edit");
|
||||
|
||||
ToolExecutor exec = (toolExecutionRequest, memoryId) -> {
|
||||
// 权限校验(使用提前捕获的结果,避免在异步线程中调用 Shiro)
|
||||
if (!hasGrantPermission) {
|
||||
return "无权限:您没有给用户授予角色的权限";
|
||||
}
|
||||
JSONObject args = JSONObject.parseObject(toolExecutionRequest.arguments());
|
||||
String userId = args.getString("userId");
|
||||
String roleIdsStr = args.getString("roleIds");
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
package org.jeecg.modules.message.websocket;
|
||||
|
||||
import java.io.EOFException;
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import jakarta.websocket.*;
|
||||
@ -56,7 +58,7 @@ public class WebSocket {
|
||||
sessionPool.remove(userId);
|
||||
log.debug("【系统 WebSocket】连接断开,总数为:" + sessionPool.size());
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
log.error("【系统 WebSocket】连接断开异常", e);
|
||||
}
|
||||
}
|
||||
|
||||
@ -135,8 +137,13 @@ public class WebSocket {
|
||||
*/
|
||||
@OnError
|
||||
public void onError(Session session, Throwable t) {
|
||||
log.warn("【系统 WebSocket】消息出现错误");
|
||||
t.printStackTrace();
|
||||
// ClosedChannelException / EOFException 是应用关闭时 Tomcat 主动断开连接的正常现象,降级为 debug
|
||||
Throwable cause = t.getCause() != null ? t.getCause() : t;
|
||||
if (cause instanceof ClosedChannelException || cause instanceof EOFException) {
|
||||
log.debug("【系统 WebSocket】连接已关闭(正常关闭): {}", t.getMessage());
|
||||
} else {
|
||||
log.warn("【系统 WebSocket】消息出现错误", t);
|
||||
}
|
||||
}
|
||||
//==========【系统 WebSocket接受、推送消息等方法 —— 具体服务节点推送ws消息】========================================================================================
|
||||
|
||||
|
||||
@ -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.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);
|
||||
@ -213,6 +230,37 @@ public class OpenApiController extends JeecgController<OpenApi, OpenApiService>
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验原始接口路径是否合法:必须以 / 开头,不允许 // 和 .. 防止路径穿越
|
||||
*/
|
||||
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("原始接口路径包含非法字符");
|
||||
}
|
||||
if (!decoded.startsWith("/")) {
|
||||
throw new JeecgBootBizTipException("原始接口路径必须以 / 开头");
|
||||
}
|
||||
if (decoded.startsWith("//") || decoded.startsWith("/\\")) {
|
||||
throw new JeecgBootBizTipException("原始接口路径不能以 // 或 /\\ 开头");
|
||||
}
|
||||
if (decoded.contains("..")) {
|
||||
throw new JeecgBootBizTipException("原始接口路径不能包含 ..");
|
||||
}
|
||||
String lower = decoded.toLowerCase();
|
||||
if (lower.contains("://") || lower.startsWith("http:") || lower.startsWith("https:")
|
||||
|| lower.startsWith("file:") || lower.startsWith("ftp:") || lower.startsWith("gopher:")
|
||||
|| lower.startsWith("jar:") || lower.startsWith("netdoc:")) {
|
||||
throw new JeecgBootBizTipException("原始接口路径不允许包含协议");
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/json")
|
||||
public SwaggerModel swaggerModel() {
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
{
|
||||
"id": "E0CC280",
|
||||
"appTitle": null,
|
||||
"appLogo": null,
|
||||
"carouselImgJson": null,
|
||||
"routeImgJson": null,
|
||||
"appVersion": "1.0.0",
|
||||
"versionNum": 100,
|
||||
"downloadUrl": "https://upload.jeecg.com/jeecg/qiaoqiaoyunsite/app/JeecgUniapp3_0617.apk",
|
||||
"wgtUrl": "",
|
||||
"webDownloadUrl": "https://upload.jeecg.com/jeecg/qiaoqiaoyunsite/app/jeecgboot-setup-3.8.3.exe",
|
||||
"updateNote": "1. 优化用户体验\n2. 修复已知bug\n"
|
||||
}
|
||||
@ -26,6 +26,7 @@ import org.jeecg.common.util.encryption.EncryptedString;
|
||||
import org.jeecg.config.JeecgBaseConfig;
|
||||
import org.jeecg.config.shiro.IgnoreAuth;
|
||||
import org.jeecg.modules.base.service.BaseCommonService;
|
||||
import org.jeecg.modules.system.constant.DefIndexConst;
|
||||
import org.jeecg.modules.system.entity.SysDepart;
|
||||
import org.jeecg.modules.system.entity.SysRoleIndex;
|
||||
import org.jeecg.modules.system.entity.SysUser;
|
||||
@ -188,6 +189,11 @@ public class LoginController {
|
||||
String username = JwtUtil.getUsername(token);
|
||||
LoginUser sysUser = sysBaseApi.getUserByName(username);
|
||||
if(sysUser!=null) {
|
||||
//update-begin---author:zhangdaihao ---date:2026-04-15 for:【issue/9517】校验token签名,防止伪造token强制他人下线(DoS)-----------
|
||||
if (!JwtUtil.verify(token, username, sysUser.getPassword())) {
|
||||
return Result.error("Token无效!");
|
||||
}
|
||||
//update-end---author:zhangdaihao ---date:2026-04-15 for:【issue/9517】校验token签名,防止伪造token强制他人下线(DoS)-----------
|
||||
asyncClearLogoutCache(token, sysUser); // 异步清理
|
||||
SecurityUtils.getSubject().logout();
|
||||
return Result.ok("退出登录成功!");
|
||||
@ -214,6 +220,9 @@ public class LoginController {
|
||||
redisUtil.del(CommonConstant.PREFIX_USER_TOKEN_PC + sysUser.getUsername());
|
||||
redisUtil.del(CommonConstant.PREFIX_USER_TOKEN_APP + sysUser.getUsername());
|
||||
redisUtil.del(CommonConstant.PREFIX_USER_TOKEN_PHONE + sysUser.getUsername());
|
||||
|
||||
// 清空用户的默认首页缓存
|
||||
redisUtil.del(DefIndexConst.CACHE_TYPE + sysUser.getUsername());
|
||||
baseCommonService.addLog("用户名: "+sysUser.getRealname()+",退出成功!", CommonConstant.LOG_TYPE_1, null, sysUser);
|
||||
log.debug("【退出成功操作】异步处理,退出后,清理用户缓存: "+sysUser.getRealname());
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import com.jeecg.dingtalk.api.core.response.Response;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang.StringUtils;
|
||||
import org.apache.shiro.SecurityUtils;
|
||||
import org.apache.shiro.authz.annotation.RequiresPermissions;
|
||||
import org.jeecg.common.api.dto.PushMessageDTO;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.config.TenantContext;
|
||||
@ -110,6 +111,7 @@ public class SysAnnouncementController {
|
||||
* @param req
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:sysAnnouncement:list")
|
||||
@RequestMapping(value = "/list", method = RequestMethod.GET)
|
||||
public Result<IPage<SysAnnouncement>> queryPageList(SysAnnouncement sysAnnouncement,
|
||||
@RequestParam(name="pageNo", defaultValue="1") Integer pageNo,
|
||||
@ -136,6 +138,7 @@ public class SysAnnouncementController {
|
||||
* @param sysAnnouncement
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:sysAnnouncement:add")
|
||||
@RequestMapping(value = "/add", method = RequestMethod.POST)
|
||||
public Result<SysAnnouncement> add(@RequestBody SysAnnouncement sysAnnouncement) {
|
||||
Result<SysAnnouncement> result = new Result<SysAnnouncement>();
|
||||
@ -143,6 +146,10 @@ public class SysAnnouncementController {
|
||||
// 代码逻辑说明: 标题处理xss攻击的问题
|
||||
String title = XssUtils.scriptXss(sysAnnouncement.getTitile());
|
||||
sysAnnouncement.setTitile(title);
|
||||
//update-begin---author:liusq ---date:2025-04-13 for:【issues/9521】富文本msgContent字段未做XSS过滤,存在存储型XSS漏洞-----------
|
||||
String msgContent = XssUtils.richTextXss(sysAnnouncement.getMsgContent());
|
||||
sysAnnouncement.setMsgContent(msgContent);
|
||||
//update-end---author:liusq ---date:2025-04-13 for:【issues/9521】富文本msgContent字段未做XSS过滤,存在存储型XSS漏洞-----------
|
||||
// 【安全校验】校验附件文件名,防止路径遍历攻击
|
||||
SsrfFileTypeFilter.checkPathTraversalBatch(sysAnnouncement.getFiles());
|
||||
sysAnnouncement.setDelFlag(CommonConstant.DEL_FLAG_0.toString());
|
||||
@ -165,6 +172,7 @@ public class SysAnnouncementController {
|
||||
* @param sysAnnouncement
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:sysAnnouncement:edit")
|
||||
@RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST})
|
||||
public Result<SysAnnouncement> eidt(@RequestBody SysAnnouncement sysAnnouncement) {
|
||||
Result<SysAnnouncement> result = new Result<SysAnnouncement>();
|
||||
@ -176,6 +184,10 @@ public class SysAnnouncementController {
|
||||
// 代码逻辑说明: 标题处理xss攻击的问题
|
||||
String title = XssUtils.scriptXss(sysAnnouncement.getTitile());
|
||||
sysAnnouncement.setTitile(title);
|
||||
//update-begin---author:liusq ---date:2025-04-13 for:【issues/9521】富文本msgContent字段未做XSS过滤,存在存储型XSS漏洞-----------
|
||||
String msgContent = XssUtils.richTextXss(sysAnnouncement.getMsgContent());
|
||||
sysAnnouncement.setMsgContent(msgContent);
|
||||
//update-end---author:liusq ---date:2025-04-13 for:【issues/9521】富文本msgContent字段未做XSS过滤,存在存储型XSS漏洞-----------
|
||||
// 【安全校验】校验附件文件名,防止路径遍历攻击
|
||||
SsrfFileTypeFilter.checkPathTraversalBatch(sysAnnouncement.getFiles());
|
||||
sysAnnouncement.setNoticeType(NoticeTypeEnum.NOTICE_TYPE_SYSTEM.getValue());
|
||||
@ -196,6 +208,7 @@ public class SysAnnouncementController {
|
||||
* @param sysAnnouncement
|
||||
* @return
|
||||
*/
|
||||
//@RequiresPermissions("system:sysAnnouncement:editIzTop")
|
||||
@RequestMapping(value = "/editIzTop", method = {RequestMethod.PUT,RequestMethod.POST})
|
||||
public Result<SysAnnouncement> editIzTop(@RequestBody SysAnnouncement sysAnnouncement) {
|
||||
Result<SysAnnouncement> result = new Result<SysAnnouncement>();
|
||||
@ -216,6 +229,7 @@ public class SysAnnouncementController {
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:sysAnnouncement:delete")
|
||||
@RequestMapping(value = "/delete", method = RequestMethod.DELETE)
|
||||
public Result<SysAnnouncement> delete(@RequestParam(name="id",required=true) String id) {
|
||||
Result<SysAnnouncement> result = new Result<SysAnnouncement>();
|
||||
@ -238,6 +252,7 @@ public class SysAnnouncementController {
|
||||
* @param ids
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:sysAnnouncement:deleteBatch")
|
||||
@RequestMapping(value = "/deleteBatch", method = RequestMethod.DELETE)
|
||||
public Result<SysAnnouncement> deleteBatch(@RequestParam(name="ids",required=true) String ids) {
|
||||
Result<SysAnnouncement> result = new Result<SysAnnouncement>();
|
||||
@ -278,6 +293,7 @@ public class SysAnnouncementController {
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:sysAnnouncement:doReleaseData")
|
||||
@RequestMapping(value = "/doReleaseData", method = RequestMethod.GET)
|
||||
public Result<SysAnnouncement> doReleaseData(@RequestParam(name="id",required=true) String id, HttpServletRequest request) {
|
||||
Result<SysAnnouncement> result = new Result<SysAnnouncement>();
|
||||
@ -358,6 +374,7 @@ public class SysAnnouncementController {
|
||||
* @param id
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:sysAnnouncement:doReovkeData")
|
||||
@RequestMapping(value = "/doReovkeData", method = RequestMethod.GET)
|
||||
public Result<SysAnnouncement> doReovkeData(@RequestParam(name="id",required=true) String id, HttpServletRequest request) {
|
||||
Result<SysAnnouncement> result = new Result<SysAnnouncement>();
|
||||
@ -467,6 +484,7 @@ public class SysAnnouncementController {
|
||||
*
|
||||
* @param request
|
||||
*/
|
||||
@RequiresPermissions("system:sysAnnouncement:exportXls")
|
||||
@RequestMapping(value = "/exportXls")
|
||||
public ModelAndView exportXls(SysAnnouncement sysAnnouncement,HttpServletRequest request) {
|
||||
// Step.1 组装查询条件
|
||||
@ -491,6 +509,7 @@ public class SysAnnouncementController {
|
||||
* @param response
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:sysAnnouncement:importExcel")
|
||||
@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
|
||||
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
|
||||
MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest) request;
|
||||
@ -532,6 +551,7 @@ public class SysAnnouncementController {
|
||||
* @param anntId
|
||||
* @return
|
||||
*/
|
||||
//@RequiresPermissions("system:sysAnnouncement:syncNotic")
|
||||
@RequestMapping(value = "/syncNotic", method = RequestMethod.GET)
|
||||
public Result<SysAnnouncement> syncNotic(@RequestParam(name="anntId",required=false) String anntId, HttpServletRequest request) {
|
||||
Result<SysAnnouncement> result = new Result<SysAnnouncement>();
|
||||
@ -681,7 +701,7 @@ public class SysAnnouncementController {
|
||||
Result<Page<SysAnnouncementSend>> result = new Result<>();
|
||||
//----------------------------------------------------------------------------------------
|
||||
// step.1 此接口过慢,可以采用缓存一小时方案
|
||||
String keyString = String.format(CommonConstant.CACHE_KEY_USER_LAST_ANNOUNT_TIME_1HOUR + "_" + noticeType, userId);
|
||||
String keyString = String.format(CommonConstant.CACHE_KEY_USER_LAST_ANNOUNT_TIME_1HOUR, userId) + "_" + noticeType;
|
||||
if (redisTemplate.hasKey(keyString)) {
|
||||
log.debug("[SysAnnouncementSend Redis] 通过Redis缓存查询用户最后一次收到系统通知时间,userId={}", userId);
|
||||
Page<SysAnnouncementSend> pageList = (Page<SysAnnouncementSend>) redisTemplate.opsForValue().get(keyString);
|
||||
|
||||
@ -285,14 +285,14 @@ public class SysAnnouncementSendController {
|
||||
@RequestParam(name="busId",required=true) String busId,
|
||||
@RequestParam(name="busType",required=false) String busType) {
|
||||
//更新阅读状态
|
||||
sysAnnouncementSendService.updateReadFlagByBusId(busId,busType);
|
||||
|
||||
//刷新未读数量
|
||||
JSONObject obj = new JSONObject();
|
||||
obj.put(WebsocketConst.MSG_CMD, WebsocketConst.CMD_USER);
|
||||
LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
|
||||
webSocket.sendMessage(sysUser.getId(), obj.toJSONString());
|
||||
|
||||
boolean updateFlag = sysAnnouncementSendService.updateReadFlagByBusId(busId,busType);
|
||||
if(updateFlag){
|
||||
//刷新未读数量
|
||||
JSONObject obj = new JSONObject();
|
||||
obj.put(WebsocketConst.MSG_CMD, WebsocketConst.CMD_USER);
|
||||
LoginUser sysUser = (LoginUser) SecurityUtils.getSubject().getPrincipal();
|
||||
webSocket.sendMessage(sysUser.getId(), obj.toJSONString());
|
||||
}
|
||||
return Result.ok();
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package org.jeecg.modules.system.controller;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.io.IOUtils;
|
||||
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.util.RedisUtil;
|
||||
@ -10,6 +11,9 @@ import org.jeecg.common.util.oConvertUtils;
|
||||
import org.jeecg.modules.system.entity.SysAppVersion;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import com.alibaba.fastjson.JSONObject;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
|
||||
/**
|
||||
* @Description: app系统配置
|
||||
@ -25,6 +29,10 @@ public class SysAppVersionController{
|
||||
|
||||
@Autowired
|
||||
private RedisUtil redisUtil;
|
||||
/**
|
||||
* app3版本json文件路径
|
||||
*/
|
||||
private final String JSON_PATH = "classpath:org/jeecg/modules/system/config/json/app3-version.json";
|
||||
|
||||
/**
|
||||
* APP缓存前缀
|
||||
@ -41,16 +49,29 @@ public class SysAppVersionController{
|
||||
if (oConvertUtils.isNotEmpty(appConfig)) {
|
||||
try {
|
||||
SysAppVersion sysAppVersion = (SysAppVersion)appConfig;
|
||||
if(oConvertUtils.isEmpty(sysAppVersion.getDownloadUrl())){
|
||||
String jsonContent = readJson(JSON_PATH);
|
||||
sysAppVersion = JSONObject.parseObject(jsonContent, SysAppVersion.class);
|
||||
return Result.OK(sysAppVersion);
|
||||
}
|
||||
return Result.OK(sysAppVersion);
|
||||
} catch (Exception e) {
|
||||
log.error(e.toString(),e);
|
||||
return Result.error("app版本信息获取失败:" + e.getMessage());
|
||||
}
|
||||
}else{
|
||||
// 缓存中没有,从配置的json文件中获取
|
||||
try {
|
||||
String jsonContent = readJson(JSON_PATH);
|
||||
SysAppVersion sysAppVersion = JSONObject.parseObject(jsonContent, SysAppVersion.class);
|
||||
return Result.OK(sysAppVersion);
|
||||
} catch (Exception e) {
|
||||
log.error("从JSON文件读取app版本信息失败:{}", e);
|
||||
}
|
||||
}
|
||||
return Result.OK();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 保存APP3
|
||||
*
|
||||
@ -65,4 +86,21 @@ public class SysAppVersionController{
|
||||
redisUtil.set(APP3_VERSION + id,sysAppVersion);
|
||||
return Result.OK();
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取json格式文件
|
||||
* @param jsonSrc
|
||||
* @return
|
||||
*/
|
||||
private String readJson(String jsonSrc) {
|
||||
String json = "";
|
||||
try {
|
||||
//换个写法,解决springboot读取jar包中文件的问题
|
||||
InputStream stream = getClass().getClassLoader().getResourceAsStream(jsonSrc.replace("classpath:", ""));
|
||||
json = IOUtils.toString(stream,"UTF-8");
|
||||
} catch (IOException e) {
|
||||
log.error(e.getMessage(),e);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
}
|
||||
|
||||
@ -121,11 +121,13 @@ public class SysDataSourceController extends JeecgController<SysDataSource, ISys
|
||||
*/
|
||||
@AutoLog(value = "多数据源管理-添加")
|
||||
@Operation(summary = "多数据源管理-添加")
|
||||
@RequiresPermissions("system:datasource:add")
|
||||
@PostMapping(value = "/add")
|
||||
public Result<?> add(@RequestBody SysDataSource sysDataSource) {
|
||||
// 代码逻辑说明: jdbc连接地址漏洞问题
|
||||
try {
|
||||
JdbcSecurityUtil.validate(sysDataSource.getDbUrl());
|
||||
JdbcSecurityUtil.validateDriver(sysDataSource.getDbDriver());
|
||||
}catch (JeecgBootException e){
|
||||
log.error(e.toString());
|
||||
return Result.error("操作失败:" + e.getMessage());
|
||||
@ -141,11 +143,13 @@ public class SysDataSourceController extends JeecgController<SysDataSource, ISys
|
||||
*/
|
||||
@AutoLog(value = "多数据源管理-编辑")
|
||||
@Operation(summary = "多数据源管理-编辑")
|
||||
@RequiresPermissions("system:datasource:edit")
|
||||
@RequestMapping(value = "/edit", method ={RequestMethod.PUT, RequestMethod.POST})
|
||||
public Result<?> edit(@RequestBody SysDataSource sysDataSource) {
|
||||
// 代码逻辑说明: jdbc连接地址漏洞问题
|
||||
try {
|
||||
JdbcSecurityUtil.validate(sysDataSource.getDbUrl());
|
||||
JdbcSecurityUtil.validateDriver(sysDataSource.getDbDriver());
|
||||
} catch (JeecgBootException e) {
|
||||
log.error(e.toString());
|
||||
return Result.error("操作失败:" + e.getMessage());
|
||||
@ -161,6 +165,7 @@ public class SysDataSourceController extends JeecgController<SysDataSource, ISys
|
||||
*/
|
||||
@AutoLog(value = "多数据源管理-通过id删除")
|
||||
@Operation(summary = "多数据源管理-通过id删除")
|
||||
@RequiresPermissions("system:datasource:delete")
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<?> delete(@RequestParam(name = "id") String id) {
|
||||
return sysDataSourceService.deleteDataSource(id);
|
||||
@ -174,6 +179,7 @@ public class SysDataSourceController extends JeecgController<SysDataSource, ISys
|
||||
*/
|
||||
@AutoLog(value = "多数据源管理-批量删除")
|
||||
@Operation(summary = "多数据源管理-批量删除")
|
||||
@RequiresPermissions("system:datasource:delete")
|
||||
@DeleteMapping(value = "/deleteBatch")
|
||||
public Result<?> deleteBatch(@RequestParam(name = "ids") String ids) {
|
||||
List<String> idList = Arrays.asList(ids.split(","));
|
||||
@ -193,6 +199,7 @@ public class SysDataSourceController extends JeecgController<SysDataSource, ISys
|
||||
*/
|
||||
@AutoLog(value = "多数据源管理-通过id查询")
|
||||
@Operation(summary = "多数据源管理-通过id查询")
|
||||
@RequiresPermissions("system:datasource:list")
|
||||
@GetMapping(value = "/queryById")
|
||||
public Result<?> queryById(@RequestParam(name = "id") String id) throws InterruptedException {
|
||||
SysDataSource sysDataSource = sysDataSourceService.getById(id);
|
||||
@ -211,6 +218,7 @@ public class SysDataSourceController extends JeecgController<SysDataSource, ISys
|
||||
* @param request
|
||||
* @param sysDataSource
|
||||
*/
|
||||
@RequiresPermissions("system:datasource:export")
|
||||
@RequestMapping(value = "/exportXls")
|
||||
public ModelAndView exportXls(HttpServletRequest request, SysDataSource sysDataSource) {
|
||||
//------------------------------------------------------------------------------------------------
|
||||
@ -229,6 +237,7 @@ public class SysDataSourceController extends JeecgController<SysDataSource, ISys
|
||||
* @param response
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:datasource:import")
|
||||
@RequestMapping(value = "/importExcel", method = RequestMethod.POST)
|
||||
public Result<?> importExcel(HttpServletRequest request, HttpServletResponse response) {
|
||||
return super.importExcel(request, response, SysDataSource.class);
|
||||
|
||||
@ -137,10 +137,10 @@ public class SysDepartController {
|
||||
* @return
|
||||
*/
|
||||
@RequestMapping(value = "/queryDepartTreeSync", method = RequestMethod.GET)
|
||||
public Result<List<SysDepartTreeModel>> queryDepartTreeSync(@RequestParam(name = "pid", required = false) String parentId,@RequestParam(name = "ids", required = false) String ids, @RequestParam(name = "primaryKey", required = false) String primaryKey) {
|
||||
public Result<List<SysDepartTreeModel>> queryDepartTreeSync(@RequestParam(name = "pid", required = false) String parentId,@RequestParam(name = "ids", required = false) String ids, @RequestParam(name = "primaryKey", required = false) String primaryKey, @RequestParam(name = "orgCategory", required = false) String orgCategory) {
|
||||
Result<List<SysDepartTreeModel>> result = new Result<>();
|
||||
try {
|
||||
List<SysDepartTreeModel> list = sysDepartService.queryTreeListByPid(parentId,ids, primaryKey);
|
||||
List<SysDepartTreeModel> list = sysDepartService.queryTreeListByPid(parentId,ids, primaryKey, orgCategory);
|
||||
result.setResult(list);
|
||||
result.setSuccess(true);
|
||||
} catch (Exception e) {
|
||||
@ -737,6 +737,16 @@ public class SysDepartController {
|
||||
List<SysPositionSelectTreeVo> list = sysDepartService.getRankRelation(departId);
|
||||
return Result.ok(list);
|
||||
}
|
||||
/**
|
||||
* 获取ALL职级关系
|
||||
* @param departId
|
||||
* @return
|
||||
*/
|
||||
@GetMapping("/getALLRankRelation")
|
||||
public Result<List<SysPositionSelectTreeVo>> getALLRankRelation(@RequestParam(name = "departId",required = false) String departId){
|
||||
List<SysPositionSelectTreeVo> list = sysDepartService.getALLRankRelation(departId);
|
||||
return Result.ok(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据部门code获取当前和上级部门名称
|
||||
|
||||
@ -31,6 +31,7 @@ import org.jeecg.modules.system.model.SysDictTree;
|
||||
import org.jeecg.modules.system.model.TreeSelectModel;
|
||||
import org.jeecg.modules.system.service.ISysDictItemService;
|
||||
import org.jeecg.modules.system.service.ISysDictService;
|
||||
import org.jeecg.modules.system.vo.SysDictBatchVo;
|
||||
import org.jeecg.modules.system.vo.SysDictPage;
|
||||
import org.jeecg.modules.system.vo.lowapp.SysDictVo;
|
||||
import org.jeecgframework.poi.excel.ExcelImportCheckUtil;
|
||||
@ -406,6 +407,94 @@ public class SysDictController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @功能:字典和字典项一起新增(支持批量)
|
||||
* @param sysDictBatchVo 字典批量数据
|
||||
* @return
|
||||
*/
|
||||
@RequiresPermissions("system:dict:add")
|
||||
@RequestMapping(value = "/batchAddDictWithItems", method = RequestMethod.POST)
|
||||
public Result<Map<String, Object>> batchAddDictWithItems(@RequestBody SysDictBatchVo sysDictBatchVo) {
|
||||
Result<Map<String, Object>> result = new Result<Map<String, Object>>();
|
||||
//update-begin---author:zzl ---date:2026-04-03 for:字典和字典项一起新增(支持批量)---
|
||||
log.info("========== 批量新增字典开始 ==========");
|
||||
log.info("请求参数: {}", JSON.toJSONString(sysDictBatchVo));
|
||||
if (sysDictBatchVo == null || sysDictBatchVo.getDictList() == null || sysDictBatchVo.getDictList().isEmpty()) {
|
||||
log.warn("字典列表为空,参数校验不通过");
|
||||
result.error500("字典列表不能为空!");
|
||||
return result;
|
||||
}
|
||||
int successCount = 0;
|
||||
int failCount = 0;
|
||||
StringBuilder message = new StringBuilder();
|
||||
List<Map<String, String>> failList = new ArrayList<>();
|
||||
log.info("待处理的字典数量: {}", sysDictBatchVo.getDictList().size());
|
||||
for (int i = 0; i < sysDictBatchVo.getDictList().size(); i++) {
|
||||
SysDictPage sysDictPage = sysDictBatchVo.getDictList().get(i);
|
||||
log.info("开始处理第 {} 个字典, dictCode: {}, dictName: {}", i + 1, sysDictPage.getDictCode(), sysDictPage.getDictName());
|
||||
try {
|
||||
SysDict sysDict = new SysDict();
|
||||
sysDict.setDictName(sysDictPage.getDictName());
|
||||
sysDict.setDictCode(sysDictPage.getDictCode());
|
||||
sysDict.setDescription(sysDictPage.getDescription());
|
||||
sysDict.setDelFlag(CommonConstant.DEL_FLAG_0);
|
||||
Integer num = sysDictService.saveMain(sysDict, sysDictPage.getSysDictItemList());
|
||||
if (num > 0) {
|
||||
successCount++;
|
||||
log.info("第 {} 个字典[{}]保存成功", i + 1, sysDictPage.getDictCode());
|
||||
} else if (num == -1) {
|
||||
failCount++;
|
||||
Map<String, String> failItem = new HashMap<>();
|
||||
failItem.put("dictCode", sysDictPage.getDictCode());
|
||||
failItem.put("dictName", sysDictPage.getDictName());
|
||||
failItem.put("errorMsg", "字典项值为空,已忽略!");
|
||||
failList.add(failItem);
|
||||
message.append("第").append(i + 1).append("个字典[").append(sysDictPage.getDictCode()).append("]:字典项值为空,已忽略!\n");
|
||||
log.warn("第 {} 个字典[{}]字典项值为空,已忽略", i + 1, sysDictPage.getDictCode());
|
||||
} else {
|
||||
failCount++;
|
||||
Map<String, String> failItem = new HashMap<>();
|
||||
failItem.put("dictCode", sysDictPage.getDictCode());
|
||||
failItem.put("dictName", sysDictPage.getDictName());
|
||||
failItem.put("errorMsg", "字典编码已经存在!");
|
||||
failList.add(failItem);
|
||||
message.append("第").append(i + 1).append("个字典[").append(sysDictPage.getDictCode()).append("]:字典编码已经存在!\n");
|
||||
log.warn("第 {} 个字典[{}]字典编码已经存在", i + 1, sysDictPage.getDictCode());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
failCount++;
|
||||
Map<String, String> failItem = new HashMap<>();
|
||||
failItem.put("dictCode", sysDictPage.getDictCode());
|
||||
failItem.put("dictName", sysDictPage.getDictName());
|
||||
failItem.put("errorMsg", e.getMessage());
|
||||
failList.add(failItem);
|
||||
message.append("第").append(i + 1).append("个字典[").append(sysDictPage.getDictCode()).append("]:").append(e.getMessage()).append("\n");
|
||||
log.error("第 {} 个字典[{}]处理异常: {}", i + 1, sysDictPage.getDictCode(), e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
Map<String, Object> returnMap = new HashMap<>();
|
||||
returnMap.put("successCount", successCount);
|
||||
returnMap.put("failCount", failCount);
|
||||
returnMap.put("message", message.toString());
|
||||
returnMap.put("failList", failList);
|
||||
if (failCount == 0) {
|
||||
result.success("批量保存成功!共保存 " + successCount + " 个字典!");
|
||||
log.info("批量保存成功,共保存 {} 个字典", successCount);
|
||||
} else if (successCount > 0) {
|
||||
result.success("部分保存成功!成功 " + successCount + " 个,失败 " + failCount + " 个!");
|
||||
log.warn("部分保存成功,成功 {} 个,失败 {} 个", successCount, failCount);
|
||||
} else {
|
||||
result.error500("全部保存失败!");
|
||||
log.error("全部保存失败!共 {} 个字典", sysDictBatchVo.getDictList().size());
|
||||
}
|
||||
|
||||
|
||||
result.setResult(returnMap);
|
||||
log.info("========== 批量新增字典结束 ==========");
|
||||
//update-end---author:zzl ---date:2026-04-03 for:字典和字典项一起新增(支持批量)---
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @功能:编辑
|
||||
* @param sysDict
|
||||
|
||||
@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.shiro.authz.annotation.RequiresRoles;
|
||||
import org.jeecg.common.api.vo.Result;
|
||||
import org.jeecg.common.aspect.annotation.AutoLog;
|
||||
import org.jeecg.common.system.base.controller.JeecgController;
|
||||
@ -65,6 +66,7 @@ public class SysFillRuleController extends JeecgController<SysFillRule, ISysFill
|
||||
* @param ruleCode
|
||||
* @return
|
||||
*/
|
||||
@RequiresRoles({"admin"})
|
||||
@GetMapping(value = "/testFillRule")
|
||||
public Result testFillRule(@RequestParam("ruleCode") String ruleCode) {
|
||||
Object result = FillRuleUtil.executeRule(ruleCode, new JSONObject());
|
||||
@ -79,6 +81,7 @@ public class SysFillRuleController extends JeecgController<SysFillRule, ISysFill
|
||||
*/
|
||||
@AutoLog(value = "填值规则-添加")
|
||||
@Operation(summary = "填值规则-添加")
|
||||
@RequiresRoles({"admin"})
|
||||
@PostMapping(value = "/add")
|
||||
public Result<?> add(@RequestBody SysFillRule sysFillRule) {
|
||||
sysFillRuleService.save(sysFillRule);
|
||||
@ -93,6 +96,7 @@ public class SysFillRuleController extends JeecgController<SysFillRule, ISysFill
|
||||
*/
|
||||
@AutoLog(value = "填值规则-编辑")
|
||||
@Operation(summary = "填值规则-编辑")
|
||||
@RequiresRoles({"admin"})
|
||||
@RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST})
|
||||
public Result<?> edit(@RequestBody SysFillRule sysFillRule) {
|
||||
sysFillRuleService.updateById(sysFillRule);
|
||||
@ -107,6 +111,7 @@ public class SysFillRuleController extends JeecgController<SysFillRule, ISysFill
|
||||
*/
|
||||
@AutoLog(value = "填值规则-通过id删除")
|
||||
@Operation(summary = "填值规则-通过id删除")
|
||||
@RequiresRoles({"admin"})
|
||||
@DeleteMapping(value = "/delete")
|
||||
public Result<?> delete(@RequestParam(name = "id", required = true) String id) {
|
||||
sysFillRuleService.removeById(id);
|
||||
@ -121,6 +126,7 @@ public class SysFillRuleController extends JeecgController<SysFillRule, ISysFill
|
||||
*/
|
||||
@AutoLog(value = "填值规则-批量删除")
|
||||
@Operation(summary = "填值规则-批量删除")
|
||||
@RequiresRoles({"admin"})
|
||||
@DeleteMapping(value = "/deleteBatch")
|
||||
public Result<?> deleteBatch(@RequestParam(name = "ids", required = true) String ids) {
|
||||
this.sysFillRuleService.removeByIds(Arrays.asList(ids.split(",")));
|
||||
|
||||
@ -34,6 +34,7 @@ public class SysGatewayRouteController extends JeecgController<SysGatewayRoute,
|
||||
@Autowired
|
||||
private ISysGatewayRouteService sysGatewayRouteService;
|
||||
|
||||
@RequiresPermissions("system:gateway:updateAll")
|
||||
@PostMapping(value = "/updateAll")
|
||||
public Result<?> updateAll(@RequestBody JSONObject json) {
|
||||
sysGatewayRouteService.updateAll(json);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user