diff --git a/jeecg-boot/.claudeignore b/jeecg-boot/.claudeignore new file mode 100644 index 000000000..dd1e0e9f0 --- /dev/null +++ b/jeecg-boot/.claudeignore @@ -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/ diff --git a/jeecg-boot/CLAUDE.md b/jeecg-boot/CLAUDE.md new file mode 100644 index 000000000..1d88efcd4 --- /dev/null +++ b/jeecg-boot/CLAUDE.md @@ -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 -Dtest= + +# 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`, `ServiceImpl`) | +| 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..{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: `Controller extends JeecgController` — base class provides standard CRUD + Excel import/export +- Services: Interface `IService extends IService`, impl `ServiceImpl extends ServiceImpl` +- Mappers: `Mapper extends BaseMapper`, with XML in `mapper/xml/` + +**Common annotations on entities:** `@Data`, `@EqualsAndHashCode(callSuper = false)`, `@Accessors(chain = true)`, `@TableName` + +**API response wrapper:** `Result` (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)** diff --git a/jeecg-boot/docker-compose.yml b/jeecg-boot/docker-compose.yml index d6c3b141e..b6e83287d 100644 --- a/jeecg-boot/docker-compose.yml +++ b/jeecg-boot/docker-compose.yml @@ -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 diff --git a/jeecg-boot/jeecg-boot-base-core/pom.xml b/jeecg-boot/jeecg-boot-base-core/pom.xml index 00fb9b211..bd6209f1f 100644 --- a/jeecg-boot/jeecg-boot-base-core/pom.xml +++ b/jeecg-boot/jeecg-boot-base-core/pom.xml @@ -377,7 +377,7 @@ org.jeecgframework.boot3 - jeecg-boot-starter-chatgpt + jeecg-boot-starter-ai diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/aspect/DictAspect.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/aspect/DictAspect.java index a034ce23e..dddc64b2e 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/aspect/DictAspect.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/aspect/DictAspect.java @@ -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 diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java index a311f98a1..14725e2a2 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/CommonConstant.java @@ -249,7 +249,7 @@ public interface CommonConstant { String UPLOAD_TYPE_OSS = "alioss"; /** - * 文档上传自定义桶名称 + * 文档上传自定义桶名称 (私有加密桶名)—— 知识库功能 */ String UPLOAD_CUSTOM_BUCKET = "eoafile"; /** diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/enums/FileTypeEnum.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/enums/FileTypeEnum.java index 626a47e23..406b66ab1 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/enums/FileTypeEnum.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/enums/FileTypeEnum.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/enums/UniPushTypeEnum.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/enums/UniPushTypeEnum.java index d9ee79664..d27a7ad3c 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/enums/UniPushTypeEnum.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/constant/enums/UniPushTypeEnum.java @@ -23,7 +23,11 @@ public enum UniPushTypeEnum { /** * 系统消息 */ - SYS_MSG("system", "系统消息", "收到一条系统通告"); + SYS_MSG("system", "系统消息", "收到一条系统通告"), + /** + * 协同工作 + */ + COLLABORATION_MSG("collaboration", "系统消息", "收到一条协同工作消息"); /** * 业务类型(chat:聊天 bpm_task:流程 bpm_cc:流程抄送) diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootExceptionHandler.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootExceptionHandler.java index ebd73bb56..7b419d42c 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootExceptionHandler.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/exception/JeecgBootExceptionHandler.java @@ -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); diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/query/QueryGenerator.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/query/QueryGenerator.java index e1718358d..bb3fa4f72 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/query/QueryGenerator.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/query/QueryGenerator.java @@ -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 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; diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/util/ResourceUtil.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/util/ResourceUtil.java index 6e800fde9..87aace163 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/util/ResourceUtil.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/util/ResourceUtil.java @@ -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) { diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/SysDepartModel.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/SysDepartModel.java index 41dca9d84..d77d660f7 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/SysDepartModel.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/system/vo/SysDepartModel.java @@ -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; } diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/CommonUtils.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/CommonUtils.java index 3f9ca152b..2a405d953 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/CommonUtils.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/CommonUtils.java @@ -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; } diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/FileDownloadUtils.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/FileDownloadUtils.java index a45e02d43..b1051c722 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/FileDownloadUtils.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/FileDownloadUtils.java @@ -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秒 diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/FillRuleUtil.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/FillRuleUtil.java index cac263abf..17f297fe1 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/FillRuleUtil.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/FillRuleUtil.java @@ -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(); diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MinioUtil.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MinioUtil.java index a8f67a38c..993330d22 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MinioUtil.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MinioUtil.java @@ -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); diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MyClassLoader.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MyClassLoader.java index 20289b60a..15522e702 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MyClassLoader.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/MyClassLoader.java @@ -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!"); diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/RestUtil.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/RestUtil.java index 00928f72d..8b5ae8a12 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/RestUtil.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/RestUtil.java @@ -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 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 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); diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/SqlInjectionUtil.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/SqlInjectionUtil.java index 5c5c6b658..15ce7ceb3 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/SqlInjectionUtil.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/SqlInjectionUtil.java @@ -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 resultFindAll = ReUtil.findAll(regularStr, sql, 0, new ArrayList()); for (String res : resultFindAll) { diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/dynamic/db/DynamicDBUtil.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/dynamic/db/DynamicDBUtil.java index cf6a2eb28..0fe13edc7 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/dynamic/db/DynamicDBUtil.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/dynamic/db/DynamicDBUtil.java @@ -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); diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/filter/SsrfFileTypeFilter.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/filter/SsrfFileTypeFilter.java index 0d8e19bb2..818dea154 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/filter/SsrfFileTypeFilter.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/filter/SsrfFileTypeFilter.java @@ -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 逗号分隔的文件路径 diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/oss/OssBootUtil.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/oss/OssBootUtil.java index 29780f778..fcdc8dea2 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/oss/OssBootUtil.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/oss/OssBootUtil.java @@ -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; diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/security/JdbcSecurityUtil.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/security/JdbcSecurityUtil.java index ab948f70b..fbbd93746 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/security/JdbcSecurityUtil.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/security/JdbcSecurityUtil.java @@ -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 + "】,如需支持请联系管理员"); + } +} \ No newline at end of file diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/AiChatConfig.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/AiChatConfig.java new file mode 100644 index 000000000..1d9ef9295 --- /dev/null +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/AiChatConfig.java @@ -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; + } + +} \ No newline at end of file diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/AiRagConfigBean.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/AiRagConfigBean.java index 8ad8babf5..9976eef65 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/AiRagConfigBean.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/AiRagConfigBean.java @@ -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为空即不启用----------- } diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/mybatis/MybatisPlusSaasConfig.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/mybatis/MybatisPlusSaasConfig.java index 4228c770d..a370ff972 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/mybatis/MybatisPlusSaasConfig.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/mybatis/MybatisPlusSaasConfig.java @@ -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】注入动态表名适配拦截器解决多表名问题 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/pom.xml b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/pom.xml index 081f1b5c1..252b9fa7f 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/pom.xml +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/pom.xml @@ -41,14 +41,14 @@ dev.langchain4j langchain4j-bom - 1.9.1 + 1.12.2 pom import dev.langchain4j langchain4j-community-bom - 1.9.1-beta17 + 1.12.1-beta21 pom import @@ -75,7 +75,7 @@ org.jeecgframework.boot3 jeecg-aiflow - 3.9.1-beta1 + 3.9.2-beta commons-io @@ -160,6 +160,10 @@ dev.langchain4j langchain4j-ollama + + dev.langchain4j + langchain4j-google-ai-gemini + dev.langchain4j langchain4j-community-zhipu-ai @@ -233,12 +237,29 @@ tika-parser-text-module ${apache-tika.version} + + + dev.langchain4j + langchain4j-skills + + + + dev.langchain4j + langchain4j-experimental-skills-shell + 1.12.2-beta22 + com.deepoove poi-tl 1.12.2 + + + org.jsoup + jsoup + 1.22.1 + \ No newline at end of file diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/api/AiragBaseApiImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/api/AiragBaseApiImpl.java new file mode 100644 index 000000000..0f904c2a0 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/api/AiragBaseApiImpl.java @@ -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 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(); + } + +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/consts/AiAppConsts.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/consts/AiAppConsts.java index 514307937..0e0f2ef69 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/consts/AiAppConsts.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/consts/AiAppConsts.java @@ -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; + } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/consts/Prompts.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/consts/Prompts.java index 46f970d5a..53e9daf63 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/consts/Prompts.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/consts/Prompts.java @@ -173,4 +173,9 @@ public class Prompts { */ public static final String AI_TOUCHE_PROMPT = "请针对如下内容:[{}] 进行润色。 回复格式:{},语气:{},语言:{},长度:{}。"; + /** + * ai绘画提示词 + */ + public static final String AI_DRAW_PROMPT = "风格:{},视角:{},人物镜头:{},灯光:{},图片尺寸:{};"; + } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/controller/AiragAppController.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/controller/AiragAppController.java index 694f98a2f..40f00d865 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/controller/AiragAppController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/controller/AiragAppController.java @@ -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> listDict(AiragApp airagApp, HttpServletRequest req) { + QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(airagApp, req.getParameterMap()); + queryWrapper.select("id", "name"); + queryWrapper.orderByDesc("create_time"); + List list = airagAppService.list(queryWrapper); + List 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 edit(@RequestBody AiragApp airagApp) { + public Result 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()); diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/controller/AiragChatController.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/controller/AiragChatController.java index 8b444550a..46a1fdeb5 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/controller/AiragChatController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/controller/AiragChatController.java @@ -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 genAiPoster(@RequestBody ChatSendParams chatSendParams){ - String imageUrl = chatService.genAiPoster(chatSendParams); + public Result 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 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写作 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/enums/ImageEditEnum.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/enums/ImageEditEnum.java new file mode 100644 index 000000000..19910da30 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/enums/ImageEditEnum.java @@ -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; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/enums/ImageSizeEnum.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/enums/ImageSizeEnum.java new file mode 100644 index 000000000..722817c39 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/enums/ImageSizeEnum.java @@ -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"; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/IAiragChatService.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/IAiragChatService.java index 5f5e7a985..e6ff07d53 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/IAiragChatService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/IAiragChatService.java @@ -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创作 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/IAiragVariableService.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/IAiragVariableService.java index d2f372d5f..3bacf2749 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/IAiragVariableService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/IAiragVariableService.java @@ -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); + /** * 添加变量更新工具 * diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/impl/AiragChatServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/impl/AiragChatServiceImpl.java index 09e205011..39d4406c4 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/impl/AiragChatServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/impl/AiragChatServiceImpl.java @@ -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 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 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 mergeToolMessages(List histories, boolean showToolProcess) { + List mergedMessages = new ArrayList<>(); + if (oConvertUtils.isObjectEmpty(histories)) { + return mergedMessages; + } + // 缓存工具请求,便于后续快速匹配 + Map requestCache = new HashMap<>(); + // 当前正在合并的AI消息 + MessageHistory currentAiMsg = null; + // 合并AI消息 + BiConsumer mergeMsg = (cacheMsg, obj) -> { + String currContent; + if (obj instanceof MessageHistory) { + MessageHistory currMsg = (MessageHistory) obj; + currContent = currMsg.getContent(); + // 合并图片 + if (CollectionUtils.isNotEmpty(currMsg.getImages())) { + List images = CollectionUtils.isEmpty(cacheMsg.getImages()) ? new ArrayList<>() : cacheMsg.getImages(); + images.addAll(currMsg.getImages()); + cacheMsg.setImages(images); + } + // 合并文件 + if (CollectionUtils.isNotEmpty(currMsg.getFiles())) { + List 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 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 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 messages, ChatMessage message, ChatConversation chatConversation, String topicId, List 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 images = new ArrayList<>(); List 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 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 images = sendParams.getImages(); List> 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 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 result = airagFlowPluginService.getFlowsToPlugin(flowId); + Map 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 braveTools = BraveSearchToolBuilder.buildTools(aiRagConfigBean.getBraveSearch()); + if (!braveTools.isEmpty()) { + Map 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 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 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> 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 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()); } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/impl/AiragVariableServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/impl/AiragVariableServiceImpl.java index 9ef4ec421..a82d22300 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/impl/AiragVariableServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/service/impl/AiragVariableServiceImpl.java @@ -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重复调用----------- } }; diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/vo/AiDrawGenerateVo.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/vo/AiDrawGenerateVo.java new file mode 100644 index 000000000..da7f2ace9 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/app/vo/AiDrawGenerateVo.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/consts/LLMConsts.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/consts/LLMConsts.java index 1d6039cab..8a1c42786 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/consts/LLMConsts.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/consts/LLMConsts.java @@ -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"; } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragBaseApiController.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragBaseApiController.java index 567b533d1..754bc3b42 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragBaseApiController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragBaseApiController.java @@ -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); } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragMcpController.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragMcpController.java index 4486d4e62..0175989cf 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragMcpController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragMcpController.java @@ -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> queryPageList(AiragMcp airagMcp, @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, @@ -61,6 +63,7 @@ public class AiragMcpController extends JeecgController save(@RequestBody AiragMcp airagMcp) { return airagMcpService.edit(airagMcp); @@ -77,6 +80,7 @@ public class AiragMcpController extends JeecgController saveAndSync(@RequestBody AiragMcp airagMcp) { Result saveResult = airagMcpService.edit(airagMcp); @@ -99,6 +103,7 @@ public class AiragMcpController extends JeecgController sync(@PathVariable(name = "id", required = true) String id) { return airagMcpService.sync(id); @@ -114,6 +119,7 @@ public class AiragMcpController extends JeecgController toggleStatus(@PathVariable(name = "id",required = true) String id, @PathVariable(name = "action", required = true) String action) { @@ -129,6 +135,7 @@ public class AiragMcpController extends JeecgController saveTools(@RequestBody SaveToolsDTO dto) { return airagMcpService.saveTools(dto.getId(), dto.getTools()); @@ -141,6 +148,7 @@ public class AiragMcpController extends JeecgController delete(@RequestParam(name = "id", required = true) String id) { airagMcpService.removeById(id); @@ -154,6 +162,7 @@ public class AiragMcpController extends JeecgController queryById(@RequestParam(name = "id", required = true) String id) { AiragMcp airagMcp = airagMcpService.getById(id); @@ -169,7 +178,7 @@ public class AiragMcpController extends JeecgController importExcel(HttpServletRequest request, HttpServletResponse response) { return super.importExcel(request, response, AiragMcp.class); diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragModelController.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragModelController.java index ee805607e..a44677064 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragModelController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/controller/AiragModelController.java @@ -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 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){ diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/document/WebPageParser.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/document/WebPageParser.java new file mode 100644 index 000000000..0f311908e --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/document/WebPageParser.java @@ -0,0 +1,254 @@ +package org.jeecg.modules.airag.llm.document; + +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.nodes.Node; +import org.jsoup.nodes.TextNode; +import org.jsoup.select.Elements; + +import java.io.IOException; + +/** + * 网页解析器,使用Jsoup爬取网页并转换为Markdown格式 + * + * @author sjlei + * @date 2026/3/19 + */ +@Slf4j +public class WebPageParser { + + /** + * 请求超时时间(毫秒) + */ + private static final int TIMEOUT_MS = 15000; + + /** + * 最大body大小(5MB) + */ + private static final int MAX_BODY_SIZE = 5 * 1024 * 1024; + + /** + * User-Agent + */ + private static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + + /** + * 爬取网页并转换为Markdown + * + * @param url 网页URL + * @return Markdown格式的文本内容 + * @throws IOException 网络请求失败时抛出 + */ + public String parseToMarkdown(String url) throws IOException { + Document doc = Jsoup.connect(url) + .userAgent(USER_AGENT) + .timeout(TIMEOUT_MS) + .maxBodySize(MAX_BODY_SIZE) + .followRedirects(true) + .get(); + + // 移除脚本、样式、导航、页脚等无关元素 + doc.select("script, style, nav, footer, header, iframe, noscript, svg, form, button, input, select, textarea, .sidebar, .nav, .menu, .footer, .header, .ad, .advertisement, .comment, .comments").remove(); + + // 优先提取正文区域 + Element body = extractMainContent(doc); + + StringBuilder markdown = new StringBuilder(); + + // 提取页面标题 + String title = doc.title(); + if (title != null && !title.trim().isEmpty()) { + markdown.append("# ").append(title.trim()).append("\n\n"); + } + + // 将HTML转为Markdown + convertToMarkdown(body, markdown); + + return cleanMarkdown(markdown.toString()); + } + + /** + * 提取正文区域,优先使用article/main标签,否则使用body + */ + private Element extractMainContent(Document doc) { + // 按优先级尝试获取正文容器 + String[] selectors = {"article", "main", "[role=main]", ".content", ".post-content", ".article-content", ".entry-content", "#content"}; + for (String selector : selectors) { + Elements elements = doc.select(selector); + if (!elements.isEmpty() && elements.first().text().length() > 100) { + return elements.first(); + } + } + return doc.body() != null ? doc.body() : doc; + } + + /** + * 递归将HTML元素转换为Markdown + */ + private void convertToMarkdown(Element element, StringBuilder sb) { + for (Node child : element.childNodes()) { + if (child instanceof TextNode) { + String text = ((TextNode) child).text().trim(); + if (!text.isEmpty()) { + sb.append(text); + } + } else if (child instanceof Element) { + Element el = (Element) child; + String tagName = el.tagName().toLowerCase(); + + switch (tagName) { + case "h1": + sb.append("\n\n# ").append(el.text().trim()).append("\n\n"); + break; + case "h2": + sb.append("\n\n## ").append(el.text().trim()).append("\n\n"); + break; + case "h3": + sb.append("\n\n### ").append(el.text().trim()).append("\n\n"); + break; + case "h4": + sb.append("\n\n#### ").append(el.text().trim()).append("\n\n"); + break; + case "h5": + sb.append("\n\n##### ").append(el.text().trim()).append("\n\n"); + break; + case "h6": + sb.append("\n\n###### ").append(el.text().trim()).append("\n\n"); + break; + case "p": + sb.append("\n\n"); + convertToMarkdown(el, sb); + sb.append("\n\n"); + break; + case "br": + sb.append("\n"); + break; + case "strong": + case "b": + sb.append("**").append(el.text().trim()).append("**"); + break; + case "em": + case "i": + sb.append("*").append(el.text().trim()).append("*"); + break; + case "code": + sb.append("`").append(el.text()).append("`"); + break; + case "pre": + sb.append("\n\n```\n").append(el.text()).append("\n```\n\n"); + break; + case "a": + String href = el.attr("abs:href"); + String linkText = el.text().trim(); + if (!linkText.isEmpty() && !href.isEmpty()) { + sb.append("[").append(linkText).append("](").append(href).append(")"); + } else if (!linkText.isEmpty()) { + sb.append(linkText); + } + break; + case "img": + String src = el.attr("abs:src"); + String alt = el.attr("alt"); + // 只保留http(s)开头的真实图片URL,过滤掉base64内联图片 + if (!src.isEmpty() && src.startsWith("http")) { + sb.append("![").append(alt != null ? alt : "").append("](").append(src).append(")"); + } + break; + case "ul": + sb.append("\n"); + for (Element li : el.children()) { + if ("li".equals(li.tagName())) { + sb.append("- ").append(li.text().trim()).append("\n"); + } + } + sb.append("\n"); + break; + case "ol": + sb.append("\n"); + int idx = 1; + for (Element li : el.children()) { + if ("li".equals(li.tagName())) { + sb.append(idx++).append(". ").append(li.text().trim()).append("\n"); + } + } + sb.append("\n"); + break; + case "blockquote": + String[] lines = el.text().trim().split("\n"); + sb.append("\n"); + for (String line : lines) { + sb.append("> ").append(line).append("\n"); + } + sb.append("\n"); + break; + case "table": + convertTableToMarkdown(el, sb); + break; + case "hr": + sb.append("\n\n---\n\n"); + break; + case "div": + case "section": + case "span": + case "figure": + case "figcaption": + convertToMarkdown(el, sb); + break; + default: + convertToMarkdown(el, sb); + break; + } + } + } + } + + /** + * 将HTML表格转为Markdown表格 + */ + private void convertTableToMarkdown(Element table, StringBuilder sb) { + Elements rows = table.select("tr"); + if (rows.isEmpty()) { + return; + } + + sb.append("\n\n"); + boolean headerDone = false; + + for (Element row : rows) { + Elements cells = row.select("th, td"); + if (cells.isEmpty()) { + continue; + } + + sb.append("|"); + for (Element cell : cells) { + sb.append(" ").append(cell.text().trim()).append(" |"); + } + sb.append("\n"); + + // 在第一行后添加分隔线 + if (!headerDone) { + sb.append("|"); + for (int i = 0; i < cells.size(); i++) { + sb.append(" --- |"); + } + sb.append("\n"); + headerDone = true; + } + } + sb.append("\n"); + } + + /** + * 清理Markdown文本:去除多余空行、首尾空白 + */ + private String cleanMarkdown(String markdown) { + // 去除连续3个以上换行为2个换行 + markdown = markdown.replaceAll("\n{3,}", "\n\n"); + // 去除行首尾空白(保留换行) + markdown = markdown.replaceAll("(?m)^[ \t]+|[ \t]+$", ""); + return markdown.trim(); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/entity/AiragKnowledge.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/entity/AiragKnowledge.java index 9491348de..8ceeaa1a9 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/entity/AiragKnowledge.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/entity/AiragKnowledge.java @@ -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; } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/AIChatHandler.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/AIChatHandler.java index 4b0b94fbe..a3e839415 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/AIChatHandler.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/AIChatHandler.java @@ -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 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 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("") + if (resp != null && resp.contains("") && (null == params.getNoThinking() || params.getNoThinking())) { String[] thinkSplit = resp.split(""); 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> 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> 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 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> imageEdit(String modelId, String messages, List 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> imageEdit(AiragModel airagModel,String messages, List 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 originalImageBase64List = getFirstImageBase64(images); try { return llmHandler.imageEdit(messages, originalImageBase64List, params); } catch (Exception e) { - String errMsg = "调用绘画AI接口失败,详情请查看后台日志。"; - if (oConvertUtils.isNotEmpty(e.getMessage())) { - // 根据常见异常关键字做细致翻译 - for (Map.Entry 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。 + *

+ * 处理优先级: + *

    + *
  1. 请求超时(timeout)→ 排队提示
  2. + *
  3. 工具调用上下文丢失(messages with role 'tool'…)→ 友好提示
  4. + *
  5. {@link IAIChatHandler#MODEL_ERROR_MAP} 中的关键字匹配 → 对应中文提示
  6. + *
  7. 兜底 → defaultMsg 参数
  8. + *
+ * + * @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; + } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/CommandExecUtil.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/CommandExecUtil.java index cef1c1a1a..601cc8716 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/CommandExecUtil.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/CommandExecUtil.java @@ -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 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 { diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/EmbeddingHandler.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/EmbeddingHandler.java index acf4980d1..83b209057 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/EmbeddingHandler.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/EmbeddingHandler.java @@ -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)"); + //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 segments = splitDocumentPreservingHtmlTables(from, splitter); + List 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 splitDocumentPreservingHtmlTables(Document document, DocumentSplitter splitter) { + String text = document.text(); + Metadata metadata = document.metadata(); + List 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 result) { + List segments = splitter.split(Document.from(text, metadata)); + result.addAll(segments); + } + + /** + * 将文本作为单个完整段追加到 result(不经过分段器,用于保留完整表格块) + */ + public static void appendSegment(String text, Metadata metadata, List result) { + result.add(TextSegment.from(text, metadata)); + } + + /** + * 为分段列表的 metadata 写入从 0 开始的连续 index,供检索时标识顺序 + */ + public static void reindexSegments(List 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; } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/PluginToolBuilder.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/PluginToolBuilder.java index 106e7857e..0abe55fe8 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/PluginToolBuilder.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/handler/PluginToolBuilder.java @@ -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路径遍历漏洞修复--- } } } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/IAiragFlowPluginService.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/IAiragFlowPluginService.java index 0ea69db68..7b97064e4 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/IAiragFlowPluginService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/IAiragFlowPluginService.java @@ -16,4 +16,13 @@ public interface IAiragFlowPluginService { * @param flowIds 多个流程id */ Map getFlowsToPlugin(String flowIds); + + /** + * 获取流程插件(携带应用上下文参数) + * + * @param flowIds 多个流程id + * @param appId 应用ID(变量节点需要) + * @param memoryId 记忆库ID(记忆节点需要) + */ + Map getFlowsToPlugin(String flowIds, String appId, String memoryId); } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragBaseApiImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragBaseApiImpl.java deleted file mode 100644 index bd3bfda8e..000000000 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragBaseApiImpl.java +++ /dev/null @@ -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(); - } -} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragFlowPluginServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragFlowPluginServiceImpl.java index 9d51c9df0..2f0b17422 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragFlowPluginServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragFlowPluginServiceImpl.java @@ -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 getFlowsToPlugin(String flowIds, String appId, String memoryId) { + return doGetFlowsToPlugin(flowIds, appId, memoryId); + } + @Override public Map getFlowsToPlugin(String flowIds) { + return doGetFlowsToPlugin(flowIds, null, null); + } + + private Map doGetFlowsToPlugin(String flowIds, String appId, String memoryId) { log.info("开始构建流程插件"); // 1. 查询所有启用的流程 LambdaQueryWrapper 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); + } + /** * 获取参数 * diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragKnowledgeDocServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragKnowledgeDocServiceImpl.java index be75bf62b..4e98738ea 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragKnowledgeDocServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/llm/service/impl/AiragKnowledgeDocServiceImpl.java @@ -308,6 +308,20 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl docList = new ArrayList<>(); AtomicInteger fileCount = new AtomicInteger(0); @@ -338,6 +352,12 @@ public class AiragKnowledgeDocServiceImpl extends ServiceImpl 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 segments = new ArrayList<>(); + //存放文本的集合 + List 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 segments, List 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 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); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/controller/VideoGenerationController.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/controller/VideoGenerationController.java new file mode 100644 index 000000000..913241e82 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/controller/VideoGenerationController.java @@ -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 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 queryTask(@PathVariable String taskId) { + VideoTaskResultVo result = videoGenerationService.queryTask(taskId); + return Result.OK(result); + } + + /** + * 为已完成的视频添加AI配音 + * 流程:生成旁白文案 → TTS语音合成 → FFmpeg合并视频和音频 + */ + @PostMapping("/voiceover") + public Result 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>> getPresetPrompts() { + return Result.OK(videoGenerationService.getPresetPrompts()); + } + + /** + * 查询当前用户的视频生成记录 + */ + @GetMapping("/listByUser") + public Result> getVideoRecords(@RequestParam String userId) { + List records = videoGenerationService.getVideoRecords(userId); + return Result.OK(records); + } + + /** + * 删除视频生成记录 + */ + @DeleteMapping("/deleteVideoRecord") + public Result deleteVideoRecord(@RequestParam String userId, @RequestParam String recordId) { + boolean deleted = videoGenerationService.deleteVideoRecord(userId, recordId); + return deleted ? Result.OK("删除成功") : Result.error("记录不存在"); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/service/IVideoGenerationService.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/service/IVideoGenerationService.java new file mode 100644 index 000000000..85740c6da --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/service/IVideoGenerationService.java @@ -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> getPresetPrompts(); + + /** + * 查询用户视频生成记录列表 + */ + List getVideoRecords(String userId); + + /** + * 删除用户视频生成记录 + */ + boolean deleteVideoRecord(String userId, String recordId); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/service/impl/VideoGenerationServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/service/impl/VideoGenerationServiceImpl.java new file mode 100644 index 000000000..c5b03c44c --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/service/impl/VideoGenerationServiceImpl.java @@ -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> 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 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 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 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> getPresetPrompts() { + return PRESET_PROMPTS; + } + + @Override + public List getVideoRecords(String userId) { + String redisKey = REDIS_KEY_PREFIX + userId; + List 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 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 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 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; + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/vo/VideoGenerateVo.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/vo/VideoGenerateVo.java new file mode 100644 index 000000000..ca6ea3a58 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/vo/VideoGenerateVo.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/vo/VideoTaskResultVo.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/vo/VideoTaskResultVo.java new file mode 100644 index 000000000..23578ed94 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/video/vo/VideoTaskResultVo.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/controller/VoiceController.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/controller/VoiceController.java new file mode 100644 index 000000000..6c4bb2bae --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/controller/VoiceController.java @@ -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 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 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> getVoiceRecords(@RequestParam String userId) { + List records = voiceService.getVoiceRecords(userId); + return Result.OK(records); + } + + /** + * 删除语音生成记录 + */ + @DeleteMapping("/deleteVoiceRecord") + public Result deleteVoiceRecord(@RequestParam String userId, @RequestParam String recordId) { + boolean deleted = voiceService.deleteVoiceRecord(userId, recordId); + return deleted ? Result.OK("删除成功") : Result.error("记录不存在"); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/service/IVoiceService.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/service/IVoiceService.java new file mode 100644 index 000000000..a8e9d2a1b --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/service/IVoiceService.java @@ -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 getVoiceRecords(String userId); + + /** + * 删除用户语音生成记录 + * @param userId 用户ID + * @param recordId 记录ID + * @return 是否删除成功 + */ + boolean deleteVoiceRecord(String userId, String recordId); +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/service/impl/VoiceServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/service/impl/VoiceServiceImpl.java new file mode 100644 index 000000000..cba4f194f --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/service/impl/VoiceServiceImpl.java @@ -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 getVoiceRecords(String userId) { + String redisKey = REDIS_KEY_PREFIX + userId; + List 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 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); + } + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/util/VoiceApiHelper.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/util/VoiceApiHelper.java new file mode 100644 index 000000000..ad2432876 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/util/VoiceApiHelper.java @@ -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:

统一封装 语音 HTTP API 调用逻辑,供语音模块和视频模块复用

+* +* @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 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); + } +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/vo/VoiceGenerateVo.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/vo/VoiceGenerateVo.java new file mode 100644 index 000000000..e4f55cdf0 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/vo/VoiceGenerateVo.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/vo/VoiceResultVo.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/vo/VoiceResultVo.java new file mode 100644 index 000000000..1dbb4f326 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/voice/vo/VoiceResultVo.java @@ -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; +} diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/controller/EoaWordTemplateController.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/controller/AigcWordTemplateController.java similarity index 82% rename from jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/controller/EoaWordTemplateController.java rename to jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/controller/AigcWordTemplateController.java index 98e0029f1..f30dddf4c 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/controller/EoaWordTemplateController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/controller/AigcWordTemplateController.java @@ -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 { +public class AigcWordTemplateController extends JeecgController { @Autowired - private IEoaWordTemplateService eoaWordTemplateService; + private IAigcWordTemplateService eoaWordTemplateService; @Autowired WordTplUtils wordTplUtils; @@ -58,13 +58,13 @@ public class EoaWordTemplateController extends JeecgController> queryPageList(EoaWordTemplate eoaWordTemplate, - @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, - @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, - HttpServletRequest req) { - QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(eoaWordTemplate, req.getParameterMap()); - Page page = new Page(pageNo, pageSize); - IPage pageList = eoaWordTemplateService.page(page, queryWrapper); + public Result> queryPageList(AigcWordTemplate eoaWordTemplate, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize, + HttpServletRequest req) { + QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(eoaWordTemplate, req.getParameterMap()); + Page page = new Page(pageNo, pageSize); + IPage pageList = eoaWordTemplateService.page(page, queryWrapper); return Result.OK(pageList); } @@ -78,10 +78,10 @@ public class EoaWordTemplateController extends JeecgController add(@RequestBody EoaWordTemplate eoaWordTemplate) { + public Result 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 edit(@RequestBody EoaWordTemplate eoaWordTemplate) { + public Result edit(@RequestBody AigcWordTemplate eoaWordTemplate) { AssertUtils.assertNotEmpty("参数异常", eoaWordTemplate); AssertUtils.assertNotEmpty("模版名称不能为空", eoaWordTemplate.getName()); // 避免编辑时修改编码 @@ -145,8 +145,8 @@ public class EoaWordTemplateController extends JeecgController queryById(@RequestParam(name = "id", required = true) String id) { - EoaWordTemplate eoaWordTemplate = eoaWordTemplateService.getById(id); + public Result 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 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 { +public interface AigcWordTemplateMapper extends BaseMapper { } diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/mapper/xml/AigcWordTemplateMapper.xml b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/mapper/xml/AigcWordTemplateMapper.xml new file mode 100644 index 000000000..5fd24d896 --- /dev/null +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/mapper/xml/AigcWordTemplateMapper.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/IEoaWordTemplateService.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/IAigcWordTemplateService.java similarity index 77% rename from jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/IEoaWordTemplateService.java rename to jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/IAigcWordTemplateService.java index c4f0dfe14..488cc0cc2 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/IEoaWordTemplateService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/IAigcWordTemplateService.java @@ -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 { +public interface IAigcWordTemplateService extends IService { /** * 通过模版生成word文档 diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/impl/EoaWordTemplateServiceImpl.java b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/impl/AigcWordTemplateServiceImpl.java similarity index 87% rename from jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/impl/EoaWordTemplateServiceImpl.java rename to jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/impl/AigcWordTemplateServiceImpl.java index 9e3e55a3c..0603e859d 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/impl/EoaWordTemplateServiceImpl.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/service/impl/AigcWordTemplateServiceImpl.java @@ -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 implements IEoaWordTemplateService { +@Service("aigcWordTemplateService") +public class AigcWordTemplateServiceImpl extends ServiceImpl implements IAigcWordTemplateService { /** * 内置的系统变量键列表 @@ -50,7 +50,7 @@ public class EoaWordTemplateServiceImpl extends ServiceImpl getDepartParentIdsByDepIds(@RequestParam("depIds") Set depIds); - + + /** + * 8.4 通过 userIds 查询部门ID列表 + * + * @param userIds + * @return key = userId; value = 用户拥有的部门ID列表 + */ + @GetMapping("/sys/api/getDepartIdsByUserIds") + Map> getDepartIdsByUserIds(@RequestParam("userIds") Collection userIds); + /** * 9通过用户账号查询部门 name * @param username @@ -912,4 +921,18 @@ public interface ISysBaseAPI extends CommonAPI { */ @PostMapping("/sys/api/uniPushMsgToUser") void uniPushMsgToUser(@RequestBody PushMessageDTO pushMessageDTO); + + /** + * 根据用户名查询用户主部门信息。 + *

+ * 逻辑:取用户的主岗位(mainDepPostId),再查询该岗位节点在 sys_depart 中的父节点, + * 父节点即为用户的主部门,返回其信息。 + *

+ * + * @param username 用户账号 + * @return 主部门信息,若用户未配置主岗位则返回 {@code null} + */ + @GetMapping("/sys/api/queryMainDepartByUsername") + SysDepartModel queryMainDepartByUsername(@RequestParam("username") String username); + } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-cloud-api/src/main/java/org/jeecg/common/system/api/fallback/SysBaseAPIFallback.java b/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-cloud-api/src/main/java/org/jeecg/common/system/api/fallback/SysBaseAPIFallback.java index 0f6e9cc82..837d1fbb3 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-cloud-api/src/main/java/org/jeecg/common/system/api/fallback/SysBaseAPIFallback.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-cloud-api/src/main/java/org/jeecg/common/system/api/fallback/SysBaseAPIFallback.java @@ -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> getDepartIdsByUserIds(Collection userIds) { + return Map.of(); + } + @Override public List 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 ""; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-local-api/src/main/java/org/jeecg/common/airag/api/IAiragBaseApi.java b/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-local-api/src/main/java/org/jeecg/common/airag/api/IAiragBaseApi.java index e0798d27a..d253de717 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-local-api/src/main/java/org/jeecg/common/airag/api/IAiragBaseApi.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-local-api/src/main/java/org/jeecg/common/airag/api/IAiragBaseApi.java @@ -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); } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-local-api/src/main/java/org/jeecg/common/system/api/ISysBaseAPI.java b/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-local-api/src/main/java/org/jeecg/common/system/api/ISysBaseAPI.java index b8666db41..3ed6aa58a 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-local-api/src/main/java/org/jeecg/common/system/api/ISysBaseAPI.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-api/jeecg-system-local-api/src/main/java/org/jeecg/common/system/api/ISysBaseAPI.java @@ -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 getDepartParentIdsByDepIds(Set depIds); + /** + * 8.4 通过 userIds 查询部门ID列表 + * + * @param userIds + * @return key = userId; value = 用户拥有的部门ID列表 + */ + Map> getDepartIdsByUserIds(Collection userIds); + /** * 9通过用户账号查询部门 name * @param username @@ -644,4 +653,17 @@ public interface ISysBaseAPI extends CommonAPI { * @param pushMessageDTO 推送消息 */ void uniPushMsgToUser(PushMessageDTO pushMessageDTO); + + /** + * 根据用户名查询用户主部门信息。 + *

+ * 逻辑:取用户的主岗位(mainDepPostId),再查询该岗位节点在 sys_depart 中的父节点, + * 父节点即为用户的主部门,返回其信息。 + *

+ * + * @param username 用户账号 + * @return 主部门信息,若用户未配置主岗位则返回 {@code null} + */ + SysDepartModel queryMainDepartByUsername(String username); + } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/airag/JeecgBizToolsProvider.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/airag/JeecgBizToolsProvider.java index a45e2749d..e6c84d88a 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/airag/JeecgBizToolsProvider.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/airag/JeecgBizToolsProvider.java @@ -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 getDefaultTools(){ + public Map getDefaultTools() { Map 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 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 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"); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/airag/TestAiGenWordEnhance.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/airag/TestAiGenWordEnhance.java deleted file mode 100644 index ef6127983..000000000 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/airag/TestAiGenWordEnhance.java +++ /dev/null @@ -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 process(Map 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"); - } -} \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/api/controller/SystemApiController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/api/controller/SystemApiController.java index e05285c5d..83ee17e31 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/api/controller/SystemApiController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/api/controller/SystemApiController.java @@ -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> getDepartIdsByUserIds(@RequestParam("userIds") Collection userIds) { + return sysBaseApi.getDepartIdsByUserIds(userIds); + } + /** * 通过用户账号查询部门 name * @param username @@ -1124,4 +1136,20 @@ public class SystemApiController { public void uniPushMsgToUser(@RequestBody PushMessageDTO pushMessageDTO){ sysBaseApi.uniPushMsgToUser(pushMessageDTO); } + + /** + * 根据用户名查询用户主部门信息。 + *

+ * 逻辑:取用户的主岗位(mainDepPostId),再查询该岗位节点在 sys_depart 中的父节点, + * 父节点即为用户的主部门,返回其信息。 + *

+ * + * @param username 用户账号 + * @return 主部门信息,若用户未配置主岗位则返回 {@code null} + */ + @GetMapping("/queryMainDepartByUsername") + SysDepartModel queryMainDepartByUsername(@RequestParam("username") String username) { + return sysBaseApi.queryMainDepartByUsername(username); + } + } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/cas/util/XmlUtils.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/cas/util/XmlUtils.java index 2afaefb3e..1a25cb3a2 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/cas/util/XmlUtils.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/cas/util/XmlUtils.java @@ -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(); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/message/websocket/WebSocket.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/message/websocket/WebSocket.java index 2592715a8..6bb7b3b72 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/message/websocket/WebSocket.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/message/websocket/WebSocket.java @@ -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消息】======================================================================================== diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/controller/OpenApiController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/controller/OpenApiController.java index 840fcd4e0..2594a9e26 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/controller/OpenApiController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/controller/OpenApiController.java @@ -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 * @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 * @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 * @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 * @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 } 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 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() { diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java index 05b4fe720..e2da03ce6 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java @@ -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 */ diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java index a301d13b6..9462ddacb 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java @@ -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 blackList = Arrays.asList(openApi.getBlackList().split(",")); - if (blackList.contains(ip)) { - throw new JeecgBootException("目标接口限制IP[" + ip + "]进行访问,IP已记录,请停止访问"); + List 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 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/oss/controller/OssFileController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/oss/controller/OssFileController.java index 21eeb3ee0..868842f23 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/oss/controller/OssFileController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/oss/controller/OssFileController.java @@ -32,6 +32,7 @@ public class OssFileController { private IOssFileService ossFileService; @ResponseBody + @RequiresPermissions("system:ossFile:list") @GetMapping("/list") public Result> 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(); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/quartz/service/impl/QuartzJobServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/quartz/service/impl/QuartzJobServiceImpl.java index d090149b4..e69a58b3d 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/quartz/service/impl/QuartzJobServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/quartz/service/impl/QuartzJobServiceImpl.java @@ -174,9 +174,22 @@ public class QuartzJobServiceImpl extends ServiceImpl 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(); } } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/config/json/app3-version.json b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/config/json/app3-version.json new file mode 100644 index 000000000..f6ccbe010 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/config/json/app3-version.json @@ -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" +} \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java index b26b7e8b3..223a8a14d 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/LoginController.java @@ -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()); }); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAnnouncementController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAnnouncementController.java index c515ea086..172525b5d 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAnnouncementController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAnnouncementController.java @@ -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> 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 add(@RequestBody SysAnnouncement sysAnnouncement) { Result result = new Result(); @@ -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 eidt(@RequestBody SysAnnouncement sysAnnouncement) { Result result = new Result(); @@ -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 editIzTop(@RequestBody SysAnnouncement sysAnnouncement) { Result result = new Result(); @@ -216,6 +229,7 @@ public class SysAnnouncementController { * @param id * @return */ + @RequiresPermissions("system:sysAnnouncement:delete") @RequestMapping(value = "/delete", method = RequestMethod.DELETE) public Result delete(@RequestParam(name="id",required=true) String id) { Result result = new Result(); @@ -238,6 +252,7 @@ public class SysAnnouncementController { * @param ids * @return */ + @RequiresPermissions("system:sysAnnouncement:deleteBatch") @RequestMapping(value = "/deleteBatch", method = RequestMethod.DELETE) public Result deleteBatch(@RequestParam(name="ids",required=true) String ids) { Result result = new Result(); @@ -278,6 +293,7 @@ public class SysAnnouncementController { * @param id * @return */ + @RequiresPermissions("system:sysAnnouncement:doReleaseData") @RequestMapping(value = "/doReleaseData", method = RequestMethod.GET) public Result doReleaseData(@RequestParam(name="id",required=true) String id, HttpServletRequest request) { Result result = new Result(); @@ -358,6 +374,7 @@ public class SysAnnouncementController { * @param id * @return */ + @RequiresPermissions("system:sysAnnouncement:doReovkeData") @RequestMapping(value = "/doReovkeData", method = RequestMethod.GET) public Result doReovkeData(@RequestParam(name="id",required=true) String id, HttpServletRequest request) { Result result = new Result(); @@ -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 syncNotic(@RequestParam(name="anntId",required=false) String anntId, HttpServletRequest request) { Result result = new Result(); @@ -681,7 +701,7 @@ public class SysAnnouncementController { Result> 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 pageList = (Page) redisTemplate.opsForValue().get(keyString); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAnnouncementSendController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAnnouncementSendController.java index f2c27e289..9a3edbf82 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAnnouncementSendController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAnnouncementSendController.java @@ -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(); } } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAppVersionController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAppVersionController.java index 10b86d5c6..12f710535 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAppVersionController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysAppVersionController.java @@ -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; + } } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDataSourceController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDataSourceController.java index 3b8e2121b..763cf0659 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDataSourceController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDataSourceController.java @@ -121,11 +121,13 @@ public class SysDataSourceController extends JeecgController 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 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 delete(@RequestParam(name = "id") String id) { return sysDataSourceService.deleteDataSource(id); @@ -174,6 +179,7 @@ public class SysDataSourceController extends JeecgController deleteBatch(@RequestParam(name = "ids") String ids) { List idList = Arrays.asList(ids.split(",")); @@ -193,6 +199,7 @@ public class SysDataSourceController extends JeecgController queryById(@RequestParam(name = "id") String id) throws InterruptedException { SysDataSource sysDataSource = sysDataSourceService.getById(id); @@ -211,6 +218,7 @@ public class SysDataSourceController extends JeecgController importExcel(HttpServletRequest request, HttpServletResponse response) { return super.importExcel(request, response, SysDataSource.class); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDepartController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDepartController.java index 854b57eae..bc1235404 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDepartController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDepartController.java @@ -137,10 +137,10 @@ public class SysDepartController { * @return */ @RequestMapping(value = "/queryDepartTreeSync", method = RequestMethod.GET) - public Result> queryDepartTreeSync(@RequestParam(name = "pid", required = false) String parentId,@RequestParam(name = "ids", required = false) String ids, @RequestParam(name = "primaryKey", required = false) String primaryKey) { + public Result> 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> result = new Result<>(); try { - List list = sysDepartService.queryTreeListByPid(parentId,ids, primaryKey); + List list = sysDepartService.queryTreeListByPid(parentId,ids, primaryKey, orgCategory); result.setResult(list); result.setSuccess(true); } catch (Exception e) { @@ -737,6 +737,16 @@ public class SysDepartController { List list = sysDepartService.getRankRelation(departId); return Result.ok(list); } + /** + * 获取ALL职级关系 + * @param departId + * @return + */ + @GetMapping("/getALLRankRelation") + public Result> getALLRankRelation(@RequestParam(name = "departId",required = false) String departId){ + List list = sysDepartService.getALLRankRelation(departId); + return Result.ok(list); + } /** * 根据部门code获取当前和上级部门名称 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDictController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDictController.java index 268c3c061..538d6e06c 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDictController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysDictController.java @@ -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> batchAddDictWithItems(@RequestBody SysDictBatchVo sysDictBatchVo) { + Result> result = new Result>(); + //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> 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 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 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 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 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 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysFillRuleController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysFillRuleController.java index b8597ea57..ea6cb1102 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysFillRuleController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysFillRuleController.java @@ -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 add(@RequestBody SysFillRule sysFillRule) { sysFillRuleService.save(sysFillRule); @@ -93,6 +96,7 @@ public class SysFillRuleController extends JeecgController edit(@RequestBody SysFillRule sysFillRule) { sysFillRuleService.updateById(sysFillRule); @@ -107,6 +111,7 @@ public class SysFillRuleController extends JeecgController delete(@RequestParam(name = "id", required = true) String id) { sysFillRuleService.removeById(id); @@ -121,6 +126,7 @@ public class SysFillRuleController extends JeecgController deleteBatch(@RequestParam(name = "ids", required = true) String ids) { this.sysFillRuleService.removeByIds(Arrays.asList(ids.split(","))); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysGatewayRouteController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysGatewayRouteController.java index 68bc4eaba..9af4c944b 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysGatewayRouteController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysGatewayRouteController.java @@ -34,6 +34,7 @@ public class SysGatewayRouteController extends JeecgController updateAll(@RequestBody JSONObject json) { sysGatewayRouteService.updateAll(json); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysPermissionController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysPermissionController.java index e0792598b..743fe810b 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysPermissionController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysPermissionController.java @@ -91,9 +91,13 @@ public class SysPermissionController { query.eq(SysPermission::getDelFlag, CommonConstant.DEL_FLAG_0); query.orderByAsc(SysPermission::getSortNo); - //支持通过菜单名字,模糊查询 + //支持通过菜单名字或url,模糊查询 if(oConvertUtils.isNotEmpty(sysPermission.getName())){ - query.like(SysPermission::getName, sysPermission.getName()); + query.and(wrapper -> wrapper + .like(SysPermission::getName, sysPermission.getName()) + .or() + .like(SysPermission::getUrl, sysPermission.getName()) + ); } List list = sysPermissionService.list(query); List treeList = new ArrayList<>(); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUgroupController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUgroupController.java new file mode 100644 index 000000000..c06e62048 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUgroupController.java @@ -0,0 +1,173 @@ +package org.jeecg.modules.system.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import 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.aspect.annotation.AutoLog; +import org.jeecg.common.system.base.controller.JeecgController; +import org.jeecg.common.system.query.QueryGenerator; +import org.jeecg.modules.system.entity.SysUgroup; +import org.jeecg.modules.system.service.ISysUgroupService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.ModelAndView; + +import java.util.Arrays; +import java.util.Date; + /** + * @Description: 用户组表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +@Tag(name="用户组表") +@RestController +@RequestMapping("/sys/ugroup") +@Slf4j +public class SysUgroupController extends JeecgController { + @Autowired + private ISysUgroupService sysUgroupService; + + + /** + * 分页列表查询 + * + * @param sysUgroup + * @param pageNo + * @param pageSize + * @param req + * @return + */ + //@AutoLog(value = "用户组表-分页列表查询") + @Operation(summary="用户组表-分页列表查询") + @GetMapping(value = "/list") + public Result> queryPageList(SysUgroup sysUgroup, + @RequestParam(name="pageNo", defaultValue="1") Integer pageNo, + @RequestParam(name="pageSize", defaultValue="10") Integer pageSize, + HttpServletRequest req) { + + + QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(sysUgroup, req.getParameterMap()); + Page page = new Page(pageNo, pageSize); + IPage pageList = sysUgroupService.page(page, queryWrapper); + return Result.OK(pageList); + } + + /** + * 添加 + * + * @param sysUgroup + * @return + */ + @AutoLog(value = "用户组表-添加") + @Operation(summary="用户组表-添加") + @RequiresPermissions("system:sys_ugroup:add") + @PostMapping(value = "/add") + public Result add(@RequestBody SysUgroup sysUgroup) { + Result result = new Result(); + try { + sysUgroup.setCreateTime(new Date()); + sysUgroupService.save(sysUgroup); + result.success("添加成功!"); + } catch (Exception e) { + log.error(e.getMessage(), e); + result.error500("操作失败"); + } + return result; + } + + /** + * 编辑 + * + * @param sysUgroup + * @return + */ + @AutoLog(value = "用户组表-编辑") + @Operation(summary="用户组表-编辑") + @RequiresPermissions("system:sys_ugroup:edit") + @RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST}) + public Result edit(@RequestBody SysUgroup sysUgroup) { + sysUgroupService.updateById(sysUgroup); + return Result.OK("编辑成功!"); + } + + /** + * 通过id删除 + * + * @param id + * @return + */ + @AutoLog(value = "用户组表-通过id删除") + @Operation(summary="用户组表-通过id删除") + @RequiresPermissions("system:sys_ugroup:delete") + @DeleteMapping(value = "/delete") + public Result delete(@RequestParam(name="id",required=true) String id) { + sysUgroupService.deleteById(id); + return Result.OK("删除成功!"); + } + + /** + * 批量删除 + * + * @param ids + * @return + */ + @AutoLog(value = "用户组表-批量删除") + @Operation(summary="用户组表-批量删除") + @RequiresPermissions("system:sys_ugroup:deleteBatch") + @DeleteMapping(value = "/deleteBatch") + public Result deleteBatch(@RequestParam(name="ids",required=true) String ids) { + this.sysUgroupService.deleteByIds(Arrays.asList(ids.split(","))); + return Result.OK("批量删除成功!"); + } + + /** + * 通过id查询 + * + * @param id + * @return + */ + //@AutoLog(value = "用户组表-通过id查询") + @Operation(summary="用户组表-通过id查询") + @GetMapping(value = "/queryById") + public Result queryById(@RequestParam(name="id",required=true) String id) { + SysUgroup sysUgroup = sysUgroupService.getById(id); + if(sysUgroup==null) { + return Result.error("未找到对应数据"); + } + return Result.OK(sysUgroup); + } + + /** + * 导出excel + * + * @param request + * @param sysUgroup + */ + @RequiresPermissions("system:sys_ugroup:exportXls") + @RequestMapping(value = "/exportXls") + public ModelAndView exportXls(HttpServletRequest request, SysUgroup sysUgroup) { + return super.exportXls(request, sysUgroup, SysUgroup.class, "用户组表"); + } + + /** + * 通过excel导入数据 + * + * @param request + * @param response + * @return + */ + @RequiresPermissions("system:sys_ugroup:importExcel") + @RequestMapping(value = "/importExcel", method = RequestMethod.POST) + public Result importExcel(HttpServletRequest request, HttpServletResponse response) { + return super.importExcel(request, response, SysUgroup.class); + } + +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUgroupUserController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUgroupUserController.java new file mode 100644 index 000000000..9dcbf43a3 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUgroupUserController.java @@ -0,0 +1,164 @@ +package org.jeecg.modules.system.controller; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import 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.aspect.annotation.AutoLog; +import org.jeecg.common.system.base.controller.JeecgController; +import org.jeecg.common.system.query.QueryGenerator; +import org.jeecg.modules.system.entity.SysUgroupUser; +import org.jeecg.modules.system.service.ISysUgroupUserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.ModelAndView; + +import java.util.Arrays; + /** + * @Description: 用户组关系表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +@Tag(name="用户组关系表") +@RestController +@RequestMapping("/system/sysUgroupUser") +@Slf4j +public class SysUgroupUserController extends JeecgController { + @Autowired + private ISysUgroupUserService sysUgroupUserService; + + /** + * 分页列表查询 + * + * @param sysUgroupUser + * @param pageNo + * @param pageSize + * @param req + * @return + */ + //@AutoLog(value = "用户组关系表-分页列表查询") + @Operation(summary="用户组关系表-分页列表查询") + @GetMapping(value = "/list") + public Result> queryPageList(SysUgroupUser sysUgroupUser, + @RequestParam(name="pageNo", defaultValue="1") Integer pageNo, + @RequestParam(name="pageSize", defaultValue="10") Integer pageSize, + HttpServletRequest req) { + + + QueryWrapper queryWrapper = QueryGenerator.initQueryWrapper(sysUgroupUser, req.getParameterMap()); + Page page = new Page(pageNo, pageSize); + IPage pageList = sysUgroupUserService.page(page, queryWrapper); + return Result.OK(pageList); + } + + /** + * 添加 + * + * @param sysUgroupUser + * @return + */ + @AutoLog(value = "用户组关系表-添加") + @Operation(summary="用户组关系表-添加") + @RequiresPermissions("system:sys_ugroup_user:add") + @PostMapping(value = "/add") + public Result add(@RequestBody SysUgroupUser sysUgroupUser) { + sysUgroupUserService.save(sysUgroupUser); + + return Result.OK("添加成功!"); + } + + /** + * 编辑 + * + * @param sysUgroupUser + * @return + */ + @AutoLog(value = "用户组关系表-编辑") + @Operation(summary="用户组关系表-编辑") + @RequiresPermissions("system:sys_ugroup_user:edit") + @RequestMapping(value = "/edit", method = {RequestMethod.PUT,RequestMethod.POST}) + public Result edit(@RequestBody SysUgroupUser sysUgroupUser) { + sysUgroupUserService.updateById(sysUgroupUser); + return Result.OK("编辑成功!"); + } + + /** + * 通过id删除 + * + * @param id + * @return + */ + @AutoLog(value = "用户组关系表-通过id删除") + @Operation(summary="用户组关系表-通过id删除") + @RequiresPermissions("system:sys_ugroup_user:delete") + @DeleteMapping(value = "/delete") + public Result delete(@RequestParam(name="id",required=true) String id) { + sysUgroupUserService.removeById(id); + return Result.OK("删除成功!"); + } + + /** + * 批量删除 + * + * @param ids + * @return + */ + @AutoLog(value = "用户组关系表-批量删除") + @Operation(summary="用户组关系表-批量删除") + @RequiresPermissions("system:sys_ugroup_user:deleteBatch") + @DeleteMapping(value = "/deleteBatch") + public Result deleteBatch(@RequestParam(name="ids",required=true) String ids) { + this.sysUgroupUserService.removeByIds(Arrays.asList(ids.split(","))); + return Result.OK("批量删除成功!"); + } + + /** + * 通过id查询 + * + * @param id + * @return + */ + //@AutoLog(value = "用户组关系表-通过id查询") + @Operation(summary="用户组关系表-通过id查询") + @GetMapping(value = "/queryById") + public Result queryById(@RequestParam(name="id",required=true) String id) { + SysUgroupUser sysUgroupUser = sysUgroupUserService.getById(id); + if(sysUgroupUser==null) { + return Result.error("未找到对应数据"); + } + return Result.OK(sysUgroupUser); + } + + /** + * 导出excel + * + * @param request + * @param sysUgroupUser + */ + @RequiresPermissions("system:sys_ugroup_user:exportXls") + @RequestMapping(value = "/exportXls") + public ModelAndView exportXls(HttpServletRequest request, SysUgroupUser sysUgroupUser) { + return super.exportXls(request, sysUgroupUser, SysUgroupUser.class, "用户组关系表"); + } + + /** + * 通过excel导入数据 + * + * @param request + * @param response + * @return + */ + @RequiresPermissions("system:sys_ugroup_user:importExcel") + @RequestMapping(value = "/importExcel", method = RequestMethod.POST) + public Result importExcel(HttpServletRequest request, HttpServletResponse response) { + return super.importExcel(request, response, SysUgroupUser.class); + } + +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java index 51aa69896..d3a4ef1d8 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/controller/SysUserController.java @@ -39,6 +39,7 @@ import org.jeecg.modules.system.service.*; import org.jeecg.modules.system.util.ImportSysUserCache; import org.jeecg.modules.system.vo.SysDepartUsersVO; import org.jeecg.modules.system.vo.SysUserExportVo; +import org.jeecg.modules.system.vo.SysUserGroupVO; import org.jeecg.modules.system.vo.SysUserRoleVO; import org.jeecg.modules.system.vo.lowapp.DepartAndUserInfo; import org.jeecg.modules.system.vo.lowapp.UpdateDepartInfo; @@ -81,6 +82,9 @@ public class SysUserController { @Autowired private ISysUserRoleService sysUserRoleService; + @Autowired + private ISysUgroupUserService sysUgroupUserService; + @Autowired private ISysUserDepartService sysUserDepartService; @@ -637,14 +641,23 @@ public class SysUserController { * @return */ @RequestMapping(value = "/queryByNames", method = RequestMethod.GET) - public Result> queryByNames(@RequestParam(name = "userNames") String userNames) { - Result> result = new Result<>(); + public Result>> queryByNames(@RequestParam(name = "userNames") String userNames) { + Result>> result = new Result<>(); String[] names = userNames.split(","); - QueryWrapper queryWrapper=new QueryWrapper(); - queryWrapper.lambda().in(true,SysUser::getUsername,names); - Collection userRole = sysUserService.list(queryWrapper); + //update-begin---author:zzl ---date:2026-04-03 for:只返回username和realname字段---- + List userList = sysUserService.lambdaQuery() + .in(SysUser::getUsername, names) + .select(SysUser::getUsername, SysUser::getRealname) + .list(); + List> dataList = userList.stream().map(user -> { + Map map = new java.util.HashMap<>(); + map.put("username", user.getUsername()); + map.put("realname", user.getRealname()); + return map; + }).collect(java.util.stream.Collectors.toList()); result.setSuccess(true); - result.setResult(userRole); + result.setResult(dataList); + //update-end---author:zzl ---date:2026-04-03 for:只返回username和realname字段---- return result; } @@ -776,7 +789,100 @@ public class SysUserController { } return result; } + /** + * 给指定用户组添加用户 + * + * @param + * @return + */ + @RequestMapping(value = "/addSysUserGroup", method = RequestMethod.POST) + public Result addSysUserGroup(@RequestBody SysUserGroupVO sysUserGroupVO) { + Result result = new Result(); + try { + String groupId = sysUserGroupVO.getGroupId(); + for(String sysUserId : sysUserGroupVO.getUserIdList()) { + SysUgroupUser sysUgroupUser = new SysUgroupUser(sysUserId,groupId); + QueryWrapper queryWrapper = new QueryWrapper(); + queryWrapper.eq("group_id", groupId).eq("user_id",sysUserId); + SysUgroupUser one = sysUgroupUserService.getOne(queryWrapper); + if(one==null){ + sysUgroupUserService.save(sysUgroupUser); + } + } + result.setMessage("添加成功!"); + result.setSuccess(true); + return result; + }catch(Exception e) { + log.error(e.getMessage(), e); + result.setSuccess(false); + result.setMessage("出错了: " + e.getMessage()); + return result; + } + } + /** + * 删除指定用户组的用户关系 + * @param + * @return + */ + @RequestMapping(value = "/deleteGroupUser", method = RequestMethod.DELETE) + public Result deleteGroupUser(@RequestParam(name="groupId") String groupId, + @RequestParam(name="userId",required=true) String userId + ) { + Result result = new Result(); + try { + QueryWrapper queryWrapper = new QueryWrapper(); + queryWrapper.eq("group_id", groupId).eq("user_id",userId); + sysUgroupUserService.remove(queryWrapper); + result.success("删除成功!"); + }catch(Exception e) { + log.error(e.getMessage(), e); + result.error500("删除失败!"); + } + return result; + } + /** + * 批量删除指定用户组下的用户关系 + * + * @param + * @return + */ + @RequestMapping(value = "/deleteUserGroupBatch", method = RequestMethod.DELETE) + public Result deleteUserGroupBatch( + @RequestParam(name="groupId") String groupId, + @RequestParam(name="userIds",required=true) String userIds) { + Result result = new Result(); + try { + QueryWrapper queryWrapper = new QueryWrapper(); + queryWrapper.eq("group_id", groupId).in("user_id",Arrays.asList(userIds.split(","))); + sysUgroupUserService.remove(queryWrapper); + result.success("删除成功!"); + }catch(Exception e) { + log.error(e.getMessage(), e); + result.error500("删除失败!"); + } + return result; + } + /** + * 用户组下用户分页列表查询 + * @param pageNo + * @param pageSize + * @param req + * @return + */ + @RequestMapping(value = "/groupUserList", method = RequestMethod.GET) + public Result> groupUserList(@RequestParam(name="pageNo", defaultValue="1") Integer pageNo, + @RequestParam(name="pageSize", defaultValue="10") Integer pageSize, HttpServletRequest req) { + Result> result = new Result>(); + Page page = new Page(pageNo, pageSize); + String groupId = req.getParameter("groupId"); + String username = req.getParameter("username"); + String realname = req.getParameter("realname"); + IPage pageList = sysUserService.getUserByUgroupId(page,groupId,username,realname); + result.setSuccess(true); + result.setResult(pageList); + return result; + } /** * 部门用户列表 */ @@ -1314,6 +1420,13 @@ public class SysUserController { // 根据用户名查询用户信息 SysUser sysUser = sysUserService.getUserByName(username); + //update-begin---author:zhangdaihao ---date:2026-04-15 for:【issue/9518】校验外部传入token的签名,防止越权----------- + if (oConvertUtils.isNotEmpty(token)) { + if (sysUser == null || !JwtUtil.verify(token, username, sysUser.getPassword())) { + return Result.error(401, "token校验失败"); + } + } + //update-end---author:zhangdaihao ---date:2026-04-15 for:【issue/9518】校验外部传入token的签名,防止越权----------- Map map = new HashMap(); map.put("sysUserId", sysUser.getId()); map.put("sysUserCode", sysUser.getUsername()); // 当前登录用户登录账号 diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUgroup.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUgroup.java new file mode 100644 index 000000000..3809574c3 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUgroup.java @@ -0,0 +1,62 @@ +package org.jeecg.modules.system.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.jeecgframework.poi.excel.annotation.Excel; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; + +/** + * @Description: 用户组表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +@Data +@TableName("sys_ugroup") +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = false) +@Schema(description="用户组表") +public class SysUgroup implements Serializable { + private static final long serialVersionUID = 1L; + + /**主键id*/ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键id") + private java.lang.String id; + /**角色名称*/ + @Excel(name = "用户组名称", width = 15) + @Schema(description = "用户组名称") + private java.lang.String groupName; + /**描述*/ + @Excel(name = "描述", width = 15) + @Schema(description = "描述") + private java.lang.String description; + /**创建人*/ + @Schema(description = "创建人") + private java.lang.String createBy; + /**创建时间*/ + @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") + @Schema(description = "创建时间") + private java.util.Date createTime; + /**更新人*/ + @Schema(description = "更新人") + private java.lang.String updateBy; + /**更新时间*/ + @JsonFormat(timezone = "GMT+8",pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern="yyyy-MM-dd HH:mm:ss") + @Schema(description = "更新时间") + private java.util.Date updateTime; + /**租户ID*/ + @Excel(name = "租户ID", width = 15) + @Schema(description = "租户ID") + private java.lang.Integer tenantId; +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUgroupUser.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUgroupUser.java new file mode 100644 index 000000000..685268cfd --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/entity/SysUgroupUser.java @@ -0,0 +1,52 @@ +package org.jeecg.modules.system.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; +import org.jeecgframework.poi.excel.annotation.Excel; + +import java.io.Serializable; + +/** + * @Description: 用户组关系表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +@Data +@TableName("sys_ugroup_user") +@Accessors(chain = true) +@EqualsAndHashCode(callSuper = false) +@Schema(description="用户组关系表") +public class SysUgroupUser implements Serializable { + private static final long serialVersionUID = 1L; + + /**主键id*/ + @TableId(type = IdType.ASSIGN_ID) + @Schema(description = "主键id") + private java.lang.String id; + /**用户id*/ + @Excel(name = "用户id", width = 15) + @Schema(description = "用户id") + private java.lang.String userId; + /**用户组id*/ + @Excel(name = "用户组id", width = 15) + @Schema(description = "用户组id") + private java.lang.String groupId; + /**租户ID*/ + @Excel(name = "租户ID", width = 15) + @Schema(description = "租户ID") + private java.lang.Integer tenantId; + + public SysUgroupUser() { + } + + public SysUgroupUser(String userId, String groupId) { + this.userId = userId; + this.groupId = groupId; + } +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysAnnouncementSendMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysAnnouncementSendMapper.java index 76f762497..be7a5e3e1 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysAnnouncementSendMapper.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysAnnouncementSendMapper.java @@ -62,4 +62,13 @@ public interface SysAnnouncementSendMapper extends BaseMapper getReadAnnSendByUserId(@Param("ids") List ids, @Param("userId") String userId); + + /** + * 根据业务id、业务类型和用户id获取未读消息 + * @param busId + * @param busType + * @param userId + * @return + */ + List getUnReadAnnByBusAndUserId(@Param("busId")String busId, @Param("busType")String busType, @Param("userId")String userId); } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysDepartMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysDepartMapper.java index ab9189e20..fc06ea35a 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysDepartMapper.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysDepartMapper.java @@ -12,9 +12,11 @@ import org.jeecg.modules.system.vo.SysDepartExportVo; import org.jeecg.modules.system.vo.SysDepartPositionVo; import org.jeecg.modules.system.vo.SysUserDepVo; import org.jeecg.modules.system.vo.lowapp.ExportDepartVo; -import org.springframework.data.repository.query.Param; +import org.apache.ibatis.annotations.Param; +import java.util.Collection; import java.util.List; +import java.util.Map; /** *

@@ -42,13 +44,21 @@ public interface SysDepartMapper extends BaseMapper { public List queryDepartsByUsername(@Param("username") String username); /** - * 根据用户名查询部门 + * 根据 userId 查询部门 * * @param userId * @return */ public List queryDepartsByUserId(@Param("userId") String userId); + /** + * 根据 userIds 查询部门ID + * + * @param userIds 用户ID列表 + * @return + */ + List> queryDepartIdsByUserIds(@Param("userIds") Collection userIds); + /** * 通过部门编码获取部门id * @param orgCode 部门编码 @@ -311,4 +321,11 @@ public interface SysDepartMapper extends BaseMapper { * @return */ List getDepartmentHead(@Param("page") Page page, @Param("departId") String departId); + + /** + *获取所有部门 + * @param departId + * @return + */ + List getAllDepartPost(@Param("departId")String departId); } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPermissionMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPermissionMapper.java index cd88eee9c..3102ba0fd 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPermissionMapper.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPermissionMapper.java @@ -33,7 +33,17 @@ public interface SysPermissionMapper extends BaseMapper { * @return List */ public List queryByUser(@Param("userId") String userId); - + + //update-begin---author:scott ---date:2026-04-16 for:【pull/9445】开启多租户模式时,获取用户权限时加入tenant_id判断----------- + /** + * 根据用户id和租户id查询用户权限 + * @param userId 用户ID + * @param tenantId 租户ID + * @return List + */ + public List queryByUserWithTenantId(@Param("userId") String userId, @Param("tenantId") Integer tenantId); + //update-end---author:scott ---date:2026-04-16 for:【pull/9445】开启多租户模式时,获取用户权限时加入tenant_id判断----------- + /** * 修改菜单状态字段: 是否子节点 * @param id 菜单id diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPositionMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPositionMapper.java index aa21adbbc..4e9c85266 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPositionMapper.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysPositionMapper.java @@ -4,7 +4,8 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.apache.ibatis.annotations.Select; import org.jeecg.modules.system.entity.SysPosition; -import org.springframework.data.repository.query.Param; +import org.jeecg.modules.system.vo.SysPositionVO; +import org.apache.ibatis.annotations.Param; import java.util.List; @@ -37,4 +38,13 @@ public interface SysPositionMapper extends BaseMapper { */ @Select("SELECT id FROM sys_position WHERE name = #{name} AND tenant_id = #{tenantId} ORDER BY create_time DESC") List getPositionIdByName(@Param("name") String name, @Param("tenantId") Integer tenantId, @Param("page") Page page); + + /** + * 批量通过用户id列表查询职位(含userId字段,用于批量同步场景) + * + * @param userIds 用户id列表 + * @return 职位VO列表(每条记录含userId字段,供调用方分组) + */ + List getPositionListByUserIds(@Param("userIds") List userIds); + } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysThirdAppConfigMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysThirdAppConfigMapper.java index 0c69dafbb..d19706627 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysThirdAppConfigMapper.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysThirdAppConfigMapper.java @@ -2,7 +2,7 @@ package org.jeecg.modules.system.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.jeecg.modules.system.entity.SysThirdAppConfig; -import org.springframework.data.repository.query.Param; +import org.apache.ibatis.annotations.Param; import java.util.List; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUgroupMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUgroupMapper.java new file mode 100644 index 000000000..940d94fdc --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUgroupMapper.java @@ -0,0 +1,14 @@ +package org.jeecg.modules.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.jeecg.modules.system.entity.SysUgroup; + +/** + * @Description: 用户组表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +public interface SysUgroupMapper extends BaseMapper { + +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUgroupUserMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUgroupUserMapper.java new file mode 100644 index 000000000..16eec0a4e --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUgroupUserMapper.java @@ -0,0 +1,14 @@ +package org.jeecg.modules.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.jeecg.modules.system.entity.SysUgroupUser; + +/** + * @Description: 用户组关系表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +public interface SysUgroupUserMapper extends BaseMapper { + +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUserMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUserMapper.java index b35de6463..18056da98 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUserMapper.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUserMapper.java @@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.core.toolkit.Constants; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.jeecg.modules.system.entity.SysDepart; import org.jeecg.modules.system.entity.SysUser; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import org.jeecg.modules.system.model.SysUserSysDepPostModel; @@ -271,4 +272,23 @@ public interface SysUserMapper extends BaseMapper { * @return */ List queryDepartUserByOrgCode(@Param("page") IPage page, @Param("orgCode") String orgCode, @Param("userParams") SysUser userParams); + + /** + * 根据用户名查询用户的主部门信息 + * + * @param username + * @return + */ + SysDepart getMainDepartByUsername(@Param("username") String username); + + + /** + * 根据用户组id获取用户分页列表 + * @param page + * @param groupId + * @param username + * @param realname + * @return + */ + IPage getUserByUgroupId(Page page, @Param("groupId") String groupId, @Param("username") String username, @Param("realname") String realname); } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysAnnouncementSendMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysAnnouncementSendMapper.xml index 5c26ae559..d064deb52 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysAnnouncementSendMapper.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysAnnouncementSendMapper.xml @@ -134,4 +134,25 @@ #{id} + + + \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysDepartMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysDepartMapper.xml index 741f321bf..e16ebe871 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysDepartMapper.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysDepartMapper.xml @@ -20,8 +20,8 @@ ) ) - - + + + + + select id, realname, avatar, sex, telephone, phone, main_dep_post_id, iz_hide_contact, sort, create_time from sys_user where status = 1 and del_flag = 0 - + and depart_ids like #{bindDepartId} order by sort,create_time desc + + + \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysPermissionMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysPermissionMapper.xml index a72450303..bf5d75ce8 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysPermissionMapper.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysPermissionMapper.xml @@ -224,4 +224,108 @@ and p.del_flag = 0 + + + + + \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysPositionMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysPositionMapper.xml index 778c3f1c3..261c9a5f0 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysPositionMapper.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysPositionMapper.xml @@ -19,4 +19,15 @@ #{positionId} + + + \ No newline at end of file diff --git a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/mapper/xml/EoaWordTemplateMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUgroupMapper.xml similarity index 65% rename from jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/mapper/xml/EoaWordTemplateMapper.xml rename to jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUgroupMapper.xml index 63ed8fe8b..f693ae770 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-boot-module-airag/src/main/java/org/jeecg/modules/airag/wordtpl/mapper/xml/EoaWordTemplateMapper.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUgroupMapper.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUgroupUserMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUgroupUserMapper.xml new file mode 100644 index 000000000..3d65f7b95 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUgroupUserMapper.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserDepartMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserDepartMapper.xml index 6d924d0af..237d2e6af 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserDepartMapper.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserDepartMapper.xml @@ -19,6 +19,8 @@ and a.realname like #{bindRealname} + ORDER BY + a.sort,a.create_time desc diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserMapper.xml index 60583885b..91915b0c8 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserMapper.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserMapper.xml @@ -14,7 +14,7 @@ - + + + + UPDATE sys_user SET - org_code = #{orgCode, jdbcType=VARCHAR} + org_code = #{orgCode, jdbcType=VARCHAR} ,login_tenant_id = #{loginTenantId, jdbcType=VARCHAR} @@ -403,11 +414,11 @@ - AND su.realname LIKE bindRealname + AND su.realname LIKE #{bindRealname} - AND su.work_no LIKE bindWorkNo + AND su.work_no LIKE #{bindWorkNo} @@ -462,4 +473,16 @@ order by su.sort,su.create_time desc + + + + \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysAnnouncementSendService.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysAnnouncementSendService.java index 5ea3d05c6..ece7fbb1a 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysAnnouncementSendService.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysAnnouncementSendService.java @@ -51,5 +51,5 @@ public interface ISysAnnouncementSendService extends IService @@ -116,6 +118,14 @@ public interface ISysDepartService extends IService{ */ List queryDepartsByUserId(String userId); + /** + * 根据 用户ID 查询部门ID列表 + * + * @param userIds + * @return key = 用户ID, value = 部门ID列表 + */ + Map> queryDepartIdsByUserIds(Collection userIds); + /** * 根据部门id批量删除并删除其可能存在的子级部门 * @param ids 多个部门id @@ -147,9 +157,10 @@ public interface ISysDepartService extends IService{ * @param parentId 父id * @param ids 多个部门id * @param primaryKey 主键字段(id或者orgCode) + * @param orgCategory 逗号分隔的 orgCategory 值,如 "1,2";为空时退化为默认行为(排除岗位) * @return */ - List queryTreeListByPid(String parentId,String ids, String primaryKey); + List queryTreeListByPid(String parentId,String ids, String primaryKey, String orgCategory); /** * 获取某个部门的所有父级部门的ID @@ -305,4 +316,11 @@ public interface ISysDepartService extends IService{ * @return */ IPage getDepartmentHead(String departId, Page page); + + /** + * 获取所有职级关系 + * @param departId + * @return + */ + List getALLRankRelation(String departId); } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysPositionService.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysPositionService.java index dd33dff29..3102a3b50 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysPositionService.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysPositionService.java @@ -2,6 +2,7 @@ package org.jeecg.modules.system.service; import com.baomidou.mybatisplus.extension.service.IService; import org.jeecg.modules.system.entity.SysPosition; +import org.jeecg.modules.system.vo.SysPositionVO; import java.util.List; @@ -33,4 +34,13 @@ public interface ISysPositionService extends IService { * @return */ String getPositionName(List postList); + + /** + * 批量通过用户id列表查询职位VO(含userId字段,用于批量同步场景消除N+1查询) + * + * @param userIds 用户id列表 + * @return 职位VO列表(每条记录含userId字段,供调用方分组) + */ + List getPositionListByUserIds(List userIds); + } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysThirdAccountService.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysThirdAccountService.java index 86a74cb31..c9824226e 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysThirdAccountService.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysThirdAccountService.java @@ -79,4 +79,14 @@ public interface ISysThirdAccountService extends IService { * @return */ SysThirdAccount getOneByUuidAndThirdType(String unionid, String thirdType,Integer tenantId,String thirdUserId); + + /** + * 批量通过本地用户id列表查询第三方账号(用于全量同步批量预加载) + * + * @param sysUserIds 本地用户id列表 + * @param thirdType 第三方类型 + * @return 第三方账号列表 + */ + List listBySysUserIds(List sysUserIds, String thirdType); + } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUgroupService.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUgroupService.java new file mode 100644 index 000000000..da10117b3 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUgroupService.java @@ -0,0 +1,19 @@ +package org.jeecg.modules.system.service; + +import org.jeecg.modules.system.entity.SysUgroup; +import com.baomidou.mybatisplus.extension.service.IService; + +import java.util.List; + +/** + * @Description: 用户组表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +public interface ISysUgroupService extends IService { + + void deleteById(String id); + + void deleteByIds(List list); +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUgroupUserService.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUgroupUserService.java new file mode 100644 index 000000000..8b28c5ff7 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUgroupUserService.java @@ -0,0 +1,14 @@ +package org.jeecg.modules.system.service; + +import org.jeecg.modules.system.entity.SysUgroupUser; +import com.baomidou.mybatisplus.extension.service.IService; + +/** + * @Description: 用户组关系表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +public interface ISysUgroupUserService extends IService { + +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUserService.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUserService.java index fe66848b1..ecb8e62a2 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUserService.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/ISysUserService.java @@ -538,4 +538,14 @@ public interface ISysUserService extends IService { * @param userId */ void updateClientId(String clientId,String userId); + + /** + * 根据用户组查询用户列表 + * @param page + * @param groupId + * @param username + * @param realname + * @return + */ + IPage getUserByUgroupId(Page page, String groupId, String username, String realname); } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysAnnouncementSendServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysAnnouncementSendServiceImpl.java index 21f8b7a1b..53cce8685 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysAnnouncementSendServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysAnnouncementSendServiceImpl.java @@ -86,18 +86,15 @@ public class SysAnnouncementSendServiceImpl extends ServiceImpl().eq("bus_type",busType).eq("bus_id",busId)); - if(oConvertUtils.isNotEmpty(announcement)){ - LoginUser sysUser = (LoginUser)SecurityUtils.getSubject().getPrincipal(); - String userId = sysUser.getId(); - LambdaUpdateWrapper updateWrapper = new UpdateWrapper().lambda(); - updateWrapper.set(SysAnnouncementSend::getReadFlag, CommonConstant.HAS_READ_FLAG); - updateWrapper.set(SysAnnouncementSend::getReadTime, new Date()); - updateWrapper.eq(SysAnnouncementSend::getAnntId,announcement.getId()); - updateWrapper.eq(SysAnnouncementSend::getUserId,userId); - SysAnnouncementSend announcementSend = new SysAnnouncementSend(); - sysAnnouncementSendMapper.update(announcementSend, updateWrapper); + public boolean updateReadFlagByBusId(String busId, String busType) { + boolean updateFlag = false; + LoginUser sysUser = (LoginUser)SecurityUtils.getSubject().getPrincipal(); + String userId = sysUser.getId(); + List unReadAnnouncementsIds = sysAnnouncementSendMapper.getUnReadAnnByBusAndUserId(busId,busType,userId); + if(CollectionUtil.isNotEmpty(unReadAnnouncementsIds)){ + sysAnnouncementSendMapper.updateReaded(userId, unReadAnnouncementsIds); + updateFlag = true; } + return updateFlag; } } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysBaseApiImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysBaseApiImpl.java index 7e373dcd7..b23553ba6 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysBaseApiImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysBaseApiImpl.java @@ -370,6 +370,11 @@ public class SysBaseApiImpl implements ISysBaseAPI { return sysDepartService.queryDepartsByUserId(userId); } + @Override + public Map> getDepartIdsByUserIds(Collection userIds) { + return sysDepartService.queryDepartIdsByUserIds(userIds); + } + @Override public Set getDepartParentIdsByUsername(String username) { List list = sysDepartService.queryDepartsByUsername(username); @@ -404,6 +409,29 @@ public class SysBaseApiImpl implements ISysBaseAPI { return result; } + @Override + @Cacheable(cacheNames = CacheConstant.SYS_USERS_CACHE, key = "#username + '::main_depart_info'", unless = "#result == null") + public SysDepartModel queryMainDepartByUsername(String username) { + if (oConvertUtils.isEmpty(username)) { + return null; + } + // 根据用户名查询主部门信息 + SysDepart mainDepart = userMapper.getMainDepartByUsername(username); + if (mainDepart == null) { + return null; + } + + // 复制部门信息到模型对象 + SysDepartModel model = new SysDepartModel(); + BeanUtils.copyProperties(mainDepart, model); + + // 设置部门路径名称 + String departPathName = sysDepartService.getDepartPathNameByOrgCode(model.getOrgCode(), null); + model.setDepartPathName(departPathName); + + return model; + } + @Override public DictModel getParentDepartId(String departId) { SysDepart depart = departMapper.getParentDepartId(departId); @@ -2179,7 +2207,7 @@ public class SysBaseApiImpl implements ISysBaseAPI { log.error("{} UniPush消息推送失败 返回response:{}", pushType, response.getBody()); } } catch (RestClientException e) { - log.warn("UniAPP 消息推送异常:"+ e.getMessage(), e); + log.warn("UniAPP 消息推送异常:"+ e.getMessage()); } } /** diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysCommentServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysCommentServiceImpl.java index 849b20f9e..144b8968b 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysCommentServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysCommentServiceImpl.java @@ -353,7 +353,14 @@ public class SysCommentServiceImpl extends ServiceImpl> queryDepartIdsByUserIds(Collection userIds) { + List> mapList = baseMapper.queryDepartIdsByUserIds(userIds); + if (CollectionUtils.isEmpty(mapList)) { + return Map.of(); + } + Map> res = new HashMap<>(); + for (Map map : mapList) { + String userId = map.get("user_id"); + String departId = map.get("depart_id"); + res.computeIfAbsent(userId, k -> new ArrayList<>()).add(departId); + } + return res; + } + /** * 根据用户所负责部门ids获取父级部门编码 * @param departIds @@ -665,7 +680,7 @@ public class SysDepartServiceImpl extends ServiceImpl queryTreeListByPid(String parentId,String ids, String primaryKey) { + public List queryTreeListByPid(String parentId,String ids, String primaryKey, String orgCategory) { Consumer> square = i -> { if (oConvertUtils.isNotEmpty(ids)) { if (CommonConstant.DEPART_KEY_ORG_CODE.equals(primaryKey)) { @@ -689,8 +704,13 @@ public class SysDepartServiceImpl extends ServiceImpl(); } + /** + * 获取所有部门职务 + * @param departId + * @return + */ + @Override + public List getALLRankRelation(String departId) { + //记录当前部门 key为部门id,value为部门名称 + Map departNameMap = new HashMap<>(5); + //step1 根据id查询部门信息 + List departPositionList = baseMapper.getAllDepartPost(departId); + List selectTreeVos = new ArrayList<>(); + departPositionList.forEach(position -> { + //step2 查看是否有子级部门,存在递归查询职位 + if (!CommonConstant.IS_LEAF.equals(position.getIzLeaf())) { + //获取子级职位根据部门编码 + this.getChildrenDepartPositionByOrgCode(selectTreeVos, departNameMap, position,departId); + } + }); + return buildTree(selectTreeVos); + } + /** * 获取子级职位根据部门编码 * @@ -1767,7 +1809,7 @@ public class SysDepartServiceImpl extends ServiceImpl selectTreeVos, Map departNameMap, SysDepartPositionVo sysDepartPosition) { + private void getChildrenDepartPositionByOrgCode(List selectTreeVos, Map departNameMap, SysDepartPositionVo sysDepartPosition,String departId) { String orgCode = sysDepartPosition.getOrgCode(); //step1 根据父级id获取子级部门信息 List positionList = baseMapper.getDepartPostByOrgCode(orgCode + "%"); @@ -1778,9 +1820,11 @@ public class SysDepartServiceImpl extends ServiceImpl(5); } SysDepart depart = baseMapper.getDepartById(position.getParentId()); - if(null != depart){ - position.setDepartName(depart.getDepartName()); - } + if(null != depart && oConvertUtils.isNotEmpty(departId)) { + position.setDepartName(depart.getDepartName()); + }else{ + position.setDepartName(this.getDepartPathNameByOrgCode(depart.getOrgCode(),null)); + } if(oConvertUtils.isNotEmpty(position.getDepPostParentId())){ LambdaQueryWrapper query = new LambdaQueryWrapper<>(); query.eq(SysDepart::getId,position.getDepPostParentId()); @@ -1793,6 +1837,9 @@ public class SysDepartServiceImpl extends ServiceImpl impl String orderInfo = keyword.substring(keyword.indexOf(orderKey) + orderKey.length() + 1, keyword.length() - 1); keyword = keyword.substring(0, keyword.indexOf(orderKey)); String[] orderInfoArray = orderInfo.split(SymbolConstant.COMMA); - orderField = orderInfoArray[0]; - orderType = orderInfoArray[1]; + // 【issue/9570】排序字段和排序方向使用白名单校验,防止 boolean-blind SQL 注入(CASE WHEN/LIKE/database() 等绕过黑名单) + orderField = SqlInjectionUtil.getSqlInjectField(orderInfoArray[0]); + orderType = SqlInjectionUtil.getSqlInjectOrderType(orderInfoArray[1]); } if (oConvertUtils.isNotEmpty(keyword)) { + // 【安全】对keyword进行SQL注入检测和单引号转义,防止通过keyword参数进行SQL注入 + keyword = keyword.replace("'", "''"); + // 判断是否是多选 if (keyword.contains(SymbolConstant.COMMA)) { // 代码逻辑说明: JTC-529【表单设计器】 编辑页面报错,in参数采用双引号导致 ---- @@ -661,6 +665,13 @@ public class SysDictServiceImpl extends ServiceImpl impl if (query != null) { for (Map.Entry searchItem : query.entrySet()) { String fieldName = searchItem.getKey(); + // update-begin---author:sjlei---date:20260413 for:【#9524】修复 SQL _tableFilterSql 注入漏洞 + // _tableFilterSql 是服务端内部专用 key,对应 Mapper 中的 ${value} 裸拼接, + // 禁止从外部 condition 参数传入,防止 SQL 注入(#9520) + if ("_tableFilterSql".equals(fieldName)) { + continue; + } + // update-end-----author:sjlei---date:20260413 for:【#9520】修复 SQL _tableFilterSql 注入漏洞 queryParams.put(SqlInjectionUtil.getSqlInjectField(fieldName), searchItem.getValue()); } } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java index 53c08b796..27209b029 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPermissionServiceImpl.java @@ -3,6 +3,7 @@ package org.jeecg.modules.system.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import org.jeecg.common.config.TenantContext; import org.jeecg.common.constant.CacheConstant; import org.jeecg.common.constant.CommonConstant; import org.jeecg.common.exception.JeecgBootException; @@ -235,7 +236,19 @@ public class SysPermissionServiceImpl extends ServiceImpl queryByUser(String userId) { - List permissionList = this.sysPermissionMapper.queryByUser(userId); + //update-begin---author:scott ---date:2026-04-16 for:【pull/9445】开启多租户模式时,获取用户权限时加入tenant_id判断----------- + List permissionList; + if (MybatisPlusSaasConfig.OPEN_SYSTEM_TENANT_CONTROL) { + int tenantId = oConvertUtils.getInt(TenantContext.getTenant(), -1); + if (tenantId != -1) { + permissionList = this.sysPermissionMapper.queryByUserWithTenantId(userId, tenantId); + } else { + permissionList = this.sysPermissionMapper.queryByUser(userId); + } + } else { + permissionList = this.sysPermissionMapper.queryByUser(userId); + } + //update-end---author:scott ---date:2026-04-16 for:【pull/9445】开启多租户模式时,获取用户权限时加入tenant_id判断----------- //================= begin 开启租户的时候 如果没有test角色,默认加入test角色================ if (MybatisPlusSaasConfig.OPEN_SYSTEM_TENANT_CONTROL) { if (permissionList == null) { diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPositionServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPositionServiceImpl.java index ce38f82cb..4c9fcd38e 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPositionServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysPositionServiceImpl.java @@ -6,8 +6,10 @@ import org.jeecg.common.constant.SymbolConstant; import org.jeecg.modules.system.entity.SysPosition; import org.jeecg.modules.system.mapper.SysPositionMapper; import org.jeecg.modules.system.service.ISysPositionService; +import org.jeecg.modules.system.vo.SysPositionVO; import org.springframework.stereotype.Service; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -40,4 +42,14 @@ public class SysPositionServiceImpl extends ServiceImpl getPositionListByUserIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return Collections.emptyList(); + } + return this.baseMapper.getPositionListByUserIds(userIds); + } + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysRoleIndexServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysRoleIndexServiceImpl.java index 11fcac9f8..633a845a5 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysRoleIndexServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysRoleIndexServiceImpl.java @@ -29,7 +29,7 @@ public class SysRoleIndexServiceImpl extends ServiceImpl queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.eq(SysRoleIndex::getRoleCode, DefIndexConst.DEF_INDEX_ALL); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysThirdAccountServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysThirdAccountServiceImpl.java index dffce17c0..7c202d64a 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysThirdAccountServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysThirdAccountServiceImpl.java @@ -29,6 +29,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.util.Collections; import java.util.Date; import java.util.List; @@ -151,6 +152,19 @@ public class SysThirdAccountServiceImpl extends ServiceImpl listBySysUserIds(List sysUserIds, String thirdType) { + if (sysUserIds == null || sysUserIds.isEmpty()) { + return Collections.emptyList(); + } + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + qw.in(SysThirdAccount::getSysUserId, sysUserIds); + qw.eq(SysThirdAccount::getThirdType, thirdType); + return list(qw); + } + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + @Override public List listThirdUserIdByUsername(String[] sysUsernameArr, String thirdType, Integer tenantId) { return sysThirdAccountMapper.selectThirdIdsByUsername(sysUsernameArr, thirdType,tenantId); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUgroupServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUgroupServiceImpl.java new file mode 100644 index 000000000..256b5e8b7 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUgroupServiceImpl.java @@ -0,0 +1,41 @@ +package org.jeecg.modules.system.service.impl; + +import org.jeecg.modules.system.entity.SysUgroup; +import org.jeecg.modules.system.entity.SysUgroupUser; +import org.jeecg.modules.system.mapper.SysUgroupMapper; +import org.jeecg.modules.system.service.ISysUgroupService; +import org.jeecg.modules.system.service.ISysUgroupUserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; + +import java.util.List; + +/** + * @Description: 用户组表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +@Service("sysUgroupServiceImpl") +public class SysUgroupServiceImpl extends ServiceImpl implements ISysUgroupService { + + @Autowired + private ISysUgroupUserService sysUgroupUserService; + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteById(String id) { + this.baseMapper.deleteById(id); + sysUgroupUserService.remove(new QueryWrapper().eq("group_id", id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteByIds(List list) { + this.baseMapper.deleteBatchIds(list); + sysUgroupUserService.remove(new QueryWrapper().in("group_id", list)); + } +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUgroupUserServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUgroupUserServiceImpl.java new file mode 100644 index 000000000..0b6465f69 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUgroupUserServiceImpl.java @@ -0,0 +1,19 @@ +package org.jeecg.modules.system.service.impl; + +import org.jeecg.modules.system.entity.SysUgroupUser; +import org.jeecg.modules.system.mapper.SysUgroupUserMapper; +import org.jeecg.modules.system.service.ISysUgroupUserService; +import org.springframework.stereotype.Service; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; + +/** + * @Description: 用户组关系表 + * @Author: jeecg-boot + * @Date: 2026-02-27 + * @Version: V1.0 + */ +@Service("sysUgroupUserServiceImpl") +public class SysUgroupUserServiceImpl extends ServiceImpl implements ISysUgroupUserService { + +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUserDepartServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUserDepartServiceImpl.java index 19ec2d4b9..a9a0b0b10 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUserDepartServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUserDepartServiceImpl.java @@ -135,7 +135,7 @@ public class SysUserDepartServiceImpl extends ServiceImpl userList = this.baseMapper.queryDepartUserList(depCode, realname); - Map map = new HashMap(5); + Map map = new LinkedHashMap(5); for (SysUser sysUser : userList) { // 返回的用户数据去掉密码信息 sysUser.setSalt(""); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUserServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUserServiceImpl.java index 50f63a2d6..c2fe86894 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUserServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/SysUserServiceImpl.java @@ -3036,6 +3036,31 @@ public class SysUserServiceImpl extends ServiceImpl impl this.baseMapper.updateById(sysUser); } + /** + * 根据用户组查询用户列表 + * @param page + * @param groupId + * @param username + * @param realname + * @return + */ + @Override + public IPage getUserByUgroupId(Page page, String groupId, String username, String realname) { + IPage userGroupList = userMapper.getUserByUgroupId(page, groupId, username,realname); + List records = userGroupList.getRecords(); + if (null != records && records.size() > 0) { + List userIds = records.stream().map(SysUser::getId).collect(Collectors.toList()); + Map useDepNames = this.getDepNamesByUserIds(userIds); + for (SysUser sysUser : userGroupList.getRecords()) { + //设置部门 + sysUser.setOrgCodeTxt(useDepNames.get(sysUser.getId())); + //设置用户职位id + this.userPositionId(sysUser); + } + } + return userGroupList; + } + /** * 是否有交集 */ diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java index 57a2a527f..7a4295a31 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppDingtalkServiceImpl.java @@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.jeecg.dingtalk.api.base.JdtBaseAPI; import com.jeecg.dingtalk.api.core.response.Response; @@ -40,6 +41,7 @@ import org.jeecg.modules.system.mapper.*; import org.jeecg.modules.system.model.SysDepartTreeModel; import org.jeecg.modules.system.model.ThirdLoginModel; import org.jeecg.modules.system.service.*; +import org.jeecg.modules.system.vo.SysPositionVO; import org.jeecg.modules.system.vo.thirdapp.JdtDepartmentTreeVo; import org.jeecg.modules.system.vo.thirdapp.SyncInfoVo; import org.springframework.beans.BeanUtils; @@ -346,6 +348,41 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService { // 获取本地所有用户 sysUsers = userMapper.selectList(Wrappers.emptyWrapper()); } + if (CollectionUtils.isEmpty(sysUsers)) { + return syncInfo; + } + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + List userIds = sysUsers.stream().map(SysUser::getId).collect(Collectors.toList()); + // ① 批量预加载 sys_third_account → Map + Map thirdAccountMap = sysThirdAccountService + .listBySysUserIds(userIds, THIRD_TYPE) + .stream() + .collect(Collectors.toMap(SysThirdAccount::getSysUserId, a -> a, (a, b) -> a)); + // ② 批量预加载用户-部门关系 → Map> + LambdaQueryWrapper udQw = new LambdaQueryWrapper<>(); + udQw.in(SysUserDepart::getUserId, userIds); + Map> userDepartIdsMap = sysUserDepartService.list(udQw) + .stream() + .collect(Collectors.groupingBy( + SysUserDepart::getUserId, + Collectors.mapping(SysUserDepart::getDepId, Collectors.toList()) + )); + // ③ 批量预加载所有涉及的部门 → Map + Set allDepartIds = userDepartIdsMap.values().stream() + .flatMap(Collection::stream).collect(Collectors.toSet()); + Map departMap = Collections.emptyMap(); + if (!allDepartIds.isEmpty()) { + departMap = sysDepartService.listByIds(allDepartIds) + .stream() + .collect(Collectors.toMap(SysDepart::getId, d -> d, (a, b) -> a)); + } + // ④ 批量预加载职位 → Map> + Map> positionMap = sysPositionService + .getPositionListByUserIds(userIds) + .stream() + .collect(Collectors.groupingBy(SysPositionVO::getUserId)); + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + // 查询钉钉所有的部门,用于同步用户和部门的关系 List allDepartment = JdtDepartmentAPI.listAll(accessToken); @@ -361,7 +398,9 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService { * 1. 查询 sys_third_account(第三方账号表)是否有数据,如果有代表已同步 * 2. 本地表里没有,就先用手机号判断,不通过再用username(用户账号)判断。 */ - SysThirdAccount sysThirdAccount = sysThirdAccountService.getOneBySysUserId(sysUser.getId(), THIRD_TYPE); + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + SysThirdAccount sysThirdAccount = thirdAccountMap.get(sysUser.getId()); + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- if (sysThirdAccount != null && oConvertUtils.isNotEmpty(sysThirdAccount.getThirdUserId())) { // sys_third_account 表匹配成功,通过第三方userId查询出第三方userInfo dtUserInfo = JdtUserAPI.getUserById(sysThirdAccount.getThirdUserId(), accessToken); @@ -384,12 +423,16 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService { if (dtUserInfo != null && dtUserInfo.isSuccess() && dtUserInfo.getResult() != null) { User dtUser = dtUserInfo.getResult(); dtUserId = dtUser.getUserid(); - User updateQwUser = this.sysUserToDtUser(sysUser, dtUser, allDepartment); + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + User updateQwUser = this.sysUserToDtUser(sysUser, dtUser, allDepartment, userDepartIdsMap, departMap, positionMap); + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- Response updateRes = JdtUserAPI.update(updateQwUser, accessToken); // 收集成功/失败信息 apiSuccess = this.syncUserCollectErrInfo(updateRes, sysUser, syncInfo); } else { - User newQwUser = this.sysUserToDtUser(sysUser, allDepartment); + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + User newQwUser = this.sysUserToDtUser(sysUser, allDepartment, userDepartIdsMap, departMap, positionMap); + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- Response createRes = JdtUserAPI.create(newQwUser, accessToken); dtUserId = createRes.getResult(); // 收集成功/失败信息 @@ -611,6 +654,56 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService { return user; } + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + /** + * 【同步用户】将SysUser转为【钉钉】的User对象(创建新用户,使用批量预加载Map,消除N+1查询) + */ + private User sysUserToDtUser(SysUser sysUser, List allDepartment, + Map> userDepartIdsMap, Map departMap, + Map> positionMap) { + User user = new User(); + user.setUserid(sysUser.getUsername()); + return this.sysUserToDtUser(sysUser, user, allDepartment, userDepartIdsMap, departMap, positionMap); + } + + /** + * 【同步用户】将SysUser转为【钉钉】的User对象(更新旧用户,使用批量预加载Map,消除N+1查询) + */ + private User sysUserToDtUser(SysUser sysUser, User user, List allDepartment, + Map> userDepartIdsMap, Map departMap, + Map> positionMap) { + user.setName(sysUser.getRealname()); + user.setMobile(sysUser.getPhone()); + user.setTelephone(sysUser.getTelephone()); + user.setJob_number(sysUser.getWorkNo()); + // 职务翻译(使用预加载Map替代单次查询) + List positionList = positionMap.getOrDefault(sysUser.getId(), Collections.emptyList()); + if (!positionList.isEmpty()) { + String positionName = positionList.stream().map(SysPositionVO::getName).collect(Collectors.joining(SymbolConstant.COMMA)); + user.setTitle(positionName); + } + user.setEmail(sysUser.getEmail()); + // 查询并同步用户部门关系(使用预加载Map替代单次查询) + List departList = this.getUserDepart(sysUser, userDepartIdsMap, departMap); + if (departList != null) { + List departmentIdList = new ArrayList<>(); + for (SysDepart sysDepart : departList) { + Department department = this.getDepartmentByDepartId(sysDepart.getId(), allDepartment); + if (department != null) { + departmentIdList.add(department.getDept_id()); + } + } + user.setDept_id_list(departmentIdList.toArray(new Integer[]{})); + user.setDept_order_list(null); + } + if (oConvertUtils.isEmpty(user.getDept_id_list())) { + user.setDept_id_list(1); + user.setDept_order_list(null); + } + return user; + } + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + /** * 【同步用户】将【钉钉】的User对象转为SysUser(创建新用户) @@ -694,6 +787,24 @@ public class ThirdAppDingtalkServiceImpl implements IThirdAppService { return departList.size() == 0 ? null : departList; } + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + /** + * 查询用户和部门的关系(使用批量预加载Map,消除N+1查询) + */ + private List getUserDepart(SysUser sysUser, Map> userDepartIdsMap, + Map departMap) { + List departIds = userDepartIdsMap.get(sysUser.getId()); + if (departIds == null || departIds.isEmpty()) { + return null; + } + List departList = departIds.stream() + .map(departMap::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + return departList.isEmpty() ? null : departList; + } + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + /** * 根据sysDepartId查询钉钉的部门 */ diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppWechatEnterpriseServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppWechatEnterpriseServiceImpl.java index b018185dd..a1f2f6c9e 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppWechatEnterpriseServiceImpl.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/service/impl/ThirdAppWechatEnterpriseServiceImpl.java @@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.CollectionUtils; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.jeecg.qywx.api.base.JwAccessTokenAPI; import com.jeecg.qywx.api.core.common.AccessToken; @@ -43,6 +44,7 @@ import org.jeecg.modules.system.entity.*; import org.jeecg.modules.system.mapper.*; import org.jeecg.modules.system.model.SysDepartTreeModel; import org.jeecg.modules.system.service.*; +import org.jeecg.modules.system.vo.SysPositionVO; import org.jeecg.modules.system.vo.thirdapp.JwDepartmentTreeVo; import org.jeecg.modules.system.vo.thirdapp.JwSysUserDepartVo; import org.jeecg.modules.system.vo.thirdapp.JwUserDepartVo; @@ -353,6 +355,40 @@ public class ThirdAppWechatEnterpriseServiceImpl implements IThirdAppService { // 获取本地所有用户 sysUsers = userMapper.selectList(Wrappers.emptyWrapper()); } + if (CollectionUtils.isEmpty(sysUsers)) { + return syncInfo; + } + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + List userIds = sysUsers.stream().map(SysUser::getId).collect(Collectors.toList()); + // ① 批量预加载 sys_third_account → Map + Map thirdAccountMap = sysThirdAccountService + .listBySysUserIds(userIds, THIRD_TYPE) + .stream() + .collect(Collectors.toMap(SysThirdAccount::getSysUserId, a -> a, (a, b) -> a)); + // ② 批量预加载用户-部门关系 → Map> + LambdaQueryWrapper udQw = new LambdaQueryWrapper<>(); + udQw.in(SysUserDepart::getUserId, userIds); + Map> userDepartIdsMap = sysUserDepartService.list(udQw) + .stream() + .collect(Collectors.groupingBy( + SysUserDepart::getUserId, + Collectors.mapping(SysUserDepart::getDepId, Collectors.toList()) + )); + // ③ 批量预加载所有涉及的部门 → Map + Set allDepartIds = userDepartIdsMap.values().stream() + .flatMap(Collection::stream).collect(Collectors.toSet()); + Map departMap = Collections.emptyMap(); + if (!allDepartIds.isEmpty()) { + departMap = sysDepartService.listByIds(allDepartIds) + .stream() + .collect(Collectors.toMap(SysDepart::getId, d -> d, (a, b) -> a)); + } + // ④ 批量预加载职位 → Map> + Map> positionMap = sysPositionService + .getPositionListByUserIds(userIds) + .stream() + .collect(Collectors.groupingBy(SysPositionVO::getUserId)); + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- // 循环判断新用户和需要更新的用户 for1: @@ -367,7 +403,9 @@ public class ThirdAppWechatEnterpriseServiceImpl implements IThirdAppService { * 2. 本地表里没有,就先用手机号判断,不通过再用username判断。 */ User qwUser; - SysThirdAccount sysThirdAccount = sysThirdAccountService.getOneBySysUserId(sysUser.getId(), THIRD_TYPE); + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + SysThirdAccount sysThirdAccount = thirdAccountMap.get(sysUser.getId()); + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- for (User qwUserTemp : qwUsers) { if (sysThirdAccount == null || oConvertUtils.isEmpty(sysThirdAccount.getThirdUserId()) || !sysThirdAccount.getThirdUserId().equals(qwUserTemp.getUserid())) { // sys_third_account 表匹配失败,尝试用手机号匹配 @@ -383,7 +421,9 @@ public class ThirdAppWechatEnterpriseServiceImpl implements IThirdAppService { // } } // 循环到此说明用户匹配成功,进行更新操作 - qwUser = this.sysUserToQwUser(sysUser, qwUserTemp); + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + qwUser = this.sysUserToQwUser(sysUser, qwUserTemp, userDepartIdsMap, departMap, positionMap); + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- int errCode = JwUserAPI.updateUser(qwUser, accessToken); // 收集错误信息 this.syncUserCollectErrInfo(errCode, sysUser, syncInfo); @@ -392,7 +432,9 @@ public class ThirdAppWechatEnterpriseServiceImpl implements IThirdAppService { continue for1; } // 循环到此说明是新用户,直接调接口创建 - qwUser = this.sysUserToQwUser(sysUser); + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + qwUser = this.sysUserToQwUser(sysUser, userDepartIdsMap, departMap, positionMap); + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- int errCode = JwUserAPI.createUser(qwUser, accessToken); // 收集错误信息 boolean apiSuccess = this.syncUserCollectErrInfo(errCode, sysUser, syncInfo); @@ -554,6 +596,79 @@ public class ThirdAppWechatEnterpriseServiceImpl implements IThirdAppService { return this.sysUserToQwUser(sysUser, user); } + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + /** + * 【同步用户】将SysUser转为企业微信的User对象(创建新用户,使用批量预加载Map) + */ + private User sysUserToQwUser(SysUser sysUser, Map> userDepartIdsMap, + Map departMap, Map> positionMap) { + User user = new User(); + user.setUserid(sysUser.getUsername()); + return this.sysUserToQwUser(sysUser, user, userDepartIdsMap, departMap, positionMap); + } + + /** + * 【同步用户】将SysUser转为企业微信的User对象(更新旧用户,使用批量预加载Map) + */ + private User sysUserToQwUser(SysUser sysUser, User user, Map> userDepartIdsMap, + Map departMap, Map> positionMap) { + user.setName(sysUser.getRealname()); + user.setMobile(sysUser.getPhone()); + // 查询并同步用户部门关系(使用预加载Map替代单次查询) + List departList = this.getUserDepart(sysUser, userDepartIdsMap, departMap); + if (departList != null) { + List departmentIdList = new ArrayList<>(); + List isLeaderInDept = new ArrayList<>(); + List manageDepartIdList = new ArrayList<>(); + if (oConvertUtils.isNotEmpty(sysUser.getDepartIds())) { + manageDepartIdList = Arrays.asList(sysUser.getDepartIds().split(",")); + } + for (SysDepart sysDepart : departList) { + if (oConvertUtils.isNotEmpty(sysDepart.getQywxIdentifier())) { + try { + departmentIdList.add(Integer.parseInt(sysDepart.getQywxIdentifier())); + } catch (NumberFormatException ignored) { + continue; + } + if (CommonConstant.USER_IDENTITY_2.equals(sysUser.getUserIdentity())) { + isLeaderInDept.add(manageDepartIdList.contains(sysDepart.getId()) ? 1 : 0); + } else { + isLeaderInDept.add(0); + } + } + } + user.setDepartment(departmentIdList.toArray(new Integer[]{})); + user.setIs_leader_in_dept(isLeaderInDept.toArray(new Integer[]{})); + } + if (user.getDepartment() == null || user.getDepartment().length == 0) { + user.setDepartment(new Integer[]{1}); + user.setIs_leader_in_dept(new Integer[]{0}); + } + // 职务翻译(使用预加载Map替代单次查询) + List positionList = positionMap.getOrDefault(sysUser.getId(), Collections.emptyList()); + if (!positionList.isEmpty()) { + String positionName = positionList.stream().map(SysPositionVO::getName).collect(Collectors.joining(SymbolConstant.COMMA)); + user.setPosition(positionName); + } + if (sysUser.getSex() != null) { + user.setGender(sysUser.getSex().toString()); + } + user.setEmail(sysUser.getEmail()); + if (sysUser.getStatus() != null) { + if (CommonConstant.USER_UNFREEZE.equals(sysUser.getStatus()) || CommonConstant.USER_FREEZE.equals(sysUser.getStatus())) { + user.setEnable(sysUser.getStatus() == 1 ? 1 : 0); + } else { + user.setEnable(1); + } + } + user.setTelephone(sysUser.getTelephone()); + if (CommonConstant.DEL_FLAG_1.equals(sysUser.getDelFlag())) { + user.setEnable(0); + } + return user; + } + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + /** * 【同步用户】将SysUser转为企业微信的User对象(更新旧用户) */ @@ -648,6 +763,24 @@ public class ThirdAppWechatEnterpriseServiceImpl implements IThirdAppService { return departList.size() == 0 ? null : departList; } + //update-begin---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + /** + * 查询用户和部门的关系(使用批量预加载Map,消除N+1查询) + */ + private List getUserDepart(SysUser sysUser, Map> userDepartIdsMap, + Map departMap) { + List departIds = userDepartIdsMap.get(sysUser.getId()); + if (departIds == null || departIds.isEmpty()) { + return null; + } + List departList = departIds.stream() + .map(departMap::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + return departList.isEmpty() ? null : departList; + } + //update-end---author:sjlei ---date:2026-04-17 for:【#9496】全量同步N+1查询性能优化----------- + /** * 【同步用户】将企业微信的User对象转为SysUser(创建新用户) */ diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/util/HttpFileToMultipartFileUtil.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/util/HttpFileToMultipartFileUtil.java index 291680250..a477c33a3 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/util/HttpFileToMultipartFileUtil.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/util/HttpFileToMultipartFileUtil.java @@ -4,6 +4,7 @@ import org.apache.commons.fileupload.FileItem; import org.apache.commons.fileupload.FileItemFactory; import org.apache.commons.fileupload.disk.DiskFileItemFactory; import org.jeecg.common.util.MyCommonsMultipartFile; +import org.jeecg.common.util.filter.SsrfFileTypeFilter; import org.springframework.web.multipart.MultipartFile; import java.io.*; @@ -26,6 +27,7 @@ public class HttpFileToMultipartFileUtil { * @throws Exception */ public static MultipartFile httpFileToMultipartFile(String fileUrl, String filename) throws Exception { + SsrfFileTypeFilter.checkSsrfHttpUrl(fileUrl); byte[] bytes = downloadImageData(fileUrl); return convertByteToMultipartFile(bytes, filename); } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/util/XssUtils.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/util/XssUtils.java index 1fbb3804f..f453fd4b7 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/util/XssUtils.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/util/XssUtils.java @@ -32,6 +32,43 @@ public class XssUtils { Pattern.compile("onload(.*?)=", Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.DOTALL), }; + //update-begin---author:liusq ---date:2025-04-13 for:【issues/9521】富文本msgContent字段存储型XSS过滤----------- + /** + * 针对富文本HTML内容的XSS过滤:移除危险脚本和事件处理器,保留合法HTML标签和样式。 + * 与 scriptXss() 的区别:本方法不对HTML实体进行全局转义,适用于富文本内容(如公告正文)。 + */ + private static final Pattern[] RICH_TEXT_PATTERNS = new Pattern[]{ + // ", Pattern.CASE_INSENSITIVE), + // 自闭合