diff --git a/pom.xml b/pom.xml index a35fe1c..285aa6e 100644 --- a/pom.xml +++ b/pom.xml @@ -33,6 +33,7 @@ + ${project.artifactId} diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java index 5bf5d6c..ddf18d3 100644 --- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java +++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/json/databind/TimestampLocalDateTimeDeserializer.java @@ -1,5 +1,8 @@ package cn.iocoder.yudao.framework.common.util.json.databind; +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.hutool.core.util.StrUtil; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationContext; import com.fasterxml.jackson.databind.JsonDeserializer; @@ -7,10 +10,11 @@ import com.fasterxml.jackson.databind.JsonDeserializer; import java.io.IOException; import java.time.Instant; import java.time.LocalDateTime; -import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; /** - * 基于时间戳的 LocalDateTime 反序列化器 + * LocalDateTime 反序列化器,支持毫秒时间戳、常见日期时间字符串和 ISO-8601 日期。 * * @author 老五 */ @@ -18,10 +22,26 @@ public class TimestampLocalDateTimeDeserializer extends JsonDeserializer parse("\"not-a-date\"")); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/translate/config/YudaoTranslateAutoConfiguration.java b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/translate/config/YudaoTranslateAutoConfiguration.java index 9555ac9..b606bf5 100644 --- a/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/translate/config/YudaoTranslateAutoConfiguration.java +++ b/yudao-framework/yudao-spring-boot-starter-mybatis/src/main/java/cn/iocoder/yudao/framework/translate/config/YudaoTranslateAutoConfiguration.java @@ -1,12 +1,18 @@ package cn.iocoder.yudao.framework.translate.config; import cn.iocoder.yudao.framework.translate.core.TranslateUtils; +import org.dromara.trans.config.EasyTransMybatisPlusConfig; +import org.dromara.trans.config.TransServiceConfig; import org.dromara.trans.service.impl.TransService; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.context.annotation.Bean; -@AutoConfiguration +// 解决 Easy-Trans 自动配置顺序问题,详见 https://t.zsxq.com/nX3vi +@AutoConfiguration(after = { + EasyTransMybatisPlusConfig.class, + TransServiceConfig.class +}) public class YudaoTranslateAutoConfiguration { @Bean diff --git a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java index 3939f6c..18f43f6 100644 --- a/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java +++ b/yudao-module-ai/src/main/java/cn/iocoder/yudao/module/ai/framework/ai/config/AiAutoConfiguration.java @@ -21,6 +21,9 @@ import cn.iocoder.yudao.module.ai.framework.ai.core.model.yiyan.YiYanChatModel; import cn.iocoder.yudao.module.ai.framework.ai.core.model.zhipu.ZhiPuChatModel; import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.AiWebSearchClient; import cn.iocoder.yudao.module.ai.framework.ai.core.webserch.bocha.AiBoChaWebSearchClient; +import cn.iocoder.yudao.module.ai.tool.function.DirectoryListToolFunction; +import cn.iocoder.yudao.module.ai.tool.function.UserProfileQueryToolFunction; +import cn.iocoder.yudao.module.ai.tool.function.WeatherQueryToolFunction; import cn.iocoder.yudao.module.ai.tool.method.PersonService; import io.micrometer.observation.ObservationRegistry; import lombok.extern.slf4j.Slf4j; @@ -38,6 +41,7 @@ import org.springframework.ai.support.ToolCallbacks; import org.springframework.ai.tokenizer.JTokkitTokenCountEstimator; import org.springframework.ai.tokenizer.TokenCountEstimator; import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.function.FunctionToolCallback; import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusServiceClientProperties; import org.springframework.ai.vectorstore.milvus.autoconfigure.MilvusVectorStoreProperties; import org.springframework.ai.vectorstore.qdrant.autoconfigure.QdrantVectorStoreProperties; @@ -424,7 +428,7 @@ public class AiAutoConfiguration { return new AiBoChaWebSearchClient(yudaoAiProperties.getWebSearch().getApiKey()); } - // ========== MCP 相关 ========== + // ========== MCP & 工具相关 ========== /** * 参考自 MCP Server Boot Starter @@ -434,4 +438,25 @@ public class AiAutoConfiguration { return List.of(ToolCallbacks.from(personService)); } + @Bean + public ToolCallback directoryListToolCallback(DirectoryListToolFunction function) { + return FunctionToolCallback.builder("directory_list", function) + .description("列出指定目录的文件列表") + .inputType(DirectoryListToolFunction.Request.class).build(); + } + + @Bean + public ToolCallback weatherQueryToolCallback(WeatherQueryToolFunction function) { + return FunctionToolCallback.builder("weather_query", function) + .description("查询指定城市的天气信息") + .inputType(WeatherQueryToolFunction.Request.class).build(); + } + + @Bean + public ToolCallback userProfileQueryToolCallback(UserProfileQueryToolFunction function) { + return FunctionToolCallback.builder("user_profile_query", function) + .description("用户信息查询") + .inputType(UserProfileQueryToolFunction.Request.class).build(); + } + } diff --git a/yudao-module-ai1-springai/pom.xml b/yudao-module-ai1-springai/pom.xml new file mode 100644 index 0000000..f29374d --- /dev/null +++ b/yudao-module-ai1-springai/pom.xml @@ -0,0 +1,128 @@ + + + + cn.iocoder.boot + yudao + ${revision} + + 4.0.0 + jar + yudao-module-ai1-springai + + ${project.artifactId} + + ai1 模块,参考 XXL-AI 优化实现的 AI Agent 能力,基于 Spring AI 接入芋道: + 1、Agent 编排:模型、系统指令、知识库、MCP、SKILL 组合成 Agent,资源支持多选绑定 + 2、多供应商:统一接入 OpenAI 兼容协议的对话与嵌入模型,支持连通测试和远程模型导入 + 3、MCP、SKILL、RAG:远程与本地 MCP、SKILL.md 技能物化执行、Milvus 知识库检索并注入对话 + 4、流式对话:SSE 输出思考过程与回复,Redis Stream 支持集群部署和断线续传 + + + + 2.0.1 + 0.13.0 + 3.0.10 + + + + + cn.iocoder.boot + yudao-module-system + ${revision} + + + cn.iocoder.boot + yudao-module-infra + ${revision} + + + + + cn.iocoder.boot + yudao-spring-boot-starter-biz-tenant + + + + + cn.iocoder.boot + yudao-spring-boot-starter-security + + + + + cn.iocoder.boot + yudao-spring-boot-starter-mybatis + + + cn.iocoder.boot + yudao-spring-boot-starter-redis + + + + + cn.iocoder.boot + yudao-spring-boot-starter-test + + + + + org.springframework.ai + spring-ai-openai + + + io.swagger.core.v3 + swagger-annotations-jakarta + + + + + + org.springframework.ai + spring-ai-client-chat + + + + org.springframework.ai + spring-ai-advisors-vector-store + + + + org.springframework.ai + spring-ai-milvus-store + + + + org.slf4j + slf4j-reload4j + + + + + io.milvus + milvus-sdk-java + ${milvus-sdk.version} + + + + org.slf4j + slf4j-reload4j + + + + + + org.springaicommunity + spring-ai-agent-utils + ${spring-ai-agent-utils.version} + + + + io.modelcontextprotocol.sdk + mcp-json-jackson2 + ${mcp-sdk.version} + + + + diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/Ai1AgentController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/Ai1AgentController.java new file mode 100644 index 0000000..c38f70e --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/Ai1AgentController.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentUpdateStatusReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ProviderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - AI1 Agent") +@RestController +@RequestMapping("/ai1/agent") +@Validated +public class Ai1AgentController { + + @Resource + private Ai1AgentService agentService; + @Resource + private Ai1ProviderService providerService; + @Resource + private Ai1ModelService modelService; + + @PostMapping("/create") + @Operation(summary = "创建 Agent") + @PreAuthorize("@ss.hasPermission('ai1:agent:create')") + public CommonResult createAgent(@Valid @RequestBody Ai1AgentSaveReqVO createReqVO) { + return success(agentService.createAgent(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新 Agent") + @PreAuthorize("@ss.hasPermission('ai1:agent:update')") + public CommonResult updateAgent(@Valid @RequestBody Ai1AgentSaveReqVO updateReqVO) { + agentService.updateAgent(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 Agent", description = "级联删除会话与消息") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:agent:delete')") + public CommonResult deleteAgent(@RequestParam("id") Long id) { + agentService.deleteAgent(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除 Agent", description = "级联删除会话与消息") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:agent:delete')") + public CommonResult deleteAgentList(@RequestParam("ids") List ids) { + agentService.deleteAgentListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得 Agent") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:agent:query')") + public CommonResult getAgent(@RequestParam("id") Long id) { + Ai1AgentDO agent = agentService.getAgent(id); + return success(buildAgentRespVO(agent)); + } + + @GetMapping("/page") + @Operation(summary = "获得 Agent 分页") + @PreAuthorize("@ss.hasPermission('ai1:agent:query')") + public CommonResult> getAgentPage(@Valid Ai1AgentPageReqVO pageReqVO) { + PageResult pageResult = agentService.getAgentPage(pageReqVO); + return success(new PageResult<>(buildAgentRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得已开启的 Agent 精简列表", description = "用于会话页选择 Agent,仅返回已开启的 Agent") + public CommonResult> getAgentSimpleList() { + List list = agentService.getAgentListByStatus(CommonStatusEnum.ENABLE.getStatus()); + Map modelMap = modelService.getModelMap(convertSet(list, Ai1AgentDO::getModelId)); + return success(convertList(list, agent -> { + Ai1AgentRespVO respVO = new Ai1AgentRespVO().setId(agent.getId()).setName(agent.getName()) + .setIntroduction(agent.getIntroduction()).setModelId(agent.getModelId()); + MapUtils.findAndThen(modelMap, agent.getModelId(), model -> respVO.setModelName(model.getName())); + return respVO; + })); + } + + @PutMapping("/update-status") + @Operation(summary = "修改 Agent 状态") + @PreAuthorize("@ss.hasPermission('ai1:agent:update')") + public CommonResult updateAgentStatus(@Valid @RequestBody Ai1AgentUpdateStatusReqVO reqVO) { + agentService.updateAgentStatus(reqVO.getId(), reqVO.getStatus()); + return success(true); + } + + // ==================== 拼接 VO ==================== + + private Ai1AgentRespVO buildAgentRespVO(Ai1AgentDO agent) { + if (agent == null) { + return null; + } + return CollUtil.getFirst(buildAgentRespVOList(Collections.singletonList(agent))); + } + + private List buildAgentRespVOList(List list) { + Map providerMap = providerService.getProviderMap(convertSet(list, Ai1AgentDO::getProviderId)); + Map modelMap = modelService.getModelMap(convertSet(list, Ai1AgentDO::getModelId)); + return BeanUtils.toBean(list, Ai1AgentRespVO.class, respVO -> { + MapUtils.findAndThen(providerMap, respVO.getProviderId(), provider -> respVO.setProviderName(provider.getName())); + MapUtils.findAndThen(modelMap, respVO.getModelId(), model -> respVO.setModelName(model.getName())); + }); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentPageReqVO.java new file mode 100644 index 0000000..1be4611 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentPageReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent.vo; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 Agent 分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1AgentPageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "客服助手") + private String name; + + @Schema(description = "状态", example = "0") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentRespVO.java new file mode 100644 index 0000000..16a95a6 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentRespVO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "管理后台 - AI1 Agent Response VO") +@Data +public class Ai1AgentRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客服助手") + private String name; + + @Schema(description = "介绍", example = "解答产品使用问题") + private String introduction; + + @Schema(description = "模型所属 Provider 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long providerId; + + @Schema(description = "模型所属 Provider 名称", example = "DeepSeek") + private String providerName; + + @Schema(description = "对话模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long modelId; + + @Schema(description = "对话模型名称", example = "DeepSeek Chat") + private String modelName; + + @Schema(description = "系统指令", example = "你是一名专业的客服") + private String systemPrompt; + + @Schema(description = "知识库编号集合", example = "[1, 2]") + private List knowledgeBaseIds; + + @Schema(description = "MCP 编号集合", example = "[1]") + private List mcpIds; + + @Schema(description = "SKILL 编号集合", example = "[1]") + private List skillIds; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentSaveReqVO.java new file mode 100644 index 0000000..8e724c9 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentSaveReqVO.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - AI1 Agent 新增/修改 Request VO") +@Data +public class Ai1AgentSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客服助手") + @NotEmpty(message = "名称不能为空") + @Size(max = 100, message = "名称长度不能超过 100 个字符") + private String name; + + @Schema(description = "介绍", example = "解答产品使用问题") + @Size(max = 500, message = "介绍长度不能超过 500 个字符") + private String introduction; + + @Schema(description = "模型所属 Provider 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "Provider 不能为空") + private Long providerId; + + @Schema(description = "对话模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "对话模型不能为空") + private Long modelId; + + @Schema(description = "系统指令", example = "你是一名专业的客服") + private String systemPrompt; + + @Schema(description = "知识库编号集合", example = "[1, 2]") + private List knowledgeBaseIds; + + @Schema(description = "MCP 编号集合", example = "[1]") + private List mcpIds; + + @Schema(description = "SKILL 编号集合", example = "[1]") + private List skillIds; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentUpdateStatusReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentUpdateStatusReqVO.java new file mode 100644 index 0000000..534a777 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/agent/vo/Ai1AgentUpdateStatusReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.agent.vo; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 Agent 修改状态 Request VO") +@Data +public class Ai1AgentUpdateStatusReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(value = CommonStatusEnum.class, message = "修改状态必须是 {value}") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/Ai1HomeController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/Ai1HomeController.java new file mode 100644 index 0000000..ee8ab2d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/Ai1HomeController.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.home; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.home.Ai1HomeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - AI1 首页") +@RestController +@RequestMapping("/ai1/home") +@Validated +public class Ai1HomeController { + + @Resource + private Ai1HomeService homeService; + @Resource + private Ai1AgentService agentService; + + @GetMapping("/summary") + @Operation(summary = "获得总量统计") + @PreAuthorize("@ss.hasPermission('ai1:home:query')") + public CommonResult getSummary() { + return success(homeService.getSummary()); + } + + @GetMapping("/message-summary-by-date") + @Operation(summary = "获得按日消息统计", description = "近 days 个自然日(含今天)每日消息数量,没有消息的日期补 0") + @Parameter(name = "days", description = "天数,1 ~ 30,默认 30", example = "30") + @PreAuthorize("@ss.hasPermission('ai1:home:query')") + public CommonResult> getMessageSummaryByDate( + @RequestParam(value = "days", defaultValue = "30") @Min(value = 1, message = "天数不能小于 1") + @Max(value = 30, message = "天数不能大于 30") Integer days) { + return success(homeService.getMessageSummaryByDate(days)); + } + + @GetMapping("/message-summary-by-agent") + @Operation(summary = "获得按 Agent 消息统计", description = "近 days 个自然日(含今天)各 Agent 的消息数量") + @Parameter(name = "days", description = "天数,1 ~ 30,默认 30", example = "30") + @PreAuthorize("@ss.hasPermission('ai1:home:query')") + public CommonResult> getMessageSummaryByAgent( + @RequestParam(value = "days", defaultValue = "30") @Min(value = 1, message = "天数不能小于 1") + @Max(value = 30, message = "天数不能大于 30") Integer days) { + return success(buildMessageSummaryByAgentRespVOList(homeService.getMessageSummaryByAgent(days))); + } + + // ==================== 拼接 VO ==================== + + private List buildMessageSummaryByAgentRespVOList(List list) { + Map agentMap = agentService.getAgentMap(convertSet(list, Ai1HomeMessageSummaryByAgentRespVO::getAgentId)); + list.forEach(item -> + MapUtils.findAndThen(agentMap, item.getAgentId(), agent -> item.setAgentName(agent.getName()))); + return list; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByAgentRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByAgentRespVO.java new file mode 100644 index 0000000..25beb1d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByAgentRespVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.home.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 首页按 Agent 消息统计 Response VO") +@Data +public class Ai1HomeMessageSummaryByAgentRespVO { + + @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long agentId; + + @Schema(description = "Agent 名称,Agent 已删除时为空", example = "Hi Agent") + private String agentName; + + @Schema(description = "消息数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "36") + private Long count; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByDateRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByDateRespVO.java new file mode 100644 index 0000000..13679e8 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeMessageSummaryByDateRespVO.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.home.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 首页按日消息统计 Response VO") +@Data +public class Ai1HomeMessageSummaryByDateRespVO { + + @Schema(description = "日期,格式 yyyy-MM-dd", requiredMode = Schema.RequiredMode.REQUIRED, example = "2026-09-29") + private String date; + + @Schema(description = "当日消息数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "12") + private Long count; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeSummaryRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeSummaryRespVO.java new file mode 100644 index 0000000..3f122cc --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/home/vo/Ai1HomeSummaryRespVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.home.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 首页总量统计 Response VO") +@Data +public class Ai1HomeSummaryRespVO { + + @Schema(description = "Agent 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + private Long agentCount; + + @Schema(description = "SKILL 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long skillCount; + + @Schema(description = "MCP 数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "4") + private Long mcpCount; + + @Schema(description = "模型数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Long modelCount; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeBaseController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeBaseController.java new file mode 100644 index 0000000..5a727f3 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeBaseController.java @@ -0,0 +1,155 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBasePageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeSearchRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeDocumentService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ProviderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; + +@Tag(name = "管理后台 - AI1 知识库") +@RestController +@RequestMapping("/ai1/knowledge") +@Validated +public class Ai1KnowledgeBaseController { + + @Resource + private Ai1KnowledgeBaseService knowledgeBaseService; + @Resource + private Ai1KnowledgeDocumentService knowledgeDocumentService; + @Resource + private Ai1ProviderService providerService; + @Resource + private Ai1ModelService modelService; + + @PostMapping("/create") + @Operation(summary = "创建知识库") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:create')") + public CommonResult createKnowledgeBase(@Valid @RequestBody Ai1KnowledgeBaseSaveReqVO createReqVO) { + return success(knowledgeBaseService.createKnowledgeBase(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新知识库") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:update')") + public CommonResult updateKnowledgeBase(@Valid @RequestBody Ai1KnowledgeBaseSaveReqVO updateReqVO) { + knowledgeBaseService.updateKnowledgeBase(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除知识库", description = "连带删除文档与向量集合") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:knowledge:delete')") + public CommonResult deleteKnowledgeBase(@RequestParam("id") Long id) { + knowledgeBaseService.deleteKnowledgeBase(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除知识库", description = "连带删除文档与向量集合") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:knowledge:delete')") + public CommonResult deleteKnowledgeBaseList(@RequestParam("ids") List ids) { + knowledgeBaseService.deleteKnowledgeBaseListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得知识库") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:query')") + public CommonResult getKnowledgeBase(@RequestParam("id") Long id) { + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseService.getKnowledgeBase(id); + return success(buildKnowledgeBaseRespVO(knowledgeBase)); + } + + @GetMapping("/page") + @Operation(summary = "获得知识库分页") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:query')") + public CommonResult> getKnowledgeBasePage(@Valid Ai1KnowledgeBasePageReqVO pageReqVO) { + PageResult pageResult = knowledgeBaseService.getKnowledgeBasePage(pageReqVO); + return success(new PageResult<>(buildKnowledgeBaseRespVOList(pageResult.getList()), pageResult.getTotal())); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得知识库精简列表", description = "只包含开启状态,用于 Agent 绑定知识库的下拉选择") + public CommonResult> getKnowledgeBaseSimpleList() { + List list = knowledgeBaseService.getKnowledgeBaseListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, knowledgeBase -> new Ai1KnowledgeBaseRespVO() + .setId(knowledgeBase.getId()).setName(knowledgeBase.getName()))); + } + + @GetMapping("/search") + @Operation(summary = "知识库检索测试") + @Parameter(name = "id", description = "知识库编号", required = true, example = "1024") + @Parameter(name = "query", description = "查询文本", required = true, example = "三体舰队") + @Parameter(name = "topK", description = "检索数量,为空时使用知识库配置", example = "5") + @PreAuthorize("@ss.hasPermission('ai1:knowledge:search')") + public CommonResult> searchKnowledgeBase( + @RequestParam("id") Long id, + @RequestParam("query") @NotEmpty(message = "检索内容不能为空") String query, + @RequestParam(value = "topK", required = false) Integer topK) { + List list = knowledgeBaseService.searchKnowledgeBase(id, query, topK); + return success(buildKnowledgeSearchRespVOList(list)); + } + + // ==================== 拼接 VO ==================== + + private Ai1KnowledgeBaseRespVO buildKnowledgeBaseRespVO(Ai1KnowledgeBaseDO knowledgeBase) { + if (knowledgeBase == null) { + return null; + } + return CollUtil.getFirst(buildKnowledgeBaseRespVOList(Collections.singletonList(knowledgeBase))); + } + + private List buildKnowledgeBaseRespVOList(List list) { + Map providerMap = providerService.getProviderMap( + convertSet(list, Ai1KnowledgeBaseDO::getEmbeddingProviderId)); + Map modelMap = modelService.getModelMap( + convertSet(list, Ai1KnowledgeBaseDO::getEmbeddingModelId)); + return BeanUtils.toBean(list, Ai1KnowledgeBaseRespVO.class, respVO -> { + MapUtils.findAndThen(providerMap, respVO.getEmbeddingProviderId(), + provider -> respVO.setEmbeddingProviderName(provider.getName())); + MapUtils.findAndThen(modelMap, respVO.getEmbeddingModelId(), + model -> respVO.setEmbeddingModelName(model.getName())); + }); + } + + private List buildKnowledgeSearchRespVOList(List list) { + Map documentMap = knowledgeDocumentService.getKnowledgeDocumentMap( + convertSet(list, Ai1KnowledgeSearchRespVO::getDocumentId)); + list.forEach(respVO -> MapUtils.findAndThen(documentMap, respVO.getDocumentId(), + document -> respVO.setDocumentName(document.getName()))); + return list; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeDocumentController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeDocumentController.java new file mode 100644 index 0000000..458594c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/Ai1KnowledgeDocumentController.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentVectorizeRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeDocumentService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - AI1 知识文档") +@RestController +@RequestMapping("/ai1/knowledge/document") +@Validated +public class Ai1KnowledgeDocumentController { + + @Resource + private Ai1KnowledgeDocumentService knowledgeDocumentService; + + @PostMapping("/create") + @Operation(summary = "创建文档(粘贴文本)") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:create')") + public CommonResult createKnowledgeDocument(@Valid @RequestBody Ai1KnowledgeDocumentSaveReqVO createReqVO) { + return success(knowledgeDocumentService.createKnowledgeDocument(createReqVO)); + } + + @PostMapping("/upload") + @Operation(summary = "上传文档", description = "仅支持 .txt / .md 文本文件") + @Parameter(name = "knowledgeBaseId", description = "知识库编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:create')") + public CommonResult uploadKnowledgeDocument(@RequestParam("knowledgeBaseId") Long knowledgeBaseId, + @RequestParam("file") MultipartFile file) { + return success(knowledgeDocumentService.uploadKnowledgeDocument(knowledgeBaseId, file)); + } + + @PutMapping("/update") + @Operation(summary = "更新文档", description = "内容变更后需要重新向量化") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:update')") + public CommonResult updateKnowledgeDocument(@Valid @RequestBody Ai1KnowledgeDocumentSaveReqVO updateReqVO) { + knowledgeDocumentService.updateKnowledgeDocument(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除文档") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:delete')") + public CommonResult deleteKnowledgeDocument(@RequestParam("id") Long id) { + knowledgeDocumentService.deleteKnowledgeDocument(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除文档") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:delete')") + public CommonResult deleteKnowledgeDocumentList(@RequestParam("ids") List ids) { + knowledgeDocumentService.deleteKnowledgeDocumentListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得文档", description = "包含完整内容") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:query')") + public CommonResult getKnowledgeDocument(@RequestParam("id") Long id) { + Ai1KnowledgeDocumentDO document = knowledgeDocumentService.getKnowledgeDocument(id); + return success(BeanUtils.toBean(document, Ai1KnowledgeDocumentRespVO.class, + respVO -> respVO.setContentLength(StrUtil.length(respVO.getContent())))); + } + + @GetMapping("/page") + @Operation(summary = "获得文档分页", description = "不返回文档内容") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:query')") + public CommonResult> getKnowledgeDocumentPage(@Valid Ai1KnowledgeDocumentPageReqVO pageReqVO) { + PageResult pageResult = knowledgeDocumentService.getKnowledgeDocumentPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, Ai1KnowledgeDocumentRespVO.class, + respVO -> respVO.setContentLength(StrUtil.length(respVO.getContent())).setContent(null))); + } + + @PostMapping("/vectorize") + @Operation(summary = "文档向量化", description = "返回分片数量") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:vectorize')") + public CommonResult vectorizeKnowledgeDocument(@RequestParam("id") Long id) { + return success(knowledgeDocumentService.vectorizeKnowledgeDocument(id)); + } + + @PostMapping("/vectorize-all") + @Operation(summary = "知识库批量向量化") + @Parameter(name = "knowledgeBaseId", description = "知识库编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:knowledge-document:vectorize')") + public CommonResult vectorizeKnowledgeDocumentAll( + @RequestParam("knowledgeBaseId") Long knowledgeBaseId) { + return success(knowledgeDocumentService.vectorizeKnowledgeDocumentListByKnowledgeBaseId(knowledgeBaseId)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBasePageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBasePageReqVO.java new file mode 100644 index 0000000..0eaa82c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBasePageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 知识库分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1KnowledgeBasePageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "产品手册") + private String name; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseRespVO.java new file mode 100644 index 0000000..26920c3 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseRespVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 知识库 Response VO") +@Data +public class Ai1KnowledgeBaseRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品手册") + private String name; + + @Schema(description = "描述", example = "产品使用说明") + private String description; + + @Schema(description = "向量化 Provider 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long embeddingProviderId; + + @Schema(description = "向量化 Provider 名称", example = "Ollama") + private String embeddingProviderName; + + @Schema(description = "向量化模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Long embeddingModelId; + + @Schema(description = "向量化模型名称", example = "bge-m3") + private String embeddingModelName; + + @Schema(description = "分片大小,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "500") + private Integer chunkSize; + + @Schema(description = "分片重叠,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + private Integer chunkOverlap; + + @Schema(description = "检索数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + private Integer topK; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseSaveReqVO.java new file mode 100644 index 0000000..f4aeb63 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeBaseSaveReqVO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 知识库新增/修改 Request VO") +@Data +public class Ai1KnowledgeBaseSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "产品手册") + @NotEmpty(message = "名称不能为空") + @Size(max = 100, message = "名称长度不能超过 100 个字符") + private String name; + + @Schema(description = "描述", example = "产品使用说明") + @Size(max = 500, message = "描述长度不能超过 500 个字符") + private String description; + + @Schema(description = "向量化 Provider 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "向量化 Provider 不能为空") + private Long embeddingProviderId; + + @Schema(description = "向量化模型编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + @NotNull(message = "向量化模型不能为空") + private Long embeddingModelId; + + @Schema(description = "分片大小,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "500") + @NotNull(message = "分片大小不能为空") + @Min(value = 100, message = "分片大小不能小于 100") + private Integer chunkSize; + + @Schema(description = "分片重叠,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "50") + @NotNull(message = "分片重叠不能为空") + @Min(value = 0, message = "分片重叠不能小于 0") + private Integer chunkOverlap; + + @Schema(description = "检索数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "5") + @NotNull(message = "检索数量不能为空") + @Min(value = 1, message = "检索数量必须大于 0") + private Integer topK; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @AssertTrue(message = "分片重叠必须小于分片大小") + @JsonIgnore + public boolean isChunkOverlapValid() { + return chunkSize == null || chunkOverlap == null || chunkOverlap < chunkSize; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeSearchRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeSearchRespVO.java new file mode 100644 index 0000000..4c1b330 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/base/Ai1KnowledgeSearchRespVO.java @@ -0,0 +1,25 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 知识库检索命中 Response VO") +@Data +public class Ai1KnowledgeSearchRespVO { + + @Schema(description = "分片文本", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队距离地球还有四光年") + private String text; + + @Schema(description = "文档编号", example = "1") + private Long documentId; + + @Schema(description = "文档名称", example = "三体.txt") + private String documentName; + + @Schema(description = "分片序号", example = "0") + private Integer chunkIndex; + + @Schema(description = "相似度得分", requiredMode = Schema.RequiredMode.REQUIRED, example = "0.86") + private Double score; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentPageReqVO.java new file mode 100644 index 0000000..5e723da --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentPageReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 知识文档分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1KnowledgeDocumentPageReqVO extends PageParam { + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "知识库编号不能为空") + private Long knowledgeBaseId; + + @Schema(description = "名称,模糊匹配", example = "三体") + private String name; + + @Schema(description = "向量化状态", example = "1") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentRespVO.java new file mode 100644 index 0000000..8eeac29 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentRespVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 知识文档 Response VO") +@Data +public class Ai1KnowledgeDocumentRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "知识库编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long knowledgeBaseId; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体.txt") + private String name; + + @Schema(description = "内容;分页列表不返回,编辑时通过获取接口加载", example = "三体舰队距离地球还有四光年") + private String content; + + @Schema(description = "内容长度,单位:字符", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Integer contentLength; + + @Schema(description = "分片数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer chunkCount; + + @Schema(description = "向量化状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentSaveReqVO.java new file mode 100644 index 0000000..9d9a73a --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentSaveReqVO.java @@ -0,0 +1,30 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 知识文档新增/修改 Request VO") +@Data +public class Ai1KnowledgeDocumentSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "知识库编号;修改时不允许变更", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "知识库编号不能为空") + private Long knowledgeBaseId; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体.txt") + @NotEmpty(message = "名称不能为空") + @Size(max = 255, message = "名称长度不能超过 255 个字符") + private String name; + + @Schema(description = "内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队距离地球还有四光年") + @NotEmpty(message = "内容不能为空") + @Size(max = 16_777_215, message = "内容长度不能超过 16777215 个字符") + private String content; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentVectorizeRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentVectorizeRespVO.java new file mode 100644 index 0000000..40515a4 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/knowledge/vo/document/Ai1KnowledgeDocumentVectorizeRespVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "管理后台 - AI1 知识库批量向量化 Response VO") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Ai1KnowledgeDocumentVectorizeRespVO { + + @Schema(description = "成功数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer successCount; + + @Schema(description = "失败数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer failureCount; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/Ai1McpController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/Ai1McpController.java new file mode 100644 index 0000000..b2261c3 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/Ai1McpController.java @@ -0,0 +1,102 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.service.mcp.Ai1McpService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI1 MCP") +@RestController +@RequestMapping("/ai1/mcp") +@Validated +public class Ai1McpController { + + @Resource + private Ai1McpService mcpService; + + @PostMapping("/create") + @Operation(summary = "创建 MCP") + @PreAuthorize("@ss.hasPermission('ai1:mcp:create')") + public CommonResult createMcp(@Valid @RequestBody Ai1McpSaveReqVO createReqVO) { + return success(mcpService.createMcp(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新 MCP") + @PreAuthorize("@ss.hasPermission('ai1:mcp:update')") + public CommonResult updateMcp(@Valid @RequestBody Ai1McpSaveReqVO updateReqVO) { + mcpService.updateMcp(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 MCP") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:mcp:delete')") + public CommonResult deleteMcp(@RequestParam("id") Long id) { + mcpService.deleteMcp(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除 MCP") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:mcp:delete')") + public CommonResult deleteMcpList(@RequestParam("ids") List ids) { + mcpService.deleteMcpListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得 MCP") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:mcp:query')") + public CommonResult getMcp(@RequestParam("id") Long id) { + Ai1McpDO mcp = mcpService.getMcp(id); + return success(BeanUtils.toBean(mcp, Ai1McpRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得 MCP 分页") + @PreAuthorize("@ss.hasPermission('ai1:mcp:query')") + public CommonResult> getMcpPage(@Valid Ai1McpPageReqVO pageReqVO) { + PageResult pageResult = mcpService.getMcpPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, Ai1McpRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得 MCP 精简列表", description = "只包含开启状态,用于 Agent 绑定 MCP 的下拉选择") + public CommonResult> getMcpSimpleList() { + List list = mcpService.getMcpListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, mcp -> new Ai1McpRespVO() + .setId(mcp.getId()).setName(mcp.getName()).setTransport(mcp.getTransport()))); + } + + @PostMapping("/test") + @Operation(summary = "MCP 连通测试", description = "initialize + tools/list") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:mcp:test')") + public CommonResult testMcp(@RequestParam("id") Long id) { + return success(mcpService.testMcpConnect(id)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpConnectRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpConnectRespVO.java new file mode 100644 index 0000000..a9729b4 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpConnectRespVO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - AI1 MCP 连通测试 Response VO") +@Data +public class Ai1McpConnectRespVO { + + @Schema(description = "是否连通", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean connectable; + + @Schema(description = "服务名称", example = "weather-server") + private String serverName; + + @Schema(description = "服务版本", example = "1.0.0") + private String serverVersion; + + @Schema(description = "服务说明", example = "提供城市天气查询") + private String instructions; + + @Schema(description = "可用工具数量", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer toolCount; + + @Schema(description = "测试耗时,单位:毫秒", requiredMode = Schema.RequiredMode.REQUIRED, example = "320") + private Long elapsedMs; + + @Schema(description = "测试过程描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "连接成功,发现 2 个工具") + private String message; + + @Schema(description = "可用工具明细") + private List tools; + + @Schema(description = "MCP 工具") + @Data + public static class Tool { + + @Schema(description = "工具名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "get_weather") + private String name; + + @Schema(description = "工具标题", example = "天气查询") + private String title; + + @Schema(description = "工具描述", example = "查询指定城市的天气") + private String description; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpPageReqVO.java new file mode 100644 index 0000000..341fe6a --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpPageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 MCP 分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1McpPageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "天气") + private String name; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpRespVO.java new file mode 100644 index 0000000..f62a0d6 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpRespVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Map; + +@Schema(description = "管理后台 - AI1 MCP Response VO") +@Data +public class Ai1McpRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "天气查询") + private String name; + + @Schema(description = "传输方式", requiredMode = Schema.RequiredMode.REQUIRED, example = "http") + private String transport; + + @Schema(description = "服务地址", example = "http://127.0.0.1:8081/mcp") + private String url; + + @Schema(description = "请求头", example = "{\"Authorization\":\"Bearer xxx\"}") + private Map headers; + + @Schema(description = "完整 MCP 配置,JSON 对象", example = "{\"transport\":\"http\",\"url\":\"http://127.0.0.1:8081/mcp\"}") + private String config; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "备注", example = "查询城市天气") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpSaveReqVO.java new file mode 100644 index 0000000..091715d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/mcp/vo/Ai1McpSaveReqVO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo; + +import cn.hutool.core.map.MapUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.Map; + +@Schema(description = "管理后台 - AI1 MCP 新增/修改 Request VO") +@Data +public class Ai1McpSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "天气查询") + @NotEmpty(message = "名称不能为空") + @Size(max = 100, message = "名称长度不能超过 100 个字符") + private String name; + + @Schema(description = "传输方式", requiredMode = Schema.RequiredMode.REQUIRED, example = "http") + @NotEmpty(message = "传输方式不能为空") + @InEnum(Ai1McpTransportEnum.class) + private String transport; + + @Schema(description = "服务地址,远程必填;为空时取 config 中的 url", example = "http://127.0.0.1:8081/mcp") + @Size(max = 200, message = "服务地址长度不能超过 200 个字符") + private String url; + + @Schema(description = "请求头,远程配置以此字段为准;为空时清空请求头", example = "{\"Authorization\":\"Bearer xxx\"}") + private Map headers; + + @Schema(description = "完整 MCP 配置,JSON 对象;本地必填 command", example = "{\"transport\":\"stdio\",\"command\":\"npx\",\"args\":[\"-y\",\"xxx\"]}") + private String config; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "备注", example = "查询城市天气") + @Size(max = 500, message = "备注长度不能超过 500 个字符") + private String remark; + + @AssertTrue(message = "请求头长度不能超过 500 个字符") + @JsonIgnore + public boolean isHeadersValid() { + return MapUtil.isEmpty(headers) || JsonUtils.toJsonString(headers).length() <= 500; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ModelController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ModelController.java new file mode 100644 index 0000000..3f63524 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ModelController.java @@ -0,0 +1,116 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.*; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; + +@Tag(name = "管理后台 - AI1 模型") +@RestController +@RequestMapping("/ai1/model") +@Validated +public class Ai1ModelController { + + @Resource + private Ai1ModelService modelService; + + @PostMapping("/create") + @Operation(summary = "创建模型") + @PreAuthorize("@ss.hasPermission('ai1:model:create')") + public CommonResult createModel(@Valid @RequestBody Ai1ModelSaveReqVO createReqVO) { + return success(modelService.createModel(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新模型") + @PreAuthorize("@ss.hasPermission('ai1:model:update')") + public CommonResult updateModel(@Valid @RequestBody Ai1ModelSaveReqVO updateReqVO) { + modelService.updateModel(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除模型") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:model:delete')") + public CommonResult deleteModel(@RequestParam("id") Long id) { + modelService.deleteModel(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除模型") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:model:delete')") + public CommonResult deleteModelList(@RequestParam("ids") List ids) { + modelService.deleteModelListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得模型") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:model:query')") + public CommonResult getModel(@RequestParam("id") Long id) { + Ai1ModelDO model = modelService.getModel(id); + return success(BeanUtils.toBean(model, Ai1ModelRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得模型分页") + @PreAuthorize("@ss.hasPermission('ai1:model:query')") + public CommonResult> getModelPage(@Valid Ai1ModelPageReqVO pageReqVO) { + PageResult pageResult = modelService.getModelPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, Ai1ModelRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得模型精简列表", description = "只包含开启状态,用于 Agent 模型、知识库向量化模型的下拉选择") + @Parameter(name = "providerId", description = "供应商编号", example = "1") + @Parameter(name = "type", description = "类型", example = "0") + public CommonResult> getModelSimpleList(@RequestParam(value = "providerId", required = false) Long providerId, + @RequestParam(value = "type", required = false) Integer type) { + List list = modelService.getModelListByProviderIdAndTypeAndStatus( + providerId, type, CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, model -> new Ai1ModelRespVO() + .setId(model.getId()).setProviderId(model.getProviderId()) + .setName(model.getName()).setModel(model.getModel()).setType(model.getType()))); + } + + @GetMapping("/remote-list") + @Operation(summary = "拉取供应商远程可用模型", description = "并标注是否已导入") + @Parameter(name = "providerId", description = "供应商编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:model:import')") + public CommonResult> getRemoteModelList(@RequestParam("providerId") Long providerId) { + List remoteModels = modelService.getRemoteModelList(providerId); + List existModelList = modelService.getModelListByProviderId(providerId); + Set existModels = convertSet(existModelList, Ai1ModelDO::getModel); + return success(convertList(remoteModels, model -> new Ai1ModelRemoteRespVO(model, existModels.contains(model)))); + } + + @PostMapping("/import") + @Operation(summary = "批量导入远程模型", description = "返回实际导入数量") + @PreAuthorize("@ss.hasPermission('ai1:model:import')") + public CommonResult importRemoteModelList(@Valid @RequestBody Ai1ModelImportReqVO importReqVO) { + return success(modelService.importRemoteModelList(importReqVO)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ProviderController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ProviderController.java new file mode 100644 index 0000000..5f27f64 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/Ai1ProviderController.java @@ -0,0 +1,124 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model; + +import cn.hutool.core.util.ReUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ProviderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI1 供应商") +@RestController +@RequestMapping("/ai1/provider") +@Validated +public class Ai1ProviderController { + + @Resource + private Ai1ProviderService providerService; + + @PostMapping("/create") + @Operation(summary = "创建供应商") + @PreAuthorize("@ss.hasPermission('ai1:provider:create')") + public CommonResult createProvider(@Valid @RequestBody Ai1ProviderSaveReqVO createReqVO) { + return success(providerService.createProvider(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新供应商") + @PreAuthorize("@ss.hasPermission('ai1:provider:update')") + public CommonResult updateProvider(@Valid @RequestBody Ai1ProviderSaveReqVO updateReqVO) { + providerService.updateProvider(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除供应商") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:provider:delete')") + public CommonResult deleteProvider(@RequestParam("id") Long id) { + providerService.deleteProvider(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除供应商") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:provider:delete')") + public CommonResult deleteProviderList(@RequestParam("ids") List ids) { + providerService.deleteProviderListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得供应商") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:provider:query')") + public CommonResult getProvider(@RequestParam("id") Long id) { + Ai1ProviderDO provider = providerService.getProvider(id); + return success(buildProviderRespVO(provider)); + } + + @GetMapping("/page") + @Operation(summary = "获得供应商分页") + @PreAuthorize("@ss.hasPermission('ai1:provider:query')") + public CommonResult> getProviderPage(@Valid Ai1ProviderPageReqVO pageReqVO) { + PageResult pageResult = providerService.getProviderPage(pageReqVO); + return success(new PageResult<>(convertList(pageResult.getList(), this::buildProviderRespVO), pageResult.getTotal())); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得供应商精简列表", description = "只包含开启状态,用于 Agent 模型、知识库向量化模型的下拉选择") + public CommonResult> getProviderSimpleList() { + List list = providerService.getProviderListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, provider -> new Ai1ProviderRespVO().setId(provider.getId()).setName(provider.getName()))); + } + + @PostMapping("/test") + @Operation(summary = "供应商连通测试", description = "GET /models 优先,失败时回退 POST /chat/completions") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:provider:test')") + public CommonResult testProvider(@RequestParam("id") Long id) { + return success(providerService.testProviderConnect(id)); + } + + // ==================== 拼接 VO ==================== + + /** + * 构建供应商响应:API 密钥脱敏返回,避免查询权限直接获取完整密钥 + */ + private Ai1ProviderRespVO buildProviderRespVO(Ai1ProviderDO provider) { + if (provider == null) { + return null; + } + Ai1ProviderRespVO respVO = BeanUtils.toBean(provider, Ai1ProviderRespVO.class); + String apiKey = provider.getApiKey(); + if (ReUtil.isMatch("^\\$\\{[a-zA-Z_][a-zA-Z0-9_.-]*\\}$", apiKey)) { + // 【安全性】仅完整的变量占位符原样返回;带默认值或拼接内容的密钥仍需脱敏 + respVO.setApiKey(apiKey); + } else if (StrUtil.isNotEmpty(apiKey)) { + respVO.setApiKey(apiKey.length() <= 8 ? "****" + : StrUtil.hide(apiKey, 3, apiKey.length() - 4)); + } + return respVO; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelImportReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelImportReqVO.java new file mode 100644 index 0000000..8074f66 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelImportReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +@Schema(description = "管理后台 - AI1 远程模型导入 Request VO") +@Data +public class Ai1ModelImportReqVO { + + @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "供应商编号不能为空") + private Long providerId; + + @Schema(description = "模型标识列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[\"deepseek-chat\"]") + @NotEmpty(message = "请选择要导入的模型") + private List models; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelPageReqVO.java new file mode 100644 index 0000000..e531da4 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelPageReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 模型分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1ModelPageReqVO extends PageParam { + + @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "供应商编号不能为空") + private Long providerId; + + @Schema(description = "展示名称,模糊匹配", example = "DeepSeek") + private String name; + + @Schema(description = "类型", example = "0") + private Integer type; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRemoteRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRemoteRespVO.java new file mode 100644 index 0000000..5e234ee --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRemoteRespVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Schema(description = "管理后台 - AI1 远程模型 Response VO") +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Ai1ModelRemoteRespVO { + + @Schema(description = "模型标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "deepseek-chat") + private String model; + + @Schema(description = "是否已导入当前供应商", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean imported; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRespVO.java new file mode 100644 index 0000000..cb6a419 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 模型 Response VO") +@Data +public class Ai1ModelRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long providerId; + + @Schema(description = "展示名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "DeepSeek Chat") + private String name; + + @Schema(description = "模型标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "deepseek-chat") + private String model; + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer type; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelSaveReqVO.java new file mode 100644 index 0000000..3b64568 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/model/Ai1ModelSaveReqVO.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 模型新增/修改 Request VO") +@Data +public class Ai1ModelSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "供应商编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "供应商编号不能为空") + private Long providerId; + + @Schema(description = "展示名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "DeepSeek Chat") + @NotEmpty(message = "展示名称不能为空") + @Size(max = 50, message = "展示名称长度不能超过 50 个字符") + private String name; + + @Schema(description = "模型标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "deepseek-chat") + @NotEmpty(message = "模型标识不能为空") + @Size(max = 100, message = "模型标识长度不能超过 100 个字符") + private String model; + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "类型不能为空") + @InEnum(Ai1ModelTypeEnum.class) + private Integer type; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderConnectRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderConnectRespVO.java new file mode 100644 index 0000000..9d22d21 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderConnectRespVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 供应商连通测试 Response VO") +@Data +public class Ai1ProviderConnectRespVO { + + @Schema(description = "是否连通", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean connectable; + + @Schema(description = "最近一次请求的 HTTP 状态码,0 表示网络异常未收到响应", requiredMode = Schema.RequiredMode.REQUIRED, example = "200") + private Integer httpCode; + + @Schema(description = "测试耗时,单位:毫秒", requiredMode = Schema.RequiredMode.REQUIRED, example = "320") + private Long elapsedMs; + + @Schema(description = "测试过程描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "连通正常:GET /models 返回 HTTP 200") + private String message; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderPageReqVO.java new file mode 100644 index 0000000..2ad8ac1 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderPageReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 供应商分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1ProviderPageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "DeepSeek") + private String name; + + @Schema(description = "状态", example = "0") + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java new file mode 100644 index 0000000..cd14196 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderRespVO.java @@ -0,0 +1,41 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.util.Map; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 供应商 Response VO") +@Data +public class Ai1ProviderRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "DeepSeek") + private String name; + + @Schema(description = "接口地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://api.deepseek.com/v1") + private String baseUrl; + + @Schema(description = "API 密钥,脱敏展示", example = "sk-****abcd") + private String apiKey; + + @Schema(description = "请求 Header,JSON 对象", example = "{\"X-Session\":\"{session}\"}") + private Map headers; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "备注", example = "官方接口") + private String remark; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java new file mode 100644 index 0000000..cb41dca --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/model/vo/provider/Ai1ProviderSaveReqVO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.ai1.util.Ai1Utils; +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +import java.util.Map; + +@Schema(description = "管理后台 - AI1 供应商新增/修改 Request VO") +@Data +public class Ai1ProviderSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "DeepSeek") + @NotEmpty(message = "名称不能为空") + @Size(max = 50, message = "名称长度不能超过 50 个字符") + private String name; + + @Schema(description = "接口地址", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://api.deepseek.com/v1") + @NotEmpty(message = "接口地址不能为空") + @Size(max = 200, message = "接口地址长度不能超过 200 个字符") + private String baseUrl; + + @Schema(description = "API 密钥;修改时为空表示保持原密钥不变", example = "sk-xxx") + @Size(max = 200, message = "API 密钥长度不能超过 200 个字符") + private String apiKey; + + @Schema(description = "请求 Header,JSON 对象", example = "{\"X-Session\":\"{session}\"}") + private Map headers; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + + @Schema(description = "备注", example = "官方接口") + @Size(max = 255, message = "备注长度不能超过 255 个字符") + private String remark; + + @AssertTrue(message = "请求 Header 名称不能为空或重复,值必须为字符串,JSON 长度不能超过 2000 个字符") + @JsonIgnore + public boolean isHeadersValid() { + return Ai1Utils.isHeadersValid(headers); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionController.java new file mode 100644 index 0000000..1de68df --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionController.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.collection.MapUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI1 会话") +@RestController +@RequestMapping("/ai1/session") +@Validated +public class Ai1SessionController { + + @Resource + private Ai1SessionService sessionService; + + @Resource + private AdminUserApi adminUserApi; + + // ==================== 我的 ==================== + + @PostMapping("/create-my") + @Operation(summary = "创建【我的】会话") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult createSessionMy(@Valid @RequestBody Ai1SessionCreateMyReqVO createReqVO) { + return success(sessionService.createSessionMy(getLoginUserId(), createReqVO)); + } + + @PutMapping("/update-my") + @Operation(summary = "修改【我的】会话标题") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult updateSessionMy(@Valid @RequestBody Ai1SessionUpdateMyReqVO updateReqVO) { + sessionService.updateSessionMy(getLoginUserId(), updateReqVO); + return success(true); + } + + @DeleteMapping("/delete-my") + @Operation(summary = "删除【我的】会话", description = "连带删除消息") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult deleteSessionMy(@RequestParam("id") Long id) { + sessionService.deleteSessionMy(getLoginUserId(), id); + return success(true); + } + + @GetMapping("/my-list") + @Operation(summary = "获得【我的】会话列表", description = "指定 Agent 下,按创建时间倒序") + @Parameter(name = "agentId", description = "Agent 编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult> getSessionMyList(@RequestParam("agentId") Long agentId) { + List list = sessionService.getSessionListByAgentIdAndUserId(agentId, getLoginUserId()); + return success(BeanUtils.toBean(list, Ai1SessionRespVO.class)); + } + + // ==================== 管理 ==================== + + @GetMapping("/page") + @Operation(summary = "获得会话分页", description = "管理端查看 Agent 下全部用户的会话") + @PreAuthorize("@ss.hasPermission('ai1:session:query')") + public CommonResult> getSessionPage(@Valid Ai1SessionPageReqVO pageReqVO) { + PageResult pageResult = sessionService.getSessionPage(pageReqVO); + Map userMap = adminUserApi.getUserMap(convertSet(pageResult.getList(), Ai1SessionDO::getUserId)); + return success(BeanUtils.toBean(pageResult, Ai1SessionRespVO.class, respVO -> + MapUtils.findAndThen(userMap, respVO.getUserId(), user -> respVO.setUserNickname(user.getNickname())))); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionMessageController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionMessageController.java new file mode 100644 index 0000000..111727b --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/Ai1SessionMessageController.java @@ -0,0 +1,77 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - AI1 会话消息") +@RestController +@RequestMapping("/ai1/session/message") +@Validated +public class Ai1SessionMessageController { + + @Resource + private Ai1SessionMessageService sessionMessageService; + @Resource + private Ai1SessionService sessionService; + + // ==================== 我的 ==================== + + @GetMapping("/my-list") + @Operation(summary = "获得【我的】会话消息列表") + @Parameter(name = "sessionId", description = "会话编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public CommonResult> getSessionMessageMyList(@RequestParam("sessionId") Long sessionId) { + sessionService.validateSessionMy(getLoginUserId(), sessionId); + return success(BeanUtils.toBean(sessionMessageService.getSessionMessageListBySessionId(sessionId), + Ai1SessionMessageRespVO.class)); + } + + @PostMapping(value = "/send-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "发送消息(流式)", description = "SSE 事件:stream(助手消息编号)、thinking、message、ping、done、error;" + + "事件 data 均为 JSON 字符串,事件 id 为结果流条目编号,可用于续传") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public SseEmitter sendSessionMessageStream(@Valid @RequestBody Ai1SessionMessageSendReqVO sendReqVO) { + return sessionMessageService.sendSessionMessageStream(getLoginUserId(), sendReqVO); + } + + @PostMapping(value = "/resume-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + @Operation(summary = "断线续传(流式)", description = "从结果流 lastEventId 之后继续转发,不重新生成;事件格式同发送消息") + @Parameter(name = "messageId", description = "助手消息编号", required = true, example = "1024") + @Parameter(name = "lastEventId", description = "已收到的最后一个事件编号,为空时从头重放", example = "1727541234567-0") + @PreAuthorize("@ss.hasPermission('ai1:session:my')") + public SseEmitter resumeSessionMessageStream(@RequestParam("messageId") Long messageId, + @RequestParam(value = "lastEventId", required = false) String lastEventId) { + return sessionMessageService.resumeSessionMessageStream(getLoginUserId(), messageId, lastEventId); + } + + // ==================== 管理 ==================== + + @GetMapping("/list") + @Operation(summary = "获得会话消息列表", description = "管理端查看任意用户的会话明细") + @Parameter(name = "sessionId", description = "会话编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:session:query')") + public CommonResult> getSessionMessageList(@RequestParam("sessionId") Long sessionId) { + return success(BeanUtils.toBean(sessionMessageService.getSessionMessageListBySessionId(sessionId), + Ai1SessionMessageRespVO.class)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageRespVO.java new file mode 100644 index 0000000..3772da2 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 会话消息 Response VO") +@Data +public class Ai1SessionMessageRespVO { + + @Schema(description = "编号;助手消息的编号同时作为续传的结果流标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "会话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long sessionId; + + @Schema(description = "角色", requiredMode = Schema.RequiredMode.REQUIRED, example = "assistant") + private String role; + + @Schema(description = "思考过程", example = "用户想了解……") + private String reasoning; + + @Schema(description = "内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "四光年") + private String content; + + @Schema(description = "生成状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageSendReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageSendReqVO.java new file mode 100644 index 0000000..efc7d1a --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/message/Ai1SessionMessageSendReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 会话消息发送 Request VO") +@Data +public class Ai1SessionMessageSendReqVO { + + @Schema(description = "会话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "会话编号不能为空") + private Long sessionId; + + @Schema(description = "提问内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队还有多久到达地球?") + @NotBlank(message = "请输入内容") + private String content; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionCreateMyReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionCreateMyReqVO.java new file mode 100644 index 0000000..805d322 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionCreateMyReqVO.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 我的会话创建 Request VO") +@Data +public class Ai1SessionCreateMyReqVO { + + @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "Agent 编号不能为空") + private Long agentId; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionPageReqVO.java new file mode 100644 index 0000000..797b41d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionPageReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 会话分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1SessionPageReqVO extends PageParam { + + @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "Agent 编号不能为空") + private Long agentId; + + @Schema(description = "标题,模糊匹配", example = "三体") + private String title; + + @Schema(description = "用户编号", example = "1") + private Long userId; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionRespVO.java new file mode 100644 index 0000000..139bcfe --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 会话 Response VO") +@Data +public class Ai1SessionRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "Agent 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long agentId; + + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long userId; + + @Schema(description = "用户昵称", example = "芋道源码") + private String userNickname; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队的航速") + private String title; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间,即最近活跃时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionUpdateMyReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionUpdateMyReqVO.java new file mode 100644 index 0000000..b0749d8 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/session/vo/session/Ai1SessionUpdateMyReqVO.java @@ -0,0 +1,22 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 我的会话修改 Request VO") +@Data +public class Ai1SessionUpdateMyReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "标题", requiredMode = Schema.RequiredMode.REQUIRED, example = "三体舰队的航速") + @NotBlank(message = "标题不能为空") + @Size(max = 50, message = "标题长度不能超过 50 个字符") + private String title; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillController.java new file mode 100644 index 0000000..bb34d57 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillController.java @@ -0,0 +1,93 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +@Tag(name = "管理后台 - AI1 SKILL") +@RestController +@RequestMapping("/ai1/skill") +@Validated +public class Ai1SkillController { + + @Resource + private Ai1SkillService skillService; + + @PostMapping("/create") + @Operation(summary = "创建 SKILL", description = "自动播种固定文件 SKILL.md、scripts/、reference/") + @PreAuthorize("@ss.hasPermission('ai1:skill:create')") + public CommonResult createSkill(@Valid @RequestBody Ai1SkillSaveReqVO createReqVO) { + return success(skillService.createSkill(createReqVO)); + } + + @PutMapping("/update") + @Operation(summary = "更新 SKILL") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult updateSkill(@Valid @RequestBody Ai1SkillSaveReqVO updateReqVO) { + skillService.updateSkill(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 SKILL", description = "级联删除内容文件") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:skill:delete')") + public CommonResult deleteSkill(@RequestParam("id") Long id) { + skillService.deleteSkill(id); + return success(true); + } + + @DeleteMapping("/delete-list") + @Operation(summary = "批量删除 SKILL", description = "级联删除内容文件") + @Parameter(name = "ids", description = "编号列表", required = true) + @PreAuthorize("@ss.hasPermission('ai1:skill:delete')") + public CommonResult deleteSkillList(@RequestParam("ids") List ids) { + skillService.deleteSkillListByIds(ids); + return success(true); + } + + @GetMapping("/get") + @Operation(summary = "获得 SKILL") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:skill:query')") + public CommonResult getSkill(@RequestParam("id") Long id) { + Ai1SkillDO skill = skillService.getSkill(id); + return success(BeanUtils.toBean(skill, Ai1SkillRespVO.class)); + } + + @GetMapping("/page") + @Operation(summary = "获得 SKILL 分页") + @PreAuthorize("@ss.hasPermission('ai1:skill:query')") + public CommonResult> getSkillPage(@Valid Ai1SkillPageReqVO pageReqVO) { + PageResult pageResult = skillService.getSkillPage(pageReqVO); + return success(BeanUtils.toBean(pageResult, Ai1SkillRespVO.class)); + } + + @GetMapping("/simple-list") + @Operation(summary = "获得 SKILL 精简列表", description = "只包含开启状态,用于 Agent 绑定 SKILL 的下拉选择") + public CommonResult> getSkillSimpleList() { + List list = skillService.getSkillListByStatus(CommonStatusEnum.ENABLE.getStatus()); + return success(convertList(list, skill -> new Ai1SkillRespVO() + .setId(skill.getId()).setName(skill.getName()).setDescription(skill.getDescription()))); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillFileController.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillFileController.java new file mode 100644 index 0000000..ab328ca --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/Ai1SkillFileController.java @@ -0,0 +1,92 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.*; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillFileService; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +@Tag(name = "管理后台 - AI1 SKILL 内容文件") +@RestController +@RequestMapping("/ai1/skill/file") +@Validated +public class Ai1SkillFileController { + + @Resource + private Ai1SkillFileService skillFileService; + @Resource + private Ai1SkillService skillService; + + @GetMapping("/list") + @Operation(summary = "获得 SKILL 文件列表", description = "平铺返回,不返回文件内容,编辑时通过获取接口加载") + @Parameter(name = "skillId", description = "SKILL 编号", required = true, example = "1") + @PreAuthorize("@ss.hasPermission('ai1:skill:query')") + public CommonResult> getSkillFileList(@RequestParam("skillId") Long skillId) { + skillService.validateSkillExists(skillId); + List list = skillFileService.getSkillFileListBySkillId(skillId); + return success(BeanUtils.toBean(list, Ai1SkillFileRespVO.class, skillFile -> skillFile.setContent(null))); + } + + @GetMapping("/get") + @Operation(summary = "获得 SKILL 文件", description = "包含文件内容") + @Parameter(name = "id", description = "编号", required = true, example = "1024") + @PreAuthorize("@ss.hasPermission('ai1:skill:query')") + public CommonResult getSkillFile(@RequestParam("id") Long id) { + Ai1SkillFileDO skillFile = skillFileService.getSkillFile(id); + return success(BeanUtils.toBean(skillFile, Ai1SkillFileRespVO.class)); + } + + @PostMapping("/create") + @Operation(summary = "创建 SKILL 目录或文件") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult createSkillFile(@Valid @RequestBody Ai1SkillFileCreateReqVO createReqVO) { + return success(skillFileService.createSkillFile(createReqVO)); + } + + @PutMapping("/rename") + @Operation(summary = "重命名 SKILL 目录或文件") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult renameSkillFile(@Valid @RequestBody Ai1SkillFileRenameReqVO renameReqVO) { + skillFileService.renameSkillFile(renameReqVO); + return success(true); + } + + @PutMapping("/move") + @Operation(summary = "移动 SKILL 目录或文件") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult moveSkillFile(@Valid @RequestBody Ai1SkillFileMoveReqVO moveReqVO) { + skillFileService.moveSkillFile(moveReqVO); + return success(true); + } + + @PutMapping("/update-content") + @Operation(summary = "保存 SKILL 文件内容") + @PreAuthorize("@ss.hasPermission('ai1:skill:update')") + public CommonResult updateSkillFileContent(@Valid @RequestBody Ai1SkillFileContentReqVO contentReqVO) { + skillFileService.updateSkillFileContent(contentReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除 SKILL 目录或文件", description = "目录递归删除后代") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('ai1:skill:delete')") + public CommonResult deleteSkillFile(@RequestParam("id") Long id) { + skillFileService.deleteSkillFile(id); + return success(true); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileContentReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileContentReqVO.java new file mode 100644 index 0000000..5a66f67 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileContentReqVO.java @@ -0,0 +1,21 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件保存内容 Request VO") +@Data +public class Ai1SkillFileContentReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "文件内容,允许为空字符串", requiredMode = Schema.RequiredMode.REQUIRED, example = "# SKILL") + @NotNull(message = "文件内容不能为空") + @Size(max = 16_777_215, message = "内容长度不能超过 16777215 个字符") + private String content; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileCreateReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileCreateReqVO.java new file mode 100644 index 0000000..82fb016 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileCreateReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import cn.iocoder.yudao.framework.common.validation.InEnum; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件新增 Request VO") +@Data +public class Ai1SkillFileCreateReqVO { + + @Schema(description = "SKILL 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "SKILL 编号不能为空") + private Long skillId; + + @Schema(description = "父目录编号,0 为根级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "父目录编号不能为空") + private Long parentId; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "main.py") + @NotEmpty(message = "名称不能为空") + private String name; + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "类型不能为空") + @InEnum(Ai1SkillFileTypeEnum.class) + private Integer type; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileMoveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileMoveReqVO.java new file mode 100644 index 0000000..ba538ea --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileMoveReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件移动 Request VO") +@Data +public class Ai1SkillFileMoveReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "目标父目录编号,0 为根级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "目标父目录编号不能为空") + private Long parentId; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRenameReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRenameReqVO.java new file mode 100644 index 0000000..9dfff01 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRenameReqVO.java @@ -0,0 +1,20 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件重命名 Request VO") +@Data +public class Ai1SkillFileRenameReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + @NotNull(message = "编号不能为空") + private Long id; + + @Schema(description = "新名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "main.py") + @NotEmpty(message = "名称不能为空") + private String name; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRespVO.java new file mode 100644 index 0000000..2bf91e7 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/file/Ai1SkillFileRespVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 SKILL 内容文件 Response VO") +@Data +public class Ai1SkillFileRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "SKILL 编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long skillId; + + @Schema(description = "父目录编号,0 为根级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Long parentId; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "SKILL.md") + private String name; + + @Schema(description = "类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer type; + + @Schema(description = "文件类型,即扩展名;目录为空", example = "md") + private String fileType; + + @Schema(description = "文件内容;文件树中不返回,编辑时通过获取接口加载", example = "# SKILL") + private String content; + + @Schema(description = "是否固定:固定节点不可删除、改名、移动", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean locked; + + @Schema(description = "排序", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer sort; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillPageReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillPageReqVO.java new file mode 100644 index 0000000..aa72da0 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillPageReqVO.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "管理后台 - AI1 SKILL 分页 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +public class Ai1SkillPageReqVO extends PageParam { + + @Schema(description = "名称,模糊匹配", example = "pdf") + private String name; + + @Schema(description = "状态", example = "0") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillRespVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillRespVO.java new file mode 100644 index 0000000..23d7d27 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillRespVO.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - AI1 SKILL Response VO") +@Data +public class Ai1SkillRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") + private Long id; + + @Schema(description = "名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "pdf-reader") + private String name; + + @Schema(description = "描述", example = "读取并总结 PDF 文件") + private String description; + + @Schema(description = "版本", requiredMode = Schema.RequiredMode.REQUIRED, example = "1.0") + private String version; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + private Integer status; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime updateTime; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillSaveReqVO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillSaveReqVO.java new file mode 100644 index 0000000..cf02232 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/admin/skill/vo/skill/Ai1SkillSaveReqVO.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.validation.InEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.Data; + +@Schema(description = "管理后台 - AI1 SKILL 新增/修改 Request VO") +@Data +public class Ai1SkillSaveReqVO { + + @Schema(description = "编号", example = "1024") + private Long id; + + @Schema(description = "名称,同时作为物化目录名,仅支持字母、数字、中划线", requiredMode = Schema.RequiredMode.REQUIRED, example = "pdf-reader") + @NotEmpty(message = "名称不能为空") + @Size(max = 100, message = "名称长度不能超过 100 个字符") + @Pattern(regexp = "^[A-Za-z0-9-]+$", message = "名称仅支持字母、数字、中划线") + private String name; + + @Schema(description = "描述", example = "读取并总结 PDF 文件") + @Size(max = 500, message = "描述长度不能超过 500 个字符") + private String description; + + @Schema(description = "版本", requiredMode = Schema.RequiredMode.REQUIRED, example = "1.0") + @NotEmpty(message = "版本不能为空") + @Size(max = 20, message = "版本长度不能超过 20 个字符") + private String version; + + @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") + @NotNull(message = "状态不能为空") + @InEnum(CommonStatusEnum.class) + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/package-info.java new file mode 100644 index 0000000..a693263 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/controller/package-info.java @@ -0,0 +1,5 @@ +/** + * 提供 RESTful API 给前端: + * 1. admin 包:提供给管理后台 yudao-ui-admin 前端项目 + */ +package cn.iocoder.yudao.module.ai1.controller; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/agent/Ai1AgentDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/agent/Ai1AgentDO.java new file mode 100644 index 0000000..822c481 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/agent/Ai1AgentDO.java @@ -0,0 +1,91 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.agent; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.util.List; + +/** + * AI1 Agent DO + * + * @author 芋道源码 + */ +@TableName(value = "ai1_agent", autoResultMap = true) +@KeySequence("ai1_agent_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1AgentDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * 介绍 + */ + private String introduction; + /** + * 模型所属 Provider 编号 + * + * 关联 {@link Ai1ProviderDO#getId()} + */ + private Long providerId; + /** + * 对话模型编号 + * + * 关联 {@link Ai1ModelDO#getId()} + */ + private Long modelId; + /** + * 系统指令 + */ + private String systemPrompt; + /** + * 知识库编号集合 + * + * 关联 {@link Ai1KnowledgeBaseDO#getId()} + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List knowledgeBaseIds; + /** + * MCP 编号集合 + * + * 关联 {@link Ai1McpDO#getId()} + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List mcpIds; + /** + * SKILL 编号集合 + * + * 关联 {@link Ai1SkillDO#getId()} + */ + @TableField(typeHandler = LongListTypeHandler.class) + private List skillIds; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum};开启后才可在「Agent 会话」中使用 + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeBaseDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeBaseDO.java new file mode 100644 index 0000000..d20f97d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeBaseDO.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 知识库 DO + * + * 向量存储在 Milvus 集合 {collectionPrefix}{id}(默认 knowledge_base_{id})中,按知识库隔离 + * + * @author 芋道源码 + */ +@TableName("ai1_knowledge_base") +@KeySequence("ai1_knowledge_base_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1KnowledgeBaseDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * 描述 + */ + private String description; + /** + * 向量化 Provider 编号 + * + * 关联 {@link Ai1ProviderDO#getId()} + */ + private Long embeddingProviderId; + /** + * 向量化模型编号 + * + * 关联 {@link Ai1ModelDO#getId()} + */ + private Long embeddingModelId; + /** + * 分片大小,单位:字符 + */ + private Integer chunkSize; + /** + * 分片重叠,单位:字符 + */ + private Integer chunkOverlap; + /** + * 检索数量 + */ + private Integer topK; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeDocumentDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeDocumentDO.java new file mode 100644 index 0000000..3c91505 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/knowledge/Ai1KnowledgeDocumentDO.java @@ -0,0 +1,55 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.knowledge.Ai1KnowledgeDocumentStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 知识文档 DO + * + * @author 芋道源码 + */ +@TableName("ai1_knowledge_document") +@KeySequence("ai1_knowledge_document_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1KnowledgeDocumentDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 知识库编号 + * + * 关联 {@link Ai1KnowledgeBaseDO#getId()} + */ + private Long knowledgeBaseId; + /** + * 名称 + */ + private String name; + /** + * 内容 + */ + private String content; + /** + * 分片数量 + */ + private Integer chunkCount; + /** + * 向量化状态 + * + * 枚举 {@link Ai1KnowledgeDocumentStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/mcp/Ai1McpDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/mcp/Ai1McpDO.java new file mode 100644 index 0000000..215ddf4 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/mcp/Ai1McpDO.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.mcp; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import lombok.*; + +import java.util.Map; + +/** + * AI1 MCP 服务 DO + * + * @author 芋道源码 + */ +@TableName(value = "ai1_mcp", autoResultMap = true) +@KeySequence("ai1_mcp_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1McpDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * 传输方式 + * + * 枚举 {@link Ai1McpTransportEnum};以该列为准,保存时同步写回 {@link #config} 的 transport 字段 + */ + private String transport; + /** + * 服务地址,远程必填、本地为空 + * + * 切换为本地时需要清空,更新时由 Ai1McpMapper#updateForSave 主动置空 + */ + private String url; + /** + * 请求头,例如说 {"Authorization":"Bearer xxx"} + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private Map headers; + /** + * 完整 MCP 配置,JSON 对象 + * + * 远程:{transport, url, headers};本地:{transport, command, args, env, cwd} + */ + private String config; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ModelDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ModelDO.java new file mode 100644 index 0000000..171144b --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ModelDO.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 模型 DO + * + * @author 芋道源码 + */ +@TableName("ai1_model") +@KeySequence("ai1_model_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1ModelDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 供应商编号 + * + * 关联 {@link Ai1ProviderDO#getId()} + */ + private Long providerId; + /** + * 展示名称 + */ + private String name; + /** + * 模型标识,例如说 gpt-4o、deepseek-chat + */ + private String model; + /** + * 类型 + * + * 枚举 {@link Ai1ModelTypeEnum} + */ + private Integer type; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java new file mode 100644 index 0000000..a08da32 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/model/Ai1ProviderDO.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.util.Map; + +/** + * AI1 供应商 DO + * + * 供应商即模型提供方(OpenAI 兼容端点),维护接口地址与密钥 + * + * @author 芋道源码 + */ +@TableName(value = "ai1_provider", autoResultMap = true) +@KeySequence("ai1_provider_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1ProviderDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + */ + private String name; + /** + * 接口地址 + */ + private String baseUrl; + /** + * API 密钥 + */ + private String apiKey; + /** + * 请求 Header + * + * JSON 对象,格式为 {"X-Session":"{session}"};value 可包含 {session} 占位符,请求时替换为会话编号 + */ + @TableField(typeHandler = JacksonTypeHandler.class) + private Map headers; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + /** + * 备注 + */ + private String remark; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionDO.java new file mode 100644 index 0000000..1e2fc4c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionDO.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.session; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 会话 DO + * + * 管理后台登录用户与 Agent 的一次会话 + * + * @author 芋道源码 + */ +@TableName("ai1_session") +@KeySequence("ai1_session_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1SessionDO extends TenantBaseDO { + + /** + * 默认标题:首条消息发送后,自动替换为提问内容 + */ + public static final String TITLE_DEFAULT = "新会话"; + /** + * 自动生成的会话标题最大长度:首条提问内容超出时截断 + */ + public static final int TITLE_MAX_LENGTH = 50; + + /** + * 编号 + */ + @TableId + private Long id; + /** + * Agent 编号 + * + * 关联 {@link Ai1AgentDO#getId()} + */ + private Long agentId; + /** + * 用户编号 + * + * 关联 AdminUserDO 的 id 编号 + */ + private Long userId; + /** + * 标题 + */ + private String title; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionMessageDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionMessageDO.java new file mode 100644 index 0000000..3704d7f --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/session/Ai1SessionMessageDO.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.session; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 会话消息 DO + * + * 助手消息的编号同时作为 Redis 结果流标识,用于断线续传 + * + * @author 芋道源码 + */ +@TableName("ai1_session_message") +@KeySequence("ai1_session_message_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1SessionMessageDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 会话编号 + * + * 关联 {@link Ai1SessionDO#getId()} + */ + private Long sessionId; + /** + * 角色 + * + * 枚举 {@link Ai1SessionMessageRoleEnum} + */ + private String role; + /** + * 思考过程,即推理模型的 reasoning_content + */ + private String reasoning; + /** + * 内容 + */ + private String content; + /** + * 生成状态 + * + * 枚举 {@link Ai1SessionMessageStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillDO.java new file mode 100644 index 0000000..26b4073 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillDO.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 SKILL DO + * + * @author 芋道源码 + */ +@TableName("ai1_skill") +@KeySequence("ai1_skill_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1SkillDO extends TenantBaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 名称 + * + * 同时作为物化目录名,租户内唯一,仅支持字母、数字、中划线 + */ + private String name; + /** + * 描述 + */ + private String description; + /** + * 版本 + */ + private String version; + /** + * 状态 + * + * 枚举 {@link CommonStatusEnum} + */ + private Integer status; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillFileDO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillFileDO.java new file mode 100644 index 0000000..22b51fe --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/dataobject/skill/Ai1SkillFileDO.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.ai1.dal.dataobject.skill; + +import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * AI1 SKILL 内容文件 DO + * + * 以 parentId 组织为文件树,物化时按树结构写入本地技能目录 + * + * @author 芋道源码 + */ +@TableName("ai1_skill_file") +@KeySequence("ai1_skill_file_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Ai1SkillFileDO extends TenantBaseDO { + + /** + * 父目录编号:根级 + */ + public static final Long PARENT_ID_ROOT = 0L; + + /** + * 固定文件名:SKILL 入口文件 + */ + public static final String NAME_SKILL = "SKILL.md"; + + /** + * 编号 + */ + @TableId + private Long id; + /** + * SKILL 编号 + * + * 关联 {@link Ai1SkillDO#getId()} + */ + private Long skillId; + /** + * 父目录编号 + * + * 关联 {@link Ai1SkillFileDO#getId()};根级为 {@link #PARENT_ID_ROOT} + */ + private Long parentId; + /** + * 文件或目录名称 + */ + private String name; + /** + * 类型 + * + * 枚举 {@link Ai1SkillFileTypeEnum} + */ + private Integer type; + /** + * 文件类型,即扩展名;目录为空 + */ + private String fileType; + /** + * 文件内容;目录为空 + */ + private String content; + /** + * 是否固定 + * + * 固定节点(SKILL.md、scripts/、reference/)不可删除、改名、移动 + */ + private Boolean locked; + /** + * 排序 + */ + private Integer sort; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/agent/Ai1AgentMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/agent/Ai1AgentMapper.java new file mode 100644 index 0000000..8a6402d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/agent/Ai1AgentMapper.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.agent; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 Agent Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1AgentMapper extends BaseMapperX { + + default PageResult selectPage(Ai1AgentPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1AgentDO::getName, reqVO.getName()) + .eqIfPresent(Ai1AgentDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1AgentDO::getId)); + } + + default List selectListByStatusOrderById(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1AgentDO::getStatus, status) + .orderByAsc(Ai1AgentDO::getId)); + } + + default Long selectCountByModelIds(Collection modelIds) { + return selectCount(new LambdaQueryWrapperX().in(Ai1AgentDO::getModelId, modelIds)); + } + + default Long selectCountByKnowledgeBaseIds(Collection knowledgeBaseIds) { + return selectCount(new LambdaQueryWrapperX() + .apply("(" + MyBatisUtils.findInSet("knowledge_base_ids", knowledgeBaseIds) + ")", knowledgeBaseIds.toArray())); + } + + default Long selectCountBySkillIds(Collection skillIds) { + return selectCount(new LambdaQueryWrapperX() + .apply("(" + MyBatisUtils.findInSet("skill_ids", skillIds) + ")", skillIds.toArray())); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/home/Ai1HomeMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/home/Ai1HomeMapper.java new file mode 100644 index 0000000..e4a7d16 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/home/Ai1HomeMapper.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.home; + +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * AI1 首页统计 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1HomeMapper { + + @Select("SELECT (SELECT COUNT(1) FROM ai1_agent WHERE deleted = FALSE) AS agentCount, " + + "(SELECT COUNT(1) FROM ai1_skill WHERE deleted = FALSE) AS skillCount, " + + "(SELECT COUNT(1) FROM ai1_mcp WHERE deleted = FALSE) AS mcpCount, " + + "(SELECT COUNT(1) FROM ai1_model WHERE deleted = FALSE) AS modelCount") + Ai1HomeSummaryRespVO selectSummary(); + + /** + * 按天统计消息数量,仅返回有消息的日期;使用 CAST AS DATE 兼容 MySQL 与 H2 + */ + @Select("SELECT CAST(create_time AS DATE) AS `date`, COUNT(1) AS `count` " + + "FROM ai1_session_message " + + "WHERE create_time BETWEEN #{beginTime} AND #{endTime} AND deleted = FALSE " + + "GROUP BY CAST(create_time AS DATE) " + + "ORDER BY `date`") + List selectMessageSummaryListByCreateTimeBetweenGroupByDate( + @Param("beginTime") LocalDateTime beginTime, @Param("endTime") LocalDateTime endTime); + + /** + * 按 Agent 统计消息数量,按数量倒序,数量相同按 Agent 编号升序;已删除会话下的消息不计入 + */ + @Select("SELECT c.agent_id AS agentId, COUNT(1) AS `count` " + + "FROM ai1_session_message m " + + "INNER JOIN ai1_session c ON m.session_id = c.id AND c.deleted = FALSE " + + "WHERE m.create_time BETWEEN #{beginTime} AND #{endTime} AND m.deleted = FALSE " + + "GROUP BY c.agent_id " + + "ORDER BY `count` DESC, c.agent_id") + List selectMessageSummaryListByCreateTimeBetweenGroupByAgentId( + @Param("beginTime") LocalDateTime beginTime, @Param("endTime") LocalDateTime endTime); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeBaseMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeBaseMapper.java new file mode 100644 index 0000000..d7fe0e0 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeBaseMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.knowledge; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBasePageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 知识库 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1KnowledgeBaseMapper extends BaseMapperX { + + default PageResult selectPage(Ai1KnowledgeBasePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1KnowledgeBaseDO::getName, reqVO.getName()) + .eqIfPresent(Ai1KnowledgeBaseDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1KnowledgeBaseDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1KnowledgeBaseDO::getStatus, status) + .orderByAsc(Ai1KnowledgeBaseDO::getId)); + } + + default Long selectCountByEmbeddingModelIds(Collection modelIds) { + return selectCount(new LambdaQueryWrapperX() + .in(Ai1KnowledgeBaseDO::getEmbeddingModelId, modelIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeDocumentMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeDocumentMapper.java new file mode 100644 index 0000000..8bad4d8 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/knowledge/Ai1KnowledgeDocumentMapper.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.knowledge; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 知识文档 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1KnowledgeDocumentMapper extends BaseMapperX { + + default PageResult selectPage(Ai1KnowledgeDocumentPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(Ai1KnowledgeDocumentDO::getKnowledgeBaseId, reqVO.getKnowledgeBaseId()) + .likeIfPresent(Ai1KnowledgeDocumentDO::getName, reqVO.getName()) + .eqIfPresent(Ai1KnowledgeDocumentDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1KnowledgeDocumentDO::getId)); + } + + default List selectListByKnowledgeBaseId(Long knowledgeBaseId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1KnowledgeDocumentDO::getKnowledgeBaseId, knowledgeBaseId) + .orderByAsc(Ai1KnowledgeDocumentDO::getId)); + } + + default int deleteByKnowledgeBaseIds(Collection knowledgeBaseIds) { + return delete(new LambdaQueryWrapperX() + .in(Ai1KnowledgeDocumentDO::getKnowledgeBaseId, knowledgeBaseIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/mcp/Ai1McpMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/mcp/Ai1McpMapper.java new file mode 100644 index 0000000..5e7c7af --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/mcp/Ai1McpMapper.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.mcp; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI1 MCP 服务 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1McpMapper extends BaseMapperX { + + default PageResult selectPage(Ai1McpPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1McpDO::getName, reqVO.getName()) + .eqIfPresent(Ai1McpDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1McpDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1McpDO::getStatus, status) + .orderByAsc(Ai1McpDO::getId)); + } + + default void updateForSave(Ai1McpDO mcp) { + update(mcp, new LambdaUpdateWrapper().eq(Ai1McpDO::getId, mcp.getId()) + .set(mcp.getUrl() == null, Ai1McpDO::getUrl, null)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ModelMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ModelMapper.java new file mode 100644 index 0000000..1f5ee1b --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ModelMapper.java @@ -0,0 +1,51 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.model; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 模型 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1ModelMapper extends BaseMapperX { + + default PageResult selectPage(Ai1ModelPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(Ai1ModelDO::getProviderId, reqVO.getProviderId()) + .likeIfPresent(Ai1ModelDO::getName, reqVO.getName()) + .eqIfPresent(Ai1ModelDO::getType, reqVO.getType()) + .orderByAsc(Ai1ModelDO::getId)); + } + + default Ai1ModelDO selectByProviderIdAndModel(Long providerId, String model) { + return selectOne(Ai1ModelDO::getProviderId, providerId, Ai1ModelDO::getModel, model); + } + + default List selectListByProviderId(Long providerId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1ModelDO::getProviderId, providerId) + .orderByAsc(Ai1ModelDO::getId)); + } + + default List selectListByProviderIdAndTypeAndStatus(Long providerId, Integer type, Integer status) { + return selectList(new LambdaQueryWrapperX() + .eqIfPresent(Ai1ModelDO::getProviderId, providerId) + .eqIfPresent(Ai1ModelDO::getType, type) + .eq(Ai1ModelDO::getStatus, status) + .orderByAsc(Ai1ModelDO::getId)); + } + + default Long selectCountByProviderIds(Collection providerIds) { + return selectCount(new LambdaQueryWrapperX().in(Ai1ModelDO::getProviderId, providerIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ProviderMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ProviderMapper.java new file mode 100644 index 0000000..bc6e892 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/model/Ai1ProviderMapper.java @@ -0,0 +1,33 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.model; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI1 供应商 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1ProviderMapper extends BaseMapperX { + + default PageResult selectPage(Ai1ProviderPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1ProviderDO::getName, reqVO.getName()) + .eqIfPresent(Ai1ProviderDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1ProviderDO::getId)); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1ProviderDO::getStatus, status) + .orderByAsc(Ai1ProviderDO::getId)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/package-info.java new file mode 100644 index 0000000..9e8b5d0 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/package-info.java @@ -0,0 +1,9 @@ +/** + * DAL = Data Access Layer 数据访问层 + * 1. data object:数据对象 + * 2. redis:Redis 的 CRUD 操作 + * 3. mysql:MySQL 的 CRUD 操作 + * + * 其中,MySQL 的表以 ai1_ 作为前缀 + */ +package cn.iocoder.yudao.module.ai1.dal.mysql; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMapper.java new file mode 100644 index 0000000..0b1c88f --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMapper.java @@ -0,0 +1,40 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.session; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 会话 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1SessionMapper extends BaseMapperX { + + default PageResult selectPage(Ai1SessionPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eq(Ai1SessionDO::getAgentId, reqVO.getAgentId()) + .likeIfPresent(Ai1SessionDO::getTitle, reqVO.getTitle()) + .eqIfPresent(Ai1SessionDO::getUserId, reqVO.getUserId()) + .orderByDesc(Ai1SessionDO::getId)); + } + + default List selectListByAgentIdAndUserId(Long agentId, Long userId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SessionDO::getAgentId, agentId) + .eq(Ai1SessionDO::getUserId, userId) + .orderByDesc(Ai1SessionDO::getId)); + } + + default List selectListByAgentIds(Collection agentIds) { + return selectList(new LambdaQueryWrapperX().in(Ai1SessionDO::getAgentId, agentIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMessageMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMessageMapper.java new file mode 100644 index 0000000..6999c76 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/session/Ai1SessionMessageMapper.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.session; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 会话消息 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1SessionMessageMapper extends BaseMapperX { + + default List selectListBySessionId(Long sessionId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SessionMessageDO::getSessionId, sessionId) + .orderByAsc(Ai1SessionMessageDO::getId)); + } + + /** + * 按编号倒序,取指定消息之前最近的 limit 条 + */ + default List selectListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit) { + return selectList(new QueryWrapperX() + .limitN(limit) + .eq("session_id", sessionId) + .lt("id", id) + .orderByDesc("id")); + } + + default Long selectCountBySessionId(Long sessionId) { + return selectCount(Ai1SessionMessageDO::getSessionId, sessionId); + } + + default int deleteBySessionIds(Collection sessionIds) { + return delete(new LambdaQueryWrapperX().in(Ai1SessionMessageDO::getSessionId, sessionIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillFileMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillFileMapper.java new file mode 100644 index 0000000..acc4645 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillFileMapper.java @@ -0,0 +1,47 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.skill; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 SKILL 内容文件 Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1SkillFileMapper extends BaseMapperX { + + /** + * 按目录优先、排序号、编号升序返回 SKILL 下的全部节点 + */ + default List selectListBySkillId(Long skillId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SkillFileDO::getSkillId, skillId) + .orderByAsc(Ai1SkillFileDO::getType) + .orderByAsc(Ai1SkillFileDO::getSort) + .orderByAsc(Ai1SkillFileDO::getId)); + } + + default List selectListBySkillIdAndParentId(Long skillId, Long parentId) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SkillFileDO::getSkillId, skillId) + .eq(Ai1SkillFileDO::getParentId, parentId)); + } + + default Ai1SkillFileDO selectBySkillIdAndParentIdAndName(Long skillId, Long parentId, String name) { + return selectOne(new LambdaQueryWrapperX() + .eq(Ai1SkillFileDO::getSkillId, skillId) + .eq(Ai1SkillFileDO::getParentId, parentId) + .eq(Ai1SkillFileDO::getName, name)); + } + + default int deleteBySkillIds(Collection skillIds) { + return delete(new LambdaQueryWrapperX().in(Ai1SkillFileDO::getSkillId, skillIds)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillMapper.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillMapper.java new file mode 100644 index 0000000..a5be25b --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/mysql/skill/Ai1SkillMapper.java @@ -0,0 +1,37 @@ +package cn.iocoder.yudao.module.ai1.dal.mysql.skill; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillPageReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * AI1 SKILL Mapper + * + * @author 芋道源码 + */ +@Mapper +public interface Ai1SkillMapper extends BaseMapperX { + + default PageResult selectPage(Ai1SkillPageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .likeIfPresent(Ai1SkillDO::getName, reqVO.getName()) + .eqIfPresent(Ai1SkillDO::getStatus, reqVO.getStatus()) + .orderByAsc(Ai1SkillDO::getId)); + } + + default Ai1SkillDO selectByName(String name) { + return selectOne(Ai1SkillDO::getName, name); + } + + default List selectListByStatus(Integer status) { + return selectList(new LambdaQueryWrapperX() + .eq(Ai1SkillDO::getStatus, status) + .orderByAsc(Ai1SkillDO::getId)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/redis/Ai1RedisKeyConstants.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/redis/Ai1RedisKeyConstants.java new file mode 100644 index 0000000..8a1c01d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/dal/redis/Ai1RedisKeyConstants.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.ai1.dal.redis; + +/** + * AI1 Redis Key 枚举类 + * + * @author 芋道源码 + */ +public interface Ai1RedisKeyConstants { + + /** + * 会话生成任务队列 + *

+ * KEY 格式:ai1:session:tasks + * VALUE 数据类型:Stream 生成任务,字段为 tenantId、messageId、agentId、sessionId、content + *

+ * 所有租户共用一个队列,KEY 不带 tenantId;worker 从任务的 tenantId 字段还原租户上下文 + */ + String SESSION_TASK_STREAM = "ai1:session:tasks"; + + /** + * 会话生成任务队列的消费组 + */ + String SESSION_TASK_GROUP = "ai1-session-workers"; + + /** + * 会话生成任务执行锁,按助手消息编号隔离;使用 Redisson 看门狗续期 + *

+ * KEY 格式:ai1:session:task-lock:{messageId} + */ + String SESSION_TASK_LOCK = "ai1:session:task-lock:%d"; + + /** + * 会话结果流,按助手消息编号隔离 + *

+ * KEY 格式:ai1:session:result:{messageId} + * VALUE 数据类型:Stream 生成事件,字段为 type、data + *

+ * messageId 全局唯一,KEY 不带 tenantId;续传前已按租户、用户校验消息归属 + */ + String SESSION_RESULT_STREAM = "ai1:session:result:%d"; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java new file mode 100644 index 0000000..1bb1b99 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/Ai1ErrorCodeConstants.java @@ -0,0 +1,80 @@ +package cn.iocoder.yudao.module.ai1.enums; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; + +/** + * AI1 错误码枚举类 + *

+ * ai1 系统,使用 1-041-000-000 段 + */ +public interface Ai1ErrorCodeConstants { + + // ========== 供应商 1-041-000-000 ========== + ErrorCode PROVIDER_NOT_EXISTS = new ErrorCode(1_041_000_000, "供应商不存在"); + ErrorCode PROVIDER_DISABLE = new ErrorCode(1_041_000_001, "供应商({}) 已禁用"); + ErrorCode PROVIDER_HAS_MODEL = new ErrorCode(1_041_000_003, "供应商下存在模型,禁止删除"); + ErrorCode PROVIDER_REMOTE_MODEL_LOAD_FAIL = new ErrorCode(1_041_000_004, "模型拉取失败:请检查供应商地址与 API 密钥"); + ErrorCode PROVIDER_REMOTE_MODEL_EMPTY = new ErrorCode(1_041_000_005, "远程未返回可用模型"); + ErrorCode CONFIG_PLACEHOLDER_NOT_RESOLVED = new ErrorCode(1_041_000_006, "配置({})无法解析,请检查环境变量或配置项"); + ErrorCode PROVIDER_HEADERS_INVALID = new ErrorCode(1_041_000_007, "请求 Header 格式不正确或名称重复,请检查供应商配置"); + + // ========== 模型 1-041-001-000 ========== + ErrorCode MODEL_NOT_EXISTS = new ErrorCode(1_041_001_000, "模型不存在"); + ErrorCode MODEL_DISABLE = new ErrorCode(1_041_001_001, "模型({}) 已禁用"); + ErrorCode MODEL_DUPLICATE = new ErrorCode(1_041_001_002, "模型标识({}) 已存在"); + ErrorCode MODEL_USED_BY_AGENT = new ErrorCode(1_041_001_003, "模型已被 Agent 使用,禁止删除"); + ErrorCode MODEL_NOT_BELONG_PROVIDER = new ErrorCode(1_041_001_004, "模型不属于该供应商"); + ErrorCode MODEL_TYPE_NOT_CHAT = new ErrorCode(1_041_001_005, "所选模型不是对话模型"); + ErrorCode MODEL_TYPE_NOT_EMBEDDING = new ErrorCode(1_041_001_006, "所选模型不是嵌入模型"); + ErrorCode MODEL_IMPORT_ALL_EXISTS = new ErrorCode(1_041_001_007, "所选模型均已导入,无需重复导入"); + ErrorCode MODEL_USED_BY_KNOWLEDGE_BASE = new ErrorCode(1_041_001_008, "模型已被知识库使用,禁止删除"); + ErrorCode MODEL_TYPE_USED_BY_AGENT = new ErrorCode(1_041_001_009, "模型已被 Agent 使用,禁止修改类型"); + ErrorCode MODEL_TYPE_USED_BY_KNOWLEDGE_BASE = new ErrorCode(1_041_001_010, "模型已被知识库使用,禁止修改类型"); + + // ========== 知识库 1-041-002-000 ========== + ErrorCode KNOWLEDGE_BASE_NOT_EXISTS = new ErrorCode(1_041_002_000, "知识库不存在"); + ErrorCode KNOWLEDGE_BASE_DOCUMENT_EMPTY = new ErrorCode(1_041_002_001, "知识库下暂无文档"); + ErrorCode KNOWLEDGE_BASE_SEARCH_FAIL = new ErrorCode(1_041_002_002, "向量检索失败:{}"); + ErrorCode KNOWLEDGE_BASE_USED_BY_AGENT = new ErrorCode(1_041_002_003, "知识库已被 Agent 使用,禁止删除"); + + // ========== 知识文档 1-041-003-000 ========== + ErrorCode KNOWLEDGE_DOCUMENT_NOT_EXISTS = new ErrorCode(1_041_003_000, "文档不存在"); + ErrorCode KNOWLEDGE_DOCUMENT_CONTENT_EMPTY = new ErrorCode(1_041_003_001, "文档内容为空,无法向量化"); + ErrorCode KNOWLEDGE_DOCUMENT_FILE_TYPE_INVALID = new ErrorCode(1_041_003_002, "仅支持 .txt / .md 文本文件"); + ErrorCode KNOWLEDGE_DOCUMENT_FILE_EMPTY = new ErrorCode(1_041_003_003, "文件内容为空"); + ErrorCode KNOWLEDGE_DOCUMENT_FILE_READ_FAIL = new ErrorCode(1_041_003_004, "文件读取失败:{}"); + ErrorCode KNOWLEDGE_DOCUMENT_VECTORIZE_FAIL = new ErrorCode(1_041_003_005, "向量化失败:{}"); + + // ========== MCP 1-041-004-000 ========== + ErrorCode MCP_NOT_EXISTS = new ErrorCode(1_041_004_000, "MCP 不存在"); + ErrorCode MCP_CONFIG_INVALID = new ErrorCode(1_041_004_001, "config 配置需为合法的 JSON 对象"); + ErrorCode MCP_COMMAND_REQUIRED = new ErrorCode(1_041_004_002, "本地(stdio)类型必须在 config 中配置 command 命令"); + ErrorCode MCP_URL_REQUIRED = new ErrorCode(1_041_004_003, "远程(http)类型必须配置服务地址"); + + // ========== SKILL 1-041-005-000 ========== + ErrorCode SKILL_NOT_EXISTS = new ErrorCode(1_041_005_000, "SKILL 不存在"); + ErrorCode SKILL_USED_BY_AGENT = new ErrorCode(1_041_005_001, "SKILL 已被 Agent 使用,禁止删除"); + ErrorCode SKILL_NAME_DUPLICATE = new ErrorCode(1_041_005_002, "SKILL 名称({}) 已存在"); + + // ========== SKILL 内容文件 1-041-006-000 ========== + ErrorCode SKILL_FILE_NOT_EXISTS = new ErrorCode(1_041_006_000, "文件或目录不存在"); + ErrorCode SKILL_FILE_NAME_INVALID = new ErrorCode(1_041_006_001, "名称格式不合法(不能包含路径分隔符、控制字符,且不能为 . 或 ..)"); + ErrorCode SKILL_FILE_NAME_DUPLICATE = new ErrorCode(1_041_006_002, "同级下已存在同名节点({})"); + ErrorCode SKILL_FILE_PARENT_INVALID = new ErrorCode(1_041_006_003, "父节点必须是同一 SKILL 下的目录"); + ErrorCode SKILL_FILE_LOCKED = new ErrorCode(1_041_006_004, "固定文件或目录禁止{}"); + ErrorCode SKILL_FILE_MOVE_TO_SELF = new ErrorCode(1_041_006_005, "不能移入自身"); + ErrorCode SKILL_FILE_NOT_FILE = new ErrorCode(1_041_006_006, "目录不支持保存内容"); + ErrorCode SKILL_FILE_MOVE_TO_DESCENDANT = new ErrorCode(1_041_006_007, "不能移入自身的子目录"); + ErrorCode SKILL_FILE_PARENT_NOT_EXISTS = new ErrorCode(1_041_006_008, "父目录不存在"); + + // ========== Agent 1-041-007-000 ========== + ErrorCode AGENT_NOT_EXISTS = new ErrorCode(1_041_007_000, "Agent 不存在"); + ErrorCode AGENT_DISABLE = new ErrorCode(1_041_007_001, "Agent({}) 已关闭"); + + // ========== 会话 1-041-008-000 ========== + ErrorCode SESSION_NOT_EXISTS = new ErrorCode(1_041_008_000, "会话不存在"); + + // ========== 会话消息 1-041-009-000 ========== + ErrorCode SESSION_MESSAGE_NOT_EXISTS = new ErrorCode(1_041_009_000, "会话消息不存在"); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/knowledge/Ai1KnowledgeDocumentStatusEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/knowledge/Ai1KnowledgeDocumentStatusEnum.java new file mode 100644 index 0000000..b8fda48 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/knowledge/Ai1KnowledgeDocumentStatusEnum.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.ai1.enums.knowledge; + +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 知识文档向量化状态的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1KnowledgeDocumentStatusEnum implements ArrayValuable { + + UNPROCESSED(0, "未处理"), + VECTORIZED(1, "已向量化"), + FAILED(2, "失败"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(Ai1KnowledgeDocumentStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/mcp/Ai1McpTransportEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/mcp/Ai1McpTransportEnum.java new file mode 100644 index 0000000..f9da8e7 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/mcp/Ai1McpTransportEnum.java @@ -0,0 +1,44 @@ +package cn.iocoder.yudao.module.ai1.enums.mcp; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 MCP 传输方式的枚举 + * + * 取值与 MCP 配置 JSON 中的 transport 字段一致 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1McpTransportEnum implements ArrayValuable { + + HTTP("http", "远程(Streamable HTTP)"), + STDIO("stdio", "本地进程(stdio)"); + + public static final String[] ARRAYS = Arrays.stream(values()).map(Ai1McpTransportEnum::getTransport).toArray(String[]::new); + + /** + * 传输方式 + */ + private final String transport; + /** + * 名称 + */ + private final String name; + + @Override + public String[] array() { + return ARRAYS; + } + + public static boolean isStdio(String transport) { + return ObjUtil.equal(STDIO.transport, transport); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/model/Ai1ModelTypeEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/model/Ai1ModelTypeEnum.java new file mode 100644 index 0000000..d26c5a6 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/model/Ai1ModelTypeEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.ai1.enums.model; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 模型类型的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1ModelTypeEnum implements ArrayValuable { + + CHAT(0, "对话"), + EMBEDDING(1, "嵌入"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(Ai1ModelTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isChat(Integer type) { + return ObjUtil.equal(CHAT.type, type); + } + + public static boolean isEmbedding(Integer type) { + return ObjUtil.equal(EMBEDDING.type, type); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageRoleEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageRoleEnum.java new file mode 100644 index 0000000..2597969 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageRoleEnum.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.ai1.enums.session; + +import cn.hutool.core.util.ObjUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * AI1 会话消息角色的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1SessionMessageRoleEnum { + + USER("user", "用户"), + ASSISTANT("assistant", "助手"); + + /** + * 角色 + */ + private final String role; + /** + * 名称 + */ + private final String name; + + public static boolean isUser(String role) { + return ObjUtil.equal(USER.role, role); + } + + public static boolean isAssistant(String role) { + return ObjUtil.equal(ASSISTANT.role, role); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageStatusEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageStatusEnum.java new file mode 100644 index 0000000..e545c98 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionMessageStatusEnum.java @@ -0,0 +1,43 @@ +package cn.iocoder.yudao.module.ai1.enums.session; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 会话消息生成状态的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1SessionMessageStatusEnum implements ArrayValuable { + + GENERATING(0, "生成中"), // 助手消息占位,可按消息编号续传 + SUCCESS(1, "完成"), + FAILED(2, "失败"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(Ai1SessionMessageStatusEnum::getStatus).toArray(Integer[]::new); + + /** + * 状态 + */ + private final Integer status; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isGenerating(Integer status) { + return ObjUtil.equal(GENERATING.status, status); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionStreamEventEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionStreamEventEnum.java new file mode 100644 index 0000000..b11cd60 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/session/Ai1SessionStreamEventEnum.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.ai1.enums.session; + +import cn.hutool.core.util.ObjUtil; +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * AI1 会话流事件的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1SessionStreamEventEnum { + + STREAM("stream", "结果流标识"), // 连接建立时下发,data 为助手消息编号 + THINKING("thinking", "思考过程增量"), + MESSAGE("message", "回复内容增量"), + PING("ping", "空闲心跳"), + DONE("done", "生成结束"), + ERROR("error", "生成失败"); // data 为错误提示 + + /** + * 事件 + */ + private final String event; + /** + * 名称 + */ + private final String name; + + public static boolean isTerminal(String event) { + return ObjUtil.equal(DONE.event, event) || ObjUtil.equal(ERROR.event, event); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/skill/Ai1SkillFileTypeEnum.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/skill/Ai1SkillFileTypeEnum.java new file mode 100644 index 0000000..73fa785 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/enums/skill/Ai1SkillFileTypeEnum.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.ai1.enums.skill; + +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * AI1 SKILL 内容节点类型的枚举 + * + * @author 芋道源码 + */ +@Getter +@AllArgsConstructor +public enum Ai1SkillFileTypeEnum implements ArrayValuable { + + DIRECTORY(0, "目录"), + FILE(1, "文件"); + + public static final Integer[] ARRAYS = Arrays.stream(values()).map(Ai1SkillFileTypeEnum::getType).toArray(Integer[]::new); + + /** + * 类型 + */ + private final Integer type; + /** + * 名称 + */ + private final String name; + + @Override + public Integer[] array() { + return ARRAYS; + } + + public static boolean isDirectory(Integer type) { + return ObjUtil.equal(DIRECTORY.type, type); + } + + public static boolean isFile(Integer type) { + return ObjUtil.equal(FILE.type, type); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/Ai1AutoConfiguration.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/Ai1AutoConfiguration.java new file mode 100644 index 0000000..af986cd --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/Ai1AutoConfiguration.java @@ -0,0 +1,16 @@ +package cn.iocoder.yudao.module.ai1.framework.ai.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +/** + * 芋道 AI1 自动配置 + * + * 模型、向量存储、MCP 客户端均由 framework/ai/core 与 tool 层按数据库配置程序化构建,这里只负责注册 {@link YudaoAi1Properties} + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(YudaoAi1Properties.class) +public class Ai1AutoConfiguration { +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/YudaoAi1Properties.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/YudaoAi1Properties.java new file mode 100644 index 0000000..d36d982 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/config/YudaoAi1Properties.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.ai1.framework.ai.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 芋道 AI1 配置类 + * + * @author 芋道源码 + */ +@ConfigurationProperties(prefix = "yudao.ai1") +@Data +public class YudaoAi1Properties { + + /** + * Milvus 向量数据库 + */ + private Milvus milvus = new Milvus(); + + /** + * 会话流 + */ + private Session session = new Session(); + + /** + * SKILL 技能 + */ + private Skill skill = new Skill(); + + @Data + public static class Milvus { + + /** + * 连接地址 + */ + private String uri = "http://127.0.0.1:19530"; + /** + * 用户名 + */ + private String username; + /** + * 密码 + */ + private String password; + /** + * 数据库 + */ + private String database = "default"; + /** + * 知识库集合名前缀,实际集合名为 {collectionPrefix}{知识库编号} + */ + private String collectionPrefix = "knowledge_base_"; + /** + * 默认检索数量,知识库未配置检索数量时使用 + */ + private Integer defaultTopK = 5; + + } + + @Data + public static class Session { + + /** + * 默认系统指令中的 Agent 名称占位符 + */ + public static final String AGENT_NAME_PLACEHOLDER = "{agentName}"; + + /** + * 默认系统指令:Agent 未配置系统指令时使用,{agentName} 占位符替换为 Agent 名称 + */ + private String defaultSystemPrompt = "你是 " + AGENT_NAME_PLACEHOLDER + " 的智能助手。"; + /** + * 结果流 + */ + private Stream stream = new Stream(); + /** + * 生成 worker + */ + private Worker worker = new Worker(); + /** + * SSE 连接 + */ + private Sse sse = new Sse(); + /** + * 上下文历史 + */ + private History history = new History(); + + } + + @Data + public static class Stream { + + /** + * 单次生成 / 单 SSE 连接最长时长,单位:毫秒 + */ + private Long timeoutMs = 180000L; + /** + * 结果流保留时长,单位:秒;决定断线、刷新后可续传的时间窗 + */ + private Long ttlSeconds = 3600L; + + } + + @Data + public static class Worker { + + /** + * 单节点生成 worker 线程数(多实例部署时为每个实例的数量) + */ + private Integer count = 4; + + } + + @Data + public static class Sse { + + /** + * 单节点 SSE 最大并发连接数,超出时拒绝新连接 + */ + private Integer maxConnections = 64; + + } + + @Data + public static class History { + + /** + * 附加给模型的最近历史消息条数上限 + */ + private Integer limit = 20; + + } + + @Data + public static class Skill { + + /** + * 技能物化根目录,实际目录为 {root}/agent_{agentId}(Agent 编号全局唯一,无需按租户分层); + * 默认放在用户目录下,集群部署建议挂载共享持久卷 + */ + private String root = System.getProperty("user.home") + "/yudao-ai1/skills"; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/package-info.java new file mode 100644 index 0000000..44f4a1f --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/ai/package-info.java @@ -0,0 +1,4 @@ +/** + * ai1 模块的 ai 配置 + */ +package cn.iocoder.yudao.module.ai1.framework.ai; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/package-info.java new file mode 100644 index 0000000..04ba873 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/package-info.java @@ -0,0 +1,6 @@ +/** + * 属于 ai1 模块的 framework 封装 + * + * @author 芋道源码 + */ +package cn.iocoder.yudao.module.ai1.framework; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/config/Ai1WebConfiguration.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/config/Ai1WebConfiguration.java new file mode 100644 index 0000000..fad9d74 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/config/Ai1WebConfiguration.java @@ -0,0 +1,24 @@ +package cn.iocoder.yudao.module.ai1.framework.web.config; + +import cn.iocoder.yudao.framework.swagger.config.YudaoSwaggerAutoConfiguration; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * ai1 模块的 web 组件的 Configuration + * + * @author 芋道源码 + */ +@Configuration(proxyBeanMethods = false) +public class Ai1WebConfiguration { + + /** + * ai1 模块的 API 分组 + */ + @Bean + public GroupedOpenApi ai1GroupedOpenApi() { + return YudaoSwaggerAutoConfiguration.buildGroupedOpenApi("ai1"); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/package-info.java new file mode 100644 index 0000000..955c3f1 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/framework/web/package-info.java @@ -0,0 +1,4 @@ +/** + * ai1 模块的 web 配置 + */ +package cn.iocoder.yudao.module.ai1.framework.web; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java new file mode 100644 index 0000000..ef1dace --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatTool.java @@ -0,0 +1,151 @@ +package cn.iocoder.yudao.module.ai1.harness.llm; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import lombok.AllArgsConstructor; +import lombok.Data; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +/** + * AI1 LLM 会话工具 + * + * 只负责「按给定模型与消息执行一次流式会话」:接收已装配好的系统指令、历史消息、工具与 Advisor, + * 经 ChatClient 流式会话,增量通过回调输出;传输方式(SSE、Redis Stream)由调用方决定 + * + * @author 芋道源码 + */ +@Component +public class Ai1LlmChatTool { + + /** + * 思考过程的元数据键(Spring AI OpenAI 推理模型的 reasoning_content) + */ + private static final String METADATA_REASONING = "reasoningContent"; + + @Resource + private Ai1LlmModelFactory llmModelFactory; + + /** + * 流式会话:装配消息 → 附加工具、Advisor → 增量输出 + * + * @param model 模型运行时快照(需为对话模型) + * @param systemPrompt 系统指令,可为空 + * @param histories 历史消息,按时间升序;每项为 [role, content] + * @param content 当前用户消息 + * @param tools 工具(ToolCallback 或 @Tool 对象),可为空 + * @param advisors 会话 Advisor,可为空 + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符 + * @param onThinking 思考过程增量回调,可为空 + * @param onContent 回复内容增量回调,可为空 + * @return 完整回复(内容 + 思考过程) + */ + public ChatText chat(Ai1ModelRespBO model, String systemPrompt, List histories, String content, + List tools, List advisors, Long sessionId, + Consumer onThinking, Consumer onContent) { + // 1. 装配会话请求:消息(系统指令 + 历史 + 当前)+ 工具 + Advisor + ChatClient.ChatClientRequestSpec spec = llmModelFactory.buildChatClient(model, sessionId).prompt() + .messages(buildMessages(systemPrompt, histories, content)); + if (CollUtil.isNotEmpty(tools)) { + // Spring AI 1.x 的 tools 只接受 @Tool 对象,MCP / SkillsTool 的回调需直接注册。 + List callbacks = new ArrayList<>(); + for (Object tool : tools) { + if (tool instanceof ToolCallback callback) { + callbacks.add(callback); + } else { + callbacks.addAll(List.of(ToolCallbacks.from(tool))); + } + } + spec = spec.toolCallbacks(callbacks); + } + if (CollUtil.isNotEmpty(advisors)) { + spec = spec.advisors(advisors); + } + + // 2. 流式执行,逐段回调 + StringBuilder contentText = new StringBuilder(); + StringBuilder thinkingText = new StringBuilder(); + for (ChatResponse chatResponse : spec.stream().chatResponse().toIterable()) { + Generation generation = chatResponse.getResult(); + // 部分供应商在流末尾下发只含 usage、没有输出的分片,跳过避免 NPE + if (generation == null || generation.getOutput() == null) { + continue; + } + // 2.1 思考过程:Spring AI 1.x 的 reasoning_content 是增量分片,直接转发(重复文本也可能是有效增量) + Object reasoningValue = generation.getOutput().getMetadata().get(METADATA_REASONING); + String delta = reasoningValue instanceof String value ? value : null; + if (StrUtil.isNotBlank(delta)) { + thinkingText.append(delta); + if (onThinking != null) { + onThinking.accept(delta); + } + } + // 2.2 回复内容 + String chunk = generation.getOutput().getText(); + if (StrUtil.isNotBlank(chunk)) { + contentText.append(chunk); + if (onContent != null) { + onContent.accept(chunk); + } + } + } + return new ChatText(contentText.toString(), thinkingText.toString()); + } + + /** + * 消息装配:系统指令 + 历史 + 当前用户消息;推理模型的思考过程不进入上下文 + */ + private static List buildMessages(String systemPrompt, List histories, String content) { + List messages = new ArrayList<>(); + if (StrUtil.isNotBlank(systemPrompt)) { + messages.add(new SystemMessage(systemPrompt)); + } + if (CollUtil.isNotEmpty(histories)) { + for (String[] history : histories) { + String text = StrUtil.nullToEmpty(history[1]); + if (Ai1SessionMessageRoleEnum.isUser(history[0])) { + messages.add(new UserMessage(text)); + } else if (Ai1SessionMessageRoleEnum.isAssistant(history[0])) { + messages.add(new AssistantMessage(text)); + } + } + } + messages.add(new UserMessage(content)); + return messages; + } + + /** + * LLM 会话结果 + */ + @Data + @AllArgsConstructor + public static class ChatText { + + /** + * 回复内容 + */ + private String content; + /** + * 思考过程,推理模型才有 + */ + private String thinking; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java new file mode 100644 index 0000000..6e0645c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactory.java @@ -0,0 +1,237 @@ +package cn.iocoder.yudao.module.ai1.harness.llm; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; + +import java.net.URI; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Predicate; + +/** + * AI1 LLM 模型工厂 + * + * 按 {@link Ai1ModelRespBO} 程序化构建 Spring AI 模型: + * 1. 对话模型:OpenAiChatModel(OpenAI 兼容协议,覆盖 DeepSeek、GLM、Ollama(/v1) 等) + * 2. 嵌入模型:OpenAiEmbeddingModel(向量化) + * + * 模型按 providerId:modelId 缓存复用(编号全局唯一,无需再区分租户),缓存使用 Guava Cache 按容量淘汰最久未访问的模型; + * Provider、模型的配置变更时,由对应 Service 调用 {@link #evictByProviderId}、{@link #evictByModelId} 显式失效 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1LlmModelFactory { + + /** + * 请求 Header 会话占位符:构建模型时替换为当前会话编号 + */ + public static final String SESSION_PLACEHOLDER = "{session}"; + /** + * 缓存上限,达到后逐出最久未访问的模型 + */ + private static final int CACHE_MAX = 128; + + /** + * 缓存写入后的过期时间:修改 Provider / 模型时只能失效本节点,其他节点最迟在该时间后按新配置重建 + */ + private static final Duration CACHE_EXPIRE = Duration.ofMinutes(10); + + /** + * 缓存 key 分隔符 + */ + private static final String KEY_SEPARATOR = ":"; + + /** + * 对话模型缓存:providerId:modelId:sessionKey → ChatModel + */ + private final Cache chatModelCache = CacheBuilder.newBuilder() + .maximumSize(CACHE_MAX).expireAfterWrite(CACHE_EXPIRE).build(); + /** + * 嵌入模型缓存:providerId:modelId → EmbeddingModel + */ + private final Cache embeddingModelCache = CacheBuilder.newBuilder() + .maximumSize(CACHE_MAX).expireAfterWrite(CACHE_EXPIRE).build(); + + /** + * 获取(或构建)对话模型,并包装为 ChatClient + * + * @param model 模型运行时快照 + * @param sessionId 会话编号,用于替换请求 Header 中的 {session} 占位符,可为空 + * @return ChatClient + */ + public ChatClient buildChatClient(Ai1ModelRespBO model, Long sessionId) { + return ChatClient.builder(getOrCreateChatModel(model, sessionId)).build(); + } + + /** + * 获取(或构建)嵌入模型 + * + * @param model 模型运行时快照 + * @return 嵌入模型 + */ + public EmbeddingModel getOrCreateEmbeddingModel(Ai1ModelRespBO model) { + return embeddingModelCache.asMap().computeIfAbsent(buildCacheKey(model), key -> { + OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder() + .model(model.getModel()) + .build(); + log.debug("[getOrCreateEmbeddingModel][providerId({}) modelId({}) 嵌入模型构建完成]", model.getProviderId(), model.getModelId()); + return new OpenAiEmbeddingModel(buildOpenAiApi(model, null), MetadataMode.EMBED, options); + }); + } + + /** + * 失效指定 Provider 下的全部模型缓存(Provider 的地址、密钥、Header、状态变更或删除时调用) + * + * @param providerId Provider 编号 + */ + public void evictByProviderId(Long providerId) { + String prefix = providerId + KEY_SEPARATOR; + evict(key -> key.startsWith(prefix)); + } + + /** + * 失效指定模型的缓存(模型标识、类型、状态变更或删除时调用) + * + * @param modelId 模型编号 + */ + public void evictByModelId(Long modelId) { + String modelIdText = String.valueOf(modelId); + evict(key -> modelIdText.equals(StrUtil.split(key, KEY_SEPARATOR).get(1))); + } + + /** + * 按 key 条件失效对话模型、嵌入模型缓存 + * + * @param keyMatcher key 匹配条件 + */ + private void evict(Predicate keyMatcher) { + chatModelCache.asMap().keySet().removeIf(keyMatcher); + embeddingModelCache.asMap().keySet().removeIf(keyMatcher); + } + + /** + * 获取(或构建)对话模型 + * + * 仅当请求 Header 使用 {session} 占位符(需按会话隔离)时,才把会话编号纳入缓存 key; + * 否则同一模型跨会话复用,避免每个会话都构建一个模型实例 + */ + private OpenAiChatModel getOrCreateChatModel(Ai1ModelRespBO model, Long sessionId) { + // 1. 计算缓存 key:Header 含 {session} 占位符时按会话隔离,否则跨会话共享 + String sessionKey = usesSessionHeader(model.getHeaders()) && sessionId != null + ? String.valueOf(sessionId) : ""; + String cacheKey = buildCacheKey(model) + KEY_SEPARATOR + sessionKey; + + // 2. 命中缓存直接复用;未命中时按运行时快照构建 OpenAI 兼容对话模型并缓存 + return chatModelCache.asMap().computeIfAbsent(cacheKey, key -> { + OpenAiChatOptions options = OpenAiChatOptions.builder() + .model(model.getModel()) + .build(); + log.debug("[getOrCreateChatModel][providerId({}) modelId({}) 对话模型构建完成]", model.getProviderId(), model.getModelId()); + return OpenAiChatModel.builder().openAiApi(buildOpenAiApi(model, sessionId)).defaultOptions(options).build(); + }); + } + + /** + * Spring AI 1.x 将连接参数放在 OpenAiApi,而非模型 Options;仅在模型构建边界适配。 + * normalizeBaseUrl 已包含 /v1 或供应商的自定义前缀,因此端点不再重复添加 /v1。 + */ + private static OpenAiApi buildOpenAiApi(Ai1ModelRespBO model, Long sessionId) { + MultiValueMap headers = new LinkedMultiValueMap<>(); + buildHeaders(model.getHeaders(), sessionId).forEach(headers::add); + return OpenAiApi.builder() + .baseUrl(normalizeBaseUrl(model.getBaseUrl())) + .apiKey(StrUtil.nullToEmpty(model.getApiKey())) + .headers(headers) + .completionsPath("/chat/completions") + .embeddingsPath("/embeddings") + .build(); + } + + /** + * 构建模型缓存 key:providerId:modelId:配置指纹 + * + * 带上配置指纹:配置变化后新请求自然命中新 key;并发下按旧快照构建的实例只会写入旧 key,不会再被读到 + * + * @param model 模型运行时快照 + * @return 缓存 key + */ + private static String buildCacheKey(Ai1ModelRespBO model) { + return model.getProviderId() + KEY_SEPARATOR + model.getModelId() + KEY_SEPARATOR + + Objects.hash(model.getBaseUrl(), model.getApiKey(), model.getModel(), model.getModelType(), model.getHeaders()); + } + + /** + * 归一化接口地址:去掉结尾斜杠;裸地址(无路径)自动补 /v1, + * 保证连通测试、模型拉取与实际会话访问同一个 OpenAI 兼容端点 + * + * 例如说:Ollama 的 http://127.0.0.1:11434 + * 归一化为 http://127.0.0.1:11434/v1 + * + * @param baseUrl 接口地址 + * @return 归一化后的地址 + */ + public static String normalizeBaseUrl(String baseUrl) { + String url = StrUtil.removeSuffix(StrUtil.trim(baseUrl), "/"); + try { + String path = URI.create(url).getPath(); + if (StrUtil.isBlank(path) || "/".equals(path)) { + url = url + "/v1"; + } + } catch (Exception e) { + // 非法 URL 按原值使用,交由后续请求报错 + log.warn("[normalizeBaseUrl][接口地址({}) 解析失败,按原值使用]", url, e); + } + return url; + } + + /** + * 请求 Header 是否使用 {session} 占位符 + */ + private static boolean usesSessionHeader(Map headers) { + return headers != null && headers.values().stream().anyMatch(value -> StrUtil.contains(value, SESSION_PLACEHOLDER)); + } + + /** + * 构建请求 Header:{session} 占位符替换为会话编号;会话编号为空时,跳过带占位符的 Header + */ + private static Map buildHeaders(Map headers, Long sessionId) { + Map result = new LinkedHashMap<>(); + if (headers == null || headers.isEmpty()) { + return result; + } + for (Map.Entry header : headers.entrySet()) { + String key = header.getKey(); + String value = header.getValue(); + if (StrUtil.isBlank(key)) { + continue; + } + if (StrUtil.contains(value, SESSION_PLACEHOLDER)) { + if (sessionId == null) { + continue; + } + value = value.replace(SESSION_PLACEHOLDER, String.valueOf(sessionId)); + } + result.put(key.trim(), StrUtil.nullToEmpty(value)); + } + return result; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientTool.java new file mode 100644 index 0000000..915c5e7 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientTool.java @@ -0,0 +1,456 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpConnectRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.cache.RemovalListener; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.client.transport.ServerParameters; +import io.modelcontextprotocol.client.transport.StdioClientTransport; +import io.modelcontextprotocol.client.transport.customizer.McpSyncHttpClientRequestCustomizer; +import io.modelcontextprotocol.json.McpJsonDefaults; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.annotation.PreDestroy; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; + +/** + * AI1 MCP 客户端工具(官方 Java MCP SDK):连接、列举工具、调用工具、连通测试 + * + * 1. 传输层:http(Streamable HTTP)/ stdio(本地进程);传输方式以 {@link Ai1McpDO#getTransport()} 列为准, + * 其余参数取自 config JSON(缺失时回退平铺列) + * 2. 客户端与工具列表按 MCP 编号 + 配置指纹缓存,配置变化时重建并关闭旧连接;闲置超时自动关闭 + * + * 注意:stdio 会在服务器上启动本地进程,与源工程行为一致,多租户不可信部署需自行评估 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1McpClientTool { + + /** + * 远程服务默认的消息端点 + */ + private static final String DEFAULT_HTTP_ENDPOINT = "/mcp"; + /** + * 缓存 key 分隔符 + */ + private static final String KEY_SEPARATOR = ":"; + /** + * 缓存的闲置过期时间:超过该时间未使用的客户端自动关闭,释放连接与子进程 + * + * 多节点下,其他节点删除 MCP 后,本节点的残留客户端也依赖该过期释放 + */ + private static final Duration CACHE_EXPIRE_AFTER_ACCESS = Duration.ofMinutes(30); + + /** + * 客户端缓存:MCP 编号:配置指纹 → 客户端 + * + * key 带上配置指纹,配置变化后自然命中不到旧客户端,不需要额外的缓存项对象 + */ + private final Cache clientCache = CacheBuilder.newBuilder() + .expireAfterAccess(CACHE_EXPIRE_AFTER_ACCESS) + .removalListener((RemovalListener) notification -> closeQuietly(notification.getValue())) + .build(); + /** + * 工具列表缓存:MCP 编号:配置指纹 → 工具列表 + */ + private final Cache> toolsCache = CacheBuilder.newBuilder() + .expireAfterAccess(CACHE_EXPIRE_AFTER_ACCESS) + .build(); + /** + * 按 MCP 串行建连的锁,避免不同服务的初始化互相阻塞 + */ + private final Map clientLocks = new ConcurrentHashMap<>(); + + /** + * 获取 MCP 服务暴露的工具列表(带缓存,配置变化自动重建) + * + * 直接返回 SDK 的工具定义(含名称、描述、入参 JSON Schema),仅供 {@link Ai1McpToolFactory} 桥接 ToolCallback 使用 + * + * @param mcp MCP 服务 + * @return 工具列表;列举失败时返回空列表 + */ + public List listTools(Ai1McpDO mcp) { + // 1. 命中缓存:配置指纹一致时,直接返回 + String cacheKey = buildCacheKey(mcp); + List cached = toolsCache.getIfPresent(cacheKey); + if (cached != null) { + return cached; + } + + // 2. 调用 tools/list 拉取工具列表;失败时记录日志,按空列表处理 + try { + McpSchema.ListToolsResult result = getOrCreateClient(mcp).listTools(); + List tools = result != null && result.tools() != null + ? result.tools() : Collections.emptyList(); + // 3. 仅缓存成功的结果(包含服务确实没有工具的空列表),失败后允许下次会话重试 + toolsCache.put(cacheKey, tools); + return tools; + } catch (Exception e) { + log.warn("[listTools][MCP({}/{}) tools/list 失败]", mcp.getId(), mcp.getName(), e); + return Collections.emptyList(); + } + } + + /** + * 调用 MCP 工具(阻塞),返回文本结果 + * + * @param mcp MCP 服务 + * @param toolName 工具名称(MCP 服务侧的原始名称) + * @param arguments 工具参数 + * @return 文本结果 + */ + public String callTool(Ai1McpDO mcp, String toolName, Map arguments) { + McpSyncClient client = getOrCreateClient(mcp); + // 同一客户端的调用串行执行,避免 stdio 管道并发读写错乱 + synchronized (client) { + McpSchema.CallToolRequest request = McpSchema.CallToolRequest.builder(toolName) + .arguments(MapUtil.emptyIfNull(arguments)).build(); + McpSchema.CallToolResult result; + try { + result = client.callTool(request); + } catch (McpError e) { + // 服务端 JSON-RPC 业务错误不表示连接失效 + throw e; + } catch (Exception e) { + String cacheKey = buildCacheKey(mcp); + synchronized (clientLocks.computeIfAbsent(mcp.getId(), key -> new Object())) { + // 只清理本次失败的客户端,避免旧调用误删已经重建的连接 + if (clientCache.asMap().remove(cacheKey, client)) { + toolsCache.invalidate(cacheKey); + } + } + // 不自动重跑有副作用的工具;下次调用重新建连 + throw e; + } + return readText(result); + } + } + + /** + * 连通测试:initialize + tools/list,使用临时客户端,不影响缓存 + * + * @param mcp MCP 服务 + * @return 测试结果 + */ + public Ai1McpConnectRespVO testConnect(Ai1McpDO mcp) { + long startTime = System.currentTimeMillis(); + McpSyncClient client = null; + try { + // 1. 建立连接 + McpClientTransport transport = buildTransport(mcp); + if (transport == null) { + return buildConnectFailResult("配置不完整(缺少必要参数)", startTime); + } + client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(30)) + .initializationTimeout(Duration.ofSeconds(15)) + .build(); + client.initialize(); + + // 2. 读取服务信息与工具列表 + Ai1McpConnectRespVO result = new Ai1McpConnectRespVO().setConnectable(true) + .setInstructions(client.getServerInstructions()); + McpSchema.Implementation serverInfo = client.getServerInfo(); + if (serverInfo != null) { + result.setServerName(serverInfo.name()).setServerVersion(serverInfo.version()); + } + McpSchema.ListToolsResult listResult = client.listTools(); + result.setTools(convertList(listResult != null ? listResult.tools() : null, tool -> new Ai1McpConnectRespVO.Tool() + .setName(tool.name()).setTitle(tool.title()).setDescription(tool.description()))); + return result.setToolCount(result.getTools().size()) + .setMessage("连接成功,发现 " + result.getTools().size() + " 个工具") + .setElapsedMs(System.currentTimeMillis() - startTime); + } catch (Exception e) { + log.warn("[testConnect][MCP({}/{}) 连通测试失败]", mcp.getId(), mcp.getName(), e); + return buildConnectFailResult(String.valueOf(e.getMessage()), startTime); + } finally { + closeQuietly(client); + } + } + + /** + * 移除并关闭指定 MCP 的客户端(删除、配置变更时调用,释放子进程与连接线程) + * + * @param mcpId MCP 编号 + */ + public void evict(Long mcpId) { + String prefix = mcpId + KEY_SEPARATOR; + toolsCache.asMap().keySet().removeIf(key -> key.startsWith(prefix)); + clientCache.asMap().keySet().removeIf(key -> key.startsWith(prefix)); + } + + /** + * 移除并关闭指定 MCP 除当前指纹外的客户端与工具缓存 + */ + private void evictExcept(Long mcpId, String currentKey) { + String prefix = mcpId + KEY_SEPARATOR; + toolsCache.asMap().keySet().removeIf(key -> key.startsWith(prefix) && !key.equals(currentKey)); + clientCache.asMap().keySet().removeIf(key -> key.startsWith(prefix) && !key.equals(currentKey)); + } + + @PreDestroy + public void destroy() { + toolsCache.invalidateAll(); + clientCache.invalidateAll(); + } + + // ==================== 连接 / 传输 ==================== + + /** + * 获取(或构建)MCP 客户端:配置指纹变化时重建,旧连接由缓存的 RemovalListener 关闭 + */ + private McpSyncClient getOrCreateClient(Ai1McpDO mcp) { + // 1. 命中缓存:配置指纹一致时,直接复用 + String cacheKey = buildCacheKey(mcp); + McpSyncClient cached = clientCache.getIfPresent(cacheKey); + if (cached != null) { + return cached; + } + synchronized (clientLocks.computeIfAbsent(mcp.getId(), key -> new Object())) { + cached = clientCache.getIfPresent(cacheKey); + if (cached != null) { + return cached; + } + // 2. 配置变化:构建并初始化新客户端,成功后再释放旧指纹的客户端(与源码一致,避免打断在途调用) + McpClientTransport transport = buildTransport(mcp); + if (transport == null) { + throw new IllegalStateException("MCP(" + mcp.getName() + ") 配置不完整(缺少必要参数)"); + } + McpSyncClient client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(60)) + .initializationTimeout(Duration.ofSeconds(15)) + .build(); + try { + client.initialize(); + } catch (Exception e) { + // 初始化失败的客户端没有进入缓存,必须主动释放连接或 stdio 子进程 + closeQuietly(client); + throw e; + } + clientCache.put(cacheKey, client); + evictExcept(mcp.getId(), cacheKey); + return client; + } + } + + /** + * 构建传输层:stdio 走本地进程,其余走 Streamable HTTP;参数缺失时返回 null + */ + private McpClientTransport buildTransport(Ai1McpDO mcp) { + McpConfig config = resolveConfig(parseConfig(mcp)); + try { + return Ai1McpTransportEnum.isStdio(config.getTransport()) ? buildStdioTransport(config) : buildHttpTransport(config); + } catch (ServiceException ex) { + throw ex; + } catch (Exception e) { + log.warn("[buildTransport][MCP({}) 传输层构建失败]", mcp.getId(), e); + return null; + } + } + + /** + * Streamable HTTP 传输:按「base = scheme://host:port、endpoint = raw path + query」构建, + * 保证配置的服务地址精确作为 MCP 消息端点(避免 resolve("/mcp") 覆盖路径导致 404) + */ + private McpClientTransport buildHttpTransport(McpConfig config) { + if (StrUtil.isBlank(config.getUrl())) { + return null; + } + String baseUri = config.getUrl(); + String endpoint = DEFAULT_HTTP_ENDPOINT; + try { + URI uri = URI.create(config.getUrl()); + if (uri.getScheme() != null && uri.getAuthority() != null) { + baseUri = uri.getScheme() + "://" + uri.getAuthority(); + endpoint = StrUtil.isEmpty(uri.getRawPath()) ? DEFAULT_HTTP_ENDPOINT : uri.getRawPath(); + if (uri.getRawQuery() != null) { + endpoint += "?" + uri.getRawQuery(); + } + } + } catch (IllegalArgumentException e) { + log.warn("[buildHttpTransport][服务地址({}) 解析失败,按原值使用]", config.getUrl(), e); + } + HttpClientStreamableHttpTransport.Builder builder = HttpClientStreamableHttpTransport.builder(baseUri) + .endpoint(endpoint) + .customizeClient(clientBuilder -> clientBuilder.connectTimeout(Duration.ofSeconds(10))); + if (MapUtil.isNotEmpty(config.getHeaders())) { + builder.httpRequestCustomizer(buildHeaderCustomizer(config.getHeaders())); + } + return builder.build(); + } + + /** + * 本地进程传输:command + args + env + */ + private McpClientTransport buildStdioTransport(McpConfig config) { + if (StrUtil.isBlank(config.getCommand())) { + return null; + } + ServerParameters.Builder params = ServerParameters.builder(config.getCommand()); + if (CollUtil.isNotEmpty(config.getArgs())) { + params.args(config.getArgs()); + } + if (MapUtil.isNotEmpty(config.getEnv())) { + params.env(config.getEnv()); + } + return new StdioClientTransport(params.build(), McpJsonDefaults.getMapper()); + } + + /** + * 请求头自定义器:为 MCP HTTP 请求统一追加 headers + */ + private static McpSyncHttpClientRequestCustomizer buildHeaderCustomizer(Map headers) { + return (builder, method, uri, protocolVersion, context) -> headers.forEach((key, value) -> { + if (StrUtil.isNotEmpty(key) && value != null) { + builder.header(key, value); + } + }); + } + + private static void closeQuietly(McpSyncClient client) { + if (client == null) { + return; + } + try { + client.closeGracefully(); + } catch (Exception e) { + log.warn("[closeQuietly][MCP 客户端关闭失败]", e); + } + } + + // ==================== 配置 / 结果解析 ==================== + + /** + * 解析 MCP 配置:transport 以列为准;url、headers 优先取 config,缺失时回退平铺列 + */ + private static McpConfig parseConfig(Ai1McpDO mcp) { + Map configMap = MapUtil.emptyIfNull(JsonUtils.parseMap(mcp.getConfig())); + McpConfig config = new McpConfig(); + config.setTransport(StrUtil.blankToDefault(mcp.getTransport(), + StrUtil.blankToDefault(MapUtil.getStr(configMap, "transport"), Ai1McpTransportEnum.HTTP.getTransport()))); + config.setUrl(StrUtil.blankToDefault(MapUtil.getStr(configMap, "url"), mcp.getUrl())); + Map headers = parseStringMap(configMap.get("headers")); + config.setHeaders(headers != null ? headers : mcp.getHeaders()); + config.setCommand(MapUtil.getStr(configMap, "command")); + Object args = configMap.get("args"); + config.setArgs(args instanceof List ? Convert.toList(String.class, args) : null); + config.setEnv(parseStringMap(configMap.get("env"))); + return config; + } + + /** + * 调用前解析地址、请求头、命令和环境变量里的 ${ENV},库中的原文不改 + */ + private static McpConfig resolveConfig(McpConfig config) { + config.setUrl(resolveSpringPlaceholders(config.getUrl())); + config.setCommand(resolveSpringPlaceholders(config.getCommand())); + config.setHeaders(resolveSpringPlaceholders(config.getHeaders())); + config.setEnv(resolveSpringPlaceholders(config.getEnv())); + config.setArgs(convertList(config.getArgs(), arg -> resolveSpringPlaceholders(arg))); + return config; + } + + /** + * 字符串 Map 解析(config 中的 headers、env):支持 JSON 对象,或 JSON 对象形式的字符串 + */ + private static Map parseStringMap(Object value) { + if (value instanceof String) { + value = JsonUtils.parseMap((String) value); + } + return value instanceof Map ? MapUtil.removeNullValue(Convert.toMap(String.class, String.class, value)) : null; + } + + /** + * 工具调用结果文本提取:优先文本内容,其次结构化内容 + */ + private static String readText(McpSchema.CallToolResult result) { + if (result == null) { + return ""; + } + String text = CollUtil.join(convertList(result.content(), content -> content instanceof McpSchema.TextContent + ? ((McpSchema.TextContent) content).text() : null), ""); + if (StrUtil.isNotEmpty(text)) { + return text; + } + if (result.structuredContent() != null) { + return JsonUtils.toJsonString(result.structuredContent()); + } + return String.valueOf(result); + } + + /** + * 缓存 key:MCP 编号 + 配置指纹,传输方式、config、平铺列任一变化即得到新的 key + * + * 多节点下,各节点加载到的 MCP 配置变化后指纹不同,自然重建客户端 + */ + private static String buildCacheKey(Ai1McpDO mcp) { + return mcp.getId() + KEY_SEPARATOR + + Objects.hash(mcp.getTransport(), mcp.getConfig(), mcp.getUrl(), mcp.getHeaders()); + } + + /** + * 构建连通测试失败结果 + */ + private static Ai1McpConnectRespVO buildConnectFailResult(String message, long startTime) { + return new Ai1McpConnectRespVO().setConnectable(false).setToolCount(0).setTools(new ArrayList<>()) + .setMessage(message).setElapsedMs(System.currentTimeMillis() - startTime); + } + + /** + * 解析后的 MCP 服务配置 + */ + @Data + private static class McpConfig { + + /** + * 传输方式 + */ + private String transport; + /** + * 服务地址 + */ + private String url; + /** + * 请求头 + */ + private Map headers; + /** + * stdio 命令 + */ + private String command; + /** + * stdio 参数 + */ + private List args; + /** + * stdio 环境变量 + */ + private Map env; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactory.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactory.java new file mode 100644 index 0000000..e53639a --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactory.java @@ -0,0 +1,141 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.ReUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.service.mcp.Ai1McpService; +import io.modelcontextprotocol.spec.McpSchema; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.ai.tool.function.FunctionToolCallback; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.function.Function; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; + +/** + * AI1 MCP 工具工厂:把 Agent 绑定的 MCP 服务暴露的工具,桥接为 Spring AI ToolCallback + * + * 1. 工具名:MCP 名称净化 + "__" + 原始工具名;无有效名称时使用 MCP 编号,超长时保留摘要 + * 2. 入参:透传 MCP 工具的 JSON Schema,调用落地仍走 {@link Ai1McpClientTool} + * + * 工具挂到 ChatClient 后,由 Spring AI 的工具调用机制统一驱动;与 {@link Ai1McpClientTool} 同包 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1McpToolFactory { + + /** + * MCP 名称与工具名称的分隔符 + */ + private static final String NAME_SEPARATOR = "__"; + + @Resource + private Ai1McpService mcpService; + + @Resource + private Ai1McpClientTool mcpClientTool; + + /** + * 构建 Agent 绑定的 MCP 工具集合;单个服务失败时跳过,不影响会话 + * + * tools/list 结果已由 {@link Ai1McpClientTool} 按配置指纹缓存,这里只做 ToolCallback 包装 + * + * @param agent Agent + * @return 工具集合 + */ + public List buildTools(Ai1AgentDO agent) { + List tools = new ArrayList<>(); + if (CollUtil.isEmpty(agent.getMcpIds())) { + return tools; + } + for (Ai1McpDO mcp : mcpService.getMcpList(agent.getMcpIds())) { + if (CommonStatusEnum.isDisable(mcp.getStatus())) { + continue; + } + tools.addAll(convertList(listTools(agent, mcp), tool -> buildToolCallback(mcp, tool))); + } + return tools; + } + + /** + * 列举 MCP 服务暴露的工具;失败时记录日志并返回空列表 + * + * @param agent Agent + * @param mcp MCP 服务 + * @return 工具列表 + */ + private List listTools(Ai1AgentDO agent, Ai1McpDO mcp) { + try { + return mcpClientTool.listTools(mcp); + } catch (Exception e) { + log.warn("[listTools][Agent({}) 加载 MCP({}) 工具失败]", agent.getId(), mcp.getName(), e); + return Collections.emptyList(); + } + } + + /** + * 构建单个工具回调:名称去冲突 + 入参 Schema 透传 + MCP 客户端调用落地 + */ + private ToolCallback buildToolCallback(Ai1McpDO mcp, McpSchema.Tool tool) { + Function, String> function = arguments -> + mcpClientTool.callTool(mcp, tool.name(), arguments); + Map inputSchema = tool.inputSchema(); + if (MapUtil.isEmpty(inputSchema)) { + inputSchema = new HashMap<>(); + inputSchema.put("type", "object"); + inputSchema.put("properties", new HashMap<>()); + } + return FunctionToolCallback.builder(buildToolName(mcp, tool), function) + .description(StrUtil.nullToEmpty(tool.description())) + .inputType(Map.class) + .inputSchema(JsonUtils.toJsonString(inputSchema)) + .build(); + } + + /** + * 构建工具名:无有效 MCP 名称时使用编号;超过 64 字符时截短并追加摘要,避免截断后重名 + * + * @param mcp MCP 服务 + * @param tool MCP 工具 + * @return 工具名 + */ + private static String buildToolName(Ai1McpDO mcp, McpSchema.Tool tool) { + String prefix = slugify(mcp.getName()); + if (StrUtil.isEmpty(prefix)) { + prefix = "mcp_" + mcp.getId(); + } + String name = prefix + NAME_SEPARATOR + tool.name(); + // OpenAI 兼容接口的工具名最多 64 字符,保留 47 字符前缀 + "_" + 16 位摘要。 + // 摘要由 MCP 编号和原始工具名生成,避免不同服务或工具截断后重名。 + if (name.length() > 64) { + String suffix = DigestUtil.sha256Hex(mcp.getId() + ":" + tool.name()).substring(0, 16); + name = name.substring(0, 47) + "_" + suffix; + } + return name; + } + + /** + * 名称净化:只保留小写字母、数字、下划线(工具名需匹配 ^[a-zA-Z0-9_-]+$) + */ + static String slugify(String name) { + if (StrUtil.isBlank(name)) { + return ""; + } + String slug = ReUtil.replaceAll(name.toLowerCase(Locale.ROOT), "[^a-z0-9_]+", "_"); + slug = ReUtil.replaceAll(slug, "_+", "_"); + return ReUtil.replaceAll(slug, "^_+|_+$", ""); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java new file mode 100644 index 0000000..24f7f2d --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderTool.java @@ -0,0 +1,240 @@ +package cn.iocoder.yudao.module.ai1.harness.model; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.http.ContentType; +import cn.hutool.http.Header; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import cn.hutool.http.HttpStatus; +import cn.hutool.http.Method; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_EMPTY; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL; +import static cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory.normalizeBaseUrl; + +/** + * AI1 供应商工具:面向 OpenAI 兼容接口的原始 HTTP 探测 + * + * 1. {@link #testConnect}:GET /models 优先、回退 POST /chat/completions 的连通测试 + * 2. {@link #listModels}:拉取 GET /models 返回的模型标识 + * + * 入参为已解析 ${ENV} 占位符的地址、密钥、Header,不感知供应商的存储与租户归属 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1ProviderTool { + + /** + * 请求的连接超时,单位:毫秒 + */ + private static final int CONNECT_TIMEOUT = 5000; + /** + * 请求的读取超时,单位:毫秒 + */ + private static final int READ_TIMEOUT = 8000; + /** + * 连通测试回退时的最小会话请求体:仅探测服务可达与鉴权,不做真实会话 + */ + private static final String CONNECT_CHAT_BODY = "{\"model\":\"test\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}]}"; + + /** + * 连通测试:GET {baseUrl}/models 优先;失败(认证错误除外)时,回退 POST {baseUrl}/chat/completions + * + * @param baseUrl 接口地址 + * @param apiKey API 密钥 + * @param headers 请求 Header + * @return 测试结果 + */ + public Ai1ProviderConnectRespVO testConnect(String baseUrl, String apiKey, Map headers) { + long startTime = System.currentTimeMillis(); + String url = normalizeBaseUrl(baseUrl); + + // 1. 主校验:GET /models(标准 OpenAI 兼容接口) + HttpOutcome models = executeRequest(Method.GET, url + "/models", apiKey, headers, null); + if (models.getHttpCode() == HttpStatus.HTTP_OK) { + return buildConnectResult(true, HttpStatus.HTTP_OK, "连通正常:GET /models 返回 HTTP 200", startTime); + } + if (isAuthFail(models.getHttpCode())) { + return buildConnectResult(false, models.getHttpCode(), + "认证失败:GET /models 返回 HTTP " + models.getHttpCode() + ",请检查 API 密钥", startTime); + } + + // 2. 回退:POST /chat/completions 最小请求(仅探测服务可达与鉴权,不做真实会话) + HttpOutcome chat = executeRequest(Method.POST, url + "/chat/completions", apiKey, headers, CONNECT_CHAT_BODY); + if (chat.getHttpCode() == HttpStatus.HTTP_OK) { + return buildConnectResult(true, HttpStatus.HTTP_OK, "GET /models 不可用(HTTP " + models.getHttpCode() + + "),回退 POST /chat/completions 连通正常:HTTP 200", startTime); + } + if (isAuthFail(chat.getHttpCode())) { + return buildConnectResult(false, chat.getHttpCode(), + "认证失败:POST /chat/completions 返回 HTTP " + chat.getHttpCode() + ",请检查 API 密钥", startTime); + } + if (chat.getHttpCode() > 0) { + return buildConnectResult(false, chat.getHttpCode(), "服务可达但接口异常:GET /models HTTP " + models.getHttpCode() + + ",POST /chat/completions HTTP " + chat.getHttpCode(), startTime); + } + + // 3. 两次均网络异常:合并报告 + String modelsMessage = StrUtil.isNotBlank(models.getMessage()) ? "GET /models 失败:" + models.getMessage() : "GET /models 无响应"; + String chatMessage = StrUtil.isNotBlank(chat.getMessage()) + ? "POST /chat/completions 失败:" + chat.getMessage() : "POST /chat/completions 无响应"; + return buildConnectResult(false, 0, "连接失败:" + modelsMessage + ";" + chatMessage, startTime); + } + + /** + * 拉取远程模型标识列表:GET {baseUrl}/models,解析 data[].id + * + * @param baseUrl 接口地址 + * @param apiKey API 密钥 + * @param headers 请求 Header + * @return 模型标识列表 + */ + public List listModels(String baseUrl, String apiKey, Map headers) { + // 1. 请求 GET /models:非 HTTP 200 时,提示检查配置 + String url = normalizeBaseUrl(baseUrl) + "/models"; + HttpOutcome outcome = executeRequest(Method.GET, url, apiKey, headers, null); + if (outcome.getHttpCode() != HttpStatus.HTTP_OK) { + log.warn("[listModels][地址({}) 模型拉取失败,httpCode({}) message({})]", url, outcome.getHttpCode(), outcome.getMessage()); + throw exception(PROVIDER_REMOTE_MODEL_LOAD_FAIL); + } + + // 2. 解析 data[].id:解析失败时,提示检查配置;解析结果为空时,提示无可用模型 + List models = parseModels(outcome.getBody()); + if (models == null) { + log.warn("[listModels][地址({}) 模型数据解析失败,body({})]", url, StrUtil.maxLength(outcome.getBody(), 200)); + throw exception(PROVIDER_REMOTE_MODEL_LOAD_FAIL); + } + if (CollUtil.isEmpty(models)) { + throw exception(PROVIDER_REMOTE_MODEL_EMPTY); + } + return models; + } + + /** + * 解析 GET /models 响应的 data[].id + * + * 响应不是 JSON 对象、或 data 存在但不是数组时,视为解析失败(与源码一致) + * + * @param body 响应体 + * @return 模型标识列表;解析失败返回 null + */ + static List parseModels(String body) { + Map root = JsonUtils.parseMap(body); + if (root == null) { + return null; + } + Object data = root.get("data"); + if (data != null && !(data instanceof List)) { + return null; + } + return convertList((List) data, item -> item instanceof Map + ? StrUtil.blankToDefault(MapUtil.getStr((Map) item, "id"), null) : null); + } + + /** + * 执行 OpenAI 兼容接口请求,地址非法、网络异常时记录原因 + * + * @param method 请求方法 + * @param url 请求地址 + * @param apiKey API 密钥 + * @param headers 请求 Header + * @param body JSON 请求体,可为空 + * @return 请求结果 + */ + private static HttpOutcome executeRequest(Method method, String url, String apiKey, + Map headers, String body) { + try { + HttpRequest request = buildOpenAiRequest(method, url, apiKey, headers); + if (body != null) { + request.body(body, ContentType.JSON.getValue()); + } + try (HttpResponse response = request.execute()) { + return new HttpOutcome(response.getStatus(), null, response.body()); + } + } catch (Exception e) { + // HTTP 客户端异常可能包含密钥或 Header 原文,不能直接回传。 + return new HttpOutcome(0, "请求发送失败,请检查服务地址、密钥及 Header(" + + e.getClass().getSimpleName() + ")", null); + } + } + + /** + * 构建 OpenAI 兼容接口的 HTTP 请求:携带 API 密钥与静态 Header,包含 {session} 占位符的 Header 跳过 + * + * @param method 请求方法 + * @param url 请求地址 + * @param apiKey API 密钥 + * @param headers 请求 Header + * @return HTTP 请求 + */ + private static HttpRequest buildOpenAiRequest(Method method, String url, String apiKey, Map headers) { + HttpRequest request = HttpRequest.of(url).method(method) + .setConnectionTimeout(CONNECT_TIMEOUT).setReadTimeout(READ_TIMEOUT) + .header(Header.ACCEPT, ContentType.JSON.getValue()); + if (StrUtil.isNotBlank(apiKey)) { + request.bearerAuth(apiKey); + } + if (MapUtil.isNotEmpty(headers)) { + for (Map.Entry header : headers.entrySet()) { + String key = header.getKey(); + String value = header.getValue(); + if (StrUtil.isBlank(key) || StrUtil.contains(value, Ai1LlmModelFactory.SESSION_PLACEHOLDER)) { + continue; + } + request.header(key.trim(), StrUtil.nullToEmpty(value)); + } + } + return request; + } + + /** + * 认证失败:HTTP 401 未授权、403 禁止访问 + */ + private static boolean isAuthFail(int httpCode) { + return httpCode == HttpStatus.HTTP_UNAUTHORIZED || httpCode == HttpStatus.HTTP_FORBIDDEN; + } + + private static Ai1ProviderConnectRespVO buildConnectResult(boolean connectable, int httpCode, String message, long startTime) { + return new Ai1ProviderConnectRespVO().setConnectable(connectable).setHttpCode(httpCode).setMessage(message) + .setElapsedMs(System.currentTimeMillis() - startTime); + } + + /** + * HTTP 请求结果 + */ + @Data + @AllArgsConstructor + private static class HttpOutcome { + + /** + * HTTP 状态码,0 表示网络异常未收到响应 + */ + private int httpCode; + /** + * 异常信息 + */ + private String message; + /** + * 响应体 + */ + private String body; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/package-info.java new file mode 100644 index 0000000..0722d06 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/package-info.java @@ -0,0 +1,7 @@ +/** + * Agent 运行时支撑: + * + * 这里是平台去调用大模型、MCP 服务、知识库和 SKILL 的客户端与工厂, + * 不是注册给模型调用的 function tool。模型可调用的工具,在会话时由 MCP ToolCallback 和 SKILL 另行装配。 + */ +package cn.iocoder.yudao.module.ai1.harness; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagTool.java new file mode 100644 index 0000000..d5f1e90 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagTool.java @@ -0,0 +1,345 @@ +package cn.iocoder.yudao.module.ai1.harness.rag; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.lang.loader.LazyFunLoader; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeSearchRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import io.milvus.client.MilvusServiceClient; +import io.milvus.param.ConnectParam; +import io.milvus.param.IndexType; +import io.milvus.param.MetricType; +import io.milvus.param.R; +import io.milvus.param.RpcStatus; +import io.milvus.param.collection.DropCollectionParam; +import jakarta.annotation.PreDestroy; +import jakarta.annotation.Resource; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder; +import org.springframework.ai.vectorstore.milvus.MilvusVectorStore; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MODEL_TYPE_NOT_EMBEDDING; + +/** + * AI1 RAG 工具(Spring AI + Milvus) + * + * 知识库的向量化、检索、清理与 Advisor 装配: + * 1. 向量化:分片 → Document(含 documentId、chunkIndex 元数据)→ VectorStore.add(先清理旧向量) + * 2. 检索:VectorStore.similaritySearch + * 3. 清理:按 documentId 元数据删除向量;知识库删除时 drop 整个集合 + * 4. Advisor:为会话装配 QuestionAnswerAdvisor,检索上下文自动注入 + * + * 集合名为 {collectionPrefix}{知识库编号}(编号全局唯一,无需租户前缀);调用方必须先在当前租户下查出知识库,再传入本工具, + * 以此保证不会越权访问其他租户的集合 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1RagTool { + + /** + * 向量存储缓存上限 + */ + private static final int CACHE_MAX = 128; + /** + * 向量元数据:文档编号 + */ + private static final String METADATA_DOCUMENT_ID = "documentId"; + /** + * 向量元数据:分片序号 + */ + private static final String METADATA_CHUNK_INDEX = "chunkIndex"; + + @Resource + private YudaoAi1Properties ai1Properties; + + @Resource + private Ai1ModelService modelService; + + @Resource + private Ai1LlmModelFactory llmModelFactory; + + /** + * 向量存储缓存:知识库编号 → 向量存储;按访问顺序淘汰,嵌入模型实例变化(配置变更后重建)时自动重建 + */ + private final Cache vectorStoreCache = CacheBuilder.newBuilder() + .maximumSize(CACHE_MAX).build(); + /** + * 按知识库串行初始化的锁,避免并发首访重复创建集合和索引 + */ + private final Map vectorStoreLocks = new ConcurrentHashMap<>(); + /** + * Milvus 客户端:首次使用时创建,全局复用一个连接 + */ + private final LazyFunLoader milvusClientLoader = LazyFunLoader.on(this::createMilvusClient); + + /** + * 文档向量化:分片 → 嵌入 → 写入 Milvus;先清理旧向量再写入,支持重复向量化 + * + * @param knowledgeBase 知识库(需已配置嵌入模型) + * @param documentId 文档编号 + * @param content 文档内容 + * @return 分片数量 + */ + public int vectorize(Ai1KnowledgeBaseDO knowledgeBase, Long documentId, String content) { + // 1. 分片 + List chunks = split(content, knowledgeBase.getChunkSize(), knowledgeBase.getChunkOverlap()); + if (CollUtil.isEmpty(chunks)) { + throw new IllegalArgumentException("文档无可分片内容"); + } + + // 2. 清理旧向量后写入 + MilvusVectorStore vectorStore = getOrCreateVectorStore(knowledgeBase); + vectorStore.delete(new FilterExpressionBuilder().eq(METADATA_DOCUMENT_ID, documentId).build()); + List documents = new ArrayList<>(chunks.size()); + for (int i = 0; i < chunks.size(); i++) { + Map metadata = new LinkedHashMap<>(); + metadata.put(METADATA_DOCUMENT_ID, documentId); + metadata.put(METADATA_CHUNK_INDEX, i); + documents.add(new Document(chunks.get(i), metadata)); + } + vectorStore.add(documents); + return chunks.size(); + } + + /** + * 向量检索:按查询文本召回知识库相关分片 + * + * @param knowledgeBase 知识库 + * @param query 查询文本 + * @param topK 检索数量,小于等于 0 时使用知识库配置 + * @return 命中分片;文档名称由调用方按文档编号补充 + */ + public List search(Ai1KnowledgeBaseDO knowledgeBase, String query, Integer topK) { + // 1. 相似度检索:未指定检索数量时,使用知识库配置 + MilvusVectorStore vectorStore = getOrCreateVectorStore(knowledgeBase); + List documents = vectorStore.similaritySearch(SearchRequest.builder() + .query(query).topK(topK != null && topK > 0 ? topK : getDefaultTopK(knowledgeBase)).build()); + + // 2. 转换为命中分片:从向量元数据中还原文档编号、分片序号 + return convertList(documents, document -> new Ai1KnowledgeSearchRespVO().setText(document.getText()) + .setDocumentId(Convert.toLong(document.getMetadata().get(METADATA_DOCUMENT_ID))) + .setChunkIndex(Convert.toInt(document.getMetadata().get(METADATA_CHUNK_INDEX))) + .setScore(ObjUtil.defaultIfNull(document.getScore(), 0D))); + } + + /** + * 按文档编号清理向量(文档删除、内容变更时调用);失败时仅记录日志 + * + * @param knowledgeBase 知识库 + * @param documentIds 文档编号集合 + */ + public void deleteByDocumentIds(Ai1KnowledgeBaseDO knowledgeBase, Collection documentIds) { + if (CollUtil.isEmpty(documentIds)) { + return; + } + try { + getOrCreateVectorStore(knowledgeBase).delete(new FilterExpressionBuilder() + .in(METADATA_DOCUMENT_ID, documentIds.toArray()).build()); + } catch (Exception e) { + log.warn("[deleteByDocumentIds][知识库({}) 文档({}) 向量清理失败]", knowledgeBase.getId(), documentIds, e); + } + } + + /** + * 删除知识库的整个向量集合(知识库删除时调用,避免遗留孤儿集合);失败时仅记录日志 + * + * @param knowledgeBaseId 知识库编号 + */ + public void dropCollection(Long knowledgeBaseId) { + evict(knowledgeBaseId); + try { + R result = milvusClientLoader.get().dropCollection(DropCollectionParam.newBuilder() + .withDatabaseName(ai1Properties.getMilvus().getDatabase()) + .withCollectionName(buildCollectionName(knowledgeBaseId)) + .build()); + if (result.getStatus() != R.Status.Success.getCode()) { + log.warn("[dropCollection][知识库({}) 向量集合删除失败:{}]", knowledgeBaseId, result.getMessage()); + } + } catch (Exception e) { + log.warn("[dropCollection][知识库({}) 向量集合删除异常]", knowledgeBaseId, e); + } + } + + /** + * 为知识库构建 RAG Advisor:检索上下文自动注入会话 + * + * @param knowledgeBase 知识库 + * @return RAG Advisor + */ + public Advisor buildAdvisor(Ai1KnowledgeBaseDO knowledgeBase) { + MilvusVectorStore vectorStore = getOrCreateVectorStore(knowledgeBase); + SearchRequest searchRequest = SearchRequest.builder().topK(getDefaultTopK(knowledgeBase)).build(); + return QuestionAnswerAdvisor.builder(vectorStore).searchRequest(searchRequest).build(); + } + + /** + * 失效知识库的向量存储缓存 + * + * 只作用于当前节点;其他节点在嵌入模型变更后,会因嵌入模型实例不一致自然重建 + * + * @param knowledgeBaseId 知识库编号 + */ + public void evict(Long knowledgeBaseId) { + vectorStoreCache.invalidate(knowledgeBaseId); + } + + @PreDestroy + public void destroy() { + milvusClientLoader.ifInitialized(MilvusServiceClient::close); + } + + // ==================== 内部实现:嵌入模型 / 向量存储 / 分片 ==================== + + /** + * 获取(或构建)知识库向量存储:嵌入模型实例不变时复用;Provider、模型配置变更后模型工厂会重建实例,此处随之重建 + */ + private MilvusVectorStore getOrCreateVectorStore(Ai1KnowledgeBaseDO knowledgeBase) { + // 1. 缓存命中、且嵌入模型实例未变化时,直接复用 + EmbeddingModel embeddingModel = getEmbeddingModel(knowledgeBase); + CachedVectorStore cached = vectorStoreCache.getIfPresent(knowledgeBase.getId()); + if (cached != null && cached.getEmbeddingModel() == embeddingModel) { + return cached.getVectorStore(); + } + + // 2. 缓存未命中或嵌入模型实例变化时,构建新的向量存储;按知识库串行初始化,避免并发首访重复创建集合和索引 + synchronized (vectorStoreLocks.computeIfAbsent(knowledgeBase.getId(), key -> new Object())) { + cached = vectorStoreCache.getIfPresent(knowledgeBase.getId()); + if (cached != null && cached.getEmbeddingModel() == embeddingModel) { + return cached.getVectorStore(); + } + // 2.1 构建向量存储:集合不存在时自动创建 + String collectionName = buildCollectionName(knowledgeBase.getId()); + MilvusVectorStore vectorStore = MilvusVectorStore.builder(milvusClientLoader.get(), embeddingModel) + .databaseName(ai1Properties.getMilvus().getDatabase()) + .collectionName(collectionName) + .metricType(MetricType.COSINE) + .indexType(IndexType.FLAT) + .initializeSchema(true) + .build(); + // 2.2 手动构建的实例不会触发 Spring 生命周期回调,需主动初始化:建集合、建索引、加载 + try { + vectorStore.afterPropertiesSet(); + } catch (Exception e) { + throw new IllegalStateException("向量集合(" + collectionName + ") 初始化失败:" + e.getMessage(), e); + } + // 2.3 放入缓存 + vectorStoreCache.put(knowledgeBase.getId(), new CachedVectorStore() + .setEmbeddingModel(embeddingModel).setVectorStore(vectorStore)); + log.debug("[getOrCreateVectorStore][知识库({}) 向量存储构建完成]", knowledgeBase.getId()); + return vectorStore; + } + } + + /** + * 解析知识库的嵌入模型:Provider、模型不存在或被禁用时抛出业务异常 + */ + private EmbeddingModel getEmbeddingModel(Ai1KnowledgeBaseDO knowledgeBase) { + Ai1ModelRespBO model = modelService.getModelRespBO( + knowledgeBase.getEmbeddingProviderId(), knowledgeBase.getEmbeddingModelId()); + if (!Ai1ModelTypeEnum.isEmbedding(model.getModelType())) { + throw exception(MODEL_TYPE_NOT_EMBEDDING); + } + return llmModelFactory.getOrCreateEmbeddingModel(model); + } + + /** + * 创建 Milvus 客户端:配置了用户名时,开启鉴权 + */ + private MilvusServiceClient createMilvusClient() { + YudaoAi1Properties.Milvus milvus = ai1Properties.getMilvus(); + ConnectParam.Builder builder = ConnectParam.newBuilder() + .withUri(milvus.getUri()) + .withDatabaseName(milvus.getDatabase()); + if (StrUtil.isNotBlank(milvus.getUsername())) { + builder.withAuthorization(milvus.getUsername(), milvus.getPassword()); + } + return new MilvusServiceClient(builder.build()); + } + + /** + * 构建知识库的向量集合名:一个知识库对应一个集合 + * + * 不同知识库可能使用不同的嵌入模型,向量维度不同,无法共用一个集合;后续也可能落到不同的向量库,按知识库隔离更便于迁移和整体删除 + */ + private String buildCollectionName(Long knowledgeBaseId) { + return ai1Properties.getMilvus().getCollectionPrefix() + knowledgeBaseId; + } + + /** + * 获得知识库的检索数量:知识库未配置时,使用 yudao.ai1.milvus.default-top-k 配置 + */ + private int getDefaultTopK(Ai1KnowledgeBaseDO knowledgeBase) { + return knowledgeBase.getTopK() != null && knowledgeBase.getTopK() > 0 ? knowledgeBase.getTopK() + : ai1Properties.getMilvus().getDefaultTopK(); + } + + /** + * 文本分片:按 chunkSize、chunkOverlap 做字符级分片 + * + * @param content 文本 + * @param chunkSize 分片大小 + * @param chunkOverlap 分片重叠,限制在 [0, chunkSize - 1] + * @return 分片列表 + */ + @SuppressWarnings("MathClampMigration") + static List split(String content, Integer chunkSize, Integer chunkOverlap) { + List chunks = new ArrayList<>(); + if (StrUtil.isEmpty(content)) { + return chunks; + } + int size = Math.max(ObjUtil.defaultIfNull(chunkSize, 0), 1); + int overlap = Math.min(Math.max(ObjUtil.defaultIfNull(chunkOverlap, 0), 0), size - 1); + int length = content.length(); + int start = 0; + while (start < length) { + int end = Math.min(start + size, length); + chunks.add(content.substring(start, end)); + if (end >= length) { + break; + } + start = end - overlap; + } + return chunks; + } + + /** + * 向量存储缓存项 + */ + @Data + private static class CachedVectorStore { + + /** + * 构建时使用的嵌入模型实例,用于判断是否需要重建 + */ + private EmbeddingModel embeddingModel; + /** + * 向量存储 + */ + private MilvusVectorStore vectorStore; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java new file mode 100644 index 0000000..e190cf5 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamTool.java @@ -0,0 +1,847 @@ +package cn.iocoder.yudao.module.ai1.harness.session; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.convert.Convert; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.thread.ExecutorBuilder; +import cn.hutool.core.thread.ThreadUtil; +import cn.hutool.core.util.NumberUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.redis.Ai1RedisKeyConstants; +import cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionStreamEventEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.advisor.api.Advisor; +import org.springframework.context.SmartLifecycle; +import org.springframework.context.annotation.Lazy; +import org.redisson.api.RStream; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.redisson.api.stream.*; +import org.redisson.client.codec.StringCodec; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.time.Duration; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * AI1 会话流工具:会话流的「投递 / 续传入口 + 生成 worker + 结果流 + SSE 转发」一体 + * + * 【完整流程】 + * 1. 发送(请求侧,由 Ai1SessionMessageService 调用):{@link #submit} 投递任务(XADD 任务队列)→ {@link #open} 打开 SSE 连接 + * 2. 生成(worker 侧,{@link #start} 拉起的消费线程):{@link #consumeLoop} → {@link #handleTask} → {@link #generate}; + * 增量写入结果流,结束后回填助手消息并 XACK + * 3. 下发与续传(连接侧):{@link #streamResult} 从结果流 XREAD 转发为 SSE;携带 lastEventId 时从断点之后重放,不重新生成 + * + * 【关键约定】 + * 1. 助手消息编号同时作为结果流标识;生成与连接解耦,任意节点都可以转发、续传,worker 可独立扩容 + * 2. 投递时存在租户则携带 tenantId,worker 统一在 {@link TenantUtils#execute(Long, Runnable)} 中执行;关闭多租户时不携带,按空租户上下文执行; + * 禁止使用 executeIgnore(关闭租户过滤会导致跨租户读取) + * 3. SSE 事件的 data 统一为 JSON 字符串,避免换行、首字符空格在 SSE 协议中被截断 + * 4. 使用 Redisson 原生 RStream + StringCodec:字段按纯字符串存储(项目 RedisTemplate 的 JSON 序列化会破坏 Stream 字段); + * 不使用 Spring Data 的 opsForStream,因为 Redisson 适配层在阻塞读取无数据时会抛 ClassCastException, + * 且阻塞时长超过命令超时会占满连接池 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1SessionStreamTool implements SmartLifecycle { + + /** + * SSE 转发线程名前缀 + */ + private static final String SSE_THREAD_NAME_PREFIX = "ai1-session-sse-"; + /** + * 生成 worker 线程名前缀,完整线程名为「前缀 + 消费者名」 + */ + private static final String WORKER_THREAD_NAME_PREFIX = "ai1-session-"; + + /** + * 任务队列最大长度(近似裁剪) + */ + private static final int TASK_STREAM_MAX_LENGTH = 5000; + /** + * XREAD 阻塞窗口,单位:毫秒;即空闲心跳间隔,需小于 Redis 超时时间 + */ + private static final long READ_BLOCK_MILLIS = 5000; + /** + * 结果流单次读取条数 + */ + private static final int READ_BATCH_SIZE = 50; + /** + * 超时任务认领间隔,单位:毫秒 + */ + private static final long RECLAIM_INTERVAL_MILLIS = 60_000; + /** + * 超时任务认领的空闲阈值缓冲,单位:毫秒;空闲阈值 = SSE 连接超时 + 缓冲,执行锁另行保护在途任务 + */ + private static final long RECLAIM_IDLE_BUFFER_MILLIS = 60_000; + /** + * 超时任务单次认领条数 + */ + private static final int RECLAIM_BATCH_SIZE = 100; + /** + * worker 消费异常后的退避时长,单位:毫秒 + */ + private static final long CONSUME_ERROR_BACKOFF_MILLIS = 1000; + /** + * worker 停止时,等待单个消费线程退出的最长时长,单位:毫秒 + */ + private static final long WORKER_JOIN_TIMEOUT_MILLIS = 3000; + /** + * 结果流连续读取失败的最大次数,达到后中断转发 + */ + private static final int READ_ERROR_MAX_COUNT = 3; + /** + * 结果流读取失败后的重试间隔,单位:毫秒 + */ + private static final long READ_ERROR_BACKOFF_MILLIS = 500; + + /** + * 结果流丢失判定:转发到连接超时前该时长,结果流仍不存在时,判定任务丢失(预留两个阻塞读窗口,保证 error 能在连接超时前送达) + */ + private static final long LOST_CHECK_ADVANCE_MILLIS = READ_BLOCK_MILLIS * 2; + + /** + * 结果流丢失时的错误提示 + */ + static final String LOST_MESSAGE = "生成任务已丢失,请重新发送"; + + /** + * worker 中断后的错误提示;保留部分内容,不重新生成以免输出或工具调用重复 + */ + static final String INTERRUPTED_MESSAGE = "生成任务已中断,请重新发送"; + + private static final String FIELD_MESSAGE_ID = "messageId"; + private static final String FIELD_AGENT_ID = "agentId"; + private static final String FIELD_SESSION_ID = "sessionId"; + private static final String FIELD_CONTENT = "content"; + private static final String FIELD_TENANT_ID = "tenantId"; + private static final String FIELD_TYPE = "type"; + private static final String FIELD_DATA = "data"; + + @Resource + private RedissonClient redissonClient; + @Resource + private YudaoAi1Properties ai1Properties; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SessionMessageService sessionMessageService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SessionService sessionService; + @Resource + private Ai1AgentService agentService; + @Resource + private Ai1ModelService modelService; + @Resource + private Ai1KnowledgeBaseService knowledgeBaseService; + + @Resource + private Ai1LlmChatTool llmChatTool; + @Resource + private Ai1RagTool ragTool; + + @Resource + private Ai1McpToolFactory mcpToolFactory; + @Resource + private Ai1SkillToolFactory skillToolFactory; + + /** + * 结果流 TTL 上次续期时刻:助手消息编号 → 毫秒时间戳,用于 EXPIRE 节流 + */ + private final Map resultExpireTimes = new ConcurrentHashMap<>(); + /** + * 实例标识,消费者名按「实例 + 序号」隔离 + */ + private final String instanceId = UUID.randomUUID().toString().substring(0, 8); + /** + * 生成 worker 消费线程列表,停止时逐个等待退出 + */ + private final List workers = new ArrayList<>(); + /** + * 是否运行中:worker 主循环以此判断是否继续消费,停止时置为 false + */ + private volatile boolean running = false; + /** + * SSE 转发线程池:单个转发阻塞于 XREAD 直到流结束,用有界池控制并发 + */ + private ExecutorService sseExecutor; + + // ==================== 生命周期 ==================== + + /** + * 启动:建 SSE 转发线程池 → 初始化任务消费组 → 拉起 worker 消费线程 + * + * 使用 SmartLifecycle 而非 @PostConstruct:worker 依赖其他 Bean 与 Redis,需在容器刷新完成后启动 + */ + @Override + public void start() { + // 1. 创建 SSE 转发线程池 + // 使用 SynchronousQueue(队列容量为 0):否则线程池在队列未满前不会扩容到 maxPoolSize,且长连接排队同样不可接受; + // 并发已满时直接拒绝(AbortPolicy),由 open 返回“服务繁忙” + // 使用 Hutool ExecutorBuilder 构建线程池 + int maxConnections = ai1Properties.getSession().getSse().getMaxConnections(); + // 线程名前缀见 SSE_THREAD_NAME_PREFIX + sseExecutor = ExecutorBuilder.create() + .setCorePoolSize(Math.max(1, maxConnections / 8)).setMaxPoolSize(maxConnections) + .useSynchronousQueue() + .setThreadFactory(ThreadUtil.newNamedThreadFactory(SSE_THREAD_NAME_PREFIX, false)) + .build(); + + // 2. 初始化任务消费组 + initTaskGroup(); + + // 3. 拉起 worker 消费线程,消费者名按「实例 + 序号」隔离 + running = true; + int workerCount = ai1Properties.getSession().getWorker().getCount(); + for (int i = 0; i < workerCount; i++) { + String consumer = "worker-" + instanceId + "-" + i; + Thread thread = ThreadUtil.newThread(() -> consumeLoop(consumer), WORKER_THREAD_NAME_PREFIX + consumer, true); + workers.add(thread); + thread.start(); + } + log.info("[start][Chat worker 启动完成,实例({}) 数量({})]", instanceId, workerCount); + } + + /** + * 停止:置停止标志,等待各消费线程退出(每个最多 3s),最后关闭 SSE 转发线程池 + */ + @Override + public void stop() { + running = false; + for (Thread worker : workers) { + try { + worker.join(WORKER_JOIN_TIMEOUT_MILLIS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + workers.clear(); + if (sseExecutor != null) { + sseExecutor.shutdown(); + } + log.info("[stop][Chat worker 已停止,实例({})]", instanceId); + } + + @Override + public boolean isRunning() { + return running; + } + + /** + * 初始化任务消费组(幂等):Stream 不存在时一并创建(MKSTREAM),消费组已存在(BUSYGROUP)视为正常 + */ + private void initTaskGroup() { + try { + getTaskStream().createGroup(StreamCreateGroupArgs.name(Ai1RedisKeyConstants.SESSION_TASK_GROUP).id(StreamMessageId.ALL).makeStream()); + } catch (Exception e) { + if (!isBusyGroup(e)) { + log.warn("[initTaskGroup][Chat 任务消费组初始化失败]", e); + } + } + } + + private static boolean isBusyGroup(Throwable e) { + for (Throwable cause = e; cause != null; cause = cause.getCause()) { + if (StrUtil.contains(cause.getMessage(), "BUSYGROUP")) { + return true; + } + } + return false; + } + + // ==================== 投递 / 连接入口 ==================== + + /** + * 投递生成任务 + * + * @param messageId 助手消息编号,即结果流标识 + * @param agentId Agent 编号 + * @param sessionId 会话编号 + * @param content 用户提问内容 + */ + public void submit(Long messageId, Long agentId, Long sessionId, String content) { + // 1. 组装任务字段 + Map fields = new LinkedHashMap<>(); + // 1.1 租户可选:存在租户才携带,关闭多租户时 worker 直接执行 + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId != null) { + fields.put(FIELD_TENANT_ID, String.valueOf(tenantId)); + } + // 1.2 生成所需的业务字段 + fields.put(FIELD_MESSAGE_ID, String.valueOf(messageId)); + fields.put(FIELD_AGENT_ID, String.valueOf(agentId)); + // 生成时,请求 Header 中的 {session} 占位符替换为此处的会话编号 + fields.put(FIELD_SESSION_ID, String.valueOf(sessionId)); + fields.put(FIELD_CONTENT, content); + + // 2. 投递到任务队列,超过最大长度时近似裁剪最早的任务 + getTaskStream().add(StreamAddArgs.entries(fields).trimNonStrict().maxLen(TASK_STREAM_MAX_LENGTH).noLimit()); + } + + /** + * 打开 SSE 连接:从结果流 lastEventId 之后转发(为空则从头重放),生成由 worker 异步完成 + * + * @param messageId 助手消息编号 + * @param lastEventId 已收到的最后一个事件编号,可为空 + * @return SSE 连接 + */ + public SseEmitter open(Long messageId, String lastEventId) { + SseEmitter emitter = new SseEmitter(ai1Properties.getSession().getStream().getTimeoutMs()); + // 转发线程没有租户上下文,判定任务丢失后回写消息时使用 + Long tenantId = TenantContextHolder.getTenantId(); + try { + sseExecutor.execute(() -> { + try { + // 发送失败后由 Servlet 容器清理连接,不能再次 complete,避免与请求回收竞争 + if (streamResult(emitter, messageId, lastEventId, tenantId)) { + emitter.complete(); + } + } catch (Exception e) { + log.warn("[open][助手消息({}) 会话流转发异常]", messageId, e); + sendErrorAndComplete(emitter, e.getMessage()); + } + }); + } catch (RejectedExecutionException e) { + log.warn("[open][会话流转发并发已满,助手消息({})]", messageId); + sendErrorAndComplete(emitter, "服务繁忙,请稍后重试"); + } + return emitter; + } + + /** + * 返回只包含 error 终态的 SSE 连接,用于请求校验失败等场景 + * + * @param message 错误提示 + * @return SSE 连接 + */ + public SseEmitter error(String message) { + SseEmitter emitter = new SseEmitter(ai1Properties.getSession().getStream().getTimeoutMs()); + sendErrorAndComplete(emitter, message); + return emitter; + } + + // ==================== worker 消费 ==================== + + /** + * worker 主循环:周期性认领超时遗留任务,其余时间阻塞消费新任务;异常时退避 1s,保证长期稳定 + */ + private void consumeLoop(String consumer) { + long lastReclaimTime = 0L; + while (running) { + try { + // 1. 到达认领间隔时,先接管其他 worker 遗留的超时任务 + long now = System.currentTimeMillis(); + if (now - lastReclaimTime >= RECLAIM_INTERVAL_MILLIS) { + lastReclaimTime = now; + reclaimTasks(consumer).forEach((recordId, fields) -> handleTask(recordId, fields, true)); + } + + // 2. 阻塞读取从未投递过的新任务,每次一条,处理完再读取下一条 + Map> records = getTaskStream().readGroup(Ai1RedisKeyConstants.SESSION_TASK_GROUP, consumer, + StreamReadGroupArgs.neverDelivered().count(1).timeout(Duration.ofMillis(READ_BLOCK_MILLIS))); + if (records != null) { + records.forEach(this::handleTask); + } + } catch (Throwable e) { + // 3. 已停止时直接退出;否则记录异常并退避,避免 Redis 故障时空转 + if (!running) { + return; + } + log.warn("[consumeLoop][Chat worker({}) 消费异常]", consumer, e); + ThreadUtil.sleep(CONSUME_ERROR_BACKOFF_MILLIS); + } + } + } + + /** + * 认领长时间未确认的任务;PEL 空闲时长不代表生成已停止,接管后仍需获取消息执行锁 + */ + private Map> reclaimTasks(String consumer) { + Duration minIdle = Duration.ofMillis(ai1Properties.getSession().getStream().getTimeoutMs() + RECLAIM_IDLE_BUFFER_MILLIS); + try { + // 1. 查询空闲超过阈值的待确认任务 + RStream taskStream = getTaskStream(); + List pendingEntries = taskStream.listPending(StreamPendingRangeArgs.groupName(Ai1RedisKeyConstants.SESSION_TASK_GROUP) + .startId(StreamMessageId.MIN).endId(StreamMessageId.MAX).count(RECLAIM_BATCH_SIZE).idleTime(minIdle)); + if (CollUtil.isEmpty(pendingEntries)) { + return Collections.emptyMap(); + } + + // 2. 认领到当前消费者:认领时再次校验空闲时长,避免与其他节点重复认领 + StreamMessageId[] ids = pendingEntries.stream().map(PendingEntry::getId).toArray(StreamMessageId[]::new); + return taskStream.claim(Ai1RedisKeyConstants.SESSION_TASK_GROUP, consumer, minIdle.toMillis(), TimeUnit.MILLISECONDS, ids); + } catch (Exception e) { + log.warn("[reclaimTasks][Chat 任务认领失败]", e); + return Collections.emptyMap(); + } + } + + /** + * 消费单条新任务 + */ + void handleTask(StreamMessageId recordId, Map fields) { + handleTask(recordId, fields, false); + } + + /** + * 消费任务:执行锁保护生成与回填;已完成的任务直接确认,中断的任务保留部分内容并标记失败 + */ + void handleTask(StreamMessageId recordId, Map fields, boolean reclaimed) { + // 1. 解析任务:没有消息编号的异常记录,直接确认丢弃 + Long messageId = Convert.toLong(fields.get(FIELD_MESSAGE_ID)); + if (messageId == null) { + ackTask(recordId); + return; + } + // 2. 看门狗为长任务自动续期;认领到在途任务时跳过,不确认,交由原 worker 完成 + RLock lock = redissonClient.getLock(String.format(Ai1RedisKeyConstants.SESSION_TASK_LOCK, messageId)); + if (!lock.tryLock()) { + return; + } + long workerThreadId = Thread.currentThread().threadId(); + try { + Long tenantId = Convert.toLong(fields.get(FIELD_TENANT_ID)); + TenantUtils.execute(tenantId, () -> executeTask(recordId, fields, messageId, lock, workerThreadId, reclaimed)); + } catch (Exception e) { + // Redis 或回填失败时保留 pending,后续接管不会重新生成 + log.error("[handleTask][助手消息({}) 处理失败]", messageId, e); + } finally { + resultExpireTimes.remove(messageId); + try { + if (lock.isHeldByThread(workerThreadId)) { + lock.unlock(); + } + } catch (Exception e) { + log.warn("[handleTask][助手消息({}) 执行锁释放失败]", messageId, e); + } + } + } + + /** + * 持有执行锁后处理任务:清理异常记录、补齐已完成任务、收尾中断任务,或执行新一轮生成 + */ + private void executeTask(StreamMessageId recordId, Map fields, Long messageId, + RLock lock, long workerThreadId, boolean reclaimed) { + // 1.1 查询助手消息,不存在或不是助手角色时确认丢弃 + Ai1SessionMessageDO message = sessionMessageService.getSessionMessage(messageId); + if (message == null || !Ai1SessionMessageRoleEnum.isAssistant(message.getRole())) { + checkTaskLock(lock, workerThreadId); + ackTask(recordId); + return; + } + // 1.2 回填成功后 XACK 可能失败,补齐缺失终态后确认,不再调用模型 + if (!Ai1SessionMessageStatusEnum.isGenerating(message.getStatus())) { + completeTaskResult(message, lock, workerThreadId); + checkTaskLock(lock, workerThreadId); + ackTask(recordId); + return; + } + // 1.3 接管任务不重跑,保留结果流中的部分输出;新任务已有结果时同样按中断处理 + Long sessionId = Convert.toLong(fields.get(FIELD_SESSION_ID)); + if (reclaimed || getResultStream(messageId).isExists()) { + // 1.3.1 按结果流顺序恢复已产生的正文和思考 + Ai1LlmChatTool.ChatText partialText = readPartialResult(messageId); + // 1.3.2 回填部分结果并标记失败;每次写入前校验执行权,防止旧 worker 继续写入 + checkTaskLock(lock, workerThreadId); + saveAssistantMessage(messageId, sessionId, partialText, Ai1SessionMessageStatusEnum.FAILED.getStatus()); + // 1.3.3 写入错误提示和结束事件,续传连接可及时退出 + checkTaskLock(lock, workerThreadId); + appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), INTERRUPTED_MESSAGE); + checkTaskLock(lock, workerThreadId); + appendResult(messageId, Ai1SessionStreamEventEnum.DONE.getEvent(), ""); + // 1.3.4 确认任务;以上任一步失败时保留 pending,交由后续接管收尾 + checkTaskLock(lock, workerThreadId); + ackTask(recordId); + return; + } + + // 2. 解析任务字段 + Long agentId = Convert.toLong(fields.get(FIELD_AGENT_ID)); + String content = fields.get(FIELD_CONTENT); + + // 3. 生成并回填;增量回调可能在模型线程执行,因此按 worker 线程编号校验锁归属 + StringBuilder contentText = new StringBuilder(); + StringBuilder thinkingText = new StringBuilder(); + Consumer onThinking = delta -> { + checkTaskLock(lock, workerThreadId); + thinkingText.append(delta); + appendResult(messageId, Ai1SessionStreamEventEnum.THINKING.getEvent(), delta); + }; + Consumer onContent = delta -> { + checkTaskLock(lock, workerThreadId); + contentText.append(delta); + appendResult(messageId, Ai1SessionStreamEventEnum.MESSAGE.getEvent(), delta); + }; + // 3.1 标记生成开始,再调用模型并回填完整结果 + try { + checkTaskLock(lock, workerThreadId); + // 标记生成已开始;长思考没有增量时,续传也不会误判结果流丢失 + appendResult(messageId, Ai1SessionStreamEventEnum.PING.getEvent(), ""); + Ai1LlmChatTool.ChatText chatText = generate(messageId, agentId, sessionId, content, onThinking, onContent); + checkTaskLock(lock, workerThreadId); + saveAssistantMessage(messageId, sessionId, chatText, Ai1SessionMessageStatusEnum.SUCCESS.getStatus()); + } catch (Exception e) { + // 失去锁的旧 worker 不得再写结果、终态或 XACK,留给新持有者处理 + if (!lock.isHeldByThread(workerThreadId)) { + return; + } + // 3.2 生成失败时下发错误,保留本轮已产生的增量并回填失败状态 + log.warn("[handleTask][助手消息({}) 生成失败]", messageId, e); + appendResult(messageId, Ai1SessionStreamEventEnum.ERROR.getEvent(), StrUtil.blankToDefault(e.getMessage(), "生成失败")); + checkTaskLock(lock, workerThreadId); + saveAssistantMessage(messageId, sessionId, new Ai1LlmChatTool.ChatText(contentText.toString(), + thinkingText.toString()), Ai1SessionMessageStatusEnum.FAILED.getStatus()); + } + // 4. 回填后写终态并确认;失败时由后续接管清理,不能在 finally 中无条件确认 + checkTaskLock(lock, workerThreadId); + appendResult(messageId, Ai1SessionStreamEventEnum.DONE.getEvent(), ""); + checkTaskLock(lock, workerThreadId); + ackTask(recordId); + } + + /** + * 已回填任务的终态补偿:只补齐缺失的 error/done,不再次生成或回填消息 + * + * 数据库回填与结果流写入不是同一事务,worker 可能在两者之间退出。 + */ + private void completeTaskResult(Ai1SessionMessageDO message, RLock lock, long workerThreadId) { + // 1. 只查询最后一条事件,已有 done 则无需再补写 + Map> last = getResultStream(message.getId()) + .rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1)); + String lastType = last.isEmpty() ? null : CollUtil.getFirst(last.values()).get(FIELD_TYPE); + if (Ai1SessionStreamEventEnum.DONE.getEvent().equals(lastType)) { + return; + } + // 2. 失败消息缺少 error 时补写错误提示,末尾已有 error 时不重复写入 + if (Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()) + && !Ai1SessionStreamEventEnum.ERROR.getEvent().equals(lastType)) { + checkTaskLock(lock, workerThreadId); + appendResult(message.getId(), Ai1SessionStreamEventEnum.ERROR.getEvent(), INTERRUPTED_MESSAGE); + } + // 3. 补写 done,使续传连接能够结束;调用方在补齐后再确认任务 + checkTaskLock(lock, workerThreadId); + appendResult(message.getId(), Ai1SessionStreamEventEnum.DONE.getEvent(), ""); + } + + /** + * 校验执行锁仍属于原 worker;增量回调可能在模型线程执行,因此不能使用当前回调线程编号 + * + * 失去执行权时抛出异常,中止旧 worker 的后续写入,由新持有者收尾。 + */ + private static void checkTaskLock(RLock lock, long workerThreadId) { + if (!lock.isHeldByThread(workerThreadId)) { + throw new IllegalStateException("生成任务执行权已失效"); + } + } + + /** + * 按结果流顺序还原中断任务的正文和思考,忽略心跳、错误提示及结束事件 + */ + private Ai1LlmChatTool.ChatText readPartialResult(Long messageId) { + StringBuilder content = new StringBuilder(); + StringBuilder thinking = new StringBuilder(); + // 读取完整结果流,按条目编号升序拼接各类增量 + getResultStream(messageId).range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX)) + .values().forEach(fields -> { + if (Ai1SessionStreamEventEnum.MESSAGE.getEvent().equals(fields.get(FIELD_TYPE))) { + content.append(StrUtil.nullToEmpty(fields.get(FIELD_DATA))); + } else if (Ai1SessionStreamEventEnum.THINKING.getEvent().equals(fields.get(FIELD_TYPE))) { + thinking.append(StrUtil.nullToEmpty(fields.get(FIELD_DATA))); + } + }); + return new Ai1LlmChatTool.ChatText(content.toString(), thinking.toString()); + } + + private void ackTask(StreamMessageId recordId) { + getTaskStream().ack(Ai1RedisKeyConstants.SESSION_TASK_GROUP, recordId); + } + + // ==================== 生成编排 ==================== + + /** + * 执行一次生成:校验 Agent → 解析模型 → 装配历史、工具、RAG → 流式会话 + * + * 历史只取当前助手占位之前最近 historyLimit 条,跳过未完成的助手占位,并移除末尾的当前提问(由 LLM 工具另行追加) + */ + @SuppressWarnings("SequencedCollectionMethodCanBeUsed") + private Ai1LlmChatTool.ChatText generate(Long messageId, Long agentId, Long sessionId, String content, + Consumer onThinking, Consumer onContent) { + // 1. 生成可能发生在其他节点,重新校验 Agent 与模型;后台会话不要求 Agent 已发布 + Ai1AgentDO agent = agentService.validateAgentExists(agentId); + Ai1ModelRespBO model = modelService.getModelRespBO(agent.getProviderId(), agent.getModelId()); + if (!Ai1ModelTypeEnum.isChat(model.getModelType())) { + throw ServiceExceptionUtil.exception(Ai1ErrorCodeConstants.MODEL_TYPE_NOT_CHAT); + } + + // 2. 历史消息:按编号倒序取最近 N 条,再升序还原为会话顺序 + // TODO @芋艿:【优化点】截取历史的 limit 会导致 prompt cache 频繁失效。后续需要改成 prompt 压缩 + List recentMessages = new ArrayList<>(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan( + sessionId, messageId, ai1Properties.getSession().getHistory().getLimit())); + recentMessages.sort(Comparator.comparing(Ai1SessionMessageDO::getId)); + List histories = new ArrayList<>(); + for (Ai1SessionMessageDO message : recentMessages) { + // 跳过未完成的助手占位(如异常残留),避免把半截回复带入上下文 + if (Ai1SessionMessageRoleEnum.isAssistant(message.getRole()) + && Ai1SessionMessageStatusEnum.isGenerating(message.getStatus())) { + continue; + } + histories.add(new String[]{message.getRole(), message.getContent()}); + } + // 末尾为本次刚落库的当前提问,移除以免重复注入 + String[] lastHistory = CollUtil.getLast(histories); + if (lastHistory != null && Ai1SessionMessageRoleEnum.isUser(lastHistory[0])) { + histories.remove(histories.size() - 1); + } + + // 3. 系统指令:未配置时使用默认引导文案 + String systemPrompt = StrUtil.isNotBlank(agent.getSystemPrompt()) ? agent.getSystemPrompt().trim() + : StrUtil.replace(ai1Properties.getSession().getDefaultSystemPrompt(), + YudaoAi1Properties.Session.AGENT_NAME_PLACEHOLDER, agent.getName()); + + // 4. 工具(MCP + SKILL)与 RAG Advisor;知识库不可用时降级跳过,并把提示写入思考流 + List tools = new ArrayList<>(mcpToolFactory.buildTools(agent)); + tools.addAll(skillToolFactory.buildTools(agent)); + StringBuilder noticeText = new StringBuilder(); + List advisors = new ArrayList<>(); + for (Ai1KnowledgeBaseDO knowledgeBase : knowledgeBaseService.getKnowledgeBaseList(agent.getKnowledgeBaseIds())) { + if (CommonStatusEnum.isDisable(knowledgeBase.getStatus())) { + continue; + } + try { + advisors.add(ragTool.buildAdvisor(knowledgeBase)); + } catch (Exception e) { + log.warn("[generate][Agent({}) 知识库({}) RAG 装配失败,已降级跳过]", agent.getId(), knowledgeBase.getId(), e); + String notice = "【知识库降级】「" + knowledgeBase.getName() + "」检索服务不可用,本次会话已跳过 RAG 上下文。\n"; + onThinking.accept(notice); + noticeText.append(notice); + } + } + + // 5. 流式会话;降级提示并入最终思考文本,保证刷新后内容一致 + Ai1LlmChatTool.ChatText chatText = llmChatTool.chat(model, systemPrompt, histories, content, tools, advisors, + sessionId, onThinking, onContent); + if (!noticeText.isEmpty()) { + chatText.setThinking(noticeText + StrUtil.nullToEmpty(chatText.getThinking())); + } + return chatText; + } + + /** + * 回填助手消息(占位记录)的内容、思考过程与生成状态,并刷新会话活跃时间 + */ + private void saveAssistantMessage(Long messageId, Long sessionId, Ai1LlmChatTool.ChatText chatText, Integer status) { + sessionMessageService.updateSessionMessage(new Ai1SessionMessageDO().setId(messageId) + .setContent(StrUtil.nullToEmpty(chatText.getContent())) + .setReasoning(StrUtil.emptyToNull(chatText.getThinking())) + .setStatus(status)); + sessionService.touchSession(sessionId); + } + + // ==================== 结果流 ==================== + + /** + * 追加结果流条目并续期 TTL:生成中按半个 TTL 节流续期;终态续期一次完整 TTL 并清理节流状态 + */ + private void appendResult(Long messageId, String type, String data) { + // 1. 追加结果流条目 + RStream resultStream = getResultStream(messageId); + resultStream.add(StreamAddArgs.entries(FIELD_TYPE, type, FIELD_DATA, StrUtil.nullToEmpty(data))); + + // 2. 终态:续期一次完整 TTL,保证续传时间窗从结束时刻起算,并清理节流状态 + Duration ttl = Duration.ofSeconds(ai1Properties.getSession().getStream().getTtlSeconds()); + if (Ai1SessionStreamEventEnum.isTerminal(type)) { + resultStream.expire(ttl); + resultExpireTimes.remove(messageId); + return; + } + + // 3. 生成中:距上次续期超过半个 TTL 才续期,避免每个增量都执行 EXPIRE + long now = System.currentTimeMillis(); + Long lastExpireTime = resultExpireTimes.get(messageId); + if (lastExpireTime == null || now - lastExpireTime >= ttl.toMillis() / 2) { + resultStream.expire(ttl); + resultExpireTimes.put(messageId, now); + } + } + + // ==================== SSE 转发 ==================== + + /** + * SSE 转发主循环:先下发 stream 事件告知结果流标识;随后按条目实时下发,事件编号为结果流条目编号; + * 空闲时下发 ping 心跳;遇到终态、超时、客户端断开、Redis 连续读取失败时退出 + * + * @return 是否需要主动完成连接;断开、超时回调或发送失败时由 Servlet 容器完成清理 + */ + private boolean streamResult(SseEmitter emitter, Long messageId, String lastEventId, Long tenantId) { + // 1. 客户端断开、完成、超时时置取消标志,终止转发循环 + AtomicBoolean cancelled = new AtomicBoolean(false); + emitter.onCompletion(() -> cancelled.set(true)); + emitter.onTimeout(() -> cancelled.set(true)); + emitter.onError(e -> cancelled.set(true)); + + // 2. 先下发 stream 事件,告知前端结果流标识(助手消息编号),用于断线续传 + StreamMessageId fromId = parseStreamMessageId(lastEventId); + long startTime = System.currentTimeMillis(); + long timeoutMs = ai1Properties.getSession().getStream().getTimeoutMs(); + long deadline = startTime + timeoutMs; + long lostCheckTime = startTime + Math.max(timeoutMs - LOST_CHECK_ADVANCE_MILLIS, 0); + boolean lostChecked = false; + int errorCount = 0; + sendEvent(emitter, Ai1SessionStreamEventEnum.STREAM.getEvent(), String.valueOf(messageId), null, cancelled); + + // 3. 循环读取结果流并转发,直到终态、超时或取消 + while (!cancelled.get()) { + // 3.1 超过单连接最长时长,结束转发 + if (System.currentTimeMillis() > deadline) { + log.warn("[streamResult][助手消息({}) 会话流转发超时]", messageId); + return !cancelled.get(); + } + // 3.2 从上次位置之后阻塞读取;读取失败时容错重试,连续失败达到阈值才中断,客户端可再续传 + Map> records; + try { + records = getResultStream(messageId).read(StreamReadArgs.greaterThan(fromId) + .count(READ_BATCH_SIZE).timeout(Duration.ofMillis(READ_BLOCK_MILLIS))); + } catch (Exception e) { + log.warn("[streamResult][助手消息({}) 会话流读取异常]", messageId, e); + if (++errorCount >= READ_ERROR_MAX_COUNT) { + return !cancelled.get(); + } + ThreadUtil.sleep(READ_ERROR_BACKOFF_MILLIS); + continue; + } + errorCount = 0; + // 3.3 阻塞窗口内无新条目时,下发空闲心跳,防止网关、浏览器因长时间无数据断开 + if (MapUtil.isEmpty(records)) { + // 临近连接超时,结果流仍不存在:任务丢失(投递失败、worker 回填失败、结果流已过期等), + // 回写失败并下发 error 终态,避免消息永远停在生成中、前端反复续传 + if (!lostChecked && System.currentTimeMillis() >= lostCheckTime) { + lostChecked = true; + if (!getResultStream(messageId).isExists()) { + log.warn("[streamResult][助手消息({}) 结果流不存在,判定生成任务丢失]", messageId); + failLostMessage(messageId, tenantId); + sendEvent(emitter, Ai1SessionStreamEventEnum.ERROR.getEvent(), LOST_MESSAGE, null, cancelled); + return !cancelled.get(); + } + } + sendEvent(emitter, Ai1SessionStreamEventEnum.PING.getEvent(), "", null, cancelled); + continue; + } + // 3.4 按条目编号顺序下发(Redisson 返回的 Map 有序),事件编号为条目编号;遇到终态结束 + for (Map.Entry> record : records.entrySet()) { + fromId = record.getKey(); + String type = record.getValue().get(FIELD_TYPE); + sendEvent(emitter, type, record.getValue().get(FIELD_DATA), fromId.toString(), cancelled); + if (Ai1SessionStreamEventEnum.isTerminal(type)) { + return !cancelled.get(); + } + } + } + return false; + } + + /** + * 任务丢失时,把仍处于生成中的助手消息回写为失败;已完成或已失败的消息不改动 + */ + void failLostMessage(Long messageId, Long tenantId) { + try { + TenantUtils.execute(tenantId, () -> { + Ai1SessionMessageDO message = sessionMessageService.getSessionMessage(messageId); + if (message == null || !Ai1SessionMessageStatusEnum.isGenerating(message.getStatus())) { + return; + } + sessionMessageService.updateSessionMessage(new Ai1SessionMessageDO().setId(messageId) + .setStatus(Ai1SessionMessageStatusEnum.FAILED.getStatus())); + }); + } catch (Exception e) { + log.error("[failLostMessage][助手消息({}) 回写失败状态失败]", messageId, e); + } + } + + /** + * 发送单个 SSE 事件;发送异常说明客户端已断开,置取消标志以终止转发 + */ + private static void sendEvent(SseEmitter emitter, String event, String data, String id, AtomicBoolean cancelled) { + if (cancelled.get()) { + return; + } + try { + SseEmitter.SseEventBuilder builder = SseEmitter.event().name(event).data(JsonUtils.toJsonString(StrUtil.nullToEmpty(data))); + if (StrUtil.isNotBlank(id)) { + builder.id(id); + } + emitter.send(builder); + } catch (Exception e) { + cancelled.set(true); + } + } + + private static void sendErrorAndComplete(SseEmitter emitter, String message) { + try { + emitter.send(SseEmitter.event().name(Ai1SessionStreamEventEnum.ERROR.getEvent()) + .data(JsonUtils.toJsonString(StrUtil.blankToDefault(message, "生成失败")))); + } catch (Exception ignored) { + // 客户端已断开,由 Servlet 容器清理连接 + return; + } + emitter.complete(); + } + + // ==================== 通用方法 ==================== + + private RStream getTaskStream() { + return redissonClient.getStream(Ai1RedisKeyConstants.SESSION_TASK_STREAM, StringCodec.INSTANCE); + } + + private RStream getResultStream(Long messageId) { + return redissonClient.getStream(String.format(Ai1RedisKeyConstants.SESSION_RESULT_STREAM, messageId), StringCodec.INSTANCE); + } + + /** + * 解析结果流条目编号(格式为 毫秒时间戳-序号);为空或非法时从头读取 + */ + static StreamMessageId parseStreamMessageId(String id) { + if (StrUtil.isBlank(id)) { + return new StreamMessageId(0, 0); + } + List parts = StrUtil.split(id.trim(), '-'); + if (parts.size() != 2 || !NumberUtil.isLong(parts.get(0)) || !NumberUtil.isLong(parts.get(1))) { + return new StreamMessageId(0, 0); + } + return new StreamMessageId(Long.parseLong(parts.get(0)), Long.parseLong(parts.get(1))); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java new file mode 100644 index 0000000..7f08873 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactory.java @@ -0,0 +1,293 @@ +package cn.iocoder.yudao.module.ai1.harness.skill; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillFileService; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillService; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import jakarta.annotation.Resource; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springaicommunity.agent.tools.*; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 SKILL 工具工厂(spring-ai-agent-utils SkillsTool) + * + * 把 Agent 绑定的 SKILL(数据库中的文件树)物化为本地技能目录,再构建 SkillsTool 与执行类工具: + *
+ * ${yudao.ai1.skill.root}/
+ * └── agent_{agentId}/              # Agent 级沙箱(Agent 编号全局唯一,无需按租户分层)
+ *     ├── .fingerprint              # 指纹标记,仅用于本地缓存校验
+ *     └── {skillName}/              # 每个 SKILL 一个子目录
+ *         ├── SKILL.md
+ *         ├── scripts/
+ *         └── reference/
+ * 
+ * 变更检测:SKILL 内容变更会刷新其 update_time,这里按「编号:更新时间」计算指纹,不一致时整目录重建 + * (先清空再物化,杜绝换绑、删除后的残留文件) + * + * 注意:执行类工具(Shell、FileSystem 等)会在服务器上执行命令,与源工程行为一致,多租户不可信部署需自行评估 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class Ai1SkillToolFactory { + + /** + * 指纹标记文件名 + */ + private static final String FILE_NAME_FINGERPRINT = ".fingerprint"; + + /** + * 快照缓存的最大数量 + */ + private static final int SNAPSHOT_CACHE_MAX_SIZE = 1000; + + @Resource + private YudaoAi1Properties ai1Properties; + + @Resource + private Ai1SkillService skillService; + @Resource + private Ai1SkillFileService skillFileService; + + /** + * Agent 物化快照缓存:Agent 编号 → 快照 + * + * 集群下各节点独立缓存,每次使用前按「编号:更新时间」指纹校验,Agent 换绑或 SKILL 变更后自动重建,无需跨节点失效 + */ + private final Cache snapshotCache = CacheBuilder.newBuilder() + .maximumSize(SNAPSHOT_CACHE_MAX_SIZE).build(); + /** + * 按 Agent 串行物化的锁,避免并发重建互相破坏目录 + */ + private final Map syncLocks = new ConcurrentHashMap<>(); + + /** + * 构建 Agent 绑定 SKILL 所需的全部工具:SkillsTool(注入 SKILL.md 内容)+ 执行类工具(限定于 Agent 物化根目录) + * + * @param agent Agent + * @return 工具列表;未绑定有效 SKILL 时返回空列表 + */ + public List buildTools(Ai1AgentDO agent) { + // 1. 同步物化快照;未绑定有效 SKILL 时,不提供任何工具 + SkillSnapshot snapshot = sync(agent); + if (snapshot == null) { + return Collections.emptyList(); + } + + // 2. SkillsTool:要求至少一个有效 SKILL.md,否则只物化文件,不注册技能工具 + List tools = new ArrayList<>(); + if (snapshot.getSkillsTool() != null) { + tools.add(snapshot.getSkillsTool()); + } + + // 3. 执行类工具:ShellTools(bash)、FileSystemTools(Read/Write/Edit)、Glob、Grep、ListDirectory + Path root = snapshot.getRoot(); + tools.add(ShellTools.builder().workingDirectory(root).build()); + tools.add(FileSystemTools.builder().allowedDirectory(root).build()); + tools.add(GlobTool.builder().workingDirectory(root).allowedDirectory(root).build()); + tools.add(GrepTool.builder().workingDirectory(root).allowedDirectory(root).build()); + tools.add(ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build()); + return tools; + } + + /** + * 清理 Agent 物化沙箱:摘除快照缓存并删除本地目录(Agent 删除时调用) + * + * @param agentId Agent 编号 + */ + public void evict(Long agentId) { + snapshotCache.invalidate(agentId); + try { + FileUtil.del(buildAgentRoot(agentId)); + } catch (Exception e) { + log.warn("[evict][Agent({}) SKILL 沙箱清理失败]", agentId, e); + } + } + + /** + * 同步并返回 Agent 物化快照:指纹变化则整目录重建,否则复用缓存 + */ + private SkillSnapshot sync(Ai1AgentDO agent) { + // 1.1 查询绑定的 SKILL + if (CollUtil.isEmpty(agent.getSkillIds())) { + return null; + } + List skills = skillService.getSkillList(agent.getSkillIds()); + if (CollUtil.isEmpty(skills)) { + return null; + } + // 1.2 指纹一致时,直接复用缓存 + String fingerprint = buildFingerprint(skills); + SkillSnapshot cached = snapshotCache.getIfPresent(agent.getId()); + if (isSnapshotValid(cached, fingerprint)) { + return cached; + } + + // 2. 指纹变化,整目录重建(双重检查,并发请求下避免重复重建) + synchronized (syncLocks.computeIfAbsent(agent.getId(), key -> new Object())) { + cached = snapshotCache.getIfPresent(agent.getId()); + if (isSnapshotValid(cached, fingerprint)) { + return cached; + } + Path root = buildAgentRoot(agent.getId()); + try { + // 2.1 清空目录后,逐个物化开启的 SKILL + FileUtil.del(root); + FileUtil.mkdir(root.toFile()); + List skillDirectories = new ArrayList<>(); + // 固定注册顺序;同名 frontmatter 按 agent-utils 的首次注册规则处理。 + for (Ai1SkillDO skill : skills.stream().sorted(Comparator.comparing(Ai1SkillDO::getId)).toList()) { + if (CommonStatusEnum.isEnable(skill.getStatus()) + && materializeSkill(root.resolve(sanitize(skill.getName())), skill.getId())) { + skillDirectories.add(root.resolve(sanitize(skill.getName())).toString()); + } + } + // 2.2 写入指纹标记,构建技能工具并缓存快照 + FileUtil.writeUtf8String(fingerprint, root.resolve(FILE_NAME_FINGERPRINT).toFile()); + ToolCallback skillsTool = skillDirectories.isEmpty() ? null + : SkillsTool.builder().addSkillsDirectories(skillDirectories).build(); + SkillSnapshot snapshot = new SkillSnapshot(fingerprint, root, skillsTool); + snapshotCache.put(agent.getId(), snapshot); + log.info("[sync][Agent({}) SKILL 物化完成,目录({})]", agent.getId(), root); + return snapshot; + } catch (Exception e) { + log.warn("[sync][Agent({}) SKILL 物化失败]", agent.getId(), e); + return cached; + } + } + } + + /** + * 计算 SKILL 指纹:按编号排序后拼接「编号:更新时间」,任一 SKILL 换绑或内容变更都会导致指纹变化 + * + * @param skills SKILL 列表 + * @return 指纹 + */ + private static String buildFingerprint(List skills) { + return skills.stream().sorted(Comparator.comparing(Ai1SkillDO::getId)) + .map(skill -> skill.getId() + ":" + skill.getUpdateTime()) + .collect(Collectors.joining(";")); + } + + /** + * 本地快照是否有效:指纹一致,且标记文件存在、内容一致(防止目录被外部清理) + */ + private static boolean isSnapshotValid(SkillSnapshot cached, String fingerprint) { + // 1. 内存快照不存在,或指纹不一致 + if (cached == null || ObjUtil.notEqual(fingerprint, cached.getFingerprint())) { + return false; + } + // 2. 标记文件不存在或内容不一致,说明目录已被外部清理或篡改 + File marker = cached.getRoot().resolve(FILE_NAME_FINGERPRINT).toFile(); + return FileUtil.exist(marker) && fingerprint.equals(FileUtil.readUtf8String(marker)); + } + + /** + * 物化单个 SKILL 的文件树到技能目录 + * + * @return 是否包含根级 SKILL.md + */ + private boolean materializeSkill(Path skillDirectory, Long skillId) { + // 1. 查询 SKILL 的全部节点,构建编号映射,用于沿 parentId 解析相对路径 + List files = skillFileService.getSkillFileListBySkillId(skillId); + Map fileMap = convertMap(files, Ai1SkillFileDO::getId); + + // 2. 逐个节点写入:文件写入内容(自动创建父目录),目录直接创建 + Map pathCache = new HashMap<>(); + boolean hasSkillFile = false; + for (Ai1SkillFileDO file : files) { + File target = skillDirectory.resolve(resolveRelativePath(file, fileMap, pathCache)).toFile(); + if (Ai1SkillFileTypeEnum.isFile(file.getType())) { + // 根级的 SKILL.md 是技能入口 + hasSkillFile |= Ai1SkillFileDO.NAME_SKILL.equals(file.getName()) + && ObjUtil.equal(file.getParentId(), Ai1SkillFileDO.PARENT_ID_ROOT); + FileUtil.writeUtf8String(StrUtil.nullToEmpty(file.getContent()), target); + } else { + FileUtil.mkdir(target); + } + } + + // 3. 缺少根级 SKILL.md 时,只物化文件,不作为技能注册 + if (!hasSkillFile) { + log.warn("[materializeSkill][SKILL({}) 缺少根级 SKILL.md]", skillId); + } + return hasSkillFile; + } + + /** + * 解析节点相对路径:沿 parentId 链自底向上拼接,每段名称净化以防目录穿越 + */ + private static Path resolveRelativePath(Ai1SkillFileDO file, Map fileMap, Map pathCache) { + Path cached = pathCache.get(file.getId()); + if (cached != null) { + return cached; + } + Path segment = Path.of(sanitize(file.getName())); + Ai1SkillFileDO parent = fileMap.get(file.getParentId()); + // 父节点为自身时(异常数据)按根级处理,避免无限递归 + Path path = parent == null || ObjUtil.equal(parent.getId(), file.getId()) + ? segment : resolveRelativePath(parent, fileMap, pathCache).resolve(segment); + pathCache.put(file.getId(), path); + return path; + } + + private Path buildAgentRoot(Long agentId) { + return Path.of(ai1Properties.getSkill().getRoot(), "agent_" + agentId); + } + + /** + * 路径净化:屏蔽路径分隔符与穿越片段 + */ + private static String sanitize(String name) { + if (StrUtil.isBlank(name)) { + return "_"; + } + return StrUtil.replace(StrUtil.replaceChars(name, new char[]{'\\', '/'}, "_"), "..", "_"); + } + + /** + * Agent 物化快照 + */ + @Data + @AllArgsConstructor + private static class SkillSnapshot { + + /** + * 指纹 + */ + private final String fingerprint; + /** + * Agent 物化根目录 + */ + private final Path root; + /** + * 技能工具;无有效 SKILL.md 时为空 + */ + private final ToolCallback skillsTool; + + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/package-info.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/package-info.java new file mode 100644 index 0000000..58a0997 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/package-info.java @@ -0,0 +1,10 @@ +/** + * ai1 模块,参考 XXL-AI 优化实现的 AI Agent 能力。 + * 例如说:Agent 编排、多供应商模型、MCP、SKILL、RAG 知识库、流式会话 + * + * 1. Controller URL:以 /ai1/ 开头,避免和其它 Module 冲突 + * 2. DataObject 表名:以 ai1_ 开头,方便在数据库中区分 + * + * 注意,由于 Ai1 模块下容易和其它模块重名,所以类名都加 Ai1 前缀 + */ +package cn.iocoder.yudao.module.ai1; diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentService.java new file mode 100644 index 0000000..14533f2 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentService.java @@ -0,0 +1,141 @@ +package cn.iocoder.yudao.module.ai1.service.agent; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 Agent Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1AgentService { + + /** + * 创建 Agent,初始为关闭 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createAgent(@Valid Ai1AgentSaveReqVO createReqVO); + + /** + * 更新 Agent;状态不在此修改 + * + * @param updateReqVO 更新信息 + */ + void updateAgent(@Valid Ai1AgentSaveReqVO updateReqVO); + + /** + * 删除 Agent,级联删除会话与消息,并清理 SKILL 物化沙箱 + * + * @param id 编号 + */ + void deleteAgent(Long id); + + /** + * 批量删除 Agent,级联删除会话与消息,并清理 SKILL 物化沙箱 + * + * @param ids 编号列表 + */ + void deleteAgentListByIds(List ids); + + /** + * 获得 Agent + * + * @param id 编号 + * @return Agent + */ + Ai1AgentDO getAgent(Long id); + + /** + * 校验 Agent 是否存在 + * + * @param id 编号 + * @return Agent + */ + Ai1AgentDO validateAgentExists(Long id); + + /** + * 获得 Agent 分页 + * + * @param pageReqVO 分页查询 + * @return Agent 分页 + */ + PageResult getAgentPage(Ai1AgentPageReqVO pageReqVO); + + /** + * 校验 Agent 存在且已开启 + * + * @param id 编号 + * @return Agent + */ + Ai1AgentDO validateAgentEnabled(Long id); + + /** + * 获得指定状态的 Agent 列表,用于会话页选择 Agent + * + * @param status 状态 + * @return Agent 列表 + */ + List getAgentListByStatus(Integer status); + + /** + * 获得 Agent 列表 + * + * @param ids 编号集合 + * @return Agent 列表 + */ + List getAgentList(Collection ids); + + /** + * 获得 Agent Map + * + * @param ids 编号集合 + * @return Agent Map + */ + default Map getAgentMap(Collection ids) { + return convertMap(getAgentList(ids), Ai1AgentDO::getId); + } + + /** + * 获得使用指定模型的 Agent 数量 + * + * @param modelIds 模型编号集合 + * @return Agent 数量 + */ + Long getAgentCountByModelIds(Collection modelIds); + + /** + * 获得绑定指定知识库的 Agent 数量 + * + * @param knowledgeBaseIds 知识库编号集合 + * @return Agent 数量 + */ + Long getAgentCountByKnowledgeBaseIds(Collection knowledgeBaseIds); + + /** + * 获得绑定指定 SKILL 的 Agent 数量 + * + * @param skillIds SKILL 编号集合 + * @return Agent 数量 + */ + Long getAgentCountBySkillIds(Collection skillIds); + + /** + * 修改 Agent 状态 + * + * @param id Agent 编号 + * @param status 状态 + */ + void updateAgentStatus(Long id, Integer status); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImpl.java new file mode 100644 index 0000000..d23a22e --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImpl.java @@ -0,0 +1,177 @@ +package cn.iocoder.yudao.module.ai1.service.agent; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.AGENT_DISABLE; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.AGENT_NOT_EXISTS; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MODEL_TYPE_NOT_CHAT; + +/** + * AI1 Agent Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1AgentServiceImpl implements Ai1AgentService { + + @Resource + private Ai1AgentMapper agentMapper; + + @Resource + private Ai1ModelService modelService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SessionService sessionService; + + @Resource + private Ai1SkillToolFactory skillToolFactory; + + @Override + public Long createAgent(Ai1AgentSaveReqVO createReqVO) { + // 1. 校验对话模型 + validateChatModel(createReqVO.getProviderId(), createReqVO.getModelId()); + + // 2. 插入 + Ai1AgentDO agent = BeanUtils.toBean(createReqVO, Ai1AgentDO.class).setStatus(CommonStatusEnum.DISABLE.getStatus()); + agentMapper.insert(agent); + return agent.getId(); + } + + @Override + public void updateAgent(Ai1AgentSaveReqVO updateReqVO) { + // 1. 校验存在、对话模型 + validateAgentExists(updateReqVO.getId()); + validateChatModel(updateReqVO.getProviderId(), updateReqVO.getModelId()); + + // 2. 更新 + agentMapper.updateById(BeanUtils.toBean(updateReqVO, Ai1AgentDO.class)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAgent(Long id) { + deleteAgentListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteAgentListByIds(List ids) { + // 1. 校验存在 + ids.forEach(this::validateAgentExists); + + // 2. 删除 Agent,同一事务内级联删除会话与消息 + agentMapper.deleteByIds(ids); + sessionService.deleteSessionListByAgentIds(ids); + + // 3. 清理 SKILL 物化沙箱 + ids.forEach(skillToolFactory::evict); + } + + @Override + public Ai1AgentDO getAgent(Long id) { + return agentMapper.selectById(id); + } + + @Override + public Ai1AgentDO validateAgentExists(Long id) { + Ai1AgentDO agent = agentMapper.selectById(id); + if (agent == null) { + throw exception(AGENT_NOT_EXISTS); + } + return agent; + } + + @Override + public Ai1AgentDO validateAgentEnabled(Long id) { + Ai1AgentDO agent = validateAgentExists(id); + if (CommonStatusEnum.isDisable(agent.getStatus())) { + throw exception(AGENT_DISABLE, agent.getName()); + } + return agent; + } + + @Override + public PageResult getAgentPage(Ai1AgentPageReqVO pageReqVO) { + return agentMapper.selectPage(pageReqVO); + } + + @Override + public List getAgentListByStatus(Integer status) { + return agentMapper.selectListByStatusOrderById(status); + } + + @Override + public List getAgentList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return agentMapper.selectByIds(ids); + } + + @Override + public Long getAgentCountByModelIds(Collection modelIds) { + if (CollUtil.isEmpty(modelIds)) { + return 0L; + } + return agentMapper.selectCountByModelIds(modelIds); + } + + @Override + public Long getAgentCountByKnowledgeBaseIds(Collection knowledgeBaseIds) { + if (CollUtil.isEmpty(knowledgeBaseIds)) { + return 0L; + } + return agentMapper.selectCountByKnowledgeBaseIds(knowledgeBaseIds); + } + + @Override + public Long getAgentCountBySkillIds(Collection skillIds) { + if (CollUtil.isEmpty(skillIds)) { + return 0L; + } + return agentMapper.selectCountBySkillIds(skillIds); + } + + @Override + public void updateAgentStatus(Long id, Integer status) { + // 1. 校验存在 + validateAgentExists(id); + + // 2. 更新状态 + agentMapper.updateById(new Ai1AgentDO().setId(id).setStatus(status)); + } + + /** + * 校验对话模型:Provider、模型存在且开启,模型归属于该 Provider,且为对话模型 + */ + private void validateChatModel(Long providerId, Long modelId) { + Ai1ModelRespBO model = modelService.getModelRespBO(providerId, modelId); + if (!Ai1ModelTypeEnum.isChat(model.getModelType())) { + throw exception(MODEL_TYPE_NOT_CHAT); + } + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeService.java new file mode 100644 index 0000000..621c3b1 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeService.java @@ -0,0 +1,39 @@ +package cn.iocoder.yudao.module.ai1.service.home; + +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; + +import java.util.List; + +/** + * AI1 首页统计 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1HomeService { + + /** + * 获得总量统计:Agent、SKILL、MCP、模型数量 + * + * @return 总量统计 + */ + Ai1HomeSummaryRespVO getSummary(); + + /** + * 获得近 days 个自然日(含今天)的按日消息统计,没有消息的日期补 0,按日期升序 + * + * @param days 天数 + * @return 每日消息数量 + */ + List getMessageSummaryByDate(Integer days); + + /** + * 获得近 days 个自然日(含今天)的按 Agent 消息统计,按数量倒序 + * + * @param days 天数 + * @return 各 Agent 消息数量 + */ + List getMessageSummaryByAgent(Integer days); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImpl.java new file mode 100644 index 0000000..4fecd18 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImpl.java @@ -0,0 +1,64 @@ +package cn.iocoder.yudao.module.ai1.service.home; + +import cn.hutool.core.date.DatePattern; +import cn.hutool.core.date.LocalDateTimeUtil; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; +import cn.iocoder.yudao.module.ai1.dal.mysql.home.Ai1HomeMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getDayBeginTime; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getDayEndTime; + +/** + * AI1 首页统计 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1HomeServiceImpl implements Ai1HomeService { + + @Resource + private Ai1HomeMapper homeMapper; + + @Override + public Ai1HomeSummaryRespVO getSummary() { + return homeMapper.selectSummary(); + } + + @Override + public List getMessageSummaryByDate(Integer days) { + // 1. 查询区间内有消息的日期 + LocalDateTime endTime = getDayEndTime(LocalDateTime.now()); + LocalDateTime beginTime = getDayBeginTime(endTime.minusDays(days - 1L)); + // 先查询按日聚合结果,再转换为“日期 -> 消息数量”的 Map,便于补齐没有消息的日期 + List list = homeMapper.selectMessageSummaryListByCreateTimeBetweenGroupByDate(beginTime, endTime); + Map countMap = convertMap(list, Ai1HomeMessageSummaryByDateRespVO::getDate, Ai1HomeMessageSummaryByDateRespVO::getCount); + + // 2. 生成连续日期序列,没有消息的日期补 0,保证折线不断点 + List result = new ArrayList<>(days); + for (int i = 0; i < days; i++) { + String date = LocalDateTimeUtil.format(beginTime.plusDays(i), DatePattern.NORM_DATE_FORMATTER); + result.add(new Ai1HomeMessageSummaryByDateRespVO().setDate(date).setCount(countMap.getOrDefault(date, 0L))); + } + return result; + } + + @Override + public List getMessageSummaryByAgent(Integer days) { + LocalDateTime endTime = getDayEndTime(LocalDateTime.now()); + LocalDateTime beginTime = getDayBeginTime(endTime.minusDays(days - 1L)); + return homeMapper.selectMessageSummaryListByCreateTimeBetweenGroupByAgentId(beginTime, endTime); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseService.java new file mode 100644 index 0000000..32adeb0 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseService.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBasePageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeSearchRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 知识库 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1KnowledgeBaseService { + + /** + * 创建知识库 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createKnowledgeBase(@Valid Ai1KnowledgeBaseSaveReqVO createReqVO); + + /** + * 更新知识库 + * + * @param updateReqVO 更新信息 + */ + void updateKnowledgeBase(@Valid Ai1KnowledgeBaseSaveReqVO updateReqVO); + + /** + * 删除知识库,连带删除文档与 Milvus 向量集合 + * + * @param id 编号 + */ + void deleteKnowledgeBase(Long id); + + /** + * 批量删除知识库,连带删除文档与 Milvus 向量集合 + * + * @param ids 编号列表 + */ + void deleteKnowledgeBaseListByIds(List ids); + + /** + * 获得知识库 + * + * @param id 编号 + * @return 知识库 + */ + Ai1KnowledgeBaseDO getKnowledgeBase(Long id); + + /** + * 校验知识库是否存在 + * + * @param id 编号 + * @return 知识库 + */ + Ai1KnowledgeBaseDO validateKnowledgeBaseExists(Long id); + + /** + * 获得知识库分页 + * + * @param pageReqVO 分页查询 + * @return 知识库分页 + */ + PageResult getKnowledgeBasePage(Ai1KnowledgeBasePageReqVO pageReqVO); + + /** + * 获得指定状态的知识库列表 + * + * @param status 状态 + * @return 知识库列表 + */ + List getKnowledgeBaseListByStatus(Integer status); + + /** + * 获得知识库列表 + * + * @param ids 编号集合 + * @return 知识库列表 + */ + List getKnowledgeBaseList(Collection ids); + + /** + * 获得引用指定嵌入模型的知识库数量 + * + * @param modelIds 模型编号集合 + * @return 知识库数量 + */ + Long getKnowledgeBaseCountByEmbeddingModelIds(Collection modelIds); + + /** + * 向量检索:按查询文本召回知识库相关分片 + * + * @param id 编号 + * @param query 查询文本 + * @param topK 检索数量,为空时使用知识库配置 + * @return 命中分片 + */ + List searchKnowledgeBase(Long id, String query, Integer topK); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImpl.java new file mode 100644 index 0000000..8b7331c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImpl.java @@ -0,0 +1,182 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBasePageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeSearchRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.knowledge.Ai1KnowledgeBaseMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 知识库 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class Ai1KnowledgeBaseServiceImpl implements Ai1KnowledgeBaseService { + + @Resource + private Ai1KnowledgeBaseMapper knowledgeBaseMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1KnowledgeDocumentService knowledgeDocumentService; + @Resource + private Ai1ModelService modelService; + + @Resource + private Ai1RagTool ragTool; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1AgentService agentService; + + @Override + public Long createKnowledgeBase(Ai1KnowledgeBaseSaveReqVO createReqVO) { + // 1. 校验向量化模型 + validateEmbeddingModel(createReqVO.getEmbeddingProviderId(), createReqVO.getEmbeddingModelId()); + + // 2. 插入 + Ai1KnowledgeBaseDO knowledgeBase = BeanUtils.toBean(createReqVO, Ai1KnowledgeBaseDO.class); + knowledgeBaseMapper.insert(knowledgeBase); + return knowledgeBase.getId(); + } + + @Override + public void updateKnowledgeBase(Ai1KnowledgeBaseSaveReqVO updateReqVO) { + // 1. 校验存在、向量化模型 + Ai1KnowledgeBaseDO knowledgeBase = validateKnowledgeBaseExists(updateReqVO.getId()); + validateEmbeddingModel(updateReqVO.getEmbeddingProviderId(), updateReqVO.getEmbeddingModelId()); + + // 2. 更新 + Ai1KnowledgeBaseDO updateObj = BeanUtils.toBean(updateReqVO, Ai1KnowledgeBaseDO.class); + knowledgeBaseMapper.updateById(updateObj); + + // 3. 向量化模型变更时,失效向量存储缓存,保证后续按新模型重建 + if (ObjUtil.notEqual(knowledgeBase.getEmbeddingProviderId(), updateObj.getEmbeddingProviderId()) + || ObjUtil.notEqual(knowledgeBase.getEmbeddingModelId(), updateObj.getEmbeddingModelId())) { + ragTool.evict(updateObj.getId()); + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteKnowledgeBase(Long id) { + deleteKnowledgeBaseListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteKnowledgeBaseListByIds(List ids) { + // 1.1 校验存在 + ids.forEach(this::validateKnowledgeBaseExists); + // 1.2 校验未被 Agent 绑定 + if (agentService.getAgentCountByKnowledgeBaseIds(ids) > 0) { + throw exception(KNOWLEDGE_BASE_USED_BY_AGENT); + } + + // 2. 删除知识库与其下文档 + knowledgeBaseMapper.deleteByIds(ids); + knowledgeDocumentService.deleteKnowledgeDocumentListByKnowledgeBaseIds(ids); + + // 3. 删除 Milvus 向量集合:外部资源无法回滚,事务提交后再执行,失败仅记录日志 + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + ids.forEach(ragTool::dropCollection); + } + }); + } + + @Override + public Ai1KnowledgeBaseDO getKnowledgeBase(Long id) { + return knowledgeBaseMapper.selectById(id); + } + + @Override + public Ai1KnowledgeBaseDO validateKnowledgeBaseExists(Long id) { + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseMapper.selectById(id); + if (knowledgeBase == null) { + throw exception(KNOWLEDGE_BASE_NOT_EXISTS); + } + return knowledgeBase; + } + + @Override + public PageResult getKnowledgeBasePage(Ai1KnowledgeBasePageReqVO pageReqVO) { + return knowledgeBaseMapper.selectPage(pageReqVO); + } + + @Override + public List getKnowledgeBaseListByStatus(Integer status) { + return knowledgeBaseMapper.selectListByStatus(status); + } + + @Override + public List getKnowledgeBaseList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return knowledgeBaseMapper.selectByIds(ids); + } + + @Override + public Long getKnowledgeBaseCountByEmbeddingModelIds(Collection modelIds) { + if (CollUtil.isEmpty(modelIds)) { + return 0L; + } + return knowledgeBaseMapper.selectCountByEmbeddingModelIds(modelIds); + } + + @Override + public List searchKnowledgeBase(Long id, String query, Integer topK) { + // 1. 校验存在(当前租户下),再访问其向量集合 + Ai1KnowledgeBaseDO knowledgeBase = validateKnowledgeBaseExists(id); + + // 2. 检索:业务异常(如模型被禁用)直接抛出,其余异常包装为检索失败 + try { + return ragTool.search(knowledgeBase, query, topK); + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + log.warn("[searchKnowledgeBase][知识库({}) query({}) topK({}) 向量检索失败]", id, query, topK, e); + throw exception(KNOWLEDGE_BASE_SEARCH_FAIL, e.getMessage()); + } + } + + /** + * 校验向量化模型:Provider、模型存在且开启,模型归属于该 Provider,且为嵌入模型 + */ + private void validateEmbeddingModel(Long providerId, Long modelId) { + Ai1ModelRespBO model = modelService.getModelRespBO(providerId, modelId); + if (!Ai1ModelTypeEnum.isEmbedding(model.getModelType())) { + throw exception(MODEL_TYPE_NOT_EMBEDDING); + } + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentService.java new file mode 100644 index 0000000..13e0c96 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentService.java @@ -0,0 +1,119 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentVectorizeRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import jakarta.validation.Valid; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 知识文档 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1KnowledgeDocumentService { + + /** + * 创建文档(粘贴文本),初始为未处理状态 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createKnowledgeDocument(@Valid Ai1KnowledgeDocumentSaveReqVO createReqVO); + + /** + * 上传文档:读取 .txt / .md 文本文件内容后创建 + * + * @param knowledgeBaseId 知识库编号 + * @param file 文件 + * @return 编号 + */ + Long uploadKnowledgeDocument(Long knowledgeBaseId, MultipartFile file); + + /** + * 更新文档;内容变更时重置为未处理状态,并清理旧向量 + * + * @param updateReqVO 更新信息 + */ + void updateKnowledgeDocument(@Valid Ai1KnowledgeDocumentSaveReqVO updateReqVO); + + /** + * 删除文档,连带清理向量 + * + * @param id 编号 + */ + void deleteKnowledgeDocument(Long id); + + /** + * 批量删除文档,连带清理向量 + * + * @param ids 编号列表 + */ + void deleteKnowledgeDocumentListByIds(List ids); + + /** + * 删除知识库下的全部文档;向量由知识库删除时整体 drop,这里不逐条清理 + * + * @param knowledgeBaseIds 知识库编号集合 + */ + void deleteKnowledgeDocumentListByKnowledgeBaseIds(Collection knowledgeBaseIds); + + /** + * 获得文档 + * + * @param id 编号 + * @return 文档 + */ + Ai1KnowledgeDocumentDO getKnowledgeDocument(Long id); + + /** + * 获得文档分页 + * + * @param pageReqVO 分页查询 + * @return 文档分页 + */ + PageResult getKnowledgeDocumentPage(Ai1KnowledgeDocumentPageReqVO pageReqVO); + + /** + * 获得文档列表 + * + * @param ids 编号集合 + * @return 文档列表 + */ + List getKnowledgeDocumentList(Collection ids); + + /** + * 获得指定编号的文档 Map + * + * @param ids 编号集合 + * @return 文档 Map + */ + default Map getKnowledgeDocumentMap(Collection ids) { + return convertMap(getKnowledgeDocumentList(ids), Ai1KnowledgeDocumentDO::getId); + } + + /** + * 文档向量化:分片 → 嵌入 → 写入 Milvus,并回写状态与分片数;失败时回写失败状态并抛出异常 + * + * @param id 编号 + * @return 分片数量 + */ + Integer vectorizeKnowledgeDocument(Long id); + + /** + * 知识库批量向量化:逐个向量化库下有内容的文档,单个失败不中断 + * + * @param knowledgeBaseId 知识库编号 + * @return 成功、失败数量 + */ + Ai1KnowledgeDocumentVectorizeRespVO vectorizeKnowledgeDocumentListByKnowledgeBaseId(Long knowledgeBaseId); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImpl.java new file mode 100644 index 0000000..dd971fe --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImpl.java @@ -0,0 +1,244 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.IoUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentVectorizeRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.knowledge.Ai1KnowledgeDocumentMapper; +import cn.iocoder.yudao.module.ai1.enums.knowledge.Ai1KnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.multipart.MultipartFile; + +import java.io.InputStream; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 知识文档 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class Ai1KnowledgeDocumentServiceImpl implements Ai1KnowledgeDocumentService { + + // TODO @芋艿:【优化】后续可以考虑支持更多文件类型; + /** + * 允许上传的文本文件扩展名 + */ + private static final List UPLOAD_FILE_SUFFIXES = Arrays.asList(".txt", ".md"); + + @Resource + private Ai1KnowledgeDocumentMapper knowledgeDocumentMapper; + + @Resource + private Ai1KnowledgeBaseService knowledgeBaseService; + @Resource + private Ai1ModelService modelService; + + @Resource + private Ai1RagTool ragTool; + + @Override + public Long createKnowledgeDocument(Ai1KnowledgeDocumentSaveReqVO createReqVO) { + // 1. 校验知识库存在 + knowledgeBaseService.validateKnowledgeBaseExists(createReqVO.getKnowledgeBaseId()); + + // 2. 插入:初始为未处理,需手动向量化 + Ai1KnowledgeDocumentDO document = BeanUtils.toBean(createReqVO, Ai1KnowledgeDocumentDO.class) + .setChunkCount(0).setStatus(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus()); + knowledgeDocumentMapper.insert(document); + return document.getId(); + } + + @Override + public Long uploadKnowledgeDocument(Long knowledgeBaseId, MultipartFile file) { + // 1.1 校验文件类型 + String filename = file.getOriginalFilename(); + if (StrUtil.isBlank(filename) || !StrUtil.endWithAnyIgnoreCase(filename, UPLOAD_FILE_SUFFIXES.toArray(new String[0]))) { + throw exception(KNOWLEDGE_DOCUMENT_FILE_TYPE_INVALID); + } + // 1.2 读取文本内容(UTF-8),并校验非空 + String content; + try (InputStream inputStream = file.getInputStream()) { + content = IoUtil.readUtf8(inputStream); + } catch (Exception e) { + log.warn("[uploadKnowledgeDocument][知识库({}) 文件({}) 读取失败]", knowledgeBaseId, filename, e); + throw exception(KNOWLEDGE_DOCUMENT_FILE_READ_FAIL, e.getMessage()); + } + if (StrUtil.isBlank(content)) { + throw exception(KNOWLEDGE_DOCUMENT_FILE_EMPTY); + } + + // 2. 创建文档 + Ai1KnowledgeDocumentSaveReqVO createReqVO = new Ai1KnowledgeDocumentSaveReqVO() + .setKnowledgeBaseId(knowledgeBaseId).setName(filename).setContent(content); + return createKnowledgeDocument(createReqVO); + } + + @Override + public void updateKnowledgeDocument(Ai1KnowledgeDocumentSaveReqVO updateReqVO) { + // 1. 校验存在 + Ai1KnowledgeDocumentDO document = validateKnowledgeDocumentExists(updateReqVO.getId()); + + // 2. 更新:归属知识库不可变更;内容变更时重置为未处理,需重新向量化 + Ai1KnowledgeDocumentDO updateObj = new Ai1KnowledgeDocumentDO().setId(document.getId()) + .setName(updateReqVO.getName()).setContent(updateReqVO.getContent()); + boolean contentChanged = ObjUtil.notEqual(document.getContent(), updateReqVO.getContent()); + if (contentChanged) { + updateObj.setChunkCount(0).setStatus(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus()); + } + knowledgeDocumentMapper.updateById(updateObj); + + // 3. 内容变更时清理旧向量 + if (contentChanged) { + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseService.getKnowledgeBase(document.getKnowledgeBaseId()); + if (knowledgeBase != null) { + ragTool.deleteByDocumentIds(knowledgeBase, Collections.singletonList(document.getId())); + } + } + } + + @Override + public void deleteKnowledgeDocument(Long id) { + deleteKnowledgeDocumentListByIds(Collections.singletonList(id)); + } + + @Override + public void deleteKnowledgeDocumentListByIds(List ids) { + // 1. 校验存在 + List documents = convertList(ids, this::validateKnowledgeDocumentExists); + + // 2. 删除 + knowledgeDocumentMapper.deleteByIds(ids); + + // 3. 按知识库分组清理向量 + Map> documentIdsMap = convertMultiMap(documents, + Ai1KnowledgeDocumentDO::getKnowledgeBaseId, Ai1KnowledgeDocumentDO::getId); + for (Ai1KnowledgeBaseDO knowledgeBase : knowledgeBaseService.getKnowledgeBaseList(documentIdsMap.keySet())) { + ragTool.deleteByDocumentIds(knowledgeBase, documentIdsMap.get(knowledgeBase.getId())); + } + } + + @Override + public void deleteKnowledgeDocumentListByKnowledgeBaseIds(Collection knowledgeBaseIds) { + if (CollUtil.isEmpty(knowledgeBaseIds)) { + return; + } + knowledgeDocumentMapper.deleteByKnowledgeBaseIds(knowledgeBaseIds); + } + + @Override + public Ai1KnowledgeDocumentDO getKnowledgeDocument(Long id) { + return knowledgeDocumentMapper.selectById(id); + } + + @Override + public PageResult getKnowledgeDocumentPage(Ai1KnowledgeDocumentPageReqVO pageReqVO) { + return knowledgeDocumentMapper.selectPage(pageReqVO); + } + + @Override + public List getKnowledgeDocumentList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return knowledgeDocumentMapper.selectByIds(ids); + } + + @Override + public Integer vectorizeKnowledgeDocument(Long id) { + // 1. 校验文档、所属知识库存在 + Ai1KnowledgeDocumentDO document = validateKnowledgeDocumentExists(id); + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseService.validateKnowledgeBaseExists(document.getKnowledgeBaseId()); + + // 2. 向量化,并回写状态与分片数 + return vectorizeKnowledgeDocument(knowledgeBase, document); + } + + @Override + public Ai1KnowledgeDocumentVectorizeRespVO vectorizeKnowledgeDocumentListByKnowledgeBaseId(Long knowledgeBaseId) { + // 1. 校验知识库存在、且有文档 + Ai1KnowledgeBaseDO knowledgeBase = knowledgeBaseService.validateKnowledgeBaseExists(knowledgeBaseId); + List documents = knowledgeDocumentMapper.selectListByKnowledgeBaseId(knowledgeBaseId); + if (CollUtil.isEmpty(documents)) { + throw exception(KNOWLEDGE_BASE_DOCUMENT_EMPTY); + } + // 1.2 校验嵌入模型可用:配置有误时直接提示,避免逐篇失败把整库文档刷成失败状态 + Ai1ModelRespBO embeddingModel = modelService.getModelRespBO( + knowledgeBase.getEmbeddingProviderId(), knowledgeBase.getEmbeddingModelId()); + if (!Ai1ModelTypeEnum.isEmbedding(embeddingModel.getModelType())) { + throw exception(MODEL_TYPE_NOT_EMBEDDING); + } + + // 2. 逐个向量化:跳过空内容文档,单个失败不中断 + int successCount = 0; + int failureCount = 0; + for (Ai1KnowledgeDocumentDO document : documents) { + if (StrUtil.isBlank(document.getContent())) { + continue; + } + try { + vectorizeKnowledgeDocument(knowledgeBase, document); + successCount++; + } catch (ServiceException e) { + failureCount++; + log.warn("[vectorizeKnowledgeDocumentListByKnowledgeBaseId][知识库({}) 文档({}) 向量化失败:{}]", + knowledgeBaseId, document.getId(), e.getMessage()); + } + } + return new Ai1KnowledgeDocumentVectorizeRespVO(successCount, failureCount); + } + + /** + * 单文档向量化:调用 RAG 工具写入向量后回写已向量化;任何失败都回写失败状态,并抛出业务异常 + */ + private Integer vectorizeKnowledgeDocument(Ai1KnowledgeBaseDO knowledgeBase, Ai1KnowledgeDocumentDO document) { + if (StrUtil.isBlank(document.getContent())) { + throw exception(KNOWLEDGE_DOCUMENT_CONTENT_EMPTY); + } + try { + int chunkCount = ragTool.vectorize(knowledgeBase, document.getId(), document.getContent()); + knowledgeDocumentMapper.updateById(new Ai1KnowledgeDocumentDO().setId(document.getId()) + .setChunkCount(chunkCount).setStatus(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus())); + return chunkCount; + } catch (Exception e) { + log.warn("[vectorizeKnowledgeDocument][文档({}) 向量化失败]", document.getId(), e); + knowledgeDocumentMapper.updateById(new Ai1KnowledgeDocumentDO().setId(document.getId()) + .setStatus(Ai1KnowledgeDocumentStatusEnum.FAILED.getStatus())); + if (e instanceof ServiceException) { + throw (ServiceException) e; + } + throw exception(KNOWLEDGE_DOCUMENT_VECTORIZE_FAIL, e.getMessage()); + } + } + + private Ai1KnowledgeDocumentDO validateKnowledgeDocumentExists(Long id) { + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(id); + if (document == null) { + throw exception(KNOWLEDGE_DOCUMENT_NOT_EXISTS); + } + return document; + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpService.java new file mode 100644 index 0000000..9cd1601 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpService.java @@ -0,0 +1,89 @@ +package cn.iocoder.yudao.module.ai1.service.mcp; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 MCP 服务 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1McpService { + + /** + * 创建 MCP + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createMcp(@Valid Ai1McpSaveReqVO createReqVO); + + /** + * 更新 MCP + * + * @param updateReqVO 更新信息 + */ + void updateMcp(@Valid Ai1McpSaveReqVO updateReqVO); + + /** + * 删除 MCP + * + * @param id 编号 + */ + void deleteMcp(Long id); + + /** + * 批量删除 MCP + * + * @param ids 编号列表 + */ + void deleteMcpListByIds(List ids); + + /** + * 获得 MCP + * + * @param id 编号 + * @return MCP + */ + Ai1McpDO getMcp(Long id); + + /** + * 获得 MCP 分页 + * + * @param pageReqVO 分页查询 + * @return MCP 分页 + */ + PageResult getMcpPage(Ai1McpPageReqVO pageReqVO); + + /** + * 获得指定状态的 MCP 列表 + * + * @param status 状态 + * @return MCP 列表 + */ + List getMcpListByStatus(Integer status); + + /** + * 获得 MCP 列表 + * + * @param ids 编号集合 + * @return MCP 列表 + */ + List getMcpList(Collection ids); + + /** + * 测试 MCP 连通性 + * + * @param id 编号 + * @return 测试结果 + */ + Ai1McpConnectRespVO testMcpConnect(Long id); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImpl.java new file mode 100644 index 0000000..9507738 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImpl.java @@ -0,0 +1,159 @@ +package cn.iocoder.yudao.module.ai1.service.mcp; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.map.MapUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.mcp.Ai1McpMapper; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpClientTool; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 MCP 服务 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1McpServiceImpl implements Ai1McpService { + + @Resource + private Ai1McpMapper mcpMapper; + + @Resource + private Ai1McpClientTool mcpClientTool; + + @Override + public Long createMcp(Ai1McpSaveReqVO createReqVO) { + Ai1McpDO mcp = buildMcp(createReqVO); + mcpMapper.insert(mcp); + return mcp.getId(); + } + + @Override + public void updateMcp(Ai1McpSaveReqVO updateReqVO) { + // 1. 校验存在 + validateMcpExists(updateReqVO.getId()); + + // 2. 更新:切换为本地时服务地址为空,由 updateForSave 主动置空 + mcpMapper.updateForSave(buildMcp(updateReqVO)); + + // 3. 释放本节点的旧连接,下次使用时按新配置重建 + mcpClientTool.evict(updateReqVO.getId()); + } + + @Override + public void deleteMcp(Long id) { + deleteMcpListByIds(Collections.singletonList(id)); + } + + @Override + public void deleteMcpListByIds(List ids) { + // 1. 校验存在 + ids.forEach(this::validateMcpExists); + + // 2. 删除 + mcpMapper.deleteByIds(ids); + + // 3. 释放本节点的连接与子进程;其他节点的闲置连接按访问过期自动释放 + ids.forEach(mcpClientTool::evict); + } + + @Override + public Ai1McpDO getMcp(Long id) { + return mcpMapper.selectById(id); + } + + @Override + public PageResult getMcpPage(Ai1McpPageReqVO pageReqVO) { + return mcpMapper.selectPage(pageReqVO); + } + + @Override + public List getMcpListByStatus(Integer status) { + return mcpMapper.selectListByStatus(status); + } + + @Override + public List getMcpList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return mcpMapper.selectByIds(ids); + } + + @Override + public Ai1McpConnectRespVO testMcpConnect(Long id) { + Ai1McpDO mcp = validateMcpExists(id); + return mcpClientTool.testConnect(mcp); + } + + private Ai1McpDO validateMcpExists(Long id) { + Ai1McpDO mcp = mcpMapper.selectById(id); + if (mcp == null) { + throw exception(MCP_NOT_EXISTS); + } + return mcp; + } + + /** + * 校验并归一化 MCP 配置,保证传输方式、config、平铺列一致: + * 1. 传输方式以 transport 列为准,写回 config 的 transport 字段 + * 2. 远程:服务地址优先取平铺列,为空时取 config.url;请求头以平铺列为准,统一写回 config + * 3. 本地:config 必须包含 command,服务地址置空 + */ + private Ai1McpDO buildMcp(Ai1McpSaveReqVO reqVO) { + // 1.1 解析 config + Map config = new LinkedHashMap<>(); + if (StrUtil.isNotBlank(reqVO.getConfig())) { + Map configMap = JsonUtils.parseMap(reqVO.getConfig()); + if (configMap == null) { + throw exception(MCP_CONFIG_INVALID); + } + config.putAll(configMap); + } + // 1.2 传输方式以列为准 + config.put("transport", reqVO.getTransport()); + + // 2. 按传输方式校验必填项 + String url = StrUtil.blankToDefault(reqVO.getUrl(), MapUtil.getStr(config, "url")); + Map headers = reqVO.getHeaders(); + if (Ai1McpTransportEnum.isStdio(reqVO.getTransport())) { + if (StrUtil.isBlank(MapUtil.getStr(config, "command"))) { + throw exception(MCP_COMMAND_REQUIRED); + } + url = null; + } else { + if (StrUtil.isBlank(url)) { + throw exception(MCP_URL_REQUIRED); + } + config.put("url", url); + // 空请求头统一保存为空集合,避免更新时跳过 null 后残留旧鉴权 + headers = MapUtil.emptyIfNull(headers); + if (MapUtil.isNotEmpty(headers)) { + config.put("headers", headers); + } else { + config.remove("headers"); + } + } + + // 3. 构建 + return BeanUtils.toBean(reqVO, Ai1McpDO.class).setUrl(url).setHeaders(headers) + .setConfig(JsonUtils.toJsonString(config)); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelService.java new file mode 100644 index 0000000..8f7d559 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelService.java @@ -0,0 +1,146 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelImportReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 模型 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1ModelService { + + /** + * 创建模型 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createModel(@Valid Ai1ModelSaveReqVO createReqVO); + + /** + * 更新模型 + * + * @param updateReqVO 更新信息 + */ + void updateModel(@Valid Ai1ModelSaveReqVO updateReqVO); + + /** + * 删除模型 + * + * @param id 编号 + */ + void deleteModel(Long id); + + /** + * 批量删除模型 + * + * @param ids 编号列表 + */ + void deleteModelListByIds(List ids); + + /** + * 获得模型 + * + * @param id 编号 + * @return 模型 + */ + Ai1ModelDO getModel(Long id); + + /** + * 校验模型是否存在 + * + * @param id 编号 + * @return 模型 + */ + Ai1ModelDO validateModelExists(Long id); + + /** + * 获得模型分页 + * + * @param pageReqVO 分页查询 + * @return 模型分页 + */ + PageResult getModelPage(Ai1ModelPageReqVO pageReqVO); + + /** + * 获得指定供应商下的模型列表 + * + * @param providerId 供应商编号 + * @return 模型列表 + */ + List getModelListByProviderId(Long providerId); + + /** + * 获得指定供应商、类型、状态的模型列表 + * + * @param providerId 供应商编号,为空时不过滤 + * @param type 类型,为空时不过滤 + * @param status 状态 + * @return 模型列表 + */ + List getModelListByProviderIdAndTypeAndStatus(Long providerId, Integer type, Integer status); + + /** + * 获得模型列表 + * + * @param ids 编号集合 + * @return 模型列表 + */ + List getModelList(Collection ids); + + /** + * 获得指定编号的模型 Map + * + * @param ids 编号集合 + * @return 模型 Map + */ + default Map getModelMap(Collection ids) { + return convertMap(getModelList(ids), Ai1ModelDO::getId); + } + + /** + * 获得指定供应商下的模型数量 + * + * @param providerIds 供应商编号集合 + * @return 模型数量 + */ + Long getModelCountByProviderIds(Collection providerIds); + + /** + * 拉取供应商远程可用的模型标识列表 + * + * @param providerId 供应商编号 + * @return 模型标识列表 + */ + List getRemoteModelList(Long providerId); + + /** + * 获得模型调用参数 + * + * @param providerId 供应商编号 + * @param modelId 模型编号 + * @return 模型调用参数 + */ + Ai1ModelRespBO getModelRespBO(Long providerId, Long modelId); + + /** + * 批量导入远程模型 + * + * @param importReqVO 导入信息 + * @return 实际导入数量 + */ + Integer importRemoteModelList(@Valid Ai1ModelImportReqVO importReqVO); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java new file mode 100644 index 0000000..ba3836c --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImpl.java @@ -0,0 +1,237 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelImportReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ModelMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.harness.model.Ai1ProviderTool; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; + +/** + * AI1 模型 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1ModelServiceImpl implements Ai1ModelService { + + @Resource + private Ai1ModelMapper modelMapper; + + @Resource + private Ai1ProviderService providerService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1AgentService agentService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1KnowledgeBaseService knowledgeBaseService; + + @Resource + private Ai1LlmModelFactory llmModelFactory; + + @Resource + private Ai1ProviderTool providerTool; + + @Override + public Long createModel(Ai1ModelSaveReqVO createReqVO) { + // 1. 校验供应商存在、模型标识唯一 + providerService.validateProviderExists(createReqVO.getProviderId()); + validateModelUnique(null, createReqVO.getProviderId(), createReqVO.getModel()); + + // 2. 插入 + Ai1ModelDO model = BeanUtils.toBean(createReqVO, Ai1ModelDO.class); + modelMapper.insert(model); + return model.getId(); + } + + @Override + public void updateModel(Ai1ModelSaveReqVO updateReqVO) { + // 1.1 校验存在、供应商归属不可变、模型标识唯一 + Ai1ModelDO model = validateModelExists(updateReqVO.getId()); + if (ObjUtil.notEqual(model.getProviderId(), updateReqVO.getProviderId())) { + throw exception(MODEL_NOT_BELONG_PROVIDER); + } + providerService.validateProviderExists(updateReqVO.getProviderId()); + validateModelUnique(updateReqVO.getId(), updateReqVO.getProviderId(), updateReqVO.getModel()); + // 1.2 被使用的模型禁止修改类型,避免 Agent 或知识库配置失效 + if (ObjUtil.notEqual(model.getType(), updateReqVO.getType())) { + List ids = Collections.singletonList(model.getId()); + if (agentService.getAgentCountByModelIds(ids) > 0) { + throw exception(MODEL_TYPE_USED_BY_AGENT); + } + if (knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(ids) > 0) { + throw exception(MODEL_TYPE_USED_BY_KNOWLEDGE_BASE); + } + } + + // 2. 更新 + Ai1ModelDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ModelDO.class); + modelMapper.updateById(updateObj); + + // 3. 失效本节点已缓存的模型实例 + llmModelFactory.evictByModelId(updateReqVO.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteModel(Long id) { + deleteModelListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteModelListByIds(List ids) { + // 1. 校验存在;被 Agent 或知识库使用时禁止删除 + ids.forEach(this::validateModelExists); + if (agentService.getAgentCountByModelIds(ids) > 0) { + throw exception(MODEL_USED_BY_AGENT); + } + if (knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(ids) > 0) { + throw exception(MODEL_USED_BY_KNOWLEDGE_BASE); + } + + // 2. 删除 + modelMapper.deleteByIds(ids); + + // 3. 失效本节点已缓存的模型实例 + ids.forEach(llmModelFactory::evictByModelId); + } + + @Override + public Ai1ModelDO getModel(Long id) { + return modelMapper.selectById(id); + } + + @Override + public Ai1ModelDO validateModelExists(Long id) { + Ai1ModelDO model = modelMapper.selectById(id); + if (model == null) { + throw exception(MODEL_NOT_EXISTS); + } + return model; + } + + @Override + public PageResult getModelPage(Ai1ModelPageReqVO pageReqVO) { + return modelMapper.selectPage(pageReqVO); + } + + @Override + public List getModelListByProviderId(Long providerId) { + return modelMapper.selectListByProviderId(providerId); + } + + @Override + public List getModelListByProviderIdAndTypeAndStatus(Long providerId, Integer type, Integer status) { + return modelMapper.selectListByProviderIdAndTypeAndStatus(providerId, type, status); + } + + @Override + public List getModelList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return modelMapper.selectByIds(ids); + } + + @Override + public Long getModelCountByProviderIds(Collection providerIds) { + if (CollUtil.isEmpty(providerIds)) { + return 0L; + } + return modelMapper.selectCountByProviderIds(providerIds); + } + + @Override + public List getRemoteModelList(Long providerId) { + // 1. 校验供应商存在 + Ai1ProviderDO provider = providerService.validateProviderExists(providerId); + + // 2. 拉取远程模型:调用前解析 ${ENV} 占位符,库中的原文不改 + return providerTool.listModels(resolveSpringPlaceholders(provider.getBaseUrl()), + resolveSpringPlaceholders(provider.getApiKey()), resolveSpringPlaceholders(provider.getHeaders())); + } + + @Override + public Ai1ModelRespBO getModelRespBO(Long providerId, Long modelId) { + // 1.1 校验供应商存在且开启 + Ai1ProviderDO provider = providerService.validateProviderExists(providerId); + if (CommonStatusEnum.isDisable(provider.getStatus())) { + throw exception(PROVIDER_DISABLE, provider.getName()); + } + // 1.2 校验模型存在、开启,且归属于该供应商 + Ai1ModelDO model = validateModelExists(modelId); + if (ObjUtil.notEqual(model.getProviderId(), providerId)) { + throw exception(MODEL_NOT_BELONG_PROVIDER); + } + if (CommonStatusEnum.isDisable(model.getStatus())) { + throw exception(MODEL_DISABLE, model.getName()); + } + + // 2. 组装调用参数:解析 ${ENV} 占位符,库中的原文不改,响应也不返回解析结果 + return new Ai1ModelRespBO().setProviderId(provider.getId()) + .setModelId(model.getId()).setModel(model.getModel()).setModelType(model.getType()) + .setBaseUrl(resolveSpringPlaceholders(provider.getBaseUrl())) + .setApiKey(resolveSpringPlaceholders(provider.getApiKey())) + .setHeaders(resolveSpringPlaceholders(provider.getHeaders())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Integer importRemoteModelList(Ai1ModelImportReqVO importReqVO) { + // 1.1 校验供应商存在 + providerService.validateProviderExists(importReqVO.getProviderId()); + // 1.2 过滤空白与已存在的模型标识(同时对入参去重,保持入参顺序) + List existModelList = modelMapper.selectListByProviderId(importReqVO.getProviderId()); + Set existModels = convertSet(existModelList, Ai1ModelDO::getModel); + Set importModels = convertSetBySupplier(importReqVO.getModels(), StrUtil::trim, LinkedHashSet::new); + importModels.removeIf(model -> StrUtil.isBlank(model) || existModels.contains(model)); + if (CollUtil.isEmpty(importModels)) { + throw exception(MODEL_IMPORT_ALL_EXISTS); + } + + // 2. 批量插入:展示名称默认同模型标识,类型默认会话,状态默认开启 + List models = convertList(importModels, model -> Ai1ModelDO.builder() + .providerId(importReqVO.getProviderId()).name(StrUtil.maxLength(model, 47)).model(model) + .type(Ai1ModelTypeEnum.CHAT.getType()).status(CommonStatusEnum.ENABLE.getStatus()).build()); + modelMapper.insertBatch(models); + return models.size(); + } + + /** + * 校验同一供应商下模型标识唯一 + */ + private void validateModelUnique(Long id, Long providerId, String model) { + Ai1ModelDO existModel = modelMapper.selectByProviderIdAndModel(providerId, model); + if (existModel != null && ObjUtil.notEqual(existModel.getId(), id)) { + throw exception(MODEL_DUPLICATE, model); + } + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderService.java new file mode 100644 index 0000000..079d7c2 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderService.java @@ -0,0 +1,110 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; + +/** + * AI1 供应商 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1ProviderService { + + /** + * 创建供应商 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createProvider(@Valid Ai1ProviderSaveReqVO createReqVO); + + /** + * 更新供应商 + * + * @param updateReqVO 更新信息 + */ + void updateProvider(@Valid Ai1ProviderSaveReqVO updateReqVO); + + /** + * 删除供应商 + * + * @param id 编号 + */ + void deleteProvider(Long id); + + /** + * 批量删除供应商 + * + * @param ids 编号列表 + */ + void deleteProviderListByIds(List ids); + + /** + * 获得供应商 + * + * @param id 编号 + * @return 供应商 + */ + Ai1ProviderDO getProvider(Long id); + + /** + * 校验供应商是否存在 + * + * @param id 编号 + * @return 供应商 + */ + Ai1ProviderDO validateProviderExists(Long id); + + /** + * 获得供应商分页 + * + * @param pageReqVO 分页查询 + * @return 供应商分页 + */ + PageResult getProviderPage(Ai1ProviderPageReqVO pageReqVO); + + /** + * 获得指定状态的供应商列表 + * + * @param status 状态 + * @return 供应商列表 + */ + List getProviderListByStatus(Integer status); + + /** + * 获得供应商列表 + * + * @param ids 编号集合 + * @return 供应商列表 + */ + List getProviderList(Collection ids); + + /** + * 获得供应商 Map + * + * @param ids 编号集合 + * @return 供应商 Map + */ + default Map getProviderMap(Collection ids) { + return convertMap(getProviderList(ids), Ai1ProviderDO::getId); + } + + /** + * 测试供应商连通性 + * + * @param id 编号 + * @return 测试结果 + */ + Ai1ProviderConnectRespVO testProviderConnect(Long id); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java new file mode 100644 index 0000000..51dc685 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImpl.java @@ -0,0 +1,140 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ProviderMapper; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.harness.model.Ai1ProviderTool; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.normalizeHeaders; +import static cn.iocoder.yudao.module.ai1.util.Ai1Utils.resolveSpringPlaceholders; + +/** + * AI1 供应商 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1ProviderServiceImpl implements Ai1ProviderService { + + @Resource + private Ai1ProviderMapper providerMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1ModelService modelService; + + @Resource + private Ai1LlmModelFactory llmModelFactory; + + @Resource + private Ai1ProviderTool providerTool; + + @Override + public Long createProvider(Ai1ProviderSaveReqVO createReqVO) { + Ai1ProviderDO provider = BeanUtils.toBean(createReqVO, Ai1ProviderDO.class); + provider.setHeaders(normalizeHeaders(createReqVO.getHeaders())); + providerMapper.insert(provider); + return provider.getId(); + } + + @Override + public void updateProvider(Ai1ProviderSaveReqVO updateReqVO) { + // 1. 校验存在 + validateProviderExists(updateReqVO.getId()); + + // 2. 更新:API 密钥为空时置为 null,由 updateById 跳过该字段,保持原密钥 + Ai1ProviderDO updateObj = BeanUtils.toBean(updateReqVO, Ai1ProviderDO.class); + updateObj.setHeaders(normalizeHeaders(updateReqVO.getHeaders())); + if (StrUtil.isBlank(updateObj.getApiKey())) { + updateObj.setApiKey(null); + } + providerMapper.updateById(updateObj); + + // 3. 地址、密钥、Header、状态可能变化,失效本节点已缓存的模型实例 + llmModelFactory.evictByProviderId(updateReqVO.getId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteProvider(Long id) { + deleteProviderListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteProviderListByIds(List ids) { + // 1. 校验存在;其下存在模型时禁止删除 + ids.forEach(this::validateProviderExists); + if (modelService.getModelCountByProviderIds(ids) > 0) { + throw exception(PROVIDER_HAS_MODEL); + } + + // 2. 删除 + providerMapper.deleteByIds(ids); + + // 3. 失效本节点已缓存的模型实例 + ids.forEach(llmModelFactory::evictByProviderId); + } + + @Override + public Ai1ProviderDO getProvider(Long id) { + return providerMapper.selectById(id); + } + + @Override + public Ai1ProviderDO validateProviderExists(Long id) { + Ai1ProviderDO provider = providerMapper.selectById(id); + if (provider == null) { + throw exception(PROVIDER_NOT_EXISTS); + } + return provider; + } + + @Override + public PageResult getProviderPage(Ai1ProviderPageReqVO pageReqVO) { + return providerMapper.selectPage(pageReqVO); + } + + @Override + public List getProviderListByStatus(Integer status) { + return providerMapper.selectListByStatus(status); + } + + @Override + public List getProviderList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return providerMapper.selectByIds(ids); + } + + @Override + public Ai1ProviderConnectRespVO testProviderConnect(Long id) { + // 1. 校验存在 + Ai1ProviderDO provider = validateProviderExists(id); + + // 2. 连通测试:调用前解析 ${ENV} 占位符,库中的原文不改 + return providerTool.testConnect(resolveSpringPlaceholders(provider.getBaseUrl()), + resolveSpringPlaceholders(provider.getApiKey()), resolveSpringPlaceholders(provider.getHeaders())); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java new file mode 100644 index 0000000..8ae40ee --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/model/bo/Ai1ModelRespBO.java @@ -0,0 +1,52 @@ +package cn.iocoder.yudao.module.ai1.service.model.bo; + +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.util.Map; + +/** + * AI1 模型调用参数 + * + * @author 芋道源码 + */ +@Data +@Accessors(chain = true) +public class Ai1ModelRespBO { + + /** + * 模型编号 + */ + private Long modelId; + /** + * 模型标识,例如说 gpt-4o + */ + private String model; + /** + * 模型类型 + * + * 枚举 {@link Ai1ModelTypeEnum} + */ + private Integer modelType; + + // ==================== 供应商 ==================== + + /** + * 供应商编号 + */ + private Long providerId; + /** + * 接口地址 + */ + private String baseUrl; + /** + * API 密钥 + */ + private String apiKey; + /** + * 请求 Header,value 可包含 {session} 占位符 + */ + private Map headers; + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageService.java new file mode 100644 index 0000000..61c3170 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageService.java @@ -0,0 +1,79 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import jakarta.validation.Valid; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 会话消息 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1SessionMessageService { + + /** + * 获得会话的消息列表,按编号升序 + * + * @param sessionId 会话编号 + * @return 消息列表 + */ + List getSessionMessageListBySessionId(Long sessionId); + + /** + * 获得会话中指定消息之前最近的若干条消息,按编号倒序 + * + * @param sessionId 会话编号 + * @param id 消息编号(不含) + * @param limit 数量上限 + * @return 消息列表 + */ + List getSessionMessageListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit); + + /** + * 发送消息(SSE 流式):校验会话归属 → 落库用户消息与助手占位 → 投递生成任务 → 打开 SSE 连接 + * + * 校验、落库失败时不抛出异常,而是返回只包含 error 事件的 SSE 连接 + * + * @param userId 用户编号 + * @param sendReqVO 发送信息 + * @return SSE 连接 + */ + SseEmitter sendSessionMessageStream(Long userId, @Valid Ai1SessionMessageSendReqVO sendReqVO); + + /** + * 断线续传(SSE 流式):校验消息 → 会话 → 用户归属后,从结果流 lastEventId 之后继续转发,不重新生成 + * + * @param userId 用户编号 + * @param messageId 助手消息编号,即结果流标识 + * @param lastEventId 已收到的最后一个事件编号,可为空 + * @return SSE 连接 + */ + SseEmitter resumeSessionMessageStream(Long userId, Long messageId, String lastEventId); + + /** + * 获得消息 + * + * @param id 编号 + * @return 消息 + */ + Ai1SessionMessageDO getSessionMessage(Long id); + + /** + * 更新消息(worker 回填助手消息) + * + * @param updateObj 更新对象 + */ + void updateSessionMessage(Ai1SessionMessageDO updateObj); + + /** + * 删除会话下的全部消息 + * + * @param sessionIds 会话编号集合 + */ + void deleteSessionMessageListBySessionIds(Collection sessionIds); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImpl.java new file mode 100644 index 0000000..d46e724 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImpl.java @@ -0,0 +1,179 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.harness.session.Ai1SessionStreamTool; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.Collection; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_MESSAGE_NOT_EXISTS; + +/** + * AI1 会话消息 Service 实现类 + * + * 流式生成与下发委托 {@link Ai1SessionStreamTool};本类只负责会话校验与消息落库 + * + * @author 芋道源码 + */ +@Service +@Validated +@Slf4j +public class Ai1SessionMessageServiceImpl implements Ai1SessionMessageService { + + @Resource + private Ai1SessionMessageMapper sessionMessageMapper; + + @Resource + private Ai1SessionService sessionService; + @Resource + private Ai1AgentService agentService; + + @Resource + private Ai1SessionStreamTool sessionStreamTool; + + @Override + public List getSessionMessageListBySessionId(Long sessionId) { + return sessionMessageMapper.selectListBySessionId(sessionId); + } + + @Override + public List getSessionMessageListBySessionIdAndIdLessThan(Long sessionId, Long id, Integer limit) { + return sessionMessageMapper.selectListBySessionIdAndIdLessThan(sessionId, id, limit); + } + + @Override + public SseEmitter sendSessionMessageStream(Long userId, Ai1SessionMessageSendReqVO sendReqVO) { + try { + // 1. 校验会话归属、Agent 存在且已开启 + Ai1SessionDO session = sessionService.validateSessionMy(userId, sendReqVO.getSessionId()); + agentService.validateAgentEnabled(session.getAgentId()); + + // 2. 同一事务内落库用户消息与助手占位 + Long messageId = getSelf().createRoundMessages(session, sendReqVO.getContent()); + + // 3. 投递生成任务,并打开 SSE 连接 + try { + sessionStreamTool.submit(messageId, session.getAgentId(), session.getId(), sendReqVO.getContent()); + } catch (Exception e) { + // 仅补偿投递失败;SSE 打开失败不影响已投递的后台任务 + failSubmittedMessage(messageId); + throw e; + } + return sessionStreamTool.open(messageId, null); + } catch (ServiceException e) { + return sessionStreamTool.error(e.getMessage()); + } catch (Exception e) { + log.warn("[sendSessionMessageStream][用户({}) 会话({}) 提交失败]", userId, sendReqVO.getSessionId(), e); + return sessionStreamTool.error("会话提交失败,请稍后重试"); + } + } + + private void failSubmittedMessage(Long messageId) { + try { + // Redis 超时不代表任务一定未写入,只更新仍在生成中的占位,避免覆盖 worker 已回填的终态 + sessionMessageMapper.update(new Ai1SessionMessageDO().setStatus(Ai1SessionMessageStatusEnum.FAILED.getStatus()), + new LambdaQueryWrapperX() + .eq(Ai1SessionMessageDO::getId, messageId) + .eq(Ai1SessionMessageDO::getRole, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .eq(Ai1SessionMessageDO::getStatus, Ai1SessionMessageStatusEnum.GENERATING.getStatus())); + } catch (Exception e) { + // 补偿失败不能覆盖原始投递异常,后续仍由结果流丢失检查兜底 + log.error("[failSubmittedMessage][助手消息({}) 投递失败状态回填失败]", messageId, e); + } + } + + @Override + public SseEmitter resumeSessionMessageStream(Long userId, Long messageId, String lastEventId) { + try { + // 1. 校验消息存在、为助手消息,且所属会话归属当前用户 + Ai1SessionMessageDO message = sessionMessageMapper.selectById(messageId); + if (message == null || !Ai1SessionMessageRoleEnum.isAssistant(message.getRole())) { + throw exception(SESSION_MESSAGE_NOT_EXISTS); + } + sessionService.validateSessionMy(userId, message.getSessionId()); + + // 2. 校验通过后,从 lastEventId 之后续传结果流,不重新生成 + return sessionStreamTool.open(messageId, lastEventId); + } catch (ServiceException e) { + return sessionStreamTool.error(e.getMessage()); + } catch (Exception e) { + log.warn("[resumeSessionMessageStream][用户({}) 消息({}) 续传失败]", userId, messageId, e); + return sessionStreamTool.error("会话续传失败,请稍后重试"); + } + } + + /** + * 开启一轮会话:首条消息自动生成标题 → 落库用户消息(完成)与助手占位(生成中)→ 刷新会话活跃时间 + * + * @param session 已校验归属的会话 + * @param content 提问内容 + * @return 助手消息编号,即结果流标识 + */ + @Transactional(rollbackFor = Exception.class) + public Long createRoundMessages(Ai1SessionDO session, String content) { + // 1. 首条消息时,使用提问内容作为标题(超长截断) + if (Ai1SessionDO.TITLE_DEFAULT.equals(session.getTitle()) + && sessionMessageMapper.selectCountBySessionId(session.getId()) == 0) { + String title = content.trim(); + sessionService.updateSessionTitle(session.getId(), title.length() > Ai1SessionDO.TITLE_MAX_LENGTH + ? StrUtil.sub(title, 0, Ai1SessionDO.TITLE_MAX_LENGTH - 3) + "..." : title); + } + + // 2. 用户消息 + 助手占位;刷新页面后,前端按生成中的助手消息编号续传 + sessionMessageMapper.insert(new Ai1SessionMessageDO().setSessionId(session.getId()) + .setRole(Ai1SessionMessageRoleEnum.USER.getRole()).setContent(content) + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus())); + Ai1SessionMessageDO assistantMessage = new Ai1SessionMessageDO().setSessionId(session.getId()) + .setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()).setContent("") + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus()); + sessionMessageMapper.insert(assistantMessage); + + // 3. 刷新会话活跃时间 + sessionService.touchSession(session.getId()); + return assistantMessage.getId(); + } + + @Override + public Ai1SessionMessageDO getSessionMessage(Long id) { + return sessionMessageMapper.selectById(id); + } + + @Override + public void updateSessionMessage(Ai1SessionMessageDO updateObj) { + sessionMessageMapper.updateById(updateObj); + } + + @Override + public void deleteSessionMessageListBySessionIds(Collection sessionIds) { + if (CollUtil.isEmpty(sessionIds)) { + return; + } + sessionMessageMapper.deleteBySessionIds(sessionIds); + } + + /** + * 获得自身的代理对象,解决 AOP 生效问题 + */ + private Ai1SessionMessageServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionService.java new file mode 100644 index 0000000..e034443 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionService.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 会话 Service 接口 + * + * 「我的」系列方法按登录用户校验归属,避免越权访问他人会话 + * + * @author 芋道源码 + */ +public interface Ai1SessionService { + + /** + * 创建我的会话;后台会话不要求 Agent 已发布 + * + * @param userId 用户编号 + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createSessionMy(Long userId, @Valid Ai1SessionCreateMyReqVO createReqVO); + + /** + * 修改我的会话标题 + * + * @param userId 用户编号 + * @param updateReqVO 修改信息 + */ + void updateSessionMy(Long userId, @Valid Ai1SessionUpdateMyReqVO updateReqVO); + + /** + * 删除我的会话,连带删除消息 + * + * @param userId 用户编号 + * @param id 编号 + */ + void deleteSessionMy(Long userId, Long id); + + /** + * 校验会话存在,且归属于指定用户 + * + * @param userId 用户编号 + * @param id 编号 + * @return 会话 + */ + Ai1SessionDO validateSessionMy(Long userId, Long id); + + /** + * 获得指定用户在指定 Agent 下的会话列表,按编号倒序 + * + * @param agentId Agent 编号 + * @param userId 用户编号 + * @return 会话列表 + */ + List getSessionListByAgentIdAndUserId(Long agentId, Long userId); + + /** + * 获得会话分页(管理端查看 Agent 的全部会话) + * + * @param pageReqVO 分页查询 + * @return 会话分页 + */ + PageResult getSessionPage(Ai1SessionPageReqVO pageReqVO); + + /** + * 修改会话标题(首条消息发送时自动命名) + * + * @param id 编号 + * @param title 标题 + */ + void updateSessionTitle(Long id, String title); + + /** + * 刷新会话的更新时间,即最近活跃时间 + * + * @param id 编号 + */ + void touchSession(Long id); + + /** + * 删除 Agent 下的全部会话与消息(Agent 删除时调用) + * + * @param agentIds Agent 编号集合 + */ + void deleteSessionListByAgentIds(Collection agentIds); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImpl.java new file mode 100644 index 0000000..96d31d6 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImpl.java @@ -0,0 +1,123 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMapper; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_NOT_EXISTS; + +/** + * AI1 会话 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1SessionServiceImpl implements Ai1SessionService { + + @Resource + private Ai1SessionMapper sessionMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1AgentService agentService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SessionMessageService sessionMessageService; + + @Override + public Long createSessionMy(Long userId, Ai1SessionCreateMyReqVO createReqVO) { + // 1. 校验 Agent 存在且已开启 + agentService.validateAgentEnabled(createReqVO.getAgentId()); + + // 2. 插入:首条消息发送后,标题自动替换为提问内容 + Ai1SessionDO session = new Ai1SessionDO().setAgentId(createReqVO.getAgentId()) + .setUserId(userId).setTitle(Ai1SessionDO.TITLE_DEFAULT); + sessionMapper.insert(session); + return session.getId(); + } + + @Override + public void updateSessionMy(Long userId, Ai1SessionUpdateMyReqVO updateReqVO) { + // 1. 校验归属 + validateSessionMy(userId, updateReqVO.getId()); + + // 2. 更新标题 + updateSessionTitle(updateReqVO.getId(), updateReqVO.getTitle().trim()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSessionMy(Long userId, Long id) { + // 1. 校验归属 + validateSessionMy(userId, id); + + // 2. 删除会话与消息 + sessionMapper.deleteById(id); + sessionMessageService.deleteSessionMessageListBySessionIds(Collections.singletonList(id)); + } + + @Override + public Ai1SessionDO validateSessionMy(Long userId, Long id) { + Ai1SessionDO session = sessionMapper.selectById(id); + if (session == null || ObjUtil.notEqual(session.getUserId(), userId)) { + throw exception(SESSION_NOT_EXISTS); + } + return session; + } + + @Override + public List getSessionListByAgentIdAndUserId(Long agentId, Long userId) { + return sessionMapper.selectListByAgentIdAndUserId(agentId, userId); + } + + @Override + public PageResult getSessionPage(Ai1SessionPageReqVO pageReqVO) { + return sessionMapper.selectPage(pageReqVO); + } + + @Override + public void updateSessionTitle(Long id, String title) { + sessionMapper.updateById(new Ai1SessionDO().setId(id).setTitle(title)); + } + + @Override + public void touchSession(Long id) { + sessionMapper.updateById(new Ai1SessionDO().setId(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSessionListByAgentIds(Collection agentIds) { + // 1. 查询 Agent 下的全部会话编号 + if (CollUtil.isEmpty(agentIds)) { + return; + } + List sessionIds = convertList(sessionMapper.selectListByAgentIds(agentIds), Ai1SessionDO::getId); + if (CollUtil.isEmpty(sessionIds)) { + return; + } + + // 2. 删除会话与消息 + sessionMapper.deleteByIds(sessionIds); + sessionMessageService.deleteSessionMessageListBySessionIds(sessionIds); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileService.java new file mode 100644 index 0000000..a4cdd26 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileService.java @@ -0,0 +1,87 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileContentReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileCreateReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileMoveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileRenameReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 SKILL 内容文件 Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1SkillFileService { + + /** + * 创建 SKILL 的默认文件:SKILL.md(含 frontmatter)、scripts/、reference/,均为固定节点 + * + * @param skill SKILL + */ + void createDefaultSkillFileList(Ai1SkillDO skill); + + /** + * 创建目录或文件;文件按扩展名推断类型,并填充默认内容 + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createSkillFile(@Valid Ai1SkillFileCreateReqVO createReqVO); + + /** + * 重命名节点;固定节点禁止 + * + * @param renameReqVO 重命名信息 + */ + void renameSkillFile(@Valid Ai1SkillFileRenameReqVO renameReqVO); + + /** + * 移动节点;固定节点禁止,且不能移入自身或其后代 + * + * @param moveReqVO 移动信息 + */ + void moveSkillFile(@Valid Ai1SkillFileMoveReqVO moveReqVO); + + /** + * 保存文件内容;目录不支持 + * + * @param contentReqVO 内容信息 + */ + void updateSkillFileContent(@Valid Ai1SkillFileContentReqVO contentReqVO); + + /** + * 删除节点;固定节点禁止,目录递归删除后代 + * + * @param id 编号 + */ + void deleteSkillFile(Long id); + + /** + * 删除 SKILL 下的全部节点(SKILL 删除时调用) + * + * @param skillIds SKILL 编号集合 + */ + void deleteSkillFileListBySkillIds(Collection skillIds); + + /** + * 获得节点 + * + * @param id 编号 + * @return 节点 + */ + Ai1SkillFileDO getSkillFile(Long id); + + /** + * 获得 SKILL 下的全部节点,目录优先、按排序号升序 + * + * @param skillId SKILL 编号 + * @return 节点列表 + */ + List getSkillFileListBySkillId(Long skillId); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImpl.java new file mode 100644 index 0000000..040d859 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImpl.java @@ -0,0 +1,310 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.io.FileUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileContentReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileCreateReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileMoveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileRenameReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillFileMapper; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 SKILL 内容文件 Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1SkillFileServiceImpl implements Ai1SkillFileService { + + /** + * 固定目录:scripts,存放可执行脚本 + */ + private static final String DIRECTORY_NAME_SCRIPTS = "scripts"; + /** + * 固定目录:reference,存放参考文档 + */ + private static final String DIRECTORY_NAME_REFERENCE = "reference"; + + /** + * SKILL.md 默认内容模板:占位符依次为名称、描述、名称 + */ + private static final String SKILL_FILE_CONTENT_TEMPLATE = "---\n" + + "name: {}\n" + + "description: {}\n" + + "---\n\n" + + "# {}\n\n" + + "本 Skill 文件树符合社区规范:`SKILL.md` 为入口,引用文件用相对路径。\n\n" + + "- `scripts/`:可存放可执行脚本(.py / .sh / .js 等)\n" + + "- `reference/`:可存放参考文档(.md / .json / .yaml 等)\n" + + "- 其他文件/目录可自由新增扩展\n"; + /** + * 新建 Markdown 文件的默认内容模板 + */ + private static final String MARKDOWN_FILE_CONTENT_TEMPLATE = "# 新文档\n\n请在此编写内容(Markdown)。\n"; + + /** + * 名称最大长度 + */ + private static final int NAME_MAX_LENGTH = 200; + + @Resource + private Ai1SkillFileMapper skillFileMapper; + + @Resource + private Ai1SkillService skillService; + + @Override + public void createDefaultSkillFileList(Ai1SkillDO skill) { + // 1. 新建时用管理信息初始化 SKILL.md,后续面向模型的名称、描述由文件编辑器独立维护 + String content = StrUtil.format(SKILL_FILE_CONTENT_TEMPLATE, toFrontmatterValue(skill.getName()), + toFrontmatterValue(skill.getDescription()), skill.getName()); + + // 2. 插入固定节点:SKILL.md + scripts/ + reference/ + Ai1SkillFileDO skillFile = Ai1SkillFileDO.builder().skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(Ai1SkillFileDO.NAME_SKILL).type(Ai1SkillFileTypeEnum.FILE.getType()).fileType("md").content(content) + .locked(true).sort(1).build(); + Ai1SkillFileDO scriptsDirectory = Ai1SkillFileDO.builder().skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(DIRECTORY_NAME_SCRIPTS).type(Ai1SkillFileTypeEnum.DIRECTORY.getType()).locked(true).sort(2).build(); + Ai1SkillFileDO referenceDirectory = Ai1SkillFileDO.builder().skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(DIRECTORY_NAME_REFERENCE).type(Ai1SkillFileTypeEnum.DIRECTORY.getType()).locked(true).sort(3).build(); + skillFileMapper.insertBatch(Arrays.asList(skillFile, scriptsDirectory, referenceDirectory)); + } + + private static String toFrontmatterValue(String value) { + // 元数据值保持单行,避免名称、描述中的换行被解析成额外的字段 + return StrUtil.trim(StrUtil.replaceChars(StrUtil.nullToEmpty(value), new char[]{'\r', '\n'}, " ")); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSkillFile(Ai1SkillFileCreateReqVO createReqVO) { + // 1.1 校验 SKILL 存在、名称格式、父目录 + skillService.validateSkillExists(createReqVO.getSkillId()); + String name = validateSkillFileName(createReqVO.getName()); + validateParentDirectory(createReqVO.getSkillId(), createReqVO.getParentId()); + // 1.2 校验同级唯一 + validateSkillFileNameUnique(null, createReqVO.getSkillId(), createReqVO.getParentId(), name); + + // 2. 插入:排序号追加到同级末尾;文件按扩展名推断类型并填充默认内容 + List siblings = skillFileMapper.selectListBySkillIdAndParentId(createReqVO.getSkillId(), createReqVO.getParentId()); + Integer maxSort = getMaxValue(siblings, Ai1SkillFileDO::getSort); + Ai1SkillFileDO skillFile = Ai1SkillFileDO.builder().skillId(createReqVO.getSkillId()).parentId(createReqVO.getParentId()) + .name(name).type(createReqVO.getType()).locked(false).sort(maxSort != null ? maxSort + 1 : 1).build(); + // Markdown 文件使用默认模板内容,其他文件为空内容 + if (Ai1SkillFileTypeEnum.isFile(createReqVO.getType())) { + skillFile.setFileType(parseFileType(name)); + skillFile.setContent("md".equals(skillFile.getFileType()) ? MARKDOWN_FILE_CONTENT_TEMPLATE : ""); + } + skillFileMapper.insert(skillFile); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(createReqVO.getSkillId()); + return skillFile.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void renameSkillFile(Ai1SkillFileRenameReqVO renameReqVO) { + // 1. 校验存在、非固定、名称格式、同级唯一 + Ai1SkillFileDO skillFile = validateSkillFileExists(renameReqVO.getId()); + validateSkillFileNotLocked(skillFile, "重命名"); + String name = validateSkillFileName(renameReqVO.getName()); + validateSkillFileNameUnique(skillFile.getId(), skillFile.getSkillId(), skillFile.getParentId(), name); + + // 2. 更新:文件同步刷新扩展名 + Ai1SkillFileDO updateObj = new Ai1SkillFileDO().setId(skillFile.getId()).setName(name); + if (Ai1SkillFileTypeEnum.isFile(skillFile.getType())) { + updateObj.setFileType(parseFileType(name)); + } + skillFileMapper.updateById(updateObj); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(skillFile.getSkillId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void moveSkillFile(Ai1SkillFileMoveReqVO moveReqVO) { + // 1.1 校验存在、非固定 + Ai1SkillFileDO skillFile = validateSkillFileExists(moveReqVO.getId()); + validateSkillFileNotLocked(skillFile, "移动"); + // 1.2 校验目标目录:不能是自身或其后代,且必须是同一 SKILL 下的目录 + Long parentId = moveReqVO.getParentId(); + if (ObjUtil.equal(parentId, skillFile.getId())) { + throw exception(SKILL_FILE_MOVE_TO_SELF); + } + if (isDescendant(skillFile.getSkillId(), skillFile.getId(), parentId)) { + throw exception(SKILL_FILE_MOVE_TO_DESCENDANT); + } + validateParentDirectory(skillFile.getSkillId(), parentId); + // 1.3 校验目标目录下同名唯一 + validateSkillFileNameUnique(skillFile.getId(), skillFile.getSkillId(), parentId, skillFile.getName()); + + // 2. 更新 + skillFileMapper.updateById(new Ai1SkillFileDO().setId(skillFile.getId()).setParentId(parentId)); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(skillFile.getSkillId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateSkillFileContent(Ai1SkillFileContentReqVO contentReqVO) { + // 1. 校验存在,且为文件 + Ai1SkillFileDO skillFile = validateSkillFileExists(contentReqVO.getId()); + if (!Ai1SkillFileTypeEnum.isFile(skillFile.getType())) { + throw exception(SKILL_FILE_NOT_FILE); + } + + // 2. 更新 + skillFileMapper.updateById(new Ai1SkillFileDO().setId(skillFile.getId()).setContent(contentReqVO.getContent())); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(skillFile.getSkillId()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSkillFile(Long id) { + // 1. 校验存在、非固定 + Ai1SkillFileDO skillFile = validateSkillFileExists(id); + validateSkillFileNotLocked(skillFile, "删除"); + + // 2.1 收集自身及全部后代 + List skillFiles = skillFileMapper.selectListBySkillId(skillFile.getSkillId()); + Map> childIdsMap = convertMultiMap(skillFiles, Ai1SkillFileDO::getParentId, Ai1SkillFileDO::getId); + List ids = new ArrayList<>(); + ids.add(skillFile.getId()); + Deque queue = new ArrayDeque<>(ids); + // 按层级向下广度遍历,最多 Short.MAX_VALUE 次,避免异常数据中的环导致死循环 + for (int i = 0; i < Short.MAX_VALUE && !queue.isEmpty(); i++) { + List childIds = childIdsMap.get(queue.poll()); + if (CollUtil.isNotEmpty(childIds)) { + ids.addAll(childIds); + queue.addAll(childIds); + } + } + // 2.2 逻辑删除 + skillFileMapper.deleteByIds(ids); + + // 3. 刷新 SKILL 更新时间 + skillService.touchSkill(skillFile.getSkillId()); + } + + @Override + public void deleteSkillFileListBySkillIds(Collection skillIds) { + if (CollUtil.isEmpty(skillIds)) { + return; + } + skillFileMapper.deleteBySkillIds(skillIds); + } + + @Override + public Ai1SkillFileDO getSkillFile(Long id) { + return skillFileMapper.selectById(id); + } + + @Override + public List getSkillFileListBySkillId(Long skillId) { + return skillFileMapper.selectListBySkillId(skillId); + } + + private Ai1SkillFileDO validateSkillFileExists(Long id) { + Ai1SkillFileDO skillFile = skillFileMapper.selectById(id); + if (skillFile == null) { + throw exception(SKILL_FILE_NOT_EXISTS); + } + return skillFile; + } + + private static void validateSkillFileNotLocked(Ai1SkillFileDO skillFile, String operation) { + if (Boolean.TRUE.equals(skillFile.getLocked())) { + throw exception(SKILL_FILE_LOCKED, operation); + } + } + + /** + * 校验名称格式:去除首尾空白,长度不超过 200,且不包含路径分隔符、控制字符,不能为 . 或 .. + * + * @return 去除首尾空白后的名称 + */ + private static String validateSkillFileName(String name) { + String trimmed = StrUtil.trim(name); + if (StrUtil.isEmpty(trimmed) || trimmed.length() > NAME_MAX_LENGTH || StrUtil.equalsAny(trimmed, ".", "..") + || StrUtil.containsAny(trimmed, '/', '\\') || trimmed.chars().anyMatch(Character::isISOControl)) { + throw exception(SKILL_FILE_NAME_INVALID); + } + return trimmed; + } + + /** + * 校验父目录:根级直接通过;否则必须是同一 SKILL 下的目录 + */ + private void validateParentDirectory(Long skillId, Long parentId) { + if (ObjUtil.equal(parentId, Ai1SkillFileDO.PARENT_ID_ROOT)) { + return; + } + Ai1SkillFileDO parent = skillFileMapper.selectById(parentId); + if (parent == null) { + throw exception(SKILL_FILE_PARENT_NOT_EXISTS); + } + if (ObjUtil.notEqual(parent.getSkillId(), skillId) || !Ai1SkillFileTypeEnum.isDirectory(parent.getType())) { + throw exception(SKILL_FILE_PARENT_INVALID); + } + } + + private void validateSkillFileNameUnique(Long id, Long skillId, Long parentId, String name) { + Ai1SkillFileDO skillFile = skillFileMapper.selectBySkillIdAndParentIdAndName(skillId, parentId, name); + if (skillFile != null && ObjUtil.notEqual(skillFile.getId(), id)) { + throw exception(SKILL_FILE_NAME_DUPLICATE, name); + } + } + + /** + * 判断 candidateId 是否为 nodeId 的后代(移动时的成环保护) + */ + private boolean isDescendant(Long skillId, Long nodeId, Long candidateId) { + // 1. 查询 SKILL 下全部节点,构建编号映射 + List skillFiles = skillFileMapper.selectListBySkillId(skillId); + Map fileMap = convertMap(skillFiles, Ai1SkillFileDO::getId); + + // 2. 沿父链向上查找,最多遍历节点总数次,防御异常数据中的环 + Long currentId = candidateId; + for (int i = 0; i <= fileMap.size() && ObjUtil.notEqual(currentId, Ai1SkillFileDO.PARENT_ID_ROOT); i++) { + Ai1SkillFileDO current = fileMap.get(currentId); + if (current == null) { + return false; + } + if (ObjUtil.equal(current.getParentId(), nodeId)) { + return true; + } + currentId = current.getParentId(); + } + return false; + } + + /** + * 从文件名推断扩展名:小写,无扩展名时默认 txt + */ + private static String parseFileType(String name) { + String suffix = FileUtil.getSuffix(name); + return StrUtil.isEmpty(suffix) ? "txt" : suffix.toLowerCase(Locale.ROOT); + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillService.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillService.java new file mode 100644 index 0000000..78e5e16 --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillService.java @@ -0,0 +1,95 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import jakarta.validation.Valid; + +import java.util.Collection; +import java.util.List; + +/** + * AI1 SKILL Service 接口 + * + * @author 芋道源码 + */ +public interface Ai1SkillService { + + /** + * 创建 SKILL + * + * @param createReqVO 创建信息 + * @return 编号 + */ + Long createSkill(@Valid Ai1SkillSaveReqVO createReqVO); + + /** + * 更新 SKILL + * + * @param updateReqVO 更新信息 + */ + void updateSkill(@Valid Ai1SkillSaveReqVO updateReqVO); + + /** + * 删除 SKILL,级联删除内容文件 + * + * @param id 编号 + */ + void deleteSkill(Long id); + + /** + * 批量删除 SKILL,级联删除内容文件 + * + * @param ids 编号列表 + */ + void deleteSkillListByIds(List ids); + + /** + * 获得 SKILL + * + * @param id 编号 + * @return SKILL + */ + Ai1SkillDO getSkill(Long id); + + /** + * 校验 SKILL 是否存在 + * + * @param id 编号 + * @return SKILL + */ + Ai1SkillDO validateSkillExists(Long id); + + /** + * 获得 SKILL 分页 + * + * @param pageReqVO 分页查询 + * @return SKILL 分页 + */ + PageResult getSkillPage(Ai1SkillPageReqVO pageReqVO); + + /** + * 获得指定状态的 SKILL 列表 + * + * @param status 状态 + * @return SKILL 列表 + */ + List getSkillListByStatus(Integer status); + + /** + * 获得 SKILL 列表 + * + * @param ids 编号集合 + * @return SKILL 列表 + */ + List getSkillList(Collection ids); + + /** + * 刷新 SKILL 的更新时间:内容文件变更后调用,作为本地物化的变更指纹 + * + * @param id 编号 + */ + void touchSkill(Long id); + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImpl.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImpl.java new file mode 100644 index 0000000..36da4eb --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImpl.java @@ -0,0 +1,138 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillPageReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillMapper; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import jakarta.annotation.Resource; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; + +/** + * AI1 SKILL Service 实现类 + * + * @author 芋道源码 + */ +@Service +@Validated +public class Ai1SkillServiceImpl implements Ai1SkillService { + + @Resource + private Ai1SkillMapper skillMapper; + + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1SkillFileService skillFileService; + @Resource + @Lazy // 延迟加载,避免循环依赖 + private Ai1AgentService agentService; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createSkill(Ai1SkillSaveReqVO createReqVO) { + // 1. 校验名称唯一 + validateSkillNameUnique(null, createReqVO.getName()); + + // 2. 插入 + Ai1SkillDO skill = BeanUtils.toBean(createReqVO, Ai1SkillDO.class); + skillMapper.insert(skill); + + // 3. 创建默认文件:SKILL.md + scripts/ + reference/ + skillFileService.createDefaultSkillFileList(skill); + return skill.getId(); + } + + @Override + public void updateSkill(Ai1SkillSaveReqVO updateReqVO) { + // 1. 校验存在、名称唯一 + validateSkillExists(updateReqVO.getId()); + validateSkillNameUnique(updateReqVO.getId(), updateReqVO.getName()); + + // 2. 更新管理信息;SKILL.md 中面向模型的名称、描述由文件编辑器独立维护 + Ai1SkillDO updateObj = BeanUtils.toBean(updateReqVO, Ai1SkillDO.class); + skillMapper.updateById(updateObj); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSkill(Long id) { + deleteSkillListByIds(Collections.singletonList(id)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteSkillListByIds(List ids) { + // 1.1 校验存在 + ids.forEach(this::validateSkillExists); + // 1.2 校验未被 Agent 绑定 + if (agentService.getAgentCountBySkillIds(ids) > 0) { + throw exception(SKILL_USED_BY_AGENT); + } + + // 2. 删除 SKILL,并级联删除内容文件 + skillMapper.deleteByIds(ids); + skillFileService.deleteSkillFileListBySkillIds(ids); + } + + @Override + public Ai1SkillDO getSkill(Long id) { + return skillMapper.selectById(id); + } + + @Override + public Ai1SkillDO validateSkillExists(Long id) { + Ai1SkillDO skill = skillMapper.selectById(id); + if (skill == null) { + throw exception(SKILL_NOT_EXISTS); + } + return skill; + } + + @Override + public PageResult getSkillPage(Ai1SkillPageReqVO pageReqVO) { + return skillMapper.selectPage(pageReqVO); + } + + @Override + public List getSkillListByStatus(Integer status) { + return skillMapper.selectListByStatus(status); + } + + @Override + public List getSkillList(Collection ids) { + if (CollUtil.isEmpty(ids)) { + return Collections.emptyList(); + } + return skillMapper.selectByIds(ids); + } + + @Override + public void touchSkill(Long id) { + skillMapper.updateById(new Ai1SkillDO().setId(id)); + } + + /** + * 校验名称租户内唯一 + */ + private void validateSkillNameUnique(Long id, String name) { + Ai1SkillDO skill = skillMapper.selectByName(name); + if (skill != null && ObjUtil.notEqual(skill.getId(), id)) { + throw exception(SKILL_NAME_DUPLICATE, name); + } + } + +} diff --git a/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java new file mode 100644 index 0000000..a9d239e --- /dev/null +++ b/yudao-module-ai1-springai/src/main/java/cn/iocoder/yudao/module/ai1/util/Ai1Utils.java @@ -0,0 +1,90 @@ +package cn.iocoder.yudao.module.ai1.util; + +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import org.springframework.core.env.Environment; + +import java.util.*; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.CONFIG_PLACEHOLDER_NOT_RESOLVED; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_HEADERS_INVALID; + +/** + * AI1 工具类 + * + * @author 芋道源码 + */ +public class Ai1Utils { + + /** + * 解析 DB 等动态配置里的 Spring 占位符,例如 ${OPENAI_API_KEY} + * + * 库里只保存占位符,真实密钥留在环境变量或配置文件;仅在发起调用前解析,响应里仍返回占位符本身 + * + * @param value 待解析的配置值 + * @return 解析后的配置值;不含占位符时原样返回 + */ + public static String resolveSpringPlaceholders(String value) { + if (StrUtil.isBlank(value) || !StrUtil.contains(value, "${")) { + return value; + } + try { + return SpringUtil.getBean(Environment.class).resolveRequiredPlaceholders(value); + } catch (IllegalArgumentException ex) { + throw exception(CONFIG_PLACEHOLDER_NOT_RESOLVED, "动态配置项"); + } + } + + /** + * 解析 Map 中每个值里的 Spring 占位符,键保持不变 + * + * @param values 待解析的值 + * @return 解析后的 Map;入参为 null 时返回 null + */ + public static Map resolveSpringPlaceholders(Map values) { + if (values == null) { + return null; + } + Map resolved = new LinkedHashMap<>(); + values.forEach((key, value) -> resolved.put(key, resolveSpringPlaceholders(value))); + return resolved; + } + + /** + * 规范化请求 Header,保留输入顺序;不解析环境占位符 + */ + public static Map normalizeHeaders(Map values) { + if (values == null) { + return null; + } + Map headers = new LinkedHashMap<>(); + Set names = new HashSet<>(); + values.forEach((key, value) -> { + if (StrUtil.isBlank(key) || value == null) { + throw exception(PROVIDER_HEADERS_INVALID); + } + String name = key.trim(); + if (!names.add(name.toLowerCase(Locale.ROOT))) { + throw exception(PROVIDER_HEADERS_INVALID); + } + headers.put(name, value); + }); + if (JsonUtils.toJsonString(headers).length() > 2000) { + throw exception(PROVIDER_HEADERS_INVALID); + } + return headers; + } + + public static boolean isHeadersValid(Map headers) { + try { + normalizeHeaders(headers); + return true; + } catch (ServiceException ex) { + return false; + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatToolTest.java new file mode 100644 index 0000000..67d51f2 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmChatToolTest.java @@ -0,0 +1,106 @@ +package cn.iocoder.yudao.module.ai1.harness.llm; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.ToolResponseMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.model.tool.ToolCallingChatOptions; +import org.springframework.ai.model.tool.ToolCallingManager; +import org.springframework.ai.tool.annotation.Tool; +import org.springframework.ai.tool.function.FunctionToolCallback; +import reactor.core.publisher.Flux; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1LlmChatTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1LlmChatToolTest extends BaseMockitoUnitTest { + + @InjectMocks + private Ai1LlmChatTool llmChatTool; + @Mock + private Ai1LlmModelFactory llmModelFactory; + + @Test + public void testChat_mixedTools() { + // 准备参数 + AnnotatedTools annotatedTools = new AnnotatedTools(); + AtomicInteger mcpCalls = new AtomicInteger(); + AtomicInteger skillCalls = new AtomicInteger(); + List tools = List.of(annotatedTools, + FunctionToolCallback.builder("mcp_probe", (String value) -> { + mcpCalls.incrementAndGet(); + return "mcp:" + value; + }).inputType(String.class).description("MCP 回调").build(), + FunctionToolCallback.builder("skill_probe", (String value) -> { + skillCalls.incrementAndGet(); + return "skill:" + value; + }).inputType(String.class).description("SKILL 回调").build()); + + // mock chatModel 和 llmModelFactory 的方法 + ChatModel chatModel = mock(ChatModel.class); + when(chatModel.getDefaultOptions()).thenReturn(ToolCallingChatOptions.builder().build()); + ToolCallingManager manager = ToolCallingManager.builder().build(); + when(chatModel.stream(any(Prompt.class))).thenAnswer(invocation -> { + Prompt prompt = invocation.getArgument(0); + ToolCallingChatOptions options = (ToolCallingChatOptions) prompt.getOptions(); + assertEquals(3, options.getToolCallbacks().size()); + assertEquals(3, manager.resolveToolDefinitions(options).size()); + AssistantMessage toolCalls = AssistantMessage.builder().content("").toolCalls(List.of( + new AssistantMessage.ToolCall("1", "function", "annotated_probe", "{}"), + new AssistantMessage.ToolCall("2", "function", "mcp_probe", "\"hello\""), + new AssistantMessage.ToolCall("3", "function", "skill_probe", "\"hello\""))).build(); + ChatResponse response = new ChatResponse(List.of(new Generation(toolCalls))); + List results = manager.executeToolCalls(prompt, response) + .conversationHistory().stream().filter(ToolResponseMessage.class::isInstance) + .map(ToolResponseMessage.class::cast).flatMap(message -> message.getResponses().stream()).toList(); + assertEquals(3, results.size()); + assertTrue(results.stream().anyMatch(result -> result.responseData().contains("annotated"))); + assertTrue(results.stream().anyMatch(result -> result.responseData().contains("mcp:hello"))); + assertTrue(results.stream().anyMatch(result -> result.responseData().contains("skill:hello"))); + return Flux.just(new ChatResponse(List.of(new Generation(new AssistantMessage("工具调用成功"))))); + }); + ChatClient chatClient = ChatClient.create(chatModel); + when(llmModelFactory.buildChatClient(any(), any())).thenReturn(chatClient); + + // 调用 + Ai1LlmChatTool.ChatText result = llmChatTool.chat(new Ai1ModelRespBO(), "", List.of(), "调用工具", + tools, List.of(), 1L, null, null); + // 断言 + assertEquals("工具调用成功", result.getContent()); + assertEquals(1, annotatedTools.calls); + assertEquals(1, mcpCalls.get()); + assertEquals(1, skillCalls.get()); + } + + // ========== 测试工具 ========== + + public static class AnnotatedTools { + + private int calls; + + @Tool(name = "annotated_probe", description = "注解工具") + public String probe() { + calls++; + return "annotated"; + } + + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java new file mode 100644 index 0000000..bed9e23 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/llm/Ai1LlmModelFactoryTest.java @@ -0,0 +1,218 @@ +package cn.iocoder.yudao.module.ai1.harness.llm; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import com.fasterxml.jackson.databind.JsonNode; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springaicommunity.agent.tools.SkillsTool; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.tool.ToolCallback; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link Ai1LlmModelFactory} 的单元测试 + */ +public class Ai1LlmModelFactoryTest { + + private final Ai1LlmModelFactory factory = new Ai1LlmModelFactory(); + private final List requests = new CopyOnWriteArrayList<>(); + private HttpServer server; + private String baseUrl; + private String requestedTool; + + @BeforeEach + public void before() throws Exception { + // mock HTTP 服务 + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requests.add(new Request(exchange.getRequestURI().getPath(), + exchange.getRequestHeaders().getFirst("Authorization"), + exchange.getRequestHeaders().getFirst("X-Session"), + exchange.getRequestHeaders().getFirst("X-Provider"), + JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)))); + boolean embedding = exchange.getRequestURI().getPath().endsWith("/embeddings"); + boolean callTool = requestedTool != null && requests.size() == 1; + String response = callTool ? toolCallSse(requestedTool) : embedding + ? "{\"object\":\"list\",\"data\":[{\"object\":\"embedding\",\"index\":0,\"embedding\":[0.1,0.2]}],\"model\":\"test-model\",\"usage\":{\"prompt_tokens\":1,\"total_tokens\":1}}" + : sse("{\"role\":\"assistant\",\"reasoning_content\":\"想\"}") + + sse("{\"reasoning_content\":\"想\"}") + + sse("{\"reasoning_content\":\"想到\"}") + + sse("{\"content\":\"你好\"}") + + "data: [DONE]\n\n"; + byte[] bytes = response.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", embedding ? "application/json" : "text/event-stream"); + exchange.sendResponseHeaders(200, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } + }); + server.start(); + baseUrl = "http://127.0.0.1:" + server.getAddress().getPort(); + } + + @AfterEach + public void after() { + server.stop(0); + } + + @ParameterizedTest + @ValueSource(strings = {"", "/v1/", "/proxy/v1"}) + public void testChat_endpointHeadersAndReasoningDeltas(String prefix) { + // 准备参数 + Ai1ModelRespBO model = model(baseUrl + prefix); + Ai1LlmChatTool chatTool = new Ai1LlmChatTool(); + ReflectionTestUtils.setField(chatTool, "llmModelFactory", factory); + List thinking = new ArrayList<>(); + List content = new ArrayList<>(); + + // 调用 + Ai1LlmChatTool.ChatText result = chatTool.chat(model, "系统指令", List.of(new String[]{"user", "历史"}), + "当前消息", List.of(), List.of(), 42L, thinking::add, content::add); + + // 断言 + assertEquals("你好", result.getContent()); + assertEquals("想想想到", result.getThinking()); + assertEquals(List.of("想", "想", "想到"), thinking); + assertEquals(List.of("你好"), content); + assertEquals(1, requests.size()); + Request request = requests.get(0); + String normalizedPrefix = prefix.isEmpty() ? "/v1" : prefix.replaceAll("/$", ""); + assertEquals(normalizedPrefix + "/chat/completions", request.path()); + assertEquals("Bearer test-key", request.authorization()); + assertEquals("42", request.session()); + assertEquals("provider", request.provider()); + assertEquals("test-model", request.body().get("model").asText()); + assertEquals(3, request.body().get("messages").size()); + assertTrue(request.body().get("stream").asBoolean()); + } + + @Test + public void testEmbedding_endpointAndHeaders() { + // 准备参数 + Ai1ModelRespBO model = model(baseUrl + "/proxy/v1/"); + EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); + + // 调用 + float[] result = embedding.embed("文档内容"); + // 断言 + assertArrayEquals(new float[]{0.1F, 0.2F}, result); + Request request = requests.get(0); + assertEquals("/proxy/v1/embeddings", request.path()); + assertEquals("Bearer test-key", request.authorization()); + assertNull(request.session()); + assertEquals("provider", request.provider()); + assertEquals("test-model", request.body().get("model").asText()); + assertEquals("文档内容", request.body().get("input").get(0).asText()); + } + + @Test + public void testChat_skillCallback(@TempDir Path root) throws Exception { + // 准备参数 + Files.writeString(root.resolve("SKILL.md"), "---\nname: probe\ndescription: test\n---\nunique-skill-content\n"); + ToolCallback skill = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); + requestedTool = skill.getToolDefinition().name(); + Ai1LlmChatTool chat = new Ai1LlmChatTool(); + ReflectionTestUtils.setField(chat, "llmModelFactory", factory); + + // 调用 + Ai1LlmChatTool.ChatText result = chat.chat(model(baseUrl), "system", List.of(), "use skill", + List.of(skill), List.of(), 42L, null, null); + // 断言 + assertEquals("你好", result.getContent()); + assertEquals(2, requests.size()); + assertEquals(requestedTool, requests.get(0).body().get("tools").get(0).get("function").get("name").asText()); + boolean hasToolResult = false; + for (JsonNode message : requests.get(1).body().get("messages")) { + if ("tool".equals(message.get("role").asText())) { + hasToolResult = true; + assertTrue(message.get("content").asText().contains("unique-skill-content")); + } + } + assertTrue(hasToolResult); + assertEquals("42", requests.get(1).session()); + } + + @Test + public void testCache_sessionIsolationAndInvalidation() { + // 准备参数 + Ai1ModelRespBO model = model(baseUrl); + + // 调用 + Object chat = chatModel(model, 1L); + // 断言 + assertSame(chat, chatModel(model, 1L)); + assertNotSame(chat, chatModel(model, 2L)); + EmbeddingModel embedding = factory.getOrCreateEmbeddingModel(model); + assertSame(embedding, factory.getOrCreateEmbeddingModel(model)); + + // 调用 + factory.evictByModelId(model.getModelId()); + // 断言 + assertNotSame(chat, chatModel(model, 1L)); + assertNotSame(embedding, factory.getOrCreateEmbeddingModel(model)); + + // 准备参数 + model.setHeaders(Map.of("X-Provider", "provider")); + // 调用 + chat = chatModel(model, 1L); + // 断言 + assertSame(chat, chatModel(model, 2L)); + + // 调用 + model.setApiKey("changed-key"); + // 断言 + assertNotSame(chat, chatModel(model, 1L)); + + // 调用 + chat = chatModel(model, 1L); + factory.evictByProviderId(model.getProviderId()); + // 断言 + assertNotSame(chat, chatModel(model, 1L)); + } + + // ========== 工具方法 ========== + + private Object chatModel(Ai1ModelRespBO model, Long sessionId) { + return ReflectionTestUtils.invokeMethod(factory, "getOrCreateChatModel", model, sessionId); + } + + private static Ai1ModelRespBO model(String url) { + return new Ai1ModelRespBO().setProviderId(1L).setModelId(2L).setBaseUrl(url).setApiKey("test-key") + .setModel("test-model").setHeaders(Map.of("X-Session", "{session}", "X-Provider", "provider")); + } + + private static String sse(String delta) { + return "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test-model\"," + + "\"choices\":[{\"index\":0,\"delta\":" + delta + ",\"finish_reason\":null}]}\n\n"; + } + + private static String toolCallSse(String name) { + String delta = JsonUtils.toJsonString(Map.of("role", "assistant", "tool_calls", List.of(Map.of( + "index", 0, "id", "call-probe", "type", "function", "function", Map.of( + "name", name, "arguments", "{\"command\":\"probe\"}"))))); + return sse(delta) + + "data: {\"id\":\"test\",\"object\":\"chat.completion.chunk\",\"created\":1,\"model\":\"test-model\"," + + "\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}]}\n\n" + + "data: [DONE]\n\n"; + } + + private record Request(String path, String authorization, String session, String provider, JsonNode body) { + } +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolConcurrencyTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolConcurrencyTest.java new file mode 100644 index 0000000..3768f4b --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolConcurrencyTest.java @@ -0,0 +1,141 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpClientTransport; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1McpClientTool} 的并发单元测试 + * + * @author 芋道源码 + */ +public class Ai1McpClientToolConcurrencyTest { + + @Test + public void testGetOrCreateClient_differentMcpConnectsIndependently() throws Exception { + // mock 数据 + Ai1McpClientTool tool = new Ai1McpClientTool(); + McpSyncClient slowClient = mock(McpSyncClient.class); + McpSyncClient readyClient = mock(McpSyncClient.class); + CountDownLatch initializing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + // mock slowClient 的方法 + when(slowClient.initialize()).thenAnswer(invocation -> { + initializing.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + throw new IllegalStateException("连接失败"); + }); + // 准备参数 + Ai1McpDO slowMcp = new Ai1McpDO().setId(1L).setName("不可达服务").setTransport("http") + .setUrl("http://127.0.0.1:8081/mcp"); + Ai1McpDO readyMcp = new Ai1McpDO().setId(2L).setName("可用服务").setTransport("http") + .setUrl("http://127.0.0.1:8082/mcp"); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + // 调用 + Future slow = executor.submit(() -> getOrCreateClient(tool, slowMcp, slowClient)); + assertTrue(initializing.await(5, TimeUnit.SECONDS)); + Future ready = executor.submit(() -> getOrCreateClient(tool, readyMcp, readyClient)); + // 断言 + assertSame(readyClient, ready.get(2, TimeUnit.SECONDS)); + assertFalse(slow.isDone()); + verify(readyClient).initialize(); + release.countDown(); + ExecutionException exception = assertThrows(ExecutionException.class, () -> slow.get(5, TimeUnit.SECONDS)); + assertEquals("连接失败", exception.getCause().getMessage()); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + tool.destroy(); + } + } + + @Test + public void testGetOrCreateClient_sameMcpInitializesOnce() throws Exception { + // mock 数据 + Ai1McpClientTool tool = new Ai1McpClientTool(); + McpSyncClient firstClient = mock(McpSyncClient.class); + McpSyncClient secondClient = mock(McpSyncClient.class); + CountDownLatch initializing = new CountDownLatch(1); + CountDownLatch secondStarted = new CountDownLatch(1); + AtomicReference secondThread = new AtomicReference<>(); + CountDownLatch release = new CountDownLatch(1); + // mock firstClient 的方法 + when(firstClient.initialize()).thenAnswer(invocation -> { + initializing.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }); + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("测试服务").setTransport("http") + .setUrl("http://127.0.0.1:8081/mcp"); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + // 调用 + Future first = executor.submit(() -> getOrCreateClient(tool, mcp, firstClient)); + assertTrue(initializing.await(5, TimeUnit.SECONDS)); + Future second = executor.submit(() -> { + secondThread.set(Thread.currentThread()); + secondStarted.countDown(); + return getOrCreateClient(tool, mcp, secondClient); + }); + assertTrue(secondStarted.await(5, TimeUnit.SECONDS)); + assertTimeoutPreemptively(Duration.ofSeconds(2), () -> { + while (secondThread.get().getState() != Thread.State.BLOCKED + || Arrays.stream(secondThread.get().getStackTrace()).noneMatch(frame -> + frame.getClassName().equals(Ai1McpClientTool.class.getName()) + && frame.getMethodName().equals("getOrCreateClient"))) { + Thread.sleep(5); + } + }); + release.countDown(); + // 断言 + assertSame(firstClient, first.get(5, TimeUnit.SECONDS)); + assertSame(firstClient, second.get(5, TimeUnit.SECONDS)); + verify(firstClient).initialize(); + verifyNoInteractions(secondClient); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + tool.destroy(); + } + } + + private static McpSyncClient getOrCreateClient(Ai1McpClientTool tool, Ai1McpDO mcp, McpSyncClient client) { + AtomicReference transport = new AtomicReference<>(); + McpClient.SyncSpec spec = mock(McpClient.SyncSpec.class, RETURNS_SELF); + when(spec.build()).thenReturn(client); + try (MockedStatic clientFactory = mockStatic(McpClient.class)) { + clientFactory.when(() -> McpClient.sync(any(McpClientTransport.class))).thenAnswer(invocation -> { + transport.set(invocation.getArgument(0)); + return spec; + }); + return ReflectionTestUtils.invokeMethod(tool, "getOrCreateClient", mcp); + } finally { + if (transport.get() != null) { + transport.get().closeGracefully().block(Duration.ofSeconds(5)); + } + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolRecoveryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolRecoveryTest.java new file mode 100644 index 0000000..4b31874 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolRecoveryTest.java @@ -0,0 +1,330 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpError; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpTransportException; +import io.modelcontextprotocol.spec.McpTransportSessionClosedException; +import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeoutException; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1McpClientTool} 的故障恢复单元测试 + * + * @author 芋道源码 + */ +public class Ai1McpClientToolRecoveryTest extends BaseMockitoUnitTest { + + @InjectMocks + private Ai1McpClientTool mcpClientTool; + + @Mock + private McpSyncClient failedClient; + @Mock + private McpSyncClient readyClient; + + @AfterEach + public void after() { + mcpClientTool.destroy(); + } + + @Test + public void testListTools_initializeFailureCanRetry() { + // mock failedClient 和 readyClient 的方法 + when(failedClient.initialize()).thenThrow(new IllegalStateException("建连失败")); + List tools = buildTools(); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + // 调用,并断言 + assertTrue(listTools(mcp, failedClient).isEmpty()); + verify(failedClient).closeGracefully(); + assertEquals(tools, listTools(mcp, readyClient)); + assertEquals(tools, listTools(mcp, readyClient)); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + } + + @Test + public void testListTools_listFailureCanRetry() { + // mock readyClient 的方法 + List tools = buildTools(); + when(readyClient.listTools()).thenThrow(new IllegalStateException("列举失败")) + .thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + // 调用,并断言 + assertTrue(listTools(mcp, readyClient).isEmpty()); + assertEquals(tools, listTools(mcp, readyClient)); + assertEquals(tools, listTools(mcp, readyClient)); + verify(readyClient).initialize(); + verify(readyClient, times(2)).listTools(); + } + + @Test + public void testListTools_successfulEmptyResultIsCached() { + // mock readyClient 的方法 + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(List.of(), null, null)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + // 调用,并断言 + assertTrue(listTools(mcp, readyClient).isEmpty()); + assertTrue(listTools(mcp, readyClient).isEmpty()); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + } + + @Test + public void testListTools_closeFailureDoesNotPreventRetry() { + // mock failedClient 和 readyClient 的方法 + when(failedClient.initialize()).thenThrow(new IllegalStateException("建连失败")); + doThrow(new IllegalStateException("关闭失败")).when(failedClient).closeGracefully(); + List tools = buildTools(); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + // 调用,并断言 + assertTrue(listTools(mcp, failedClient).isEmpty()); + assertEquals(tools, listTools(mcp, readyClient)); + verify(failedClient).closeGracefully(); + verify(readyClient).initialize(); + } + + @ParameterizedTest + @MethodSource("callFailures") + public void testCallTool_failureRebuildsClient(RuntimeException failure) { + // mock failedClient 和 readyClient 的方法 + List tools = buildTools(); + when(failedClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(failedClient.callTool(any())).thenAnswer(invocation -> { + throw failure; + }); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.callTool(any())).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + withClients(() -> { + // 调用,并断言失败原样返回,本次不自动重跑 + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertSame(failure, assertThrows(RuntimeException.class, + () -> mcpClientTool.callTool(mcp, "calculator", null))); + verify(failedClient).callTool(any()); + verify(failedClient).closeGracefully(); + verify(readyClient, never()).callTool(any()); + // 调用,并断言工具缓存已清理,下次列举和调用使用新客户端 + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + verify(readyClient).callTool(any()); + return null; + }, failedClient, readyClient); + } + + @Test + public void testCallTool_businessErrorKeepsClient() { + // mock readyClient 的方法 + McpError failure = McpError.builder(McpSchema.ErrorCodes.INVALID_PARAMS).message("参数错误").build(); + List tools = buildTools(); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.callTool(any())).thenThrow(failure).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + withClients(() -> { + // 调用,并断言业务错误保留客户端和工具缓存 + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertSame(failure, assertThrows(McpError.class, + () -> mcpClientTool.callTool(mcp, "calculator", Map.of()))); + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + verify(readyClient, never()).closeGracefully(); + return null; + }, readyClient); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testCallTool_resultKeepsClient(boolean isError) { + // mock readyClient 的方法 + when(readyClient.callTool(any())).thenReturn(buildResult(isError)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + withClients(() -> { + // 调用,并断言工具结果(含 isError)均不会断开连接 + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + verify(readyClient).initialize(); + verify(readyClient, times(2)).callTool(any()); + verify(readyClient, never()).closeGracefully(); + return null; + }, readyClient); + } + + @Test + public void testCallTool_closeFailureKeepsOriginalError() { + // mock failedClient 和 readyClient 的方法 + McpTransportException failure = new McpTransportException("连接断开"); + when(failedClient.callTool(any())).thenThrow(failure); + doThrow(new IllegalStateException("关闭失败")).when(failedClient).closeGracefully(); + when(readyClient.callTool(any())).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + + withClients(() -> { + // 调用,并断言释放失败不掩盖调用错误,也不妨碍下次重建 + assertSame(failure, assertThrows(McpTransportException.class, + () -> mcpClientTool.callTool(mcp, "calculator", Map.of()))); + assertEquals("工具结果", mcpClientTool.callTool(mcp, "calculator", Map.of())); + verify(failedClient).closeGracefully(); + verify(readyClient).initialize(); + return null; + }, failedClient, readyClient); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testCallTool_lateFailureKeepsNewClient(boolean configChanged) { + // mock failedClient 和 readyClient 的方法 + List tools = buildTools(); + when(failedClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.callTool(any())).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + Ai1McpDO newMcp = buildMcp(); + if (configChanged) { + newMcp.setUrl("http://127.0.0.1:8082/mcp"); + } + McpTransportException failure = new McpTransportException("旧连接断开"); + when(failedClient.callTool(any())).thenAnswer(invocation -> { + // mock 在途调用期间被配置更新或手动清理,随后已重建新客户端 + if (!configChanged) { + mcpClientTool.evict(mcp.getId()); + } + assertEquals(tools, mcpClientTool.listTools(newMcp)); + throw failure; + }); + + withClients(() -> { + // 调用,并断言旧调用失败不能清理新客户端或新工具缓存 + assertEquals(tools, mcpClientTool.listTools(mcp)); + assertSame(failure, assertThrows(McpTransportException.class, + () -> mcpClientTool.callTool(mcp, "calculator", Map.of()))); + assertEquals(tools, mcpClientTool.listTools(newMcp)); + assertEquals("工具结果", mcpClientTool.callTool(newMcp, "calculator", Map.of())); + verify(failedClient).closeGracefully(); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + verify(readyClient, never()).closeGracefully(); + return null; + }, failedClient, readyClient); + } + + @Test + public void testCallTool_failureKeepsOtherMcpClient() { + // mock failedClient 和 readyClient 的方法 + McpTransportException failure = new McpTransportException("连接断开"); + when(failedClient.callTool(any())).thenThrow(failure); + List tools = buildTools(); + when(readyClient.listTools()).thenReturn(new McpSchema.ListToolsResult(tools, null, null)); + when(readyClient.callTool(any())).thenReturn(buildResult(false)); + // 准备参数 + Ai1McpDO mcp = buildMcp(); + Ai1McpDO otherMcp = buildMcp().setId(2L); + + withClients(() -> { + // 调用,并断言某个 MCP 失败不影响其他服务的连接与工具缓存 + assertThrows(McpTransportException.class, () -> mcpClientTool.callTool(mcp, "calculator", Map.of())); + assertEquals(tools, mcpClientTool.listTools(otherMcp)); + assertThrows(McpTransportException.class, () -> mcpClientTool.callTool(mcp, "calculator", Map.of())); + assertEquals(tools, mcpClientTool.listTools(otherMcp)); + assertEquals("工具结果", mcpClientTool.callTool(otherMcp, "calculator", Map.of())); + verify(readyClient).initialize(); + verify(readyClient).listTools(); + verify(readyClient, never()).closeGracefully(); + return null; + }, failedClient, readyClient, failedClient); + } + + // ========== 测试数据 ========== + + private static Ai1McpDO buildMcp() { + return new Ai1McpDO().setId(1L).setName("测试服务").setTransport("http") + .setUrl("http://127.0.0.1:8081/mcp"); + } + + private static List buildTools() { + return List.of(McpSchema.Tool.builder("calculator").description("计算器") + .inputSchema(Map.of("type", "object")).build()); + } + + private static McpSchema.CallToolResult buildResult(boolean isError) { + return McpSchema.CallToolResult.builder().addTextContent("工具结果").isError(isError).build(); + } + + private static Stream callFailures() { + return Stream.of(new McpTransportSessionClosedException(), new McpTransportSessionNotFoundException("session"), + new McpTransportException("HTTP 连接失败"), new RuntimeException("Failed to enqueue message"), + new RuntimeException("MCP session with server terminated"), + new RuntimeException(new IOException("管道已关闭")), + reactor.core.Exceptions.propagate(new TimeoutException("工具调用超时"))); + } + + // ========== mock 方法 ========== + + private List listTools(Ai1McpDO mcp, McpSyncClient client) { + return withClients(() -> mcpClientTool.listTools(mcp), client); + } + + private T withClients(Supplier action, McpSyncClient... clients) { + List transports = new ArrayList<>(); + McpClient.SyncSpec spec = mock(McpClient.SyncSpec.class, RETURNS_SELF); + lenient().when(spec.build()).thenReturn(clients[0], Arrays.copyOfRange(clients, 1, clients.length)); + try (MockedStatic clientFactory = mockStatic(McpClient.class)) { + clientFactory.when(() -> McpClient.sync(any(McpClientTransport.class))).thenAnswer(invocation -> { + transports.add(invocation.getArgument(0)); + return spec; + }); + return action.get(); + } finally { + for (McpClientTransport transport : transports) { + transport.closeGracefully().block(Duration.ofSeconds(5)); + } + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolTest.java new file mode 100644 index 0000000..ab68b44 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpClientToolTest.java @@ -0,0 +1,140 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import com.sun.net.httpserver.HttpServer; +import io.modelcontextprotocol.spec.McpClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.InetSocketAddress; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link Ai1McpClientTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1McpClientToolTest { + + @Test + public void testParseConfig_argsArray() { + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setTransport("stdio") + .setConfig("{\"command\":\"npx\",\"args\":[\"-y\",\"server\"]}"); + + // 调用 + Object config = ReflectionTestUtils.invokeMethod(Ai1McpClientTool.class, "parseConfig", mcp); + // 断言 + assertNotNull(config); + assertEquals(List.of("-y", "server"), ReflectionTestUtils.getField(config, "args")); + } + + @Test + public void testParseConfig_argsNotArray() { + // 准备参数 + List argsValues = List.of("\"-y,server\"", "123", "true", "{\"value\":\"server\"}", "null"); + + for (String args : argsValues) { + Ai1McpDO mcp = new Ai1McpDO().setTransport("stdio") + .setConfig("{\"command\":\"npx\",\"args\":" + args + "}"); + + // 调用 + Object config = ReflectionTestUtils.invokeMethod(Ai1McpClientTool.class, "parseConfig", mcp); + // 断言 + assertNotNull(config); + assertNull(ReflectionTestUtils.getField(config, "args")); + } + } + + @Test + public void testParseConfig_argsEmptyOrMissing() { + // 准备参数 + List configs = List.of("{\"command\":\"npx\",\"args\":[]}", "{\"command\":\"npx\"}"); + + // 调用 + Object emptyConfig = ReflectionTestUtils.invokeMethod(Ai1McpClientTool.class, "parseConfig", + new Ai1McpDO().setTransport("stdio").setConfig(configs.get(0))); + Object missingConfig = ReflectionTestUtils.invokeMethod(Ai1McpClientTool.class, "parseConfig", + new Ai1McpDO().setTransport("stdio").setConfig(configs.get(1))); + // 断言 + assertNotNull(emptyConfig); + assertEquals(List.of(), ReflectionTestUtils.getField(emptyConfig, "args")); + assertNotNull(missingConfig); + assertNull(ReflectionTestUtils.getField(missingConfig, "args")); + } + + @Test + public void testBuildTransport_httpQuery() { + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setTransport("http") + .setUrl("https://example.com/custom/mcp?token=a%2Bb%3D&tag=x&tag=y#ignored"); + + // 调用 + McpClientTransport transport = ReflectionTestUtils.invokeMethod(new Ai1McpClientTool(), "buildTransport", mcp); + // 断言 + assertNotNull(transport); + try { + assertEquals(URI.create("https://example.com"), ReflectionTestUtils.getField(transport, "baseUri")); + assertEquals("/custom/mcp?token=a%2Bb%3D&tag=x&tag=y", ReflectionTestUtils.getField(transport, "endpoint")); + } finally { + transport.closeGracefully().block(Duration.ofSeconds(5)); + } + } + + @Test + public void testBuildTransport_httpDefaultEndpoint() { + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setTransport("http").setUrl("https://example.com?api_key=x"); + + // 调用 + McpClientTransport transport = ReflectionTestUtils.invokeMethod(new Ai1McpClientTool(), "buildTransport", mcp); + // 断言 + assertNotNull(transport); + try { + assertEquals("/mcp?api_key=x", ReflectionTestUtils.getField(transport, "endpoint")); + } finally { + transport.closeGracefully().block(Duration.ofSeconds(5)); + } + } + + @Test + public void testBuildTransport_httpRequestPreservesEncoding() throws Exception { + // mock 数据 + AtomicReference requestUri = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + requestUri.set(exchange.getRequestURI().toASCIIString()); + exchange.getRequestBody().readAllBytes(); + exchange.sendResponseHeaders(202, -1); + exchange.close(); + }); + server.start(); + try { + for (String endpoint : List.of("/custom%2Fmcp?token=a%2Bb%3D&tag=x&tag=y", "?api_key=x")) { + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setTransport("http") + .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + endpoint); + McpClientTransport transport = ReflectionTestUtils.invokeMethod(new Ai1McpClientTool(), "buildTransport", mcp); + assertNotNull(transport); + try { + // 调用 + transport.sendMessage(new McpSchema.JSONRPCNotification("notifications/initialized")) + .block(Duration.ofSeconds(5)); + // 断言 + assertEquals(endpoint.startsWith("?") ? "/mcp" + endpoint : endpoint, requestUri.get()); + } finally { + transport.closeGracefully().block(Duration.ofSeconds(5)); + } + } + } finally { + server.stop(0); + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java new file mode 100644 index 0000000..717116a --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpRuntimeCompatibilityTest.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import com.fasterxml.jackson.databind.JsonNode; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link Ai1McpClientTool} 的运行时测试 + */ +public class Ai1McpRuntimeCompatibilityTest { + + @Test + @Timeout(15) + public void testStreamableHttp_initializeListAndCall() throws Exception { + // mock HTTP 服务 + List methods = new CopyOnWriteArrayList<>(); + List paths = new CopyOnWriteArrayList<>(); + List headers = new CopyOnWriteArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/proxy/mcp", exchange -> { + if (!"POST".equals(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + exchange.close(); + return; + } + paths.add(exchange.getRequestURI().toString()); + headers.add(exchange.getRequestHeaders().getFirst("X-Probe")); + JsonNode request = JsonUtils.parseTree(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + String method = request.get("method").asText(); + methods.add(method); + if (!request.has("id")) { + exchange.sendResponseHeaders(202, -1); + exchange.close(); + return; + } + Object result = switch (method) { + case "initialize" -> Map.of("protocolVersion", request.path("params").path("protocolVersion").asText(), + "capabilities", Map.of("tools", Map.of("listChanged", false)), + "serverInfo", Map.of("name", "ai1-probe", "version", "1.0")); + case "tools/list" -> Map.of("tools", List.of(Map.of("name", "echo", "description", "Echo tool", + "inputSchema", Map.of("type", "object", "properties", Map.of("value", Map.of("type", "string")))))); + case "tools/call" -> Map.of("content", List.of(Map.of("type", "text", + "text", request.path("params").path("arguments").path("value").asText())), "isError", false); + default -> Map.of(); + }; + byte[] response = JsonUtils.toJsonByte(Map.of("jsonrpc", "2.0", "id", request.get("id"), "result", result)); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, response.length); + try (var output = exchange.getResponseBody()) { + output.write(response); + } + }); + server.start(); + Ai1McpClientTool client = new Ai1McpClientTool(); + try { + // 准备参数 + Ai1McpDO mcp = new Ai1McpDO().setId(1L).setName("probe").setTransport("http") + .setUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/mcp?key=test") + .setHeaders(Map.of("X-Probe", "compatibility")); + + // 调用,并断言工具发现和调用 + assertEquals(1, client.listTools(mcp).size()); + assertEquals("echo", client.listTools(mcp).get(0).name()); + assertEquals("hello", client.callTool(mcp, "echo", Map.of("value", "hello"))); + // 断言 HTTP 请求 + assertTrue(methods.containsAll(List.of("initialize", "notifications/initialized", "tools/list", "tools/call"))); + assertTrue(paths.stream().allMatch("/proxy/mcp?key=test"::equals)); + assertTrue(headers.stream().allMatch("compatibility"::equals)); + } finally { + client.destroy(); + server.stop(0); + } + } +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactoryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactoryTest.java new file mode 100644 index 0000000..bf83222 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/mcp/Ai1McpToolFactoryTest.java @@ -0,0 +1,151 @@ +package cn.iocoder.yudao.module.ai1.harness.mcp; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.service.mcp.Ai1McpService; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.ai.tool.ToolCallback; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link Ai1McpToolFactory} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1McpToolFactoryTest extends BaseMockitoUnitTest { + + @InjectMocks + private Ai1McpToolFactory mcpToolFactory; + + @Mock + private Ai1McpService mcpService; + @Mock + private Ai1McpClientTool mcpClientTool; + + @Test + public void testBuildTools_chineseNames() { + // mock 数据 + Ai1McpDO first = buildMcp(1L, "高德地图"); + Ai1McpDO second = buildMcp(2L, "百度地图"); + + // 调用 + List callbacks = buildTools(List.of(first, second), List.of("search")); + // 断言 + assertEquals(List.of("mcp_1__search", "mcp_2__search"), callbacks.stream() + .map(callback -> callback.getToolDefinition().name()).toList()); + } + + @Test + public void testBuildTools_emptyNames() { + // mock 数据 + List mcps = List.of(buildMcp(1L, " "), buildMcp(2L, "___"), buildMcp(3L, "!!!")); + + // 调用 + List callbacks = buildTools(mcps, List.of("search")); + // 断言 + assertEquals(List.of("mcp_1__search", "mcp_2__search", "mcp_3__search"), callbacks.stream() + .map(callback -> callback.getToolDefinition().name()).toList()); + } + + @Test + public void testBuildTools_englishName() { + // mock 数据 + Ai1McpDO mcp = buildMcp(1L, "GitHub"); + + // 调用 + List callbacks = buildTools(List.of(mcp), List.of("search")); + // 断言 + assertEquals("github__search", callbacks.getFirst().getToolDefinition().name()); + } + + @Test + public void testBuildTools_nameAtLimit() { + // mock 数据 + Ai1McpDO mcp = buildMcp(1L, "a".repeat(56)); + + // 调用 + List callbacks = buildTools(List.of(mcp), List.of("search")); + // 断言 + assertEquals("a".repeat(56) + "__search", callbacks.getFirst().getToolDefinition().name()); + assertEquals(64, callbacks.getFirst().getToolDefinition().name().length()); + } + + @Test + public void testBuildTools_longToolNames() { + // mock 数据 + Ai1McpDO mcp = buildMcp(1L, "GitHub"); + String prefix = "search_" + "a".repeat(80); + + // 调用 + List callbacks = buildTools(List.of(mcp), List.of(prefix + "_first", prefix + "_second")); + // 断言 + String firstName = callbacks.get(0).getToolDefinition().name(); + String secondName = callbacks.get(1).getToolDefinition().name(); + assertEquals(64, firstName.length()); + assertEquals(64, secondName.length()); + assertTrue(firstName.matches("[a-zA-Z0-9_-]+")); + assertNotEquals(firstName, secondName); + } + + @Test + public void testBuildTools_longMcpNames() { + // mock 数据 + String prefix = "a".repeat(80); + Ai1McpDO first = buildMcp(1L, prefix + "_first"); + Ai1McpDO second = buildMcp(2L, prefix + "_second"); + + // 调用 + List callbacks = buildTools(List.of(first, second), List.of("search")); + // 断言 + String firstName = callbacks.get(0).getToolDefinition().name(); + String secondName = callbacks.get(1).getToolDefinition().name(); + assertEquals(64, firstName.length()); + assertEquals(64, secondName.length()); + assertNotEquals(firstName, secondName); + } + + @Test + public void testBuildTools_callOriginalTool() { + // mock 数据 + Ai1McpDO mcp = buildMcp(1L, "高德地图"); + String toolName = "search_" + "a".repeat(80); + Map arguments = Map.of("query", "北京"); + // mock mcpClientTool 的方法 + when(mcpClientTool.callTool(mcp, toolName, arguments)).thenReturn("搜索结果"); + List callbacks = buildTools(List.of(mcp), List.of(toolName)); + + // 调用 + String result = callbacks.getFirst().call("{\"query\":\"北京\"}"); + // 断言 + assertEquals("搜索结果", JsonUtils.parseObject(result, String.class)); + verify(mcpClientTool).callTool(mcp, toolName, arguments); + } + + private List buildTools(List mcps, List toolNames) { + Ai1AgentDO agent = new Ai1AgentDO().setId(1L).setMcpIds(mcps.stream().map(Ai1McpDO::getId).toList()); + when(mcpService.getMcpList(agent.getMcpIds())).thenReturn(mcps); + List tools = toolNames.stream().map(name -> McpSchema.Tool.builder(name) + .description("测试工具").inputSchema(Map.of("type", "object")).build()).toList(); + for (Ai1McpDO mcp : mcps) { + when(mcpClientTool.listTools(mcp)).thenReturn(tools); + } + return mcpToolFactory.buildTools(agent); + } + + private static Ai1McpDO buildMcp(Long id, String name) { + return new Ai1McpDO().setId(id).setName(name).setStatus(CommonStatusEnum.ENABLE.getStatus()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java new file mode 100644 index 0000000..8d161b7 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/model/Ai1ProviderToolTest.java @@ -0,0 +1,127 @@ +package cn.iocoder.yudao.module.ai1.harness.model; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.util.Ai1Utils; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.core.env.Environment; +import org.springframework.mock.env.MockEnvironment; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.PROVIDER_REMOTE_MODEL_LOAD_FAIL; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; + +/** + * {@link Ai1ProviderTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1ProviderToolTest { + + // 本机不可达的接口地址 + private static final String UNREACHABLE_BASE_URL = "http://127.0.0.1:1/v1"; + + private final Ai1ProviderTool providerTool = new Ai1ProviderTool(); + + @Test + public void testTestConnect_unreachable() { + // 调用 + Ai1ProviderConnectRespVO result = providerTool.testConnect(UNREACHABLE_BASE_URL, null, null); + // 断言 + assertFalse(result.getConnectable()); + assertEquals(0, result.getHttpCode()); + assertTrue(result.getMessage().startsWith("连接失败:")); + assertNotNull(result.getElapsedMs()); + } + + @Test + public void testListModels_unreachable() { + // 调用,并断言异常 + assertServiceException(() -> providerTool.listModels(UNREACHABLE_BASE_URL, null, null), PROVIDER_REMOTE_MODEL_LOAD_FAIL); + } + + @Test + public void testTestConnectAndListModels_staticHeaders() throws Exception { + // mock HTTP 服务 + List> received = new CopyOnWriteArrayList<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/proxy/v1/models", exchange -> { + received.add(Map.of("auth", exchange.getRequestHeaders().getFirst("Authorization"), + "test", exchange.getRequestHeaders().getFirst("X-Test"), + "session", String.valueOf(exchange.getRequestHeaders().getFirst("X-Session")))); + byte[] body = "{\"data\":[{\"id\":\"test-model\"}]}".getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (var output = exchange.getResponseBody()) { + output.write(body); + } + }); + server.start(); + try { + // 准备参数 + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/proxy/v1/"; + Map headers = Map.of("X-Test", "a\"b\\c", "X-Session", "{session}"); + // 调用 + Ai1ProviderConnectRespVO result = providerTool.testConnect(url, "synthetic-key", headers); + List models = providerTool.listModels(url, "synthetic-key", headers); + // 断言 + assertTrue(result.getConnectable()); + assertEquals(List.of("test-model"), models); + assertEquals(2, received.size()); + received.forEach(request -> assertEquals(Map.of("auth", "Bearer synthetic-key", "test", "a\"b\\c", "session", "null"), request)); + } finally { + server.stop(0); + } + } + + @Test + public void testTestConnect_invalidConfiguration() throws Exception { + // 准备参数 + String secret = "synthetic-private-token\nprivate-suffix"; + // mock HTTP 服务 + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.start(); + try (MockedStatic spring = mockStatic(SpringUtil.class)) { + // mock 环境变量 + spring.when(() -> SpringUtil.getBean(Environment.class)) + .thenReturn(new MockEnvironment().withProperty("HTTP_HEADER_TOKEN", secret)); + // 准备参数 + String url = "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; + Map headers = Ai1Utils.resolveSpringPlaceholders(Map.of("X-Token", "${HTTP_HEADER_TOKEN}")); + // 调用 + List results = List.of( + providerTool.testConnect(url, null, headers), providerTool.testConnect(url, secret, null)); + // 断言 + for (Ai1ProviderConnectRespVO result : results) { + assertFalse(result.getConnectable()); + assertEquals(0, result.getHttpCode()); + assertFalse(result.getMessage().contains("synthetic-private-token")); + assertFalse(result.getMessage().contains("private-suffix")); + } + } finally { + server.stop(0); + } + } + + @Test + public void testParseModels() { + // 调用,并断言过滤无效模型 + assertEquals(List.of("gpt-4o", "qwen3"), Ai1ProviderTool.parseModels( + "{\"data\":[{\"id\":\"gpt-4o\"},{\"id\":\"\"},\"x\",{\"id\":\"qwen3\"}]}")); + // 调用,并断言空列表 + assertTrue(Ai1ProviderTool.parseModels("{\"object\":\"list\"}").isEmpty()); + assertTrue(Ai1ProviderTool.parseModels("{\"data\":[]}").isEmpty()); + // 调用,并断言解析失败 + assertNull(Ai1ProviderTool.parseModels("{\"data\":{\"id\":\"gpt-4o\"}}")); + assertNull(Ai1ProviderTool.parseModels("not json")); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolConcurrencyTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolConcurrencyTest.java new file mode 100644 index 0000000..6e9aa2e --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolConcurrencyTest.java @@ -0,0 +1,202 @@ +package cn.iocoder.yudao.module.ai1.harness.rag; + +import cn.hutool.core.lang.loader.LazyFunLoader; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import io.milvus.client.MilvusServiceClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.vectorstore.milvus.MilvusVectorStore; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.Duration; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1RagTool} 的并发单元测试 + * + * @author 芋道源码 + */ +public class Ai1RagToolConcurrencyTest extends BaseMockitoUnitTest { + + @InjectMocks + private Ai1RagTool ragTool; + + @Mock + private Ai1ModelService modelService; + @Mock + private Ai1LlmModelFactory llmModelFactory; + @Mock + private EmbeddingModel embeddingModel; + @Mock + private MilvusServiceClient milvusClient; + + @BeforeEach + public void before() { + ReflectionTestUtils.setField(ragTool, "ai1Properties", new YudaoAi1Properties()); + ReflectionTestUtils.setField(ragTool, "milvusClientLoader", LazyFunLoader.on(() -> milvusClient)); + when(modelService.getModelRespBO(any(), any())).thenReturn(new Ai1ModelRespBO() + .setModelType(Ai1ModelTypeEnum.EMBEDDING.getType())); + when(llmModelFactory.getOrCreateEmbeddingModel(any())).thenReturn(embeddingModel); + } + + @Test + public void testGetOrCreateVectorStore_sameKnowledgeBaseInitializesOnce() throws Exception { + // mock 数据 + MilvusVectorStore firstStore = mock(MilvusVectorStore.class); + MilvusVectorStore secondStore = mock(MilvusVectorStore.class); + CountDownLatch initializing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference secondThread = new AtomicReference<>(); + // mock firstStore 的方法 + doAnswer(invocation -> { + initializing.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }).when(firstStore).afterPropertiesSet(); + // 准备参数 + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(1L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + // 调用 + Future first = executor.submit(() -> getOrCreateVectorStore(knowledgeBase, firstStore)); + assertTrue(initializing.await(5, TimeUnit.SECONDS)); + Future second = executor.submit(() -> { + secondThread.set(Thread.currentThread()); + return getOrCreateVectorStore(knowledgeBase, secondStore); + }); + assertTimeoutPreemptively(Duration.ofSeconds(2), () -> { + while (secondThread.get() == null || secondThread.get().getState() != Thread.State.BLOCKED + || Arrays.stream(secondThread.get().getStackTrace()).noneMatch(frame -> + frame.getClassName().equals(Ai1RagTool.class.getName()) + && frame.getMethodName().equals("getOrCreateVectorStore"))) { + Thread.sleep(5); + } + }); + release.countDown(); + // 断言 + assertSame(firstStore, first.get(5, TimeUnit.SECONDS)); + assertSame(firstStore, second.get(5, TimeUnit.SECONDS)); + verify(firstStore).afterPropertiesSet(); + verifyNoInteractions(secondStore); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + ragTool.destroy(); + } + } + + @Test + public void testGetOrCreateVectorStore_differentKnowledgeBasesInitializeIndependently() throws Exception { + // mock 数据 + MilvusVectorStore firstStore = mock(MilvusVectorStore.class); + MilvusVectorStore secondStore = mock(MilvusVectorStore.class); + CountDownLatch initializing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + // mock firstStore 的方法 + doAnswer(invocation -> { + initializing.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }).when(firstStore).afterPropertiesSet(); + // 准备参数 + Ai1KnowledgeBaseDO firstBase = new Ai1KnowledgeBaseDO().setId(1L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + Ai1KnowledgeBaseDO secondBase = new Ai1KnowledgeBaseDO().setId(2L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + // 调用 + Future first = executor.submit(() -> getOrCreateVectorStore(firstBase, firstStore)); + assertTrue(initializing.await(5, TimeUnit.SECONDS)); + Future second = executor.submit(() -> getOrCreateVectorStore(secondBase, secondStore)); + // 断言 + assertSame(secondStore, second.get(2, TimeUnit.SECONDS)); + assertFalse(first.isDone()); + release.countDown(); + assertSame(firstStore, first.get(5, TimeUnit.SECONDS)); + verify(firstStore).afterPropertiesSet(); + verify(secondStore).afterPropertiesSet(); + } finally { + release.countDown(); + executor.shutdownNow(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + ragTool.destroy(); + } + } + + @Test + public void testGetOrCreateVectorStore_initializationFailureCanRetry() throws Exception { + // mock 数据 + MilvusVectorStore failedStore = mock(MilvusVectorStore.class); + MilvusVectorStore readyStore = mock(MilvusVectorStore.class); + // mock failedStore 的方法 + doThrow(new IllegalStateException("初始化失败")).when(failedStore).afterPropertiesSet(); + // 准备参数 + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(1L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + + // 调用,并断言异常 + assertThrows(IllegalStateException.class, () -> getOrCreateVectorStore(knowledgeBase, failedStore)); + // 调用 + MilvusVectorStore result = getOrCreateVectorStore(knowledgeBase, readyStore); + // 断言 + assertSame(readyStore, result); + verify(failedStore).afterPropertiesSet(); + verify(readyStore).afterPropertiesSet(); + } + + @Test + public void testGetOrCreateVectorStore_embeddingModelChanged() throws Exception { + // mock 数据 + MilvusVectorStore firstStore = mock(MilvusVectorStore.class); + MilvusVectorStore secondStore = mock(MilvusVectorStore.class); + EmbeddingModel nextModel = mock(EmbeddingModel.class); + // mock llmModelFactory 的方法 + when(llmModelFactory.getOrCreateEmbeddingModel(any())).thenReturn(embeddingModel, nextModel); + // 准备参数 + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(1L) + .setEmbeddingProviderId(1L).setEmbeddingModelId(1L); + + // 调用 + MilvusVectorStore first = getOrCreateVectorStore(knowledgeBase, firstStore); + MilvusVectorStore second = getOrCreateVectorStore(knowledgeBase, secondStore); + // 断言 + assertSame(firstStore, first); + assertSame(secondStore, second); + verify(firstStore).afterPropertiesSet(); + verify(secondStore).afterPropertiesSet(); + } + + private MilvusVectorStore getOrCreateVectorStore(Ai1KnowledgeBaseDO knowledgeBase, MilvusVectorStore store) { + MilvusVectorStore.Builder builder = mock(MilvusVectorStore.Builder.class, RETURNS_SELF); + when(builder.build()).thenReturn(store); + try (MockedStatic factory = mockStatic(MilvusVectorStore.class)) { + factory.when(() -> MilvusVectorStore.builder(any(MilvusServiceClient.class), any(EmbeddingModel.class))) + .thenReturn(builder); + return ReflectionTestUtils.invokeMethod(ragTool, "getOrCreateVectorStore", knowledgeBase); + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolTest.java new file mode 100644 index 0000000..78cc95c --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/rag/Ai1RagToolTest.java @@ -0,0 +1,50 @@ +package cn.iocoder.yudao.module.ai1.harness.rag; + +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link Ai1RagTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1RagToolTest { + + @Test + public void testSplit_withOverlap() { + // 准备参数 + String content = "abcdefghij"; + + // 调用 + List chunks = Ai1RagTool.split(content, 4, 2); + // 断言 + assertEquals(Arrays.asList("abcd", "cdef", "efgh", "ghij"), chunks); + } + + @Test + public void testSplit_overlapLimitedToChunkSizeMinusOne() { + // 准备参数 + String content = "abcde"; + + // 调用 + List chunks = Ai1RagTool.split(content, 3, 10); + // 断言重叠长度不超过分片大小 - 1 + assertEquals(Arrays.asList("abc", "bcd", "cde"), chunks); + } + + @Test + public void testSplit_empty() { + // 调用 + List emptyChunks = Ai1RagTool.split("", 500, 50); + List shortChunks = Ai1RagTool.split("abc", 500, 50); + // 断言 + assertEquals(Collections.emptyList(), emptyChunks); + assertEquals(Collections.singletonList("abc"), shortChunks); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1RedisBlockingReadIntegrationTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1RedisBlockingReadIntegrationTest.java new file mode 100644 index 0000000..1e0b249 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1RedisBlockingReadIntegrationTest.java @@ -0,0 +1,68 @@ +package cn.iocoder.yudao.module.ai1.harness.session; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.redisson.Redisson; +import org.redisson.api.RStream; +import org.redisson.api.RedissonClient; +import org.redisson.api.stream.StreamCreateGroupArgs; +import org.redisson.api.stream.StreamMessageId; +import org.redisson.api.stream.StreamReadArgs; +import org.redisson.api.stream.StreamReadGroupArgs; +import org.redisson.client.codec.StringCodec; +import org.redisson.config.Config; + +import java.time.Duration; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * Redis Stream 阻塞读的集成测试 + * + * 默认禁用,使用 -Dai1.integration.redis=true 启用;ai1.integration.redis.address 可指定独立 Redis + * + * @author 芋道源码 + */ +@EnabledIfSystemProperty(named = "ai1.integration.redis", matches = "true") +public class Ai1RedisBlockingReadIntegrationTest { + + @Test + @Timeout(40) + public void testIdleBlockingReads() { + // 准备参数 + Config config = new Config(); + config.useSingleServer().setAddress(System.getProperty("ai1.integration.redis.address", "redis://127.0.0.1:6379")); + // 断言默认命令超时 + assertEquals(3000, config.useSingleServer().getTimeout()); + RedissonClient client = Redisson.create(config); + RStream tasks = client.getStream("ai1:p3-test:tasks:" + UUID.randomUUID(), StringCodec.INSTANCE); + RStream results = client.getStream("ai1:p3-test:results:" + UUID.randomUUID(), StringCodec.INSTANCE); + try { + // mock 数据 + tasks.createGroup(StreamCreateGroupArgs.name("test-workers").makeStream()); + for (int i = 0; i < 2; i++) { + // 调用,并断言 worker 空闲阻塞读 + long start = System.nanoTime(); + assertTrue(tasks.readGroup("test-workers", "test-consumer", StreamReadGroupArgs.neverDelivered() + .count(1).timeout(Duration.ofSeconds(5))).isEmpty()); + long workerMillis = Duration.ofNanos(System.nanoTime() - start).toMillis(); + assertTrue(workerMillis >= 4500, "worker 未等待 5 秒:" + workerMillis); + // 调用,并断言 SSE 空闲阻塞读 + start = System.nanoTime(); + assertTrue(results.read(StreamReadArgs.greaterThan(new StreamMessageId(0, 0)) + .count(64).timeout(Duration.ofSeconds(5))).isEmpty()); + long sseMillis = Duration.ofNanos(System.nanoTime() - start).toMillis(); + assertTrue(sseMillis >= 4500, "SSE 未等待 5 秒:" + sseMillis); + System.out.println("SESS-22 round=" + i + " workerMs=" + workerMillis + " sseMs=" + sseMillis); + } + } finally { + // 清理数据 + tasks.delete(); + results.delete(); + client.shutdown(); + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java new file mode 100644 index 0000000..2f57288 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionStreamToolTest.java @@ -0,0 +1,477 @@ +package cn.iocoder.yudao.module.ai1.harness.session; + +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.redisson.api.RLock; +import org.redisson.api.RStream; +import org.redisson.api.RedissonClient; +import org.redisson.api.stream.StreamAddArgs; +import org.redisson.api.stream.StreamMessageId; +import org.redisson.api.stream.StreamRangeArgs; +import org.redisson.api.stream.StreamReadArgs; +import org.redisson.client.codec.Codec; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SessionStreamTool} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1SessionStreamToolTest extends BaseMockitoUnitTest { + + private static final Long TENANT_ID = 1L; + private static final Long MESSAGE_ID = 1024L; + private static final Long AGENT_ID = 1L; + private static final Long SESSION_ID = 10L; + private static final StreamMessageId RECORD_ID = new StreamMessageId(1, 0); + + @InjectMocks + private Ai1SessionStreamTool sessionStreamTool; + + @Mock + private RedissonClient redissonClient; + @Mock + private RStream taskStream; + @Mock + private RStream resultStream; + @Mock + private RLock taskLock; + @Spy + private YudaoAi1Properties ai1Properties = new YudaoAi1Properties(); + + @Mock + private Ai1SessionMessageService sessionMessageService; + @Mock + private Ai1SessionService sessionService; + @Mock + private Ai1AgentService agentService; + @Mock + private Ai1ModelService modelService; + @Mock + private Ai1KnowledgeBaseService knowledgeBaseService; + @Mock + private Ai1LlmChatTool llmChatTool; + @Mock + private Ai1McpToolFactory mcpToolFactory; + @Mock + private Ai1SkillToolFactory skillToolFactory; + @Mock + private Ai1RagTool ragTool; + + @BeforeEach + public void before() { + // mock redissonClient 的方法(部分用例不访问 Redis) + lenient().doReturn(taskStream).when(redissonClient).getStream(eq("ai1:session:tasks"), any(Codec.class)); + lenient().doReturn(resultStream).when(redissonClient).getStream(eq("ai1:session:result:" + MESSAGE_ID), any(Codec.class)); + lenient().when(redissonClient.getLock("ai1:session:task-lock:" + MESSAGE_ID)).thenReturn(taskLock); + lenient().when(taskLock.tryLock()).thenReturn(true); + lenient().when(taskLock.isHeldByThread(anyLong())).thenReturn(true); + lenient().when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO() + .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus())); + } + + @Test + public void testStreamResult_disconnectedMustNotComplete() throws Exception { + // 准备参数 + SseEmitter emitter = mock(SseEmitter.class); + // mock 连接断开 + doThrow(new IOException("client disconnected")).when(emitter).send( + any(SseEmitter.SseEventBuilder.class)); + + // 调用 + Boolean shouldComplete = ReflectionTestUtils.invokeMethod( + sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); + + // 断言 + assertEquals(Boolean.FALSE, shouldComplete); + verifyNoInteractions(resultStream); + verify(emitter, never()).complete(); + } + + @Test + public void testStreamResult_terminalMustComplete() { + // 准备参数 + SseEmitter emitter = mock(SseEmitter.class); + // mock 结果流 + when(resultStream.read(any(StreamReadArgs.class))) + .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); + + // 调用 + Boolean shouldComplete = ReflectionTestUtils.invokeMethod( + sessionStreamTool, "streamResult", emitter, MESSAGE_ID, null, TENANT_ID); + + // 断言 + assertEquals(Boolean.TRUE, shouldComplete); + } + + @Test + public void testSendErrorAndComplete_disconnectedMustNotComplete() throws Exception { + // 准备参数 + SseEmitter emitter = mock(SseEmitter.class); + // mock 连接断开 + doThrow(new IOException("client disconnected")).when(emitter).send( + any(SseEmitter.SseEventBuilder.class)); + + // 调用 + ReflectionTestUtils.invokeMethod( + Ai1SessionStreamTool.class, "sendErrorAndComplete", emitter, "error"); + + // 断言 + verify(emitter, never()).complete(); + } + + @Test + public void testFailLostMessage_generating() { + // mock 方法 + AtomicReference tenantRef = new AtomicReference<>(); + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenAnswer(invocation -> { + tenantRef.set(TenantContextHolder.getTenantId()); + return new Ai1SessionMessageDO().setId(MESSAGE_ID).setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus()); + }); + + // 调用 + sessionStreamTool.failLostMessage(MESSAGE_ID, TENANT_ID); + // 断言 + assertEquals(TENANT_ID, tenantRef.get()); + verify(sessionMessageService).updateSessionMessage(argThat(message -> MESSAGE_ID.equals(message.getId()) + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()))); + } + + @Test + public void testFailLostMessage_finished() { + // mock sessionMessageService 的方法 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn( + new Ai1SessionMessageDO().setId(MESSAGE_ID).setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus())); + + // 调用 + sessionStreamTool.failLostMessage(MESSAGE_ID, TENANT_ID); + // 断言 + verify(sessionMessageService, never()).updateSessionMessage(any()); + } + + @Test + public void testHandleTask_withoutTenant() { + // mock 方法 + AtomicReference tenantIdInGenerate = new AtomicReference<>(-1L); + when(agentService.validateAgentExists(AGENT_ID)).thenAnswer(invocation -> { + tenantIdInGenerate.set(TenantContextHolder.getTenantId()); + return new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L); + }); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), eq("你是 客服 的智能助手。"), anyList(), eq("你好"), anyList(), anyList(), eq(SESSION_ID), any(), any())) + .thenReturn(new Ai1LlmChatTool.ChatText("您好", "")); + // 准备参数 + Map fields = buildTaskFields(); + fields.remove("tenantId"); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, fields); + // 断言 + assertNull(tenantIdInGenerate.get()); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Ai1SessionMessageDO.class); + verify(sessionMessageService).updateSessionMessage(messageCaptor.capture()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), messageCaptor.getValue().getStatus()); + assertEquals(List.of("ping", "done"), captureResultTypes()); + verify(taskStream).ack("ai1-session-workers", RECORD_ID); + } + + @Test + public void testHandleTask_generateInTaskTenant() { + // mock 方法 + AtomicReference tenantIdInGenerate = new AtomicReference<>(); + when(agentService.validateAgentExists(AGENT_ID)).thenAnswer(invocation -> { + tenantIdInGenerate.set(TenantContextHolder.getTenantId()); + return new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L); + }); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), anyString(), anyList(), eq("你好"), anyList(), anyList(), eq(SESSION_ID), any(), any())) + .thenAnswer(invocation -> { + Consumer onContent = invocation.getArgument(8); + onContent.accept("您好"); + return new Ai1LlmChatTool.ChatText("您好", ""); + }); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields()); + // 断言 + assertEquals(TENANT_ID, tenantIdInGenerate.get()); + assertNull(TenantContextHolder.getTenantId()); + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Ai1SessionMessageDO.class); + verify(sessionMessageService).updateSessionMessage(messageCaptor.capture()); + assertEquals(MESSAGE_ID, messageCaptor.getValue().getId()); + assertEquals("您好", messageCaptor.getValue().getContent()); + assertNull(messageCaptor.getValue().getReasoning()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), messageCaptor.getValue().getStatus()); + verify(sessionService).touchSession(SESSION_ID); + assertEquals(List.of("ping", "message", "done"), captureResultTypes()); + } + + @Test + public void testHandleTask_generateFailed() { + // mock agentService 的方法 + when(agentService.validateAgentExists(AGENT_ID)).thenThrow(new IllegalStateException("Agent 不存在")); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields()); + // 断言 + ArgumentCaptor messageCaptor = ArgumentCaptor.forClass(Ai1SessionMessageDO.class); + verify(sessionMessageService).updateSessionMessage(messageCaptor.capture()); + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), messageCaptor.getValue().getStatus()); + assertEquals(List.of("ping", "error", "done"), captureResultTypes()); + } + + @Test + public void testHandleTask_locked() { + // mock taskLock 的方法 + when(taskLock.tryLock()).thenReturn(false); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(sessionMessageService, llmChatTool, resultStream, taskStream); + verify(taskLock, never()).unlock(); + } + + @Test + public void testHandleTask_finished() { + // mock sessionMessageService 的方法 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO() + .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus())); + + when(resultStream.rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1))) + .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(resultStream, never()).add(any()); + verify(sessionMessageService, never()).updateSessionMessage(any()); + verify(taskStream).ack("ai1-session-workers", RECORD_ID); + verify(taskLock).unlock(); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void testHandleTask_reclaimedKeepsPartialResult(boolean reclaimed) { + // mock resultStream 的方法 + Map> results = new LinkedHashMap<>(); + results.put(new StreamMessageId(1, 0), Map.of("type", "thinking", "data", "思考")); + results.put(new StreamMessageId(1, 1), Map.of("type", "message", "data", "第一段")); + results.put(new StreamMessageId(1, 2), Map.of("type", "message", "data", "第二段")); + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenReturn(results); + lenient().when(resultStream.isExists()).thenReturn(true); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), reclaimed); + // 断言 + verifyNoInteractions(llmChatTool, agentService); + verify(sessionMessageService).updateSessionMessage(argThat(message -> "第一段第二段".equals(message.getContent()) + && "思考".equals(message.getReasoning()) + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()))); + assertEquals(List.of("error", "done"), captureResultTypes()); + verify(taskStream).ack("ai1-session-workers", RECORD_ID); + } + + @Test + public void testHandleTask_reclaimedWithoutResult() { + // mock resultStream 的方法 + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenReturn(Map.of()); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(sessionMessageService).updateSessionMessage(argThat(message -> "".equals(message.getContent()) + && Ai1SessionMessageStatusEnum.FAILED.getStatus().equals(message.getStatus()))); + assertEquals(List.of("error", "done"), captureResultTypes()); + } + + @Test + public void testHandleTask_resultReadFailedKeepsPending() { + // mock resultStream 的方法 + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenThrow(new IllegalStateException("Redis 不可用")); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(sessionMessageService, never()).updateSessionMessage(any()); + verify(taskStream, never()).ack(anyString(), any(StreamMessageId.class)); + verify(resultStream, never()).add(any()); + verify(taskLock).unlock(); + } + + @Test + public void testHandleTask_lockLostDuringGenerate() { + // mock 方法 + when(agentService.validateAgentExists(AGENT_ID)).thenReturn( + new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L)); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any())) + .thenAnswer(invocation -> { + when(taskLock.isHeldByThread(anyLong())).thenReturn(false); + Consumer onContent = invocation.getArgument(8); + onContent.accept("旧 worker 输出"); + return new Ai1LlmChatTool.ChatText("旧 worker 输出", ""); + }); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields()); + // 断言 + assertEquals(List.of("ping"), captureResultTypes()); + verify(sessionMessageService, never()).updateSessionMessage(any()); + verify(taskStream, never()).ack(anyString(), any(StreamMessageId.class)); + verify(taskLock, never()).unlock(); + } + + @Test + public void testHandleTask_saveFailedKeepsPending() { + // mock 方法 + when(resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX))).thenReturn(Map.of()); + doThrow(new IllegalStateException("数据库不可用")).when(sessionMessageService).updateSessionMessage(any()); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(taskStream, never()).ack(anyString(), any(StreamMessageId.class)); + verify(resultStream, never()).add(any()); + verify(taskLock).unlock(); + } + + @Test + public void testHandleTask_ackFailedDoesNotRegenerate() { + // mock 方法 + Ai1SessionMessageDO message = new Ai1SessionMessageDO().setId(MESSAGE_ID) + .setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus()); + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(message); + doAnswer(invocation -> { + message.setStatus(invocation.getArgument(0).getStatus()); + return null; + }).when(sessionMessageService).updateSessionMessage(any()); + when(agentService.validateAgentExists(AGENT_ID)).thenReturn( + new Ai1AgentDO().setId(AGENT_ID).setName("客服").setProviderId(1L).setModelId(2L)); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any())) + .thenReturn(new Ai1LlmChatTool.ChatText("回复", "")); + when(taskStream.ack("ai1-session-workers", RECORD_ID)).thenThrow(new IllegalStateException("ACK 失败")) + .thenReturn(1L); + when(resultStream.rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1))) + .thenReturn(Map.of(RECORD_ID, Map.of("type", "done", "data", ""))); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields()); + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verify(llmChatTool).chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()); + verify(sessionMessageService).updateSessionMessage(any()); + assertEquals(List.of("ping", "done"), captureResultTypes()); + verify(taskStream, times(2)).ack("ai1-session-workers", RECORD_ID); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testHandleTask_finishedWithoutTerminal(Integer status) { + // mock 方法 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenReturn(new Ai1SessionMessageDO() + .setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()).setStatus(status)); + when(resultStream.rangeReversed(StreamRangeArgs.startId(StreamMessageId.MAX).endId(StreamMessageId.MIN).count(1))).thenReturn(Map.of()); + + // 调用 + sessionStreamTool.handleTask(RECORD_ID, buildTaskFields(), true); + // 断言 + verifyNoInteractions(llmChatTool); + verify(sessionMessageService, never()).updateSessionMessage(any()); + assertEquals(status.equals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()) + ? List.of("done") : List.of("error", "done"), captureResultTypes()); + verify(taskStream).ack("ai1-session-workers", RECORD_ID); + } + + // ========== 测试数据 ========== + + private static Map buildTaskFields() { + Map fields = new HashMap<>(); + fields.put("tenantId", String.valueOf(TENANT_ID)); + fields.put("messageId", String.valueOf(MESSAGE_ID)); + fields.put("agentId", String.valueOf(AGENT_ID)); + fields.put("sessionId", String.valueOf(SESSION_ID)); + fields.put("content", "你好"); + return fields; + } + + // ========== 断言工具 ========== + + @SuppressWarnings({"unchecked", "rawtypes"}) + private List captureResultTypes() { + ArgumentCaptor captor = ArgumentCaptor.forClass(StreamAddArgs.class); + verify(resultStream, atLeastOnce()).add(captor.capture()); + List types = new ArrayList<>(); + for (StreamAddArgs args : captor.getAllValues()) { + // StreamAddArgs 没有读取方法,通过 entries 字段获取事件类型 + types.add(String.valueOf(readEntries(args).get("type"))); + } + return types; + } + + @SuppressWarnings("unchecked") + private static Map readEntries(StreamAddArgs args) { + try { + java.lang.reflect.Field field = args.getClass().getDeclaredField("entries"); + field.setAccessible(true); + return (Map) field.get(args); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionTaskRecoveryIntegrationTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionTaskRecoveryIntegrationTest.java new file mode 100644 index 0000000..136e5aa --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/session/Ai1SessionTaskRecoveryIntegrationTest.java @@ -0,0 +1,265 @@ +package cn.iocoder.yudao.module.ai1.harness.session; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmChatTool; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpToolFactory; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionMessageService; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.mockito.Mock; +import org.redisson.Redisson; +import org.redisson.api.RScript; +import org.redisson.api.RStream; +import org.redisson.api.RedissonClient; +import org.redisson.api.stream.StreamAddArgs; +import org.redisson.api.stream.StreamCreateGroupArgs; +import org.redisson.api.stream.StreamMessageId; +import org.redisson.api.stream.StreamRangeArgs; +import org.redisson.api.stream.StreamReadGroupArgs; +import org.redisson.client.codec.StringCodec; +import org.redisson.config.Config; +import org.redisson.config.NameMapper; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static cn.iocoder.yudao.module.ai1.dal.redis.Ai1RedisKeyConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 会话任务认领与执行锁的 Redis 集成测试 + * + * 默认禁用,使用 -Dai1.integration.redis=true 启用;ai1.integration.redis.address 可指定独立 Redis + * LLM 与数据库使用 mock + * + * @author 芋道源码 + */ +@EnabledIfSystemProperty(named = "ai1.integration.redis", matches = "true") +@Timeout(30) +public class Ai1SessionTaskRecoveryIntegrationTest extends BaseMockitoUnitTest { + + private static final Long MESSAGE_ID = 1024L; + private static final Long SESSION_ID = 10L; + + @Mock + private Ai1SessionMessageService sessionMessageService; + @Mock + private Ai1SessionService sessionService; + @Mock + private Ai1AgentService agentService; + @Mock + private Ai1ModelService modelService; + @Mock + private Ai1KnowledgeBaseService knowledgeBaseService; + @Mock + private Ai1LlmChatTool llmChatTool; + @Mock + private Ai1McpToolFactory mcpToolFactory; + @Mock + private Ai1SkillToolFactory skillToolFactory; + @Mock + private Ai1RagTool ragTool; + + private RedissonClient firstClient; + private RedissonClient secondClient; + private Ai1SessionStreamTool firstWorker; + private Ai1SessionStreamTool secondWorker; + private RStream taskStream; + private RStream resultStream; + private ExecutorService executor; + private final CountDownLatch generated = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicReference message = new AtomicReference<>(); + private final String keyPrefix = "ai1:task-recovery-test:" + UUID.randomUUID() + ":"; + + @BeforeEach + public void before() { + // 准备两个节点与消息 + firstClient = createClient(); + secondClient = createClient(); + firstWorker = createWorker(firstClient); + secondWorker = createWorker(secondClient); + taskStream = secondClient.getStream(SESSION_TASK_STREAM, StringCodec.INSTANCE); + resultStream = secondClient.getStream(String.format(SESSION_RESULT_STREAM, MESSAGE_ID), StringCodec.INSTANCE); + taskStream.createGroup(StreamCreateGroupArgs.name(SESSION_TASK_GROUP).makeStream()); + executor = Executors.newSingleThreadExecutor(); + message.set(new Ai1SessionMessageDO().setId(MESSAGE_ID).setRole(Ai1SessionMessageRoleEnum.ASSISTANT.getRole()) + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus())); + + // mock 业务方法 + when(sessionMessageService.getSessionMessage(MESSAGE_ID)).thenAnswer(invocation -> message.get()); + doAnswer(invocation -> { + message.set(invocation.getArgument(0)); + return null; + }).when(sessionMessageService).updateSessionMessage(any()); + when(agentService.validateAgentExists(1L)).thenReturn(new Ai1AgentDO().setId(1L).setName("客服") + .setProviderId(1L).setModelId(2L)); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.CHAT.getType())); + when(sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(eq(SESSION_ID), eq(MESSAGE_ID), anyInt())) + .thenReturn(new ArrayList<>()); + when(llmChatTool.chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any())) + .thenAnswer(invocation -> { + Consumer onContent = invocation.getArgument(8); + onContent.accept("第一段"); + generated.countDown(); + assertTrue(release.await(15, TimeUnit.SECONDS)); + onContent.accept("第二段"); + return new Ai1LlmChatTool.ChatText("第一段第二段", ""); + }); + } + + @AfterEach + public void after() throws InterruptedException { + release.countDown(); + executor.shutdown(); + assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); + taskStream.delete(); + resultStream.delete(); + secondClient.getLock(String.format(SESSION_TASK_LOCK, MESSAGE_ID)).forceUnlock(); + firstClient.shutdown(); + secondClient.shutdown(); + } + + @Test + public void testReclaim_activeWorkerIsNotRegenerated() throws Exception { + // 准备任务,交给第一个节点生成 + StreamMessageId recordId = addTask(); + Map fields = taskStream.readGroup(SESSION_TASK_GROUP, "first", + StreamReadGroupArgs.neverDelivered().count(1)).get(recordId); + Future generation = executor.submit(() -> firstWorker.handleTask(recordId, fields)); + assertTrue(generated.await(5, TimeUnit.SECONDS)); + + // 等待超过两次看门狗租期,再模拟 PEL 空闲超过认领阈值 + Thread.sleep(3200); + assertTrue(secondClient.getLock(String.format(SESSION_TASK_LOCK, MESSAGE_ID)).isLocked()); + Map> reclaimed = reclaimTask(recordId); + assertTrue(reclaimed.containsKey(recordId)); + secondWorker.handleTask(recordId, reclaimed.get(recordId), true); + + // 断言第二个节点没有生成或确认在途任务 + assertEquals(1, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal()); + assertEquals(List.of("ping", "message"), resultTypes()); + verify(llmChatTool).chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()); + + // 调用第一个节点继续完成,并断言只有一份输出 + release.countDown(); + generation.get(5, TimeUnit.SECONDS); + assertEquals("第一段第二段", message.get().getContent()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), message.get().getStatus()); + assertEquals(List.of("ping", "message", "message", "done"), resultTypes()); + assertEquals(0, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal()); + } + + @Test + public void testReclaim_stoppedWorkerKeepsPartialResult() throws Exception { + // 准备任务,交给第一个节点生成 + StreamMessageId recordId = addTask(); + Map fields = taskStream.readGroup(SESSION_TASK_GROUP, "first", + StreamReadGroupArgs.neverDelivered().count(1)).get(recordId); + Future generation = executor.submit(() -> firstWorker.handleTask(recordId, fields)); + assertTrue(generated.await(5, TimeUnit.SECONDS)); + + // 停止节点 Redis 客户端,模拟节点退出导致看门狗停止续期 + firstClient.shutdown(); + Thread.sleep(2000); + assertFalse(secondClient.getLock(String.format(SESSION_TASK_LOCK, MESSAGE_ID)).isLocked()); + Map> reclaimed = reclaimTask(recordId); + secondWorker.handleTask(recordId, reclaimed.get(recordId), true); + + // 断言接管只保留部分内容并标记失败,不再次调用模型 + assertEquals("第一段", message.get().getContent()); + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), message.get().getStatus()); + assertEquals(List.of("ping", "message", "error", "done"), resultTypes()); + assertEquals(0, taskStream.getPendingInfo(SESSION_TASK_GROUP).getTotal()); + verify(llmChatTool).chat(any(), anyString(), anyList(), anyString(), anyList(), anyList(), anyLong(), any(), any()); + + // 调用旧节点恢复,断言不追加旧增量或覆盖回填结果 + release.countDown(); + generation.get(5, TimeUnit.SECONDS); + assertEquals("第一段", message.get().getContent()); + assertEquals(List.of("ping", "message", "error", "done"), resultTypes()); + } + + // ========== 测试数据 ========== + + private RedissonClient createClient() { + Config config = new Config().setLockWatchdogTimeout(1500); + // 两个节点共享本用例的独立 Key 前缀,测试及清理不会访问业务队列 + config.setNameMapper(new NameMapper() { + @Override + public String map(String name) { + return name == null ? null : keyPrefix + name; + } + + @Override + public String unmap(String name) { + return name != null && name.startsWith(keyPrefix) ? name.substring(keyPrefix.length()) : name; + } + }); + config.useSingleServer().setAddress(System.getProperty("ai1.integration.redis.address", "redis://127.0.0.1:6379")); + return Redisson.create(config); + } + + private Ai1SessionStreamTool createWorker(RedissonClient client) { + Ai1SessionStreamTool worker = new Ai1SessionStreamTool(); + ReflectionTestUtils.setField(worker, "redissonClient", client); + ReflectionTestUtils.setField(worker, "ai1Properties", new YudaoAi1Properties()); + ReflectionTestUtils.setField(worker, "sessionMessageService", sessionMessageService); + ReflectionTestUtils.setField(worker, "sessionService", sessionService); + ReflectionTestUtils.setField(worker, "agentService", agentService); + ReflectionTestUtils.setField(worker, "modelService", modelService); + ReflectionTestUtils.setField(worker, "knowledgeBaseService", knowledgeBaseService); + ReflectionTestUtils.setField(worker, "llmChatTool", llmChatTool); + ReflectionTestUtils.setField(worker, "mcpToolFactory", mcpToolFactory); + ReflectionTestUtils.setField(worker, "skillToolFactory", skillToolFactory); + ReflectionTestUtils.setField(worker, "ragTool", ragTool); + return worker; + } + + private StreamMessageId addTask() { + return taskStream.add(StreamAddArgs.entries(Map.of("messageId", String.valueOf(MESSAGE_ID), + "agentId", "1", "sessionId", String.valueOf(SESSION_ID), "content", "你好"))); + } + + private Map> reclaimTask(StreamMessageId recordId) { + // 只调整本用例独立前缀下的任务,复用生产的查询与认领逻辑 + secondClient.getScript(StringCodec.INSTANCE).eval(RScript.Mode.READ_WRITE, + "return #redis.call('XCLAIM', KEYS[1], ARGV[1], 'first', 0, ARGV[2], 'IDLE', 300000, 'JUSTID')", + RScript.ReturnType.LONG, List.of(SESSION_TASK_STREAM), SESSION_TASK_GROUP, recordId.toString()); + return ReflectionTestUtils.invokeMethod(secondWorker, "reclaimTasks", "second"); + } + + private List resultTypes() { + return resultStream.range(StreamRangeArgs.startId(StreamMessageId.MIN).endId(StreamMessageId.MAX)).values().stream() + .map(fields -> fields.get("type")).toList(); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java new file mode 100644 index 0000000..a2da13e --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillRuntimeCompatibilityTest.java @@ -0,0 +1,147 @@ +package cn.iocoder.yudao.module.ai1.harness.skill; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springaicommunity.agent.tools.*; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.ToolCallback; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +public class Ai1SkillRuntimeCompatibilityTest { + + @TempDir + private Path root; + + @Test + public void testSkillsTool_duplicateName() throws Exception { + // 准备参数 + Path first = Files.createDirectory(root.resolve("first")); + Path second = Files.createDirectory(root.resolve("second")); + Files.writeString(first.resolve("SKILL.md"), "---\nname: same\ndescription: first\n---\nfirst-content\n"); + Files.writeString(second.resolve("SKILL.md"), "---\nname: same\ndescription: second\n---\nsecond-content\n"); + ToolCallback callback = SkillsTool.builder().addSkillsDirectories(List.of(first.toString(), second.toString())).build(); + + // 调用 + String result = callback.call("{\"command\":\"same\"}"); + // 断言 + assertTrue(result.contains("first-content")); + assertTrue(result.contains(first.toString())); + assertFalse(result.contains("second-content")); + assertFalse(callback.getToolDefinition().description().contains("second")); + } + + @Test + public void testFileTools_pathConfinement() throws Exception { + // 准备参数 + Path nested = Files.createDirectory(root.resolve("nested")); + Files.writeString(nested.resolve("marker.txt"), "inside-marker"); + Path outside = Files.createTempDirectory(root.getParent(), "ai1-outside-"); + try { + Files.writeString(outside.resolve("private.txt"), "outside-marker"); + Files.createSymbolicLink(root.resolve("escape"), outside); + Map callbacks = callbacks(); + + // 调用,并断言目录内搜索 + for (String path : List.of("nested", nested.toString())) { + assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", path)).contains("marker.txt")); + assertTrue(call(callbacks, "Grep", Map.of("pattern", "inside-marker", "path", path)).contains("marker.txt")); + assertTrue(call(callbacks, "ListDirectory", Map.of("path", path)).contains("marker.txt")); + } + // 调用,并断言目录外搜索 + for (String path : List.of("../" + outside.getFileName(), outside.toString(), "escape")) { + assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", path)).contains("private.txt")); + assertFalse(call(callbacks, "Grep", Map.of("pattern", "outside-marker", "path", path)).contains("private.txt")); + assertFalse(call(callbacks, "ListDirectory", Map.of("path", path)).contains("private.txt")); + } + // 调用,并断言目录外文件读写 + Path privateFile = outside.resolve("private.txt"); + assertFalse(call(callbacks, "Read", Map.of("filePath", privateFile.toString())).contains("outside-marker")); + call(callbacks, "Write", Map.of("filePath", privateFile.toString(), "content", "changed")); + call(callbacks, "Edit", Map.of("filePath", root.resolve("escape/private.txt").toString(), + "old_string", "outside-marker", "new_string", "changed")); + // 断言 + assertEquals("outside-marker", Files.readString(privateFile)); + } finally { + Files.deleteIfExists(root.resolve("escape")); + Files.deleteIfExists(outside.resolve("private.txt")); + Files.deleteIfExists(outside); + } + } + + @Test + public void testTools_execute() throws Exception { + // 准备参数 + Path skill = Files.createDirectory(root.resolve("probe")); + Files.writeString(skill.resolve("SKILL.md"), "---\nname: probe\ndescription: 兼容性测试\n---\n执行测试脚本\n"); + Files.writeString(root.resolve("probe.txt"), "compatibility-ok\n"); + ToolCallback skillCallback = SkillsTool.builder().addSkillsDirectory(root.toString()).build(); + + // 调用,并断言技能读取 + assertTrue(skillCallback.getToolDefinition().inputSchema().contains("command")); + assertTrue(skillCallback.call("{\"command\":\"probe\"}").contains("执行测试脚本")); + + // 准备执行工具 + Map callbacks = Arrays.stream(ToolCallbacks.from( + ShellTools.builder().workingDirectory(root).build(), + FileSystemTools.builder().allowedDirectory(root).build(), + GlobTool.builder().workingDirectory(root).allowedDirectory(root).build(), + GrepTool.builder().workingDirectory(root).allowedDirectory(root).build(), + ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) + .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); + + // 调用,并断言命令执行和文件搜索 + assertTrue(call(callbacks, "Bash", Map.of("command", "pwd", "timeout", 5000)).contains(root.toRealPath().toString())); + assertTrue(call(callbacks, "Read", Map.of("filePath", root.resolve("probe.txt").toString())).contains("compatibility-ok")); + assertTrue(call(callbacks, "Glob", Map.of("pattern", "*.txt")).contains("probe.txt")); + assertTrue(call(callbacks, "Grep", Map.of("pattern", "compatibility-ok")).contains("probe.txt")); + assertTrue(call(callbacks, "ListDirectory", Map.of()).contains("probe.txt")); + + // 调用 + call(callbacks, "Write", Map.of("filePath", root.resolve("written.txt").toString(), "content", "before")); + // 断言 + assertEquals("before", Files.readString(root.resolve("written.txt"))); + // 调用 + call(callbacks, "Edit", Map.of("filePath", root.resolve("written.txt").toString(), "old_string", "before", "new_string", "after")); + // 断言 + assertEquals("after", Files.readString(root.resolve("written.txt"))); + + // 准备目录外文件 + Path outside = Files.createTempFile(root.getParent(), "ai1-outside-", ".txt"); + try { + Files.writeString(outside, "private-marker"); + // 调用,并断言目录外访问 + assertFalse(call(callbacks, "Read", Map.of("filePath", outside.toString())).contains("private-marker")); + assertFalse(call(callbacks, "Glob", Map.of("pattern", "*.txt", "path", root.getParent().toString())).contains(outside.getFileName().toString())); + } finally { + Files.deleteIfExists(outside); + } + } + + // ========== 工具方法 ========== + + private Map callbacks() { + return Arrays.stream(ToolCallbacks.from( + FileSystemTools.builder().allowedDirectory(root).build(), + GlobTool.builder().workingDirectory(root).allowedDirectory(root).build(), + GrepTool.builder().workingDirectory(root).allowedDirectory(root).build(), + ListDirectoryTool.builder().workingDirectory(root).allowedDirectory(root).build())) + .collect(Collectors.toMap(callback -> callback.getToolDefinition().name(), Function.identity())); + } + + private static String call(Map callbacks, String name, Map arguments) { + ToolCallback callback = callbacks.get(name); + assertNotNull(callback, name); + assertNotNull(JsonUtils.parseTree(callback.getToolDefinition().inputSchema())); + return callback.call(JsonUtils.toJsonString(arguments)); + } +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java new file mode 100644 index 0000000..c7fcc5f --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/harness/skill/Ai1SkillToolFactoryTest.java @@ -0,0 +1,222 @@ +package cn.iocoder.yudao.module.ai1.harness.skill; + +import cn.hutool.core.io.FileUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import cn.iocoder.yudao.module.ai1.framework.ai.config.YudaoAi1Properties; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillFileService; +import cn.iocoder.yudao.module.ai1.service.skill.Ai1SkillService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.springframework.ai.tool.ToolCallback; + +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SkillToolFactory} 的单元测试 + * + * @author 芋道源码 + */ +public class Ai1SkillToolFactoryTest extends BaseMockitoUnitTest { + + private static final Long AGENT_ID = 1L; + private static final Long SKILL_ID = 10L; + private static final String SKILL_CONTENT = "---\nname: pdf-reader\ndescription: 读取 PDF\n---\n\n# pdf-reader\n"; + + @InjectMocks + private Ai1SkillToolFactory skillToolFactory; + + @Spy + private YudaoAi1Properties ai1Properties = new YudaoAi1Properties(); + @Mock + private Ai1SkillService skillService; + @Mock + private Ai1SkillFileService skillFileService; + + @TempDir + private Path tempDir; + + @BeforeEach + public void before() { + // 设置 SKILL 文件目录 + ai1Properties.getSkill().setRoot(tempDir.toString()); + } + + @Test + public void testBuildTools_materialize() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + mockSkillFiles("echo hi"); + + // 调用 + List tools = skillToolFactory.buildTools(buildAgent()); + // 断言 + assertEquals(6, tools.size()); + assertInstanceOf(ToolCallback.class, tools.get(0)); + Path skillDirectory = agentRoot().resolve("pdf-reader"); + assertEquals(SKILL_CONTENT, FileUtil.readUtf8String(skillDirectory.resolve("SKILL.md").toFile())); + assertEquals("echo hi", FileUtil.readUtf8String(skillDirectory.resolve("scripts/run.sh").toFile())); + assertTrue(FileUtil.exist(agentRoot().resolve(".fingerprint").toFile())); + } + + @Test + public void testBuildTools_fingerprintReuseAndRebuild() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + mockSkillFiles("echo v1"); + Ai1AgentDO agent = buildAgent(); + + // 调用 + skillToolFactory.buildTools(agent); + skillToolFactory.buildTools(agent); + // 断言 + verify(skillFileService, times(1)).getSkillFileListBySkillId(SKILL_ID); + + // mock 残留文件 + FileUtil.writeUtf8String("stale", agentRoot().resolve("stale.txt").toFile()); + // mock 更新时间和文件内容变化 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 11, 0)); + mockSkillFiles("echo v2"); + + // 调用 + skillToolFactory.buildTools(agent); + // 断言 + verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); + assertEquals("echo v2", FileUtil.readUtf8String(agentRoot().resolve("pdf-reader/scripts/run.sh").toFile())); + assertFalse(FileUtil.exist(agentRoot().resolve("stale.txt").toFile())); + } + + @Test + public void testBuildTools_rebuildWhenDirectoryRemoved() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + mockSkillFiles("echo hi"); + Ai1AgentDO agent = buildAgent(); + skillToolFactory.buildTools(agent); + + // mock 物化目录被删除 + FileUtil.del(agentRoot()); + + // 调用 + skillToolFactory.buildTools(agent); + // 断言 + verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); + assertTrue(FileUtil.exist(agentRoot().resolve("pdf-reader/SKILL.md").toFile())); + } + + @Test + public void testBuildTools_disabledSkill() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.DISABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + + // 调用 + List tools = skillToolFactory.buildTools(buildAgent()); + // 断言只保留执行工具,不物化 SKILL 文件 + assertEquals(5, tools.size()); + assertTrue(tools.stream().noneMatch(ToolCallback.class::isInstance)); + assertFalse(FileUtil.exist(agentRoot().resolve("pdf-reader").toFile())); + verify(skillFileService, never()).getSkillFileListBySkillId(anyLong()); + } + + @Test + public void testBuildTools_noSkill() { + // 调用,并断言 + assertTrue(skillToolFactory.buildTools(new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of())).isEmpty()); + verify(skillService, never()).getSkillList(anyCollection()); + } + + @Test + public void testEvict() { + // mock skillService 和 skillFileService 的方法 + mockSkill(CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.of(2026, 10, 1, 10, 0)); + mockSkillFiles("echo hi"); + Ai1AgentDO agent = buildAgent(); + skillToolFactory.buildTools(agent); + assertTrue(FileUtil.exist(agentRoot().toFile())); + + // 调用 + skillToolFactory.evict(AGENT_ID); + // 断言 + assertFalse(FileUtil.exist(agentRoot().toFile())); + + // 调用 + skillToolFactory.buildTools(agent); + // 断言重新物化 + verify(skillFileService, times(2)).getSkillFileListBySkillId(SKILL_ID); + } + + @Test + public void testBuildTools_duplicateFrontmatter() { + // 准备参数 + Ai1SkillDO first = new Ai1SkillDO().setId(10L).setName("first").setStatus(CommonStatusEnum.ENABLE.getStatus()); + Ai1SkillDO second = new Ai1SkillDO().setId(20L).setName("second").setStatus(CommonStatusEnum.ENABLE.getStatus()); + Ai1AgentDO agent = new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of(20L, 10L)); + // mock skillService 和 skillFileService 的方法 + when(skillService.getSkillList(agent.getSkillIds())).thenReturn(List.of(second, first)); + for (Ai1SkillDO skill : List.of(first, second)) { + String content = "---\nname: same\ndescription: " + skill.getName() + "\n---\n" + skill.getName() + "-content\n"; + when(skillFileService.getSkillFileListBySkillId(skill.getId())).thenReturn(List.of( + Ai1SkillFileDO.builder().id(skill.getId()).skillId(skill.getId()).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(Ai1SkillFileDO.NAME_SKILL).type(Ai1SkillFileTypeEnum.FILE.getType()).content(content).build())); + } + + // 调用 + ToolCallback tool = (ToolCallback) skillToolFactory.buildTools(agent).get(0); + String result = tool.call("{\"command\":\"same\"}"); + // 断言 + assertTrue(result.contains("first-content")); + assertTrue(result.contains(agentRoot().resolve("first").toString())); + assertFalse(result.contains("second-content")); + + // 调用 + skillToolFactory.evict(AGENT_ID); + ToolCallback rebuiltTool = (ToolCallback) skillToolFactory.buildTools(agent).get(0); + String rebuiltResult = rebuiltTool.call("{\"command\":\"same\"}"); + // 断言 + assertEquals(result, rebuiltResult); + } + + // ========== 测试数据 ========== + + private Path agentRoot() { + return tempDir.resolve("agent_" + AGENT_ID); + } + + private static Ai1AgentDO buildAgent() { + return new Ai1AgentDO().setId(AGENT_ID).setSkillIds(List.of(SKILL_ID)); + } + + // ========== mock 方法 ========== + + private void mockSkill(Integer status, LocalDateTime updateTime) { + Ai1SkillDO skill = new Ai1SkillDO().setId(SKILL_ID).setName("pdf-reader").setStatus(status); + skill.setUpdateTime(updateTime); + when(skillService.getSkillList(List.of(SKILL_ID))).thenReturn(List.of(skill)); + } + + private void mockSkillFiles(String scriptContent) { + Ai1SkillFileDO skillFile = Ai1SkillFileDO.builder().id(100L).skillId(SKILL_ID).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name(Ai1SkillFileDO.NAME_SKILL).type(Ai1SkillFileTypeEnum.FILE.getType()).content(SKILL_CONTENT).build(); + Ai1SkillFileDO scripts = Ai1SkillFileDO.builder().id(101L).skillId(SKILL_ID).parentId(Ai1SkillFileDO.PARENT_ID_ROOT) + .name("scripts").type(Ai1SkillFileTypeEnum.DIRECTORY.getType()).build(); + Ai1SkillFileDO script = Ai1SkillFileDO.builder().id(102L).skillId(SKILL_ID).parentId(101L) + .name("run.sh").type(Ai1SkillFileTypeEnum.FILE.getType()).content(scriptContent).build(); + when(skillFileService.getSkillFileListBySkillId(SKILL_ID)).thenReturn(Arrays.asList(skillFile, scripts, script)); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImplTest.java new file mode 100644 index 0000000..537a009 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/agent/Ai1AgentServiceImplTest.java @@ -0,0 +1,168 @@ +package cn.iocoder.yudao.module.ai1.service.agent; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.agent.vo.Ai1AgentSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.skill.Ai1SkillToolFactory; +import cn.iocoder.yudao.module.ai1.service.session.Ai1SessionService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.AGENT_DISABLE; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.MODEL_TYPE_NOT_CHAT; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link Ai1AgentServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1AgentServiceImpl.class) +public class Ai1AgentServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1AgentServiceImpl agentService; + + @Resource + private Ai1AgentMapper agentMapper; + + @MockitoBean + private Ai1ModelService modelService; + @MockitoBean + private Ai1SessionService sessionService; + @MockitoBean + private Ai1SkillToolFactory skillToolFactory; + + @Test + public void testCreateAgent_modelNotChat() { + // mock modelService 的方法 + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO() + .setModelType(Ai1ModelTypeEnum.EMBEDDING.getType())); + + // 调用,并断言异常 + assertServiceException(() -> agentService.createAgent(buildAgentSaveReqVO()), MODEL_TYPE_NOT_CHAT); + } + + @Test + public void testCreateAgent_success() { + // mock modelService 的方法 + mockChatModel(); + + // 调用 + Long id = agentService.createAgent(buildAgentSaveReqVO().setKnowledgeBaseIds(Arrays.asList(1L, 2L))); + // 断言 + Ai1AgentDO agent = agentMapper.selectById(id); + assertEquals(CommonStatusEnum.DISABLE.getStatus(), agent.getStatus()); + assertEquals(Arrays.asList(1L, 2L), agent.getKnowledgeBaseIds()); + assertTrue(agent.getMcpIds().isEmpty()); + } + + @Test + public void testUpdateAgent_clearBindings() { + // mock 数据 + mockChatModel(); + Long id = agentService.createAgent(buildAgentSaveReqVO().setSkillIds(Arrays.asList(3L))); + + // 调用 + agentService.updateAgent(buildAgentSaveReqVO().setId(id)); + // 断言 + assertTrue(agentMapper.selectById(id).getSkillIds().isEmpty()); + } + + @Test + public void testUpdateAgentStatus_success() { + // mock 数据 + mockChatModel(); + Long id = agentService.createAgent(buildAgentSaveReqVO()); + + // 调用 + agentService.updateAgentStatus(id, CommonStatusEnum.ENABLE.getStatus()); + // 断言 + Ai1AgentDO agent = agentMapper.selectById(id); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), agent.getStatus()); + assertEquals("客服助手", agent.getName()); + } + + @Test + public void testValidateAgentEnabled_disable() { + // mock 数据 + mockChatModel(); + Long id = agentService.createAgent(buildAgentSaveReqVO()); + + // 调用,并断言异常 + assertServiceException(() -> agentService.validateAgentEnabled(id), AGENT_DISABLE, "客服助手"); + } + + @Test + public void testGetAgentListByStatus() { + // mock 数据 + mockChatModel(); + Long enabledId = agentService.createAgent(buildAgentSaveReqVO()); + agentService.createAgent(buildAgentSaveReqVO()); + agentService.updateAgentStatus(enabledId, CommonStatusEnum.ENABLE.getStatus()); + + // 调用 + List list = agentService.getAgentListByStatus(CommonStatusEnum.ENABLE.getStatus()); + // 断言 + assertEquals(1, list.size()); + assertEquals(enabledId, list.get(0).getId()); + } + + @Test + public void testDeleteAgent_cascade() { + // mock 数据 + mockChatModel(); + Long id = agentService.createAgent(buildAgentSaveReqVO()); + + // 调用 + agentService.deleteAgent(id); + // 断言 + assertNull(agentMapper.selectById(id)); + verify(sessionService).deleteSessionListByAgentIds(Collections.singletonList(id)); + verify(skillToolFactory).evict(id); + } + + @Test + public void testGetAgentCountByKnowledgeBaseIdsAndSkillIds() { + // mock 数据 + mockChatModel(); + agentService.createAgent(buildAgentSaveReqVO().setKnowledgeBaseIds(List.of(1L, 12L)).setSkillIds(List.of(3L))); + + // 调用,并断言精确匹配 + assertEquals(1L, agentService.getAgentCountByKnowledgeBaseIds(List.of(12L))); + assertEquals(1L, agentService.getAgentCountByKnowledgeBaseIds(List.of(2L, 1L))); + assertEquals(0L, agentService.getAgentCountByKnowledgeBaseIds(List.of(2L))); + assertEquals(1L, agentService.getAgentCountBySkillIds(List.of(3L))); + assertEquals(0L, agentService.getAgentCountBySkillIds(List.of(1L))); + assertEquals(0L, agentService.getAgentCountBySkillIds(Collections.emptyList())); + } + + // ========== mock 方法 ========== + + private void mockChatModel() { + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO() + .setModelType(Ai1ModelTypeEnum.CHAT.getType())); + } + + // ========== 测试数据 ========== + + private static Ai1AgentSaveReqVO buildAgentSaveReqVO() { + return new Ai1AgentSaveReqVO().setName("客服助手").setProviderId(1L).setModelId(2L).setSystemPrompt("你是客服") + .setKnowledgeBaseIds(Collections.emptyList()).setMcpIds(Collections.emptyList()).setSkillIds(Collections.emptyList()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImplTest.java new file mode 100644 index 0000000..fbb36af --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/home/Ai1HomeServiceImplTest.java @@ -0,0 +1,167 @@ +package cn.iocoder.yudao.module.ai1.service.home; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByAgentRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeMessageSummaryByDateRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.home.vo.Ai1HomeSummaryRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.agent.Ai1AgentDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.agent.Ai1AgentMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.home.Ai1HomeMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.mcp.Ai1McpMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ModelMapper; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static cn.hutool.core.util.RandomUtil.randomEle; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.getDayBeginTime; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * {@link Ai1HomeServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1HomeServiceImpl.class) +public class Ai1HomeServiceImplTest extends BaseDbUnitTest { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Resource + private Ai1HomeServiceImpl homeService; + + @Resource + private Ai1HomeMapper homeMapper; + @Resource + private Ai1AgentMapper agentMapper; + @Resource + private Ai1SkillMapper skillMapper; + @Resource + private Ai1McpMapper mcpMapper; + @Resource + private Ai1ModelMapper modelMapper; + @Resource + private Ai1SessionMapper sessionMapper; + @Resource + private Ai1SessionMessageMapper sessionMessageMapper; + + @Test + public void testGetSummary() { + // mock 数据 + agentMapper.insert(randomAgent()); + Ai1AgentDO deletedAgent = randomAgent(); + agentMapper.insert(deletedAgent); + agentMapper.deleteById(deletedAgent.getId()); + agentMapper.insert(randomAgent()); + skillMapper.insert(randomPojo(Ai1SkillDO.class, o -> o.setStatus(0))); + mcpMapper.insert(randomPojo(Ai1McpDO.class, o -> o.setStatus(0))); + for (int i = 0; i < 3; i++) { + modelMapper.insert(randomPojo(Ai1ModelDO.class, o -> o.setType(0).setStatus(0))); + } + + // 调用 + Ai1HomeSummaryRespVO summary = homeService.getSummary(); + // 断言 + assertEquals(2L, summary.getAgentCount()); + assertEquals(1L, summary.getSkillCount()); + assertEquals(1L, summary.getMcpCount()); + assertEquals(3L, summary.getModelCount()); + } + + @Test + public void testGetMessageSummaryByDate() { + // mock 数据 + LocalDateTime now = LocalDateTime.now(); + Long sessionId = insertSession(1L); + insertSessionMessage(sessionId, now); + insertSessionMessage(sessionId, now); + insertSessionMessage(sessionId, now.minusDays(2)); + insertSessionMessage(sessionId, now.minusDays(8)); + Long deletedMessageId = insertSessionMessage(sessionId, now); + sessionMessageMapper.deleteById(deletedMessageId); + + // 调用 + List list = homeService.getMessageSummaryByDate(7); + // 断言 + assertEquals(7, list.size()); + assertEquals(getDayBeginTime(now.minusDays(6)).format(DATE_FORMATTER), list.get(0).getDate()); + assertEquals(now.format(DATE_FORMATTER), list.get(6).getDate()); + assertEquals(2L, list.get(6).getCount()); + assertEquals(0L, list.get(5).getCount()); + assertEquals(1L, list.get(4).getCount()); + assertEquals(3L, list.stream().mapToLong(Ai1HomeMessageSummaryByDateRespVO::getCount).sum()); + } + + @Test + public void testGetMessageSummaryByDate_daysGreaterThan30() { + // 调用 + List list = homeService.getMessageSummaryByDate(60); + // 断言 + assertEquals(60, list.size()); + } + + @Test + public void testGetMessageSummaryByAgent() { + // mock 数据 + LocalDateTime now = LocalDateTime.now(); + Long session1 = insertSession(1L); + Long session2 = insertSession(2L); + Long deletedSession = insertSession(3L); + insertSessionMessage(session1, now); + insertSessionMessage(session1, now.minusDays(1)); + insertSessionMessage(session1, now.minusDays(3)); + insertSessionMessage(session2, now); + insertSessionMessage(session2, now.minusDays(10)); + for (int i = 0; i < 5; i++) { + insertSessionMessage(deletedSession, now); + } + sessionMapper.deleteById(deletedSession); + + // 调用 + List list = homeService.getMessageSummaryByAgent(7); + // 断言 + assertEquals(2, list.size()); + assertEquals(1L, list.get(0).getAgentId()); + assertEquals(3L, list.get(0).getCount()); + assertEquals(2L, list.get(1).getAgentId()); + assertEquals(1L, list.get(1).getCount()); + } + + // ========== 随机对象 ========== + + private static Ai1AgentDO randomAgent() { + return randomPojo(Ai1AgentDO.class, o -> { + o.setStatus(randomEle(new Integer[]{0, 1})); + o.setKnowledgeBaseIds(null).setMcpIds(null).setSkillIds(null); + }); + } + + private Long insertSession(Long agentId) { + Ai1SessionDO session = randomPojo(Ai1SessionDO.class, o -> o.setAgentId(agentId)); + sessionMapper.insert(session); + return session.getId(); + } + + private Long insertSessionMessage(Long sessionId, LocalDateTime createTime) { + Ai1SessionMessageDO message = randomPojo(Ai1SessionMessageDO.class, o -> { + o.setSessionId(sessionId).setRole("user").setStatus(1); + o.setCreateTime(createTime); + }); + sessionMessageMapper.insert(message); + return message.getId(); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImplTest.java new file mode 100644 index 0000000..acca7db --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeBaseServiceImplTest.java @@ -0,0 +1,101 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.base.Ai1KnowledgeBaseSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.knowledge.Ai1KnowledgeBaseMapper; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import jakarta.annotation.Resource; +import jakarta.validation.ConstraintViolationException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link Ai1KnowledgeBaseServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1KnowledgeBaseServiceImpl.class) +public class Ai1KnowledgeBaseServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1KnowledgeBaseServiceImpl knowledgeBaseService; + @Resource + private Ai1KnowledgeBaseMapper knowledgeBaseMapper; + + @MockitoBean + private Ai1KnowledgeDocumentService knowledgeDocumentService; + @MockitoBean + private Ai1ModelService modelService; + @MockitoBean + private Ai1RagTool ragTool; + @MockitoBean + private Ai1AgentService agentService; + + @Test + public void testGetKnowledgeBaseCountByEmbeddingModelIds() { + // mock 数据:开启与关闭的知识库都算引用,已逻辑删除和其他模型的不算 + knowledgeBaseMapper.insert(new Ai1KnowledgeBaseDO().setName("开启的知识库").setEmbeddingModelId(10L) + .setStatus(CommonStatusEnum.ENABLE.getStatus())); + knowledgeBaseMapper.insert(new Ai1KnowledgeBaseDO().setName("关闭的知识库").setEmbeddingModelId(20L) + .setStatus(CommonStatusEnum.DISABLE.getStatus())); + knowledgeBaseMapper.insert(new Ai1KnowledgeBaseDO().setName("其他模型").setEmbeddingModelId(30L)); + Ai1KnowledgeBaseDO deleted = new Ai1KnowledgeBaseDO().setName("已删除").setEmbeddingModelId(10L); + knowledgeBaseMapper.insert(deleted); + knowledgeBaseMapper.deleteById(deleted.getId()); + + // 调用,并断言引用数量 + assertEquals(2L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Arrays.asList(10L, 20L))); + assertEquals(1L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.singletonList(10L))); + } + + @Test + public void testGetKnowledgeBaseCountByEmbeddingModelIds_empty() { + // 调用,并断言空参数及未引用模型 + assertEquals(0L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(null)); + assertEquals(0L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.emptyList())); + assertEquals(0L, knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.singletonList(999L))); + } + + @ParameterizedTest + @CsvSource({"99,0,false", "100,0,true", "100,99,true", "100,100,false", "500,501,false", "500,-1,false"}) + public void testSaveReqVO_chunkValidation(int chunkSize, int chunkOverlap, boolean valid) { + // 准备参数 + Ai1KnowledgeBaseSaveReqVO reqVO = new Ai1KnowledgeBaseSaveReqVO().setName("知识库") + .setEmbeddingProviderId(1L).setEmbeddingModelId(2L).setStatus(CommonStatusEnum.ENABLE.getStatus()) + .setChunkSize(chunkSize).setChunkOverlap(chunkOverlap).setTopK(5); + + // 调用,并断言边界与组合校验 + if (valid) { + assertDoesNotThrow(() -> ValidationUtils.validate(reqVO)); + } else { + assertThrows(ConstraintViolationException.class, () -> ValidationUtils.validate(reqVO)); + } + } + + @Test + public void testSaveReqVO_chunkRequired() { + // 准备参数,缺少分片大小和重叠 + Ai1KnowledgeBaseSaveReqVO reqVO = new Ai1KnowledgeBaseSaveReqVO().setName("知识库") + .setEmbeddingProviderId(1L).setEmbeddingModelId(2L).setStatus(CommonStatusEnum.ENABLE.getStatus()).setTopK(5); + + // 调用,并断言必填错误而非组合校验空指针 + ConstraintViolationException ex = assertThrows(ConstraintViolationException.class, + () -> ValidationUtils.validate(reqVO)); + assertTrue(ex.getConstraintViolations().stream().anyMatch(v -> "chunkSize".equals(v.getPropertyPath().toString()))); + assertTrue(ex.getConstraintViolations().stream().anyMatch(v -> "chunkOverlap".equals(v.getPropertyPath().toString()))); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImplTest.java new file mode 100644 index 0000000..cee5621 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/knowledge/Ai1KnowledgeDocumentServiceImplTest.java @@ -0,0 +1,224 @@ +package cn.iocoder.yudao.module.ai1.service.knowledge; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentSaveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.knowledge.vo.document.Ai1KnowledgeDocumentVectorizeRespVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeBaseDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.knowledge.Ai1KnowledgeDocumentDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.knowledge.Ai1KnowledgeDocumentMapper; +import cn.iocoder.yudao.module.ai1.enums.knowledge.Ai1KnowledgeDocumentStatusEnum; +import cn.iocoder.yudao.module.ai1.harness.rag.Ai1RagTool; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.service.model.Ai1ModelService; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1KnowledgeDocumentServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1KnowledgeDocumentServiceImpl.class) +public class Ai1KnowledgeDocumentServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1KnowledgeDocumentServiceImpl knowledgeDocumentService; + + @Resource + private Ai1KnowledgeDocumentMapper knowledgeDocumentMapper; + + @MockitoBean + private Ai1KnowledgeBaseService knowledgeBaseService; + @MockitoBean + private Ai1RagTool ragTool; + @MockitoBean + private Ai1ModelService modelService; + + @Test + public void testCreateKnowledgeDocument_initUnprocessed() { + // 准备参数 + Ai1KnowledgeDocumentSaveReqVO reqVO = new Ai1KnowledgeDocumentSaveReqVO() + .setKnowledgeBaseId(1L).setName("三体.txt").setContent("三体舰队"); + + // 调用 + Long id = knowledgeDocumentService.createKnowledgeDocument(reqVO); + // 断言 + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(id); + assertEquals(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), document.getStatus()); + assertEquals(0, document.getChunkCount()); + verify(knowledgeBaseService).validateKnowledgeBaseExists(1L); + } + + @Test + public void testUploadKnowledgeDocument_fileTypeInvalid() { + // 准备参数 + MockMultipartFile file = new MockMultipartFile("file", "a.pdf", null, "x".getBytes(StandardCharsets.UTF_8)); + + // 调用,并断言异常 + assertServiceException(() -> knowledgeDocumentService.uploadKnowledgeDocument(1L, file), KNOWLEDGE_DOCUMENT_FILE_TYPE_INVALID); + } + + @Test + public void testUploadKnowledgeDocument_success() { + // 准备参数 + MockMultipartFile file = new MockMultipartFile("file", "说明.MD", null, "# 标题".getBytes(StandardCharsets.UTF_8)); + + // 调用 + Long id = knowledgeDocumentService.uploadKnowledgeDocument(1L, file); + // 断言 + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(id); + assertEquals("说明.MD", document.getName()); + assertEquals("# 标题", document.getContent()); + } + + @Test + public void testUpdateKnowledgeDocument_contentChanged() { + // mock 数据 + Ai1KnowledgeDocumentDO dbDocument = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "旧内容"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(dbDocument.getKnowledgeBaseId()); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.getKnowledgeBase(dbDocument.getKnowledgeBaseId())).thenReturn(knowledgeBase); + + // 调用 + knowledgeDocumentService.updateKnowledgeDocument(new Ai1KnowledgeDocumentSaveReqVO().setId(dbDocument.getId()) + .setKnowledgeBaseId(999L).setName("新名称").setContent("新内容")); + // 断言 + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(dbDocument.getId()); + assertEquals(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), document.getStatus()); + assertEquals(0, document.getChunkCount()); + assertEquals(dbDocument.getKnowledgeBaseId(), document.getKnowledgeBaseId()); + verify(ragTool).deleteByDocumentIds(knowledgeBase, Collections.singletonList(dbDocument.getId())); + } + + @Test + public void testUpdateKnowledgeDocument_nameOnly() { + // mock 数据 + Ai1KnowledgeDocumentDO dbDocument = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "内容"); + + // 调用 + knowledgeDocumentService.updateKnowledgeDocument(new Ai1KnowledgeDocumentSaveReqVO().setId(dbDocument.getId()) + .setKnowledgeBaseId(dbDocument.getKnowledgeBaseId()).setName("新名称").setContent("内容")); + // 断言 + assertEquals(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), + knowledgeDocumentMapper.selectById(dbDocument.getId()).getStatus()); + verifyNoInteractions(ragTool); + } + + @Test + public void testVectorizeKnowledgeDocument_success() { + // mock 数据 + Ai1KnowledgeDocumentDO dbDocument = insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), "内容"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(dbDocument.getKnowledgeBaseId()); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.validateKnowledgeBaseExists(dbDocument.getKnowledgeBaseId())).thenReturn(knowledgeBase); + when(ragTool.vectorize(knowledgeBase, dbDocument.getId(), "内容")).thenReturn(3); + + // 调用 + Integer chunkCount = knowledgeDocumentService.vectorizeKnowledgeDocument(dbDocument.getId()); + // 断言 + assertEquals(3, chunkCount); + Ai1KnowledgeDocumentDO document = knowledgeDocumentMapper.selectById(dbDocument.getId()); + assertEquals(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), document.getStatus()); + assertEquals(3, document.getChunkCount()); + } + + @Test + public void testVectorizeKnowledgeDocument_fail() { + // mock 数据 + Ai1KnowledgeDocumentDO dbDocument = insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), "内容"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(dbDocument.getKnowledgeBaseId()); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.validateKnowledgeBaseExists(dbDocument.getKnowledgeBaseId())).thenReturn(knowledgeBase); + when(ragTool.vectorize(any(), anyLong(), anyString())).thenThrow(new IllegalStateException("连接失败")); + + // 调用,并断言异常 + assertServiceException(() -> knowledgeDocumentService.vectorizeKnowledgeDocument(dbDocument.getId()), + KNOWLEDGE_DOCUMENT_VECTORIZE_FAIL, "连接失败"); + // 断言 + assertEquals(Ai1KnowledgeDocumentStatusEnum.FAILED.getStatus(), + knowledgeDocumentMapper.selectById(dbDocument.getId()).getStatus()); + } + + @Test + public void testVectorizeListByKnowledgeBaseId_partialFail() { + // mock 数据 + Ai1KnowledgeDocumentDO success = insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), "成功"); + Ai1KnowledgeDocumentDO failure = insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), "失败"); + insertDocument(Ai1KnowledgeDocumentStatusEnum.UNPROCESSED.getStatus(), ""); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(success.getKnowledgeBaseId()) + .setEmbeddingProviderId(1L).setEmbeddingModelId(2L); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.validateKnowledgeBaseExists(knowledgeBase.getId())).thenReturn(knowledgeBase); + when(modelService.getModelRespBO(1L, 2L)).thenReturn(new Ai1ModelRespBO().setModelType(Ai1ModelTypeEnum.EMBEDDING.getType())); + when(ragTool.vectorize(knowledgeBase, success.getId(), "成功")).thenReturn(1); + when(ragTool.vectorize(knowledgeBase, failure.getId(), "失败")).thenThrow(new IllegalStateException("嵌入失败")); + + // 调用 + Ai1KnowledgeDocumentVectorizeRespVO result = knowledgeDocumentService + .vectorizeKnowledgeDocumentListByKnowledgeBaseId(knowledgeBase.getId()); + // 断言 + assertEquals(1, result.getSuccessCount()); + assertEquals(1, result.getFailureCount()); + } + + @Test + public void testVectorizeListByKnowledgeBaseId_embeddingModelDisabled() { + // mock 数据 + Ai1KnowledgeDocumentDO document = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "内容"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(document.getKnowledgeBaseId()) + .setEmbeddingProviderId(1L).setEmbeddingModelId(2L); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.validateKnowledgeBaseExists(knowledgeBase.getId())).thenReturn(knowledgeBase); + when(modelService.getModelRespBO(1L, 2L)).thenThrow(exception(MODEL_DISABLE, "text-embedding")); + + // 调用,并断言异常 + assertServiceException(() -> knowledgeDocumentService.vectorizeKnowledgeDocumentListByKnowledgeBaseId(knowledgeBase.getId()), + MODEL_DISABLE, "text-embedding"); + // 断言 + verify(ragTool, never()).vectorize(any(), anyLong(), anyString()); + assertEquals(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), + knowledgeDocumentMapper.selectById(document.getId()).getStatus()); + } + + @Test + public void testDeleteKnowledgeDocumentList_cleanVectorsByKnowledgeBase() { + // mock 数据 + Ai1KnowledgeDocumentDO document1 = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "1"); + Ai1KnowledgeDocumentDO document2 = insertDocument(Ai1KnowledgeDocumentStatusEnum.VECTORIZED.getStatus(), "2"); + Ai1KnowledgeBaseDO knowledgeBase = new Ai1KnowledgeBaseDO().setId(document1.getKnowledgeBaseId()); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.getKnowledgeBaseList(anyCollection())).thenReturn(Collections.singletonList(knowledgeBase)); + + // 调用 + knowledgeDocumentService.deleteKnowledgeDocumentListByIds(Arrays.asList(document1.getId(), document2.getId())); + // 断言 + assertNull(knowledgeDocumentMapper.selectById(document1.getId())); + assertNull(knowledgeDocumentMapper.selectById(document2.getId())); + verify(ragTool).deleteByDocumentIds(knowledgeBase, Arrays.asList(document1.getId(), document2.getId())); + } + + // ========== 测试数据 ========== + + private Ai1KnowledgeDocumentDO insertDocument(Integer status, String content) { + Ai1KnowledgeDocumentDO document = new Ai1KnowledgeDocumentDO().setKnowledgeBaseId(1L).setName("三体.txt") + .setContent(content).setChunkCount(0).setStatus(status); + knowledgeDocumentMapper.insert(document); + return document; + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImplTest.java new file mode 100644 index 0000000..99dc7c2 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/mcp/Ai1McpServiceImplTest.java @@ -0,0 +1,288 @@ +package cn.iocoder.yudao.module.ai1.service.mcp; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.mcp.vo.Ai1McpSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.mcp.Ai1McpDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.mcp.Ai1McpMapper; +import cn.iocoder.yudao.module.ai1.enums.mcp.Ai1McpTransportEnum; +import cn.iocoder.yudao.module.ai1.harness.mcp.Ai1McpClientTool; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * {@link Ai1McpServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1McpServiceImpl.class) +public class Ai1McpServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1McpServiceImpl mcpService; + + @Resource + private Ai1McpMapper mcpMapper; + + @MockitoBean + private Ai1McpClientTool mcpClientTool; + + @Test + public void testCreateMcp_httpGenerateConfig() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(Collections.singletonMap("Authorization", "Bearer x")); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpMapper.selectById(id); + assertEquals("Bearer x", mcp.getHeaders().get("Authorization")); + Map config = JsonUtils.parseMap(mcp.getConfig()); + assertEquals("http", config.get("transport")); + assertEquals("http://127.0.0.1:8081/mcp", config.get("url")); + assertEquals("Bearer x", ((Map) config.get("headers")).get("Authorization")); + } + + @Test + public void testCreateMcp_transportColumnWins() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()).setUrl("http://ignored") + .setConfig("{\"transport\":\"http\",\"command\":\"npx\",\"args\":[\"-y\",\"server\"]}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpMapper.selectById(id); + assertEquals("stdio", mcp.getTransport()); + assertNull(mcp.getUrl()); + assertEquals("stdio", JsonUtils.parseMap(mcp.getConfig()).get("transport")); + } + + @Test + public void testCreateMcp_stdioWithoutCommand() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()).setConfig("{\"args\":[]}"); + + // 调用,并断言异常 + assertServiceException(() -> mcpService.createMcp(reqVO), MCP_COMMAND_REQUIRED); + } + + @Test + public void testCreateMcp_httpWithoutUrl() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()); + + // 调用,并断言异常 + assertServiceException(() -> mcpService.createMcp(reqVO), MCP_URL_REQUIRED); + } + + @Test + public void testCreateMcp_configInvalid() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()).setConfig("[1,2]"); + + // 调用,并断言异常 + assertServiceException(() -> mcpService.createMcp(reqVO), MCP_CONFIG_INVALID); + } + + @Test + public void testUpdateMcp_switchToStdioClearsUrl() { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()).setUrl("http://127.0.0.1:8081/mcp")); + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()).setId(id) + .setConfig("{\"command\":\"uvx\"}"); + + // 调用 + mcpService.updateMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpMapper.selectById(id); + assertEquals("stdio", mcp.getTransport()); + assertNull(mcp.getUrl()); + verify(mcpClientTool).evict(id); + } + + @Test + public void testUpdateMcp_httpEvictCache() { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp")); + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()).setId(id) + .setUrl("http://127.0.0.1:8082/mcp").setHeaders(Map.of("Authorization", "Bearer new")) + .setConfig("{\"url\":\"http://127.0.0.1:8081/mcp\",\"headers\":{\"Authorization\":\"Bearer old\"}}"); + + // 调用 + mcpService.updateMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpService.getMcp(id); + assertEquals(reqVO.getUrl(), mcp.getUrl()); + assertEquals(reqVO.getHeaders(), mcp.getHeaders()); + Map config = JsonUtils.parseMap(mcp.getConfig()); + assertEquals(reqVO.getUrl(), config.get("url")); + assertEquals(reqVO.getHeaders(), config.get("headers")); + verify(mcpClientTool).evict(id); + } + + @ParameterizedTest + @NullSource + @MethodSource("emptyHeaders") + public void testUpdateMcp_clearHeaders(Map headers) { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(Map.of("Authorization", "Bearer old"))); + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()).setId(id) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(headers) + .setConfig("{\"transport\":\"http\",\"url\":\"http://127.0.0.1:8081/mcp\"," + + "\"headers\":{\"Authorization\":\"Bearer old\"}}"); + + // 调用 + mcpService.updateMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpService.getMcp(id); + assertTrue(mcp.getHeaders().isEmpty()); + assertFalse(JsonUtils.parseMap(mcp.getConfig()).containsKey("headers")); + verify(mcpClientTool).evict(id); + } + + @Test + public void testCreateMcp_headersColumnWins() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(Map.of("Authorization", "Bearer column")) + .setConfig("{\"headers\":{\"Authorization\":\"Bearer config\"}}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言平铺请求头覆盖 config 中的旧请求头 + Ai1McpDO mcp = mcpService.getMcp(id); + assertEquals(reqVO.getHeaders(), mcp.getHeaders()); + assertEquals(mcp.getHeaders(), JsonUtils.parseMap(mcp.getConfig()).get("headers")); + } + + @ParameterizedTest + @NullSource + @MethodSource("emptyHeaders") + public void testCreateMcp_emptyHeadersRemoveConfigHeaders(Map headers) { + // 准备参数:平铺请求头为空,config 中仍有旧鉴权 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp").setHeaders(headers) + .setConfig("{\"headers\":{\"Authorization\":\"Bearer old\"}}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言列表回显和运行时配置均不保留旧请求头 + Ai1McpDO mcp = mcpService.getMcp(id); + assertTrue(mcp.getHeaders().isEmpty()); + assertFalse(JsonUtils.parseMap(mcp.getConfig()).containsKey("headers")); + } + + @Test + public void testCreateMcp_configUrlFallback() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setConfig("{\"url\":\"http://127.0.0.1:8081/mcp\"}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言 + Ai1McpDO mcp = mcpService.getMcp(id); + assertEquals("http://127.0.0.1:8081/mcp", mcp.getUrl()); + assertEquals(mcp.getUrl(), JsonUtils.parseMap(mcp.getConfig()).get("url")); + } + + @Test + public void testCreateMcp_conflictingUrls() { + // 准备参数 + Ai1McpSaveReqVO reqVO = buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp") + .setConfig("{\"url\":\"http://127.0.0.1:8082/mcp\"}"); + + // 调用 + Long id = mcpService.createMcp(reqVO); + // 断言平铺地址覆盖 config 中的旧地址 + Ai1McpDO mcp = mcpService.getMcp(id); + assertEquals(reqVO.getUrl(), mcp.getUrl()); + assertEquals(reqVO.getUrl(), JsonUtils.parseMap(mcp.getConfig()).get("url")); + } + + @Test + public void testDeleteMcp_success() { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()) + .setConfig("{\"command\":\"npx\"}")); + + // 调用 + mcpService.deleteMcp(id); + // 断言 + assertNull(mcpMapper.selectById(id)); + verify(mcpClientTool).evict(id); + } + + @Test + public void testDeleteMcpListByIds_success() { + // mock 数据 + Long id1 = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp")); + Long id2 = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.STDIO.getTransport()) + .setConfig("{\"command\":\"npx\"}")); + Long otherId = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8082/mcp")); + // 准备参数 + List ids = List.of(id1, id2); + + // 调用 + mcpService.deleteMcpListByIds(ids); + // 断言 + assertNull(mcpMapper.selectById(id1)); + assertNull(mcpMapper.selectById(id2)); + assertNotNull(mcpMapper.selectById(otherId)); + verify(mcpClientTool).evict(id1); + verify(mcpClientTool).evict(id2); + } + + @Test + public void testDeleteMcpListByIds_notExists() { + // mock 数据 + Long id = mcpService.createMcp(buildMcpSaveReqVO(Ai1McpTransportEnum.HTTP.getTransport()) + .setUrl("http://127.0.0.1:8081/mcp")); + // 准备参数 + List ids = List.of(id, Long.MAX_VALUE); + + // 调用,并断言异常 + assertServiceException(() -> mcpService.deleteMcpListByIds(ids), MCP_NOT_EXISTS); + // 断言 + assertNotNull(mcpMapper.selectById(id)); + verifyNoInteractions(mcpClientTool); + } + + // ========== 测试数据 ========== + + private static Stream> emptyHeaders() { + return Stream.of(Collections.emptyMap()); + } + + private static Ai1McpSaveReqVO buildMcpSaveReqVO(String transport) { + return new Ai1McpSaveReqVO().setName("天气查询").setTransport(transport).setStatus(CommonStatusEnum.ENABLE.getStatus()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java new file mode 100644 index 0000000..799bb05 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ModelServiceImplTest.java @@ -0,0 +1,368 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelImportReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.model.Ai1ModelSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ModelDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ModelMapper; +import cn.iocoder.yudao.module.ai1.enums.model.Ai1ModelTypeEnum; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.harness.model.Ai1ProviderTool; +import cn.iocoder.yudao.module.ai1.service.model.bo.Ai1ModelRespBO; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.service.knowledge.Ai1KnowledgeBaseService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * {@link Ai1ModelServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1ModelServiceImpl.class) +public class Ai1ModelServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1ModelServiceImpl modelService; + + @Resource + private Ai1ModelMapper modelMapper; + + @MockitoBean + private Ai1ProviderService providerService; + @MockitoBean + private Ai1AgentService agentService; + @MockitoBean + private Ai1KnowledgeBaseService knowledgeBaseService; + @MockitoBean + private Ai1LlmModelFactory llmModelFactory; + @MockitoBean + private Ai1ProviderTool providerTool; + + @Test + public void testCreateModel_duplicate() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // 准备参数 + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setProviderId(dbModel.getProviderId()).setModel(dbModel.getModel())); + + // 调用,并断言异常 + assertServiceException(() -> modelService.createModel(reqVO), MODEL_DUPLICATE, dbModel.getModel()); + } + + @Test + public void testCreateModel_sameModelInOtherProvider() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // 准备参数 + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setProviderId(dbModel.getProviderId() + 1).setModel(dbModel.getModel())); + + // 调用 + Long id = modelService.createModel(reqVO); + // 断言 + assertEquals(reqVO.getModel(), modelMapper.selectById(id).getModel()); + } + + @Test + public void testUpdateModel_evictCache() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // 准备参数 + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()).setProviderId(dbModel.getProviderId())); + + // 调用 + modelService.updateModel(reqVO); + // 断言 + assertEquals(reqVO.getModel(), modelMapper.selectById(dbModel.getId()).getModel()); + assertEquals(dbModel.getProviderId(), modelMapper.selectById(dbModel.getId()).getProviderId()); + verify(llmModelFactory).evictByModelId(dbModel.getId()); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1}) + public void testUpdateModel_providerCannotChange(int type) { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(o -> o.setType(type)); + modelMapper.insert(dbModel); + // 准备参数:同类型模型改挂到其他供应商,并同时修改名称与标识 + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()) + .setProviderId(dbModel.getProviderId() + 1).setType(type) + .setName("新名称").setModel("new-model")); + + // 调用,并断言异常 + assertServiceException(() -> modelService.updateModel(reqVO), MODEL_NOT_BELONG_PROVIDER); + // 断言归属和其他字段均未修改,也未校验新供应商或清理缓存 + Ai1ModelDO model = modelMapper.selectById(dbModel.getId()); + assertEquals(dbModel.getProviderId(), model.getProviderId()); + assertEquals(dbModel.getName(), model.getName()); + assertEquals(dbModel.getModel(), model.getModel()); + assertEquals(dbModel.getType(), model.getType()); + verifyNoInteractions(providerService, agentService, knowledgeBaseService, llmModelFactory); + } + + @Test + public void testDeleteModel_usedByAgent() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // mock agentService 的方法 + when(agentService.getAgentCountByModelIds(anyList())).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.deleteModel(dbModel.getId()), MODEL_USED_BY_AGENT); + // 断言 + assertNotNull(modelMapper.selectById(dbModel.getId())); + } + + @Test + public void testDeleteModel_usedByKnowledgeBase() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // mock knowledgeBaseService 的方法 + when(knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.singletonList(dbModel.getId()))) + .thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.deleteModel(dbModel.getId()), MODEL_USED_BY_KNOWLEDGE_BASE); + // 断言 + assertNotNull(modelMapper.selectById(dbModel.getId())); + verifyNoInteractions(llmModelFactory); + } + + @Test + public void testDeleteModelListByIds_usedByKnowledgeBase() { + // mock 数据 + Ai1ModelDO first = randomModelDO(); + Ai1ModelDO second = randomModelDO(); + modelMapper.insert(first); + modelMapper.insert(second); + // 准备参数 + List ids = Arrays.asList(first.getId(), second.getId()); + when(knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(ids)).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.deleteModelListByIds(ids), MODEL_USED_BY_KNOWLEDGE_BASE); + // 断言整批未删除或清理缓存 + assertNotNull(modelMapper.selectById(first.getId())); + assertNotNull(modelMapper.selectById(second.getId())); + verifyNoInteractions(llmModelFactory); + } + + @Test + public void testDeleteModelListByIds_success() { + // mock 数据 + Ai1ModelDO first = randomModelDO(); + Ai1ModelDO second = randomModelDO(); + modelMapper.insert(first); + modelMapper.insert(second); + List ids = Arrays.asList(first.getId(), second.getId()); + + // 调用 + modelService.deleteModelListByIds(ids); + // 断言 + assertNull(modelMapper.selectById(first.getId())); + assertNull(modelMapper.selectById(second.getId())); + verify(knowledgeBaseService).getKnowledgeBaseCountByEmbeddingModelIds(ids); + verify(llmModelFactory).evictByModelId(first.getId()); + verify(llmModelFactory).evictByModelId(second.getId()); + } + + @Test + public void testUpdateModel_typeUsedByKnowledgeBase() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(o -> o.setType(Ai1ModelTypeEnum.EMBEDDING.getType())); + modelMapper.insert(dbModel); + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()) + .setProviderId(dbModel.getProviderId()).setType(Ai1ModelTypeEnum.CHAT.getType())); + when(knowledgeBaseService.getKnowledgeBaseCountByEmbeddingModelIds(Collections.singletonList(dbModel.getId()))) + .thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.updateModel(reqVO), MODEL_TYPE_USED_BY_KNOWLEDGE_BASE); + // 断言 + assertEquals(Ai1ModelTypeEnum.EMBEDDING.getType(), modelMapper.selectById(dbModel.getId()).getType()); + verifyNoInteractions(llmModelFactory); + } + + @Test + public void testUpdateModel_typeUsedByAgent() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(o -> o.setType(Ai1ModelTypeEnum.CHAT.getType())); + modelMapper.insert(dbModel); + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()).setProviderId(dbModel.getProviderId())); + when(agentService.getAgentCountByModelIds(Collections.singletonList(dbModel.getId()))).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> modelService.updateModel(reqVO), MODEL_TYPE_USED_BY_AGENT); + // 断言 + assertEquals(Ai1ModelTypeEnum.CHAT.getType(), modelMapper.selectById(dbModel.getId()).getType()); + verifyNoInteractions(llmModelFactory); + } + + @Test + public void testUpdateModel_sameTypeDoesNotCheckReferences() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()).setProviderId(dbModel.getProviderId()).setType(dbModel.getType())); + + // 调用 + modelService.updateModel(reqVO); + // 断言改名等操作不查询引用 + assertEquals(reqVO.getName(), modelMapper.selectById(dbModel.getId()).getName()); + verifyNoInteractions(agentService, knowledgeBaseService); + verify(llmModelFactory).evictByModelId(dbModel.getId()); + } + + @Test + public void testUpdateModel_unusedTypeCanChange() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(o -> o.setType(Ai1ModelTypeEnum.EMBEDDING.getType())); + modelMapper.insert(dbModel); + Ai1ModelSaveReqVO reqVO = randomModelSaveReqVO(o -> o.setId(dbModel.getId()) + .setProviderId(dbModel.getProviderId()).setType(Ai1ModelTypeEnum.CHAT.getType())); + + // 调用 + modelService.updateModel(reqVO); + // 断言 + assertEquals(Ai1ModelTypeEnum.CHAT.getType(), modelMapper.selectById(dbModel.getId()).getType()); + verify(llmModelFactory).evictByModelId(dbModel.getId()); + } + + @Test + public void testImportRemoteModelList_skipExisting() { + // mock 数据 + Long providerId = randomLongId(); + modelMapper.insert(randomModelDO(o -> o.setProviderId(providerId).setModel("deepseek-chat"))); + // 准备参数 + Ai1ModelImportReqVO reqVO = new Ai1ModelImportReqVO().setProviderId(providerId) + .setModels(Arrays.asList("deepseek-chat", "deepseek-reasoner", " deepseek-reasoner ", " ")); + + // 调用 + Integer count = modelService.importRemoteModelList(reqVO); + // 断言 + assertEquals(1, count); + List models = modelMapper.selectListByProviderId(providerId); + assertEquals(Arrays.asList("deepseek-chat", "deepseek-reasoner"), convertList(models, Ai1ModelDO::getModel)); + Ai1ModelDO imported = models.get(1); + assertEquals(Ai1ModelTypeEnum.CHAT.getType(), imported.getType()); + assertEquals(CommonStatusEnum.ENABLE.getStatus(), imported.getStatus()); + } + + @Test + public void testImportRemoteModelList_allExists() { + // mock 数据 + Ai1ModelDO dbModel = randomModelDO(); + modelMapper.insert(dbModel); + // 准备参数 + Ai1ModelImportReqVO reqVO = new Ai1ModelImportReqVO().setProviderId(dbModel.getProviderId()) + .setModels(Arrays.asList(dbModel.getModel())); + + // 调用,并断言异常 + assertServiceException(() -> modelService.importRemoteModelList(reqVO), MODEL_IMPORT_ALL_EXISTS); + } + + @Test + public void testGetRemoteModelList_success() { + // mock providerService 和 providerTool 的方法 + Long providerId = randomLongId(); + Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setId(providerId).setHeaders(null)); + when(providerService.validateProviderExists(providerId)).thenReturn(provider); + List remoteModels = Arrays.asList("deepseek-chat", "deepseek-reasoner"); + when(providerTool.listModels(provider.getBaseUrl(), provider.getApiKey(), null)).thenReturn(remoteModels); + + // 调用 + List models = modelService.getRemoteModelList(providerId); + // 断言 + assertEquals(remoteModels, models); + } + + @Test + public void testGetModelRespBO_providerDisable() { + // mock 方法 + Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus())); + when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); + + // 调用,并断言异常 + assertServiceException(() -> modelService.getModelRespBO(provider.getId(), randomLongId()), + PROVIDER_DISABLE, provider.getName()); + } + + @Test + public void testGetModelRespBO_modelNotBelong() { + // mock 方法 + Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus())); + when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); + // mock 数据 + Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId() + 1)); + modelMapper.insert(model); + + // 调用,并断言异常 + assertServiceException(() -> modelService.getModelRespBO(provider.getId(), model.getId()), MODEL_NOT_BELONG_PROVIDER); + } + + @Test + public void testGetModelRespBO_success() { + // mock 方法 + Ai1ProviderDO provider = randomPojo(Ai1ProviderDO.class, o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()) + .setHeaders(Map.of("X-App", "yudao"))); + when(providerService.validateProviderExists(provider.getId())).thenReturn(provider); + // mock 数据 + Ai1ModelDO model = randomModelDO(o -> o.setProviderId(provider.getId())); + modelMapper.insert(model); + + // 调用 + Ai1ModelRespBO respBO = modelService.getModelRespBO(provider.getId(), model.getId()); + // 断言 + assertEquals(provider.getBaseUrl(), respBO.getBaseUrl()); + assertEquals(model.getModel(), respBO.getModel()); + assertEquals(model.getType(), respBO.getModelType()); + assertEquals(Map.of("X-App", "yudao"), respBO.getHeaders()); + } + + // ========== 随机对象 ========== + + @SafeVarargs + private static Ai1ModelDO randomModelDO(Consumer... consumers) { + Consumer consumer = o -> o.setId(null).setName(StrUtil.maxLength(o.getName(), 10)) + .setType(Ai1ModelTypeEnum.CHAT.getType()).setStatus(CommonStatusEnum.ENABLE.getStatus()); + return randomPojo(Ai1ModelDO.class, ArrayUtils.append(consumer, consumers)); + } + + @SafeVarargs + private static Ai1ModelSaveReqVO randomModelSaveReqVO(Consumer... consumers) { + Consumer consumer = o -> o.setId(null).setName(StrUtil.maxLength(o.getName(), 10)) + .setType(Ai1ModelTypeEnum.EMBEDDING.getType()).setStatus(CommonStatusEnum.ENABLE.getStatus()); + return randomPojo(Ai1ModelSaveReqVO.class, ArrayUtils.append(consumer, consumers)); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java new file mode 100644 index 0000000..8e6bd82 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/model/Ai1ProviderServiceImplTest.java @@ -0,0 +1,186 @@ +package cn.iocoder.yudao.module.ai1.service.model; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderConnectRespVO; +import cn.iocoder.yudao.module.ai1.controller.admin.model.vo.provider.Ai1ProviderSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.model.Ai1ProviderDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.model.Ai1ProviderMapper; +import cn.iocoder.yudao.module.ai1.harness.llm.Ai1LlmModelFactory; +import cn.iocoder.yudao.module.ai1.harness.model.Ai1ProviderTool; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1ProviderServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1ProviderServiceImpl.class) +public class Ai1ProviderServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1ProviderServiceImpl providerService; + + @Resource + private Ai1ProviderMapper providerMapper; + + @MockitoBean + private Ai1ModelService modelService; + @MockitoBean + private Ai1LlmModelFactory llmModelFactory; + @MockitoBean + private Ai1ProviderTool providerTool; + + @Test + public void testCreateProvider_success() { + // 准备参数 + Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setHeaders(Map.of("X-Session", "{session}"))); + + // 调用 + Long id = providerService.createProvider(reqVO); + // 断言 + Ai1ProviderDO provider = providerMapper.selectById(id); + assertEquals(reqVO.getName(), provider.getName()); + assertEquals(reqVO.getApiKey(), provider.getApiKey()); + } + + @Test + public void testUpdateProvider_blankApiKeyKeepsOriginal() { + // mock 数据 + Ai1ProviderDO dbProvider = randomProviderDO(); + providerMapper.insert(dbProvider); + // 准备参数 + Ai1ProviderSaveReqVO reqVO = randomProviderSaveReqVO(o -> o.setId(dbProvider.getId()).setApiKey("")); + + // 调用 + providerService.updateProvider(reqVO); + // 断言 + Ai1ProviderDO provider = providerMapper.selectById(dbProvider.getId()); + assertEquals(dbProvider.getApiKey(), provider.getApiKey()); + assertEquals(reqVO.getBaseUrl(), provider.getBaseUrl()); + verify(llmModelFactory).evictByProviderId(dbProvider.getId()); + } + + @Test + public void testDeleteProvider_hasModel() { + // mock 数据 + Ai1ProviderDO dbProvider = randomProviderDO(); + providerMapper.insert(dbProvider); + // mock modelService 的方法 + when(modelService.getModelCountByProviderIds(anyList())).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> providerService.deleteProvider(dbProvider.getId()), PROVIDER_HAS_MODEL); + // 断言 + assertNotNull(providerMapper.selectById(dbProvider.getId())); + } + + @Test + public void testDeleteProvider_success() { + // mock 数据 + Ai1ProviderDO dbProvider = randomProviderDO(); + providerMapper.insert(dbProvider); + // mock modelService 的方法 + when(modelService.getModelCountByProviderIds(anyList())).thenReturn(0L); + + // 调用 + providerService.deleteProvider(dbProvider.getId()); + // 断言 + assertNull(providerMapper.selectById(dbProvider.getId())); + verify(llmModelFactory).evictByProviderId(dbProvider.getId()); + } + + @Test + public void testTestProviderConnect_notExists() { + // 调用,并断言异常 + assertServiceException(() -> providerService.testProviderConnect(randomLongId()), PROVIDER_NOT_EXISTS); + } + + @Test + public void testTestProviderConnect_success() { + // mock 数据 + Ai1ProviderDO dbProvider = randomProviderDO(o -> o.setHeaders(Map.of("X-App", "yudao"))); + providerMapper.insert(dbProvider); + // mock 方法 + Ai1ProviderConnectRespVO result = randomPojo(Ai1ProviderConnectRespVO.class); + when(providerTool.testConnect(eq(dbProvider.getBaseUrl()), eq(dbProvider.getApiKey()), + eq(Map.of("X-App", "yudao")))) + .thenReturn(result); + + // 调用 + Ai1ProviderConnectRespVO connectResult = providerService.testProviderConnect(dbProvider.getId()); + // 断言 + assertSame(result, connectResult); + } + + @Test + public void testCreateAndUpdateProvider_headers() { + // 准备参数 + Map headers = new LinkedHashMap<>(); + headers.put(" X-Z ", "${HEADER_TOKEN}"); + headers.put("X-A", "{session}"); + Ai1ProviderSaveReqVO req = randomProviderSaveReqVO(o -> o.setHeaders(headers)); + + // 调用 + Long id = providerService.createProvider(req); + // 断言 + Ai1ProviderDO created = providerMapper.selectById(id); + assertEquals(List.of("X-Z", "X-A"), List.copyOf(created.getHeaders().keySet())); + assertEquals("${HEADER_TOKEN}", created.getHeaders().get("X-Z")); + assertEquals("{session}", created.getHeaders().get("X-A")); + + // 准备参数 + req.setId(id).setHeaders(null); + // 调用 + providerService.updateProvider(req); + // 断言 + assertEquals(created.getHeaders(), providerMapper.selectById(id).getHeaders()); + + // 准备参数 + req.setHeaders(Map.of("X-New", "new")); + // 调用 + providerService.updateProvider(req); + // 断言 + assertEquals(req.getHeaders(), providerMapper.selectById(id).getHeaders()); + + // 准备参数 + req.setHeaders(Map.of()); + // 调用 + providerService.updateProvider(req); + // 断言 + assertEquals(Map.of(), providerMapper.selectById(id).getHeaders()); + } + + // ========== 随机对象 ========== + + @SafeVarargs + private static Ai1ProviderDO randomProviderDO(Consumer... consumers) { + Consumer consumer = o -> o.setId(null).setHeaders(null).setStatus(CommonStatusEnum.ENABLE.getStatus()); + return randomPojo(Ai1ProviderDO.class, ArrayUtils.append(consumer, consumers)); + } + + @SafeVarargs + private static Ai1ProviderSaveReqVO randomProviderSaveReqVO(Consumer... consumers) { + Consumer consumer = o -> o.setId(null).setHeaders(null).setStatus(CommonStatusEnum.ENABLE.getStatus()); + return randomPojo(Ai1ProviderSaveReqVO.class, ArrayUtils.append(consumer, consumers)); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImplTest.java new file mode 100644 index 0000000..63f5d7b --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionMessageServiceImplTest.java @@ -0,0 +1,334 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.message.Ai1SessionMessageSendReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionMessageDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMessageMapper; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageRoleEnum; +import cn.iocoder.yudao.module.ai1.enums.session.Ai1SessionMessageStatusEnum; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import cn.iocoder.yudao.module.ai1.harness.session.Ai1SessionStreamTool; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.redisson.client.RedisConnectionException; +import org.redisson.client.RedisTimeoutException; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SessionMessageServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1SessionMessageServiceImpl.class) +public class Ai1SessionMessageServiceImplTest extends BaseDbUnitTest { + + private static final Long TENANT_ID = 1L; + private static final Long USER_ID = 100L; + + @Resource + private Ai1SessionMessageServiceImpl sessionMessageService; + + @MockitoSpyBean + private Ai1SessionMessageMapper sessionMessageMapper; + + @MockitoBean + private Ai1SessionService sessionService; + @MockitoBean + private Ai1AgentService agentService; + @MockitoBean + private Ai1SessionStreamTool sessionStreamTool; + + @BeforeEach + public void before() { + // 设置租户上下文 + TenantContextHolder.setTenantId(TENANT_ID); + } + + @AfterEach + public void after() { + // 清理租户上下文 + TenantContextHolder.clear(); + } + + @Test + public void testSendSessionMessageStream_success() { + // mock 方法 + Ai1SessionDO session = new Ai1SessionDO().setId(10L).setAgentId(1L).setUserId(USER_ID) + .setTitle(Ai1SessionDO.TITLE_DEFAULT); + // mock sessionService 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(session); + SseEmitter emitter = new SseEmitter(); + when(sessionStreamTool.open(anyLong(), isNull())).thenReturn(emitter); + // 准备参数 + String content = "三体舰队还有多久到达地球?请结合原著中的时间线详细说明,并对比面壁计划、执剑人与掩体计划等人类应对方案的技术发展差异"; + + // 调用 + SseEmitter result = sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent(content)); + // 断言 + assertSame(emitter, result); + List messages = sessionMessageMapper.selectListBySessionId(10L); + assertEquals(2, messages.size()); + assertEquals(Ai1SessionMessageRoleEnum.USER.getRole(), messages.get(0).getRole()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), messages.get(0).getStatus()); + Ai1SessionMessageDO assistant = messages.get(1); + assertEquals(Ai1SessionMessageRoleEnum.ASSISTANT.getRole(), assistant.getRole()); + assertEquals(Ai1SessionMessageStatusEnum.GENERATING.getStatus(), assistant.getStatus()); + // 断言首条消息命名和任务投递 + verify(sessionService).updateSessionTitle(eq(10L), + argThat(title -> title.length() == Ai1SessionDO.TITLE_MAX_LENGTH && title.endsWith("..."))); + verify(sessionStreamTool).submit(assistant.getId(), 1L, 10L, content); + verify(sessionStreamTool).open(assistant.getId(), null); + } + + @Test + public void testSendSessionMessageStream_submitFailed() { + // mock sessionService 和 sessionStreamTool 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + doThrow(new RedisConnectionException("Redis 不可用")).when(sessionStreamTool) + .submit(anyLong(), eq(1L), eq(10L), eq("你好")); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言用户消息保留、助手不再生成中 + List messages = sessionMessageMapper.selectListBySessionId(10L); + assertEquals(2, messages.size()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), messages.get(0).getStatus()); + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), messages.get(1).getStatus()); + assertEquals("", messages.get(1).getContent()); + verify(sessionStreamTool, never()).open(anyLong(), any()); + verify(sessionStreamTool).error("会话提交失败,请稍后重试"); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2}) + public void testSendSessionMessageStream_submitTimeoutKeepsWorkerResult(int status) { + // mock sessionService 和 sessionStreamTool 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + doAnswer(invocation -> { + Long messageId = invocation.getArgument(0); + sessionMessageMapper.updateById(new Ai1SessionMessageDO().setId(messageId) + .setStatus(status).setContent("worker 已回填").setReasoning("已有思考")); + throw new RedisTimeoutException("投递响应丢失"); + }).when(sessionStreamTool).submit(anyLong(), eq(1L), eq(10L), eq("你好")); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言已有终态和内容不被补偿覆盖 + Ai1SessionMessageDO assistant = sessionMessageMapper.selectListBySessionId(10L).get(1); + assertEquals(status, assistant.getStatus()); + assertEquals("worker 已回填", assistant.getContent()); + assertEquals("已有思考", assistant.getReasoning()); + verify(sessionStreamTool, never()).open(anyLong(), any()); + } + + @Test + public void testSendSessionMessageStream_workerCanCompleteAfterSubmitTimeout() { + // mock sessionService 和 sessionStreamTool 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + doThrow(new RedisTimeoutException("投递响应丢失")).when(sessionStreamTool) + .submit(anyLong(), eq(1L), eq(10L), eq("你好")); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + Ai1SessionMessageDO assistant = sessionMessageMapper.selectListBySessionId(10L).get(1); + assertEquals(Ai1SessionMessageStatusEnum.FAILED.getStatus(), assistant.getStatus()); + // 调用 worker 回填,模拟 Redis 实际写入后响应丢失 + sessionMessageService.updateSessionMessage(new Ai1SessionMessageDO().setId(assistant.getId()) + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()).setContent("稍后生成成功")); + // 断言后续完成结果仍可落库 + Ai1SessionMessageDO result = sessionMessageMapper.selectById(assistant.getId()); + assertEquals(Ai1SessionMessageStatusEnum.SUCCESS.getStatus(), result.getStatus()); + assertEquals("稍后生成成功", result.getContent()); + } + + @Test + public void testSendSessionMessageStream_openFailedKeepsGenerating() { + // mock sessionService 和 sessionStreamTool 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + when(sessionStreamTool.open(anyLong(), isNull())).thenThrow(new IllegalStateException("SSE 不可用")); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言已投递任务不因 SSE 异常而被标失败 + Ai1SessionMessageDO assistant = sessionMessageMapper.selectListBySessionId(10L).get(1); + assertEquals(Ai1SessionMessageStatusEnum.GENERATING.getStatus(), assistant.getStatus()); + verify(sessionStreamTool).submit(assistant.getId(), 1L, 10L, "你好"); + verify(sessionStreamTool).error("会话提交失败,请稍后重试"); + } + + @Test + public void testSendSessionMessageStream_compensationFailedKeepsOriginalError() { + // mock sessionService、sessionStreamTool 和 sessionMessageMapper 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenReturn(new Ai1SessionDO() + .setId(10L).setAgentId(1L).setTitle(Ai1SessionDO.TITLE_DEFAULT)); + doThrow(exception(SESSION_NOT_EXISTS)).when(sessionStreamTool) + .submit(anyLong(), eq(1L), eq(10L), eq("你好")); + doThrow(new IllegalStateException("数据库不可用")).when(sessionMessageMapper) + .update(any(Ai1SessionMessageDO.class), any(Wrapper.class)); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, + new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言补偿异常不替换原投递业务异常 + verify(sessionStreamTool).error("会话不存在"); + verify(sessionStreamTool, never()).open(anyLong(), any()); + assertEquals(Ai1SessionMessageStatusEnum.GENERATING.getStatus(), + sessionMessageMapper.selectListBySessionId(10L).get(1).getStatus()); + } + + @ParameterizedTest + @ValueSource(ints = {47, 48, 49, 50, 51, 100}) + public void testCreateRoundMessages_titleLength(int length) { + // 准备参数 + Ai1SessionDO session = new Ai1SessionDO().setId(10L).setTitle(Ai1SessionDO.TITLE_DEFAULT); + String title = "问".repeat(length); + + // 调用 + sessionMessageService.createRoundMessages(session, " " + title + " "); + // 断言 + verify(sessionService).updateSessionTitle(10L, length > 50 ? "问".repeat(47) + "..." : title); + } + + @Test + public void testCreateRoundMessages_existingMessagesKeepTitle() { + // mock 数据 + insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole()); + // 准备参数 + Ai1SessionDO session = new Ai1SessionDO().setId(10L).setTitle(Ai1SessionDO.TITLE_DEFAULT); + + // 调用 + sessionMessageService.createRoundMessages(session, "已有消息不覆盖标题"); + // 断言 + verify(sessionService, never()).updateSessionTitle(anyLong(), anyString()); + } + + @Test + public void testSendSessionMessageStream_notOwner() { + // mock sessionService 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenThrow(exception(SESSION_NOT_EXISTS)); + + // 调用 + sessionMessageService.sendSessionMessageStream(USER_ID, new Ai1SessionMessageSendReqVO().setSessionId(10L).setContent("你好")); + // 断言 + verify(sessionStreamTool).error("会话不存在"); + verify(sessionStreamTool, never()).submit(anyLong(), anyLong(), anyLong(), anyString()); + assertTrue(sessionMessageMapper.selectListBySessionId(10L).isEmpty()); + } + + @Test + public void testResumeSessionMessageStream_otherUser() { + // mock 数据 + Ai1SessionMessageDO message = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + // mock sessionService 的方法 + when(sessionService.validateSessionMy(USER_ID, 10L)).thenThrow(exception(SESSION_NOT_EXISTS)); + + // 调用 + sessionMessageService.resumeSessionMessageStream(USER_ID, message.getId(), null); + // 断言 + verify(sessionStreamTool).error("会话不存在"); + verify(sessionStreamTool, never()).open(anyLong(), any()); + } + + @Test + public void testResumeSessionMessageStream_userMessage() { + // mock 数据 + Ai1SessionMessageDO message = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole()); + + // 调用 + sessionMessageService.resumeSessionMessageStream(USER_ID, message.getId(), null); + // 断言 + verify(sessionStreamTool).error("会话消息不存在"); + verify(sessionStreamTool, never()).open(anyLong(), any()); + } + + @Test + public void testResumeSessionMessageStream_success() { + // mock 数据 + Ai1SessionMessageDO message = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + + // 调用 + sessionMessageService.resumeSessionMessageStream(USER_ID, message.getId(), "1-0"); + // 断言 + verify(sessionService).validateSessionMy(USER_ID, 10L); + verify(sessionStreamTool).open(message.getId(), "1-0"); + } + + @ParameterizedTest + @ValueSource(strings = {"database", "ownership", "open"}) + public void testResumeSessionMessageStream_unexpectedException(String stage) { + // mock 数据 + Ai1SessionMessageDO message = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + sessionMessageMapper.updateById(new Ai1SessionMessageDO().setId(message.getId()) + .setStatus(Ai1SessionMessageStatusEnum.GENERATING.getStatus())); + // mock 数据库、归属校验或 SSE 打开异常 + IllegalStateException failure = new IllegalStateException("内部异常详情"); + switch (stage) { + case "database" -> doThrow(failure).when(sessionMessageMapper).selectById(message.getId()); + case "ownership" -> when(sessionService.validateSessionMy(USER_ID, 10L)).thenThrow(failure); + case "open" -> when(sessionStreamTool.open(message.getId(), "1-0")).thenThrow(failure); + } + SseEmitter emitter = new SseEmitter(); + when(sessionStreamTool.error("会话续传失败,请稍后重试")).thenReturn(emitter); + + // 调用 + SseEmitter result = sessionMessageService.resumeSessionMessageStream(USER_ID, message.getId(), "1-0"); + // 断言通过 SSE 返回安全提示,不改变服务端生成状态或重新投递任务 + assertSame(emitter, result); + verify(sessionStreamTool).error("会话续传失败,请稍后重试"); + assertEquals(Ai1SessionMessageStatusEnum.GENERATING.getStatus(), + sessionMessageMapper.selectListBySessionId(10L).get(0).getStatus()); + verify(sessionStreamTool, never()).submit(anyLong(), anyLong(), anyLong(), anyString()); + } + + @Test + public void testGetSessionMessageListBySessionIdAndIdLessThan() { + // mock 数据 + Ai1SessionMessageDO message1 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole()); + Ai1SessionMessageDO message2 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + Ai1SessionMessageDO message3 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.USER.getRole()); + Ai1SessionMessageDO message4 = insertSessionMessage(10L, Ai1SessionMessageRoleEnum.ASSISTANT.getRole()); + + // 调用 + List list = sessionMessageService.getSessionMessageListBySessionIdAndIdLessThan(10L, message4.getId(), 2); + // 断言 + assertEquals(List.of(message3.getId(), message2.getId()), list.stream().map(Ai1SessionMessageDO::getId).toList()); + assertNotEquals(message1.getId(), list.get(1).getId()); + } + + // ========== 测试数据 ========== + + private Ai1SessionMessageDO insertSessionMessage(Long sessionId, String role) { + Ai1SessionMessageDO message = new Ai1SessionMessageDO().setSessionId(sessionId).setRole(role).setContent("内容") + .setStatus(Ai1SessionMessageStatusEnum.SUCCESS.getStatus()); + sessionMessageMapper.insert(message); + return message; + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImplTest.java new file mode 100644 index 0000000..d8201bc --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/session/Ai1SessionServiceImplTest.java @@ -0,0 +1,107 @@ +package cn.iocoder.yudao.module.ai1.service.session; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionCreateMyReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.session.vo.session.Ai1SessionUpdateMyReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.session.Ai1SessionDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.session.Ai1SessionMapper; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.SESSION_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +/** + * {@link Ai1SessionServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1SessionServiceImpl.class) +public class Ai1SessionServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1SessionServiceImpl sessionService; + + @Resource + private Ai1SessionMapper sessionMapper; + + @MockitoBean + private Ai1AgentService agentService; + @MockitoBean + private Ai1SessionMessageService sessionMessageService; + + @Test + public void testCreateSessionMy_success() { + // 准备参数 + Ai1SessionCreateMyReqVO reqVO = new Ai1SessionCreateMyReqVO().setAgentId(1L); + + // 调用 + Long id = sessionService.createSessionMy(100L, reqVO); + // 断言 + Ai1SessionDO session = sessionMapper.selectById(id); + assertEquals(100L, session.getUserId()); + assertEquals(Ai1SessionDO.TITLE_DEFAULT, session.getTitle()); + verify(agentService).validateAgentEnabled(1L); + } + + @Test + public void testUpdateSessionMy_otherUser() { + // mock 数据 + Long id = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + + // 调用,并断言异常 + assertServiceException(() -> sessionService.updateSessionMy(200L, + new Ai1SessionUpdateMyReqVO().setId(id).setTitle("改标题")), SESSION_NOT_EXISTS); + // 断言 + assertEquals(Ai1SessionDO.TITLE_DEFAULT, sessionMapper.selectById(id).getTitle()); + } + + @Test + public void testDeleteSessionMy_otherUser() { + // mock 数据 + Long id = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + + // 调用,并断言异常 + assertServiceException(() -> sessionService.deleteSessionMy(200L, id), SESSION_NOT_EXISTS); + // 断言 + assertNotNull(sessionMapper.selectById(id)); + verifyNoInteractions(sessionMessageService); + } + + @Test + public void testDeleteSessionMy_success() { + // mock 数据 + Long id = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + + // 调用 + sessionService.deleteSessionMy(100L, id); + // 断言 + assertNull(sessionMapper.selectById(id)); + verify(sessionMessageService).deleteSessionMessageListBySessionIds(Collections.singletonList(id)); + } + + @Test + public void testGetSessionListByAgentIdAndUserId() { + // mock 数据 + Long id1 = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + Long id2 = sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + sessionService.createSessionMy(200L, new Ai1SessionCreateMyReqVO().setAgentId(1L)); + sessionService.createSessionMy(100L, new Ai1SessionCreateMyReqVO().setAgentId(2L)); + + // 调用 + List list = sessionService.getSessionListByAgentIdAndUserId(1L, 100L); + // 断言 + assertEquals(Arrays.asList(id2, id1), list.stream().map(Ai1SessionDO::getId).toList()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImplTest.java new file mode 100644 index 0000000..73d4cd9 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillFileServiceImplTest.java @@ -0,0 +1,210 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.hutool.core.collection.CollUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileContentReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileCreateReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileMoveReqVO; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.file.Ai1SkillFileRenameReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillFileDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillFileMapper; +import cn.iocoder.yudao.module.ai1.enums.skill.Ai1SkillFileTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springaicommunity.agent.utils.MarkdownParser; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SkillFileServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1SkillFileServiceImpl.class) +public class Ai1SkillFileServiceImplTest extends BaseDbUnitTest { + + private static final Long SKILL_ID = 1L; + + @Resource + private Ai1SkillFileServiceImpl skillFileService; + + @Resource + private Ai1SkillFileMapper skillFileMapper; + + @MockitoBean + private Ai1SkillService skillService; + + @Test + public void testCreateDefaultSkillFileList() { + // 准备参数 + Ai1SkillDO skill = new Ai1SkillDO().setId(SKILL_ID).setName("pdf-reader").setDescription("读取 PDF"); + + // 调用 + skillFileService.createDefaultSkillFileList(skill); + // 断言 + List files = skillFileMapper.selectListBySkillId(SKILL_ID); + assertEquals(3, files.size()); + assertTrue(files.stream().allMatch(file -> Boolean.TRUE.equals(file.getLocked()) + && Ai1SkillFileDO.PARENT_ID_ROOT.equals(file.getParentId()))); + Ai1SkillFileDO skillFile = CollUtil.findOne(files, file -> Ai1SkillFileDO.NAME_SKILL.equals(file.getName())); + assertNotNull(skillFile); + assertTrue(skillFile.getContent().startsWith("---\nname: pdf-reader\ndescription: 读取 PDF\n---")); + } + + @Test + public void testCreateDefaultSkillFileList_descriptionWithNewlines() { + // 准备参数 + Ai1SkillDO skill = new Ai1SkillDO().setId(SKILL_ID).setName("pdf-reader") + .setDescription("读取 PDF\r\nversion: 2"); + + // 调用 + skillFileService.createDefaultSkillFileList(skill); + // 断言 + Ai1SkillFileDO skillFile = skillFileMapper.selectBySkillIdAndParentIdAndName( + SKILL_ID, Ai1SkillFileDO.PARENT_ID_ROOT, Ai1SkillFileDO.NAME_SKILL); + MarkdownParser parser = new MarkdownParser(skillFile.getContent()); + assertEquals(Map.of("name", "pdf-reader", "description", "读取 PDF version: 2"), parser.getFrontMatter()); + assertTrue(parser.getContent().startsWith("# pdf-reader\n\n")); + } + + @Test + public void testCreateSkillFile_success() { + // 准备参数 + Ai1SkillFileCreateReqVO reqVO = buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, " guide.MD ", + Ai1SkillFileTypeEnum.FILE.getType()); + + // 调用 + Long id = skillFileService.createSkillFile(reqVO); + // 断言 + Ai1SkillFileDO file = skillFileMapper.selectById(id); + assertEquals("guide.MD", file.getName()); + assertEquals("md", file.getFileType()); + assertTrue(file.getContent().startsWith("# 新文档")); + assertFalse(file.getLocked()); + verify(skillService).touchSkill(SKILL_ID); + } + + @Test + public void testCreateSkillFile_nameInvalid() { + // 调用,并断言异常 + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, "..", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_NAME_INVALID); + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, "a/b", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_NAME_INVALID); + } + + @Test + public void testCreateSkillFile_parentIsFile() { + // mock 数据 + Ai1SkillFileDO parent = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(parent.getId(), "b.txt", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_PARENT_INVALID); + } + + @Test + public void testCreateSkillFile_duplicate() { + // mock 数据 + insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, "a.txt", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_NAME_DUPLICATE, "a.txt"); + } + + @Test + public void testCreateSkillFile_parentNotExists() { + // 调用,并断言异常 + assertServiceException(() -> skillFileService.createSkillFile(buildCreateReqVO(99999L, "b.txt", + Ai1SkillFileTypeEnum.FILE.getType())), SKILL_FILE_PARENT_NOT_EXISTS); + } + + @Test + public void testRenameSkillFile_locked() { + // mock 数据 + Ai1SkillFileDO file = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "SKILL.md", Ai1SkillFileTypeEnum.FILE.getType(), true); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.renameSkillFile(new Ai1SkillFileRenameReqVO().setId(file.getId()) + .setName("README.md")), SKILL_FILE_LOCKED, "重命名"); + } + + @Test + public void testMoveSkillFile_intoDescendant() { + // mock 数据 + Ai1SkillFileDO a = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + Ai1SkillFileDO b = insertSkillFile(a.getId(), "b", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.moveSkillFile(new Ai1SkillFileMoveReqVO().setId(a.getId()) + .setParentId(b.getId())), SKILL_FILE_MOVE_TO_DESCENDANT); + assertServiceException(() -> skillFileService.moveSkillFile(new Ai1SkillFileMoveReqVO().setId(a.getId()) + .setParentId(a.getId())), SKILL_FILE_MOVE_TO_SELF); + } + + @Test + public void testMoveSkillFile_success() { + // mock 数据 + Ai1SkillFileDO a = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + Ai1SkillFileDO c = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "c.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + + // 调用 + skillFileService.moveSkillFile(new Ai1SkillFileMoveReqVO().setId(c.getId()).setParentId(a.getId())); + // 断言 + assertEquals(a.getId(), skillFileMapper.selectById(c.getId()).getParentId()); + } + + @Test + public void testUpdateSkillFileContent_directory() { + // mock 数据 + Ai1SkillFileDO a = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + + // 调用,并断言异常 + assertServiceException(() -> skillFileService.updateSkillFileContent(new Ai1SkillFileContentReqVO().setId(a.getId()) + .setContent("x")), SKILL_FILE_NOT_FILE); + } + + @Test + public void testDeleteSkillFile_cascadeAndRecreate() { + // mock 数据 + Ai1SkillFileDO a = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "a", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + Ai1SkillFileDO b = insertSkillFile(a.getId(), "b", Ai1SkillFileTypeEnum.DIRECTORY.getType(), false); + insertSkillFile(b.getId(), "c.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + Ai1SkillFileDO d = insertSkillFile(Ai1SkillFileDO.PARENT_ID_ROOT, "d.txt", Ai1SkillFileTypeEnum.FILE.getType(), false); + + // 调用 + skillFileService.deleteSkillFile(a.getId()); + Long newId = skillFileService.createSkillFile(buildCreateReqVO(Ai1SkillFileDO.PARENT_ID_ROOT, "a", + Ai1SkillFileTypeEnum.DIRECTORY.getType())); + // 断言 + List files = skillFileMapper.selectListBySkillId(SKILL_ID); + assertEquals(2, files.size()); + assertTrue(convertList(files, Ai1SkillFileDO::getId).containsAll(Arrays.asList(d.getId(), newId))); + } + + // ========== 测试数据 ========== + + private static Ai1SkillFileCreateReqVO buildCreateReqVO(Long parentId, String name, Integer type) { + return new Ai1SkillFileCreateReqVO().setSkillId(SKILL_ID).setParentId(parentId).setName(name).setType(type); + } + + private Ai1SkillFileDO insertSkillFile(Long parentId, String name, Integer type, boolean locked) { + Ai1SkillFileDO file = Ai1SkillFileDO.builder().skillId(SKILL_ID).parentId(parentId).name(name).type(type) + .locked(locked).sort(1).content(Ai1SkillFileTypeEnum.isFile(type) ? "" : null).build(); + skillFileMapper.insert(file); + return file; + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImplTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImplTest.java new file mode 100644 index 0000000..d893ee6 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/service/skill/Ai1SkillServiceImplTest.java @@ -0,0 +1,128 @@ +package cn.iocoder.yudao.module.ai1.service.skill; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.ai1.controller.admin.skill.vo.skill.Ai1SkillSaveReqVO; +import cn.iocoder.yudao.module.ai1.dal.dataobject.skill.Ai1SkillDO; +import cn.iocoder.yudao.module.ai1.dal.mysql.skill.Ai1SkillMapper; +import cn.iocoder.yudao.module.ai1.service.agent.Ai1AgentService; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collections; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link Ai1SkillServiceImpl} 的单元测试 + * + * @author 芋道源码 + */ +@Import(Ai1SkillServiceImpl.class) +public class Ai1SkillServiceImplTest extends BaseDbUnitTest { + + @Resource + private Ai1SkillServiceImpl skillService; + + @Resource + private Ai1SkillMapper skillMapper; + + @MockitoBean + private Ai1SkillFileService skillFileService; + @MockitoBean + private Ai1AgentService agentService; + + @Test + public void testCreateSkill_success() { + // 准备参数 + Ai1SkillSaveReqVO reqVO = buildSkillSaveReqVO("pdf-reader"); + + // 调用 + Long id = skillService.createSkill(reqVO); + // 断言 + Ai1SkillDO skill = skillMapper.selectById(id); + assertEquals("pdf-reader", skill.getName()); + verify(skillFileService).createDefaultSkillFileList(any(Ai1SkillDO.class)); + } + + @Test + public void testCreateSkill_duplicate() { + // mock 数据 + skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + + // 调用,并断言异常 + assertServiceException(() -> skillService.createSkill(buildSkillSaveReqVO("pdf-reader")), + SKILL_NAME_DUPLICATE, "pdf-reader"); + } + + @Test + public void testDeleteSkill_thenRecreateSameName() { + // mock 数据 + Long id = skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + + // 调用 + skillService.deleteSkill(id); + Long newId = skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + // 断言 + assertNull(skillMapper.selectById(id)); + assertNotEquals(id, newId); + assertEquals("pdf-reader", skillMapper.selectById(newId).getName()); + verify(skillFileService).deleteSkillFileListBySkillIds(eq(Collections.singletonList(id))); + } + + @Test + public void testUpdateSkill_duplicate() { + // mock 数据 + skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + Long id = skillService.createSkill(buildSkillSaveReqVO("excel-reader")); + + // 准备参数 + Ai1SkillSaveReqVO reqVO = buildSkillSaveReqVO("pdf-reader").setId(id); + + // 调用,并断言异常 + assertServiceException(() -> skillService.updateSkill(reqVO), SKILL_NAME_DUPLICATE, "pdf-reader"); + } + + @Test + public void testDeleteSkill_usedByAgent() { + // mock 数据 + Long id = skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + // mock agentService 的方法 + when(agentService.getAgentCountBySkillIds(Collections.singletonList(id))).thenReturn(1L); + + // 调用,并断言异常 + assertServiceException(() -> skillService.deleteSkill(id), SKILL_USED_BY_AGENT); + // 断言 + assertNotNull(skillMapper.selectById(id)); + verify(skillFileService, never()).deleteSkillFileListBySkillIds(any()); + } + + @Test + public void testUpdateSkill_managementInfoOnly() { + // mock 数据 + Long id = skillService.createSkill(buildSkillSaveReqVO("pdf-reader")); + clearInvocations(skillFileService); + + // 调用 + skillService.updateSkill(buildSkillSaveReqVO("财务 PDF 工具").setId(id).setDescription("给财务人员使用")); + // 断言 + Ai1SkillDO skill = skillMapper.selectById(id); + assertEquals("财务 PDF 工具", skill.getName()); + assertEquals("给财务人员使用", skill.getDescription()); + verifyNoInteractions(skillFileService); + } + + // ========== 测试数据 ========== + + private static Ai1SkillSaveReqVO buildSkillSaveReqVO(String name) { + return new Ai1SkillSaveReqVO().setName(name).setDescription("读取 PDF").setVersion("1.0") + .setStatus(CommonStatusEnum.ENABLE.getStatus()); + } + +} diff --git a/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java new file mode 100644 index 0000000..514ba0f --- /dev/null +++ b/yudao-module-ai1-springai/src/test/java/cn/iocoder/yudao/module/ai1/util/Ai1UtilsTest.java @@ -0,0 +1,119 @@ +package cn.iocoder.yudao.module.ai1.util; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springframework.core.env.Environment; +import org.springframework.mock.env.MockEnvironment; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.module.ai1.enums.Ai1ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mockStatic; + +public class Ai1UtilsTest { + + @Test + public void testNormalizeHeaders_orderAndValues() { + // 准备参数 + Map input = new LinkedHashMap<>(); + input.put(" X-Z ", "${TEST}"); + input.put("X-A", "{session}"); + + // 调用 + Map normalized = Ai1Utils.normalizeHeaders(input); + + // 断言 + assertInstanceOf(LinkedHashMap.class, normalized); + assertEquals(List.of("X-Z", "X-A"), List.copyOf(normalized.keySet())); + assertEquals("${TEST}", normalized.get("X-Z")); + assertEquals("{session}", normalized.get("X-A")); + assertTrue(input.containsKey(" X-Z ")); + assertNotSame(input, normalized); + } + + @Test + public void testNormalizeHeaders_invalid() { + // 准备参数 + Map duplicate = new LinkedHashMap<>(); + duplicate.put("X-Test", "static"); + duplicate.put(" x-test ", "{session}"); + Map nullValue = new LinkedHashMap<>(); + nullValue.put("X-Test", null); + Map nullKey = new LinkedHashMap<>(); + nullKey.put(null, "value"); + + // 调用,并断言异常 + for (Map input : List.of(duplicate, nullValue, nullKey, Map.of(" ", "value"))) { + assertFalse(Ai1Utils.isHeadersValid(input)); + ServiceException ex = assertThrows(ServiceException.class, () -> Ai1Utils.normalizeHeaders(input)); + assertEquals(PROVIDER_HEADERS_INVALID.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains("static")); + } + } + + @Test + public void testNormalizeHeaders_nullAndEmpty() { + // 调用,并断言 + assertNull(Ai1Utils.normalizeHeaders(null)); + assertEquals(Map.of(), Ai1Utils.normalizeHeaders(Map.of())); + assertEquals(Map.of("X-Test", ""), Ai1Utils.normalizeHeaders(Map.of("X-Test", ""))); + assertNull(Ai1Utils.resolveSpringPlaceholders((Map) null)); + } + + @Test + public void testIsHeadersValid_jsonLength() { + // 调用,并断言 + assertTrue(Ai1Utils.isHeadersValid(Map.of("X", "a".repeat(1992)))); + assertFalse(Ai1Utils.isHeadersValid(Map.of("X", "a".repeat(1993)))); + assertFalse(Ai1Utils.isHeadersValid(Map.of("X", "\"".repeat(1000)))); + } + + @Test + public void testResolveSpringPlaceholders_unresolved() { + // 准备参数 + try (MockedStatic spring = mockStatic(SpringUtil.class)) { + spring.when(() -> SpringUtil.getBean(Environment.class)).thenReturn(new MockEnvironment()); + // 调用,并断言异常 + for (String value : List.of("literal-private-api-key-${MISSING}", "https://private-token@host/${MISSING}")) { + ServiceException ex = assertThrows(ServiceException.class, () -> Ai1Utils.resolveSpringPlaceholders(value)); + assertEquals(CONFIG_PLACEHOLDER_NOT_RESOLVED.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains("private")); + assertFalse(ex.getMessage().contains("Header")); + assertFalse(ex.getMessage().contains(value)); + } + ServiceException env = assertThrows(ServiceException.class, () -> + Ai1Utils.resolveSpringPlaceholders(Map.of("MCP_TOKEN", "private-env-${MISSING}"))); + assertFalse(env.getMessage().contains("Header")); + assertFalse(env.getMessage().contains("private-env")); + } + } + + @Test + public void testResolveSpringPlaceholders_map() { + // 准备参数 + try (MockedStatic spring = mockStatic(SpringUtil.class)) { + spring.when(() -> SpringUtil.getBean(Environment.class)) + .thenReturn(new MockEnvironment().withProperty("HEADER_TEST", "a\"b\\c")); + Map input = new LinkedHashMap<>(); + input.put("X-Z", "${HEADER_TEST}-{session}"); + input.put("X-A", "unchanged"); + + // 调用 + Map resolved = Ai1Utils.resolveSpringPlaceholders(input); + + // 断言 + assertEquals("a\"b\\c-{session}", resolved.get("X-Z")); + assertEquals(List.of("X-Z", "X-A"), List.copyOf(resolved.keySet())); + assertEquals("${HEADER_TEST}-{session}", input.get("X-Z")); + ServiceException ex = assertThrows(ServiceException.class, () -> + Ai1Utils.resolveSpringPlaceholders(Map.of("X-Test", "literal-secret-${MISSING_HEADER_TEST}"))); + assertEquals(CONFIG_PLACEHOLDER_NOT_RESOLVED.getCode(), ex.getCode()); + assertFalse(ex.getMessage().contains("literal-secret")); + } + } +} diff --git a/yudao-module-ai1-springai/src/test/resources/application-unit-test.yaml b/yudao-module-ai1-springai/src/test/resources/application-unit-test.yaml new file mode 100644 index 0000000..1442d6d --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/application-unit-test.yaml @@ -0,0 +1,49 @@ +spring: + main: + lazy-initialization: true # 开启懒加载,加快速度 + banner-mode: off # 单元测试,禁用 Banner + allow-bean-definition-overriding: true # 允许 Bean 覆盖,解决 Redis 配置冲突 + +--- #################### 数据库相关配置 #################### + +spring: + # 数据源配置项 + datasource: + name: ruoyi-vue-pro + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value,day; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 + driver-class-name: org.h2.Driver + username: sa + password: + druid: + async-init: true # 单元测试,异步初始化 Druid 连接池,提升启动速度 + initial-size: 1 # 单元测试,配置为 1,提升启动速度 + sql: + init: + schema-locations: classpath:/sql/create_tables.sql + + # Redis 配置。Redisson 默认的配置足够使用,一般不需要进行调优 + data: + redis: + host: 127.0.0.1 # 地址 + port: 16379 # 端口(单元测试,使用 16379 端口) + database: 0 # 数据库索引 + +mybatis: + lazy-initialization: true # 单元测试,设置 MyBatis Mapper 延迟加载,加速每个单元测试 + +--- #################### 定时任务相关配置 #################### + +--- #################### 配置中心相关配置 #################### + +--- #################### 服务保障相关配置 #################### + +# Lock4j 配置项(单元测试,禁用 Lock4j) + +--- #################### 监控相关配置 #################### + +--- #################### 芋道相关配置 #################### + +# 芋道配置项,设置当前项目所有自定义的配置 +yudao: + info: + base-package: cn.iocoder.yudao.module diff --git a/yudao-module-ai1-springai/src/test/resources/logback.xml b/yudao-module-ai1-springai/src/test/resources/logback.xml new file mode 100644 index 0000000..daf756b --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/logback.xml @@ -0,0 +1,4 @@ + + + + diff --git a/yudao-module-ai1-springai/src/test/resources/provider-api-key-cases.json b/yudao-module-ai1-springai/src/test/resources/provider-api-key-cases.json new file mode 100644 index 0000000..54e4c6e --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/provider-api-key-cases.json @@ -0,0 +1,74 @@ +[ + { + "apiKey": "${OPENAI_KEY}", + "placeholder": true + }, + { + "apiKey": "${openai_key}", + "placeholder": true + }, + { + "apiKey": "${openai.api-key}", + "placeholder": true + }, + { + "apiKey": "${_API_KEY}", + "placeholder": true + }, + { + "apiKey": "sk-secret-value", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY:sk-secret-value}", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY:}", + "placeholder": false + }, + { + "apiKey": "prefix-${OPENAI_KEY}", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY}-suffix", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY}${OTHER_KEY}", + "placeholder": false + }, + { + "apiKey": "${}", + "placeholder": false + }, + { + "apiKey": "${ OPENAI_KEY}", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY }", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY}\n", + "placeholder": false + }, + { + "apiKey": " ${OPENAI_KEY}", + "placeholder": false + }, + { + "apiKey": "${OPENAI_KEY", + "placeholder": false + }, + { + "apiKey": "", + "placeholder": false + }, + { + "apiKey": null, + "placeholder": false + } +] diff --git a/yudao-module-ai1-springai/src/test/resources/sql/clean.sql b/yudao-module-ai1-springai/src/test/resources/sql/clean.sql new file mode 100644 index 0000000..a48e1e2 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/sql/clean.sql @@ -0,0 +1,10 @@ +DELETE FROM "ai1_provider"; +DELETE FROM "ai1_model"; +DELETE FROM "ai1_mcp"; +DELETE FROM "ai1_skill"; +DELETE FROM "ai1_skill_file"; +DELETE FROM "ai1_knowledge_base"; +DELETE FROM "ai1_knowledge_document"; +DELETE FROM "ai1_agent"; +DELETE FROM "ai1_session"; +DELETE FROM "ai1_session_message"; diff --git a/yudao-module-ai1-springai/src/test/resources/sql/create_tables.sql b/yudao-module-ai1-springai/src/test/resources/sql/create_tables.sql new file mode 100644 index 0000000..c4ef951 --- /dev/null +++ b/yudao-module-ai1-springai/src/test/resources/sql/create_tables.sql @@ -0,0 +1,199 @@ +-- ---------------------------- +-- AI1 Provider +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_provider" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(50) NOT NULL, + "base_url" varchar(200) NOT NULL, + "api_key" varchar(200) DEFAULT NULL, + "headers" varchar(2000) DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT 0, + "remark" varchar(255) DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 Provider 模型 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_model" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "provider_id" bigint NOT NULL, + "name" varchar(50) NOT NULL, + "model" varchar(100) NOT NULL, + "type" tinyint NOT NULL DEFAULT 0, + "status" tinyint NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 MCP 服务 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_mcp" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "transport" varchar(32) NOT NULL, + "url" varchar(200) DEFAULT NULL, + "headers" varchar(500) DEFAULT NULL, + "config" text DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT 0, + "remark" varchar(500) DEFAULT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 SKILL +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_skill" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "description" varchar(500) DEFAULT NULL, + "version" varchar(20) NOT NULL DEFAULT '1.0', + "status" tinyint NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 SKILL 内容文件 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_skill_file" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "skill_id" bigint NOT NULL, + "parent_id" bigint NOT NULL DEFAULT 0, + "name" varchar(200) NOT NULL, + "type" tinyint NOT NULL DEFAULT 1, + "file_type" varchar(20) DEFAULT NULL, + "content" text DEFAULT NULL, + "locked" tinyint NOT NULL DEFAULT 0, + "sort" int NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 知识库 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_knowledge_base" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "description" varchar(500) DEFAULT NULL, + "embedding_provider_id" bigint NOT NULL DEFAULT 0, + "embedding_model_id" bigint NOT NULL DEFAULT 0, + "chunk_size" int NOT NULL DEFAULT 500, + "chunk_overlap" int NOT NULL DEFAULT 50, + "top_k" int NOT NULL DEFAULT 5, + "status" tinyint NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 知识文档 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_knowledge_document" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "knowledge_base_id" bigint NOT NULL, + "name" varchar(255) NOT NULL, + "content" text DEFAULT NULL, + "chunk_count" int NOT NULL DEFAULT 0, + "status" tinyint NOT NULL DEFAULT 0, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 Agent +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_agent" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(100) NOT NULL, + "introduction" varchar(500) DEFAULT NULL, + "provider_id" bigint NOT NULL DEFAULT 0, + "model_id" bigint NOT NULL DEFAULT 0, + "system_prompt" text DEFAULT NULL, + "knowledge_base_ids" varchar(500) DEFAULT NULL, + "mcp_ids" varchar(500) DEFAULT NULL, + "skill_ids" varchar(500) DEFAULT NULL, + "status" tinyint NOT NULL DEFAULT 1, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 会话 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_session" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "agent_id" bigint NOT NULL, + "user_id" bigint NOT NULL, + "title" varchar(100) NOT NULL DEFAULT '新会话', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); + +-- ---------------------------- +-- AI1 会话消息 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS "ai1_session_message" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "session_id" bigint NOT NULL, + "role" varchar(10) NOT NULL, + "reasoning" mediumtext DEFAULT NULL, + "content" mediumtext NOT NULL, + "status" tinyint NOT NULL DEFAULT 1, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + "tenant_id" bigint NOT NULL DEFAULT 0, + PRIMARY KEY ("id") +); diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/enums/ErrorCodeConstants.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/enums/ErrorCodeConstants.java index b815780..bb9868b 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/enums/ErrorCodeConstants.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/enums/ErrorCodeConstants.java @@ -66,6 +66,9 @@ public interface ErrorCodeConstants { ErrorCode TASK_WITHDRAW_FAIL_TASK_NOT_EXISTS = new ErrorCode(1_009_005_018, "撤回失败,未查询到用户已办任务!"); ErrorCode TASK_WITHDRAW_FAIL_NOT_ALLOW = new ErrorCode(1_009_005_019, "撤回失败,此流程不允许撤回操作!"); ErrorCode TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW = new ErrorCode(1_009_005_020, "撤回失败,下一节点不满足撤回条件!"); + ErrorCode TASK_SIGN_DELETE_FAIL_NOT_SELF = new ErrorCode(1_009_005_021, "任务减签失败,原因:你不是该任务的加签人"); + ErrorCode TASK_OPERATE_FAIL_APPROVING = new ErrorCode(1_009_005_022, "操作失败,原因:该任务已审批通过,等待加签任务审批完成"); + ErrorCode TASK_SIGN_CREATE_TYPE_NOT_EXISTS = new ErrorCode(1_009_005_023, "任务加签失败,加签类型({})不存在"); // ========== 动态表单模块 1-009-010-000 ========== ErrorCode FORM_NOT_EXISTS = new ErrorCode(1_009_010_000, "动态表单不存在"); diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmParallelMultiInstanceBehavior.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmParallelMultiInstanceBehavior.java index b35f18e..fef02f4 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmParallelMultiInstanceBehavior.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmParallelMultiInstanceBehavior.java @@ -17,6 +17,7 @@ import org.flowable.engine.impl.bpmn.behavior.ParallelMultiInstanceBehavior; import org.flowable.common.engine.api.delegate.Expression; import java.util.List; +import java.util.Objects; import java.util.Set; /** @@ -37,6 +38,10 @@ public class BpmParallelMultiInstanceBehavior extends ParallelMultiInstanceBehav super(activity, innerActivityBehavior); // 关联 Pull Request:https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1483 // 在解析/构造阶段基于 activityId 初始化与 activity 绑定且不变的字段,避免在运行期修改 Behavior 实例状态 + // 注意:仅 UserTask 使用自定义变量;CallActivity 等节点,需要保留 Flowable 解析的 collection 配置(例如说,子流程多实例的表单字段) + if (!(activity instanceof UserTask)) { + return; + } super.collectionExpression = null; // collectionExpression 和 collectionVariable 是互斥的 super.collectionVariable = FlowableUtils.formatExecutionCollectionVariable(activity.getId()); // 从 execution.getVariable() 读取当前所有任务处理的人的 key @@ -77,10 +82,10 @@ public class BpmParallelMultiInstanceBehavior extends ParallelMultiInstanceBehav if (execution.getCurrentFlowElement() instanceof CallActivity) { FlowElement flowElement = execution.getCurrentFlowElement(); Integer sourceType = BpmnModelUtils.parseMultiInstanceSourceType(flowElement); - if (sourceType.equals(BpmChildProcessMultiInstanceSourceTypeEnum.NUMBER_FORM.getType())) { + if (Objects.equals(sourceType, BpmChildProcessMultiInstanceSourceTypeEnum.NUMBER_FORM.getType())) { return execution.getVariable(super.collectionExpression.getExpressionText(), Integer.class); } - if (sourceType.equals(BpmChildProcessMultiInstanceSourceTypeEnum.MULTIPLE_FORM.getType())) { + if (Objects.equals(sourceType, BpmChildProcessMultiInstanceSourceTypeEnum.MULTIPLE_FORM.getType())) { return execution.getVariable(super.collectionExpression.getExpressionText(), List.class).size(); } } @@ -92,17 +97,29 @@ public class BpmParallelMultiInstanceBehavior extends ParallelMultiInstanceBehav @Override public void setCollectionExpression(Expression collectionExpression) { - // 保持自定义变量名,忽略解析器写入的 collection 表达式 + // UserTask 保持自定义变量名,忽略解析器写入的 collection 表达式 + if (activity instanceof UserTask) { + return; + } + super.setCollectionExpression(collectionExpression); } @Override public void setCollectionVariable(String collectionVariable) { - // 保持自定义变量名,忽略解析器写入的 collection 变量名 + // UserTask 保持自定义变量名,忽略解析器写入的 collection 变量名 + if (activity instanceof UserTask) { + return; + } + super.setCollectionVariable(collectionVariable); } @Override public void setCollectionElementVariable(String collectionElementVariable) { - // 保持自定义变量名,忽略解析器写入的单元素变量名 + // UserTask 保持自定义变量名,忽略解析器写入的单元素变量名 + if (activity instanceof UserTask) { + return; + } + super.setCollectionElementVariable(collectionElementVariable); } } diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmSequentialMultiInstanceBehavior.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmSequentialMultiInstanceBehavior.java index 65e6b29..fba536a 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmSequentialMultiInstanceBehavior.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/behavior/BpmSequentialMultiInstanceBehavior.java @@ -16,6 +16,7 @@ import org.flowable.engine.impl.persistence.entity.ExecutionEntity; import java.util.LinkedHashSet; import java.util.List; +import java.util.Objects; import java.util.Set; /** @@ -34,6 +35,10 @@ public class BpmSequentialMultiInstanceBehavior extends SequentialMultiInstanceB super(activity, innerActivityBehavior); // 关联 Pull Request:https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1483 // 在解析/构造阶段基于 activityId 初始化与 activity 绑定且不变的字段,避免在运行期修改 Behavior 实例状态 + // 注意:仅 UserTask 使用自定义变量;CallActivity 等节点,需要保留 Flowable 解析的 collection 配置(例如说,子流程多实例的表单字段) + if (!(activity instanceof UserTask)) { + return; + } super.collectionExpression = null; // collectionExpression 和 collectionVariable 是互斥的 super.collectionVariable = FlowableUtils.formatExecutionCollectionVariable(activity.getId()); // 从 execution.getVariable() 读取当前所有任务处理的人的 key @@ -70,10 +75,10 @@ public class BpmSequentialMultiInstanceBehavior extends SequentialMultiInstanceB if (execution.getCurrentFlowElement() instanceof CallActivity) { FlowElement flowElement = execution.getCurrentFlowElement(); Integer sourceType = BpmnModelUtils.parseMultiInstanceSourceType(flowElement); - if (sourceType.equals(BpmChildProcessMultiInstanceSourceTypeEnum.NUMBER_FORM.getType())) { + if (Objects.equals(sourceType, BpmChildProcessMultiInstanceSourceTypeEnum.NUMBER_FORM.getType())) { return execution.getVariable(super.collectionExpression.getExpressionText(), Integer.class); } - if (sourceType.equals(BpmChildProcessMultiInstanceSourceTypeEnum.MULTIPLE_FORM.getType())) { + if (Objects.equals(sourceType, BpmChildProcessMultiInstanceSourceTypeEnum.MULTIPLE_FORM.getType())) { return execution.getVariable(super.collectionExpression.getExpressionText(), List.class).size(); } } @@ -96,17 +101,29 @@ public class BpmSequentialMultiInstanceBehavior extends SequentialMultiInstanceB @Override public void setCollectionExpression(Expression collectionExpression) { - // 保持自定义变量名,忽略解析器写入的 collection 表达式 + // UserTask 保持自定义变量名,忽略解析器写入的 collection 表达式 + if (activity instanceof UserTask) { + return; + } + super.setCollectionExpression(collectionExpression); } @Override public void setCollectionVariable(String collectionVariable) { - // 保持自定义变量名,忽略解析器写入的 collection 变量名 + // UserTask 保持自定义变量名,忽略解析器写入的 collection 变量名 + if (activity instanceof UserTask) { + return; + } + super.setCollectionVariable(collectionVariable); } @Override public void setCollectionElementVariable(String collectionElementVariable) { - // 保持自定义变量名,忽略解析器写入的单元素变量名 + // UserTask 保持自定义变量名,忽略解析器写入的单元素变量名 + if (activity instanceof UserTask) { + return; + } + super.setCollectionElementVariable(collectionElementVariable); } } diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/listener/BpmTaskEventListener.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/listener/BpmTaskEventListener.java index 6e2e48c..f5111e2 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/listener/BpmTaskEventListener.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/listener/BpmTaskEventListener.java @@ -116,7 +116,7 @@ public class BpmTaskEventListener extends AbstractFlowableEngineEventListener { log.error("[timerFired][解析 entity({}) elementId 为空,跳过处理]", entity); return; } - FlowElement element = BpmnModelUtils.getFlowElementById(bpmnModel, entity.getElementId()); + FlowElement element = BpmnModelUtils.getFlowElementById(bpmnModel, elementId); if (!(element instanceof BoundaryEvent)) { return; } diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java index 358ee9f..024bb1b 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtils.java @@ -89,7 +89,7 @@ public class BpmHttpRequestUtils { BpmProcessInstanceService processInstanceService = SpringUtils.getBean(BpmProcessInstanceService.class); ProcessInstance processInstance = processInstanceService.getProcessInstance(event.getId()); if (processInstance != null) { - headers.add(HEADER_TENANT_ID, String.valueOf(TenantContextHolder.getTenantId())); + headers.add(HEADER_TENANT_ID, processInstance.getTenantId()); } } // 1.2 设置请求体 diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/SimpleModelUtils.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/SimpleModelUtils.java index 9cb44a7..3d88db8 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/SimpleModelUtils.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/SimpleModelUtils.java @@ -876,11 +876,11 @@ public class SimpleModelUtils { if (childProcessSetting.getTimeoutSetting() != null && Boolean.TRUE.equals(childProcessSetting.getTimeoutSetting().getEnable())) { BoundaryEvent boundaryEvent = null; if (childProcessSetting.getTimeoutSetting().getType().equals(BpmDelayTimerTypeEnum.FIXED_DATE_TIME.getType())) { - boundaryEvent = buildTimeoutBoundaryEvent(callActivity, BpmBoundaryEventTypeEnum.DELAY_TIMER_TIMEOUT.getType(), - childProcessSetting.getTimeoutSetting().getTimeExpression(), null, null); - } else if (childProcessSetting.getTimeoutSetting().getType().equals(BpmDelayTimerTypeEnum.FIXED_TIME_DURATION.getType())) { boundaryEvent = buildTimeoutBoundaryEvent(callActivity, BpmBoundaryEventTypeEnum.CHILD_PROCESS_TIMEOUT.getType(), null, null, childProcessSetting.getTimeoutSetting().getTimeExpression()); + } else if (childProcessSetting.getTimeoutSetting().getType().equals(BpmDelayTimerTypeEnum.FIXED_TIME_DURATION.getType())) { + boundaryEvent = buildTimeoutBoundaryEvent(callActivity, BpmBoundaryEventTypeEnum.CHILD_PROCESS_TIMEOUT.getType(), + childProcessSetting.getTimeoutSetting().getTimeExpression(), null, null); } flowElements.add(boundaryEvent); } @@ -931,7 +931,7 @@ public class SimpleModelUtils { if (ObjUtil.isNotNull(timeDuration)) { eventDefinition.setTimeDuration(timeDuration); } - if (ObjUtil.isNotNull(timeDuration)) { + if (ObjUtil.isNotNull(timeCycle)) { eventDefinition.setTimeCycle(timeCycle); } if (ObjUtil.isNotNull(timeDate)) { diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/definition/BpmModelServiceImpl.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/definition/BpmModelServiceImpl.java index 7151851..0b0e666 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/definition/BpmModelServiceImpl.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/definition/BpmModelServiceImpl.java @@ -210,7 +210,7 @@ public class BpmModelServiceImpl implements BpmModelService { // 保存排序 long sort = System.currentTimeMillis(); // 使用时间戳 - i 作为排序 - for (int i = ids.size() - 1; i > 0; i--) { + for (int i = ids.size() - 1; i >= 0; i--) { Model model = modelMap.get(ids.get(i)); // 更新模型 BpmModelMetaInfoVO metaInfo = BpmModelConvert.INSTANCE.parseMetaInfo(model).setSort(sort); diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessDefinitionServiceImpl.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessDefinitionServiceImpl.java index 836b518..a83620d 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessDefinitionServiceImpl.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessDefinitionServiceImpl.java @@ -57,7 +57,12 @@ public class BpmProcessDefinitionServiceImpl implements BpmProcessDefinitionServ @Override public ProcessDefinition getProcessDefinition(String id) { - return repositoryService.getProcessDefinition(id); + // 不使用 repositoryService.getProcessDefinition(id) 的原因:流程定义不存在时,它会抛出 FlowableObjectNotFoundException, + // 而调用方都是通过返回 null 判断流程定义不存在 + if (StrUtil.isEmpty(id)) { + return null; + } + return repositoryService.createProcessDefinitionQuery().processDefinitionId(id).singleResult(); } @Override diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java index b11a372..3582157 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImpl.java @@ -22,6 +22,7 @@ import cn.iocoder.yudao.module.bpm.convert.task.BpmProcessInstanceConvert; import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO; import cn.iocoder.yudao.module.bpm.dal.redis.BpmProcessIdRedisDAO; import cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmFieldPermissionEnum; import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelFormTypeEnum; import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelTypeEnum; import cn.iocoder.yudao.module.bpm.enums.definition.BpmSimpleModelNodeTypeEnum; @@ -152,19 +153,82 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService .list(); } - private Map getFormFieldsPermission(BpmnModel bpmnModel, - String activityId, String taskId) { - // 1. 获取流程活动编号。流程活动 Id 为空事,从流程任务中获取流程活动 Id - if (StrUtil.isEmpty(activityId) && StrUtil.isNotEmpty(taskId)) { - activityId = Optional.ofNullable(taskService.getHistoricTask(taskId)) - .map(HistoricTaskInstance::getTaskDefinitionKey).orElse(null); + /** + * 获得当前登录用户的表单字段权限 + * + * @param loginUserId 当前登录用户编号 + * @param bpmnModel 流程模型 + * @param reqVO 审批详情请求 + * @param processInstance 流程实例;为 null 时,表示流程未发起 + * @param todoTask 当前登录用户的待办任务 + * @return 表单字段权限 + */ + private Map getFormFieldsPermission(Long loginUserId, BpmnModel bpmnModel, + BpmApprovalDetailReqVO reqVO, + HistoricProcessInstance processInstance, + BpmTaskRespVO todoTask) { + // 情况一:有待办任务,以待办任务所在节点的表单权限为准,可编辑 + if (todoTask != null) { + return BpmnModelUtils.parseFormFieldsPermission(bpmnModel, todoTask.getTaskDefinitionKey()); + } + // 情况二:流程未发起,使用传递的流程活动(例如说,发起人节点)的表单权限,可编辑 + if (processInstance == null) { + return BpmnModelUtils.parseFormFieldsPermission(bpmnModel, reqVO.getActivityId()); } + + // 情况三:流程已发起,但没有待办任务,只能查看 + // 3.1 优先,使用自己办理过的任务(已办)所在节点 + String activityId = getMyHistoricTaskDefinitionKey(loginUserId, reqVO.getTaskId(), processInstance.getId()); + // 3.2 其次,使用传递的流程活动(例如说,抄送节点) if (StrUtil.isEmpty(activityId)) { + activityId = reqVO.getActivityId(); + } + // 3.3 最后,如果是流程发起人(例如说,从“我的流程”进入),使用发起人节点 + if (StrUtil.isEmpty(activityId) && ObjUtil.equal(String.valueOf(loginUserId), processInstance.getStartUserId())) { + activityId = START_USER_NODE_ID; + } + // 没有待办任务,无法提交表单,所以将“可编辑”降级为“只读”,避免误导 + return downgradeWritePermission(BpmnModelUtils.parseFormFieldsPermission(bpmnModel, activityId)); + } + + /** + * 获得用户在指定流程实例中,办理过的 taskId 任务所在的节点编号 + * + * @param userId 用户编号 + * @param taskId 任务编号 + * @param processInstanceId 流程实例编号 + * @return 节点编号;任务不存在、不属于该流程实例、或者不是该用户办理的,返回 null + */ + private String getMyHistoricTaskDefinitionKey(Long userId, String taskId, String processInstanceId) { + if (StrUtil.isEmpty(taskId)) { + return null; + } + HistoricTaskInstance task = taskService.getHistoricTask(taskId); + if (task == null || ObjUtil.notEqual(task.getProcessInstanceId(), processInstanceId)) { return null; } + // 校验是该用户办理(审批人或拥有人)的任务 + String userIdStr = String.valueOf(userId); + if (ObjUtil.notEqual(userIdStr, task.getAssignee()) && ObjUtil.notEqual(userIdStr, task.getOwner())) { + return null; + } + return task.getTaskDefinitionKey(); + } - // 2. 从 BpmnModel 中解析表单字段权限 - return BpmnModelUtils.parseFormFieldsPermission(bpmnModel, activityId); + /** + * 将表单字段权限中的“可编辑”降级为“只读” + * + * @param fieldsPermission 表单字段权限 + * @return 表单字段权限 + */ + private static Map downgradeWritePermission(Map fieldsPermission) { + if (CollUtil.isEmpty(fieldsPermission)) { + return fieldsPermission; + } + String write = String.valueOf(BpmFieldPermissionEnum.WRITE.getPermission()); + String read = String.valueOf(BpmFieldPermissionEnum.READ.getPermission()); + fieldsPermission.replaceAll((field, permission) -> write.equals(permission) ? read : permission); + return fieldsPermission; } @Override @@ -198,6 +262,9 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService ProcessDefinition processDefinition = processDefinitionService.getProcessDefinition( historicProcessInstance != null ? historicProcessInstance.getProcessDefinitionId() : reqVO.getProcessDefinitionId()); + if (processDefinition == null) { + throw exception(ErrorCodeConstants.PROCESS_DEFINITION_NOT_EXISTS); + } BpmProcessDefinitionInfoDO processDefinitionInfo = processDefinitionService .getProcessDefinitionInfo(processDefinition.getId()); BpmnModel bpmnModel = processDefinitionService.getProcessDefinitionBpmnModel(processDefinition.getId()); @@ -218,7 +285,7 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService // 2.2 流程已经结束,直接 return,无需预测 if (BpmProcessInstanceStatusEnum.isProcessEndStatus(processInstanceStatus)) { - return buildApprovalDetail(reqVO, bpmnModel, processDefinition, processDefinitionInfo, + return buildApprovalDetail(loginUserId, reqVO, bpmnModel, processDefinition, processDefinitionInfo, historicProcessInstance, processInstanceStatus, endActivityNodes, runActivityNodes, null, null); } @@ -243,7 +310,7 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService processVariables, activities, needSimulateTaskDefKeysByReturn); // 4. 拼接最终数据 - return buildApprovalDetail(reqVO, bpmnModel, processDefinition, processDefinitionInfo, historicProcessInstance, + return buildApprovalDetail(loginUserId, reqVO, bpmnModel, processDefinition, processDefinitionInfo, historicProcessInstance, processInstanceStatus, endActivityNodes, runActivityNodes, simulateActivityNodes, todoTask); } @@ -368,7 +435,8 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService *

* 主要是,拼接审批人的用户信息、部门信息 */ - private BpmApprovalDetailRespVO buildApprovalDetail(BpmApprovalDetailReqVO reqVO, + private BpmApprovalDetailRespVO buildApprovalDetail(Long loginUserId, + BpmApprovalDetailReqVO reqVO, BpmnModel bpmnModel, ProcessDefinition processDefinition, BpmProcessDefinitionInfoDO processDefinitionInfo, @@ -386,8 +454,8 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService Map deptMap = deptApi.getDeptMap(convertSet(userMap.values(), AdminUserRespDTO::getDeptId)); // 2. 表单权限 - String taskId = reqVO.getTaskId() == null && todoTask != null ? todoTask.getId() : reqVO.getTaskId(); - Map formFieldsPermission = getFormFieldsPermission(bpmnModel, reqVO.getActivityId(), taskId); + Map formFieldsPermission = getFormFieldsPermission(loginUserId, bpmnModel, reqVO, + processInstance, todoTask); // 3. 拼接数据 return BpmProcessInstanceConvert.INSTANCE.buildApprovalDetail(bpmnModel, processDefinition, @@ -777,6 +845,10 @@ public class BpmProcessInstanceServiceImpl implements BpmProcessInstanceService * @see 相关 issue */ private void validateDynamicFormRequiredFields(ProcessDefinition definition, Map variables) { + // 流程定义不存在时,交给 createProcessInstance0 统一校验,避免空指针 + if (definition == null) { + return; + } BpmProcessDefinitionInfoDO processDefinitionInfo = processDefinitionService .getProcessDefinitionInfo(definition.getId()); if (processDefinitionInfo == null diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java index 5b8aa4a..b103a0b 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImpl.java @@ -60,6 +60,7 @@ import org.flowable.task.service.impl.persistence.entity.TaskEntity; import org.flowable.task.service.impl.persistence.entity.TaskEntityImpl; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -147,7 +148,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { @Override public BpmTaskRespVO getTodoTask(Long userId, String taskId, String processInstanceId) { // 1.1 获取指定的用户待办任务 - Task todoTask = getMyTodoTask(userId, taskId); + Task todoTask = getMyTodoTask(userId, taskId, processInstanceId); // 1.2 获取不到,则获取该流程实例下,第一个用户的待办任务 if (todoTask == null) { todoTask = getMyFirstTodoTask(userId, processInstanceId); @@ -180,11 +181,12 @@ public class BpmTaskServiceImpl implements BpmTaskService { /** * 获得用户指定 taskId 任务编号的“待办”(未审批、且可审核)的任务 * - * @param userId 用户编号 - * @param taskId 任务编号 + * @param userId 用户编号 + * @param taskId 任务编号 + * @param processInstanceId 流程编号 * @return 任务 */ - private Task getMyTodoTask(Long userId, String taskId) { + private Task getMyTodoTask(Long userId, String taskId, String processInstanceId) { if (StrUtil.isEmpty(taskId)) { return null; } @@ -192,6 +194,10 @@ public class BpmTaskServiceImpl implements BpmTaskService { if (task == null) { return null; } + // 校验任务属于该流程实例,避免使用其它流程实例的任务 + if (ObjectUtil.notEqual(task.getProcessInstanceId(), processInstanceId)) { + return null; + } if (!isAssignUserTask(userId, task) && !isAddSignUserTask(userId, task)) { return null; } @@ -322,6 +328,18 @@ public class BpmTaskServiceImpl implements BpmTaskService { && ObjectUtil.notEqual(userId, NumberUtils.parseLong(task.getAssignee()))) { throw exception(TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); } + // 为什么 assignee 为空、owner 非空时,不允许操作? + // 例如说:【加签】等待中(WAIT)的任务(向前加签的父任务、向后加签的子任务),只有 owner 没有 assignee, + // 需要等待其它任务审批后,才能恢复 assignee 继续审批。此时,任何人都不能直接审批、拒绝它 + if (StrUtil.isBlank(task.getAssignee()) && StrUtil.isNotBlank(task.getOwner())) { + throw exception(TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + } + // 为什么审批通过中(APPROVING)时,不允许操作? + // 例如说:【向后加签】的父任务已经审批通过,需要等待加签出来的子任务审批完成后自动完成。此时,不能重复审批、拒绝、继续加签等 + Integer status = (Integer) task.getTaskLocalVariables().get(BpmnVariableConstants.TASK_VARIABLE_STATUS); + if (BpmTaskStatusEnum.APPROVING.getStatus().equals(status)) { + throw exception(TASK_OPERATE_FAIL_APPROVING); + } return task; } @@ -569,6 +587,17 @@ public class BpmTaskServiceImpl implements BpmTaskService { // ========== Update 写入相关方法 ========== + /** + * 自动审批通过,供事务完成后的回调调用 + * + * @see 自动审批事务问题 + */ + @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) + @DataPermission(enable = false) + public void approveTaskInternal(Long userId, BpmTaskApproveReqVO reqVO) { + approveTask(userId, reqVO); + } + @Override @Transactional(rollbackFor = Exception.class) @DataPermission(enable = false) // 关闭数据权限,避免查询不到用户数据。相关案例:https://gitee.com/zhijiantianya/yudao-cloud/issues/ID1UYA @@ -751,11 +780,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { // 2. 激活子任务 List childrenTaskList = getTaskListByParentTaskId(task.getId()); - for (Task childrenTask : childrenTaskList) { - taskService.resolveTask(childrenTask.getId()); - // 更新子 task 状态 - updateTaskStatus(childrenTask.getId(), BpmTaskStatusEnum.RUNNING.getStatus()); - } + childrenTaskList.forEach(childrenTask -> resolveWaitTask(childrenTask.getId())); } /** @@ -777,8 +802,8 @@ public class BpmTaskServiceImpl implements BpmTaskService { } // 1.2 只处理加签的父任务 Task parentTask = validateTaskExists(parentTaskId); - String scopeType = parentTask.getScopeType(); - if (BpmTaskSignTypeEnum.of(scopeType) == null) { + BpmTaskSignTypeEnum signType = BpmTaskSignTypeEnum.of(parentTask.getScopeType()); + if (signType == null) { return; } @@ -787,14 +812,11 @@ public class BpmTaskServiceImpl implements BpmTaskService { parentTaskImpl.setScopeType(null); taskService.saveTask(parentTaskImpl); - // 3.1 情况一:处理向【向前】加签 - if (BpmTaskSignTypeEnum.BEFORE.getType().equals(scopeType)) { - // 3.1.1 owner 重新赋值给父任务的 assignee,这样它就可以被审批 - taskService.resolveTask(parentTaskId); - // 3.1.2 更新流程任务 status - updateTaskStatus(parentTaskId, BpmTaskStatusEnum.RUNNING.getStatus()); + // 3.1 情况一:处理向【向前】加签,owner 重新赋值给父任务的 assignee,这样它就可以被审批 + if (signType == BpmTaskSignTypeEnum.BEFORE) { + resolveWaitTask(parentTaskId); // 3.2 情况二:处理向【向后】加签 - } else if (BpmTaskSignTypeEnum.AFTER.getType().equals(scopeType)) { + } else { // 只有 parentTask 处于 APPROVING 的情况下,才可以继续 complete 完成 // 否则,一个未审批的 parentTask 任务,在加签出来的任务都被减签的情况下,就直接完成审批,这样会存在问题 Integer status = (Integer) parentTask.getTaskLocalVariables().get(BpmnVariableConstants.TASK_VARIABLE_STATUS); @@ -831,6 +853,17 @@ public class BpmTaskServiceImpl implements BpmTaskService { updateTaskStatusAndReason(task.getId(), BpmTaskStatusEnum.RUNNING.getStatus(), reqVO.getReason()); } + /** + * 自动审批拒绝,供事务完成后的回调调用 + * + * @see 自动审批事务问题 + */ + @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) + @DataPermission(enable = false) + public void rejectTaskInternal(Long userId, BpmTaskRejectReqVO reqVO) { + rejectTask(userId, reqVO); + } + @Override @Transactional(rollbackFor = Exception.class) @DataPermission(enable = false) // 关闭数据权限,避免查询不到用户数据。相关案例:https://gitee.com/zhijiantianya/yudao-cloud/issues/ID1UYA @@ -905,6 +938,31 @@ public class BpmTaskServiceImpl implements BpmTaskService { taskService.setVariableLocal(id, BpmnVariableConstants.TASK_VARIABLE_REASON, reason); } + /** + * 保存【加签】等待中的任务:设置 owner、置空 assignee,并更新状态为 WAIT + *

+ * 适用于向前加签的父任务、向后加签的子任务,它们需要等待其它任务审批后,再通过 {@link #resolveWaitTask(String)} 恢复审批 + * + * @param task 任务 + * @param owner 拥有人,恢复审批时成为 assignee + */ + private void saveWaitTask(TaskEntityImpl task, String owner) { + task.setOwner(owner); + task.setAssignee(null); + taskService.saveTask(task); + updateTaskStatus(task.getId(), BpmTaskStatusEnum.WAIT.getStatus()); + } + + /** + * 恢复【加签】等待中的任务:将 owner 重新赋值给 assignee,并更新状态为 RUNNING,这样它就可以被审批 + * + * @param id 任务编号 + */ + private void resolveWaitTask(String id) { + taskService.resolveTask(id); + updateTaskStatus(id, BpmTaskStatusEnum.RUNNING.getStatus()); + } + @Override @Transactional(rollbackFor = Exception.class) @DataPermission(enable = false) // 关闭数据权限,避免查询不到用户数据。相关案例:https://gitee.com/zhijiantianya/yudao-cloud/issues/ID1UYA @@ -1043,7 +1101,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { String taskId = reqVO.getId(); // 1.1 校验任务 Task task = validateTask(userId, reqVO.getId()); - if (task.getAssignee().equals(reqVO.getDelegateUserId().toString())) { // 校验当前审批人和被委派人不是同一人 + if (StrUtil.equals(task.getAssignee(), reqVO.getDelegateUserId().toString())) { // 校验当前审批人和被委派人不是同一人 throw exception(TASK_DELEGATE_FAIL_USER_REPEAT); } // 1.2 校验目标用户存在 @@ -1074,7 +1132,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { String taskId = reqVO.getId(); // 1.1 校验任务 Task task = validateTask(userId, reqVO.getId()); - if (task.getAssignee().equals(reqVO.getAssigneeUserId().toString())) { // 校验当前审批人和被转派人不是同一人 + if (StrUtil.equals(task.getAssignee(), reqVO.getAssigneeUserId().toString())) { // 校验当前审批人和被转派人不是同一人 throw exception(TASK_TRANSFER_FAIL_USER_REPEAT); } // 1.2 校验目标用户存在 @@ -1144,26 +1202,22 @@ public class BpmTaskServiceImpl implements BpmTaskService { // 1. 获取和校验任务 TaskEntityImpl taskEntity = validateTaskCanCreateSign(userId, reqVO); List userList = adminUserApi.getUserList(reqVO.getUserIds()); - if (CollUtil.isEmpty(userList)) { + if (CollUtil.isEmpty(userList) || userList.size() != reqVO.getUserIds().size()) { throw exception(TASK_SIGN_CREATE_USER_NOT_EXIST); } // 2. 处理当前任务 // 2.1 开启计数功能,主要用于为了让表 ACT_RU_TASK 中的 SUB_TASK_COUNT_ 字段记录下总共有多少子任务,后续可能有用 taskEntity.setCountEnabled(true); - // 2.2 向前加签,设置 owner,置空 assign。等子任务都完成后,再调用 resolveTask 重新将 owner 设置为 assign - // 原因是:不能和向前加签的子任务一起审批,需要等前面的子任务都完成才能审批 - if (reqVO.getType().equals(BpmTaskSignTypeEnum.BEFORE.getType())) { - taskEntity.setOwner(taskEntity.getAssignee()); - taskEntity.setAssignee(null); - } - // 2.4 记录加签方式,完成任务时需要用到判断 + // 2.2 记录加签方式,完成任务时需要用到判断 taskEntity.setScopeType(reqVO.getType()); - // 2.5 保存当前任务修改后的值 - taskService.saveTask(taskEntity); - // 2.6 更新 task 状态为 WAIT,只有在向前加签的时候 - if (reqVO.getType().equals(BpmTaskSignTypeEnum.BEFORE.getType())) { - updateTaskStatus(taskEntity.getId(), BpmTaskStatusEnum.WAIT.getStatus()); + // 2.3 保存当前任务修改后的值 + // 向前加签,设置 owner,置空 assignee,状态为 WAIT。等子任务都完成后,再调用 resolveWaitTask 重新将 owner 设置为 assignee + // 原因是:不能和向前加签的子任务一起审批,需要等前面的子任务都完成才能审批 + if (BpmTaskSignTypeEnum.BEFORE.getType().equals(reqVO.getType())) { + saveWaitTask(taskEntity, taskEntity.getAssignee()); + } else { + taskService.saveTask(taskEntity); } // 3. 创建加签任务 @@ -1179,6 +1233,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { /** * 校验任务是否可以加签,主要校验加签类型是否一致: *

+ * 0. 加签类型必须存在 * 1. 如果存在“向前加签”的任务,则不能“向后加签” * 2. 如果存在“向后加签”的任务,则不能“向前加签” * @@ -1187,6 +1242,9 @@ public class BpmTaskServiceImpl implements BpmTaskService { * @return 当前任务 */ private TaskEntityImpl validateTaskCanCreateSign(Long userId, BpmTaskSignCreateReqVO reqVO) { + if (BpmTaskSignTypeEnum.of(reqVO.getType()) == null) { + throw exception(TASK_SIGN_CREATE_TYPE_NOT_EXISTS, reqVO.getType()); + } TaskEntityImpl taskEntity = (TaskEntityImpl) validateTask(userId, reqVO.getId()); // 向前加签和向后加签不能同时存在 if (taskEntity.getScopeType() != null @@ -1237,20 +1295,14 @@ public class BpmTaskServiceImpl implements BpmTaskService { TaskEntityImpl task = (TaskEntityImpl) taskService.newTask(IdUtil.fastSimpleUUID()); BpmTaskConvert.INSTANCE.copyTo(parentTask, task); - // 2.1 向前加签,设置审批人 + // 2.1 向前加签,设置审批人,子任务可以直接审批 if (BpmTaskSignTypeEnum.BEFORE.getType().equals(parentTask.getScopeType())) { task.setAssignee(assignee); - // 2.2 向后加签,设置 owner 不设置 assignee 是因为不能同时审批,需要等父任务完成 - } else { - task.setOwner(assignee); - } - // 2.3 保存子任务 - taskService.saveTask(task); - - // 3. 向后前签,设置子任务的状态为 WAIT,因为需要等父任务审批完 - if (BpmTaskSignTypeEnum.AFTER.getType().equals(parentTask.getScopeType())) { - updateTaskStatus(task.getId(), BpmTaskStatusEnum.WAIT.getStatus()); + taskService.saveTask(task); + return; } + // 2.2 向后加签,设置 owner 不设置 assignee,状态为 WAIT,是因为不能同时审批,需要等父任务审批完 + saveWaitTask(task, assignee); } @Override @@ -1259,7 +1311,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { @SuppressWarnings("DataFlowIssue") public void deleteSignTask(Long userId, BpmTaskSignDeleteReqVO reqVO) { // 1.1 校验 task 可以被减签 - Task task = validateTaskCanSignDelete(reqVO.getId()); + Task task = validateTaskCanSignDelete(userId, reqVO.getId()); // 1.2 校验取消人存在 AdminUserRespDTO cancelUser = null; if (StrUtil.isNotBlank(task.getAssignee())) { @@ -1353,11 +1405,15 @@ public class BpmTaskServiceImpl implements BpmTaskService { /** * 校验任务是否能被减签 + *

+ * 1. 被减签的任务,必须是通过加签生成的任务 + * 2. 当前用户,必须是父任务(或祖先任务)的审批人、拥有人,即加签人 * - * @param id 任务编号 + * @param userId 当前用户编号 + * @param id 任务编号 * @return 任务信息 */ - private Task validateTaskCanSignDelete(String id) { + private Task validateTaskCanSignDelete(Long userId, String id) { Task task = validateTaskExists(id); if (task.getParentTaskId() == null) { throw exception(TASK_SIGN_DELETE_NO_PARENT); @@ -1369,9 +1425,38 @@ public class BpmTaskServiceImpl implements BpmTaskService { if (BpmTaskSignTypeEnum.of(parentTask.getScopeType()) == null) { throw exception(TASK_SIGN_DELETE_NO_PARENT); } + if (!isSignCreateUser(userId, parentTask)) { + throw exception(TASK_SIGN_DELETE_FAIL_NOT_SELF); + } return task; } + /** + * 判断指定用户,是否为父任务(或祖先任务)的审批人、拥有人 + *

+ * 为什么包含祖先任务?多层加签时,最上层的加签人,可以减签它下面所有层级的加签任务 + * + * @param userId 用户编号 + * @param parentTask 父任务 + * @return 是否 + */ + private boolean isSignCreateUser(Long userId, Task parentTask) { + if (userId == null) { + return false; + } + Task task = parentTask; + for (int i = 0; i < Short.MAX_VALUE && task != null; i++) { + if (isAssignUserTask(userId, task) || isOwnerUserTask(userId, task)) { + return true; + } + if (task.getParentTaskId() == null) { + return false; + } + task = getTask(task.getParentTaskId()); + } + return false; + } + // ========== Event 事件相关方法 ========== @Override @@ -1433,19 +1518,19 @@ public class BpmTaskServiceImpl implements BpmTaskService { return; } if (ObjectUtil.equal(assignEmptyHandlerType, BpmUserTaskAssignEmptyHandlerTypeEnum.APPROVE.getType())) { - getSelf().approveTask(null, new BpmTaskApproveReqVO() + getSelf().approveTaskInternal(null, new BpmTaskApproveReqVO() .setId(task.getId()).setReason(BpmReasonEnum.ASSIGN_EMPTY_APPROVE.getReason())); } else if (ObjectUtil.equal(assignEmptyHandlerType, BpmUserTaskAssignEmptyHandlerTypeEnum.REJECT.getType())) { - getSelf().rejectTask(null, new BpmTaskRejectReqVO() + getSelf().rejectTaskInternal(null, new BpmTaskRejectReqVO() .setId(task.getId()).setReason(BpmReasonEnum.ASSIGN_EMPTY_REJECT.getReason())); } // 特殊情况二:【自动审核】审批类型为自动通过、不通过 } else { if (ObjectUtil.equal(approveType, BpmUserTaskApproveTypeEnum.AUTO_APPROVE.getType())) { - getSelf().approveTask(null, new BpmTaskApproveReqVO() + getSelf().approveTaskInternal(null, new BpmTaskApproveReqVO() .setId(task.getId()).setReason(BpmReasonEnum.APPROVE_TYPE_AUTO_APPROVE.getReason())); } else if (ObjectUtil.equal(approveType, BpmUserTaskApproveTypeEnum.AUTO_REJECT.getType())) { - getSelf().rejectTask(null, new BpmTaskRejectReqVO() + getSelf().rejectTaskInternal(null, new BpmTaskRejectReqVO() .setId(task.getId()).setReason(BpmReasonEnum.APPROVE_TYPE_AUTO_REJECT.getReason())); } } @@ -1526,7 +1611,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { .finished(); if (BpmAutoApproveTypeEnum.APPROVE_ALL.getType().equals(processDefinitionInfo.getAutoApprovalType()) && approvedTaskQuery.taskAssignee(task.getAssignee()).count() > 0) { - getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) + getSelf().approveTaskInternal(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) .setReason(BpmAutoApproveTypeEnum.APPROVE_ALL.getName())); return; } @@ -1543,7 +1628,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { approvedTaskQuery.taskDefinitionKeys(sourceTaskIds).orderByTaskCreateTime().desc(); // 设置 taskIds, 并按创建时间倒序排序 HistoricTaskInstance firstHisTask = CollUtil.getFirst(approvedTaskQuery.list()); if (firstHisTask != null && StrUtil.equals(firstHisTask.getAssignee(), task.getAssignee())) { - getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) + getSelf().approveTaskInternal(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) .setReason(BpmAutoApproveTypeEnum.APPROVE_SEQUENT.getName())); return; } @@ -1566,7 +1651,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { && (skipStartUserNodeFlag == null // 目的:一般是“主流程”,发起人节点,自动通过审核 || BooleanUtil.isTrue(skipStartUserNodeFlag)) // 目的:一般是“子流程”,发起人节点,按配置自动通过审核 && ObjUtil.notEqual(returnTaskFlag, Boolean.TRUE)) { - getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) + getSelf().approveTaskInternal(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) .setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP_START_USER_NODE.getReason())); return; } @@ -1579,7 +1664,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { // 情况一:自动跳过 if (ObjectUtils.equalsAny(assignStartUserHandlerType, BpmUserTaskAssignStartUserHandlerTypeEnum.SKIP.getType())) { - getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) + getSelf().approveTaskInternal(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) .setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP.getReason())); return; } @@ -1593,7 +1678,7 @@ public class BpmTaskServiceImpl implements BpmTaskService { // 找不到部门负责人的情况下,自动审批通过 // noinspection DataFlowIssue if (dept.getLeaderUserId() == null) { - getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) + getSelf().approveTaskInternal(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) .setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_DEPT_LEADER_NOT_FOUND.getReason())); return; } @@ -1648,7 +1733,10 @@ public class BpmTaskServiceImpl implements BpmTaskService { log.error("[processTaskTimeout][processInstanceId({}) 没有找到流程实例]", processInstanceId); return; } - List taskList = getRunningTaskListByProcessInstanceId(processInstanceId, true, taskDefineKey); + // 过滤【向后加签】审批通过中(APPROVING)的父任务:它已经审批通过,等待加签任务审批后自动完成,无需超时处理 + List taskList = filterList(getRunningTaskListByProcessInstanceId(processInstanceId, true, taskDefineKey), + task -> ObjectUtil.notEqual(task.getTaskLocalVariables().get(BpmnVariableConstants.TASK_VARIABLE_STATUS), + BpmTaskStatusEnum.APPROVING.getStatus())); // TODO 优化:未来需要考虑加签的情况 if (CollUtil.isEmpty(taskList)) { log.error("[processTaskTimeout][processInstanceId({}) 定义Key({}) 没有找到任务]", processInstanceId, taskDefineKey); diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/listener/BpmUserTaskListener.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/listener/BpmUserTaskListener.java index c1f6d9c..b5983c0 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/listener/BpmUserTaskListener.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/listener/BpmUserTaskListener.java @@ -14,6 +14,9 @@ import org.flowable.task.service.delegate.DelegateTask; import org.springframework.context.annotation.Scope; import org.springframework.stereotype.Component; +import java.util.ArrayList; +import java.util.List; + import static cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmnModelUtils.parseListenerConfig; // TODO @芋艿:可能会想换个包地址 @@ -43,17 +46,19 @@ public class BpmUserTaskListener implements TaskListener { // 2. 发起请求 // TODO @芋艿:哪些默认参数,后续再调研下;感觉可以搞个 task 字段,把整个 delegateTask 放进去; - listenerHandler.getBody().add(new BpmSimpleModelNodeVO.HttpRequestParam().setKey("processInstanceId") + List body = listenerHandler.getBody() == null + ? new ArrayList<>() : new ArrayList<>(listenerHandler.getBody()); + body.add(new BpmSimpleModelNodeVO.HttpRequestParam().setKey("processInstanceId") .setType(BpmHttpRequestParamTypeEnum.FIXED_VALUE.getType()).setValue(delegateTask.getProcessInstanceId())); - listenerHandler.getBody().add(new BpmSimpleModelNodeVO.HttpRequestParam().setKey("assignee") + body.add(new BpmSimpleModelNodeVO.HttpRequestParam().setKey("assignee") .setType(BpmHttpRequestParamTypeEnum.FIXED_VALUE.getType()).setValue(delegateTask.getAssignee())); - listenerHandler.getBody().add(new BpmSimpleModelNodeVO.HttpRequestParam().setKey("taskDefinitionKey") + body.add(new BpmSimpleModelNodeVO.HttpRequestParam().setKey("taskDefinitionKey") .setType(BpmHttpRequestParamTypeEnum.FIXED_VALUE.getType()).setValue(delegateTask.getTaskDefinitionKey())); - listenerHandler.getBody().add(new BpmSimpleModelNodeVO.HttpRequestParam().setKey("taskId") + body.add(new BpmSimpleModelNodeVO.HttpRequestParam().setKey("taskId") .setType(BpmHttpRequestParamTypeEnum.FIXED_VALUE.getType()).setValue(delegateTask.getId())); BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, - listenerHandler.getPath(), listenerHandler.getHeader(), listenerHandler.getBody(), false, null); + listenerHandler.getPath(), listenerHandler.getHeader(), body, false, null); // 3. 是否需要后续操作?TODO 芋艿:待定! } -} \ No newline at end of file +} diff --git a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/trigger/http/BpmHttpCallbackTrigger.java b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/trigger/http/BpmHttpCallbackTrigger.java index d2cc03e..36ed508 100644 --- a/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/trigger/http/BpmHttpCallbackTrigger.java +++ b/yudao-module-bpm/src/main/java/cn/iocoder/yudao/module/bpm/service/task/trigger/http/BpmHttpCallbackTrigger.java @@ -12,6 +12,9 @@ import org.flowable.engine.runtime.ProcessInstance; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import java.util.ArrayList; +import java.util.List; + /** * BPM HTTP 回调触发器 * @@ -41,11 +44,13 @@ public class BpmHttpCallbackTrigger extends BpmAbstractHttpRequestTrigger { // 2. 发起请求 ProcessInstance processInstance = processInstanceService.getProcessInstance(processInstanceId); - setting.getBody().add(new BpmSimpleModelNodeVO.HttpRequestParam() + List body = setting.getBody() == null + ? new ArrayList<>() : new ArrayList<>(setting.getBody()); + body.add(new BpmSimpleModelNodeVO.HttpRequestParam() .setKey("taskDefineKey") // 重要:回调请求 taskDefineKey 需要传给被调用方,用于回调执行 .setType(BpmHttpRequestParamTypeEnum.FIXED_VALUE.getType()).setValue(setting.getCallbackTaskDefineKey())); BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, - setting.getUrl(), setting.getHeader(), setting.getBody(), false, null); + setting.getUrl(), setting.getHeader(), body, false, null); } } diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BaseFlowableUnitTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BaseFlowableUnitTest.java new file mode 100644 index 0000000..324e1ab --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BaseFlowableUnitTest.java @@ -0,0 +1,157 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO; +import cn.iocoder.yudao.module.bpm.dal.redis.BpmProcessIdRedisDAO; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelTypeEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.config.BpmFlowableConfiguration; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.candidate.BpmTaskCandidateInvoker; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.listener.BpmProcessInstanceEventListener; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.listener.BpmTaskEventListener; +import cn.iocoder.yudao.module.bpm.service.comment.BpmCommentService; +import cn.iocoder.yudao.module.bpm.service.definition.BpmFormService; +import cn.iocoder.yudao.module.bpm.service.definition.BpmModelService; +import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService; +import cn.iocoder.yudao.module.bpm.service.message.BpmMessageService; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceCopyService; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.BpmTaskServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.listener.BpmCallActivityListener; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.flowable.engine.RepositoryService; +import org.flowable.spring.boot.ProcessEngineAutoConfiguration; +import org.flowable.spring.boot.ProcessEngineServicesAutoConfiguration; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.boot.autoconfigure.transaction.TransactionAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.Collection; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 依赖 Flowable 引擎的单元测试 + * + *

使用 H2 数据源,Flowable 表由测试环境的 Flowable 自动配置创建;需要业务表的测试自行导入对应 SQL。

+ *

加载真实的 {@link BpmTaskServiceImpl}、{@link BpmProcessInstanceServiceImpl} 及 Flowable 事件监听器, + * 保证事务代理、getSelf() 自调用、任务/流程实例状态回调与生产一致;其它模块、DB 相关的 Service 统一 Mock。

+ *

注意:Mock 统一声明在基类,保证子类共享同一个 Spring 上下文(同一个 Flowable 引擎),子类不要再额外声明 {@link MockitoBean}。

+ * + * @author HUIHUI + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE, + classes = BaseFlowableUnitTest.Application.class) +@ActiveProfiles("unit-test") +@TestPropertySource(properties = { + // Flowable 8 的 H2 脚本使用旧式 identity 语法,LEGACY 模式兼容该语法;业务表脚本在需要时由测试显式导入。 + "spring.datasource.url=jdbc:h2:mem:bpm-flowable-testdb;MODE=LEGACY;DATABASE_TO_UPPER=false;NON_KEYWORDS=value", + "spring.sql.init.mode=never", + // 关闭异步执行器,定时器由测试显式触发,避免后台线程与测试交错执行 + "flowable.async-executor-activate=false" +}) +public abstract class BaseFlowableUnitTest { + + @MockitoBean + protected BpmProcessDefinitionService processDefinitionService; + @MockitoBean + protected BpmModelService modelService; + @MockitoBean + protected BpmCommentService commentService; + @MockitoBean + protected BpmMessageService messageService; + @MockitoBean + protected BpmFormService formService; + @MockitoBean + protected BpmProcessInstanceCopyService processInstanceCopyService; + @MockitoBean + protected BpmProcessIdRedisDAO processIdRedisDAO; + @MockitoBean + protected AdminUserApi adminUserApi; + @MockitoBean + protected DeptApi deptApi; + + @Import({BaseDbUnitTest.Application.class, BpmFlowableConfiguration.class, + BpmTaskServiceImpl.class, BpmProcessInstanceServiceImpl.class, + BpmTaskEventListener.class, BpmProcessInstanceEventListener.class}) + @ImportAutoConfiguration({ProcessEngineAutoConfiguration.class, ProcessEngineServicesAutoConfiguration.class, + TransactionAutoConfiguration.class}) + public static class Application { + + @Bean("testBpmTaskCandidateInvoker") + @Primary + public BpmTaskCandidateInvoker bpmTaskCandidateInvoker() { + BpmTaskCandidateInvoker invoker = mock(BpmTaskCandidateInvoker.class); + when(invoker.calculateUsersByTask(any())).thenReturn(Set.of(1L)); + return invoker; + } + + /** + * 子流程发起人监听器:BPMN 通过 ${bpmCallActivityListener} 引用,需与组件扫描的 Bean 名称一致 + */ + @Bean("bpmCallActivityListener") + public BpmCallActivityListener bpmCallActivityListener() { + return new BpmCallActivityListener(); + } + } + + /** + * 审批人计算的 Mock,属于上下文单例(非 {@link MockitoBean}),每个测试前重置,避免测试间相互影响 + */ + @Resource(name = "testBpmTaskCandidateInvoker") + protected BpmTaskCandidateInvoker taskCandidateInvoker; + + @BeforeEach + public void resetTaskCandidateInvoker() { + // 默认:任务的审批人为用户 1 + reset(taskCandidateInvoker); + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(Set.of(1L)); + } + + /** + * 将流程定义、模型、用户相关的 Mock 委托给真实 Flowable 引擎,模拟业务表中已有对应数据 + * + *

流程定义信息默认为允许撤回的 BPMN 流程;用户编号为 id 时,昵称为「用户 + id」。

+ */ + @SuppressWarnings("unchecked") + protected void mockFlowableBackedServices(RepositoryService repositoryService) { + when(processDefinitionService.getActiveProcessDefinition(anyString())).thenAnswer(invocation -> + repositoryService.createProcessDefinitionQuery().processDefinitionKey(invocation.getArgument(0)) + .latestVersion().singleResult()); + when(processDefinitionService.getProcessDefinition(anyString())).thenAnswer(invocation -> + repositoryService.createProcessDefinitionQuery().processDefinitionId(invocation.getArgument(0)) + .singleResult()); + when(processDefinitionService.getProcessDefinitionBpmnModel(anyString())).thenAnswer(invocation -> + repositoryService.getBpmnModel(invocation.getArgument(0))); + when(processDefinitionService.getProcessDefinitionInfo(anyString())).thenAnswer(invocation -> + new BpmProcessDefinitionInfoDO().setProcessDefinitionId(invocation.getArgument(0)) + .setModelType(BpmModelTypeEnum.BPMN.getType()).setAllowWithdrawTask(true)); + when(processDefinitionService.canUserStartProcessDefinition(any(), any())).thenReturn(true); + when(modelService.getBpmnModelByDefinitionId(anyString())).thenAnswer(invocation -> + repositoryService.getBpmnModel(invocation.getArgument(0))); + when(adminUserApi.getUser(any())).thenAnswer(invocation -> buildUser(invocation.getArgument(0))); + when(adminUserApi.getUserList(anyCollection())).thenAnswer(invocation -> + ((Collection) invocation.getArgument(0)).stream().map(BaseFlowableUnitTest::buildUser).toList()); + when(adminUserApi.getUserMap(anyCollection())).thenAnswer(invocation -> + ((Collection) invocation.getArgument(0)).stream() + .collect(Collectors.toMap(Function.identity(), BaseFlowableUnitTest::buildUser))); + } + + private static AdminUserRespDTO buildUser(Long id) { + return new AdminUserRespDTO().setId(id).setNickname("用户" + id); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmModelDeployFlowableTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmModelDeployFlowableTest.java new file mode 100644 index 0000000..2172b02 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmModelDeployFlowableTest.java @@ -0,0 +1,297 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable; + +import cn.iocoder.yudao.framework.common.exception.ErrorCode; +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.BpmModelSaveReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.BpmTaskApproveReqVO; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO; +import cn.iocoder.yudao.module.bpm.dal.mysql.definition.BpmProcessDefinitionInfoMapper; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelFormTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmSimpleModelNodeTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskApproveMethodEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskApproveTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmTaskCandidateStrategyEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.FlowableUtils; +import cn.iocoder.yudao.module.bpm.service.definition.BpmModelServiceImpl; +import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.BpmTaskServiceImpl; +import jakarta.annotation.Resource; +import org.flowable.common.engine.impl.db.SuspensionState; +import org.flowable.engine.HistoryService; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.TaskService; +import org.flowable.engine.repository.Model; +import org.flowable.engine.repository.ProcessDefinition; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.jdbc.Sql; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.MODEL_UPDATE_FAIL_NOT_MANAGER; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_DEFINITION_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; + +/** + * 流程模型部署、挂起、排序、清理、删除的 Flowable 集成测试 + * + *

使用真实的 {@link BpmModelServiceImpl}、{@link BpmProcessDefinitionServiceImpl}(Flowable + bpm_process_definition_info 表), + * 并让流程实例 Service 依赖的 {@link #processDefinitionService} 委托给真实实现,验证【模型部署 → 发起 → 审批】的完整闭环。

+ * + * @author HUIHUI + */ +@Sql(scripts = "/sql/flowable/create_tables.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD) +@Sql(scripts = "/sql/flowable/clean.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) +public class BpmModelDeployFlowableTest extends BaseFlowableUnitTest { + + private static final String MODEL_KEY = "modelDeploy"; + + @Resource + private RepositoryService repositoryService; + @Resource + private RuntimeService runtimeService; + @Resource + private HistoryService historyService; + @Resource + private TaskService flowableTaskService; + @Resource + private BpmProcessDefinitionInfoMapper processDefinitionMapper; + + @Resource + private BpmTaskServiceImpl taskService; + @Resource + private BpmProcessInstanceServiceImpl processInstanceService; + + private final BpmProcessDefinitionServiceImpl processDefinitionServiceImpl = new BpmProcessDefinitionServiceImpl(); + private final BpmModelServiceImpl modelServiceImpl = new BpmModelServiceImpl(); + + @BeforeEach + public void setUp() { + // 真实的流程定义、流程模型 Service,依赖的 Flowable、Mapper 使用 Spring 中的真实 Bean + ReflectionTestUtils.setField(processDefinitionServiceImpl, "repositoryService", repositoryService); + ReflectionTestUtils.setField(processDefinitionServiceImpl, "processDefinitionMapper", processDefinitionMapper); + ReflectionTestUtils.setField(processDefinitionServiceImpl, "adminUserApi", adminUserApi); + ReflectionTestUtils.setField(modelServiceImpl, "repositoryService", repositoryService); + ReflectionTestUtils.setField(modelServiceImpl, "runtimeService", runtimeService); + ReflectionTestUtils.setField(modelServiceImpl, "historyService", historyService); + ReflectionTestUtils.setField(modelServiceImpl, "taskService", flowableTaskService); + ReflectionTestUtils.setField(modelServiceImpl, "processDefinitionService", processDefinitionServiceImpl); + ReflectionTestUtils.setField(modelServiceImpl, "bpmFormService", formService); + ReflectionTestUtils.setField(modelServiceImpl, "taskCandidateInvoker", taskCandidateInvoker); + ReflectionTestUtils.setField(modelServiceImpl, "processInstanceCopyService", processInstanceCopyService); + + // 发起、审批流程时,流程定义相关的查询委托给真实实现,读取部署后写入的 bpm_process_definition_info + mockFlowableBackedServices(repositoryService); + doAnswer(delegatesTo(processDefinitionServiceImpl)).when(processDefinitionService) + .getActiveProcessDefinition(anyString()); + doAnswer(delegatesTo(processDefinitionServiceImpl)).when(processDefinitionService) + .getProcessDefinition(anyString()); + doAnswer(delegatesTo(processDefinitionServiceImpl)).when(processDefinitionService) + .getProcessDefinitionInfo(anyString()); + doAnswer(delegatesTo(processDefinitionServiceImpl)).when(processDefinitionService) + .canUserStartProcessDefinition(any(), any()); + } + + @AfterEach + public void tearDown() { + repositoryService.createDeploymentQuery().deploymentKey(MODEL_KEY).list() + .forEach(deployment -> repositoryService.deleteDeployment(deployment.getId(), true)); + repositoryService.createModelQuery().modelKey(MODEL_KEY).list() + .forEach(model -> repositoryService.deleteModel(model.getId())); + } + + @Test + public void testDeployModel_redeploy() { + // 准备参数:创建 Simple 模型 + String modelId = createModel(); + + // 调用:首次部署 + modelServiceImpl.deployModel(1L, modelId); + // 断言:生成流程定义及拓展信息,模型关联最新部署 + ProcessDefinition definition1 = getActiveProcessDefinition(); + assertEquals("模型部署", definition1.getName()); + assertEquals("OA", definition1.getCategory()); + assertEquals(definition1.getDeploymentId(), repositoryService.getModel(modelId).getDeploymentId()); + BpmProcessDefinitionInfoDO definitionInfo1 = processDefinitionMapper.selectByProcessDefinitionId(definition1.getId()); + assertEquals(modelId, definitionInfo1.getModelId()); + assertEquals(BpmModelTypeEnum.SIMPLE.getType(), definitionInfo1.getModelType()); + assertEquals(List.of(1L), definitionInfo1.getManagerUserIds()); + assertNotNull(definitionInfo1.getSimpleModel()); + // 断言:可基于该流程定义发起流程,停留在审批节点 + String processInstanceId1 = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId1); + assertEquals(definition1.getId(), task1.getProcessDefinitionId()); + assertEquals("approve", task1.getTaskDefinitionKey()); + + // 调用:再次部署 + modelServiceImpl.deployModel(1L, modelId); + // 断言:老的流程定义被挂起,新发起的流程使用新的流程定义 + ProcessDefinition definition2 = getActiveProcessDefinition(); + assertNotEquals(definition1.getId(), definition2.getId()); + assertTrue(repositoryService.getProcessDefinition(definition1.getId()).isSuspended()); + assertEquals(definition2.getDeploymentId(), repositoryService.getModel(modelId).getDeploymentId()); + String processInstanceId2 = startProcessInstance(); + assertEquals(definition2.getId(), getRunningTask(processInstanceId2).getProcessDefinitionId()); + + // 调用 + 断言:老流程定义上进行中的流程不受挂起影响,仍可审批通过 + approveTask(task1); + assertProcessInstanceStatus(processInstanceId1, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + approveTask(getRunningTask(processInstanceId2)); + assertProcessInstanceStatus(processInstanceId2, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + + // 调用 + 断言:调整排序,同一模型的所有流程定义拓展信息同步更新 + modelServiceImpl.updateModelSortBatch(1L, List.of(modelId)); + Long sort = processDefinitionMapper.selectByProcessDefinitionId(definition2.getId()).getSort(); + assertEquals(sort, processDefinitionMapper.selectByProcessDefinitionId(definition1.getId()).getSort()); + assertTrue(sort > definitionInfo1.getSort()); + } + + @Test + public void testDeployModel_notManager() { + // 准备参数 + String modelId = createModel(); + + // 调用,并断言异常:非模型管理员不允许部署 + assertServiceException(() -> modelServiceImpl.deployModel(2L, modelId), MODEL_UPDATE_FAIL_NOT_MANAGER, "模型部署"); + assertEquals(0, repositoryService.createProcessDefinitionQuery().processDefinitionKey(MODEL_KEY).count()); + } + + @Test + public void testUpdateModelState_suspendAndActive() { + // 准备参数 + String modelId = createModel(); + modelServiceImpl.deployModel(1L, modelId); + String processDefinitionId = getActiveProcessDefinition().getId(); + + // 调用 + 断言:挂起后不允许发起 + modelServiceImpl.updateModelState(1L, modelId, SuspensionState.SUSPENDED.getStateCode()); + assertTrue(repositoryService.getProcessDefinition(processDefinitionId).isSuspended()); + assertStartProcessInstanceFail(PROCESS_DEFINITION_NOT_EXISTS); + // 调用 + 断言:激活后恢复发起 + modelServiceImpl.updateModelState(1L, modelId, SuspensionState.ACTIVE.getStateCode()); + assertFalse(repositoryService.getProcessDefinition(processDefinitionId).isSuspended()); + assertEquals("approve", getRunningTask(startProcessInstance()).getTaskDefinitionKey()); + } + + @Test + public void testCleanModel_success() { + // 准备参数:一个进行中的流程,一个已结束的流程 + String modelId = createModel(); + modelServiceImpl.deployModel(1L, modelId); + String runningId = startProcessInstance(); + String finishedId = startProcessInstance(); + approveTask(getRunningTask(finishedId)); + assertProcessInstanceStatus(finishedId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + + // 调用 + modelServiceImpl.cleanModel(1L, modelId); + // 断言:流程实例、历史、任务、抄送全部清理,模型与流程定义保留 + assertEquals(0, runtimeService.createProcessInstanceQuery().processDefinitionKey(MODEL_KEY).count()); + assertEquals(0, historyService.createHistoricProcessInstanceQuery().processDefinitionKey(MODEL_KEY).count()); + assertEquals(0, flowableTaskService.createTaskQuery().processDefinitionKey(MODEL_KEY).count()); + verify(processInstanceCopyService).deleteProcessInstanceCopy(runningId); + verify(processInstanceCopyService).deleteProcessInstanceCopy(finishedId); + assertNotNull(repositoryService.getModel(modelId)); + assertNotNull(getActiveProcessDefinition()); + } + + @Test + public void testDeleteModel_success() { + // 准备参数:一个进行中的流程 + String modelId = createModel(); + modelServiceImpl.deployModel(1L, modelId); + String processDefinitionId = getActiveProcessDefinition().getId(); + String processInstanceId = startProcessInstance(); + + // 调用 + modelServiceImpl.deleteModel(1L, modelId); + // 断言:模型被删除,流程定义被挂起,不允许再发起 + assertNull(repositoryService.getModel(modelId)); + assertTrue(repositoryService.getProcessDefinition(processDefinitionId).isSuspended()); + assertStartProcessInstanceFail(PROCESS_DEFINITION_NOT_EXISTS); + // 断言:进行中的流程不受影响,仍可审批通过 + approveTask(getRunningTask(processInstanceId)); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + // ========== 私有方法 ========== + + /** + * 创建 Simple 模型:发起人 -> 审批(用户 1)-> 结束,管理员为用户 1 + */ + private String createModel() { + BpmSimpleModelNodeVO approveNode = new BpmSimpleModelNodeVO().setId("approve").setName("审批") + .setType(BpmSimpleModelNodeTypeEnum.APPROVE_NODE.getType()) + .setApproveType(BpmUserTaskApproveTypeEnum.USER.getType()) + .setApproveMethod(BpmUserTaskApproveMethodEnum.RANDOM.getMethod()) + .setCandidateStrategy(BpmTaskCandidateStrategyEnum.USER.getStrategy()).setCandidateParam("1") + .setChildNode(new BpmSimpleModelNodeVO().setId("end").setType(BpmSimpleModelNodeTypeEnum.END_NODE.getType())); + BpmSimpleModelNodeVO startUserNode = new BpmSimpleModelNodeVO().setId("start-user").setName("发起人") + .setType(BpmSimpleModelNodeTypeEnum.START_USER_NODE.getType()) + .setChildNode(approveNode); + BpmModelSaveReqVO reqVO = new BpmModelSaveReqVO(); + reqVO.setKey(MODEL_KEY); + reqVO.setName("模型部署"); + reqVO.setCategory("OA"); + reqVO.setSimpleModel(startUserNode); + reqVO.setType(BpmModelTypeEnum.SIMPLE.getType()); + reqVO.setFormType(BpmModelFormTypeEnum.CUSTOM.getType()); + reqVO.setFormCustomCreatePath("/bpm/oa/leave/create"); + reqVO.setFormCustomViewPath("/bpm/oa/leave/detail"); + reqVO.setVisible(true); + reqVO.setManagerUserIds(List.of(1L)); + return modelServiceImpl.createModel(reqVO); + } + + private ProcessDefinition getActiveProcessDefinition() { + ProcessDefinition definition = processDefinitionServiceImpl.getActiveProcessDefinition(MODEL_KEY); + assertNotNull(definition); + return definition; + } + + private String startProcessInstance() { + return processInstanceService.createProcessInstance(1L, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey(MODEL_KEY).setBusinessKey("business-1")); + } + + /** + * 发起流程失败:ServiceException 被 FlowableUtils#executeAuthenticatedUserId 包装,由全局异常处理器解包 + */ + private void assertStartProcessInstanceFail(ErrorCode errorCode) { + RuntimeException exception = assertThrows(RuntimeException.class, this::startProcessInstance); + ServiceException serviceException = assertInstanceOf(ServiceException.class, exception.getCause()); + assertEquals(errorCode.getCode(), serviceException.getCode()); + } + + private Task getRunningTask(String processInstanceId) { + List tasks = taskService.getRunningTaskListByProcessInstanceId(processInstanceId, null, null); + assertEquals(1, tasks.size()); + return tasks.get(0); + } + + private void approveTask(Task task) { + taskService.approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) + .setReason("同意")); + } + + private void assertProcessInstanceStatus(String processInstanceId, Integer status) { + assertEquals(status, FlowableUtils.getProcessInstanceStatus( + processInstanceService.getHistoricProcessInstance(processInstanceId))); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmMultiInstanceFlowableTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmMultiInstanceFlowableTest.java new file mode 100644 index 0000000..445af49 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmMultiInstanceFlowableTest.java @@ -0,0 +1,338 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable; + +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.BpmTaskApproveReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.BpmTaskRejectReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmChildProcessMultiInstanceSourceTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmChildProcessStartUserEmptyTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmChildProcessStartUserTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmSimpleModelNodeTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskApproveMethodEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskApproveTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmReasonEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.behavior.BpmParallelMultiInstanceBehavior; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.behavior.BpmSequentialMultiInstanceBehavior; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmTaskCandidateStrategyEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.FlowableUtils; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.SimpleModelUtils; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.BpmTaskServiceImpl; +import jakarta.annotation.Resource; +import org.flowable.bpmn.model.BpmnModel; +import org.flowable.engine.HistoryService; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.history.HistoricProcessInstance; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.task.api.Task; +import org.flowable.task.api.history.HistoricTaskInstance; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * 多实例(会签、或签、依次审批、子流程多实例)的 Flowable 集成测试 + * + *

覆盖 {@link BpmParallelMultiInstanceBehavior}、{@link BpmSequentialMultiInstanceBehavior} 在真实引擎中的实例创建、 + * 审批人分配,以及 {@link BpmTaskServiceImpl} 审批后完成条件的流转。

+ * + * @author HUIHUI + */ +public class BpmMultiInstanceFlowableTest extends BaseFlowableUnitTest { + + @Resource + private RepositoryService repositoryService; + @Resource + private RuntimeService runtimeService; + @Resource + private HistoryService historyService; + + @Resource + private BpmTaskServiceImpl taskService; + @Resource + private BpmProcessInstanceServiceImpl processInstanceService; + + private final List deploymentIds = new ArrayList<>(); + + @BeforeEach + public void setUp() { + deploymentIds.add(repositoryService.createDeployment() + .addClasspathResource("bpmn/multi-instance/user-task.bpmn20.xml") + .addClasspathResource("bpmn/multi-instance/call-activity.bpmn20.xml") + .deploy().getId()); + mockFlowableBackedServices(repositoryService); + // 多人审批的审批人:用户 1、用户 2(有序) + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(new LinkedHashSet<>(List.of(1L, 2L))); + } + + @AfterEach + public void tearDown() { + deploymentIds.forEach(id -> repositoryService.deleteDeployment(id, true)); + } + + // ========== 多人审批 ========== + + @Test + public void testApproveTask_any() { + // 准备参数 + String processInstanceId = startProcessInstance("multiAny", null); + List tasks = getRunningTasks(processInstanceId); + assertEquals(List.of("1", "2"), convertList(tasks, Task::getAssignee)); + + // 调用:用户 1 审批通过 + approveTask(tasks.get(0)); + // 断言:或签一人通过即可,用户 2 的任务被取消,流程审批通过 + assertTaskStatus(tasks.get(1).getId(), BpmTaskStatusEnum.CANCEL.getStatus(), + BpmReasonEnum.CANCEL_BY_SYSTEM.getReason()); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + @Test + public void testRejectTask_any() { + // 准备参数 + String processInstanceId = startProcessInstance("multiAny", null); + List tasks = getRunningTasks(processInstanceId); + + // 调用:用户 1 审批不通过 + taskService.rejectTask(1L, new BpmTaskRejectReqVO().setId(tasks.get(0).getId()).setReason("不同意")); + // 断言:或签一人不通过即可,用户 2 的任务被取消,流程不通过 + assertTaskStatus(tasks.get(0).getId(), BpmTaskStatusEnum.REJECT.getStatus(), "不同意"); + assertTaskStatus(tasks.get(1).getId(), BpmTaskStatusEnum.CANCEL.getStatus(), + BpmReasonEnum.CANCEL_BY_SYSTEM.getReason()); + assertTrue(getRunningTasks(processInstanceId).isEmpty()); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.REJECT.getStatus()); + } + + @Test + public void testApproveTask_ratio() { + // 准备参数 + String processInstanceId = startProcessInstance("multiRatio", null); + List tasks = getRunningTasks(processInstanceId); + assertEquals(List.of("1", "2"), convertList(tasks, Task::getAssignee)); + + // 调用 + 断言:用户 1 审批通过,仍需等待用户 2 审批 + approveTask(tasks.get(0)); + assertEquals(List.of(tasks.get(1).getId()), convertList(getRunningTasks(processInstanceId), Task::getId)); + // 调用 + 断言:用户 2 审批通过,流程审批通过 + approveTask(tasks.get(1)); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + @Test + public void testApproveTask_ratioPartial() { + // 准备参数:通过比例 60%,审批人为用户 1、用户 2、用户 3 + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(new LinkedHashSet<>(List.of(1L, 2L, 3L))); + String processInstanceId = startProcessInstance("multiRatioPartial", null); + List tasks = getRunningTasks(processInstanceId); + assertEquals(List.of("1", "2", "3"), convertList(tasks, Task::getAssignee)); + + // 调用 + 断言:用户 1 审批通过(1/3 < 60%),仍需等待其他人审批 + approveTask(tasks.get(0)); + assertEquals(List.of(tasks.get(1).getId(), tasks.get(2).getId()), + convertList(getRunningTasks(processInstanceId), Task::getId)); + // 调用 + 断言:用户 2 审批通过(2/3 >= 60%),用户 3 的任务被取消,流程审批通过 + approveTask(tasks.get(1)); + assertTaskStatus(tasks.get(2).getId(), BpmTaskStatusEnum.CANCEL.getStatus(), + BpmReasonEnum.CANCEL_BY_SYSTEM.getReason()); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + @Test + public void testRejectTask_ratio() { + // 准备参数:用户 1 已审批通过 + String processInstanceId = startProcessInstance("multiRatio", null); + List tasks = getRunningTasks(processInstanceId); + approveTask(tasks.get(0)); + + // 调用:用户 2 审批不通过 + taskService.rejectTask(2L, new BpmTaskRejectReqVO().setId(tasks.get(1).getId()).setReason("不同意")); + // 断言:会签任一人不通过,流程不通过 + assertTaskStatus(tasks.get(0).getId(), BpmTaskStatusEnum.APPROVE.getStatus(), "同意"); + assertTaskStatus(tasks.get(1).getId(), BpmTaskStatusEnum.REJECT.getStatus(), "不同意"); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.REJECT.getStatus()); + } + + @Test + public void testRejectTask_ratioReturn() { + // 准备参数:发起审批节点由用户 1 审批,会签节点由用户 1、用户 2 审批 + when(taskCandidateInvoker.calculateUsersByTask(any())).thenAnswer(invocation -> + "multiReturn-task".equals(((DelegateExecution) invocation.getArgument(0)).getCurrentActivityId()) + ? new LinkedHashSet<>(List.of(1L, 2L)) : Set.of(1L)); + String processInstanceId = startProcessInstance("multiReturn", null); + approveTask(getRunningTask(processInstanceId)); + List tasks = getRunningTasks(processInstanceId); + assertEquals(List.of("1", "2"), convertList(tasks, Task::getAssignee)); + + // 调用:用户 2 审批不通过,按配置退回到发起审批节点 + taskService.rejectTask(2L, new BpmTaskRejectReqVO().setId(tasks.get(1).getId()).setReason("退回修改")); + // 断言:用户 2 的任务标记为退回,用户 1 的会签任务被取消,流程回到发起审批节点 + assertTaskStatus(tasks.get(1).getId(), BpmTaskStatusEnum.RETURN.getStatus(), "退回修改"); + assertTaskStatus(tasks.get(0).getId(), BpmTaskStatusEnum.CANCEL.getStatus(), + BpmReasonEnum.CANCEL_BY_SYSTEM.getReason()); + Task firstTask = getRunningTask(processInstanceId); + assertEquals("multiReturn-first", firstTask.getTaskDefinitionKey()); + + // 调用 + 断言:再次审批发起审批节点后,重新生成用户 1、用户 2 的会签任务 + approveTask(firstTask); + List newTasks = getRunningTasks(processInstanceId); + assertEquals(List.of("1", "2"), convertList(newTasks, Task::getAssignee)); + assertEquals(List.of("multiReturn-task", "multiReturn-task"), convertList(newTasks, Task::getTaskDefinitionKey)); + // 调用 + 断言:全部审批通过后,流程审批通过 + newTasks.forEach(this::approveTask); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + @Test + public void testApproveTask_sequential() { + // 准备参数 + String processInstanceId = startProcessInstance("multiSequential", null); + Task task1 = getRunningTask(processInstanceId); + assertEquals("1", task1.getAssignee()); + + // 调用 + 断言:用户 1 审批通过后,才轮到用户 2 + approveTask(task1); + Task task2 = getRunningTask(processInstanceId); + assertEquals("2", task2.getAssignee()); + // 调用 + 断言:用户 2 审批通过,流程审批通过 + approveTask(task2); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + // ========== 子流程多实例 ========== + + @Test + public void testCallActivity_fixedQuantity() { + // 调用 + String processInstanceId = startProcessInstance("multiChildFixedQuantity", null); + // 断言:并行发起 2 个子流程,全部审批通过后,主流程结束 + List children = getChildProcessInstances(processInstanceId); + assertEquals(2, children.size()); + children.forEach(child -> approveTask(getRunningTask(child.getId()))); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + @Test + public void testCallActivity_numberForm() { + // 调用:表单 count = 3 + String processInstanceId = startProcessInstance("multiChildNumberForm", new HashMap<>(Map.of("count", 3))); + // 断言:并行发起 3 个子流程,全部审批通过后,主流程结束 + List children = getChildProcessInstances(processInstanceId); + assertEquals(3, children.size()); + children.forEach(child -> approveTask(getRunningTask(child.getId()))); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + @Test + public void testCallActivity_multipleFormSequential() { + // 调用:表单 users 选择了 2 个用户 + String processInstanceId = startProcessInstance("multiChildMultipleForm", + new HashMap<>(Map.of("users", List.of(1L, 2L)))); + // 断言:依次发起子流程,前一个子流程结束后才发起下一个 + for (int i = 0; i < 2; i++) { + List children = getChildProcessInstances(processInstanceId); + assertEquals(1, children.size()); + approveTask(getRunningTask(children.get(0).getId())); + } + assertEquals(2, historyService.createHistoricProcessInstanceQuery() + .superProcessInstanceId(processInstanceId).count()); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + // ========== Simple 设计器模型 ========== + + @Test + public void testSimpleModel_ratioApproveThenChildProcess() { + // 准备参数:由 SimpleModelUtils 生成【会签 -> 子流程(固定数量 2 个)】的流程,与 Simple 设计器保存后部署的模型一致 + BpmSimpleModelNodeVO childProcessNode = new BpmSimpleModelNodeVO().setId("child").setName("子流程") + .setType(BpmSimpleModelNodeTypeEnum.CHILD_PROCESS.getType()) + .setChildProcessSetting(new BpmSimpleModelNodeVO.ChildProcessSetting() + .setCalledProcessDefinitionKey("multiChild").setCalledProcessDefinitionName("子流程") + .setAsync(false).setSkipStartUserNode(false) + .setStartUserSetting(new BpmSimpleModelNodeVO.ChildProcessSetting.StartUserSetting() + .setType(BpmChildProcessStartUserTypeEnum.MAIN_PROCESS_START_USER.getType()) + .setEmptyType(BpmChildProcessStartUserEmptyTypeEnum.MAIN_PROCESS_START_USER.getType())) + .setMultiInstanceSetting(new BpmSimpleModelNodeVO.ChildProcessSetting.MultiInstanceSetting() + .setEnable(true).setSequential(false).setApproveRatio(100) + .setSourceType(BpmChildProcessMultiInstanceSourceTypeEnum.FIXED_QUANTITY.getType()) + .setSource("2"))) + .setChildNode(new BpmSimpleModelNodeVO().setId("end").setType(BpmSimpleModelNodeTypeEnum.END_NODE.getType())); + BpmSimpleModelNodeVO approveNode = new BpmSimpleModelNodeVO().setId("approve").setName("会签审批") + .setType(BpmSimpleModelNodeTypeEnum.APPROVE_NODE.getType()) + .setApproveType(BpmUserTaskApproveTypeEnum.USER.getType()) + .setApproveMethod(BpmUserTaskApproveMethodEnum.RATIO.getMethod()).setApproveRatio(100) + .setCandidateStrategy(BpmTaskCandidateStrategyEnum.USER.getStrategy()).setCandidateParam("1,2") + .setChildNode(childProcessNode); + BpmnModel bpmnModel = SimpleModelUtils.buildBpmnModel("simpleMulti", "Simple 多实例", approveNode); + deploymentIds.add(repositoryService.createDeployment().addBpmnModel("simpleMulti.bpmn20.xml", bpmnModel) + .deploy().getId()); + String processInstanceId = startProcessInstance("simpleMulti", null); + + // 调用 + 断言:会签需要用户 1、用户 2 都审批通过 + List tasks = getRunningTasks(processInstanceId); + assertEquals(List.of("1", "2"), convertList(tasks, Task::getAssignee)); + tasks.forEach(this::approveTask); + // 调用 + 断言:并行发起 2 个子流程,全部审批通过后,主流程结束 + List children = getChildProcessInstances(processInstanceId); + assertEquals(2, children.size()); + children.forEach(child -> approveTask(getRunningTask(child.getId()))); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + // ========== 私有方法 ========== + + private String startProcessInstance(String processDefinitionKey, Map variables) { + return processInstanceService.createProcessInstance(1L, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey(processDefinitionKey).setVariables(variables)); + } + + private List getChildProcessInstances(String processInstanceId) { + return runtimeService.createProcessInstanceQuery().superProcessInstanceId(processInstanceId).list(); + } + + private List getRunningTasks(String processInstanceId) { + // 按审批人排序,便于断言 + return taskService.getRunningTaskListByProcessInstanceId(processInstanceId, null, null).stream() + .sorted(Comparator.comparing(Task::getAssignee)).toList(); + } + + private Task getRunningTask(String processInstanceId) { + List tasks = getRunningTasks(processInstanceId); + assertEquals(1, tasks.size()); + return tasks.get(0); + } + + private void approveTask(Task task) { + taskService.approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId()) + .setReason("同意")); + } + + private void assertTaskStatus(String taskId, Integer status, String reason) { + HistoricTaskInstance task = taskService.getHistoricTask(taskId); + assertEquals(status, FlowableUtils.getTaskStatus(task)); + assertEquals(reason, FlowableUtils.getTaskReason(task)); + } + + private void assertProcessInstanceStatus(String processInstanceId, Integer status) { + HistoricProcessInstance processInstance = processInstanceService.getHistoricProcessInstance(processInstanceId); + assertNotNull(processInstance.getEndTime()); + assertEquals(status, FlowableUtils.getProcessInstanceStatus(processInstance)); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmProcessDefinitionServiceFlowableTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmProcessDefinitionServiceFlowableTest.java new file mode 100644 index 0000000..b180cc1 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmProcessDefinitionServiceFlowableTest.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable; + +import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionServiceImpl; +import jakarta.annotation.Resource; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.repository.ProcessDefinition; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link BpmProcessDefinitionServiceImpl} 依赖 Flowable 引擎的集成测试 + * + *

基类将 BpmProcessDefinitionService 声明为 Mock,这里直接构造真实实现,并注入真实的 {@link RepositoryService}。

+ * + * @author HUIHUI + */ +public class BpmProcessDefinitionServiceFlowableTest extends BaseFlowableUnitTest { + + @Resource + private RepositoryService repositoryService; + + private final BpmProcessDefinitionServiceImpl processDefinitionServiceImpl = new BpmProcessDefinitionServiceImpl(); + + private String deploymentId; + + @BeforeEach + public void setUp() { + ReflectionTestUtils.setField(processDefinitionServiceImpl, "repositoryService", repositoryService); + deploymentId = repositoryService.createDeployment() + .addClasspathResource("bpmn/simple-approve.bpmn20.xml") + .deploy().getId(); + } + + @AfterEach + public void tearDown() { + repositoryService.deleteDeployment(deploymentId, true); + } + + @Test + public void testGetProcessDefinition_success() { + // 准备参数 + String id = repositoryService.createProcessDefinitionQuery().deploymentId(deploymentId).singleResult().getId(); + + // 调用 + ProcessDefinition processDefinition = processDefinitionServiceImpl.getProcessDefinition(id); + // 断言 + assertEquals("simpleApprove", processDefinition.getKey()); + } + + @Test + public void testGetProcessDefinition_notExists() { + // 调用,并断言:不存在时返回 null,而不是抛出 Flowable 异常,调用方据此提示“流程定义不存在” + assertNull(processDefinitionServiceImpl.getProcessDefinition("not-exists")); + assertNull(processDefinitionServiceImpl.getProcessDefinition(null)); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmProcessInstanceQueryFlowableTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmProcessInstanceQueryFlowableTest.java new file mode 100644 index 0000000..b7a9b05 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmProcessInstanceQueryFlowableTest.java @@ -0,0 +1,463 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmApprovalDetailReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmApprovalDetailRespVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmApprovalDetailRespVO.ActivityNode; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstanceBpmnModelViewRespVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstancePageReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.BpmTaskApproveReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.BpmTaskRejectReqVO; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmSimpleModelNodeTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.BpmTaskServiceImpl; +import jakarta.annotation.Resource; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.TaskService; +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.history.HistoricProcessInstance; +import org.flowable.engine.repository.Deployment; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_DEFINITION_NOT_EXISTS; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_INSTANCE_NOT_EXISTS; +import static cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants.START_USER_NODE_ID; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.when; + +/** + * {@link BpmProcessInstanceServiceImpl} 查询相关方法的 Flowable 集成测试 + * + *

流程实例、任务的状态由真实的 Flowable 事件监听器维护,覆盖审批详情、流程图进度、分页、下一节点预测等基于历史数据的查询。

+ * + * @author HUIHUI + */ +public class BpmProcessInstanceQueryFlowableTest extends BaseFlowableUnitTest { + + @Resource + private RepositoryService repositoryService; + @Resource + private RuntimeService runtimeService; + @Resource + private TaskService flowableTaskService; + + @Resource + private BpmTaskServiceImpl taskService; + @Resource + private BpmProcessInstanceServiceImpl processInstanceService; + + private final List deploymentIds = new ArrayList<>(); + + @BeforeEach + public void setUp() { + // 两级审批流程:task1 -> task2;审批人均为用户 1,候选人预测同样为用户 1 + deploy("bpmn/two-step-approve.bpmn20.xml"); + mockFlowableBackedServices(repositoryService); + when(taskCandidateInvoker.calculateUsersByActivity(any(), any(), any(), any(), any())) + .thenReturn(new LinkedHashSet<>(List.of(1L))); + } + + @AfterEach + public void tearDown() { + deploymentIds.forEach(id -> repositoryService.deleteDeployment(id, true)); + } + + // ========== getApprovalDetail ========== + + @Test + public void testGetApprovalDetail_running() { + // 准备参数:task1 已审批,task2 待审批 + String processInstanceId = startProcessInstance(1L, null); + approveRunningTask(processInstanceId); + Task task2 = getRunningTask(processInstanceId); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(1L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId)); + // 断言:已结束(发起人、task1) + 进行中(task2) + 预测(结束节点) + assertEquals(BpmProcessInstanceStatusEnum.RUNNING.getStatus(), respVO.getStatus()); + List nodes = respVO.getActivityNodes(); + assertEquals(List.of(START_USER_NODE_ID, "task1", "task2", "end"), convertList(nodes, ActivityNode::getId)); + assertEquals(List.of(BpmTaskStatusEnum.APPROVE.getStatus(), BpmTaskStatusEnum.APPROVE.getStatus(), + BpmTaskStatusEnum.RUNNING.getStatus(), BpmTaskStatusEnum.NOT_START.getStatus()), + convertList(nodes, ActivityNode::getStatus)); + assertEquals(BpmSimpleModelNodeTypeEnum.START_USER_NODE.getType(), nodes.get(0).getNodeType()); + assertEquals(1L, nodes.get(0).getTasks().get(0).getAssignee()); + assertEquals("同意", nodes.get(1).getTasks().get(0).getReason()); + assertEquals("用户1", nodes.get(2).getTasks().get(0).getAssigneeUser().getNickname()); + assertEquals(BpmSimpleModelNodeTypeEnum.END_NODE.getType(), nodes.get(3).getNodeType()); + // 断言:当前用户的待办为 task2,并返回流程实例信息 + assertEquals(task2.getId(), respVO.getTodoTask().getId()); + assertEquals(processInstanceId, respVO.getProcessInstance().getId()); + assertEquals("用户1", respVO.getProcessInstance().getStartUser().getNickname()); + } + + @Test + public void testGetApprovalDetail_approved() { + // 准备参数:task1、task2 均审批通过 + String processInstanceId = startProcessInstance(1L, null); + approveRunningTask(processInstanceId); + approveRunningTask(processInstanceId); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(1L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId)); + // 断言:流程已结束,不再预测;最后为审批通过的结束节点 + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), respVO.getStatus()); + List nodes = respVO.getActivityNodes(); + assertEquals(4, nodes.size()); + assertEquals(List.of(START_USER_NODE_ID, "task1", "task2"), convertList(nodes.subList(0, 3), ActivityNode::getId)); + ActivityNode endNode = nodes.get(3); + assertEquals(BpmSimpleModelNodeTypeEnum.END_NODE.getType(), endNode.getNodeType()); + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), endNode.getStatus()); + assertNotNull(endNode.getEndTime()); + assertNull(respVO.getTodoTask()); + } + + @Test + public void testGetApprovalDetail_rejected() { + // 准备参数:task1 审批不通过 + String processInstanceId = startProcessInstance(1L, null); + Task task1 = getRunningTask(processInstanceId); + taskService.rejectTask(1L, new BpmTaskRejectReqVO().setId(task1.getId()).setReason("不同意")); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(1L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId)); + // 断言:不通过时不展示结束节点 + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), respVO.getStatus()); + List nodes = respVO.getActivityNodes(); + assertEquals(List.of(START_USER_NODE_ID, "task1"), convertList(nodes, ActivityNode::getId)); + assertEquals(BpmTaskStatusEnum.REJECT.getStatus(), nodes.get(1).getStatus()); + assertEquals("不同意", nodes.get(1).getTasks().get(0).getReason()); + assertNull(respVO.getTodoTask()); + } + + @Test + public void testGetApprovalDetail_runningCallActivity() { + // 准备参数:主流程停留在子流程节点 + deploy("bpmn/lifecycle/child.bpmn20.xml"); + deploy("bpmn/lifecycle/child-parent.bpmn20.xml"); + String processInstanceId = processInstanceService.createProcessInstance(1L, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey("childParent")); + ProcessInstance child = runtimeService.createProcessInstanceQuery() + .superProcessInstanceId(processInstanceId).singleResult(); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(1L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId)); + // 断言:进行中的子流程节点关联子流程实例(CallActivity 没有自身的任务) + // 注意:BPMN 设计器的预测(BpmnModelUtils#simulateProcess)不会越过 CallActivity,因此不断言后续预测节点 + List nodes = respVO.getActivityNodes(); + assertEquals(List.of(START_USER_NODE_ID, "child"), convertList(nodes.subList(0, 2), ActivityNode::getId)); + ActivityNode childNode = nodes.get(1); + assertEquals(BpmSimpleModelNodeTypeEnum.CHILD_PROCESS.getType(), childNode.getNodeType()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), childNode.getStatus()); + assertEquals(child.getId(), childNode.getProcessInstanceId()); + assertTrue(childNode.getTasks().isEmpty()); + // 断言:用户 1 在主流程中没有待办 + assertNull(respVO.getTodoTask()); + } + + @Test + public void testGetApprovalDetail_notExists() { + // 调用,并断言异常 + assertServiceException(() -> processInstanceService.getApprovalDetail(1L, + new BpmApprovalDetailReqVO().setProcessInstanceId("not-exists")), PROCESS_INSTANCE_NOT_EXISTS); + } + + @Test + public void testGetApprovalDetail_definitionNotExists() { + // 调用,并断言异常:发起前预览审批详情时,流程定义已被删除 + assertServiceException(() -> processInstanceService.getApprovalDetail(1L, + new BpmApprovalDetailReqVO().setProcessDefinitionId("not-exists")), PROCESS_DEFINITION_NOT_EXISTS); + } + + // ========== getApprovalDetail 表单字段权限 ========== + + @Test // 发起人从“我的流程”进入,待办属于其他审批人:使用发起人节点的权限,可编辑降级为只读 + public void testGetApprovalDetail_formFieldsPermission_startUserWithoutTodo() { + // 准备参数:发起人 1,task1 待用户 2 审批 + String processInstanceId = startFormPermissionProcessInstance(); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(1L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId)); + // 断言 + assertNull(respVO.getTodoTask()); + assertEquals(Map.of("a", "1", "b", "3", "c", "1"), respVO.getFormFieldsPermission()); + } + + @Test // 审批人有本流程的待办:使用待办所在节点的权限,保留可编辑 + public void testGetApprovalDetail_formFieldsPermission_todoTask() { + // 准备参数:task1 待用户 2 审批 + String processInstanceId = startFormPermissionProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(2L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId)); + // 断言 + assertEquals(task1.getId(), respVO.getTodoTask().getId()); + assertEquals(Map.of("a", "1", "b", "2", "c", "3"), respVO.getFormFieldsPermission()); + } + + @Test // 传递其它流程实例的待办 taskId:忽略该任务,使用本流程实例的待办 + public void testGetApprovalDetail_formFieldsPermission_todoTaskOfOtherProcessInstance() { + // 准备参数:两个流程实例,task1 均待用户 2 审批 + String processInstanceId = startFormPermissionProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Task otherTask1 = getRunningTask(startFormPermissionProcessInstance()); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(2L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId).setTaskId(otherTask1.getId())); + // 断言 + assertEquals(task1.getId(), respVO.getTodoTask().getId()); + assertEquals(processInstanceId, respVO.getTodoTask().getProcessInstanceId()); + assertEquals(Map.of("a", "1", "b", "2", "c", "3"), respVO.getFormFieldsPermission()); + } + + @Test // 发起人传递其他审批人的 taskId:忽略该任务,回落发起人节点的权限 + public void testGetApprovalDetail_formFieldsPermission_taskIdOfOtherUser() { + // 准备参数:task1 待用户 2 审批 + String processInstanceId = startFormPermissionProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(1L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId).setTaskId(task1.getId())); + // 断言 + assertNull(respVO.getTodoTask()); + assertEquals(Map.of("a", "1", "b", "3", "c", "1"), respVO.getFormFieldsPermission()); + } + + @Test // 流程已结束:已办使用办理节点的权限,发起人使用发起人节点的权限,均降级为只读;其他人不返回权限 + public void testGetApprovalDetail_formFieldsPermission_processEnd() { + // 准备参数:用户 2 审批通过 task1,流程结束 + String processInstanceId = startFormPermissionProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + taskService.approveTask(2L, new BpmTaskApproveReqVO().setId(task1.getId()).setReason("同意")); + + // 调用 + 断言:用户 2 从“已办”进入 + BpmApprovalDetailRespVO doneRespVO = processInstanceService.getApprovalDetail(2L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId).setTaskId(task1.getId())); + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), doneRespVO.getStatus()); + assertNull(doneRespVO.getTodoTask()); + assertEquals(Map.of("a", "1", "b", "1", "c", "3"), doneRespVO.getFormFieldsPermission()); + // 调用 + 断言:发起人 1 从“我的流程”进入 + BpmApprovalDetailRespVO startUserRespVO = processInstanceService.getApprovalDetail(1L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId)); + assertEquals(Map.of("a", "1", "b", "3", "c", "1"), startUserRespVO.getFormFieldsPermission()); + // 调用 + 断言:用户 2 不传 taskId,既不是发起人也没有待办 + BpmApprovalDetailRespVO otherRespVO = processInstanceService.getApprovalDetail(2L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId)); + assertNull(otherRespVO.getFormFieldsPermission()); + } + + @Test // 传递其它流程实例的已办 taskId:忽略该任务 + public void testGetApprovalDetail_formFieldsPermission_doneTaskOfOtherProcessInstance() { + // 准备参数:两个流程实例,task1 均由用户 2 审批通过 + String processInstanceId = startFormPermissionProcessInstance(); + taskService.approveTask(2L, new BpmTaskApproveReqVO().setId(getRunningTask(processInstanceId).getId()) + .setReason("同意")); + String otherProcessInstanceId = startFormPermissionProcessInstance(); + Task otherTask1 = getRunningTask(otherProcessInstanceId); + taskService.approveTask(2L, new BpmTaskApproveReqVO().setId(otherTask1.getId()).setReason("同意")); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(2L, + new BpmApprovalDetailReqVO().setProcessInstanceId(processInstanceId).setTaskId(otherTask1.getId())); + // 断言 + assertNull(respVO.getFormFieldsPermission()); + } + + @Test // 流程未发起:使用传递的发起人节点的权限,保留可编辑 + public void testGetApprovalDetail_formFieldsPermission_notStart() { + // 准备参数 + deploy("bpmn/form-permission.bpmn20.xml"); + String processDefinitionId = repositoryService.createProcessDefinitionQuery() + .processDefinitionKey("formPermission").latestVersion().singleResult().getId(); + + // 调用 + BpmApprovalDetailRespVO respVO = processInstanceService.getApprovalDetail(1L, new BpmApprovalDetailReqVO() + .setProcessDefinitionId(processDefinitionId).setActivityId(START_USER_NODE_ID)); + // 断言 + assertNull(respVO.getTodoTask()); + assertEquals(Map.of("a", "2", "b", "3", "c", "1"), respVO.getFormFieldsPermission()); + } + + // ========== getProcessInstanceBpmnModelView ========== + + @Test + public void testGetProcessInstanceBpmnModelView_running() { + // 准备参数:task1 已审批,task2 待审批 + String processInstanceId = startProcessInstance(1L, null); + approveRunningTask(processInstanceId); + + // 调用 + BpmProcessInstanceBpmnModelViewRespVO respVO = processInstanceService.getProcessInstanceBpmnModelView(processInstanceId); + // 断言:进度信息 + assertEquals(Set.of("task2"), respVO.getUnfinishedTaskActivityIds()); + assertTrue(respVO.getFinishedTaskActivityIds().containsAll(Set.of("start", "task1"))); + assertFalse(respVO.getFinishedTaskActivityIds().contains("task2")); + assertEquals(Set.of("flow-start", "flow-task1"), respVO.getFinishedSequenceFlowActivityIds()); + assertTrue(respVO.getRejectedTaskActivityIds().isEmpty()); + // 断言:基础信息 + assertEquals(BpmProcessInstanceStatusEnum.RUNNING.getStatus(), respVO.getProcessInstance().getStatus()); + assertEquals("用户1", respVO.getProcessInstance().getStartUser().getNickname()); + assertEquals(List.of(BpmTaskStatusEnum.APPROVE.getStatus(), BpmTaskStatusEnum.RUNNING.getStatus()), + convertList(respVO.getTasks(), task -> task.getStatus())); + assertTrue(respVO.getBpmnXml().contains("twoStepApprove")); + assertNull(respVO.getSimpleModel()); + } + + @Test + public void testGetProcessInstanceBpmnModelView_rejected() { + // 准备参数:task1 已审批,task2 审批不通过(驳回到 task1),task1 再次审批不通过 + String processInstanceId = startProcessInstance(1L, null); + approveRunningTask(processInstanceId); + Task task = getRunningTask(processInstanceId); + taskService.rejectTask(1L, new BpmTaskRejectReqVO().setId(task.getId()).setReason("驳回")); + task = getRunningTask(processInstanceId); + assertEquals("task1", task.getTaskDefinitionKey()); + taskService.rejectTask(1L, new BpmTaskRejectReqVO().setId(task.getId()).setReason("不同意")); + + // 调用 + BpmProcessInstanceBpmnModelViewRespVO respVO = processInstanceService.getProcessInstanceBpmnModelView(processInstanceId); + // 断言:只取最后一个不通过的节点 + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), respVO.getProcessInstance().getStatus()); + assertEquals(Set.of("task1"), respVO.getRejectedTaskActivityIds()); + assertFalse(respVO.getFinishedTaskActivityIds().contains("task1")); + assertTrue(respVO.getUnfinishedTaskActivityIds().isEmpty()); + } + + @Test + public void testGetProcessInstanceBpmnModelView_notExists() { + assertNull(processInstanceService.getProcessInstanceBpmnModelView("not-exists")); + } + + // ========== getProcessInstancePage ========== + + @Test + public void testGetProcessInstancePage() { + // 准备参数:用户 1 发起并审批通过;用户 2 发起且审批中,表单 day = 3 + String approvedId = startProcessInstance(1L, null); + approveRunningTask(approvedId); + approveRunningTask(approvedId); + String runningId = startProcessInstance(2L, new HashMap<>(Map.of("day", 3))); + LocalDateTime now = LocalDateTime.now(); + + // 调用 + 断言:【我的流程】按发起人过滤 + assertPageIds(List.of(approvedId), processInstanceService.getProcessInstancePage(1L, new BpmProcessInstancePageReqVO())); + // 调用 + 断言:【管理流程】按 startUserId 过滤 + assertPageIds(List.of(runningId), processInstanceService.getProcessInstancePage(null, + (BpmProcessInstancePageReqVO) new BpmProcessInstancePageReqVO().setStartUserId(2L))); + // 调用 + 断言:名称、流程标识、发起时间,按发起时间倒序 + BpmProcessInstancePageReqVO reqVO = new BpmProcessInstancePageReqVO(); + reqVO.setName("两级").setProcessDefinitionKey("twoStepApprove") + .setCreateTime(new LocalDateTime[]{now.minusHours(1), now.plusHours(1)}); + assertEquals(Set.of(approvedId, runningId), + new HashSet<>(convertList(processInstanceService.getProcessInstancePage(null, reqVO).getList(), + HistoricProcessInstance::getId))); + // 调用 + 断言:状态、结束时间 + reqVO = new BpmProcessInstancePageReqVO(); + reqVO.setStatus(BpmProcessInstanceStatusEnum.APPROVE.getStatus()) + .setEndTime(new LocalDateTime[]{now.minusHours(1), now.plusHours(1)}); + assertPageIds(List.of(approvedId), processInstanceService.getProcessInstancePage(null, reqVO)); + // 调用 + 断言:表单字段,空值的字段被忽略 + reqVO = new BpmProcessInstancePageReqVO(); + reqVO.setFormFieldsParams("{\"day\": 3, \"reason\": \"\"}"); + assertPageIds(List.of(runningId), processInstanceService.getProcessInstancePage(null, reqVO)); + // 调用 + 断言:分类不匹配时返回空 + reqVO = new BpmProcessInstancePageReqVO(); + reqVO.setCategory("not-exists"); + PageResult pageResult = processInstanceService.getProcessInstancePage(null, reqVO); + assertEquals(0, pageResult.getTotal()); + assertTrue(pageResult.getList().isEmpty()); + } + + // ========== getNextApprovalNodes ========== + + @Test + public void testGetNextApprovalNodes() { + // 准备参数 + String processInstanceId = startProcessInstance(1L, null); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + 断言:task1 的下一个节点为 task2,并预测审批人 + List nodes = processInstanceService.getNextApprovalNodes(1L, + new BpmApprovalDetailReqVO().setTaskId(task1.getId())); + assertEquals(1, nodes.size()); + assertEquals("task2", nodes.get(0).getId()); + assertEquals(BpmSimpleModelNodeTypeEnum.APPROVE_NODE.getType(), nodes.get(0).getNodeType()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), nodes.get(0).getStatus()); + assertEquals(List.of(1L), nodes.get(0).getCandidateUserIds()); + assertEquals("用户1", nodes.get(0).getCandidateUsers().get(0).getNickname()); + + // 调用 + 断言:task2 的下一个节点为结束节点,无需审批 + approveRunningTask(processInstanceId); + Task task2 = getRunningTask(processInstanceId); + assertTrue(processInstanceService.getNextApprovalNodes(1L, + new BpmApprovalDetailReqVO().setTaskId(task2.getId())).isEmpty()); + } + + // ========== 私有方法 ========== + + private void deploy(String resource) { + Deployment deployment = repositoryService.createDeployment().addClasspathResource(resource).deploy(); + deploymentIds.add(deployment.getId()); + } + + private String startProcessInstance(Long userId, Map variables) { + return processInstanceService.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey("twoStepApprove").setVariables(variables)); + } + + /** + * 发起表单字段权限流程:发起人 1 的发起人节点自动通过,task1 待用户 2 审批 + */ + private String startFormPermissionProcessInstance() { + if (repositoryService.createProcessDefinitionQuery().processDefinitionKey("formPermission").count() == 0) { + deploy("bpmn/form-permission.bpmn20.xml"); + } + doAnswer(invocation -> { + DelegateExecution execution = invocation.getArgument(0); + return START_USER_NODE_ID.equals(execution.getCurrentActivityId()) ? Set.of(1L) : Set.of(2L); + }).when(taskCandidateInvoker).calculateUsersByTask(any()); + return processInstanceService.createProcessInstance(1L, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey("formPermission")); + } + + private Task getRunningTask(String processInstanceId) { + List tasks = flowableTaskService.createTaskQuery().processInstanceId(processInstanceId).list(); + assertEquals(1, tasks.size()); + return tasks.get(0); + } + + private void approveRunningTask(String processInstanceId) { + Task task = getRunningTask(processInstanceId); + taskService.approveTask(1L, new BpmTaskApproveReqVO().setId(task.getId()).setReason("同意")); + } + + private static void assertPageIds(List expected, PageResult pageResult) { + assertEquals(expected.size(), pageResult.getTotal()); + assertEquals(expected, convertList(pageResult.getList(), HistoricProcessInstance::getId)); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmProcessLifecycleFlowableTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmProcessLifecycleFlowableTest.java new file mode 100644 index 0000000..6c3446d --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmProcessLifecycleFlowableTest.java @@ -0,0 +1,245 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable; + +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.BpmModelMetaInfoVO; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmReasonEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.listener.BpmTaskEventListener; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.FlowableUtils; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.BpmTaskServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.listener.BpmCallActivityListener; +import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; +import org.flowable.engine.HistoryService; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.TaskService; +import org.flowable.engine.history.HistoricProcessInstance; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.task.api.Task; +import org.flowable.task.api.history.HistoricTaskInstance; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants.START_USER_NODE_ID; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 任务创建、分配事件后自动处理,以及子流程发起的 Flowable 集成测试 + * + *

补充 {@link BpmTaskServiceFlowableTest} 未覆盖的 {@link BpmTaskServiceImpl#processTaskCreated}、 + * {@link BpmTaskServiceImpl#processTaskAssigned} 分支,以及 {@link BpmCallActivityListener}、 + * {@link BpmProcessInstanceServiceImpl#processProcessInstanceCreated} 在真实事务提交后的回调。 + * 事件均由 {@link BpmTaskEventListener} 等真实监听器触发。

+ * + * @author HUIHUI + */ +public class BpmProcessLifecycleFlowableTest extends BaseFlowableUnitTest { + + @Resource + private RepositoryService repositoryService; + @Resource + private RuntimeService runtimeService; + @Resource + private HistoryService historyService; + @Resource + private TaskService flowableTaskService; + + @Resource + private BpmProcessInstanceServiceImpl processInstanceService; + + private final List deploymentIds = new ArrayList<>(); + + @BeforeEach + public void setUp() { + mockFlowableBackedServices(repositoryService); + } + + @AfterEach + public void tearDown() { + // 级联删除流程定义、流程实例与历史数据 + deploymentIds.forEach(id -> repositoryService.deleteDeployment(id, true)); + } + + // ========== 任务创建后的自动处理 ========== + + @Test + public void testProcessTaskCreated_assignEmptyApprove() { + // 准备参数:人工审批,计算不到审批人时自动通过 + deploy("bpmn/lifecycle/assign-empty-approve.bpmn20.xml"); + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(Set.of()); + + // 调用 + String processInstanceId = startProcessInstance("assignEmptyApprove", 1L); + // 断言:任务没有审批人,在事务完成后自动通过,且不会发送待办通知 + HistoricTaskInstance task = getHistoricTask(processInstanceId, "task"); + assertNull(task.getAssignee()); + assertTaskStatus(task.getId(), BpmTaskStatusEnum.APPROVE.getStatus(), BpmReasonEnum.ASSIGN_EMPTY_APPROVE.getReason()); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + verify(messageService, never()).sendMessageWhenTaskAssigned(any()); + } + + @Test + public void testProcessTaskCreated_assignEmptyReject() { + // 准备参数:人工审批,计算不到审批人时自动拒绝 + deploy("bpmn/lifecycle/assign-empty-reject.bpmn20.xml"); + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(Set.of()); + + // 调用 + String processInstanceId = startProcessInstance("assignEmptyReject", 1L); + // 断言:任务没有审批人,在事务完成后自动拒绝,流程不通过 + HistoricTaskInstance task = getHistoricTask(processInstanceId, "task"); + assertNull(task.getAssignee()); + assertTaskStatus(task.getId(), BpmTaskStatusEnum.REJECT.getStatus(), BpmReasonEnum.ASSIGN_EMPTY_REJECT.getReason()); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.REJECT.getStatus()); + verify(messageService, never()).sendMessageWhenTaskAssigned(any()); + } + + // ========== 任务分配后的自动处理 ========== + + @Test + public void testProcessTaskAssigned_startUserNodeAutoApprove() { + // 准备参数 + deploy("bpmn/lifecycle/start-user-node.bpmn20.xml"); + + // 调用 + String processInstanceId = startProcessInstance("startUserNode", 1L); + // 断言:发起人节点首次自动通过,流转到下一个审批节点 + assertTaskStatus(getHistoricTask(processInstanceId, START_USER_NODE_ID).getId(), + BpmTaskStatusEnum.APPROVE.getStatus(), + BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP_START_USER_NODE.getReason()); + Task task = getRunningTask(processInstanceId); + assertEquals("task", task.getTaskDefinitionKey()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), FlowableUtils.getTaskStatus(task)); + } + + @Test + public void testProcessTaskAssigned_startUserSkip() { + // 准备参数:审批人与发起人相同时,自动跳过 + deploy("bpmn/lifecycle/start-user-skip.bpmn20.xml"); + + // 调用 + 断言:发起人 1 = 审批人 1,自动通过 + String processInstanceId = startProcessInstance("startUserSkip", 1L); + assertTaskStatus(getHistoricTask(processInstanceId, "task").getId(), BpmTaskStatusEnum.APPROVE.getStatus(), + BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP.getReason()); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + + // 调用 + 断言:发起人 2 ≠ 审批人 1,正常待审批并发送待办通知 + String otherProcessInstanceId = startProcessInstance("startUserSkip", 2L); + Task task = getRunningTask(otherProcessInstanceId); + assertEquals("1", task.getAssignee()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), FlowableUtils.getTaskStatus(task)); + verify(messageService).sendMessageWhenTaskAssigned(argThat(reqDTO -> task.getId().equals(reqDTO.getTaskId()))); + } + + @Test + public void testProcessTaskAssigned_startUserTransferDeptLeader() { + // 准备参数:审批人与发起人相同时转交部门负责人;发起人 1 的部门负责人为 2 + deploy("bpmn/lifecycle/start-user-transfer-dept-leader.bpmn20.xml"); + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setNickname("用户1").setDeptId(10L)); + when(deptApi.getDept(10L)).thenReturn(new DeptRespDTO().setId(10L).setLeaderUserId(2L)); + + // 调用 + String processInstanceId = startProcessInstance("startUserTransferDeptLeader", 1L); + // 断言:任务转交给部门负责人,原审批人成为 owner,仅向部门负责人发送待办通知 + Task task = getRunningTask(processInstanceId); + assertEquals("2", task.getAssignee()); + assertEquals("1", task.getOwner()); + verify(messageService).sendMessageWhenTaskAssigned(argThat(reqDTO -> + task.getId().equals(reqDTO.getTaskId()) && reqDTO.getAssigneeUserId().equals(2L))); + verify(messageService, times(1)).sendMessageWhenTaskAssigned(any()); + } + + @Test + public void testProcessTaskAssigned_startUserDeptLeaderNotFound() { + // 准备参数:审批人与发起人相同时转交部门负责人;发起人 1 的部门没有负责人 + deploy("bpmn/lifecycle/start-user-transfer-dept-leader.bpmn20.xml"); + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setNickname("用户1").setDeptId(10L)); + when(deptApi.getDept(10L)).thenReturn(new DeptRespDTO().setId(10L)); + + // 调用 + String processInstanceId = startProcessInstance("startUserTransferDeptLeader", 1L); + // 断言:找不到部门负责人时自动通过 + assertTaskStatus(getHistoricTask(processInstanceId, "task").getId(), BpmTaskStatusEnum.APPROVE.getStatus(), + BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_DEPT_LEADER_NOT_FOUND.getReason()); + assertProcessInstanceStatus(processInstanceId, BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + } + + // ========== 子流程发起 ========== + + @Test + public void testCallActivity_startUserAndTitle() { + // 准备参数:开启自定义标题 + deploy("bpmn/lifecycle/child.bpmn20.xml"); + deploy("bpmn/lifecycle/child-parent.bpmn20.xml"); + when(processDefinitionService.getProcessDefinitionInfo(anyString())).thenAnswer(invocation -> + new BpmProcessDefinitionInfoDO().setProcessDefinitionId(invocation.getArgument(0)) + .setModelType(BpmModelTypeEnum.BPMN.getType()) + .setTitleSetting(new BpmModelMetaInfoVO.TitleSetting().setEnable(true) + .setTitle("{PROCESS_DEFINITION_NAME}-{PROCESS_START_USER_ID}"))); + + // 调用:用户 2 发起主流程 + String processInstanceId = startProcessInstance("childParent", 2L); + // 断言:主流程标题在发起时生成 + assertEquals("主流程-用户2", processInstanceService.getProcessInstance(processInstanceId).getName()); + // 断言:子流程发起人同主流程(BpmCallActivityListener),标题在 PROCESS_CREATED 事务提交后生成 + ProcessInstance child = runtimeService.createProcessInstanceQuery() + .superProcessInstanceId(processInstanceId).singleResult(); + assertNotNull(child); + assertEquals("2", child.getStartUserId()); + assertEquals("子流程-用户2", processInstanceService.getProcessInstance(child.getId()).getName()); + Task childTask = getRunningTask(child.getId()); + assertEquals("task", childTask.getTaskDefinitionKey()); + assertEquals("子流程审批", childTask.getName()); + } + + // ========== 私有方法 ========== + + private void deploy(String resource) { + deploymentIds.add(repositoryService.createDeployment().addClasspathResource(resource).deploy().getId()); + } + + private String startProcessInstance(String processDefinitionKey, Long userId) { + return processInstanceService.createProcessInstance(userId, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey(processDefinitionKey).setBusinessKey("business-1")); + } + + private Task getRunningTask(String processInstanceId) { + List tasks = flowableTaskService.createTaskQuery().processInstanceId(processInstanceId) + .includeTaskLocalVariables().list(); + assertEquals(1, tasks.size()); + return tasks.get(0); + } + + private HistoricTaskInstance getHistoricTask(String processInstanceId, String taskDefinitionKey) { + return historyService.createHistoricTaskInstanceQuery().processInstanceId(processInstanceId) + .taskDefinitionKey(taskDefinitionKey).includeTaskLocalVariables().singleResult(); + } + + private void assertTaskStatus(String taskId, Integer status, String reason) { + HistoricTaskInstance task = historyService.createHistoricTaskInstanceQuery().taskId(taskId) + .includeTaskLocalVariables().singleResult(); + assertEquals(status, FlowableUtils.getTaskStatus(task)); + assertEquals(reason, FlowableUtils.getTaskReason(task)); + } + + private void assertProcessInstanceStatus(String processInstanceId, Integer status) { + HistoricProcessInstance processInstance = historyService.createHistoricProcessInstanceQuery() + .processInstanceId(processInstanceId).includeProcessVariables().singleResult(); + assertNotNull(processInstance.getEndTime()); + assertEquals(status, FlowableUtils.getProcessInstanceStatus(processInstance)); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmTaskServiceFlowableTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmTaskServiceFlowableTest.java new file mode 100644 index 0000000..fd4bdf8 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmTaskServiceFlowableTest.java @@ -0,0 +1,1221 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable; + +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.BpmModelMetaInfoVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstanceCancelReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstanceCreateReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.*; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmAutoApproveTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskApproveTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskTimeoutHandlerTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmAttachmentTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmCommentTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmReasonEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskSignTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnVariableConstants; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmnModelUtils; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.FlowableUtils; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.BpmTaskServiceImpl; +import jakarta.annotation.Resource; +import org.flowable.bpmn.model.BpmnModel; +import org.flowable.bpmn.model.FlowElement; +import org.flowable.bpmn.model.UserTask; +import org.flowable.engine.HistoryService; +import org.flowable.engine.ManagementService; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.TaskService; +import org.flowable.engine.delegate.DelegateExecution; +import org.flowable.engine.repository.Deployment; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.engine.task.Attachment; +import org.flowable.job.api.Job; +import org.flowable.task.api.DelegationState; +import org.flowable.task.api.Task; +import org.flowable.task.api.history.HistoricTaskInstance; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link BpmTaskServiceImpl}、{@link BpmProcessInstanceServiceImpl} 的 Flowable 集成测试 + * + * @author HUIHUI + */ +@RecordApplicationEvents +public class BpmTaskServiceFlowableTest extends BaseFlowableUnitTest { + + @Resource + private RepositoryService repositoryService; + @Resource + private RuntimeService runtimeService; + @Resource + private HistoryService historyService; + @Resource + private TaskService flowableTaskService; + @Resource + private ManagementService managementService; + + @Resource + private BpmTaskServiceImpl bpmTaskService; + @Resource + private BpmProcessInstanceServiceImpl processInstanceService; + + @Resource + private ApplicationEvents applicationEvents; + + private Deployment deployment; + /** + * 单个测试额外部署的流程,在 {@link #tearDown()} 中先于 {@link #deployment} 删除 + */ + private final List extraDeploymentIds = new ArrayList<>(); + + @BeforeEach + public void setUp() { + // 部署两级审批流程:task1 -> task2,task2 驳回时退回到 task1;审批人统一由测试上下文计算为用户 1 + deployment = repositoryService.createDeployment() + .addClasspathResource("bpmn/two-step-approve.bpmn20.xml") + .deploy(); + + // 依赖的业务 Service 使用 Mock(委托给真实 Flowable 引擎),Flowable 相关 Service、BPM 任务/流程实例 Service 使用 Spring 中的真实 Bean + mockFlowableBackedServices(repositoryService); + } + + @AfterEach + public void tearDown() { + // 清理测试数据:加签子任务没有 execution,需要先删除,否则 Flowable 级联删除流程实例时会空指针 + List signTaskIds = flowableTaskService.createTaskQuery().deploymentId(deployment.getId()).list() + .stream().filter(task -> task.getExecutionId() == null).map(Task::getId).toList(); + flowableTaskService.deleteTasks(signTaskIds); + extraDeploymentIds.forEach(id -> repositoryService.deleteDeployment(id, true)); + repositoryService.deleteDeployment(deployment.getId(), true); + } + + @Test + public void testTriggerTask_success() { + // 准备参数:流程停留在 receiveTask 等待节点 + deploy("bpmn/trigger-task.bpmn20.xml"); + String processInstanceId = startProcessInstance("triggerTask"); + assertNotNull(runtimeService.createExecutionQuery().processInstanceId(processInstanceId) + .activityId("wait").singleResult()); + + // 调用 + bpmTaskService.triggerTask(processInstanceId, "wait"); + // 断言:触发等待节点后,流程结束 + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertNotNull(processInstanceService.getHistoricProcessInstance(processInstanceId).getEndTime()); + } + + @Test + public void testTriggerTask_executionNotExists() { + // 准备参数 + String processInstanceId = startProcessInstance(); + + // 调用:触发不存在的等待节点 + bpmTaskService.triggerTask(processInstanceId, "notExists"); + // 断言:流程实例不受影响 + assertEquals("task1", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testApproveTask_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + assertEquals("1", task1.getAssignee()); + + // 调用:审批 task1 + bpmTaskService.approveTask(1L, new BpmTaskApproveReqVO().setId(task1.getId()).setReason("同意")); + // 断言:task1 审批通过,流程流转到 task2 + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.APPROVE.getStatus(), "同意"); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.APPROVE), eq("同意")); + Task task2 = getRunningTask(processInstanceId); + assertEquals("task2", task2.getTaskDefinitionKey()); + + // 调用:审批 task2 + bpmTaskService.approveTask(1L, new BpmTaskApproveReqVO().setId(task2.getId()).setReason("同意")); + // 断言:流程实例结束 + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertNotNull(processInstanceService.getHistoricProcessInstance(processInstanceId).getEndTime()); + List tasks = bpmTaskService.getTaskListByProcessInstanceId(processInstanceId, true); + assertEquals(List.of("task1", "task2"), tasks.stream().map(HistoricTaskInstance::getTaskDefinitionKey).toList()); + } + + @Test + public void testApproveTask_withSignAndAttachment() { + // 准备参数:task1 开启签名 + doAnswer(invocation -> { + BpmnModel bpmnModel = repositoryService.getBpmnModel(invocation.getArgument(0)); + BpmnModelUtils.addExtensionElement(bpmnModel.getFlowElement("task1"), BpmnModelConstants.SIGN_ENABLE, "true"); + return bpmnModel; + }).when(modelService).getBpmnModelByDefinitionId(anyString()); + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + bpmTaskService.approveTask(1L, new BpmTaskApproveReqVO().setId(task1.getId()).setReason("同意") + .setSignPicUrl("https://www.iocoder.cn/sign.png") + .setAttachments(List.of("https://www.iocoder.cn/files/a.pdf")) + .setVariables(Map.of("day", 3))); + // 断言:签名、表单变量保存到任务上,表单变量同步到流程实例 + HistoricTaskInstance historicTask = bpmTaskService.getHistoricTask(task1.getId()); + assertEquals("https://www.iocoder.cn/sign.png", + historicTask.getTaskLocalVariables().get(BpmnVariableConstants.TASK_SIGN_PIC_URL)); + assertEquals(3, historicTask.getTaskLocalVariables().get("day")); + assertEquals(3, runtimeService.getVariable(processInstanceId, "day")); + // 断言:附件 + List attachments = bpmTaskService.getAttachments(processInstanceId, Set.of(task1.getId()), + BpmAttachmentTypeEnum.TASK_ATTACHMENT); + assertEquals(1, attachments.size()); + assertEquals("a.pdf", attachments.get(0).getName()); + assertEquals("https://www.iocoder.cn/files/a.pdf", attachments.get(0).getUrl()); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testApproveTask_assigneeNotSelf() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用,并断言异常 + assertServiceException(() -> bpmTaskService.approveTask(2L, + new BpmTaskApproveReqVO().setId(task1.getId()).setReason("同意")), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + } + + @Test + public void testRejectTask_finishProcessInstance() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用:task1 未配置驳回策略,默认结束流程 + bpmTaskService.rejectTask(1L, new BpmTaskRejectReqVO().setId(task1.getId()).setReason("不同意") + .setAttachments(List.of("https://www.iocoder.cn/files/b.pdf"))); + // 断言:任务不通过,附件保存到任务上 + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.REJECT.getStatus(), "不同意"); + List attachments = bpmTaskService.getAttachments(processInstanceId, Set.of(task1.getId()), + BpmAttachmentTypeEnum.TASK_ATTACHMENT); + assertEquals(List.of("b.pdf"), attachments.stream().map(Attachment::getName).toList()); + // 断言:流程实例不通过并结束 + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + assertEquals("审批不通过任务,原因:不同意", getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_REASON)); + } + + @Test + public void testRejectTask_returnUserTask() { + // 准备参数:task1 审批通过后,流转到 task2 + String processInstanceId = startProcessInstance(); + approveRunningTask(processInstanceId); + Task task2 = getRunningTask(processInstanceId); + + // 调用:task2 配置驳回到 task1 + bpmTaskService.rejectTask(1L, new BpmTaskRejectReqVO().setId(task2.getId()).setReason("退回修改")); + // 断言:task2 被标记为退回,流程回到 task1 + assertTaskStatus(task2.getId(), BpmTaskStatusEnum.RETURN.getStatus(), "退回修改"); + Task task1 = getRunningTask(processInstanceId); + assertEquals("task1", task1.getTaskDefinitionKey()); + assertEquals(Boolean.TRUE, runtimeService.getVariable(processInstanceId, + String.format(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_RETURN_FLAG, "task1"))); + assertEquals(Set.of("task2"), runtimeService.getVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_NEED_SIMULATE_TASK_IDS)); + + // 调用:再次审批 task1 + bpmTaskService.approveTask(1L, new BpmTaskApproveReqVO().setId(task1.getId()).setReason("已修改")); + // 断言:退回标记被清理,流程再次流转到 task2 + assertFalse(runtimeService.hasVariable(processInstanceId, + String.format(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_RETURN_FLAG, "task1"))); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testRejectTask_signChildTask() { + // 准备参数:task1 向前加签给用户 2 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.BEFORE.getType()).setUserIds(Set.of(2L)).setReason("加签")); + Task childTask = bpmTaskService.getTaskListByParentTaskId(task1.getId()).get(0); + + // 调用:用户 2 审批不通过加签任务 + bpmTaskService.rejectTask(2L, new BpmTaskRejectReqVO().setId(childTask.getId()).setReason("不同意")); + // 断言:加签任务、根任务都标记为不通过,流程实例不通过并结束 + assertTaskStatus(childTask.getId(), BpmTaskStatusEnum.REJECT.getStatus(), "不同意"); + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.REJECT.getStatus(), + BpmCommentTypeEnum.REJECT.formatComment("加签任务不通过")); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.REJECT), eq("加签任务不通过")); + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + } + + @Test + public void testGetUserTaskListByReturn_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + assertTrue(bpmTaskService.getUserTaskListByReturn(getRunningTask(processInstanceId).getId()).isEmpty()); + approveRunningTask(processInstanceId); + Task task2 = getRunningTask(processInstanceId); + + // 调用 + List userTasks = bpmTaskService.getUserTaskListByReturn(task2.getId()); + // 断言 + assertEquals(List.of("task1"), userTasks.stream().map(UserTask::getId).toList()); + } + + @Test + public void testReturnTask_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + approveRunningTask(processInstanceId); + Task task2 = getRunningTask(processInstanceId); + + // 调用 + bpmTaskService.returnTask(1L, new BpmTaskReturnReqVO().setId(task2.getId()) + .setTargetTaskDefinitionKey("task1").setReason("退回")); + // 断言 + assertTaskStatus(task2.getId(), BpmTaskStatusEnum.RETURN.getStatus(), "退回"); + verify(commentService).createComment(eq(task2.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.RETURN), eq("退回")); + assertEquals("task1", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testReturnTask_targetNotExists() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用,并断言异常 + assertServiceException(() -> bpmTaskService.returnTask(1L, new BpmTaskReturnReqVO().setId(task1.getId()) + .setTargetTaskDefinitionKey("notExists").setReason("退回")), TASK_TARGET_NODE_NOT_EXISTS); + } + + @Test + public void testReturnTask_sourceTargetError() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用,并断言异常:task2 位于 task1 之后,不能退回 + assertServiceException(() -> bpmTaskService.returnTask(1L, new BpmTaskReturnReqVO().setId(task1.getId()) + .setTargetTaskDefinitionKey("task2").setReason("退回")), TASK_RETURN_FAIL_SOURCE_TARGET_ERROR); + } + + @Test + public void testDelegateTask_thenApprove() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用:委派给用户 2 + bpmTaskService.delegateTask(1L, new BpmTaskDelegateReqVO().setId(task1.getId()) + .setDelegateUserId(2L).setReason("请帮忙审批")); + // 断言 + Task delegatedTask = bpmTaskService.getTask(task1.getId()); + assertEquals("1", delegatedTask.getOwner()); + assertEquals("2", delegatedTask.getAssignee()); + assertEquals(DelegationState.PENDING, delegatedTask.getDelegationState()); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.DELEGATE_START), eq("用户1"), eq("用户2"), eq("请帮忙审批")); + + // 调用:被委派人审批,任务归还给原审批人 + bpmTaskService.approveTask(2L, new BpmTaskApproveReqVO().setId(task1.getId()).setReason("已处理")); + // 断言:任务仍在 task1,审批人恢复为用户 1 + Task resolvedTask = bpmTaskService.getTask(task1.getId()); + assertEquals("1", resolvedTask.getAssignee()); + assertEquals(DelegationState.RESOLVED, resolvedTask.getDelegationState()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), + resolvedTask.getTaskLocalVariables().get(BpmnVariableConstants.TASK_VARIABLE_STATUS)); + } + + @Test + public void testDelegateTask_redelegate() { + // 准备参数:用户 1 委派给用户 2 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + bpmTaskService.delegateTask(1L, new BpmTaskDelegateReqVO().setId(task1.getId()) + .setDelegateUserId(2L).setReason("请帮忙审批")); + + // 调用:用户 2 再委派给用户 3 + bpmTaskService.delegateTask(2L, new BpmTaskDelegateReqVO().setId(task1.getId()) + .setDelegateUserId(3L).setReason("转给用户 3")); + // 断言:owner 仍为最初的审批人 + Task delegatedTask = bpmTaskService.getTask(task1.getId()); + assertEquals("1", delegatedTask.getOwner()); + assertEquals("3", delegatedTask.getAssignee()); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.DELEGATE_START), eq("用户2"), eq("用户3"), eq("转给用户 3")); + + // 调用:用户 3 审批,任务归还给用户 1 + bpmTaskService.approveTask(3L, new BpmTaskApproveReqVO().setId(task1.getId()).setReason("已处理")); + // 断言 + assertEquals("1", bpmTaskService.getTask(task1.getId()).getAssignee()); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.DELEGATE_END), any(), eq("用户1"), eq("已处理")); + } + + @Test + public void testDelegateTask_userRepeat() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用,并断言异常 + assertServiceException(() -> bpmTaskService.delegateTask(1L, new BpmTaskDelegateReqVO().setId(task1.getId()) + .setDelegateUserId(1L).setReason("委派")), TASK_DELEGATE_FAIL_USER_REPEAT); + } + + @Test + public void testTransferTask_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + bpmTaskService.transferTask(1L, new BpmTaskTransferReqVO().setId(task1.getId()) + .setAssigneeUserId(2L).setReason("转办")); + // 断言 + Task transferredTask = bpmTaskService.getTask(task1.getId()); + assertEquals("1", transferredTask.getOwner()); + assertEquals("2", transferredTask.getAssignee()); + assertNull(transferredTask.getDelegationState()); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.TRANSFER), eq("用户1"), eq("用户2"), eq("转办")); + } + + @Test + public void testTransferTask_emptyAssignee() { + // 准备参数:task1 计算不到审批人,且未配置自动处理 + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(Set.of()); + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + assertNull(task1.getAssignee()); + + // 调用:转办给用户 2 + bpmTaskService.transferTask(1L, new BpmTaskTransferReqVO().setId(task1.getId()) + .setAssigneeUserId(2L).setReason("转办")); + // 断言:用户 2 成为审批人 + Task transferredTask = bpmTaskService.getTask(task1.getId()); + assertEquals("2", transferredTask.getAssignee()); + assertNull(transferredTask.getOwner()); + // 调用 + 断言:用户 2 审批通过,流程流转到 task2 + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(Set.of(1L)); + bpmTaskService.approveTask(2L, new BpmTaskApproveReqVO().setId(task1.getId()).setReason("同意")); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testCreateSignTask_before_thenApproveChild() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用:向前加签给用户 2 + bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.BEFORE.getType()).setUserIds(Set.of(2L)).setReason("加签")); + // 断言:父任务等待子任务审批,子任务分配给用户 2 + Task parentTask = bpmTaskService.getTask(task1.getId()); + assertNull(parentTask.getAssignee()); + assertEquals("1", parentTask.getOwner()); + assertEquals(BpmTaskSignTypeEnum.BEFORE.getType(), parentTask.getScopeType()); + assertEquals(BpmTaskStatusEnum.WAIT.getStatus(), + parentTask.getTaskLocalVariables().get(BpmnVariableConstants.TASK_VARIABLE_STATUS)); + List childTasks = bpmTaskService.getTaskListByParentTaskId(task1.getId()); + assertEquals(1, childTasks.size()); + assertEquals("2", childTasks.get(0).getAssignee()); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.ADD_SIGN), eq("用户1"), eq("向前加签"), eq("用户2"), eq("加签")); + + // 调用:用户 2 审批子任务 + bpmTaskService.approveTask(2L, new BpmTaskApproveReqVO().setId(childTasks.get(0).getId()).setReason("同意")); + // 断言:父任务恢复给用户 1 审批 + Task resolvedTask = bpmTaskService.getTask(task1.getId()); + assertEquals("1", resolvedTask.getAssignee()); + assertNull(resolvedTask.getScopeType()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), + resolvedTask.getTaskLocalVariables().get(BpmnVariableConstants.TASK_VARIABLE_STATUS)); + assertTrue(bpmTaskService.getTaskListByParentTaskId(task1.getId()).isEmpty()); + } + + @Test + public void testCreateSignTask_after_thenApprove() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用:向后加签给用户 2 + bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.AFTER.getType()).setUserIds(Set.of(2L)).setReason("加签")); + // 断言:子任务暂由用户 2 拥有,等待父任务审批 + Task childTask = bpmTaskService.getTaskListByParentTaskId(task1.getId()).get(0); + assertEquals("2", childTask.getOwner()); + assertNull(childTask.getAssignee()); + assertEquals(BpmTaskStatusEnum.WAIT.getStatus(), getTaskStatus(childTask.getId())); + + // 调用:用户 1 审批父任务,激活子任务 + bpmTaskService.approveTask(1L, new BpmTaskApproveReqVO().setId(task1.getId()).setReason("同意")); + // 断言:父任务处于审批通过中,子任务分配给用户 2 + assertEquals(BpmTaskStatusEnum.APPROVING.getStatus(), getTaskStatus(task1.getId())); + assertEquals("2", bpmTaskService.getTask(childTask.getId()).getAssignee()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), getTaskStatus(childTask.getId())); + + // 调用:用户 2 审批子任务 + bpmTaskService.approveTask(2L, new BpmTaskApproveReqVO().setId(childTask.getId()).setReason("同意")); + // 断言:父任务自动完成,流程流转到 task2 + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.APPROVE.getStatus(), "同意"); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testCreateSignTask_typeError() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.AFTER.getType()).setUserIds(Set.of(2L)).setReason("加签")); + + // 调用,并断言异常 + assertServiceException(() -> bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.BEFORE.getType()).setUserIds(Set.of(3L)).setReason("加签")), + TASK_SIGN_CREATE_TYPE_ERROR, "向后加签", "向前加签"); + } + + @Test + public void testCreateSignTask_userRepeat() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用,并断言异常:用户 1 已经是当前节点的审批人 + assertServiceException(() -> bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.BEFORE.getType()).setUserIds(Set.of(1L)).setReason("加签")), + TASK_SIGN_CREATE_USER_REPEAT, "用户1"); + } + + @Test + public void testDeleteSignTask_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.BEFORE.getType()).setUserIds(Set.of(2L)).setReason("加签")); + Task childTask = bpmTaskService.getTaskListByParentTaskId(task1.getId()).get(0); + + // 调用 + bpmTaskService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId(childTask.getId()).setReason("减签")); + // 断言:子任务被取消删除,父任务恢复给用户 1 审批 + assertNull(bpmTaskService.getTask(childTask.getId())); + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(childTask.getId())); + Task parentTask = bpmTaskService.getTask(task1.getId()); + assertEquals("1", parentTask.getAssignee()); + assertNull(parentTask.getScopeType()); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.SUB_SIGN), eq("用户1"), eq("用户2")); + } + + @Test + public void testDeleteSignTask_partial() { + // 准备参数:task1 向前加签给用户 2、用户 3 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.BEFORE.getType()).setUserIds(Set.of(2L, 3L)).setReason("加签")); + Map childTasks = bpmTaskService.getTaskListByParentTaskId(task1.getId()).stream() + .collect(Collectors.toMap(Task::getAssignee, Function.identity())); + + // 调用:减签用户 2 + bpmTaskService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId(childTasks.get("2").getId()) + .setReason("减签")); + // 断言:用户 3 的加签任务仍在,父任务继续等待 + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(childTasks.get("2").getId())); + assertEquals(List.of(childTasks.get("3").getId()), bpmTaskService.getTaskListByParentTaskId(task1.getId()) + .stream().map(Task::getId).toList()); + Task parentTask = bpmTaskService.getTask(task1.getId()); + assertNull(parentTask.getAssignee()); + assertEquals(BpmTaskSignTypeEnum.BEFORE.getType(), parentTask.getScopeType()); + + // 调用 + 断言:用户 3 审批通过后,父任务恢复给用户 1 审批 + bpmTaskService.approveTask(3L, new BpmTaskApproveReqVO().setId(childTasks.get("3").getId()).setReason("同意")); + assertEquals("1", bpmTaskService.getTask(task1.getId()).getAssignee()); + } + + @Test + public void testDeleteSignTask_afterSignNotApproved() { + // 准备参数:task1 向后加签给用户 2,但 task1 尚未审批 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.AFTER.getType()).setUserIds(Set.of(2L)).setReason("加签")); + Task childTask = bpmTaskService.getTaskListByParentTaskId(task1.getId()).get(0); + + // 调用 + bpmTaskService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId(childTask.getId()).setReason("减签")); + // 断言:父任务不会因为加签任务被减签而自动完成,仍由用户 1 审批 + Task parentTask = getRunningTask(processInstanceId); + assertEquals(task1.getId(), parentTask.getId()); + assertEquals("1", parentTask.getAssignee()); + assertNull(parentTask.getScopeType()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), getTaskStatus(task1.getId())); + } + + @Test + public void testDeleteSignTask_noParent() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用,并断言异常 + assertServiceException(() -> bpmTaskService.deleteSignTask(1L, + new BpmTaskSignDeleteReqVO().setId(task1.getId()).setReason("减签")), TASK_SIGN_DELETE_NO_PARENT); + } + + @Test + public void testWithdrawTask_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + approveRunningTask(processInstanceId); + Task task2 = getRunningTask(processInstanceId); + + // 调用 + bpmTaskService.withdrawTask(1L, task1.getId()); + // 断言:task2 被取消,流程回到 task1 + assertTaskStatus(task2.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), "前一任务撤回,系统自动取消"); + verify(commentService).createComment(eq(task2.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.CANCEL), eq("前一节点撤回")); + assertEquals("task1", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testWithdrawTask_taskNotExists() { + // 准备参数:task1 尚未审批 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用,并断言异常 + assertServiceException(() -> bpmTaskService.withdrawTask(1L, task1.getId()), TASK_WITHDRAW_FAIL_TASK_NOT_EXISTS); + } + + @Test + public void testWithdrawTask_nextTaskApproved() { + // 准备参数:task1、task2 均已审批,流程已经结束 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + approveRunningTask(processInstanceId); + approveRunningTask(processInstanceId); + + // 调用,并断言异常 + assertServiceException(() -> bpmTaskService.withdrawTask(1L, task1.getId()), + TASK_WITHDRAW_FAIL_PROCESS_NOT_RUNNING); + } + + @Test + public void testWithdrawTask_notAllow() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + approveRunningTask(processInstanceId); + when(processDefinitionService.getProcessDefinitionInfo(anyString())) + .thenReturn(new BpmProcessDefinitionInfoDO().setAllowWithdrawTask(false)); + + // 调用,并断言异常 + assertServiceException(() -> bpmTaskService.withdrawTask(1L, task1.getId()), TASK_WITHDRAW_FAIL_NOT_ALLOW); + } + + @Test + public void testMoveTaskToEnd_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + bpmTaskService.moveTaskToEnd(processInstanceId, "结束流程"); + // 断言:运行中的任务被取消,流程实例结束 + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), "系统自动取消"); + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + } + + @Test + public void testMoveTaskToEnd_withSignTask() { + // 准备参数:task1 向前加签给用户 2,子任务未审批 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + bpmTaskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId(task1.getId()) + .setType(BpmTaskSignTypeEnum.BEFORE.getType()).setUserIds(Set.of(2L)).setReason("加签")); + Task childTask = bpmTaskService.getTaskListByParentTaskId(task1.getId()).get(0); + + // 调用 + bpmTaskService.moveTaskToEnd(processInstanceId, "结束流程"); + // 断言:父任务、子任务都被取消,流程实例结束 + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), "系统自动取消"); + assertTaskStatus(childTask.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), "系统自动取消"); + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertNull(bpmTaskService.getTask(childTask.getId())); + } + + @Test + public void testProcessTaskTimeout_approve() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用:task1 超时自动通过 + bpmTaskService.processTaskTimeout(processInstanceId, "task1", + BpmUserTaskTimeoutHandlerTypeEnum.APPROVE.getType()); + // 断言 + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.APPROVE.getStatus(), BpmReasonEnum.TIMEOUT_APPROVE.getReason()); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testProcessTaskTimeout_reject() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用:task1 超时自动拒绝 + bpmTaskService.processTaskTimeout(processInstanceId, "task1", + BpmUserTaskTimeoutHandlerTypeEnum.REJECT.getType()); + // 断言:任务不通过,流程实例不通过并结束 + assertEquals(BpmTaskStatusEnum.REJECT.getStatus(), getTaskStatus(task1.getId())); + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + } + + // ========== 流程实例相关 ========== + + @Test + public void testCreateProcessInstance_withTitle() { + // 准备参数:开启标题设置 + BpmModelMetaInfoVO.TitleSetting titleSetting = new BpmModelMetaInfoVO.TitleSetting().setEnable(true) + .setTitle("{" + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_ID + "}的" + + "{" + BpmnVariableConstants.PROCESS_DEFINITION_NAME + "},请假{day}天"); + doAnswer(invocation -> new BpmProcessDefinitionInfoDO().setProcessDefinitionId(invocation.getArgument(0)) + .setModelType(BpmModelTypeEnum.BPMN.getType()).setTitleSetting(titleSetting)) + .when(processDefinitionService).getProcessDefinitionInfo(anyString()); + String processDefinitionId = repositoryService.createProcessDefinitionQuery() + .deploymentId(deployment.getId()).singleResult().getId(); + + // 调用:模拟 FlowableWebFilter 设置发起人 + String processInstanceId = FlowableUtils.executeAuthenticatedUserId(1L, + () -> processInstanceService.createProcessInstance(1L, new BpmProcessInstanceCreateReqVO() + .setProcessDefinitionId(processDefinitionId).setVariables(new HashMap<>(Map.of("day", 3))))); + // 断言 + ProcessInstance processInstance = processInstanceService.getProcessInstance(processInstanceId); + assertEquals("用户1的两级审批,请假3天", processInstance.getName()); + assertEquals(3, runtimeService.getVariable(processInstanceId, "day")); + assertEquals(1L, runtimeService.getVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_ID)); + assertEquals(BpmProcessInstanceStatusEnum.RUNNING.getStatus(), runtimeService.getVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + assertEquals("task1", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testCancelProcessInstanceByStartUser_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + processInstanceService.cancelProcessInstanceByStartUser(1L, new BpmProcessInstanceCancelReqVO() + .setId(processInstanceId).setReason("不想请了")); + // 断言:流程实例取消并结束,运行中的任务被取消 + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.CANCEL.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + assertEquals(BpmReasonEnum.CANCEL_PROCESS_INSTANCE_BY_START_USER.format("不想请了"), + getHistoricProcessVariable(processInstanceId, BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_REASON)); + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), BpmReasonEnum.CANCEL_BY_SYSTEM.getReason()); + } + + @Test + public void testCancelProcessInstanceByStartUser_notSelf() { + // 准备参数 + String processInstanceId = startProcessInstance(); + + // 调用,并断言异常 + assertServiceException(() -> processInstanceService.cancelProcessInstanceByStartUser(2L, + new BpmProcessInstanceCancelReqVO().setId(processInstanceId).setReason("取消")), + PROCESS_INSTANCE_CANCEL_FAIL_NOT_SELF); + assertNotNull(processInstanceService.getProcessInstance(processInstanceId)); + } + + @Test + public void testCancelProcessInstanceByAdmin_success() { + // 准备参数:task1 已审批,流程处于 task2 + String processInstanceId = startProcessInstance(); + approveRunningTask(processInstanceId); + + // 调用 + processInstanceService.cancelProcessInstanceByAdmin(100L, new BpmProcessInstanceCancelReqVO() + .setId(processInstanceId).setReason("流程配置错误")); + // 断言 + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.CANCEL.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + assertEquals(BpmReasonEnum.CANCEL_PROCESS_INSTANCE_BY_ADMIN.format("用户100", "流程配置错误"), + getHistoricProcessVariable(processInstanceId, BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_REASON)); + } + + @Test + public void testUpdateAndRemoveProcessInstanceVariables_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + + // 调用 + 断言:更新变量 + processInstanceService.updateProcessInstanceVariables(processInstanceId, Map.of("day", 5)); + assertEquals(5, runtimeService.getVariable(processInstanceId, "day")); + // 调用 + 断言:删除变量 + processInstanceService.removeProcessInstanceVariables(processInstanceId, Set.of("day")); + assertFalse(runtimeService.hasVariable(processInstanceId, "day")); + } + + @Test + public void testGetTodoTask_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用:不传 taskId 时,按流程实例查询首个待办 + BpmTaskRespVO todoTask = bpmTaskService.getTodoTask(1L, null, processInstanceId); + // 断言 + assertEquals(task1.getId(), todoTask.getId()); + assertEquals("一级审批", todoTask.getName()); + assertFalse(todoTask.getSignEnable()); + assertFalse(todoTask.getReasonRequire()); + // 断言:其他用户没有待办 + assertNull(bpmTaskService.getTodoTask(2L, task1.getId(), processInstanceId)); + } + + @Test + public void testGetTaskTodoPageAndDonePage_success() { + // 准备参数 + String processInstanceId = startProcessInstance(); + approveRunningTask(processInstanceId); + BpmTaskPageReqVO pageReqVO = new BpmTaskPageReqVO(); + pageReqVO.setName("审批"); + pageReqVO.setProcessDefinitionKey("twoStepApprove"); + + // 调用 + 断言:待办为 task2 + List todoTasks = bpmTaskService.getTaskTodoPage(1L, pageReqVO).getList(); + assertEquals(List.of("task2"), todoTasks.stream().map(Task::getTaskDefinitionKey).toList()); + // 调用 + 断言:已办为 task1 + pageReqVO.setStatus(BpmTaskStatusEnum.APPROVE.getStatus()); + List doneTasks = bpmTaskService.getTaskDonePage(1L, pageReqVO).getList(); + assertEquals(List.of("task1"), doneTasks.stream().map(HistoricTaskInstance::getTaskDefinitionKey).toList()); + // 调用 + 断言:其它用户没有待办 + assertEquals(0, bpmTaskService.getTaskTodoPage(2L, new BpmTaskPageReqVO()).getTotal()); + } + + // ========== 状态回调相关(依赖 BpmTaskEventListener、BpmProcessInstanceEventListener) ========== + + @Test + public void testProcessCallback_approve() { + // 调用:发起流程 + String processInstanceId = startProcessInstance(); + // 断言:任务创建回调将 task1 置为审批中,任务分配回调发送待办消息 + Task task1 = getRunningTask(processInstanceId); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), getTaskStatus(task1.getId())); + verify(messageService).sendMessageWhenTaskAssigned(argThat(reqDTO -> + task1.getId().equals(reqDTO.getTaskId()) && reqDTO.getAssigneeUserId().equals(1L) + && reqDTO.getStartUserId().equals(1L) && "用户1".equals(reqDTO.getStartUserNickname()))); + + // 调用:task1、task2 依次审批通过 + approveRunningTask(processInstanceId); + approveRunningTask(processInstanceId); + // 断言:流程完成回调将状态更新为审批通过,并发送消息、状态事件 + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + verify(messageService, times(2)).sendMessageWhenTaskAssigned(any()); + verify(messageService).sendMessageWhenProcessInstanceApprove(argThat(reqDTO -> + processInstanceId.equals(reqDTO.getProcessInstanceId()) && reqDTO.getStartUserId().equals(1L))); + BpmProcessInstanceStatusEvent event = getProcessInstanceStatusEvent(processInstanceId); + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), event.getStatus()); + assertEquals("twoStepApprove", event.getProcessDefinitionKey()); + assertEquals("business-1", event.getBusinessKey()); + } + + @Test + public void testProcessCallback_reject() { + // 准备参数 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + bpmTaskService.rejectTask(1L, new BpmTaskRejectReqVO().setId(task1.getId()).setReason("不同意")); + // 断言:流程完成回调发送不通过消息、状态事件,且不会误发通过消息 + verify(messageService).sendMessageWhenProcessInstanceReject(argThat(reqDTO -> + processInstanceId.equals(reqDTO.getProcessInstanceId()) && "审批不通过任务,原因:不同意".equals(reqDTO.getReason()))); + verify(messageService, never()).sendMessageWhenProcessInstanceApprove(any()); + BpmProcessInstanceStatusEvent event = getProcessInstanceStatusEvent(processInstanceId); + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), event.getStatus()); + assertEquals("审批不通过任务,原因:不同意", event.getReason()); + } + + @Test + public void testProcessCallback_cancel() { + // 准备参数 + String processInstanceId = startProcessInstance(); + + // 调用 + processInstanceService.cancelProcessInstanceByStartUser(1L, new BpmProcessInstanceCancelReqVO() + .setId(processInstanceId).setReason("不想请了")); + // 断言:取消时只发送状态事件,不发送通过、不通过消息 + BpmProcessInstanceStatusEvent event = getProcessInstanceStatusEvent(processInstanceId); + assertEquals(BpmProcessInstanceStatusEnum.CANCEL.getStatus(), event.getStatus()); + assertEquals(BpmReasonEnum.CANCEL_PROCESS_INSTANCE_BY_START_USER.format("不想请了"), event.getReason()); + verify(messageService, never()).sendMessageWhenProcessInstanceApprove(any()); + verify(messageService, never()).sendMessageWhenProcessInstanceReject(any()); + } + + // ========== 自动审批相关(依赖 getSelf() 获取 Spring 代理) ========== + + @Test + public void testProcessTaskCreated_autoApprove() { + // 准备参数:task1 配置为【自动通过】 + mockUserTaskExtensionElement("task1", BpmnModelConstants.USER_TASK_APPROVE_TYPE, + BpmUserTaskApproveTypeEnum.AUTO_APPROVE.getType()); + mockTaskWithoutCandidate("task1"); + + // 调用 + String processInstanceId = startProcessInstance(); + // 断言:task1 在事务结束后自动通过,流程流转到 task2 + HistoricTaskInstance task1 = getHistoricTask(processInstanceId, "task1"); + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.APPROVE.getStatus(), + BpmReasonEnum.APPROVE_TYPE_AUTO_APPROVE.getReason()); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testProcessTaskCreated_autoReject() { + // 准备参数:task1 配置为【自动拒绝】 + mockUserTaskExtensionElement("task1", BpmnModelConstants.USER_TASK_APPROVE_TYPE, + BpmUserTaskApproveTypeEnum.AUTO_REJECT.getType()); + mockTaskWithoutCandidate("task1"); + + // 调用 + String processInstanceId = startProcessInstance(); + // 断言:task1 自动拒绝,流程实例不通过并结束 + HistoricTaskInstance task1 = getHistoricTask(processInstanceId, "task1"); + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.REJECT.getStatus(), + BpmReasonEnum.APPROVE_TYPE_AUTO_REJECT.getReason()); + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + } + + @Test + public void testProcessTaskAssigned_autoApproveAll() { + // 准备参数:流程配置【仅审批一次,后续重复的审批节点均自动通过】 + mockAutoApprovalType(BpmAutoApproveTypeEnum.APPROVE_ALL); + String processInstanceId = startProcessInstance(); + + // 调用:用户 1 审批 task1 + approveRunningTask(processInstanceId); + // 断言:task2 仍由用户 1 审批,自动通过,流程结束 + HistoricTaskInstance task2 = getHistoricTask(processInstanceId, "task2"); + assertTaskStatus(task2.getId(), BpmTaskStatusEnum.APPROVE.getStatus(), BpmAutoApproveTypeEnum.APPROVE_ALL.getName()); + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + } + + @Test + public void testProcessTaskAssigned_autoApproveSequent() { + // 准备参数:流程配置【仅针对连续审批的节点自动通过】 + mockAutoApprovalType(BpmAutoApproveTypeEnum.APPROVE_SEQUENT); + String processInstanceId = startProcessInstance(); + + // 调用:用户 1 审批 task1 + approveRunningTask(processInstanceId); + // 断言:task2 的上一节点 task1 也由用户 1 审批,自动通过 + HistoricTaskInstance task2 = getHistoricTask(processInstanceId, "task2"); + assertTaskStatus(task2.getId(), BpmTaskStatusEnum.APPROVE.getStatus(), + BpmAutoApproveTypeEnum.APPROVE_SEQUENT.getName()); + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + } + + // ========== 定时器超时相关(依赖 BpmTaskEventListener#timerFired) ========== + + @Test + public void testTimerFired_userTaskTimeoutApprove() { + // 准备参数 + deploy("bpmn/timeout-approve.bpmn20.xml"); + String processInstanceId = startProcessInstance("timeoutApprove"); + Task task1 = getRunningTask(processInstanceId); + + // 调用:触发 task1 的超时定时器 + executeTimerJob(processInstanceId); + // 断言:task1 超时自动通过,流程结束 + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.APPROVE.getStatus(), BpmReasonEnum.TIMEOUT_APPROVE.getReason()); + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + } + + @Test + public void testTimerFired_userTaskTimeoutReminder() { + // 准备参数:超时处理方式改为【自动提醒】 + deploy("bpmn/timeout-approve.bpmn20.xml"); + mockBoundaryEventTimeoutHandlerType("task1-timeout", BpmUserTaskTimeoutHandlerTypeEnum.REMINDER); + String processInstanceId = startProcessInstance("timeoutApprove"); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + executeTimerJob(processInstanceId); + // 断言:发送超时提醒,任务保持审批中 + verify(messageService).sendMessageWhenTaskTimeout(argThat(reqDTO -> + task1.getId().equals(reqDTO.getTaskId()) && reqDTO.getAssigneeUserId().equals(1L) + && processInstanceId.equals(reqDTO.getProcessInstanceId()))); + assertEquals(task1.getId(), getRunningTask(processInstanceId).getId()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), getTaskStatus(task1.getId())); + } + + @Test + public void testTimerFired_userTaskTimeoutReject() { + // 准备参数:超时处理方式改为【自动拒绝】 + deploy("bpmn/timeout-approve.bpmn20.xml"); + mockBoundaryEventTimeoutHandlerType("task1-timeout", BpmUserTaskTimeoutHandlerTypeEnum.REJECT); + String processInstanceId = startProcessInstance("timeoutApprove"); + Task task1 = getRunningTask(processInstanceId); + + // 调用 + executeTimerJob(processInstanceId); + // 断言:task1 超时自动拒绝,流程实例不通过并结束 + assertTaskStatus(task1.getId(), BpmTaskStatusEnum.REJECT.getStatus(), BpmReasonEnum.REJECT_TASK.getReason()); + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + } + + @Test + public void testTimerFired_delayTimer() { + // 准备参数 + deploy("bpmn/delay-timer.bpmn20.xml"); + String processInstanceId = startProcessInstance("delayTimer"); + assertNotNull(runtimeService.createExecutionQuery().processInstanceId(processInstanceId) + .activityId("wait").singleResult()); + + // 调用:延迟器到期 + executeTimerJob(processInstanceId); + // 断言:receiveTask 被触发,流程结束 + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), getHistoricProcessVariable(processInstanceId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + } + + // ========== 子流程相关 ========== + + @Test + public void testTimerFired_childProcessTimeout() { + // 准备参数:主流程发起 twoStepApprove 子流程 + deploy("bpmn/call-activity.bpmn20.xml"); + String parentId = startProcessInstance("callActivityParent"); + ProcessInstance child = getChildProcessInstance(parentId); + Task childTask = getRunningTask(child.getId()); + + // 调用:触发子流程的超时定时器 + executeTimerJob(parentId); + // 断言:子流程被结束、任务被取消,主流程继续流转并结束 + assertTaskStatus(childTask.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), BpmReasonEnum.CANCEL_BY_SYSTEM.getReason()); + assertNull(processInstanceService.getProcessInstance(child.getId())); + assertNull(processInstanceService.getProcessInstance(parentId)); + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), getHistoricProcessVariable(parentId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + } + + @Test + public void testRejectTask_childProcessRejectParent() { + // 准备参数 + deploy("bpmn/call-activity.bpmn20.xml"); + String parentId = startProcessInstance("callActivityParent"); + ProcessInstance child = getChildProcessInstance(parentId); + Task childTask = getRunningTask(child.getId()); + + // 调用:子流程审批不通过 + bpmTaskService.rejectTask(1L, new BpmTaskRejectReqVO().setId(childTask.getId()).setReason("不同意")); + // 断言:子流程不通过时,主流程同样标记为不通过并结束 + assertNull(processInstanceService.getProcessInstance(child.getId())); + assertNull(processInstanceService.getProcessInstance(parentId)); + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), getHistoricProcessVariable(parentId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + assertEquals(BpmReasonEnum.REJECT_TASK.format(BpmReasonEnum.REJECT_CHILD_PROCESS.getReason()), + getHistoricProcessVariable(parentId, BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_REASON)); + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), getProcessInstanceStatusEvent(parentId).getStatus()); + } + + @Test + public void testCancelProcessInstanceByStartUser_cancelChildProcess() { + // 准备参数 + deploy("bpmn/call-activity.bpmn20.xml"); + String parentId = startProcessInstance("callActivityParent"); + ProcessInstance child = getChildProcessInstance(parentId); + Task childTask = getRunningTask(child.getId()); + + // 调用:取消主流程 + processInstanceService.cancelProcessInstanceByStartUser(1L, new BpmProcessInstanceCancelReqVO() + .setId(parentId).setReason("不想请了")); + // 断言:主流程、子流程都被取消 + assertNull(processInstanceService.getProcessInstance(parentId)); + assertNull(processInstanceService.getProcessInstance(child.getId())); + assertEquals(BpmProcessInstanceStatusEnum.CANCEL.getStatus(), getHistoricProcessVariable(parentId, + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + assertEquals(BpmProcessInstanceStatusEnum.CANCEL.getStatus(), getHistoricProcessVariable(child.getId(), + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + assertEquals(BpmReasonEnum.CANCEL_CHILD_PROCESS_INSTANCE_BY_MAIN_PROCESS.getReason(), + getHistoricProcessVariable(child.getId(), BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_REASON)); + assertTaskStatus(childTask.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), BpmReasonEnum.CANCEL_BY_SYSTEM.getReason()); + } + + @Test + public void testCancelProcessInstanceByStartUser_childNotAllow() { + // 准备参数 + deploy("bpmn/call-activity.bpmn20.xml"); + String parentId = startProcessInstance("callActivityParent"); + ProcessInstance child = getChildProcessInstance(parentId); + + // 调用,并断言异常:子流程不允许单独取消 + assertServiceException(() -> processInstanceService.cancelProcessInstanceByStartUser(1L, + new BpmProcessInstanceCancelReqVO().setId(child.getId()).setReason("取消")), + PROCESS_INSTANCE_CANCEL_CHILD_FAIL_NOT_ALLOW); + } + + // ========== 私有方法 ========== + + private String startProcessInstance() { + return startProcessInstance("twoStepApprove"); + } + + private String startProcessInstance(String processDefinitionKey) { + return processInstanceService.createProcessInstance(1L, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey(processDefinitionKey).setBusinessKey("business-1")); + } + + private void deploy(String resource) { + extraDeploymentIds.add(repositoryService.createDeployment().addClasspathResource(resource).deploy().getId()); + } + + private ProcessInstance getChildProcessInstance(String parentId) { + ProcessInstance child = runtimeService.createProcessInstanceQuery().superProcessInstanceId(parentId).singleResult(); + assertNotNull(child); + return child; + } + + private HistoricTaskInstance getHistoricTask(String processInstanceId, String taskDefinitionKey) { + return historyService.createHistoricTaskInstanceQuery().processInstanceId(processInstanceId) + .taskDefinitionKey(taskDefinitionKey).singleResult(); + } + + /** + * 将流程实例上唯一的定时器转为可执行任务并立即执行,模拟定时器到期 + */ + private void executeTimerJob(String processInstanceId) { + Job timerJob = managementService.createTimerJobQuery().processInstanceId(processInstanceId).singleResult(); + assertNotNull(timerJob); + Job job = managementService.moveTimerToExecutableJob(timerJob.getId()); + managementService.executeJob(job.getId()); + } + + private BpmProcessInstanceStatusEvent getProcessInstanceStatusEvent(String processInstanceId) { + List events = applicationEvents.stream(BpmProcessInstanceStatusEvent.class) + .filter(event -> processInstanceId.equals(event.getId())).toList(); + assertEquals(1, events.size()); + return events.get(0); + } + + private void mockUserTaskExtensionElement(String taskDefinitionKey, String name, Object value) { + doAnswer(invocation -> { + BpmnModel bpmnModel = repositoryService.getBpmnModel(invocation.getArgument(0)); + FlowElement element = bpmnModel.getFlowElement(taskDefinitionKey); + if (element != null && !element.getExtensionElements().containsKey(name)) { + BpmnModelUtils.addExtensionElement(element, name, String.valueOf(value)); + } + return bpmnModel; + }).when(modelService).getBpmnModelByDefinitionId(anyString()); + } + + private void mockBoundaryEventTimeoutHandlerType(String boundaryEventId, BpmUserTaskTimeoutHandlerTypeEnum handlerType) { + doAnswer(invocation -> { + BpmnModel bpmnModel = repositoryService.getBpmnModel(invocation.getArgument(0)); + FlowElement element = bpmnModel.getFlowElement(boundaryEventId); + if (element != null) { + element.getExtensionElements().get(BpmnModelConstants.USER_TASK_TIMEOUT_HANDLER_TYPE).get(0) + .setElementText(String.valueOf(handlerType.getType())); + } + return bpmnModel; + }).when(modelService).getBpmnModelByDefinitionId(anyString()); + } + + /** + * 与 {@link cn.iocoder.yudao.module.bpm.framework.flowable.core.candidate.BpmTaskCandidateInvoker#calculateUsersByTask} 一致:非人工审核(自动通过、不通过)的节点不计算审批人 + */ + private void mockTaskWithoutCandidate(String taskDefinitionKey) { + when(taskCandidateInvoker.calculateUsersByTask(any())).thenAnswer(invocation -> + taskDefinitionKey.equals(((DelegateExecution) invocation.getArgument(0)).getCurrentActivityId()) + ? Set.of() : Set.of(1L)); + } + + private void mockAutoApprovalType(BpmAutoApproveTypeEnum autoApproveType) { + doAnswer(invocation -> new BpmProcessDefinitionInfoDO().setProcessDefinitionId(invocation.getArgument(0)) + .setModelType(BpmModelTypeEnum.BPMN.getType()).setAutoApprovalType(autoApproveType.getType())) + .when(processDefinitionService).getProcessDefinitionInfo(anyString()); + } + + private Task getRunningTask(String processInstanceId) { + List tasks = bpmTaskService.getRunningTaskListByProcessInstanceId(processInstanceId, null, null); + assertEquals(1, tasks.size()); + return tasks.get(0); + } + + private void approveRunningTask(String processInstanceId) { + Task task = getRunningTask(processInstanceId); + bpmTaskService.approveTask(1L, new BpmTaskApproveReqVO().setId(task.getId()).setReason("同意")); + } + + private Integer getTaskStatus(String taskId) { + return (Integer) bpmTaskService.getHistoricTask(taskId).getTaskLocalVariables() + .get(BpmnVariableConstants.TASK_VARIABLE_STATUS); + } + + private void assertTaskStatus(String taskId, Integer status, String reason) { + HistoricTaskInstance task = bpmTaskService.getHistoricTask(taskId); + assertEquals(status, task.getTaskLocalVariables().get(BpmnVariableConstants.TASK_VARIABLE_STATUS)); + assertEquals(reason, task.getTaskLocalVariables().get(BpmnVariableConstants.TASK_VARIABLE_REASON)); + } + + private Object getHistoricProcessVariable(String processInstanceId, String name) { + return processInstanceService.getHistoricProcessInstance(processInstanceId).getProcessVariables().get(name); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmTaskSignFlowableTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmTaskSignFlowableTest.java new file mode 100644 index 0000000..27f8a1a --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmTaskSignFlowableTest.java @@ -0,0 +1,435 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable; + +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.*; +import cn.iocoder.yudao.module.bpm.enums.task.BpmCommentTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskSignTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnVariableConstants; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceServiceImpl; +import cn.iocoder.yudao.module.bpm.service.task.BpmTaskServiceImpl; +import jakarta.annotation.Resource; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.TaskService; +import org.flowable.engine.repository.Deployment; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link BpmTaskServiceImpl} 加签、减签的 Flowable 集成测试 + * + *

覆盖向前/向后加签、多层加签、加签任务审批不通过、部分减签、递归减签,以及等待中(WAIT)任务、减签的权限校验

+ * + * @author HUIHUI + */ +public class BpmTaskSignFlowableTest extends BaseFlowableUnitTest { + + @Resource + private RepositoryService repositoryService; + @Resource + private TaskService flowableTaskService; + + @Resource + private BpmTaskServiceImpl bpmTaskService; + @Resource + private BpmProcessInstanceServiceImpl processInstanceService; + + private Deployment deployment; + + @BeforeEach + public void setUp() { + // 部署两级审批流程:task1 -> task2;审批人统一由测试上下文计算为用户 1 + deployment = repositoryService.createDeployment() + .addClasspathResource("bpmn/two-step-approve.bpmn20.xml") + .deploy(); + mockFlowableBackedServices(repositoryService); + } + + @AfterEach + public void tearDown() { + // 清理测试数据:加签子任务没有 execution,需要先删除,否则 Flowable 级联删除流程实例时会空指针 + List signTaskIds = flowableTaskService.createTaskQuery().deploymentId(deployment.getId()).list() + .stream().filter(task -> task.getExecutionId() == null).map(Task::getId).toList(); + flowableTaskService.deleteTasks(signTaskIds); + repositoryService.deleteDeployment(deployment.getId(), true); + } + + // ========== 加签 ========== + + @Test + public void testCreateSignTask_beforeMultiLevel_thenApprove() { + // 准备参数:task1 向前加签给用户 2,用户 2 再向前加签给用户 3 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Task childTask = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.BEFORE, 2L).get(0); + Task grandchildTask = createSignTask(2L, childTask.getId(), BpmTaskSignTypeEnum.BEFORE, 3L).get(0); + // 断言:task1、子任务都处于等待中,只有孙任务可以审批 + assertWaitTask(task1.getId(), "1", BpmTaskSignTypeEnum.BEFORE); + assertWaitTask(childTask.getId(), "2", BpmTaskSignTypeEnum.BEFORE); + assertRunningTask(grandchildTask.getId(), "3"); + + // 调用:用户 3 审批孙任务 + approveTask(3L, grandchildTask.getId()); + // 断言:子任务恢复给用户 2 审批,task1 继续等待 + assertRunningTask(childTask.getId(), "2"); + assertNull(bpmTaskService.getTask(childTask.getId()).getScopeType()); + assertWaitTask(task1.getId(), "1", BpmTaskSignTypeEnum.BEFORE); + + // 调用:用户 2 审批子任务 + approveTask(2L, childTask.getId()); + // 断言:task1 恢复给用户 1 审批 + assertRunningTask(task1.getId(), "1"); + assertNull(bpmTaskService.getTask(task1.getId()).getScopeType()); + + // 调用:用户 1 审批 task1 + approveTask(1L, task1.getId()); + // 断言:流程流转到 task2 + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testCreateSignTask_afterMultiLevel_thenApprove() { + // 准备参数:task1 向后加签给用户 2,用户 1 审批后,用户 2 再向后加签给用户 3 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Task childTask = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.AFTER, 2L).get(0); + assertWaitTask(childTask.getId(), "2", null); + approveTask(1L, task1.getId()); + assertEquals(BpmTaskStatusEnum.APPROVING.getStatus(), getTaskStatus(task1.getId())); + assertRunningTask(childTask.getId(), "2"); + Task grandchildTask = createSignTask(2L, childTask.getId(), BpmTaskSignTypeEnum.AFTER, 3L).get(0); + assertWaitTask(grandchildTask.getId(), "3", null); + + // 调用:用户 2 审批子任务 + approveTask(2L, childTask.getId()); + // 断言:子任务处于审批通过中,孙任务恢复给用户 3 审批 + assertEquals(BpmTaskStatusEnum.APPROVING.getStatus(), getTaskStatus(childTask.getId())); + assertRunningTask(grandchildTask.getId(), "3"); + + // 调用:用户 3 审批孙任务 + approveTask(3L, grandchildTask.getId()); + // 断言:子任务、task1 依次递归完成,流程流转到 task2 + assertEquals(BpmTaskStatusEnum.APPROVE.getStatus(), getTaskStatus(childTask.getId())); + assertNull(bpmTaskService.getTask(childTask.getId())); + assertEquals(BpmTaskStatusEnum.APPROVE.getStatus(), getTaskStatus(task1.getId())); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + // ========== 等待中(WAIT)任务的权限 ========== + + @Test + public void testApproveTask_beforeSignParentWait() { + // 准备参数:task1 向前加签给用户 2,task1 只有 owner 没有 assignee + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Task childTask = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.BEFORE, 2L).get(0); + + // 调用,并断言异常:任何人(包括 owner、无 userId 的系统调用)都不能直接操作等待中的 task1 + assertServiceException(() -> approveTask(1L, task1.getId()), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> approveTask(3L, task1.getId()), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> approveTask(null, task1.getId()), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> bpmTaskService.rejectTask(3L, new BpmTaskRejectReqVO() + .setId(task1.getId()).setReason("不同意")), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> bpmTaskService.transferTask(3L, new BpmTaskTransferReqVO() + .setId(task1.getId()).setAssigneeUserId(3L).setReason("转办")), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.BEFORE, 3L), + TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + // 断言:task1 仍等待子任务审批,流程未结束 + assertWaitTask(task1.getId(), "1", BpmTaskSignTypeEnum.BEFORE); + assertNotNull(processInstanceService.getProcessInstance(processInstanceId)); + + // 调用 + 断言:用户 2 审批子任务后,用户 1 可以正常审批 task1 + approveTask(2L, childTask.getId()); + approveTask(1L, task1.getId()); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testApproveTask_afterSignChildWait() { + // 准备参数:task1 向后加签给用户 2,子任务只有 owner 没有 assignee + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Task childTask = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.AFTER, 2L).get(0); + + // 调用,并断言异常:task1 审批前,任何人(包括 owner)都不能直接操作等待中的子任务 + assertServiceException(() -> approveTask(2L, childTask.getId()), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> approveTask(3L, childTask.getId()), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> approveTask(null, childTask.getId()), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> bpmTaskService.rejectTask(3L, new BpmTaskRejectReqVO() + .setId(childTask.getId()).setReason("不同意")), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + // 断言:子任务仍等待,task1 未审批,流程未结束 + assertWaitTask(childTask.getId(), "2", null); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), getTaskStatus(task1.getId())); + assertNotNull(processInstanceService.getProcessInstance(processInstanceId)); + + // 调用 + 断言:用户 1 审批 task1 后,用户 2 可以正常审批子任务 + approveTask(1L, task1.getId()); + approveTask(2L, childTask.getId()); + assertEquals(BpmTaskStatusEnum.APPROVE.getStatus(), getTaskStatus(task1.getId())); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testApproveTask_emptyAssigneeAndOwner_withoutUserId() { + // 准备参数:task1 计算不到审批人,没有 assignee、owner + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(Set.of()); + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + assertNull(task1.getAssignee()); + assertNull(task1.getOwner()); + + // 调用:保持自动审批的历史行为,userId 为 null 时允许审批 + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(Set.of(1L)); + approveTask(null, task1.getId()); + // 断言:流程流转到 task2 + assertEquals(BpmTaskStatusEnum.APPROVE.getStatus(), getTaskStatus(task1.getId())); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + // ========== 审批不通过 ========== + + @Test + public void testRejectTask_afterSignChild() { + // 准备参数:task1 向后加签给用户 2,并且用户 1 已审批 task1 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Task childTask = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.AFTER, 2L).get(0); + approveTask(1L, task1.getId()); + // 断言:审批通过中的 task1,不能再被审批不通过 + assertServiceException(() -> bpmTaskService.rejectTask(1L, new BpmTaskRejectReqVO() + .setId(task1.getId()).setReason("不同意")), TASK_OPERATE_FAIL_APPROVING); + + // 调用:用户 2 审批不通过加签任务 + bpmTaskService.rejectTask(2L, new BpmTaskRejectReqVO().setId(childTask.getId()).setReason("不同意")); + // 断言:加签任务、task1 都标记为不通过,流程实例不通过并结束,没有残留的任务 + assertEquals(BpmTaskStatusEnum.REJECT.getStatus(), getTaskStatus(childTask.getId())); + assertEquals(BpmTaskStatusEnum.REJECT.getStatus(), getTaskStatus(task1.getId())); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.REJECT), eq("加签任务不通过")); + assertProcessInstanceRejected(processInstanceId); + } + + @Test + public void testRejectTask_afterMultiLevelMiddle_afterDeleteSign() { + // 准备参数:task1 向后加签给用户 2、用户 4,用户 1 审批后,用户 2 再向后加签给用户 3 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Map childTasks = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.AFTER, 2L, 4L).stream() + .collect(Collectors.toMap(Task::getOwner, Function.identity())); + approveTask(1L, task1.getId()); + Task childTask = childTasks.get("2"); + Task grandchildTask = createSignTask(2L, childTask.getId(), BpmTaskSignTypeEnum.AFTER, 3L).get(0); + + // 调用:用户 1 减签用户 4 + bpmTaskService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId(childTasks.get("4").getId()) + .setReason("减签")); + // 断言:用户 4 的任务被取消,task1 仍处于审批通过中,等待用户 2 审批 + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(childTasks.get("4").getId())); + assertEquals(BpmTaskStatusEnum.APPROVING.getStatus(), getTaskStatus(task1.getId())); + assertRunningTask(childTask.getId(), "2"); + assertWaitTask(grandchildTask.getId(), "3", null); + + // 调用:用户 2(中间节点)审批不通过 + bpmTaskService.rejectTask(2L, new BpmTaskRejectReqVO().setId(childTask.getId()).setReason("不同意")); + // 断言:中间节点、task1 标记为不通过,等待中的孙任务被取消,流程实例不通过并结束,没有残留的任务 + assertEquals(BpmTaskStatusEnum.REJECT.getStatus(), getTaskStatus(childTask.getId())); + assertEquals(BpmTaskStatusEnum.REJECT.getStatus(), getTaskStatus(task1.getId())); + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(grandchildTask.getId())); + assertProcessInstanceRejected(processInstanceId); + } + + // ========== 减签 ========== + + @Test + public void testDeleteSignTask_afterPartial_thenApprove() { + // 准备参数:task1 向后加签给用户 2、用户 3,并且用户 1 已审批 task1 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Map childTasks = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.AFTER, 2L, 3L).stream() + .collect(Collectors.toMap(Task::getOwner, Function.identity())); + approveTask(1L, task1.getId()); + + // 调用:用户 1 减签用户 2 + bpmTaskService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId(childTasks.get("2").getId()) + .setReason("减签")); + // 断言:用户 2 的任务被取消,task1 仍处于审批通过中,等待用户 3 审批 + assertNull(bpmTaskService.getTask(childTasks.get("2").getId())); + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(childTasks.get("2").getId())); + assertEquals(BpmTaskStatusEnum.APPROVING.getStatus(), getTaskStatus(task1.getId())); + assertEquals(BpmTaskSignTypeEnum.AFTER.getType(), bpmTaskService.getTask(task1.getId()).getScopeType()); + assertRunningTask(childTasks.get("3").getId(), "3"); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.SUB_SIGN), eq("用户1"), eq("用户2")); + + // 调用:用户 3 审批 + approveTask(3L, childTasks.get("3").getId()); + // 断言:task1 审批通过,流程流转到 task2 + assertEquals(BpmTaskStatusEnum.APPROVE.getStatus(), getTaskStatus(task1.getId())); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testDeleteSignTask_afterLast_completeApprovingParent() { + // 准备参数:task1 向后加签给用户 2,并且用户 1 已审批 task1 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Task childTask = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.AFTER, 2L).get(0); + approveTask(1L, task1.getId()); + + // 调用:减签最后一个加签任务 + bpmTaskService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId(childTask.getId()).setReason("减签")); + // 断言:审批通过中的 task1 直接完成,流程流转到 task2 + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(childTask.getId())); + assertEquals(BpmTaskStatusEnum.APPROVE.getStatus(), getTaskStatus(task1.getId())); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testDeleteSignTask_recursive() { + // 准备参数:task1 向前加签给用户 2,用户 2 再向前加签给用户 3 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Task childTask = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.BEFORE, 2L).get(0); + Task grandchildTask = createSignTask(2L, childTask.getId(), BpmTaskSignTypeEnum.BEFORE, 3L).get(0); + + // 调用:用户 1 减签用户 2 + bpmTaskService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId(childTask.getId()).setReason("减签")); + // 断言:子任务、孙任务都被取消删除,task1 恢复给用户 1 审批 + assertNull(bpmTaskService.getTask(childTask.getId())); + assertNull(bpmTaskService.getTask(grandchildTask.getId())); + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(childTask.getId())); + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(grandchildTask.getId())); + assertRunningTask(task1.getId(), "1"); + assertNull(bpmTaskService.getTask(task1.getId()).getScopeType()); + verify(commentService).createComment(eq(task1.getId()), eq(processInstanceId), + eq(BpmCommentTypeEnum.SUB_SIGN), eq("用户1"), eq("用户2")); + + // 调用 + 断言:用户 1 审批,流程流转到 task2 + approveTask(1L, task1.getId()); + assertEquals("task2", getRunningTask(processInstanceId).getTaskDefinitionKey()); + } + + @Test + public void testDeleteSignTask_notSignUser() { + // 准备参数:task1 向前加签给用户 2、用户 3 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Map childTasks = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.BEFORE, 2L, 3L).stream() + .collect(Collectors.toMap(Task::getAssignee, Function.identity())); + String childTaskId = childTasks.get("2").getId(); + + // 调用,并断言异常:其它加签人、被减签人自己、无关用户、无 userId,都不能减签 + for (Long userId : new Long[]{3L, 2L, 99L, null}) { + assertServiceException(() -> bpmTaskService.deleteSignTask(userId, new BpmTaskSignDeleteReqVO() + .setId(childTaskId).setReason("减签")), TASK_SIGN_DELETE_FAIL_NOT_SELF); + } + // 断言:加签任务都还在,task1 继续等待 + assertRunningTask(childTaskId, "2"); + assertEquals(2, bpmTaskService.getTaskListByParentTaskId(task1.getId()).size()); + assertWaitTask(task1.getId(), "1", BpmTaskSignTypeEnum.BEFORE); + } + + @Test + public void testDeleteSignTask_byParentAndAncestorSignUser() { + // 准备参数:task1 向前加签给用户 2,用户 2 再向前加签给用户 3、用户 4 + String processInstanceId = startProcessInstance(); + Task task1 = getRunningTask(processInstanceId); + Task childTask = createSignTask(1L, task1.getId(), BpmTaskSignTypeEnum.BEFORE, 2L).get(0); + Map grandchildTasks = createSignTask(2L, childTask.getId(), BpmTaskSignTypeEnum.BEFORE, 3L, 4L) + .stream().collect(Collectors.toMap(Task::getAssignee, Function.identity())); + + // 调用:用户 2(父任务的加签人)减签用户 3 + bpmTaskService.deleteSignTask(2L, new BpmTaskSignDeleteReqVO().setId(grandchildTasks.get("3").getId()) + .setReason("减签")); + // 断言:用户 3 的任务被取消,子任务继续等待用户 4 + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(grandchildTasks.get("3").getId())); + assertWaitTask(childTask.getId(), "2", BpmTaskSignTypeEnum.BEFORE); + + // 调用:用户 1(祖先任务的加签人)减签用户 4 + bpmTaskService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId(grandchildTasks.get("4").getId()) + .setReason("减签")); + // 断言:子任务恢复给用户 2 审批,task1 继续等待 + assertEquals(BpmTaskStatusEnum.CANCEL.getStatus(), getTaskStatus(grandchildTasks.get("4").getId())); + assertRunningTask(childTask.getId(), "2"); + assertWaitTask(task1.getId(), "1", BpmTaskSignTypeEnum.BEFORE); + } + + // ========== 私有方法 ========== + + private String startProcessInstance() { + return processInstanceService.createProcessInstance(1L, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey("twoStepApprove").setBusinessKey("business-1")); + } + + private Task getRunningTask(String processInstanceId) { + List tasks = bpmTaskService.getRunningTaskListByProcessInstanceId(processInstanceId, null, null); + assertEquals(1, tasks.size()); + return tasks.get(0); + } + + private List createSignTask(Long userId, String taskId, BpmTaskSignTypeEnum type, Long... userIds) { + bpmTaskService.createSignTask(userId, new BpmTaskSignCreateReqVO().setId(taskId) + .setType(type.getType()).setUserIds(Set.of(userIds)).setReason("加签")); + List childTasks = bpmTaskService.getTaskListByParentTaskId(taskId); + assertEquals(userIds.length, childTasks.size()); + return childTasks; + } + + private void approveTask(Long userId, String taskId) { + bpmTaskService.approveTask(userId, new BpmTaskApproveReqVO().setId(taskId).setReason("同意")); + } + + private Integer getTaskStatus(String taskId) { + return (Integer) bpmTaskService.getHistoricTask(taskId).getTaskLocalVariables() + .get(BpmnVariableConstants.TASK_VARIABLE_STATUS); + } + + /** + * 断言流程实例不通过并结束,并且没有残留的任务(包括没有 execution 的加签任务) + */ + private void assertProcessInstanceRejected(String processInstanceId) { + assertNull(processInstanceService.getProcessInstance(processInstanceId)); + assertEquals(BpmProcessInstanceStatusEnum.REJECT.getStatus(), processInstanceService + .getHistoricProcessInstance(processInstanceId).getProcessVariables() + .get(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + assertEquals(0, flowableTaskService.createTaskQuery().deploymentId(deployment.getId()).count()); + } + + /** + * 断言任务处于等待中:只有 owner 没有 assignee,状态为 WAIT + */ + private void assertWaitTask(String taskId, String owner, BpmTaskSignTypeEnum signType) { + Task task = bpmTaskService.getTask(taskId); + assertEquals(owner, task.getOwner()); + assertNull(task.getAssignee()); + assertEquals(signType != null ? signType.getType() : null, task.getScopeType()); + assertEquals(BpmTaskStatusEnum.WAIT.getStatus(), getTaskStatus(taskId)); + } + + /** + * 断言任务可以被 assignee 审批:状态为 RUNNING + */ + private void assertRunningTask(String taskId, String assignee) { + Task task = bpmTaskService.getTask(taskId); + assertEquals(assignee, task.getAssignee()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), getTaskStatus(taskId)); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmUserTaskActivityBehaviorFlowableTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmUserTaskActivityBehaviorFlowableTest.java new file mode 100644 index 0000000..8ce530d --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/BpmUserTaskActivityBehaviorFlowableTest.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable; + +import cn.iocoder.yudao.module.bpm.framework.flowable.core.behavior.BpmUserTaskActivityBehavior; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.FlowableUtils; +import jakarta.annotation.Resource; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.TaskService; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +/** + * {@link BpmUserTaskActivityBehavior} 的 Flowable 集成测试 + * + * @author HUIHUI + */ +public class BpmUserTaskActivityBehaviorFlowableTest extends BaseFlowableUnitTest { + + @Resource + private RepositoryService repositoryService; + @Resource + private RuntimeService runtimeService; + @Resource + private TaskService taskService; + + private String deploymentId; + + @BeforeEach + public void setUp() { + // 简单审批流程:BPMN 中的 assignee 表达式为 ${startUserId} + deploymentId = repositoryService.createDeployment() + .addClasspathResource("bpmn/simple-approve.bpmn20.xml") + .deploy().getId(); + } + + @AfterEach + public void tearDown() { + repositoryService.deleteDeployment(deploymentId, true); + } + + @Test + public void testHandleAssignments_success() { + // 准备参数:发起人为用户 1,审批人计算结果为用户 2 + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(Set.of(2L)); + + // 调用 + String processInstanceId = startProcessInstance(); + // 断言:审批人以 BpmTaskCandidateInvoker 的计算结果为准,而不是 BPMN 中的 assignee 表达式 + Task task = taskService.createTaskQuery().processInstanceId(processInstanceId).singleResult(); + assertEquals("审批", task.getName()); + assertEquals("2", task.getAssignee()); + } + + @Test + public void testHandleAssignments_candidateEmpty() { + // 准备参数:计算不到审批人 + when(taskCandidateInvoker.calculateUsersByTask(any())).thenReturn(Set.of()); + + // 调用 + String processInstanceId = startProcessInstance(); + // 断言:任务仍然创建,但没有审批人(后续由审批人为空的策略处理) + Task task = taskService.createTaskQuery().processInstanceId(processInstanceId).singleResult(); + assertNotNull(task); + assertNull(task.getAssignee()); + } + + private String startProcessInstance() { + // 模拟 FlowableWebFilter 设置发起人,与生产发起流程的上下文一致 + ProcessInstance processInstance = FlowableUtils.executeAuthenticatedUserId(1L, + () -> runtimeService.startProcessInstanceByKey("simpleApprove", "business-1", + Map.of("startUserId", 1L))); + return processInstance.getId(); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/BpmTaskCandidateInvokerTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/BpmTaskCandidateInvokerTest.java index a726377..0b80116 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/BpmTaskCandidateInvokerTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/BpmTaskCandidateInvokerTest.java @@ -49,12 +49,12 @@ public class BpmTaskCandidateInvokerTest extends BaseMockitoUnitTest { @Mock private AdminUserApi adminUserApi; - @Mock private BpmProcessInstanceService processInstanceService; @Spy private BpmTaskCandidateStrategy userStrategy; + @Mock private BpmTaskCandidateAssignEmptyStrategy emptyStrategy; diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/expression/BpmTaskAssignLeaderExpressionTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/expression/BpmTaskAssignLeaderExpressionTest.java index f7eac7c..3546481 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/expression/BpmTaskAssignLeaderExpressionTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/expression/BpmTaskAssignLeaderExpressionTest.java @@ -30,7 +30,6 @@ public class BpmTaskAssignLeaderExpressionTest extends BaseMockitoUnitTest { private AdminUserApi adminUserApi; @Mock private DeptApi deptApi; - @Mock private BpmProcessInstanceService processInstanceService; diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/dept/BpmTaskCandidateStartUserDeptLeaderMultiStrategyTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/dept/BpmTaskCandidateStartUserDeptLeaderMultiStrategyTest.java index 68b1e86..b2b6f6e 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/dept/BpmTaskCandidateStartUserDeptLeaderMultiStrategyTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/dept/BpmTaskCandidateStartUserDeptLeaderMultiStrategyTest.java @@ -33,7 +33,6 @@ public class BpmTaskCandidateStartUserDeptLeaderMultiStrategyTest extends BaseMo @Mock private BpmProcessInstanceService processInstanceService; - @Mock private AdminUserApi adminUserApi; @Mock diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/dept/BpmTaskCandidateStartUserDeptLeaderStrategyTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/dept/BpmTaskCandidateStartUserDeptLeaderStrategyTest.java index 48db3c7..61386e7 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/dept/BpmTaskCandidateStartUserDeptLeaderStrategyTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/dept/BpmTaskCandidateStartUserDeptLeaderStrategyTest.java @@ -33,7 +33,6 @@ public class BpmTaskCandidateStartUserDeptLeaderStrategyTest extends BaseMockito @Mock private BpmProcessInstanceService processInstanceService; - @Mock private AdminUserApi adminUserApi; @Mock diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/other/BpmTaskCandidateExpressionStrategyTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/other/BpmTaskCandidateExpressionStrategyTest.java index 1da8a2f..f4ab7c4 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/other/BpmTaskCandidateExpressionStrategyTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/other/BpmTaskCandidateExpressionStrategyTest.java @@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.bpm.framework.flowable.core.candidate.strategy.o import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.FlowableUtils; import org.flowable.engine.delegate.DelegateExecution; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.MockedStatic; @@ -17,7 +16,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; -@Disabled // TODO 芋艿:临时注释 public class BpmTaskCandidateExpressionStrategyTest extends BaseMockitoUnitTest { @InjectMocks diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateGroupStrategyTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateGroupStrategyTest.java index 44327ec..6eae76f 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateGroupStrategyTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateGroupStrategyTest.java @@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.bpm.framework.flowable.core.candidate.strategy.u import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmUserGroupDO; import cn.iocoder.yudao.module.bpm.service.definition.BpmUserGroupService; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -17,7 +16,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; -@Disabled // TODO 芋艿:临时注释 public class BpmTaskCandidateGroupStrategyTest extends BaseMockitoUnitTest { @InjectMocks diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidatePostStrategyTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidatePostStrategyTest.java index a444224..083eda6 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidatePostStrategyTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidatePostStrategyTest.java @@ -4,7 +4,6 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.system.api.dept.PostApi; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -18,7 +17,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; -@Disabled // TODO 芋艿:临时注释 public class BpmTaskCandidatePostStrategyTest extends BaseMockitoUnitTest { @InjectMocks diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateRoleStrategyTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateRoleStrategyTest.java index 4db4cb1..5f4f6f2 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateRoleStrategyTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateRoleStrategyTest.java @@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.bpm.framework.flowable.core.candidate.strategy.u import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.system.api.permission.PermissionApi; import cn.iocoder.yudao.module.system.api.permission.RoleApi; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; @@ -15,7 +14,6 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; -@Disabled // TODO 芋艿:临时注释 public class BpmTaskCandidateRoleStrategyTest extends BaseMockitoUnitTest { @InjectMocks diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateUserStrategyTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateUserStrategyTest.java index fca4aa1..4f8679e 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateUserStrategyTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/candidate/strategy/user/BpmTaskCandidateUserStrategyTest.java @@ -1,7 +1,6 @@ package cn.iocoder.yudao.module.bpm.framework.flowable.core.candidate.strategy.user; import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; @@ -10,7 +9,6 @@ import java.util.Set; import static cn.iocoder.yudao.framework.common.util.collection.SetUtils.asSet; import static org.junit.jupiter.api.Assertions.assertEquals; -@Disabled // TODO 芋艿:临时注释 public class BpmTaskCandidateUserStrategyTest extends BaseMockitoUnitTest { @InjectMocks @@ -27,5 +25,4 @@ public class BpmTaskCandidateUserStrategyTest extends BaseMockitoUnitTest { assertEquals(asSet(1L, 2L), userIds); } - } diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/listener/BpmTaskEventListenerTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/listener/BpmTaskEventListenerTest.java new file mode 100644 index 0000000..edc64d7 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/listener/BpmTaskEventListenerTest.java @@ -0,0 +1,67 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable.core.listener; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmBoundaryEventTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskTimeoutHandlerTypeEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmnModelUtils; +import cn.iocoder.yudao.module.bpm.service.definition.BpmModelService; +import cn.iocoder.yudao.module.bpm.service.task.BpmTaskService; +import org.flowable.bpmn.model.BoundaryEvent; +import org.flowable.bpmn.model.BpmnModel; +import org.flowable.bpmn.model.Process; +import org.flowable.common.engine.api.delegate.event.FlowableEngineEntityEvent; +import org.flowable.job.api.Job; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.mockito.Mockito.*; + +/** + * {@link BpmTaskEventListener} 的单元测试 + * + * @author 芋道源码 + */ +public class BpmTaskEventListenerTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmTaskEventListener taskEventListener; + + @Mock + private BpmModelService modelService; + @Mock + private BpmTaskService taskService; + + @Test + public void testTimerFired_elementIdFromJobHandlerConfiguration() { + // mock 流程模型:task 上挂载【超时提醒】的边界事件 + BoundaryEvent boundaryEvent = new BoundaryEvent(); + boundaryEvent.setId("timeout-boundary"); + boundaryEvent.setAttachedToRefId("task"); + BpmnModelUtils.addExtensionElement(boundaryEvent, BpmnModelConstants.BOUNDARY_EVENT_TYPE, + BpmBoundaryEventTypeEnum.USER_TASK_TIMEOUT.getType()); + BpmnModelUtils.addExtensionElement(boundaryEvent, BpmnModelConstants.USER_TASK_TIMEOUT_HANDLER_TYPE, + BpmUserTaskTimeoutHandlerTypeEnum.REMINDER.getType()); + Process process = new Process(); + process.addFlowElement(boundaryEvent); + BpmnModel bpmnModel = new BpmnModel(); + bpmnModel.addProcess(process); + when(modelService.getBpmnModelByDefinitionId("definition-1")).thenReturn(bpmnModel); + // 准备参数:部分 Flowable Job 的 elementId 为空,需要从 handler configuration 解析 activityId + Job job = mock(Job.class); + when(job.getJobHandlerConfiguration()).thenReturn("{\"activityId\":\"timeout-boundary\"}"); + FlowableEngineEntityEvent event = mock(FlowableEngineEntityEvent.class); + when(event.getProcessDefinitionId()).thenReturn("definition-1"); + when(event.getProcessInstanceId()).thenReturn("process-instance-1"); + when(event.getEntity()).thenReturn(job); + + // 调用 + taskEventListener.timerFired(event); + + // 断言:使用解析出的 activityId 查找边界事件,并触发用户任务超时处理 + verify(taskService).processTaskTimeout("process-instance-1", "task", + BpmUserTaskTimeoutHandlerTypeEnum.REMINDER.getType()); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtilsTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtilsTest.java new file mode 100644 index 0000000..0a450e8 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmHttpRequestUtilsTest.java @@ -0,0 +1,65 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable.core.util; + +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; +import org.flowable.engine.runtime.ProcessInstance; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.http.HttpEntity; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.HEADER_TENANT_ID; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link BpmHttpRequestUtils} 的单元测试 + * + * @author HUIHUI + */ +public class BpmHttpRequestUtilsTest extends BaseMockitoUnitTest { + + @Mock + private BpmProcessInstanceService processInstanceService; + @Mock + private ProcessInstance processInstance; + @Mock + private RestTemplate restTemplate; + + @AfterEach + public void tearDown() { + TenantContextHolder.clear(); + } + + @Test + public void testExecuteBpmHttpRequest_statusEventTenantFromProcessInstance() { + // 准备参数:事务完成后的回调可能已经没有租户上下文,但流程实例仍带有租户编号 + BpmProcessInstanceStatusEvent event = new BpmProcessInstanceStatusEvent(this); + event.setId("process-instance"); + when(processInstanceService.getProcessInstance("process-instance")).thenReturn(processInstance); + when(processInstance.getTenantId()).thenReturn("tenant-1"); + when(restTemplate.exchange(anyString(), any(), any(), eq(String.class))).thenReturn(ResponseEntity.ok("{}")); + + try (MockedStatic springUtilMockedStatic = mockStatic(SpringUtil.class)) { + springUtilMockedStatic.when(() -> SpringUtil.getBean(BpmProcessInstanceService.class)) + .thenReturn(processInstanceService); + springUtilMockedStatic.when(() -> SpringUtil.getBean(RestTemplate.class)).thenReturn(restTemplate); + + // 调用 + BpmHttpRequestUtils.executeBpmHttpRequest(event, "http://callback"); + } + // 断言:请求头使用流程实例租户,而不是已清空的上下文租户 + ArgumentCaptor> requestCaptor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(eq("http://callback"), any(), requestCaptor.capture(), eq(String.class)); + assertEquals("tenant-1", requestCaptor.getValue().getHeaders().getFirst(HEADER_TENANT_ID)); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmnModelUtilsTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmnModelUtilsTest.java new file mode 100644 index 0000000..d664dab --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/BpmnModelUtilsTest.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable.core.util; + +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskRejectHandlerTypeEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmTaskCandidateStrategyEnum; +import org.flowable.bpmn.model.UserTask; +import org.junit.jupiter.api.Test; + +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link BpmnModelUtils} 的单元测试 + * + * @author HUIHUI + */ +public class BpmnModelUtilsTest { + + @Test + public void testCandidateElements_success() { + // 准备参数 + UserTask userTask = new UserTask(); + + // 调用 + BpmnModelUtils.addCandidateElements(BpmTaskCandidateStrategyEnum.USER.getStrategy(), "1,2", userTask); + // 断言 + assertEquals(BpmTaskCandidateStrategyEnum.USER.getStrategy(), + BpmnModelUtils.parseCandidateStrategy(userTask)); + assertEquals("1,2", BpmnModelUtils.parseCandidateParam(userTask)); + } + + @Test + public void testCandidateElements_empty() { + // 准备参数 + UserTask userTask = new UserTask(); + + // 调用 + BpmnModelUtils.addCandidateElements(null, null, userTask); + // 断言 + assertNull(BpmnModelUtils.parseCandidateStrategy(userTask)); + assertNull(BpmnModelUtils.parseCandidateParam(userTask)); + } + + @Test + public void testRejectElements_success() { + // 准备参数 + UserTask userTask = new UserTask(); + BpmSimpleModelNodeVO.RejectHandler rejectHandler = new BpmSimpleModelNodeVO.RejectHandler() + .setType(BpmUserTaskRejectHandlerTypeEnum.RETURN_USER_TASK.getType()) + .setReturnNodeId("approve"); + + // 调用 + BpmnModelUtils.addTaskRejectElements(rejectHandler, userTask); + // 断言 + assertEquals(BpmUserTaskRejectHandlerTypeEnum.RETURN_USER_TASK, + BpmnModelUtils.parseRejectHandlerType(userTask)); + assertEquals("approve", BpmnModelUtils.parseReturnTaskId(userTask)); + } + + @Test + public void testExtensionElement_emptyValue_notAdd() { + // 准备参数 + UserTask userTask = new UserTask(); + + // 调用 + BpmnModelUtils.addExtensionElement(userTask, "remark", (String) null); + BpmnModelUtils.addExtensionElementJson(userTask, "config", null); + BpmnModelUtils.addExtensionElement(userTask, "attributes", Collections.emptyMap()); + // 断言 + assertNull(BpmnModelUtils.parseExtensionElement(userTask, "remark")); + assertNull(BpmnModelUtils.parseExtensionElement(userTask, "config")); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/SimpleModelUtilsTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/SimpleModelUtilsTest.java new file mode 100644 index 0000000..c8d2e9a --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/framework/flowable/core/util/SimpleModelUtilsTest.java @@ -0,0 +1,367 @@ +package cn.iocoder.yudao.module.bpm.framework.flowable.core.util; + +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmChildProcessStartUserEmptyTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmChildProcessStartUserTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmBoundaryEventTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmConditionOpCodeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmDelayTimerTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmSimpleModeConditionTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmSimpleModelNodeTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmTriggerTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskApproveMethodEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskApproveTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmUserTaskTimeoutHandlerTypeEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.BaseFlowableUnitTest; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmTaskCandidateStrategyEnum; +import org.flowable.bpmn.model.BoundaryEvent; +import org.flowable.bpmn.model.BpmnModel; +import org.flowable.bpmn.model.CallActivity; +import org.flowable.bpmn.model.EndEvent; +import org.flowable.bpmn.model.ExclusiveGateway; +import org.flowable.bpmn.model.FlowElement; +import org.flowable.bpmn.model.Process; +import org.flowable.bpmn.model.ReceiveTask; +import org.flowable.bpmn.model.SequenceFlow; +import org.flowable.bpmn.model.ServiceTask; +import org.flowable.bpmn.model.TimerEventDefinition; +import org.flowable.bpmn.model.UserTask; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants.BOUNDARY_EVENT_TYPE; +import static cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants.START_EVENT_NODE_ID; +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link SimpleModelUtils} 的单元测试 + * + * @author HUIHUI + */ +public class SimpleModelUtilsTest extends BaseFlowableUnitTest { + + @Test + public void testBuildBpmnModel_startUserApproveEnd() { + // 准备参数 + BpmSimpleModelNodeVO approveNode = node("approve", BpmSimpleModelNodeTypeEnum.APPROVE_NODE) + .setName("审批").setApproveType(BpmUserTaskApproveTypeEnum.USER.getType()) + .setApproveMethod(BpmUserTaskApproveMethodEnum.RANDOM.getMethod()) + .setCandidateStrategy(BpmTaskCandidateStrategyEnum.USER.getStrategy()).setCandidateParam("1,2"); + BpmSimpleModelNodeVO startUserNode = node("start-user", BpmSimpleModelNodeTypeEnum.START_USER_NODE) + .setName("发起人").setChildNode(approveNode); + approveNode.setChildNode(node("end", BpmSimpleModelNodeTypeEnum.END_NODE).setName("结束")); + + // 调用 + BpmnModel bpmnModel = SimpleModelUtils.buildBpmnModel("process", "测试流程", startUserNode); + Process process = bpmnModel.getMainProcess(); + // 断言 + assertEquals("process", process.getId()); + assertEquals("测试流程", process.getName()); + assertNotNull(process.getFlowElement(START_EVENT_NODE_ID)); + assertInstanceOf(UserTask.class, process.getFlowElement("start-user")); + UserTask approveTask = (UserTask) process.getFlowElement("approve"); + assertEquals(BpmTaskCandidateStrategyEnum.USER.getStrategy(), BpmnModelUtils.parseCandidateStrategy(approveTask)); + assertEquals("1,2", BpmnModelUtils.parseCandidateParam(approveTask)); + assertInstanceOf(EndEvent.class, process.getFlowElement("end")); + assertTrue(process.getFlowElements().stream().anyMatch(item -> item instanceof SequenceFlow + && "approve".equals(((SequenceFlow) item).getSourceRef()) + && "end".equals(((SequenceFlow) item).getTargetRef()))); + } + + @Test + public void testBuildBpmnModel_approveConfig() { + // 准备参数 + BpmSimpleModelNodeVO approveNode = node("approve", BpmSimpleModelNodeTypeEnum.APPROVE_NODE) + .setApproveType(BpmUserTaskApproveTypeEnum.USER.getType()) + .setApproveMethod(BpmUserTaskApproveMethodEnum.RATIO.getMethod()).setApproveRatio(60) + .setCandidateStrategy(BpmTaskCandidateStrategyEnum.USER.getStrategy()).setCandidateParam("1") + .setTimeoutHandler(new BpmSimpleModelNodeVO.TimeoutHandler() + .setEnable(true).setType(BpmUserTaskTimeoutHandlerTypeEnum.REMINDER.getType()) + .setTimeDuration("PT1H").setMaxRemindCount(3)); + + // 调用 + List flowElements = new ApproveNodeFlowBuilder().convert(approveNode); + // 断言 + UserTask userTask = (UserTask) flowElements.get(0); + assertEquals("${ nrOfCompletedInstances/nrOfInstances >= 0.60}", + userTask.getLoopCharacteristics().getCompletionCondition()); + assertEquals(BpmTaskCandidateStrategyEnum.USER.getStrategy(), BpmnModelUtils.parseCandidateStrategy(userTask)); + BoundaryEvent boundaryEvent = (BoundaryEvent) flowElements.get(1); + TimerEventDefinition timer = (TimerEventDefinition) boundaryEvent.getEventDefinitions().get(0); + assertEquals("PT1H", timer.getTimeDuration()); + assertEquals("R3/PT1H", timer.getTimeCycle()); + } + + @Test + public void testDelayTimerNodeConvert_durationAndDate() { + // 准备参数 + BpmSimpleModelNodeVO durationNode = node("delay-duration", BpmSimpleModelNodeTypeEnum.DELAY_TIMER_NODE) + .setDelaySetting(new BpmSimpleModelNodeVO.DelaySetting() + .setDelayType(BpmDelayTimerTypeEnum.FIXED_TIME_DURATION.getType()).setDelayTime("PT2H")); + BpmSimpleModelNodeVO dateNode = node("delay-date", BpmSimpleModelNodeTypeEnum.DELAY_TIMER_NODE) + .setDelaySetting(new BpmSimpleModelNodeVO.DelaySetting() + .setDelayType(BpmDelayTimerTypeEnum.FIXED_DATE_TIME.getType()) + .setDelayTime("2026-10-06T12:00:00")); + + // 调用 + BoundaryEvent durationEvent = (BoundaryEvent) new SimpleModelUtils.DelayTimerNodeConvert() + .convertList(durationNode).get(1); + BoundaryEvent dateEvent = (BoundaryEvent) new SimpleModelUtils.DelayTimerNodeConvert() + .convertList(dateNode).get(1); + // 断言 + TimerEventDefinition durationTimer = (TimerEventDefinition) durationEvent.getEventDefinitions().get(0); + assertEquals("PT2H", durationTimer.getTimeDuration()); + assertNull(durationTimer.getTimeDate()); + TimerEventDefinition dateTimer = (TimerEventDefinition) dateEvent.getEventDefinitions().get(0); + assertEquals("2026-10-06T12:00:00", dateTimer.getTimeDate()); + assertNull(dateTimer.getTimeDuration()); + } + + @Test + public void testBuildBpmnModel_childProcessTimeout() { + // 准备参数 + BpmSimpleModelNodeVO.ChildProcessSetting setting = new BpmSimpleModelNodeVO.ChildProcessSetting() + .setCalledProcessDefinitionKey("child-process").setCalledProcessDefinitionName("子流程") + .setAsync(false).setSkipStartUserNode(true) + .setStartUserSetting(new BpmSimpleModelNodeVO.ChildProcessSetting.StartUserSetting() + .setType(BpmChildProcessStartUserTypeEnum.MAIN_PROCESS_START_USER.getType()) + .setEmptyType(BpmChildProcessStartUserEmptyTypeEnum.MAIN_PROCESS_START_USER.getType())) + .setTimeoutSetting(new BpmSimpleModelNodeVO.ChildProcessSetting.TimeoutSetting() + .setEnable(true).setType(BpmDelayTimerTypeEnum.FIXED_DATE_TIME.getType()) + .setTimeExpression("2026-10-06T12:00:00")); + BpmSimpleModelNodeVO childNode = node("child", BpmSimpleModelNodeTypeEnum.CHILD_PROCESS) + .setChildProcessSetting(setting).setChildNode(node("end", BpmSimpleModelNodeTypeEnum.END_NODE)); + + // 调用 + BpmnModel bpmnModel = SimpleModelUtils.buildBpmnModel("process", "测试流程", childNode); + Process process = bpmnModel.getMainProcess(); + // 断言 + assertInstanceOf(CallActivity.class, process.getFlowElement("child")); + BoundaryEvent boundaryEvent = process.getFlowElements().stream().filter(item -> item instanceof BoundaryEvent) + .map(item -> (BoundaryEvent) item).findFirst().orElseThrow(); + TimerEventDefinition timer = (TimerEventDefinition) boundaryEvent.getEventDefinitions().get(0); + assertEquals("2026-10-06T12:00:00", timer.getTimeDate()); + assertNull(timer.getTimeDuration()); + assertEquals(String.valueOf(BpmBoundaryEventTypeEnum.CHILD_PROCESS_TIMEOUT.getType()), + BpmnModelUtils.parseBoundaryEventExtensionElement(boundaryEvent, BOUNDARY_EVENT_TYPE)); + } + + @Test + public void testBuildBpmnModel_childProcessTimeoutDuration() { + // 准备参数 + BpmSimpleModelNodeVO.ChildProcessSetting setting = new BpmSimpleModelNodeVO.ChildProcessSetting() + .setCalledProcessDefinitionKey("child-process").setCalledProcessDefinitionName("子流程") + .setAsync(false).setSkipStartUserNode(true) + .setStartUserSetting(new BpmSimpleModelNodeVO.ChildProcessSetting.StartUserSetting() + .setType(BpmChildProcessStartUserTypeEnum.MAIN_PROCESS_START_USER.getType()) + .setEmptyType(BpmChildProcessStartUserEmptyTypeEnum.MAIN_PROCESS_START_USER.getType())) + .setTimeoutSetting(new BpmSimpleModelNodeVO.ChildProcessSetting.TimeoutSetting() + .setEnable(true).setType(BpmDelayTimerTypeEnum.FIXED_TIME_DURATION.getType()) + .setTimeExpression("PT30M")); + BpmSimpleModelNodeVO childNode = node("child", BpmSimpleModelNodeTypeEnum.CHILD_PROCESS) + .setChildProcessSetting(setting).setChildNode(node("end", BpmSimpleModelNodeTypeEnum.END_NODE)); + + // 调用 + BpmnModel bpmnModel = SimpleModelUtils.buildBpmnModel("process", "测试流程", childNode); + Process process = bpmnModel.getMainProcess(); + // 断言:固定时长使用 timeDuration,且不会误设置 timeDate、timeCycle + BoundaryEvent boundaryEvent = process.getFlowElements().stream().filter(item -> item instanceof BoundaryEvent) + .map(item -> (BoundaryEvent) item).findFirst().orElseThrow(); + assertEquals("child", boundaryEvent.getAttachedToRef().getId()); + assertFalse(boundaryEvent.isCancelActivity()); + TimerEventDefinition timer = (TimerEventDefinition) boundaryEvent.getEventDefinitions().get(0); + assertEquals("PT30M", timer.getTimeDuration()); + assertNull(timer.getTimeDate()); + assertNull(timer.getTimeCycle()); + // 断言:边界事件类型为子流程超时,而不是延迟器超时 + assertEquals(String.valueOf(BpmBoundaryEventTypeEnum.CHILD_PROCESS_TIMEOUT.getType()), + BpmnModelUtils.parseBoundaryEventExtensionElement(boundaryEvent, BOUNDARY_EVENT_TYPE)); + } + + @Test + public void testBuildBpmnModel_conditionBranch() { + // 准备参数 + BpmSimpleModelNodeVO branch = node("branch", BpmSimpleModelNodeTypeEnum.CONDITION_BRANCH_NODE); + BpmSimpleModelNodeVO condition = node("condition", BpmSimpleModelNodeTypeEnum.CONDITION_NODE) + .setName("金额大于 10").setConditionSetting(new BpmSimpleModelNodeVO.ConditionSetting() + .setConditionType(BpmSimpleModeConditionTypeEnum.EXPRESSION.getType()) + .setConditionExpression("${amount > 10}").setDefaultFlow(false)) + .setChildNode(node("approve", BpmSimpleModelNodeTypeEnum.APPROVE_NODE) + .setApproveType(BpmUserTaskApproveTypeEnum.AUTO_APPROVE.getType())); + BpmSimpleModelNodeVO defaultCondition = node("default-condition", BpmSimpleModelNodeTypeEnum.CONDITION_NODE) + .setName("默认").setConditionSetting(new BpmSimpleModelNodeVO.ConditionSetting() + .setConditionType(BpmSimpleModeConditionTypeEnum.EXPRESSION.getType()) + .setDefaultFlow(true)) + .setChildNode(node("reject", BpmSimpleModelNodeTypeEnum.APPROVE_NODE) + .setApproveType(BpmUserTaskApproveTypeEnum.AUTO_REJECT.getType())); + branch.setConditionNodes(Arrays.asList(condition, defaultCondition)) + .setChildNode(node("end", BpmSimpleModelNodeTypeEnum.END_NODE)); + + // 调用 + Process process = SimpleModelUtils.buildBpmnModel("process", "测试流程", branch).getMainProcess(); + // 断言 + ExclusiveGateway gateway = (ExclusiveGateway) process.getFlowElement("branch"); + assertEquals("default-condition", gateway.getDefaultFlow()); + SequenceFlow conditionFlow = (SequenceFlow) process.getFlowElement("condition"); + assertEquals("${amount > 10}", conditionFlow.getConditionExpression()); + assertEquals("approve", conditionFlow.getTargetRef()); + } + + @Test + public void testTriggerNodeConvert_httpCallback() { + // 准备参数 + BpmSimpleModelNodeVO.TriggerSetting.HttpRequestTriggerSetting httpSetting = + new BpmSimpleModelNodeVO.TriggerSetting.HttpRequestTriggerSetting().setUrl("https://example.com/callback"); + BpmSimpleModelNodeVO node = node("trigger", BpmSimpleModelNodeTypeEnum.TRIGGER_NODE) + .setTriggerSetting(new BpmSimpleModelNodeVO.TriggerSetting() + .setType(BpmTriggerTypeEnum.HTTP_CALLBACK.getType()) + .setHttpRequestSetting(httpSetting)); + + // 调用 + List flowElements = new SimpleModelUtils.TriggerNodeConvert().convertList(node); + // 断言 + assertEquals(2, flowElements.size()); + assertInstanceOf(ReceiveTask.class, flowElements.get(0)); + assertInstanceOf(ServiceTask.class, flowElements.get(1)); + assertNotNull(httpSetting.getCallbackTaskDefineKey()); + assertEquals(httpSetting.getCallbackTaskDefineKey(), flowElements.get(0).getId()); + } + + @Test + public void testBuildConditionExpression_rule() { + // 准备参数 + BpmSimpleModelNodeVO.ConditionRule numberRule = new BpmSimpleModelNodeVO.ConditionRule() + .setOpCode(BpmConditionOpCodeEnum.GT.getCode()).setLeftSide("amount").setRightSide("10"); + BpmSimpleModelNodeVO.ConditionRule stringRule = new BpmSimpleModelNodeVO.ConditionRule() + .setOpCode(BpmConditionOpCodeEnum.EQ.getCode()).setLeftSide("status").setRightSide("PASS"); + BpmSimpleModelNodeVO.Condition condition = new BpmSimpleModelNodeVO.Condition() + .setAnd(true).setRules(Arrays.asList(numberRule, stringRule)); + BpmSimpleModelNodeVO.ConditionGroups groups = new BpmSimpleModelNodeVO.ConditionGroups() + .setAnd(false).setConditions(Collections.singletonList(condition)); + BpmSimpleModelNodeVO.ConditionSetting setting = new BpmSimpleModelNodeVO.ConditionSetting() + .setConditionType(BpmSimpleModeConditionTypeEnum.RULE.getType()).setConditionGroups(groups); + + // 调用 + String expression = SimpleModelUtils.buildConditionExpression(setting); + // 断言 + assertEquals("${( var:getOrDefault(amount, null) > 10 && var:getOrDefault(status, null) == \"PASS\" )}", + expression); + } + + @Test + public void testBuildConditionExpression_expressionAndEmptyRule() { + // 准备参数 + BpmSimpleModelNodeVO.ConditionSetting expressionSetting = new BpmSimpleModelNodeVO.ConditionSetting() + .setConditionType(BpmSimpleModeConditionTypeEnum.EXPRESSION.getType()) + .setConditionExpression("${amount > 1}"); + BpmSimpleModelNodeVO.ConditionSetting emptyRuleSetting = new BpmSimpleModelNodeVO.ConditionSetting() + .setConditionType(BpmSimpleModeConditionTypeEnum.RULE.getType()) + .setConditionGroups(new BpmSimpleModelNodeVO.ConditionGroups().setAnd(true) + .setConditions(Collections.emptyList())); + + // 调用,并断言 + assertEquals("${amount > 1}", SimpleModelUtils.buildConditionExpression(expressionSetting)); + assertNull(SimpleModelUtils.buildConditionExpression(emptyRuleSetting)); + } + + @Test + public void testSimulateProcess_conditionBranches() { + // 准备参数 + BpmSimpleModelNodeVO branch = node("branch", BpmSimpleModelNodeTypeEnum.CONDITION_BRANCH_NODE); + BpmSimpleModelNodeVO matched = node("matched", BpmSimpleModelNodeTypeEnum.APPROVE_NODE) + .setApproveType(BpmUserTaskApproveTypeEnum.AUTO_APPROVE.getType()) + .setConditionSetting(new BpmSimpleModelNodeVO.ConditionSetting() + .setConditionType(BpmSimpleModeConditionTypeEnum.EXPRESSION.getType()) + .setConditionExpression("${amount > 10}").setDefaultFlow(false)); + BpmSimpleModelNodeVO defaultNode = node("default", BpmSimpleModelNodeTypeEnum.APPROVE_NODE) + .setApproveType(BpmUserTaskApproveTypeEnum.AUTO_REJECT.getType()) + .setConditionSetting(new BpmSimpleModelNodeVO.ConditionSetting() + .setConditionType(BpmSimpleModeConditionTypeEnum.EXPRESSION.getType()) + .setDefaultFlow(true)); + matched.setChildNode(node("matched-end", BpmSimpleModelNodeTypeEnum.END_NODE)); + defaultNode.setChildNode(node("default-end", BpmSimpleModelNodeTypeEnum.END_NODE)); + branch.setConditionNodes(Arrays.asList( + node("condition-1", BpmSimpleModelNodeTypeEnum.CONDITION_NODE).setConditionSetting(matched.getConditionSetting()) + .setChildNode(matched), + node("condition-2", BpmSimpleModelNodeTypeEnum.CONDITION_NODE).setConditionSetting(defaultNode.getConditionSetting()) + .setChildNode(defaultNode))); + + // 调用 + List result = SimpleModelUtils.simulateProcess(branch, + Collections.singletonMap("amount", 20)); + // 断言 + assertEquals(Arrays.asList("matched", "matched-end"), result.stream() + .map(BpmSimpleModelNodeVO::getId).toList()); + } + + @Test + public void testSimulateProcess_inclusiveAndParallelBranches() { + // 准备参数 + BpmSimpleModelNodeVO inclusive = node("inclusive", BpmSimpleModelNodeTypeEnum.INCLUSIVE_BRANCH_NODE); + BpmSimpleModelNodeVO first = node("first", BpmSimpleModelNodeTypeEnum.APPROVE_NODE) + .setApproveType(BpmUserTaskApproveTypeEnum.AUTO_APPROVE.getType()); + BpmSimpleModelNodeVO second = node("second", BpmSimpleModelNodeTypeEnum.COPY_NODE); + first.setChildNode(node("first-end", BpmSimpleModelNodeTypeEnum.END_NODE)); + second.setChildNode(node("second-end", BpmSimpleModelNodeTypeEnum.END_NODE)); + inclusive.setConditionNodes(Arrays.asList( + node("condition-1", BpmSimpleModelNodeTypeEnum.CONDITION_NODE) + .setConditionSetting(new BpmSimpleModelNodeVO.ConditionSetting() + .setConditionType(BpmSimpleModeConditionTypeEnum.EXPRESSION.getType()) + .setConditionExpression("${left}").setDefaultFlow(false)).setChildNode(first), + node("condition-2", BpmSimpleModelNodeTypeEnum.CONDITION_NODE) + .setConditionSetting(new BpmSimpleModelNodeVO.ConditionSetting() + .setConditionType(BpmSimpleModeConditionTypeEnum.EXPRESSION.getType()) + .setConditionExpression("${right}").setDefaultFlow(false)).setChildNode(second))); + Map variables = new HashMap<>(); + variables.put("left", true); + variables.put("right", true); + + // 调用 + List result = SimpleModelUtils.simulateProcess(inclusive, variables); + // 断言 + assertEquals(Arrays.asList("first", "first-end", "second", "second-end"), result.stream() + .map(BpmSimpleModelNodeVO::getId).toList()); + } + + @Test + public void testNodeHelpers() { + // 准备参数 + BpmSimpleModelNodeVO sequential = node("approve", BpmSimpleModelNodeTypeEnum.APPROVE_NODE) + .setApproveMethod(BpmUserTaskApproveMethodEnum.SEQUENTIAL.getMethod()); + BpmSimpleModelNodeVO skipNode = node("skip", BpmSimpleModelNodeTypeEnum.APPROVE_NODE) + .setSkipExpression("${skip}"); + + // 调用,并断言 + assertTrue(SimpleModelUtils.isValidNode(sequential)); + assertFalse(SimpleModelUtils.isValidNode(new BpmSimpleModelNodeVO())); + assertTrue(SimpleModelUtils.isSequentialApproveNode(sequential)); + assertFalse(SimpleModelUtils.isSequentialApproveNode(node("other", BpmSimpleModelNodeTypeEnum.COPY_NODE) + .setApproveMethod(BpmUserTaskApproveMethodEnum.SEQUENTIAL.getMethod()))); + assertTrue(SimpleModelUtils.isSkipNode(skipNode, Collections.singletonMap("skip", true))); + assertFalse(SimpleModelUtils.isSkipNode(node("without-skip", BpmSimpleModelNodeTypeEnum.APPROVE_NODE), + Collections.emptyMap())); + } + + private static BpmSimpleModelNodeVO node(String id, BpmSimpleModelNodeTypeEnum type) { + return new BpmSimpleModelNodeVO().setId(id).setType(type.getType()); + } + + /** + * 通过受保护转换器验证审批节点配置,不暴露生产实现细节。 + */ + private static class ApproveNodeFlowBuilder { + List convert(BpmSimpleModelNodeVO node) { + BpmnModel model = SimpleModelUtils.buildBpmnModel("test", "test", + node.setChildNode(node("end", BpmSimpleModelNodeTypeEnum.END_NODE))); + Process process = model.getMainProcess(); + return Arrays.asList(process.getFlowElement("approve"), process.getFlowElements().stream() + .filter(item -> item instanceof BoundaryEvent).map(item -> (BoundaryEvent) item) + .findFirst().orElseThrow()); + } + } +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/category/BpmCategoryServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/category/BpmCategoryServiceImplTest.java index 19199e5..ce3d6af 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/category/BpmCategoryServiceImplTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/category/BpmCategoryServiceImplTest.java @@ -138,4 +138,4 @@ public class BpmCategoryServiceImplTest extends BaseDbUnitTest { assertPojoEquals(dbCategory, pageResult.getList().get(0)); } -} \ No newline at end of file +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/comment/BpmCommentServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/comment/BpmCommentServiceImplTest.java new file mode 100644 index 0000000..7a0db81 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/comment/BpmCommentServiceImplTest.java @@ -0,0 +1,73 @@ +package cn.iocoder.yudao.module.bpm.service.comment; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.controller.admin.comment.vo.BpmCommentCreateReqVO; +import cn.iocoder.yudao.module.bpm.enums.task.BpmCommentTypeEnum; +import cn.iocoder.yudao.module.bpm.service.task.BpmTaskService; +import org.flowable.engine.TaskService; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link BpmCommentServiceImpl} 的单元测试 + * + * @author HUIHUI + */ +public class BpmCommentServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmCommentServiceImpl commentService; + + @Mock + private TaskService taskService; + @Mock + private BpmTaskService bpmTaskService; + @Mock + private Task task; + + @Test + public void testGetCommentListByProcessInstanceId_success() { + // 准备参数 + String processInstanceId = "process-instance-1"; + + // 调用 + commentService.getCommentListByProcessInstanceId(processInstanceId); + // 断言 + verify(taskService).getProcessInstanceComments(processInstanceId); + } + + @Test + public void testCreateComment_success() { + // 准备参数 + BpmCommentCreateReqVO reqVO = new BpmCommentCreateReqVO() + .setTaskId("task-1").setMessage("请补充附件"); + when(bpmTaskService.validateTaskExists(reqVO.getTaskId())).thenReturn(task); + when(task.getId()).thenReturn(reqVO.getTaskId()); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + + // 调用 + commentService.createComment(reqVO); + // 断言 + verify(taskService).addComment(eq("task-1"), eq("process-instance-1"), + eq(BpmCommentTypeEnum.COMMENT.getType()), eq("请补充附件")); + } + + @Test + public void testCreateComment_withType_success() { + // 准备参数 + String taskId = "task-1"; + String processInstanceId = "process-instance-1"; + + // 调用 + commentService.createComment(taskId, processInstanceId, BpmCommentTypeEnum.APPROVE, "同意"); + // 断言 + verify(taskService).addComment(taskId, processInstanceId, BpmCommentTypeEnum.APPROVE.getType(), "审批通过,原因是:同意"); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmFormServiceTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmFormServiceTest.java index 93281a4..5912c96 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmFormServiceTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmFormServiceTest.java @@ -35,7 +35,6 @@ public class BpmFormServiceTest extends BaseDbUnitTest { @Resource private BpmFormServiceImpl formService; - @Resource private BpmFormMapper formMapper; diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmModelServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmModelServiceImplTest.java index 217d1b3..23007d0 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmModelServiceImplTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmModelServiceImplTest.java @@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.BpmModelMetaInfoVO; import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.BpmModelSaveReqVO; import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelUpdateReqVO; import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelTypeEnum; import cn.iocoder.yudao.module.bpm.enums.definition.BpmSimpleModelNodeTypeEnum; import org.flowable.engine.RepositoryService; @@ -22,8 +23,11 @@ import org.mockito.Mock; import java.util.Arrays; import java.util.Collections; import java.util.Date; +import java.util.List; import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.MODEL_KEY_EXISTS; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.MODEL_KEY_VALID; import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.MODEL_NOT_EXISTS; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.AdditionalMatchers.aryEq; @@ -47,6 +51,8 @@ public class BpmModelServiceImplTest extends BaseMockitoUnitTest { private RepositoryService repositoryService; @Mock private ModelQuery modelQuery; + @Mock + private BpmProcessDefinitionService processDefinitionService; @BeforeEach public void setUp() { @@ -153,6 +159,163 @@ public class BpmModelServiceImplTest extends BaseMockitoUnitTest { assertEquals(Collections.singletonList(40L), metaInfo.getManagerUserIds()); } + @Test + public void testCreateModel_simple() { + // 准备参数 + BpmSimpleModelNodeVO simpleModel = simpleModel(); + BpmModelSaveReqVO reqVO = new BpmModelSaveReqVO(); + reqVO.setKey("simple_process"); + reqVO.setName("简单流程"); + reqVO.setCategory("OA"); + reqVO.setType(BpmModelTypeEnum.SIMPLE.getType()); + reqVO.setSimpleModel(simpleModel); + Model model = mock(Model.class); + when(model.getId()).thenReturn(MODEL_ID); + when(model.getKey()).thenReturn(reqVO.getKey()); + when(model.getName()).thenReturn(reqVO.getName()); + mockModelKeyNotExists(reqVO.getKey()); + when(repositoryService.newModel()).thenReturn(model); + + // 调用 + String result = modelService.createModel(reqVO); + + // 断言 + assertEquals(MODEL_ID, result); + verify(repositoryService).saveModel(same(model)); + verify(repositoryService).addModelEditorSource(eq(MODEL_ID), any(byte[].class)); + verify(repositoryService).addModelEditorSourceExtra(eq(MODEL_ID), aryEq(JsonUtils.toJsonByte(simpleModel))); + } + + @Test + public void testCreateModel_invalidKey() { + // 准备参数 + BpmModelSaveReqVO reqVO = new BpmModelSaveReqVO().setKey("bad key"); + + // 调用,并断言异常 + assertServiceException(() -> modelService.createModel(reqVO), MODEL_KEY_VALID); + verifyNoInteractions(repositoryService); + } + + @Test + public void testCreateModel_duplicateKey() { + // 准备参数 + BpmModelSaveReqVO reqVO = new BpmModelSaveReqVO().setKey("duplicate_process"); + mockModelKeyExists(reqVO.getKey()); + + // 调用,并断言异常 + assertServiceException(() -> modelService.createModel(reqVO), MODEL_KEY_EXISTS, reqVO.getKey()); + verify(repositoryService, never()).newModel(); + } + + @Test + public void testGetModelList_nameFilter() { + // 准备参数 + Model model = mock(Model.class); + when(repositoryService.createModelQuery()).thenReturn(modelQuery); + when(modelQuery.modelNameLike(eq("%请假%"))).thenReturn(modelQuery); + when(modelQuery.modelTenantId(eq(TENANT_ID.toString()))).thenReturn(modelQuery); + when(modelQuery.list()).thenReturn(Collections.singletonList(model)); + + // 调用 + List result = modelService.getModelList("请假"); + + // 断言 + assertEquals(Collections.singletonList(model), result); + verify(modelQuery).modelNameLike(eq("%请假%")); + } + + @Test + public void testGetSimpleModel_success() { + // 准备参数 + Model model = mock(Model.class); + BpmSimpleModelNodeVO simpleModel = simpleModel(); + when(model.getId()).thenReturn(MODEL_ID); + mockGetModel(model); + when(repositoryService.getModelEditorSourceExtra(eq(MODEL_ID))).thenReturn(JsonUtils.toJsonByte(simpleModel)); + + // 调用 + BpmSimpleModelNodeVO result = modelService.getSimpleModel(MODEL_ID); + + // 断言 + assertEquals(simpleModel.getId(), result.getId()); + assertEquals(simpleModel.getType(), result.getType()); + } + + @Test + public void testUpdateSimpleModel_success() { + // 准备参数 + Model model = mock(Model.class); + when(model.getId()).thenReturn(MODEL_ID); + when(model.getKey()).thenReturn("simple_process"); + when(model.getName()).thenReturn("简单流程"); + when(model.getCreateTime()).thenReturn(new Date(1_000L)); + BpmModelMetaInfoVO metaInfo = new BpmModelMetaInfoVO(); + metaInfo.setType(BpmModelTypeEnum.SIMPLE.getType()); + metaInfo.setManagerUserIds(Collections.singletonList(10L)); + when(model.getMetaInfo()).thenReturn(JsonUtils.toJsonString(metaInfo)); + BpmSimpleModelNodeVO simpleModel = simpleModel(); + BpmSimpleModelUpdateReqVO reqVO = new BpmSimpleModelUpdateReqVO().setId(MODEL_ID).setSimpleModel(simpleModel); + mockGetModel(model); + + // 调用 + modelService.updateSimpleModel(10L, reqVO); + + // 断言 + verify(repositoryService).addModelEditorSource(eq(MODEL_ID), any(byte[].class)); + verify(repositoryService).addModelEditorSourceExtra(eq(MODEL_ID), aryEq(JsonUtils.toJsonByte(simpleModel))); + } + + @Test + public void testUpdateModelSortBatch_success() { + // 准备参数 + List ids = Arrays.asList("model-1", "model-2", "model-3"); + Model model1 = mockSortableModel("model-1"); + Model model2 = mockSortableModel("model-2"); + Model model3 = mockSortableModel("model-3"); + ModelQuery listQuery = mock(ModelQuery.class); + ModelQuery managerQuery = mock(ModelQuery.class); + when(repositoryService.createModelQuery()).thenReturn(listQuery, managerQuery, managerQuery, managerQuery); + when(listQuery.modelTenantId(eq(TENANT_ID.toString()))).thenReturn(listQuery); + when(listQuery.list()).thenReturn(Arrays.asList(model1, model2, model3)); + when(managerQuery.modelId(anyString())).thenReturn(managerQuery); + when(managerQuery.modelTenantId(eq(TENANT_ID.toString()))).thenReturn(managerQuery); + when(managerQuery.singleResult()).thenReturn(model1, model2, model3); + + // 调用 + modelService.updateModelSortBatch(1L, ids); + + // 断言:所有模型(包括第一个)都更新排序,且按 ids 顺序递增(列表按 sort 升序展示) + verify(repositoryService, times(3)).saveModel(any(Model.class)); + ArgumentCaptor modelIdCaptor = ArgumentCaptor.forClass(String.class); + ArgumentCaptor sortCaptor = ArgumentCaptor.forClass(Long.class); + verify(processDefinitionService, times(3)) + .updateProcessDefinitionSortByModelId(modelIdCaptor.capture(), sortCaptor.capture()); + assertEquals(Arrays.asList("model-3", "model-2", "model-1"), modelIdCaptor.getAllValues()); + List sorts = sortCaptor.getAllValues(); + assertTrue(sorts.get(0) > sorts.get(1) && sorts.get(1) > sorts.get(2)); + } + + private void mockModelKeyNotExists(String key) { + when(repositoryService.createModelQuery()).thenReturn(modelQuery); + when(modelQuery.modelTenantId(eq(TENANT_ID.toString()))).thenReturn(modelQuery); + when(modelQuery.modelKey(eq(key))).thenReturn(modelQuery); + when(modelQuery.singleResult()).thenReturn(null); + } + + private void mockModelKeyExists(String key) { + when(repositoryService.createModelQuery()).thenReturn(modelQuery); + when(modelQuery.modelTenantId(eq(TENANT_ID.toString()))).thenReturn(modelQuery); + when(modelQuery.modelKey(eq(key))).thenReturn(modelQuery); + when(modelQuery.singleResult()).thenReturn(mock(Model.class)); + } + + private BpmSimpleModelNodeVO simpleModel() { + return new BpmSimpleModelNodeVO().setId("start-user") + .setType(BpmSimpleModelNodeTypeEnum.START_USER_NODE.getType()) + .setChildNode(new BpmSimpleModelNodeVO().setId("end") + .setType(BpmSimpleModelNodeTypeEnum.END_NODE.getType())); + } + private Model mockModel(Integer type) { Model model = mock(Model.class); when(model.getKey()).thenReturn("test_process"); @@ -165,6 +328,16 @@ public class BpmModelServiceImplTest extends BaseMockitoUnitTest { return model; } + private Model mockSortableModel(String id) { + Model model = mock(Model.class); + when(model.getId()).thenReturn(id); + when(model.getCreateTime()).thenReturn(new Date(1_000L)); + BpmModelMetaInfoVO metaInfo = new BpmModelMetaInfoVO(); + metaInfo.setManagerUserIds(Collections.singletonList(1L)); + when(model.getMetaInfo()).thenReturn(JsonUtils.toJsonString(metaInfo)); + return model; + } + private void mockGetModel(Model model) { when(repositoryService.createModelQuery()).thenReturn(modelQuery); when(modelQuery.modelId(eq(MODEL_ID))).thenReturn(modelQuery); diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessDefinitionServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessDefinitionServiceImplTest.java new file mode 100644 index 0000000..09fe75f --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessDefinitionServiceImplTest.java @@ -0,0 +1,162 @@ +package cn.iocoder.yudao.module.bpm.service.definition; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.process.BpmProcessDefinitionPageReqVO; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO; +import cn.iocoder.yudao.module.bpm.dal.mysql.definition.BpmProcessDefinitionInfoMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.flowable.common.engine.impl.db.SuspensionState; +import org.flowable.engine.RepositoryService; +import org.flowable.engine.repository.Deployment; +import org.flowable.engine.repository.DeploymentQuery; +import org.flowable.engine.repository.ProcessDefinition; +import org.flowable.engine.repository.ProcessDefinitionQuery; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** + * {@link BpmProcessDefinitionServiceImpl} 的单元测试 + * + * @author HUIHUI + */ +public class BpmProcessDefinitionServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmProcessDefinitionServiceImpl processDefinitionService; + + @Mock + private RepositoryService repositoryService; + @Mock + private BpmProcessDefinitionInfoMapper processDefinitionMapper; + @Mock + private AdminUserApi adminUserApi; + @Mock + private ProcessDefinitionQuery processDefinitionQuery; + @Mock + private DeploymentQuery deploymentQuery; + + @Test + public void testCanUserStartProcessDefinition_null() { + // 调用,并断言 + assertFalse(processDefinitionService.canUserStartProcessDefinition(null, 1L)); + } + + @Test + public void testCanUserStartProcessDefinition_startUserIds() { + // 准备参数 + BpmProcessDefinitionInfoDO definition = new BpmProcessDefinitionInfoDO().setStartUserIds(List.of(1L, 2L)); + + // 调用,并断言 + assertTrue(processDefinitionService.canUserStartProcessDefinition(definition, 2L)); + assertFalse(processDefinitionService.canUserStartProcessDefinition(definition, 3L)); + } + + @Test + public void testCanUserStartProcessDefinition_startDeptIdsMatch() { + // 准备参数 + BpmProcessDefinitionInfoDO definition = new BpmProcessDefinitionInfoDO().setStartDeptIds(List.of(10L)); + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setDeptId(10L)); + + // 调用,并断言 + assertTrue(processDefinitionService.canUserStartProcessDefinition(definition, 1L)); + } + + @Test + public void testCanUserStartProcessDefinition_startDeptIdsNotMatch() { + // 准备参数 + BpmProcessDefinitionInfoDO definition = new BpmProcessDefinitionInfoDO().setStartDeptIds(List.of(10L)); + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setDeptId(20L)); + + // 调用,并断言 + assertFalse(processDefinitionService.canUserStartProcessDefinition(definition, 1L)); + } + + @Test + public void testCanUserStartProcessDefinition_noLimit() { + // 调用,并断言 + assertTrue(processDefinitionService.canUserStartProcessDefinition(new BpmProcessDefinitionInfoDO(), 1L)); + } + + @Test + public void testGetProcessDefinitionByDeploymentId_empty() { + // 调用,并断言 + assertNull(processDefinitionService.getProcessDefinitionByDeploymentId(null)); + assertNull(processDefinitionService.getProcessDefinitionByDeploymentId("")); + } + + @Test + public void testGetProcessDefinitionListByDeploymentIds_empty() { + // 调用,并断言 + assertTrue(processDefinitionService.getProcessDefinitionListByDeploymentIds(Collections.emptySet()).isEmpty()); + } + + @Test + public void testGetActiveProcessDefinition_success() { + // 准备参数 + ProcessDefinition definition = mock(ProcessDefinition.class); + when(repositoryService.createProcessDefinitionQuery()).thenReturn(processDefinitionQuery); + when(processDefinitionQuery.processDefinitionTenantId(anyString())).thenReturn(processDefinitionQuery); + when(processDefinitionQuery.processDefinitionKey("leave")).thenReturn(processDefinitionQuery); + when(processDefinitionQuery.active()).thenReturn(processDefinitionQuery); + when(processDefinitionQuery.singleResult()).thenReturn(definition); + + // 调用,并断言 + assertSame(definition, processDefinitionService.getActiveProcessDefinition("leave")); + } + + @Test + public void testGetDeploymentList_filterNotExists() { + // 准备参数 + Deployment deployment = mock(Deployment.class); + when(repositoryService.createDeploymentQuery()).thenReturn(deploymentQuery); + when(deploymentQuery.deploymentId(anyString())).thenReturn(deploymentQuery); + when(deploymentQuery.singleResult()).thenReturn(deployment, null); + + // 调用,并断言 + assertEquals(Collections.singletonList(deployment), + processDefinitionService.getDeploymentList(Set.of("deployment-1", "deployment-2"))); + } + + @Test + public void testUpdateProcessDefinitionState_activeAndSuspended() { + // 准备参数 + ProcessDefinition definition = mock(ProcessDefinition.class); + when(repositoryService.getProcessDefinition("definition-1")).thenReturn(definition); + + // 调用:挂起状态恢复为激活 + when(definition.isSuspended()).thenReturn(true); + processDefinitionService.updateProcessDefinitionState("definition-1", SuspensionState.ACTIVE.getStateCode()); + verify(repositoryService).activateProcessDefinitionById("definition-1", false, null); + + // 调用:激活状态挂起 + when(definition.isSuspended()).thenReturn(false); + processDefinitionService.updateProcessDefinitionState("definition-1", SuspensionState.SUSPENDED.getStateCode()); + verify(repositoryService).suspendProcessDefinitionById("definition-1", false, null); + } + + @Test + public void testGetProcessDefinitionPage_empty() { + // 准备参数 + BpmProcessDefinitionPageReqVO pageVO = new BpmProcessDefinitionPageReqVO(); + when(repositoryService.createProcessDefinitionQuery()).thenReturn(processDefinitionQuery); + when(processDefinitionQuery.processDefinitionTenantId(anyString())).thenReturn(processDefinitionQuery); + when(processDefinitionQuery.count()).thenReturn(0L); + + // 调用,并断言 + PageResult result = processDefinitionService.getProcessDefinitionPage(pageVO); + assertEquals(0L, result.getTotal()); + assertTrue(result.getList().isEmpty()); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessExpressionServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessExpressionServiceImplTest.java new file mode 100644 index 0000000..e132965 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessExpressionServiceImplTest.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.bpm.service.definition; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.framework.test.core.util.AssertUtils; +import cn.iocoder.yudao.framework.test.core.util.RandomUtils; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.expression.BpmProcessExpressionPageReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.expression.BpmProcessExpressionSaveReqVO; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessExpressionDO; +import cn.iocoder.yudao.module.bpm.dal.mysql.definition.BpmProcessExpressionMapper; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime; +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; +import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.cloneIgnoreId; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_EXPRESSION_NOT_EXISTS; + +/** + * {@link BpmProcessExpressionServiceImpl} 的单元测试类 + * + * @author HUIHUI + */ +@Import(BpmProcessExpressionServiceImpl.class) +public class BpmProcessExpressionServiceImplTest extends BaseDbUnitTest { + + @Resource + private BpmProcessExpressionServiceImpl processExpressionService; + @Resource + private BpmProcessExpressionMapper processExpressionMapper; + + @Test + public void testCreateProcessExpression_success() { + // 准备参数 + BpmProcessExpressionSaveReqVO reqVO = RandomUtils.randomPojo(BpmProcessExpressionSaveReqVO.class) + .setId(null); + + // 调用 + Long expressionId = processExpressionService.createProcessExpression(reqVO); + // 断言 + Assertions.assertNotNull(expressionId); + AssertUtils.assertPojoEquals(reqVO, processExpressionMapper.selectById(expressionId), "id"); + } + + @Test + public void testUpdateProcessExpression_success() { + // 准备参数 + BpmProcessExpressionDO dbExpression = RandomUtils.randomPojo(BpmProcessExpressionDO.class); + processExpressionMapper.insert(dbExpression); + BpmProcessExpressionSaveReqVO reqVO = RandomUtils.randomPojo(BpmProcessExpressionSaveReqVO.class) + .setId(dbExpression.getId()); + + // 调用 + processExpressionService.updateProcessExpression(reqVO); + // 断言 + AssertUtils.assertPojoEquals(reqVO, processExpressionMapper.selectById(dbExpression.getId())); + } + + @Test + public void testUpdateProcessExpression_notExists() { + // 准备参数 + BpmProcessExpressionSaveReqVO reqVO = RandomUtils.randomPojo(BpmProcessExpressionSaveReqVO.class); + + // 调用,并断言异常 + AssertUtils.assertServiceException(() -> processExpressionService.updateProcessExpression(reqVO), + PROCESS_EXPRESSION_NOT_EXISTS); + } + + @Test + public void testDeleteProcessExpression_success() { + // 准备参数 + BpmProcessExpressionDO dbExpression = RandomUtils.randomPojo(BpmProcessExpressionDO.class); + processExpressionMapper.insert(dbExpression); + + // 调用 + processExpressionService.deleteProcessExpression(dbExpression.getId()); + // 断言 + Assertions.assertNull(processExpressionMapper.selectById(dbExpression.getId())); + } + + @Test + public void testDeleteProcessExpression_notExists() { + // 准备参数 + Long id = RandomUtils.randomLongId(); + + // 调用,并断言异常 + AssertUtils.assertServiceException(() -> processExpressionService.deleteProcessExpression(id), + PROCESS_EXPRESSION_NOT_EXISTS); + } + + @Test + public void testGetProcessExpressionPage() { + // 准备参数 + BpmProcessExpressionDO dbExpression = RandomUtils.randomPojo(BpmProcessExpressionDO.class, o -> { + o.setName("请假审批"); + o.setStatus(CommonStatusEnum.ENABLE.getStatus()); + o.setCreateTime(buildTime(2023, 2, 2)); + }); + processExpressionMapper.insert(dbExpression); + processExpressionMapper.insert(cloneIgnoreId(dbExpression, o -> o.setName("报销审批"))); + processExpressionMapper.insert(cloneIgnoreId(dbExpression, o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus()))); + processExpressionMapper.insert(cloneIgnoreId(dbExpression, o -> o.setCreateTime(buildTime(2024, 2, 2)))); + BpmProcessExpressionPageReqVO reqVO = new BpmProcessExpressionPageReqVO(); + reqVO.setName("请假"); + reqVO.setStatus(CommonStatusEnum.ENABLE.getStatus()); + reqVO.setCreateTime(buildBetweenTime(2023, 2, 1, 2023, 2, 28)); + + // 调用 + PageResult pageResult = processExpressionService.getProcessExpressionPage(reqVO); + // 断言 + Assertions.assertEquals(1, pageResult.getTotal()); + AssertUtils.assertPojoEquals(dbExpression, pageResult.getList().get(0)); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessListenerServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessListenerServiceImplTest.java new file mode 100644 index 0000000..19caae5 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmProcessListenerServiceImplTest.java @@ -0,0 +1,114 @@ +package cn.iocoder.yudao.module.bpm.service.definition; + +import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.framework.test.core.util.AssertUtils; +import cn.iocoder.yudao.framework.test.core.util.RandomUtils; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.listener.BpmProcessListenerSaveReqVO; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessListenerDO; +import cn.iocoder.yudao.module.bpm.dal.mysql.definition.BpmProcessListenerMapper; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmProcessListenerTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmProcessListenerValueTypeEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; + +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_LISTENER_CLASS_IMPLEMENTS_ERROR; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_LISTENER_CLASS_NOT_FOUND; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_LISTENER_EXPRESSION_INVALID; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_LISTENER_NOT_EXISTS; + +/** + * {@link BpmProcessListenerServiceImpl} 的单元测试类 + * + * @author HUIHUI + */ +@Import(BpmProcessListenerServiceImpl.class) +public class BpmProcessListenerServiceImplTest extends BaseDbUnitTest { + + @Resource + private BpmProcessListenerServiceImpl processListenerService; + @Resource + private BpmProcessListenerMapper processListenerMapper; + + @Test + public void testCreateProcessListener_class_success() { + // 准备参数 + BpmProcessListenerSaveReqVO reqVO = randomRequest() + .setType(BpmProcessListenerTypeEnum.EXECUTION.getType()) + .setValueType(BpmProcessListenerValueTypeEnum.CLASS.getType()) + .setValue("cn.iocoder.yudao.module.bpm.framework.flowable.core.listener.demo.exection.DemoDelegateClassExecutionListener"); + + // 调用 + Long listenerId = processListenerService.createProcessListener(reqVO); + // 断言 + Assertions.assertNotNull(listenerId); + AssertUtils.assertPojoEquals(reqVO, processListenerMapper.selectById(listenerId)); + } + + @Test + public void testCreateProcessListener_expression_success() { + // 准备参数 + BpmProcessListenerSaveReqVO reqVO = randomRequest() + .setValueType(BpmProcessListenerValueTypeEnum.EXPRESSION.getType()).setValue("${listener.handle}"); + + // 调用 + Long listenerId = processListenerService.createProcessListener(reqVO); + // 断言 + Assertions.assertNotNull(listenerId); + } + + @Test + public void testCreateProcessListener_classNotFound() { + // 准备参数 + BpmProcessListenerSaveReqVO reqVO = randomRequest().setValueType(BpmProcessListenerValueTypeEnum.CLASS.getType()) + .setValue("cn.iocoder.yudao.module.bpm.NotExistsListener"); + + // 调用,并断言异常 + AssertUtils.assertServiceException(() -> processListenerService.createProcessListener(reqVO), + PROCESS_LISTENER_CLASS_NOT_FOUND, reqVO.getValue()); + } + + @Test + public void testCreateProcessListener_classNotImplements() { + // 准备参数 + BpmProcessListenerSaveReqVO reqVO = randomRequest().setValueType(BpmProcessListenerValueTypeEnum.CLASS.getType()) + .setValue(String.class.getName()); + + // 调用,并断言异常 + AssertUtils.assertServiceException(() -> processListenerService.createProcessListener(reqVO), + PROCESS_LISTENER_CLASS_IMPLEMENTS_ERROR, reqVO.getValue(), + org.flowable.engine.delegate.TaskListener.class.getName()); + } + + @Test + public void testCreateProcessListener_expressionInvalid() { + // 准备参数 + BpmProcessListenerSaveReqVO reqVO = randomRequest().setValueType(BpmProcessListenerValueTypeEnum.EXPRESSION.getType()) + .setValue("listener.handle"); + + // 调用,并断言异常 + AssertUtils.assertServiceException(() -> processListenerService.createProcessListener(reqVO), + PROCESS_LISTENER_EXPRESSION_INVALID, reqVO.getValue()); + } + + @Test + public void testDeleteProcessListener_notExists() { + // 准备参数 + Long id = RandomUtils.randomLongId(); + + // 调用,并断言异常 + AssertUtils.assertServiceException(() -> processListenerService.deleteProcessListener(id), + PROCESS_LISTENER_NOT_EXISTS); + } + + private static BpmProcessListenerSaveReqVO randomRequest() { + return RandomUtils.randomPojo(BpmProcessListenerSaveReqVO.class, o -> { + o.setStatus(CommonStatusEnum.ENABLE.getStatus()); + o.setType(BpmProcessListenerTypeEnum.TASK.getType()); + o.setEvent("create"); + }); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmUserGroupServiceTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmUserGroupServiceTest.java index cf119f2..9067dd0 100644 --- a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmUserGroupServiceTest.java +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/definition/BpmUserGroupServiceTest.java @@ -31,7 +31,6 @@ public class BpmUserGroupServiceTest extends BaseDbUnitTest { @Resource private BpmUserGroupServiceImpl userGroupService; - @Resource private BpmUserGroupMapper userGroupMapper; diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/message/BpmMessageServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/message/BpmMessageServiceImplTest.java new file mode 100644 index 0000000..7995fda --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/message/BpmMessageServiceImplTest.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.bpm.service.message; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.framework.web.config.WebProperties; +import cn.iocoder.yudao.module.bpm.enums.message.BpmMessageEnum; +import cn.iocoder.yudao.module.bpm.service.message.dto.BpmMessageSendWhenProcessInstanceApproveReqDTO; +import cn.iocoder.yudao.module.bpm.service.message.dto.BpmMessageSendWhenProcessInstanceRejectReqDTO; +import cn.iocoder.yudao.module.system.api.sms.SmsSendApi; +import cn.iocoder.yudao.module.system.api.sms.dto.send.SmsSendSingleToUserReqDTO; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; + +/** + * {@link BpmMessageServiceImpl} 的单元测试 + * + * @author HUIHUI + */ +public class BpmMessageServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmMessageServiceImpl messageService; + + @Mock + private SmsSendApi smsSendApi; + @Mock + private WebProperties webProperties; + @Mock + private WebProperties.Ui adminUi; + + @BeforeEach + public void setUp() { + org.mockito.Mockito.when(webProperties.getAdminUi()).thenReturn(adminUi); + org.mockito.Mockito.when(adminUi.getUrl()).thenReturn("http://localhost:48080"); + } + + @Test + public void testSendMessageWhenProcessInstanceApprove_success() { + // 准备参数 + BpmMessageSendWhenProcessInstanceApproveReqDTO reqDTO = new BpmMessageSendWhenProcessInstanceApproveReqDTO() + .setProcessInstanceId("process-instance-1").setProcessInstanceName("请假申请").setStartUserId(1L); + + // 调用 + messageService.sendMessageWhenProcessInstanceApprove(reqDTO); + // 断言 + SmsSendSingleToUserReqDTO smsReqDTO = captureSmsRequest(); + assertEquals(1L, smsReqDTO.getUserId()); + assertEquals(BpmMessageEnum.PROCESS_INSTANCE_APPROVE.getSmsTemplateCode(), smsReqDTO.getTemplateCode()); + assertEquals("请假申请", smsReqDTO.getTemplateParams().get("processInstanceName")); + assertEquals("http://localhost:48080/bpm/process-instance/detail?id=process-instance-1", + smsReqDTO.getTemplateParams().get("detailUrl")); + } + + @Test + public void testSendMessageWhenProcessInstanceReject_success() { + // 准备参数 + BpmMessageSendWhenProcessInstanceRejectReqDTO reqDTO = new BpmMessageSendWhenProcessInstanceRejectReqDTO() + .setProcessInstanceId("process-instance-1").setProcessInstanceName("请假申请") + .setStartUserId(1L).setReason("材料不完整"); + + // 调用 + messageService.sendMessageWhenProcessInstanceReject(reqDTO); + // 断言 + SmsSendSingleToUserReqDTO smsReqDTO = captureSmsRequest(); + assertEquals(1L, smsReqDTO.getUserId()); + assertEquals(BpmMessageEnum.PROCESS_INSTANCE_REJECT.getSmsTemplateCode(), smsReqDTO.getTemplateCode()); + assertEquals("材料不完整", smsReqDTO.getTemplateParams().get("reason")); + } + + private SmsSendSingleToUserReqDTO captureSmsRequest() { + ArgumentCaptor captor = ArgumentCaptor.forClass(SmsSendSingleToUserReqDTO.class); + verify(smsSendApi).sendSingleSmsToAdmin(captor.capture()); + return captor.getValue(); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/oa/BpmOALeaveServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/oa/BpmOALeaveServiceImplTest.java new file mode 100644 index 0000000..ad38843 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/oa/BpmOALeaveServiceImplTest.java @@ -0,0 +1,100 @@ +package cn.iocoder.yudao.module.bpm.service.oa; + +import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest; +import cn.iocoder.yudao.module.bpm.api.task.BpmProcessInstanceApi; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.bpm.controller.admin.oa.vo.BpmOALeaveCreateReqVO; +import cn.iocoder.yudao.module.bpm.dal.dataobject.oa.BpmOALeaveDO; +import cn.iocoder.yudao.module.bpm.dal.mysql.oa.BpmOALeaveMapper; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; +import jakarta.annotation.Resource; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime; +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.OA_LEAVE_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link BpmOALeaveServiceImpl} 的单元测试类 + * + * @author HUIHUI + */ +@Import(BpmOALeaveServiceImpl.class) +public class BpmOALeaveServiceImplTest extends BaseDbUnitTest { + + @Resource + private BpmOALeaveServiceImpl leaveService; + @Resource + private BpmOALeaveMapper leaveMapper; + + @MockitoBean + private BpmProcessInstanceApi processInstanceApi; + + @Test + public void testCreateLeave_success() { + // 准备参数 + BpmOALeaveCreateReqVO reqVO = new BpmOALeaveCreateReqVO().setType(1).setReason("休假") + .setStartTime(buildTime(2026, 10, 1)).setEndTime(buildTime(2026, 10, 3)) + .setStartUserSelectAssignees(Map.of("approve", List.of(2L))); + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenReturn("process-instance-1"); + + // 调用 + Long leaveId = leaveService.createLeave(1L, reqVO); + // 断言 + BpmOALeaveDO leave = leaveMapper.selectById(leaveId); + assertEquals(1L, leave.getUserId()); + assertEquals(2L, leave.getDay()); + assertEquals(BpmTaskStatusEnum.RUNNING.getStatus(), leave.getStatus()); + assertEquals("process-instance-1", leave.getProcessInstanceId()); + ArgumentCaptor captor = ArgumentCaptor.forClass(BpmProcessInstanceCreateReqDTO.class); + verify(processInstanceApi).createProcessInstance(eq(1L), captor.capture()); + assertEquals("oa_leave", captor.getValue().getProcessDefinitionKey()); + assertEquals(String.valueOf(leaveId), captor.getValue().getBusinessKey()); + assertEquals(2L, captor.getValue().getVariables().get("day")); + assertEquals(reqVO.getStartUserSelectAssignees(), captor.getValue().getStartUserSelectAssignees()); + } + + @Test + public void testCreateLeave_processCreateFails_rollback() { + // 准备参数 + BpmOALeaveCreateReqVO reqVO = new BpmOALeaveCreateReqVO().setType(1).setReason("休假") + .setStartTime(buildTime(2026, 10, 1)).setEndTime(buildTime(2026, 10, 3)); + when(processInstanceApi.createProcessInstance(eq(1L), any())).thenThrow(new IllegalStateException("流程启动失败")); + + // 调用,并断言异常 + assertThrows(IllegalStateException.class, () -> leaveService.createLeave(1L, reqVO)); + + // 断言 + assertEquals(0, leaveMapper.selectCount()); + } + + @Test + public void testUpdateLeaveStatus_success() { + // 准备参数 + BpmOALeaveDO leave = randomPojo(BpmOALeaveDO.class); + leaveMapper.insert(leave); + + // 调用 + leaveService.updateLeaveStatus(leave.getId(), BpmTaskStatusEnum.APPROVE.getStatus()); + // 断言 + assertEquals(BpmTaskStatusEnum.APPROVE.getStatus(), leaveMapper.selectById(leave.getId()).getStatus()); + } + + @Test + public void testUpdateLeaveStatus_notExists() { + // 调用,并断言异常 + assertServiceException(() -> leaveService.updateLeaveStatus(1L, BpmTaskStatusEnum.APPROVE.getStatus()), + OA_LEAVE_NOT_EXISTS); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/oa/listener/BpmOALeaveStatusListenerTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/oa/listener/BpmOALeaveStatusListenerTest.java new file mode 100644 index 0000000..7ae5985 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/oa/listener/BpmOALeaveStatusListenerTest.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.bpm.service.oa.listener; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.service.oa.BpmOALeaveService; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import static org.mockito.Mockito.verify; + +/** + * {@link BpmOALeaveStatusListener} 的单元测试 + * + * @author HUIHUI + */ +public class BpmOALeaveStatusListenerTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmOALeaveStatusListener leaveStatusListener; + + @Mock + private BpmOALeaveService leaveService; + + @Test + public void testOnApplicationEvent_sameProcessDefinitionKey_updateStatus() { + // 准备参数 + BpmProcessInstanceStatusEvent event = new BpmProcessInstanceStatusEvent(this); + event.setProcessDefinitionKey("oa_leave").setBusinessKey("1").setStatus(2); + + // 调用 + leaveStatusListener.onApplicationEvent(event); + // 断言 + verify(leaveService).updateLeaveStatus(1L, 2); + } + + @Test + public void testOnApplicationEvent_differentProcessDefinitionKey_ignore() { + // 准备参数 + BpmProcessInstanceStatusEvent event = new BpmProcessInstanceStatusEvent(this); + event.setProcessDefinitionKey("oa_reimbursement").setBusinessKey("1").setStatus(2); + + // 调用 + leaveStatusListener.onApplicationEvent(event); + // 断言 + org.mockito.Mockito.verifyNoInteractions(leaveService); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceCopyServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceCopyServiceImplTest.java new file mode 100644 index 0000000..aa920c0 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceCopyServiceImplTest.java @@ -0,0 +1,111 @@ +package cn.iocoder.yudao.module.bpm.service.task; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.dal.dataobject.task.BpmProcessInstanceCopyDO; +import cn.iocoder.yudao.module.bpm.dal.mysql.task.BpmProcessInstanceCopyMapper; +import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService; +import org.flowable.engine.repository.ProcessDefinition; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.task.api.Task; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; + +import java.util.List; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_DEFINITION_NOT_EXISTS; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.PROCESS_INSTANCE_NOT_EXISTS; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.TASK_NOT_EXISTS; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * {@link BpmProcessInstanceCopyServiceImpl} 的单元测试 + * + * @author HUIHUI + */ +public class BpmProcessInstanceCopyServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmProcessInstanceCopyServiceImpl copyService; + + @Mock + private BpmProcessInstanceCopyMapper copyMapper; + @Mock + private BpmTaskService taskService; + @Mock + private BpmProcessInstanceService processInstanceService; + @Mock + private BpmProcessDefinitionService processDefinitionService; + @Mock + private Task task; + @Mock + private ProcessInstance processInstance; + @Mock + private ProcessDefinition processDefinition; + + @Test + public void testCreateProcessInstanceCopy_task_success() { + // 准备参数 + when(taskService.getTask("task-1")).thenReturn(task); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + when(task.getTaskDefinitionKey()).thenReturn("approve"); + when(task.getName()).thenReturn("审批"); + when(task.getId()).thenReturn("task-1"); + prepareProcessInstance(); + + // 调用 + copyService.createProcessInstanceCopy(List.of(2L, 3L), "请知悉", "task-1"); + // 断言 + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(copyMapper).insertBatch(captor.capture()); + assertEquals(List.of(2L, 3L), captor.getValue().stream().map(BpmProcessInstanceCopyDO::getUserId).toList()); + assertEquals("process-instance-1", captor.getValue().get(0).getProcessInstanceId()); + assertEquals(1L, captor.getValue().get(0).getStartUserId()); + } + + @Test + public void testCreateProcessInstanceCopy_taskNotExists() { + // 准备参数 + when(taskService.getTask("task-1")).thenReturn(null); + + // 调用,并断言异常 + assertServiceException(() -> copyService.createProcessInstanceCopy(List.of(2L), "请知悉", "task-1"), + TASK_NOT_EXISTS); + } + + @Test + public void testCreateProcessInstanceCopy_processInstanceNotExists() { + // 准备参数 + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(null); + + // 调用,并断言异常 + assertServiceException(() -> copyService.createProcessInstanceCopy(List.of(2L), "请知悉", + "process-instance-1", "approve", "审批", "task-1"), PROCESS_INSTANCE_NOT_EXISTS); + } + + @Test + public void testCreateProcessInstanceCopy_processDefinitionNotExists() { + // 准备参数 + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + when(processDefinitionService.getProcessDefinition("definition-1")).thenReturn(null); + + // 调用,并断言异常 + assertServiceException(() -> copyService.createProcessInstanceCopy(List.of(2L), "请知悉", + "process-instance-1", "approve", "审批", "task-1"), PROCESS_DEFINITION_NOT_EXISTS); + } + + private void prepareProcessInstance() { + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + when(processInstance.getStartUserId()).thenReturn("1"); + when(processInstance.getName()).thenReturn("请假申请"); + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + when(processDefinitionService.getProcessDefinition("definition-1")).thenReturn(processDefinition); + when(processDefinition.getCategory()).thenReturn("oa"); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImplTest.java new file mode 100644 index 0000000..75a45ce --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/BpmProcessInstanceServiceImplTest.java @@ -0,0 +1,542 @@ +package cn.iocoder.yudao.module.bpm.service.task; + +import cn.iocoder.yudao.framework.common.exception.ServiceException; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.api.event.BpmProcessInstanceStatusEvent; +import cn.iocoder.yudao.module.bpm.api.task.dto.BpmProcessInstanceCreateReqDTO; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.BpmModelMetaInfoVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmApprovalDetailRespVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstanceCancelReqVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.instance.BpmProcessInstanceCreateReqVO; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO; +import cn.iocoder.yudao.module.bpm.dal.redis.BpmProcessIdRedisDAO; +import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelFormTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmProcessInstanceStatusEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmReasonEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmTaskCandidateStrategyEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnVariableConstants; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.event.BpmProcessInstanceEventPublisher; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmHttpRequestUtils; +import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService; +import cn.iocoder.yudao.module.bpm.service.message.BpmMessageService; +import cn.iocoder.yudao.module.bpm.service.message.dto.BpmMessageSendWhenProcessInstanceApproveReqDTO; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.flowable.common.engine.impl.identity.Authentication; +import org.flowable.engine.HistoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.repository.ProcessDefinition; +import org.flowable.engine.runtime.Execution; +import org.flowable.engine.runtime.ExecutionQuery; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.engine.runtime.ProcessInstanceBuilder; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link BpmProcessInstanceServiceImpl} 的单元测试 + * + * @author HUIHUI + */ +public class BpmProcessInstanceServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmProcessInstanceServiceImpl processInstanceService; + + @Mock + private RuntimeService runtimeService; + @Mock + private HistoryService historyService; + @Mock + private BpmProcessDefinitionService processDefinitionService; + @Mock + private BpmTaskService taskService; + @Mock + private BpmMessageService messageService; + @Mock + private AdminUserApi adminUserApi; + @Mock + private BpmProcessInstanceEventPublisher processInstanceEventPublisher; + @Mock + private BpmProcessIdRedisDAO processIdRedisDAO; + @Mock + private ProcessInstance processInstance; + @Mock + private ProcessDefinition processDefinition; + + // ========== createProcessInstance 相关 ========== + + @Test + public void testCreateProcessInstance_success() { + // 准备参数:动态表单必填字段已填写,开启流程编号规则、标题设置,并配置发起人自选审批人 + BpmProcessInstanceServiceImpl spyService = spy(processInstanceService); + BpmModelMetaInfoVO.ProcessIdRule processIdRule = new BpmModelMetaInfoVO.ProcessIdRule().setEnable(true) + .setPrefix("QJ"); + mockCreateProcessInstance(buildProcessDefinitionInfo() + .setFormType(BpmModelFormTypeEnum.NORMAL.getType()) + .setFormFields(List.of(buildFormField("day", "请假天数", List.of("required")))) + .setProcessIdRule(processIdRule) + .setTitleSetting(new BpmModelMetaInfoVO.TitleSetting().setEnable(true) + .setTitle("{" + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_ID + "}的" + + "{" + BpmnVariableConstants.PROCESS_DEFINITION_NAME + "},请假{day}天"))); + mockApprovalDetail(spyService, buildActivityNode("task1", "一级审批", BpmTaskCandidateStrategyEnum.USER), + buildActivityNode("task2", "二级审批", BpmTaskCandidateStrategyEnum.START_USER_SELECT)); + when(adminUserApi.getUserMap(List.of(2L))).thenReturn(Map.of(2L, new AdminUserRespDTO().setId(2L))); + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setNickname("张三")); + when(processIdRedisDAO.generate(processIdRule)).thenReturn("QJ-001"); + ProcessInstanceBuilder processInstanceBuilder = mockProcessInstanceBuilder(); + Map> startUserSelectAssignees = Map.of("task2", List.of(2L)); + BpmProcessInstanceCreateReqVO reqVO = new BpmProcessInstanceCreateReqVO().setProcessDefinitionId("definition-1") + .setVariables(new HashMap<>(Map.of("day", 3))) + .setStartUserSelectAssignees(startUserSelectAssignees); + + // 调用 + String processInstanceId = spyService.createProcessInstance(1L, reqVO); + // 断言 + assertEquals("process-instance-1", processInstanceId); + verify(processInstanceBuilder).processDefinitionId("definition-1"); + verify(processInstanceBuilder).businessKey(null); + verify(processInstanceBuilder).predefineProcessInstanceId("QJ-001"); + verify(processInstanceBuilder).name("张三的请假,请假3天"); + Map variables = captureVariables(processInstanceBuilder); + assertEquals(3, variables.get("day")); + assertEquals(1L, variables.get(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_ID)); + assertEquals(BpmProcessInstanceStatusEnum.RUNNING.getStatus(), + variables.get(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS)); + assertEquals(true, variables.get(BpmnVariableConstants.PROCESS_INSTANCE_SKIP_EXPRESSION_ENABLED)); + assertEquals(startUserSelectAssignees, + variables.get(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_SELECT_ASSIGNEES)); + } + + @Test + public void testCreateProcessInstance_dto() { + // 准备参数:未开启流程编号规则、标题设置 + BpmProcessInstanceServiceImpl spyService = spy(processInstanceService); + when(processDefinitionService.getActiveProcessDefinition("leave")).thenReturn(processDefinition); + when(processDefinition.getId()).thenReturn("definition-1"); + when(processDefinition.getName()).thenReturn("请假"); + BpmProcessDefinitionInfoDO processDefinitionInfo = buildProcessDefinitionInfo(); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")).thenReturn(processDefinitionInfo); + when(processDefinitionService.canUserStartProcessDefinition(processDefinitionInfo, 1L)).thenReturn(true); + mockApprovalDetail(spyService); + ProcessInstanceBuilder processInstanceBuilder = mockProcessInstanceBuilder(); + // 发起时,需要设置 Flowable 的认证用户,作为流程发起人 + List authenticatedUserIds = new ArrayList<>(); + when(processInstanceBuilder.start()).thenAnswer(invocation -> { + authenticatedUserIds.add(Authentication.getAuthenticatedUserId()); + return processInstance; + }); + + // 调用 + String processInstanceId = spyService.createProcessInstance(1L, new BpmProcessInstanceCreateReqDTO() + .setProcessDefinitionKey("leave").setBusinessKey("business-1")); + // 断言 + assertEquals("process-instance-1", processInstanceId); + assertEquals(List.of("1"), authenticatedUserIds); + assertNull(Authentication.getAuthenticatedUserId()); + verify(processInstanceBuilder).businessKey("business-1"); + verify(processInstanceBuilder).name("请假"); + verify(processInstanceBuilder, never()).predefineProcessInstanceId(anyString()); + Map variables = captureVariables(processInstanceBuilder); + assertEquals(1L, variables.get(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_ID)); + assertFalse(variables.containsKey(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_SELECT_ASSIGNEES)); + verifyNoInteractions(processIdRedisDAO); + } + + @Test + public void testCreateProcessInstance_definitionNotExists() { + // 准备参数 + BpmProcessInstanceCreateReqVO reqVO = new BpmProcessInstanceCreateReqVO().setProcessDefinitionId("definition-1"); + + // 调用,并断言异常 + assertServiceException(() -> processInstanceService.createProcessInstance(1L, reqVO), + PROCESS_DEFINITION_NOT_EXISTS); + verifyNoInteractions(runtimeService); + } + + @Test + public void testCreateProcessInstance_dtoDefinitionNotExists() { + // 调用 + RuntimeException exception = assertThrows(RuntimeException.class, () -> processInstanceService + .createProcessInstance(1L, new BpmProcessInstanceCreateReqDTO().setProcessDefinitionKey("leave"))); + // 断言:ServiceException 被 FlowableUtils 包装,由全局异常处理器解包;认证用户需要被清理 + ServiceException serviceException = assertInstanceOf(ServiceException.class, exception.getCause()); + assertEquals(PROCESS_DEFINITION_NOT_EXISTS.getCode(), serviceException.getCode()); + assertNull(Authentication.getAuthenticatedUserId()); + } + + @Test + public void testCreateProcessInstance_definitionSuspended() { + // 准备参数 + when(processDefinitionService.getProcessDefinition("definition-1")).thenReturn(processDefinition); + when(processDefinition.getId()).thenReturn("definition-1"); + when(processDefinition.isSuspended()).thenReturn(true); + + // 调用,并断言异常 + assertServiceException(() -> processInstanceService.createProcessInstance(1L, + new BpmProcessInstanceCreateReqVO().setProcessDefinitionId("definition-1")), PROCESS_DEFINITION_IS_SUSPENDED); + } + + @Test + public void testCreateProcessInstance_definitionInfoNotExists() { + // 准备参数 + when(processDefinitionService.getProcessDefinition("definition-1")).thenReturn(processDefinition); + when(processDefinition.getId()).thenReturn("definition-1"); + + // 调用,并断言异常 + assertServiceException(() -> processInstanceService.createProcessInstance(1L, + new BpmProcessInstanceCreateReqVO().setProcessDefinitionId("definition-1")), PROCESS_DEFINITION_NOT_EXISTS); + } + + @Test + public void testCreateProcessInstance_userCannotStart() { + // 准备参数 + when(processDefinitionService.getProcessDefinition("definition-1")).thenReturn(processDefinition); + when(processDefinition.getId()).thenReturn("definition-1"); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")).thenReturn(buildProcessDefinitionInfo()); + + // 调用,并断言异常 + assertServiceException(() -> processInstanceService.createProcessInstance(1L, + new BpmProcessInstanceCreateReqVO().setProcessDefinitionId("definition-1")), + PROCESS_INSTANCE_START_USER_CAN_START); + verifyNoInteractions(runtimeService); + } + + @Test + public void testCreateProcessInstance_formFieldRequired() { + // 准备参数:必填标记为数组 ["required"],字段未填写 + when(processDefinitionService.getProcessDefinition("definition-1")).thenReturn(processDefinition); + when(processDefinition.getId()).thenReturn("definition-1"); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")).thenReturn(buildProcessDefinitionInfo() + .setFormType(BpmModelFormTypeEnum.NORMAL.getType()) + .setFormFields(List.of(buildFormField("day", "请假天数", List.of("required"))))); + + // 调用,并断言异常 + assertServiceException(() -> processInstanceService.createProcessInstance(1L, + new BpmProcessInstanceCreateReqVO().setProcessDefinitionId("definition-1")), + PROCESS_INSTANCE_START_FORM_FIELD_REQUIRED, "请假天数"); + verifyNoInteractions(runtimeService); + } + + @Test + public void testCreateProcessInstance_formFieldRequiredBlank() { + // 准备参数:字段标识为 key,必填标记为对象 {"required": true},字段值为空白字符串 + when(processDefinitionService.getProcessDefinition("definition-1")).thenReturn(processDefinition); + when(processDefinition.getId()).thenReturn("definition-1"); + String formField = JsonUtils.toJsonString(Map.of("key", "reason", "validate", Map.of("required", true))); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")).thenReturn(buildProcessDefinitionInfo() + .setFormType(BpmModelFormTypeEnum.NORMAL.getType()).setFormFields(List.of(formField))); + + // 调用,并断言异常:没有 title 时,使用字段标识提示 + assertServiceException(() -> processInstanceService.createProcessInstance(1L, + new BpmProcessInstanceCreateReqVO().setProcessDefinitionId("definition-1") + .setVariables(new HashMap<>(Map.of("reason", " ")))), + PROCESS_INSTANCE_START_FORM_FIELD_REQUIRED, "reason"); + } + + @Test + public void testCreateProcessInstance_startUserSelectAssigneesNotConfig() { + // 准备参数:预测到发起人自选节点,但未选择审批人 + BpmProcessInstanceServiceImpl spyService = spy(processInstanceService); + mockCreateProcessInstance(buildProcessDefinitionInfo()); + mockApprovalDetail(spyService, + buildActivityNode("task2", "二级审批", BpmTaskCandidateStrategyEnum.START_USER_SELECT)); + + // 调用,并断言异常 + assertServiceException(() -> spyService.createProcessInstance(1L, + new BpmProcessInstanceCreateReqVO().setProcessDefinitionId("definition-1")), + PROCESS_INSTANCE_START_USER_SELECT_ASSIGNEES_NOT_CONFIG, "二级审批"); + verifyNoInteractions(runtimeService); + } + + @Test + public void testCreateProcessInstance_startUserSelectAssigneesNotExists() { + // 准备参数:选择的审批人不存在 + BpmProcessInstanceServiceImpl spyService = spy(processInstanceService); + mockCreateProcessInstance(buildProcessDefinitionInfo()); + mockApprovalDetail(spyService, + buildActivityNode("task2", "二级审批", BpmTaskCandidateStrategyEnum.START_USER_SELECT)); + when(adminUserApi.getUserMap(List.of(2L))).thenReturn(Map.of()); + + // 调用,并断言异常 + assertServiceException(() -> spyService.createProcessInstance(1L, + new BpmProcessInstanceCreateReqVO().setProcessDefinitionId("definition-1") + .setStartUserSelectAssignees(Map.of("task2", List.of(2L)))), + PROCESS_INSTANCE_START_USER_SELECT_ASSIGNEES_NOT_EXISTS, "二级审批", 2L); + } + + // ========== cancelProcessInstance 相关 ========== + + @Test + public void testCancelProcessInstanceByAdmin_notExists() { + // 准备参数 + BpmProcessInstanceServiceImpl spyService = spy(processInstanceService); + doReturn(null).when(spyService).getProcessInstance("process-instance-1"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.cancelProcessInstanceByAdmin(1L, buildCancelReq()), + PROCESS_INSTANCE_CANCEL_FAIL_NOT_EXISTS); + } + + @Test + public void testCancelProcessInstanceByStartUser_notExists() { + // 准备参数 + BpmProcessInstanceServiceImpl spyService = spy(processInstanceService); + doReturn(null).when(spyService).getProcessInstance("process-instance-1"); + BpmProcessInstanceCancelReqVO reqVO = buildCancelReq(); + + // 调用,并断言异常 + assertServiceException(() -> spyService.cancelProcessInstanceByStartUser(1L, reqVO), + PROCESS_INSTANCE_CANCEL_FAIL_NOT_EXISTS); + } + + @Test + public void testCancelProcessInstanceByStartUser_notAllow() { + // 准备参数 + BpmProcessInstanceServiceImpl spyService = spy(processInstanceService); + doReturn(processInstance).when(spyService).getProcessInstance("process-instance-1"); + when(processInstance.getStartUserId()).thenReturn("1"); + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")) + .thenReturn(new BpmProcessDefinitionInfoDO().setAllowCancelRunningProcess(false)); + BpmProcessInstanceCancelReqVO reqVO = buildCancelReq(); + + // 调用,并断言异常 + assertServiceException(() -> spyService.cancelProcessInstanceByStartUser(1L, reqVO), + PROCESS_INSTANCE_CANCEL_FAIL_NOT_ALLOW); + } + + // ========== Event 事件相关方法 ========== + + @Test + public void testProcessProcessInstanceCompleted_approve() { + // 准备参数:流程结束时仍为审批中,说明审批通过;配置了流程后置通知 + mockCompletedProcessInstance(BpmProcessInstanceStatusEnum.RUNNING.getStatus(), null); + BpmModelMetaInfoVO.HttpRequestSetting setting = new BpmModelMetaInfoVO.HttpRequestSetting() + .setUrl("http://127.0.0.1/after"); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")) + .thenReturn(new BpmProcessDefinitionInfoDO().setProcessAfterTriggerSetting(setting)); + + // 调用 + try (MockedStatic httpRequestUtilsMockedStatic = mockStatic(BpmHttpRequestUtils.class)) { + processInstanceService.processProcessInstanceCompleted(processInstance); + // 断言:后置通知 + httpRequestUtilsMockedStatic.verify(() -> BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, + "http://127.0.0.1/after", null, null, true, null)); + } + // 断言:状态更新为审批通过 + verify(runtimeService).setVariable("process-instance-1", BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS, + BpmProcessInstanceStatusEnum.APPROVE.getStatus()); + // 断言:审批通过消息 + ArgumentCaptor messageCaptor = + ArgumentCaptor.forClass(BpmMessageSendWhenProcessInstanceApproveReqDTO.class); + verify(messageService).sendMessageWhenProcessInstanceApprove(messageCaptor.capture()); + assertEquals("process-instance-1", messageCaptor.getValue().getProcessInstanceId()); + assertEquals("请假流程", messageCaptor.getValue().getProcessInstanceName()); + assertEquals(1L, messageCaptor.getValue().getStartUserId()); + // 断言:状态事件 + BpmProcessInstanceStatusEvent event = captureStatusEvent(); + assertEquals("process-instance-1", event.getId()); + assertEquals(BpmProcessInstanceStatusEnum.APPROVE.getStatus(), event.getStatus()); + assertEquals("leave", event.getProcessDefinitionKey()); + assertEquals("business-1", event.getBusinessKey()); + } + + @Test + public void testProcessProcessInstanceCompleted_childRejectRolledBack() { + // 准备参数 + BpmProcessInstanceServiceImpl spyService = spy(processInstanceService); + ProcessInstance parentProcessInstance = mockChildRejectProcessInstance(spyService); + + // 调用:事务回滚 + executeWithSynchronization(() -> spyService.processProcessInstanceCompleted(processInstance), + TransactionSynchronization.STATUS_ROLLED_BACK); + // 断言:回滚时,不结束父流程 + verify(spyService).updateProcessInstanceReject(parentProcessInstance, + BpmReasonEnum.REJECT_CHILD_PROCESS.getReason()); + verifyNoInteractions(taskService); + } + + @Test + public void testProcessProcessInstanceCreated_definitionNotExists() { + // 准备参数 + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + + // 调用 + executeWithSynchronization(() -> processInstanceService.processProcessInstanceCreated(processInstance), + TransactionSynchronization.STATUS_COMMITTED); + // 断言 + verifyNoInteractions(runtimeService, adminUserApi); + } + + @Test + public void testProcessProcessInstanceCreated_titleAndBeforeTrigger() { + // 准备参数:开启标题设置,并配置流程前置通知 + BpmModelMetaInfoVO.HttpRequestSetting setting = new BpmModelMetaInfoVO.HttpRequestSetting() + .setUrl("http://127.0.0.1/before"); + mockCreatedProcessInstance(buildProcessDefinitionInfo() + .setTitleSetting(new BpmModelMetaInfoVO.TitleSetting().setEnable(true) + .setTitle("{" + BpmnVariableConstants.PROCESS_DEFINITION_NAME + "}-{day}天")) + .setProcessBeforeTriggerSetting(setting)); + when(processInstance.getName()).thenReturn("请假"); + + // 调用 + try (MockedStatic httpRequestUtilsMockedStatic = mockStatic(BpmHttpRequestUtils.class)) { + executeWithSynchronization(() -> processInstanceService.processProcessInstanceCreated(processInstance), + TransactionSynchronization.STATUS_COMMITTED); + // 断言 + verify(runtimeService).setProcessInstanceName("process-instance-1", "请假-3天"); + httpRequestUtilsMockedStatic.verify(() -> BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, + "http://127.0.0.1/before", null, null, true, null)); + } + } + + @Test + public void testProcessProcessInstanceCreated_nameNotChanged() { + // 准备参数:未开启标题设置,名称与流程定义一致 + mockCreatedProcessInstance(buildProcessDefinitionInfo()); + when(processInstance.getName()).thenReturn("请假"); + + // 调用 + try (MockedStatic httpRequestUtilsMockedStatic = mockStatic(BpmHttpRequestUtils.class)) { + executeWithSynchronization(() -> processInstanceService.processProcessInstanceCreated(processInstance), + TransactionSynchronization.STATUS_COMMITTED); + // 断言:名称未变化时,不更新;未配置前置通知时,不发起请求 + verify(runtimeService, never()).setProcessInstanceName(anyString(), anyString()); + httpRequestUtilsMockedStatic.verifyNoInteractions(); + } + } + + // ========== 私有方法 ========== + + private static BpmProcessInstanceCancelReqVO buildCancelReq() { + return new BpmProcessInstanceCancelReqVO().setId("process-instance-1").setReason("撤销申请"); + } + + private static BpmProcessDefinitionInfoDO buildProcessDefinitionInfo() { + return new BpmProcessDefinitionInfoDO().setProcessDefinitionId("definition-1"); + } + + private static String buildFormField(String field, String title, Object validate) { + return JsonUtils.toJsonString(Map.of("field", field, "title", title, "validate", validate)); + } + + private static BpmApprovalDetailRespVO.ActivityNode buildActivityNode(String id, String name, + BpmTaskCandidateStrategyEnum strategy) { + return new BpmApprovalDetailRespVO.ActivityNode().setId(id).setName(name) + .setCandidateStrategy(strategy.getStrategy()); + } + + private void mockCreateProcessInstance(BpmProcessDefinitionInfoDO processDefinitionInfo) { + when(processDefinitionService.getProcessDefinition("definition-1")).thenReturn(processDefinition); + when(processDefinition.getId()).thenReturn("definition-1"); + lenient().when(processDefinition.getName()).thenReturn(" 请假 "); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")).thenReturn(processDefinitionInfo); + when(processDefinitionService.canUserStartProcessDefinition(processDefinitionInfo, 1L)).thenReturn(true); + } + + /** + * 模拟发起流程时的审批节点预测结果 + */ + private void mockApprovalDetail(BpmProcessInstanceServiceImpl spyService, + BpmApprovalDetailRespVO.ActivityNode... activityNodes) { + doReturn(new BpmApprovalDetailRespVO().setActivityNodes(new ArrayList<>(List.of(activityNodes)))) + .when(spyService).getApprovalDetail(eq(1L), any()); + } + + private ProcessInstanceBuilder mockProcessInstanceBuilder() { + ProcessInstanceBuilder processInstanceBuilder = mock(ProcessInstanceBuilder.class, RETURNS_SELF); + when(runtimeService.createProcessInstanceBuilder()).thenReturn(processInstanceBuilder); + lenient().when(processInstanceBuilder.start()).thenReturn(processInstance); + when(processInstance.getId()).thenReturn("process-instance-1"); + return processInstanceBuilder; + } + + @SuppressWarnings("unchecked") + private static Map captureVariables(ProcessInstanceBuilder processInstanceBuilder) { + ArgumentCaptor> variablesCaptor = ArgumentCaptor.forClass(Map.class); + verify(processInstanceBuilder).variables(variablesCaptor.capture()); + return variablesCaptor.getValue(); + } + + private void mockCompletedProcessInstance(Integer status, String reason) { + Map variables = new HashMap<>(); + variables.put(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_STATUS, status); + variables.put(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_REASON, reason); + when(processInstance.getProcessVariables()).thenReturn(variables); + lenient().when(processInstance.getId()).thenReturn("process-instance-1"); + lenient().when(processInstance.getName()).thenReturn("请假流程"); + lenient().when(processInstance.getStartUserId()).thenReturn("1"); + lenient().when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + lenient().when(processInstance.getProcessDefinitionKey()).thenReturn("leave"); + lenient().when(processInstance.getBusinessKey()).thenReturn("business-1"); + } + + private ProcessInstance mockChildRejectProcessInstance(BpmProcessInstanceServiceImpl spyService) { + mockCompletedProcessInstance(BpmProcessInstanceStatusEnum.REJECT.getStatus(), "不同意"); + when(processInstance.getSuperExecutionId()).thenReturn("super-execution-1"); + ExecutionQuery executionQuery = mock(ExecutionQuery.class, RETURNS_SELF); + when(runtimeService.createExecutionQuery()).thenReturn(executionQuery); + Execution execution = mock(Execution.class); + when(execution.getProcessInstanceId()).thenReturn("parent-process-instance-1"); + when(executionQuery.singleResult()).thenReturn(execution); + ProcessInstance parentProcessInstance = mock(ProcessInstance.class); + lenient().when(parentProcessInstance.getId()).thenReturn("parent-process-instance-1"); + when(parentProcessInstance.getProcessInstanceId()).thenReturn("parent-process-instance-1"); + doReturn(parentProcessInstance).when(spyService).getProcessInstance("parent-process-instance-1"); + return parentProcessInstance; + } + + private void mockCreatedProcessInstance(BpmProcessDefinitionInfoDO processDefinitionInfo) { + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + lenient().when(processInstance.getProcessInstanceId()).thenReturn("process-instance-1"); + when(processInstance.getStartUserId()).thenReturn("1"); + lenient().when(processInstance.getProcessVariables()).thenReturn(Map.of("day", 3)); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")).thenReturn(processDefinitionInfo); + when(processDefinitionService.getProcessDefinition("definition-1")).thenReturn(processDefinition); + when(processDefinition.getName()).thenReturn("请假"); + lenient().when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setNickname("张三")); + } + + private BpmProcessInstanceStatusEvent captureStatusEvent() { + ArgumentCaptor eventCaptor = + ArgumentCaptor.forClass(BpmProcessInstanceStatusEvent.class); + verify(processInstanceEventPublisher).sendProcessInstanceResultEvent(eventCaptor.capture()); + return eventCaptor.getValue(); + } + + /** + * 在事务同步上下文中执行,并模拟事务提交(或回滚)回调 + */ + private static void executeWithSynchronization(Runnable runnable, int transactionStatus) { + TransactionSynchronizationManager.initSynchronization(); + try { + runnable.run(); + List synchronizations = TransactionSynchronizationManager.getSynchronizations(); + if (transactionStatus == TransactionSynchronization.STATUS_COMMITTED) { + synchronizations.forEach(TransactionSynchronization::afterCommit); + } + synchronizations.forEach(synchronization -> synchronization.afterCompletion(transactionStatus)); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImplTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImplTest.java new file mode 100644 index 0000000..6934443 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/BpmTaskServiceImplTest.java @@ -0,0 +1,988 @@ +package cn.iocoder.yudao.module.bpm.service.task; + +import cn.hutool.core.io.resource.ResourceUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.BpmModelMetaInfoVO; +import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.*; +import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO; +import cn.iocoder.yudao.module.bpm.enums.definition.*; +import cn.iocoder.yudao.module.bpm.enums.task.BpmAttachmentTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmCommentTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmReasonEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskSignTypeEnum; +import cn.iocoder.yudao.module.bpm.enums.task.BpmTaskStatusEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmTaskCandidateStrategyEnum; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnModelConstants; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnVariableConstants; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmHttpRequestUtils; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmnModelUtils; +import cn.iocoder.yudao.module.bpm.service.comment.BpmCommentService; +import cn.iocoder.yudao.module.bpm.service.definition.BpmModelService; +import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService; +import cn.iocoder.yudao.module.bpm.service.message.BpmMessageService; +import cn.iocoder.yudao.module.bpm.service.message.dto.BpmMessageSendWhenTaskCreatedReqDTO; +import cn.iocoder.yudao.module.system.api.dept.DeptApi; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import org.flowable.bpmn.model.BpmnModel; +import org.flowable.bpmn.model.FlowElement; +import org.flowable.bpmn.model.Process; +import org.flowable.bpmn.model.UserTask; +import org.flowable.engine.HistoryService; +import org.flowable.engine.RuntimeService; +import org.flowable.engine.TaskService; +import org.flowable.engine.impl.persistence.entity.AttachmentEntityImpl; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.engine.task.Attachment; +import org.flowable.task.api.Task; +import org.flowable.task.api.TaskQuery; +import org.flowable.task.api.history.HistoricTaskInstance; +import org.flowable.task.api.history.HistoricTaskInstanceQuery; +import org.flowable.task.service.impl.persistence.entity.TaskEntityImpl; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException; +import static cn.iocoder.yudao.module.bpm.enums.ErrorCodeConstants.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link BpmTaskServiceImpl} 的单元测试 + * + * @author HUIHUI + */ +public class BpmTaskServiceImplTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmTaskServiceImpl taskService; + + @Mock + private TaskService flowableTaskService; + @Mock + private Task task; + @Mock + private HistoryService historyService; + @Mock + private RuntimeService runtimeService; + @Mock + private BpmProcessInstanceService processInstanceService; + @Mock + private BpmProcessDefinitionService processDefinitionService; + @Mock + private BpmProcessInstanceCopyService processInstanceCopyService; + @Mock + private BpmCommentService commentService; + @Mock + private BpmModelService modelService; + @Mock + private BpmMessageService messageService; + @Mock + private AdminUserApi adminUserApi; + @Mock + private DeptApi deptApi; + @Mock + private ProcessInstance processInstance; + + /** + * 自身代理对象,用于验证 getSelf() 发起的自动审批、自动拒绝、转办 + */ + @Mock + private BpmTaskServiceImpl selfService; + + private MockedStatic springUtilMockedStatic; + + @BeforeEach + public void setUp() { + springUtilMockedStatic = mockStatic(SpringUtil.class); + springUtilMockedStatic.when(() -> SpringUtil.getBean(any(Class.class))).thenReturn(selfService); + } + + @AfterEach + public void tearDown() { + springUtilMockedStatic.close(); + } + + @Test + public void testValidateTask_success() { + // 准备参数:通过 spy 绕过 Flowable 查询,只验证负责人校验 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTaskExists("task-1"); + when(task.getAssignee()).thenReturn("1"); + + // 调用 + Task result = spyService.validateTask(1L, "task-1"); + // 断言 + assertSame(task, result); + } + + @Test + public void testValidateTask_assigneeNotSelf() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTaskExists("task-1"); + when(task.getAssignee()).thenReturn("2"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.validateTask(1L, "task-1"), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + } + + @Test + public void testValidateTask_noAssignee_success() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTaskExists("task-1"); + when(task.getAssignee()).thenReturn(null); + + // 调用 + Task result = spyService.validateTask(null, "task-1"); + // 断言 + assertSame(task, result); + } + + @Test + public void testValidateTask_ownerOnly() { + // 准备参数:加签等待中的任务,只有 owner 没有 assignee + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTaskExists("task-1"); + when(task.getAssignee()).thenReturn(null); + when(task.getOwner()).thenReturn("1"); + + // 调用,并断言异常:owner 自己、其它用户、无 userId 都不允许操作 + assertServiceException(() -> spyService.validateTask(1L, "task-1"), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> spyService.validateTask(2L, "task-1"), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + assertServiceException(() -> spyService.validateTask(null, "task-1"), TASK_OPERATE_FAIL_ASSIGN_NOT_SELF); + } + + @Test + public void testValidateTask_approving() { + // 准备参数:向后加签的父任务,已审批通过,等待加签任务审批 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTaskExists("task-1"); + when(task.getAssignee()).thenReturn("1"); + when(task.getTaskLocalVariables()).thenReturn(Map.of(BpmnVariableConstants.TASK_VARIABLE_STATUS, + BpmTaskStatusEnum.APPROVING.getStatus())); + + // 调用,并断言异常 + assertServiceException(() -> spyService.validateTask(1L, "task-1"), TASK_OPERATE_FAIL_APPROVING); + } + + @Test + public void testValidateTaskExists_notExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(null).when(spyService).getTask("task-1"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.validateTaskExists("task-1"), TASK_NOT_EXISTS); + } + + // ========== Query 查询相关方法 ========== + + @Test + public void testGetAllChildrenTaskListByParentTaskId_success() { + // 准备参数:parent -> child1 -> child2;other 属于其它父任务 + TaskEntityImpl parent = buildTaskEntity("parent", null); + TaskEntityImpl child1 = buildTaskEntity("child1", "parent"); + TaskEntityImpl child2 = buildTaskEntity("child2", "child1"); + TaskEntityImpl other = buildTaskEntity("other", "unknown"); + + // 调用 + List result = taskService.getAllChildrenTaskListByParentTaskId("parent", + List.of(parent, child1, child2, other)); + // 断言 + assertEquals(List.of("child1", "child2"), result.stream().map(TaskEntityImpl::getId).toList()); + assertTrue(taskService.getAllChildrenTaskListByParentTaskId("parent", List.of(parent)).isEmpty()); + assertTrue(taskService.getAllChildrenTaskListByParentTaskId("parent", List.of()).isEmpty()); + } + + @Test + public void testGetAttachments_filter() { + // 准备参数 + Attachment attachment1 = buildAttachment("task-1", BpmAttachmentTypeEnum.TASK_ATTACHMENT.getType()); + Attachment attachment2 = buildAttachment("task-2", BpmAttachmentTypeEnum.TASK_ATTACHMENT.getType()); + Attachment attachment3 = buildAttachment("task-1", "other"); + when(flowableTaskService.getProcessInstanceAttachments("process-instance-1")) + .thenReturn(List.of(attachment1, attachment2, attachment3)); + + // 调用 + List result = taskService.getAttachments("process-instance-1", Set.of("task-1"), + BpmAttachmentTypeEnum.TASK_ATTACHMENT); + // 断言 + assertEquals(List.of(attachment1), result); + assertEquals(3, taskService.getAttachments("process-instance-1", null, null).size()); + } + + // ========== Update 写入相关方法 ========== + + @Test + public void testApproveTask_processInstanceNotExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.approveTask(1L, new BpmTaskApproveReqVO().setId("task-1")), + PROCESS_INSTANCE_NOT_EXISTS); + } + + @Test + public void testApproveTask_signatureNotExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + mockApproveTask(spyService, BpmnModelConstants.SIGN_ENABLE); + + // 调用,并断言异常 + assertServiceException(() -> spyService.approveTask(1L, new BpmTaskApproveReqVO().setId("task-1") + .setReason("同意")), TASK_SIGNATURE_NOT_EXISTS); + } + + @Test + public void testApproveTask_reasonRequire() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + mockApproveTask(spyService, BpmnModelConstants.REASON_REQUIRE); + + // 调用,并断言异常 + assertServiceException(() -> spyService.approveTask(1L, new BpmTaskApproveReqVO().setId("task-1")), + TASK_REASON_REQUIRE); + } + + @Test + public void testApproveTask_success() { + // 准备参数:task1 开启签名,下一个节点 task2 为审批人自选;task1 曾被退回过 + BpmTaskServiceImpl spyService = spy(taskService); + BpmnModel bpmnModel = mockApproveTaskSuccess(spyService, BpmTaskCandidateStrategyEnum.APPROVE_USER_SELECT, + Map.of("reason", "事假")); + BpmnModelUtils.addExtensionElement(bpmnModel.getFlowElement("task1"), BpmnModelConstants.SIGN_ENABLE, "true"); + when(runtimeService.getVariable("process-instance-1", + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_NEED_SIMULATE_TASK_IDS)) + .thenReturn(new HashSet<>(Set.of("task1", "task2"))); + String returnFlagKey = String.format(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_RETURN_FLAG, "task1"); + when(runtimeService.hasVariable("process-instance-1", returnFlagKey)).thenReturn(true); + BpmTaskApproveReqVO reqVO = new BpmTaskApproveReqVO().setId("task-1").setReason("同意") + .setSignPicUrl("https://www.iocoder.cn/sign.png") + .setAttachments(List.of("https://www.iocoder.cn/files/a.pdf")) + .setVariables(Map.of("day", 3)) + .setNextAssignees(Map.of("task2", List.of(2L))); + + // 调用 + spyService.approveTask(1L, reqVO); + // 断言:任务状态、签名、评论、附件 + verify(flowableTaskService).setVariableLocal("task-1", BpmnVariableConstants.TASK_VARIABLE_STATUS, + BpmTaskStatusEnum.APPROVE.getStatus()); + verify(flowableTaskService).setVariableLocal("task-1", BpmnVariableConstants.TASK_VARIABLE_REASON, "同意"); + verify(flowableTaskService).setVariableLocal("task-1", BpmnVariableConstants.TASK_SIGN_PIC_URL, + "https://www.iocoder.cn/sign.png"); + verify(commentService).createComment("task-1", "process-instance-1", BpmCommentTypeEnum.APPROVE, "同意"); + verify(flowableTaskService).createAttachment(BpmAttachmentTypeEnum.TASK_ATTACHMENT.getType(), "task-1", + "process-instance-1", "a.pdf", null, "https://www.iocoder.cn/files/a.pdf"); + // 断言:流程变量合并历史变量与前端变量,并记录下一个节点的审批人 + Map processVariables = captureProcessVariables(); + assertEquals("事假", processVariables.get("reason")); + assertEquals(3, processVariables.get("day")); + assertEquals(Map.of("task2", List.of(2L)), processVariables.get( + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_APPROVE_USER_SELECT_ASSIGNEES)); + // 断言:移除预测节点、清理退回标记,并完成任务 + verify(runtimeService).setVariable("process-instance-1", + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_NEED_SIMULATE_TASK_IDS, Set.of("task2")); + verify(runtimeService).removeVariable("process-instance-1", returnFlagKey); + verify(flowableTaskService).complete("task-1", Map.of("day", 3), true); + } + + @Test + public void testApproveTask_approveUserSelectNotConfig() { + // 准备参数:下一个节点 task2 为审批人自选,但未选择审批人 + BpmTaskServiceImpl spyService = spy(taskService); + mockApproveTaskSuccess(spyService, BpmTaskCandidateStrategyEnum.APPROVE_USER_SELECT, Map.of()); + + // 调用,并断言异常 + assertServiceException(() -> spyService.approveTask(1L, new BpmTaskApproveReqVO().setId("task-1") + .setReason("同意")), PROCESS_INSTANCE_APPROVE_USER_SELECT_ASSIGNEES_NOT_CONFIG, "二级审批"); + verify(flowableTaskService, never()).complete(anyString(), anyMap(), anyBoolean()); + } + + @Test + public void testApproveTask_startUserSelectNotConfig() { + // 准备参数:下一个节点 task2 为发起人自选,但发起时、审批时都未选择审批人 + BpmTaskServiceImpl spyService = spy(taskService); + mockApproveTaskSuccess(spyService, BpmTaskCandidateStrategyEnum.START_USER_SELECT, Map.of()); + + // 调用,并断言异常 + assertServiceException(() -> spyService.approveTask(1L, new BpmTaskApproveReqVO().setId("task-1") + .setReason("同意")), PROCESS_INSTANCE_START_USER_SELECT_ASSIGNEES_NOT_CONFIG, "二级审批"); + } + + @Test + public void testApproveTask_startUserSelectAlreadyConfig() { + // 准备参数:下一个节点 task2 为发起人自选,发起时已经选择审批人 + BpmTaskServiceImpl spyService = spy(taskService); + Map> startUserSelectAssignees = Map.of("task2", List.of(2L)); + mockApproveTaskSuccess(spyService, BpmTaskCandidateStrategyEnum.START_USER_SELECT, Map.of( + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_SELECT_ASSIGNEES, startUserSelectAssignees)); + + // 调用:审批时传入的审批人,不允许覆盖发起时选择的审批人 + spyService.approveTask(1L, new BpmTaskApproveReqVO().setId("task-1").setReason("同意") + .setNextAssignees(Map.of("task2", List.of(3L)))); + // 断言 + assertEquals(startUserSelectAssignees, captureProcessVariables().get( + BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_SELECT_ASSIGNEES)); + verify(flowableTaskService).complete("task-1", Map.of(), true); + verify(runtimeService, never()).removeVariable(anyString(), anyString()); + } + + @Test + public void testMoveTaskToEnd_noRunningTask() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(List.of()).when(spyService).getRunningTaskListByProcessInstanceId("process-instance-1", null, null); + + // 调用 + spyService.moveTaskToEnd("process-instance-1", "结束流程"); + // 断言 + verifyNoInteractions(runtimeService, modelService); + } + + @Test + public void testRejectTask_processInstanceNotExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.rejectTask(1L, new BpmTaskRejectReqVO().setId("task-1") + .setReason("不同意")), PROCESS_INSTANCE_NOT_EXISTS); + } + + @Test + public void testReturnTask_pending() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.isSuspended()).thenReturn(true); + + // 调用,并断言异常 + assertServiceException(() -> spyService.returnTask(1L, new BpmTaskReturnReqVO().setId("task-1") + .setTargetTaskDefinitionKey("task1").setReason("退回")), TASK_IS_PENDING); + } + + @Test + public void testDelegateTask_userNotExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.getAssignee()).thenReturn("1"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.delegateTask(1L, new BpmTaskDelegateReqVO().setId("task-1") + .setDelegateUserId(2L).setReason("委派")), TASK_DELEGATE_FAIL_USER_NOT_EXISTS); + } + + @Test + public void testDelegateTask_emptyAssignee_userNotExists() { + // 准备参数:自动审批或审批人为空时,任务可能没有 assignee + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.getAssignee()).thenReturn(null); + + // 调用,并断言:不应因 assignee 为空触发空指针 + assertServiceException(() -> spyService.delegateTask(1L, new BpmTaskDelegateReqVO().setId("task-1") + .setDelegateUserId(2L).setReason("委派")), TASK_DELEGATE_FAIL_USER_NOT_EXISTS); + } + + @Test + public void testTransferTask_userRepeat() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.getAssignee()).thenReturn("1"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.transferTask(1L, new BpmTaskTransferReqVO().setId("task-1") + .setAssigneeUserId(1L).setReason("转办")), TASK_TRANSFER_FAIL_USER_REPEAT); + } + + @Test + public void testTransferTask_userNotExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.getAssignee()).thenReturn("1"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.transferTask(1L, new BpmTaskTransferReqVO().setId("task-1") + .setAssigneeUserId(2L).setReason("转办")), TASK_TRANSFER_FAIL_USER_NOT_EXISTS); + } + + @Test + public void testTransferTask_alreadyHasOwner() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.getAssignee()).thenReturn("1"); + when(task.getOwner()).thenReturn("3"); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + when(adminUserApi.getUser(2L)).thenReturn(new AdminUserRespDTO().setId(2L).setNickname("李四")); + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setNickname("张三")); + + // 调用 + spyService.transferTask(1L, new BpmTaskTransferReqVO().setId("task-1").setAssigneeUserId(2L).setReason("转办")); + // 断言:已经存在 owner 时,不覆盖 owner + verify(flowableTaskService, never()).setOwner(anyString(), anyString()); + verify(flowableTaskService).setAssignee("task-1", "2"); + verify(commentService).createComment(eq("task-1"), eq("process-instance-1"), + eq(BpmCommentTypeEnum.TRANSFER), eq("张三"), eq("李四"), eq("转办")); + } + + @Test + public void testCreateSignTask_userNotExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + TaskEntityImpl taskEntity = buildTaskEntity("task-1", null); + taskEntity.setProcessInstanceId("process-instance-1"); + taskEntity.setTaskDefinitionKey("task1"); + doReturn(taskEntity).when(spyService).validateTask(1L, "task-1"); + TaskQuery taskQuery = mock(TaskQuery.class, RETURNS_SELF); + when(flowableTaskService.createTaskQuery()).thenReturn(taskQuery); + when(taskQuery.list()).thenReturn(List.of()); + + // 调用,并断言异常 + assertServiceException(() -> spyService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId("task-1") + .setType(BpmTaskSignTypeEnum.BEFORE.getType()).setUserIds(Set.of(2L)).setReason("加签")), + TASK_SIGN_CREATE_USER_NOT_EXIST); + } + + @Test + public void testCreateSignTask_typeNotExists() { + // 调用,并断言异常 + assertServiceException(() -> taskService.createSignTask(1L, new BpmTaskSignCreateReqVO().setId("task-1") + .setType("unknown").setUserIds(Set.of(2L)).setReason("加签")), + TASK_SIGN_CREATE_TYPE_NOT_EXISTS, "unknown"); + } + + @Test + public void testDeleteSignTask_parentNotExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTaskExists("task-1"); + when(task.getParentTaskId()).thenReturn("parent-1"); + doReturn(null).when(spyService).getTask("parent-1"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId("task-1") + .setReason("减签")), TASK_SIGN_DELETE_NO_PARENT); + } + + @Test + public void testDeleteSignTask_parentNotSign() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).validateTaskExists("task-1"); + when(task.getParentTaskId()).thenReturn("parent-1"); + doReturn(buildTaskEntity("parent-1", null)).when(spyService).getTask("parent-1"); + + // 调用,并断言异常 + assertServiceException(() -> spyService.deleteSignTask(1L, new BpmTaskSignDeleteReqVO().setId("task-1") + .setReason("减签")), TASK_SIGN_DELETE_NO_PARENT); + } + + @Test + public void testCopyTask_success() { + // 准备参数 + BpmTaskCopyReqVO reqVO = new BpmTaskCopyReqVO().setId("task-1").setCopyUserIds(Set.of(2L)).setReason("抄送"); + + // 调用 + taskService.copyTask(1L, reqVO); + // 断言 + verify(processInstanceCopyService).createProcessInstanceCopy(Set.of(2L), "抄送", "task-1"); + } + + @Test + public void testWithdrawTask_processNotRunning() { + // 准备参数 + HistoricTaskInstance historicTask = mockWithdrawHistoricTask("task1"); + when(historicTask.getProcessInstanceId()).thenReturn("process-instance-1"); + + // 调用,并断言异常 + assertServiceException(() -> taskService.withdrawTask(1L, "task-1"), TASK_WITHDRAW_FAIL_PROCESS_NOT_RUNNING); + } + + @Test + public void testWithdrawTask_noNextUserTask() { + // 准备参数:task2 已经是最后一个审批节点 + HistoricTaskInstance historicTask = mockWithdrawHistoricTask("task2"); + when(historicTask.getProcessInstanceId()).thenReturn("process-instance-1"); + when(historicTask.getProcessDefinitionId()).thenReturn("definition-1"); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")) + .thenReturn(new BpmProcessDefinitionInfoDO().setAllowWithdrawTask(true)); + when(modelService.getBpmnModelByDefinitionId("definition-1")).thenReturn(buildTwoStepBpmnModel()); + + // 调用,并断言异常 + assertServiceException(() -> taskService.withdrawTask(1L, "task-1"), TASK_WITHDRAW_FAIL_NEXT_TASK_NOT_ALLOW); + } + + // ========== Event 事件相关方法 ========== + + @Test + public void testProcessTaskCreated_statusExists() { + // 准备参数 + when(task.getTaskLocalVariables()).thenReturn(Map.of(BpmnVariableConstants.TASK_VARIABLE_STATUS, + BpmTaskStatusEnum.RUNNING.getStatus())); + + // 调用 + taskService.processTaskCreated(task); + // 断言 + verify(flowableTaskService, never()).setVariableLocal(any(), any(), any()); + verifyNoInteractions(processInstanceService); + } + + @Test + public void testProcessTaskCreated_processInstanceNotExists() { + // 准备参数 + mockTask("1"); + + // 调用 + taskService.processTaskCreated(task); + // 断言:状态更新为审批中,但不再处理后续逻辑 + verify(flowableTaskService).setVariableLocal("task-1", BpmnVariableConstants.TASK_VARIABLE_STATUS, + BpmTaskStatusEnum.RUNNING.getStatus()); + verifyNoInteractions(processDefinitionService, modelService); + } + + @Test + public void testProcessTaskCreated_processDefinitionNotExists() { + // 准备参数 + mockTask("1"); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + + // 调用 + taskService.processTaskCreated(task); + // 断言 + verify(processDefinitionService).getProcessDefinitionInfo("definition-1"); + verifyNoInteractions(modelService); + } + + @Test + public void testProcessTaskCreated_userApproveHasAssignee() { + // 准备参数:人工审批且已有审批人,不自动处理 + mockProcessTaskCreated("1", Map.of(BpmnModelConstants.USER_TASK_APPROVE_TYPE, + BpmUserTaskApproveTypeEnum.USER.getType(), BpmnModelConstants.USER_TASK_ASSIGN_EMPTY_HANDLER_TYPE, + BpmUserTaskAssignEmptyHandlerTypeEnum.APPROVE.getType())); + + // 调用 + executeWithSynchronization(() -> taskService.processTaskCreated(task), + TransactionSynchronization.STATUS_COMMITTED); + // 断言 + verifyNoInteractions(selfService); + } + + @Test + public void testProcessTaskCreated_rolledBack() { + // 准备参数 + mockProcessTaskCreated("1", Map.of(BpmnModelConstants.USER_TASK_APPROVE_TYPE, + BpmUserTaskApproveTypeEnum.AUTO_APPROVE.getType())); + + // 调用 + executeWithSynchronization(() -> taskService.processTaskCreated(task), + TransactionSynchronization.STATUS_ROLLED_BACK); + // 断言 + verifyNoInteractions(selfService); + } + + @Test + public void testProcessTaskCreated_taskBeforeTrigger() { + // 准备参数:配置任务前置通知 + BpmModelMetaInfoVO.HttpRequestSetting setting = new BpmModelMetaInfoVO.HttpRequestSetting() + .setUrl("http://127.0.0.1/task-before"); + mockProcessTaskCreated("1", Map.of(BpmnModelConstants.USER_TASK_APPROVE_TYPE, + BpmUserTaskApproveTypeEnum.USER.getType()), + new BpmProcessDefinitionInfoDO().setTaskBeforeTriggerSetting(setting)); + + // 调用 + try (MockedStatic httpRequestUtilsMockedStatic = mockStatic(BpmHttpRequestUtils.class)) { + executeWithSynchronization(() -> taskService.processTaskCreated(task), + TransactionSynchronization.STATUS_COMMITTED); + // 断言 + httpRequestUtilsMockedStatic.verify(() -> BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, + "http://127.0.0.1/task-before", null, null, true, null)); + } + verifyNoInteractions(selfService); + } + + @Test + public void testProcessTaskCreated_unknownStatusTaskNotExists() { + // 准备参数:事务状态未知,且任务已经不存在(例如说,已被自动审批) + BpmTaskServiceImpl spyService = spy(taskService); + mockProcessTaskCreated("1", Map.of(BpmnModelConstants.USER_TASK_APPROVE_TYPE, + BpmUserTaskApproveTypeEnum.AUTO_APPROVE.getType())); + doReturn(null).when(spyService).getTask("task-1"); + + // 调用 + executeWithSynchronization(() -> spyService.processTaskCreated(task), + TransactionSynchronization.STATUS_UNKNOWN); + // 断言 + verifyNoInteractions(selfService); + } + + @Test + public void testProcessTaskCreated_unknownStatusTaskExists() { + // 准备参数:事务状态未知,但任务仍然存在 + BpmTaskServiceImpl spyService = spy(taskService); + mockProcessTaskCreated("1", Map.of(BpmnModelConstants.USER_TASK_APPROVE_TYPE, + BpmUserTaskApproveTypeEnum.AUTO_APPROVE.getType())); + doReturn(task).when(spyService).getTask("task-1"); + + // 调用 + executeWithSynchronization(() -> spyService.processTaskCreated(task), + TransactionSynchronization.STATUS_UNKNOWN); + // 断言:仍然执行自动审批 + assertEquals(BpmReasonEnum.APPROVE_TYPE_AUTO_APPROVE.getReason(), captureApproveTaskInternal(null).getReason()); + } + + @Test + public void testProcessTaskCanceled_notExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(null).when(spyService).getTask("task-1"); + + // 调用 + spyService.processTaskCanceled("task-1"); + // 断言 + verify(flowableTaskService, never()).setVariableLocal(any(), any(), any()); + } + + @Test + public void testProcessTaskCanceled_endStatus() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).getTask("task-1"); + when(task.getTaskLocalVariables()).thenReturn(Map.of(BpmnVariableConstants.TASK_VARIABLE_STATUS, + BpmTaskStatusEnum.APPROVE.getStatus())); + + // 调用 + spyService.processTaskCanceled("task-1"); + // 断言 + verify(flowableTaskService, never()).setVariableLocal(any(), any(), any()); + } + + @Test + public void testProcessTaskCanceled_success() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + doReturn(task).when(spyService).getTask("task-1"); + when(task.getTaskLocalVariables()).thenReturn(Map.of(BpmnVariableConstants.TASK_VARIABLE_STATUS, + BpmTaskStatusEnum.RUNNING.getStatus())); + + // 调用 + spyService.processTaskCanceled("task-1"); + // 断言 + verify(flowableTaskService).setVariableLocal("task-1", BpmnVariableConstants.TASK_VARIABLE_STATUS, + BpmTaskStatusEnum.CANCEL.getStatus()); + verify(flowableTaskService).setVariableLocal("task-1", BpmnVariableConstants.TASK_VARIABLE_REASON, + BpmReasonEnum.CANCEL_BY_SYSTEM.getReason()); + } + + @Test + public void testProcessTaskAssigned_noAssignee() { + // 准备参数 + when(task.getAssignee()).thenReturn(null); + + // 调用 + executeWithSynchronization(() -> taskService.processTaskAssigned(task), + TransactionSynchronization.STATUS_COMMITTED); + // 断言 + verifyNoInteractions(processInstanceService, messageService, selfService); + } + + @Test + public void testProcessTaskAssigned_rolledBack() { + // 调用 + executeWithSynchronization(() -> taskService.processTaskAssigned(task), + TransactionSynchronization.STATUS_ROLLED_BACK); + // 断言 + verifyNoInteractions(task, processInstanceService, messageService, selfService); + } + + @Test + public void testProcessTaskAssigned_unknownStatusTaskNotExists() { + // 准备参数:事务状态未知,且任务已经不存在 + BpmTaskServiceImpl spyService = spy(taskService); + when(task.getId()).thenReturn("task-1"); + doReturn(null).when(spyService).getTask("task-1"); + + // 调用 + executeWithSynchronization(() -> spyService.processTaskAssigned(task), + TransactionSynchronization.STATUS_UNKNOWN); + // 断言 + verifyNoInteractions(processInstanceService, messageService, selfService); + } + + @Test + public void testProcessTaskAssigned_processInstanceNotExists() { + // 准备参数 + when(task.getAssignee()).thenReturn("1"); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + + // 调用 + executeWithSynchronization(() -> taskService.processTaskAssigned(task), + TransactionSynchronization.STATUS_COMMITTED); + // 断言 + verifyNoInteractions(processDefinitionService, messageService, selfService); + } + + @Test + public void testProcessTaskAssigned_startUserSkipWhenReturn() { + // 准备参数:退回到该节点时,不自动跳过,而是发送通知 + mockProcessTaskAssigned("task2", "1", null); + when(processInstance.getStartUserId()).thenReturn("1"); + when(processInstance.getProcessInstanceId()).thenReturn("process-instance-1"); + when(runtimeService.getVariable("process-instance-1", + String.format(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_RETURN_FLAG, "task2"), Boolean.class)) + .thenReturn(true); + when(modelService.getBpmnModelByDefinitionId("definition-1")).thenReturn(buildBpmnModel("task2", + Map.of(BpmnModelConstants.USER_TASK_ASSIGN_START_USER_HANDLER_TYPE, + BpmUserTaskAssignStartUserHandlerTypeEnum.SKIP.getType()))); + when(adminUserApi.getUser(1L)).thenReturn(new AdminUserRespDTO().setId(1L).setNickname("张三")); + + // 调用 + executeWithSynchronization(() -> taskService.processTaskAssigned(task), + TransactionSynchronization.STATUS_COMMITTED); + // 断言 + verifyNoInteractions(selfService); + verify(messageService).sendMessageWhenTaskAssigned(any(BpmMessageSendWhenTaskCreatedReqDTO.class)); + } + + @Test + public void testProcessTaskCompleted_processInstanceNotExists() { + // 准备参数 + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + + // 调用 + taskService.processTaskCompleted(task); + // 断言 + verifyNoInteractions(processDefinitionService); + } + + @Test + public void testProcessTaskCompleted_noTriggerSetting() { + // 准备参数 + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")) + .thenReturn(new BpmProcessDefinitionInfoDO()); + + // 调用 + taskService.processTaskCompleted(task); + // 断言 + verify(processDefinitionService).getProcessDefinitionInfo("definition-1"); + } + + @Test + public void testProcessTaskCompleted_taskAfterTrigger() { + // 准备参数:配置任务后置通知 + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + BpmModelMetaInfoVO.HttpRequestSetting setting = new BpmModelMetaInfoVO.HttpRequestSetting() + .setUrl("http://127.0.0.1/task-after"); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")) + .thenReturn(new BpmProcessDefinitionInfoDO().setTaskAfterTriggerSetting(setting)); + + // 调用 + try (MockedStatic httpRequestUtilsMockedStatic = mockStatic(BpmHttpRequestUtils.class)) { + taskService.processTaskCompleted(task); + // 断言 + httpRequestUtilsMockedStatic.verify(() -> BpmHttpRequestUtils.executeBpmHttpRequest(processInstance, + "http://127.0.0.1/task-after", null, null, true, null)); + } + } + + @Test + public void testProcessTaskTimeout_processInstanceNotExists() { + // 调用 + taskService.processTaskTimeout("process-instance-1", "task1", + BpmUserTaskTimeoutHandlerTypeEnum.REMINDER.getType()); + // 断言 + verifyNoInteractions(flowableTaskService, messageService); + } + + @Test + public void testProcessTaskTimeout_taskNotExists() { + // 准备参数 + BpmTaskServiceImpl spyService = spy(taskService); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + doReturn(List.of()).when(spyService).getRunningTaskListByProcessInstanceId("process-instance-1", true, "task1"); + + // 调用 + spyService.processTaskTimeout("process-instance-1", "task1", + BpmUserTaskTimeoutHandlerTypeEnum.REMINDER.getType()); + // 断言 + verifyNoInteractions(messageService); + } + + // ========== 私有方法 ========== + + private static TaskEntityImpl buildTaskEntity(String id, String parentTaskId) { + TaskEntityImpl taskEntity = new TaskEntityImpl(); + taskEntity.setId(id); + taskEntity.setParentTaskId(parentTaskId); + return taskEntity; + } + + private static Attachment buildAttachment(String taskId, String type) { + AttachmentEntityImpl attachment = new AttachmentEntityImpl(); + attachment.setTaskId(taskId); + attachment.setType(type); + return attachment; + } + + /** + * 构建 task1 -> task2 的两级审批流程模型 + */ + private static BpmnModel buildTwoStepBpmnModel() { + return BpmnModelUtils.getBpmnModel(ResourceUtil.readBytes("bpmn/two-step-approve.bpmn20.xml")); + } + + /** + * 构建只有一个 UserTask 的流程模型 + */ + private static BpmnModel buildBpmnModel(String userTaskId, Map extensions) { + UserTask userTask = new UserTask(); + userTask.setId(userTaskId); + extensions.forEach((name, value) -> BpmnModelUtils.addExtensionElement(userTask, name, value)); + Process process = new Process(); + process.setId("process"); + process.addFlowElement(userTask); + BpmnModel bpmnModel = new BpmnModel(); + bpmnModel.addProcess(process); + return bpmnModel; + } + + private void mockApproveTask(BpmTaskServiceImpl spyService, String extensionName) { + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + when(task.getProcessDefinitionId()).thenReturn("definition-1"); + when(task.getTaskDefinitionKey()).thenReturn("task1"); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + BpmnModel bpmnModel = buildTwoStepBpmnModel(); + BpmnModelUtils.addExtensionElement(bpmnModel.getFlowElement("task1"), extensionName, "true"); + when(modelService.getBpmnModelByDefinitionId("definition-1")).thenReturn(bpmnModel); + } + + /** + * 模拟审批 task1:下一个节点 task2 使用指定的审批人策略 + */ + private BpmnModel mockApproveTaskSuccess(BpmTaskServiceImpl spyService, BpmTaskCandidateStrategyEnum task2Strategy, + Map processVariables) { + doReturn(task).when(spyService).validateTask(1L, "task-1"); + when(task.getId()).thenReturn("task-1"); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + when(task.getProcessDefinitionId()).thenReturn("definition-1"); + when(task.getTaskDefinitionKey()).thenReturn("task1"); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + when(processInstance.getProcessVariables()).thenReturn(processVariables); + BpmnModel bpmnModel = buildTwoStepBpmnModel(); + FlowElement task2 = bpmnModel.getFlowElement("task2"); + task2.getExtensionElements().remove(BpmnModelConstants.USER_TASK_CANDIDATE_STRATEGY); + BpmnModelUtils.addExtensionElement(task2, BpmnModelConstants.USER_TASK_CANDIDATE_STRATEGY, + task2Strategy.getStrategy()); + when(modelService.getBpmnModelByDefinitionId("definition-1")).thenReturn(bpmnModel); + return bpmnModel; + } + + @SuppressWarnings("unchecked") + private Map captureProcessVariables() { + ArgumentCaptor> variablesCaptor = ArgumentCaptor.forClass(Map.class); + verify(runtimeService).setVariables(eq("process-instance-1"), variablesCaptor.capture()); + return variablesCaptor.getValue(); + } + + private HistoricTaskInstance mockWithdrawHistoricTask(String taskDefinitionKey) { + HistoricTaskInstanceQuery historicTaskQuery = mockHistoricTaskQuery(); + HistoricTaskInstance historicTask = mock(HistoricTaskInstance.class); + lenient().when(historicTask.getTaskDefinitionKey()).thenReturn(taskDefinitionKey); + when(historicTaskQuery.singleResult()).thenReturn(historicTask); + return historicTask; + } + + private HistoricTaskInstanceQuery mockHistoricTaskQuery() { + HistoricTaskInstanceQuery historicTaskQuery = mock(HistoricTaskInstanceQuery.class, RETURNS_SELF); + when(historyService.createHistoricTaskInstanceQuery()).thenReturn(historicTaskQuery); + return historicTaskQuery; + } + + private void mockTask(String assignee) { + when(task.getId()).thenReturn("task-1"); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + when(task.getTaskLocalVariables()).thenReturn(new HashMap<>()); + lenient().when(task.getAssignee()).thenReturn(assignee); + } + + private void mockProcessTaskCreated(String assignee, Map extensions) { + mockProcessTaskCreated(assignee, extensions, new BpmProcessDefinitionInfoDO()); + } + + private void mockProcessTaskCreated(String assignee, Map extensions, + BpmProcessDefinitionInfoDO processDefinitionInfo) { + mockTask(assignee); + when(task.getTaskDefinitionKey()).thenReturn("task1"); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")).thenReturn(processDefinitionInfo); + when(modelService.getBpmnModelByDefinitionId("definition-1")).thenReturn(buildBpmnModel("task1", extensions)); + } + + private void mockProcessTaskAssigned(String taskDefinitionKey, String assignee, Integer autoApprovalType) { + when(task.getId()).thenReturn("task-1"); + when(task.getAssignee()).thenReturn(assignee); + when(task.getProcessInstanceId()).thenReturn("process-instance-1"); + when(task.getProcessDefinitionId()).thenReturn("definition-1"); + lenient().when(task.getTaskDefinitionKey()).thenReturn(taskDefinitionKey); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + lenient().when(processInstance.getProcessDefinitionId()).thenReturn("definition-1"); + when(processDefinitionService.getProcessDefinitionInfo("definition-1")) + .thenReturn(new BpmProcessDefinitionInfoDO().setAutoApprovalType(autoApprovalType)); + } + + /** + * 在事务同步上下文中执行,并模拟事务完成回调 + */ + private static void executeWithSynchronization(Runnable runnable, int transactionStatus) { + TransactionSynchronizationManager.initSynchronization(); + try { + runnable.run(); + TransactionSynchronizationManager.getSynchronizations() + .forEach(synchronization -> synchronization.afterCompletion(transactionStatus)); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + private BpmTaskApproveReqVO captureApproveTaskInternal(Long userId) { + ArgumentCaptor reqVOCaptor = ArgumentCaptor.forClass(BpmTaskApproveReqVO.class); + verify(selfService).approveTaskInternal(eq(userId), reqVOCaptor.capture()); + return reqVOCaptor.getValue(); + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/listener/BpmUserTaskListenerTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/listener/BpmUserTaskListenerTest.java new file mode 100644 index 0000000..6436141 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/listener/BpmUserTaskListenerTest.java @@ -0,0 +1,74 @@ +package cn.iocoder.yudao.module.bpm.service.task.listener; + +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmHttpRequestUtils; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmnModelUtils; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; +import org.flowable.common.engine.api.delegate.Expression; +import org.flowable.engine.runtime.ProcessInstance; +import org.flowable.task.service.delegate.DelegateTask; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link BpmUserTaskListener} 的单元测试 + * + * @author HUIHUI + */ +public class BpmUserTaskListenerTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmUserTaskListener userTaskListener; + + @Mock + private BpmProcessInstanceService processInstanceService; + @Mock + private Expression listenerConfig; + + @Test + @SuppressWarnings("unchecked") + public void testNotify_bodyNull() { + // mock 方法:监听器未配置 body + ProcessInstance processInstance = mock(ProcessInstance.class); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + BpmSimpleModelNodeVO.ListenerHandler listenerHandler = new BpmSimpleModelNodeVO.ListenerHandler() + .setPath("http://127.0.0.1/listener"); + // 准备参数 + DelegateTask delegateTask = mock(DelegateTask.class); + when(delegateTask.getProcessInstanceId()).thenReturn("process-instance-1"); + when(delegateTask.getAssignee()).thenReturn("1"); + when(delegateTask.getTaskDefinitionKey()).thenReturn("task1"); + when(delegateTask.getId()).thenReturn("task-1"); + + try (MockedStatic bpmnModelUtilsMockedStatic = mockStatic(BpmnModelUtils.class); + MockedStatic httpRequestUtilsMockedStatic = mockStatic(BpmHttpRequestUtils.class)) { + bpmnModelUtilsMockedStatic.when(() -> BpmnModelUtils.parseListenerConfig(listenerConfig)) + .thenReturn(listenerHandler); + + // 调用 + userTaskListener.notify(delegateTask); + + // 断言:body 为空时不会空指针,并追加任务相关的固定参数 + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(List.class); + httpRequestUtilsMockedStatic.verify(() -> BpmHttpRequestUtils.executeBpmHttpRequest(same(processInstance), + eq("http://127.0.0.1/listener"), isNull(), bodyCaptor.capture(), eq(false), isNull())); + List body = bodyCaptor.getValue(); + assertEquals(List.of("processInstanceId", "assignee", "taskDefinitionKey", "taskId"), + convertList(body, BpmSimpleModelNodeVO.HttpRequestParam::getKey)); + assertEquals(List.of("process-instance-1", "1", "task1", "task-1"), + convertList(body, BpmSimpleModelNodeVO.HttpRequestParam::getValue)); + } + } + +} diff --git a/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/trigger/http/BpmHttpCallbackTriggerTest.java b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/trigger/http/BpmHttpCallbackTriggerTest.java new file mode 100644 index 0000000..b2006f4 --- /dev/null +++ b/yudao-module-bpm/src/test/java/cn/iocoder/yudao/module/bpm/service/task/trigger/http/BpmHttpCallbackTriggerTest.java @@ -0,0 +1,61 @@ +package cn.iocoder.yudao.module.bpm.service.task.trigger.http; + +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.simple.BpmSimpleModelNodeVO; +import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmHttpRequestUtils; +import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService; +import org.flowable.engine.runtime.ProcessInstance; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockedStatic; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * {@link BpmHttpCallbackTrigger} 的单元测试 + * + * @author HUIHUI + */ +public class BpmHttpCallbackTriggerTest extends BaseMockitoUnitTest { + + @InjectMocks + private BpmHttpCallbackTrigger httpCallbackTrigger; + + @Mock + private BpmProcessInstanceService processInstanceService; + + @Test + @SuppressWarnings("unchecked") + public void testExecute_bodyNull() { + // mock 方法 + ProcessInstance processInstance = mock(ProcessInstance.class); + when(processInstanceService.getProcessInstance("process-instance-1")).thenReturn(processInstance); + // 准备参数:回调配置未配置 body + BpmSimpleModelNodeVO.TriggerSetting.HttpRequestTriggerSetting setting = + new BpmSimpleModelNodeVO.TriggerSetting.HttpRequestTriggerSetting(); + setting.setUrl("http://127.0.0.1/callback"); + setting.setCallbackTaskDefineKey("task1"); + + try (MockedStatic httpRequestUtilsMockedStatic = mockStatic(BpmHttpRequestUtils.class)) { + // 调用 + httpCallbackTrigger.execute("process-instance-1", JsonUtils.toJsonString(setting)); + + // 断言:body 为空时不会空指针,并追加回调的 taskDefineKey + ArgumentCaptor> bodyCaptor = ArgumentCaptor.forClass(List.class); + httpRequestUtilsMockedStatic.verify(() -> BpmHttpRequestUtils.executeBpmHttpRequest(same(processInstance), + eq("http://127.0.0.1/callback"), isNull(), bodyCaptor.capture(), eq(false), isNull())); + List body = bodyCaptor.getValue(); + assertEquals(1, body.size()); + assertEquals("taskDefineKey", body.get(0).getKey()); + assertEquals("task1", body.get(0).getValue()); + } + } + +} diff --git a/yudao-module-bpm/src/test/resources/application-unit-test.yaml b/yudao-module-bpm/src/test/resources/application-unit-test.yaml index dd9b95b..0b2b606 100644 --- a/yudao-module-bpm/src/test/resources/application-unit-test.yaml +++ b/yudao-module-bpm/src/test/resources/application-unit-test.yaml @@ -9,7 +9,7 @@ spring: # 数据源配置项 datasource: name: ruoyi-vue-pro - url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 + url: jdbc:h2:mem:testdb;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value,day; # MODE 使用 MySQL 模式;DATABASE_TO_UPPER 配置表和字段使用小写 driver-class-name: org.h2.Driver username: sa password: diff --git a/yudao-module-bpm/src/test/resources/bpmn/call-activity.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/call-activity.bpmn20.xml new file mode 100644 index 0000000..50fe187 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/call-activity.bpmn20.xml @@ -0,0 +1,21 @@ + + + + + + + + + + 3 + + + PT1H + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/delay-timer.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/delay-timer.bpmn20.xml new file mode 100644 index 0000000..b95d965 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/delay-timer.bpmn20.xml @@ -0,0 +1,21 @@ + + + + + + + + + + 2 + + + PT1H + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/form-permission.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/form-permission.bpmn20.xml new file mode 100644 index 0000000..3262057 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/form-permission.bpmn20.xml @@ -0,0 +1,29 @@ + + + + + + + + + 36 + + + + + + + + + 30 + + + + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/lifecycle/assign-empty-approve.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/assign-empty-approve.bpmn20.xml new file mode 100644 index 0000000..f54cd87 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/assign-empty-approve.bpmn20.xml @@ -0,0 +1,19 @@ + + + + + + + + + 30 + 1 + 1 + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/lifecycle/assign-empty-reject.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/assign-empty-reject.bpmn20.xml new file mode 100644 index 0000000..070b282 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/assign-empty-reject.bpmn20.xml @@ -0,0 +1,19 @@ + + + + + + + + + 30 + 1 + 2 + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/lifecycle/child-parent.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/child-parent.bpmn20.xml new file mode 100644 index 0000000..f18e484 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/child-parent.bpmn20.xml @@ -0,0 +1,29 @@ + + + + + + + + + 20 + + + + + + + + + + + + 30 + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/lifecycle/child.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/child.bpmn20.xml new file mode 100644 index 0000000..75b6cfc --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/child.bpmn20.xml @@ -0,0 +1,17 @@ + + + + + + + + + 30 + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/lifecycle/start-user-node.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/start-user-node.bpmn20.xml new file mode 100644 index 0000000..56039d8 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/start-user-node.bpmn20.xml @@ -0,0 +1,23 @@ + + + + + + + + + 36 + + + + + + 30 + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/lifecycle/start-user-skip.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/start-user-skip.bpmn20.xml new file mode 100644 index 0000000..18a05c4 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/start-user-skip.bpmn20.xml @@ -0,0 +1,18 @@ + + + + + + + + + 30 + 2 + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/lifecycle/start-user-transfer-dept-leader.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/start-user-transfer-dept-leader.bpmn20.xml new file mode 100644 index 0000000..fdd96a8 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/lifecycle/start-user-transfer-dept-leader.bpmn20.xml @@ -0,0 +1,18 @@ + + + + + + + + + 30 + 3 + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/multi-instance/call-activity.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/multi-instance/call-activity.bpmn20.xml new file mode 100644 index 0000000..3a6904b --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/multi-instance/call-activity.bpmn20.xml @@ -0,0 +1,78 @@ + + + + + + + + + 30 + + + + + + + + + + + + 1 + + + + + + + + 2 + ${ nrOfCompletedInstances/nrOfInstances >= 1.00} + + + + + + + + + + + + 2 + + + + + + + + ${ nrOfCompletedInstances/nrOfInstances >= 1.00} + + + + + + + + + + + + 3 + + + + + + + + ${ nrOfCompletedInstances/nrOfInstances >= 1.00} + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/multi-instance/user-task.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/multi-instance/user-task.bpmn20.xml new file mode 100644 index 0000000..7282736 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/multi-instance/user-task.bpmn20.xml @@ -0,0 +1,95 @@ + + + + + + + + + + 30 + 3 + + + ${ nrOfCompletedInstances > 0 } + + + + + + + + + + + + 30 + 2 + + + ${ nrOfCompletedInstances/nrOfInstances >= 1.00} + + + + + + + + + + + + 30 + 2 + + + ${ nrOfCompletedInstances/nrOfInstances >= 0.60} + + + + + + + + + + + + 30 + + + + + + 30 + 2 + 2 + multiReturn-first + + + ${ nrOfCompletedInstances/nrOfInstances >= 1.00} + + + + + + + + + + + + 30 + 4 + + + 1 + ${ nrOfCompletedInstances >= nrOfInstances } + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/simple-approve.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/simple-approve.bpmn20.xml new file mode 100644 index 0000000..5ff9772 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/simple-approve.bpmn20.xml @@ -0,0 +1,16 @@ + + + + + + + + 36 + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/timeout-approve.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/timeout-approve.bpmn20.xml new file mode 100644 index 0000000..1677694 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/timeout-approve.bpmn20.xml @@ -0,0 +1,26 @@ + + + + + + + + + 30 + + + + + 1 + 2 + + + PT1H + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/trigger-task.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/trigger-task.bpmn20.xml new file mode 100644 index 0000000..18a7122 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/trigger-task.bpmn20.xml @@ -0,0 +1,11 @@ + + + + + + + + + + diff --git a/yudao-module-bpm/src/test/resources/bpmn/two-step-approve.bpmn20.xml b/yudao-module-bpm/src/test/resources/bpmn/two-step-approve.bpmn20.xml new file mode 100644 index 0000000..5d18420 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/bpmn/two-step-approve.bpmn20.xml @@ -0,0 +1,24 @@ + + + + + + + + 30 + + + + + + 30 + 2 + task1 + + + + + + diff --git a/yudao-module-bpm/src/test/resources/sql/clean.sql b/yudao-module-bpm/src/test/resources/sql/clean.sql index d4f93bb..3e6ab26 100644 --- a/yudao-module-bpm/src/test/resources/sql/clean.sql +++ b/yudao-module-bpm/src/test/resources/sql/clean.sql @@ -1,3 +1,6 @@ DELETE FROM "bpm_form"; DELETE FROM "bpm_user_group"; DELETE FROM "bpm_category"; +DELETE FROM "bpm_process_expression"; +DELETE FROM "bpm_process_listener"; +DELETE FROM "bpm_oa_leave"; diff --git a/yudao-module-bpm/src/test/resources/sql/create_tables.sql b/yudao-module-bpm/src/test/resources/sql/create_tables.sql index 1034962..e2e6a45 100644 --- a/yudao-module-bpm/src/test/resources/sql/create_tables.sql +++ b/yudao-module-bpm/src/test/resources/sql/create_tables.sql @@ -41,3 +41,50 @@ CREATE TABLE IF NOT EXISTS "bpm_form" ( "deleted" bit NOT NULL DEFAULT FALSE, PRIMARY KEY ("id") ) COMMENT '动态表单'; + +CREATE TABLE IF NOT EXISTS "bpm_process_expression" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(63) NOT NULL, + "status" tinyint NOT NULL, + "expression" varchar(255) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + PRIMARY KEY ("id") +) COMMENT '流程表达式'; + +CREATE TABLE IF NOT EXISTS "bpm_process_listener" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "name" varchar(63) NOT NULL, + "status" tinyint NOT NULL, + "type" varchar(30) NOT NULL, + "event" varchar(30) NOT NULL, + "value_type" varchar(30) NOT NULL, + "value" varchar(255) NOT NULL, + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + PRIMARY KEY ("id") +) COMMENT '流程监听器'; + +CREATE TABLE IF NOT EXISTS "bpm_oa_leave" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "user_id" bigint NOT NULL, + "type" tinyint NOT NULL, + "reason" varchar(255) NOT NULL, + "start_time" timestamp NOT NULL, + "end_time" timestamp NOT NULL, + "day" bigint NOT NULL, + "status" tinyint NOT NULL, + "process_instance_id" varchar(64) DEFAULT '', + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + PRIMARY KEY ("id") +) COMMENT 'OA 请假申请'; diff --git a/yudao-module-bpm/src/test/resources/sql/flowable/clean.sql b/yudao-module-bpm/src/test/resources/sql/flowable/clean.sql new file mode 100644 index 0000000..53c866e --- /dev/null +++ b/yudao-module-bpm/src/test/resources/sql/flowable/clean.sql @@ -0,0 +1 @@ +DELETE FROM "bpm_process_definition_info"; diff --git a/yudao-module-bpm/src/test/resources/sql/flowable/create_tables.sql b/yudao-module-bpm/src/test/resources/sql/flowable/create_tables.sql new file mode 100644 index 0000000..bdbbec1 --- /dev/null +++ b/yudao-module-bpm/src/test/resources/sql/flowable/create_tables.sql @@ -0,0 +1,39 @@ +-- Flowable 集成测试(H2 LEGACY 模式)使用的业务表,不支持表注释语法 +CREATE TABLE IF NOT EXISTS "bpm_process_definition_info" ( + "id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY, + "process_definition_id" varchar(64) NOT NULL, + "model_id" varchar(64) NOT NULL, + "model_type" tinyint NOT NULL, + "category" varchar(64), + "icon" varchar(512), + "description" varchar(255), + "form_type" tinyint, + "form_id" bigint, + "form_conf" varchar(1000), + "form_fields" varchar(5000), + "form_custom_create_path" varchar(255), + "form_custom_view_path" varchar(255), + "simple_model" varchar(10000), + "visible" bit NOT NULL DEFAULT TRUE, + "sort" bigint, + "start_user_ids" varchar(255), + "start_dept_ids" varchar(255), + "manager_user_ids" varchar(255), + "allow_cancel_running_process" bit, + "allow_withdraw_task" bit, + "process_id_rule" varchar(255), + "auto_approval_type" tinyint, + "title_setting" varchar(512), + "summary_setting" varchar(512), + "process_before_trigger_setting" varchar(512), + "process_after_trigger_setting" varchar(512), + "task_before_trigger_setting" varchar(512), + "task_after_trigger_setting" varchar(512), + "print_template_setting" varchar(512), + "creator" varchar(64) DEFAULT '', + "create_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updater" varchar(64) DEFAULT '', + "update_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + "deleted" bit NOT NULL DEFAULT FALSE, + PRIMARY KEY ("id") +); diff --git a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/codegen/vo/table/CodegenTableSaveReqVO.java b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/codegen/vo/table/CodegenTableSaveReqVO.java index 5f12e3d..c57e195 100644 --- a/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/codegen/vo/table/CodegenTableSaveReqVO.java +++ b/yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/codegen/vo/table/CodegenTableSaveReqVO.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import lombok.Data; @Schema(description = "管理后台 - 代码生成表定义创建/修改 Response VO") @@ -33,14 +34,17 @@ public class CodegenTableSaveReqVO { @Schema(description = "模块名", requiredMode = Schema.RequiredMode.REQUIRED, example = "system") @NotNull(message = "模块名不能为空") + @Pattern(regexp = "^[a-z][a-z0-9]*$", message = "模块名只能由小写字母和数字组成,且不能以数字开头") private String moduleName; @Schema(description = "业务名", requiredMode = Schema.RequiredMode.REQUIRED, example = "codegen") @NotNull(message = "业务名不能为空") + @Pattern(regexp = "^[a-z][a-z0-9]*$", message = "业务名只能由小写字母和数字组成,且不能以数字开头") private String businessName; @Schema(description = "类名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "CodegenTable") @NotNull(message = "类名称不能为空") + @Pattern(regexp = "^[A-Z][A-Za-z0-9]*$", message = "类名称必须以大写字母开头,且只能由字母和数字组成") private String className; @Schema(description = "类描述", requiredMode = Schema.RequiredMode.REQUIRED, example = "代码生成器的表定义") diff --git a/yudao-module-infra/src/main/resources/codegen/vue/views/components/form_sub_normal.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue/views/components/form_sub_normal.vue.vm index 48a404a..f3765d5 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue/views/components/form_sub_normal.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue/views/components/form_sub_normal.vue.vm @@ -244,9 +244,13 @@ Editor, #end }, - props:[ - '${subJoinColumn.javaField}' - ],// ${subJoinColumn.columnComment}(主表的关联字段) + props: { + // ${subJoinColumn.columnComment}(主表的关联字段) + ${subJoinColumn.javaField}: { + type: #if ($subJoinColumn.javaType == "String")String#else Number#end, + default: undefined + } + }, data() { return { // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用 diff --git a/yudao-module-infra/src/main/resources/codegen/vue/views/components/list_sub_erp.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue/views/components/list_sub_erp.vue.vm index 3f290cc..72114b0 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue/views/components/list_sub_erp.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue/views/components/list_sub_erp.vue.vm @@ -78,7 +78,7 @@ #if ($table.templateType == 11) - <${subSimpleClassName}Form ref="formRef" @success="getList" /> @@ -98,9 +98,13 @@ ${subSimpleClassName}Form }, #end - props:[ - '${subJoinColumn.javaField}' - ],// ${subJoinColumn.columnComment}(主表的关联字段) + props: { + // ${subJoinColumn.columnComment}(主表的关联字段) + ${subJoinColumn.javaField}: { + type: #if ($subJoinColumn.javaType == "String")String#else Number#end, + default: undefined + } + }, data() { return { // 遮罩层 diff --git a/yudao-module-infra/src/main/resources/codegen/vue/views/index.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue/views/index.vue.vm index d687daa..4f5ddee 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue/views/index.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue/views/index.vue.vm @@ -86,7 +86,7 @@ #end - + #if ($importEnable) @@ -186,7 +186,7 @@ ## 特殊:树表专属逻辑(树不需要分页) #if ( $table.templateType != 2 ) - #end diff --git a/yudao-module-infra/src/main/resources/codegen/vue3/views/components/form_sub_erp.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3/views/components/form_sub_erp.vue.vm index 8277e9b..da1f9e0 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3/views/components/form_sub_erp.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3/views/components/form_sub_erp.vue.vm @@ -79,7 +79,7 @@ {{ dict.label }} diff --git a/yudao-module-infra/src/main/resources/codegen/vue3/views/components/form_sub_normal.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3/views/components/form_sub_normal.vue.vm index 2212d4a..b958b47 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3/views/components/form_sub_normal.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3/views/components/form_sub_normal.vue.vm @@ -111,7 +111,7 @@ {{ dict.label }} @@ -233,7 +233,7 @@ {{ dict.label }} diff --git a/yudao-module-infra/src/main/resources/codegen/vue3/views/form.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3/views/form.vue.vm index 466ea45..4679445 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3/views/form.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3/views/form.vue.vm @@ -90,7 +90,7 @@ {{ dict.label }} diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben/views/data.ts.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben/views/data.ts.vm index 56f4e82..774390b 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben/views/data.ts.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben/views/data.ts.vm @@ -78,6 +78,12 @@ export const searchFormSchema: FormSchema[] = [ }, #elseif($column.htmlType == "datetime") component: 'RangePicker', + componentProps: { + showTime: true, + format: 'YYYY-MM-DD HH:mm:ss', + valueFormat: 'YYYY-MM-DD HH:mm:ss', + allowClear: true, + }, #end colProps: { span: 8 }, }, diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/form.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/form.vue.vm index 581eae0..4d2ed6c 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/form.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/form.vue.vm @@ -112,7 +112,10 @@ const [Modal, modalApi] = useVbenModal({ #set ($index = $foreach.count - 1) #set ($subClassNameVar = $subClassNameVars.get($index)) #if ($subTable.subJoinMany) ## 一对多 - ## TODO 列表值校验? + if (!${subClassNameVar}FormRef.value?.validate()) { + subTabsName.value = '${subClassNameVar}'; + return; + } #else try { await ${subClassNameVar}FormRef.value?.validate() diff --git a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/import.vue.vm b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/import.vue.vm index e4e52b4..90d9ced 100644 --- a/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/import.vue.vm +++ b/yudao-module-infra/src/main/resources/codegen/vue3_vben5_antd/general/views/import.vue.vm @@ -48,6 +48,11 @@ function beforeUpload(file: FileType) { return false; } +/** 移除文件 */ +function handleRemove() { + fileRef.value = null; +} + /** 下载导入模板 */ async function handleDownload() { const data = await import${simpleClassName}Template(); @@ -58,7 +63,12 @@ async function handleDownload() {