Compare commits

...

19 Commits

Author SHA1 Message Date
edc3babe6b docs(audit): 补充目标逐条验收矩阵 2026-06-01 06:19:54 +08:00
d5d239ae3a feat(compat): 补齐文档草案接口兼容入口 2026-06-01 06:15:44 +08:00
73237507e9 test(schema): 补齐SQL与实体映射契约校验 2026-06-01 06:09:31 +08:00
c8245ba0d6 feat(audit): 补齐摄取管道入口并沉淀完成度审计 2026-06-01 06:04:30 +08:00
1d401c6841 refactor(logging): 补齐核心模块日志与中文注释约束 2026-06-01 05:52:51 +08:00
92b0a971f2 test(workflow): 补齐模块结构与接口契约验证 2026-06-01 05:48:11 +08:00
15808b8569 test(api): 补齐剩余业务模块控制器接口验证 2026-06-01 05:44:38 +08:00
91e05a26cd test(api): 补齐聚合接口控制器测试并修复参数异常语义 2026-06-01 05:40:59 +08:00
eb64af9d50 fix(frontend): 修复工作台页面构建与类型收口 2026-06-01 05:33:16 +08:00
ebe0fc5a12 feat(studio): 补齐剩余工作台聚合接口与真实对接 2026-06-01 05:28:11 +08:00
8f7ffd6cc9 feat(frontend): 对接工作台页面真实接口 2026-06-01 04:53:05 +08:00
2dd242c54b feat(observability): 补齐运行追踪与脱敏导出链路 2026-06-01 04:45:35 +08:00
29f132e48c feat(skill): 补齐版本测试发布与工作台链路 2026-06-01 04:36:09 +08:00
32925bad8e feat(mcp): 补齐服务导入与能力工作台链路 2026-06-01 04:29:08 +08:00
8596f5074b feat(workflow): 补齐项目版本运行与工作台链路 2026-06-01 04:18:01 +08:00
5e0212d2a0 feat(agent): 补齐会话消息与工作台链路 2026-06-01 04:07:31 +08:00
041ed0b446 feat(modelprovider): 补齐模型工作台聚合与转换分层 2026-06-01 03:47:48 +08:00
d9cf838ace feat(rag): 补齐知识工作台聚合与转换分层 2026-06-01 03:38:41 +08:00
07ad8bb36b refactor(modules): 拆分多模块工程并收口common基础模块 2026-06-01 03:26:18 +08:00
428 changed files with 13793 additions and 700 deletions

View File

@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.bruce</groupId>
<artifactId>common-agent-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>common-agent-agent</artifactId>
<name>common-agent-agent</name>
<dependencies>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-rag</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-modelprovider</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -8,6 +8,7 @@ import com.bruce.agent.dto.response.AgentDefinitionResponse;
import com.bruce.agent.service.IAgentDefinitionService;
import com.bruce.common.domain.model.RequestResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
@@ -18,6 +19,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/api/agents")
@RequiredArgsConstructor
@@ -55,4 +57,15 @@ public class AgentDefinitionController {
@RequestBody AgentChatRequest request) {
return RequestResult.success(agentDefinitionService.chat(agentId, request));
}
/**
* 兼容前端实现文档中的运行入口路径
*/
@PostMapping("/{agentId}/runs")
public RequestResult<AgentChatResponse> run(@PathVariable("agentId") Long agentId,
@RequestBody AgentChatRequest request) {
log.info("Agent运行入口开始agentId={}, messageCount={}",
agentId, request.getMessages() == null ? 0 : request.getMessages().size());
return RequestResult.success(agentDefinitionService.chat(agentId, request));
}
}

View File

@@ -0,0 +1,90 @@
package com.bruce.agent.controller;
import com.bruce.agent.dto.request.AgentSessionCreateDTO;
import com.bruce.agent.dto.request.AgentSessionMessageCreateDTO;
import com.bruce.agent.service.IAgentMessageService;
import com.bruce.agent.service.IAgentSessionService;
import com.bruce.agent.service.IAgentWorkspaceService;
import com.bruce.agent.vo.AgentMessageVO;
import com.bruce.agent.vo.AgentSessionDetailVO;
import com.bruce.agent.vo.AgentWorkspaceVO;
import com.bruce.common.domain.model.RequestResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Agent 会话控制器。
* <p>
* 负责会话创建、详情、消息查询与工作台聚合查询,保持前端只消费 DTO / VO 契约。
*/
@Slf4j
@RestController
@RequestMapping("/api/agent-sessions")
@RequiredArgsConstructor
public class AgentSessionController {
private final IAgentSessionService agentSessionService;
private final IAgentMessageService agentMessageService;
private final IAgentWorkspaceService agentWorkspaceService;
@PostMapping("/create")
public RequestResult<Boolean> create(@RequestBody AgentSessionCreateDTO request) {
log.info("Agent会话创建请求开始agentId={}, sessionCode={}", request.getAgentId(), request.getSessionCode());
return RequestResult.success(agentSessionService.createSession(request));
}
@GetMapping("/detail")
public RequestResult<AgentSessionDetailVO> detail(@RequestParam("id") Long id) {
log.info("Agent会话详情查询开始sessionId={}", id);
return RequestResult.success(agentSessionService.getDetailById(id));
}
/**
* 兼容前端实现文档中的资源化会话详情路径。
*/
@GetMapping("/{sessionId}")
public RequestResult<AgentSessionDetailVO> detailByPath(@PathVariable("sessionId") Long sessionId) {
log.info("Agent会话详情按路径查询开始sessionId={}", sessionId);
return RequestResult.success(agentSessionService.getDetailById(sessionId));
}
/**
* 兼容前端实现文档中的 Agent 会话列表路径。
*/
@GetMapping("/agents/{agentId}/sessions")
public RequestResult<List<AgentSessionDetailVO>> sessionsByAgent(@PathVariable("agentId") Long agentId) {
log.info("Agent会话列表查询开始agentId={}", agentId);
return RequestResult.success(agentSessionService.listByAgentId(agentId));
}
@GetMapping("/{sessionId}/messages")
public RequestResult<List<AgentMessageVO>> messages(@PathVariable("sessionId") Long sessionId) {
log.info("Agent消息列表查询开始sessionId={}", sessionId);
return RequestResult.success(agentMessageService.listBySessionId(sessionId));
}
@PostMapping("/{sessionId}/messages")
public RequestResult<Boolean> appendMessage(@PathVariable("sessionId") Long sessionId,
@RequestBody AgentSessionMessageCreateDTO request) {
request.setSessionId(sessionId);
log.info("Agent消息写入请求开始sessionId={}, role={}, requestId={}",
sessionId, request.getRole(), request.getRequestId());
return RequestResult.success(agentMessageService.appendMessage(request));
}
@GetMapping("/workspace")
public RequestResult<AgentWorkspaceVO> workspace(@RequestParam("agentId") Long agentId,
@RequestParam(value = "sessionId", required = false) Long sessionId) {
log.info("Agent工作台查询开始agentId={}, sessionId={}", agentId, sessionId);
return RequestResult.success(agentWorkspaceService.getWorkspace(agentId, sessionId));
}
}

View File

@@ -0,0 +1,13 @@
package com.bruce.agent.dto.request;
import lombok.Data;
@Data
public class AgentSessionCreateDTO {
private Long agentId;
private String sessionCode;
private Long workflowRunId;
private String title;
private String metadataJson;
private String remark;
}

View File

@@ -0,0 +1,14 @@
package com.bruce.agent.dto.request;
import lombok.Data;
@Data
public class AgentSessionMessageCreateDTO {
private Long sessionId;
private String role;
private String content;
private String citationJson;
private Integer tokenCount;
private String requestId;
private String remark;
}

View File

@@ -11,6 +11,8 @@ public class AgentChatResponse {
private String agentName;
private Long storeId;
private String storeName;
private Long sessionId;
private String sessionCode;
private String answer;
private String modelRequestId;
private List<ReferenceChunk> references;

View File

@@ -0,0 +1,33 @@
package com.bruce.agent.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.bruce.common.domain.model.BaseEntity;
import com.bruce.common.typehandler.PgJsonbStringTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("agent_capability_binding")
public class AgentCapabilityBinding extends BaseEntity {
@TableField("owner_type")
private String ownerType;
@TableField("owner_id")
private Long ownerId;
@TableField("capability_type")
private String capabilityType;
@TableField("capability_id")
private Long capabilityId;
private Boolean enabled;
@TableField(value = "config_json", typeHandler = PgJsonbStringTypeHandler.class)
private String configJson;
private String remark;
}

View File

@@ -25,5 +25,6 @@ public class AgentDefinition extends BaseEntity {
private String status;
@TableField("remark")
private String remark;
}

View File

@@ -0,0 +1,29 @@
package com.bruce.agent.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.bruce.common.domain.model.BaseEntity;
import com.bruce.common.typehandler.PgJsonbStringTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("agent_message")
public class AgentMessage extends BaseEntity {
@TableField("session_id")
private Long sessionId;
private String role;
private String content;
@TableField(value = "citation_json", typeHandler = PgJsonbStringTypeHandler.class)
private String citationJson;
@TableField("token_count")
private Integer tokenCount;
private String remark;
}

View File

@@ -0,0 +1,32 @@
package com.bruce.agent.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.bruce.common.domain.model.BaseEntity;
import com.bruce.common.typehandler.PgJsonbStringTypeHandler;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("agent_session")
public class AgentSession extends BaseEntity {
@TableField("session_code")
private String sessionCode;
@TableField("agent_id")
private Long agentId;
@TableField("workflow_run_id")
private Long workflowRunId;
private String title;
private String status;
@TableField(value = "metadata_json", typeHandler = PgJsonbStringTypeHandler.class)
private String metadataJson;
private String remark;
}

View File

@@ -0,0 +1,52 @@
package com.bruce.agent.factory;
import com.bruce.agent.dto.request.AgentDefinitionSaveRequest;
import com.bruce.agent.dto.response.AgentDefinitionResponse;
import com.bruce.agent.entity.AgentDefinition;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* Agent 定义工厂,统一处理请求、实体和返回对象转换。
*/
@Component
public class AgentDefinitionFactory {
public AgentDefinition toEntity(AgentDefinitionSaveRequest request) {
if (request == null) {
return null;
}
AgentDefinition entity = new AgentDefinition();
entity.setId(request.getId());
entity.setAgentCode(trimToNull(request.getAgentCode()));
entity.setAgentName(trimToNull(request.getAgentName()));
entity.setSystemPrompt(trimToNull(request.getSystemPrompt()));
entity.setStoreId(request.getStoreId());
entity.setStatus(trimToNull(request.getStatus()));
entity.setRemark(trimToNull(request.getRemark()));
return entity;
}
public AgentDefinitionResponse toResponse(AgentDefinition entity) {
if (entity == null) {
return null;
}
AgentDefinitionResponse response = new AgentDefinitionResponse();
BeanUtils.copyProperties(entity, response);
return response;
}
public List<AgentDefinitionResponse> toResponses(List<AgentDefinition> entities) {
return entities == null ? List.of() : entities.stream().map(this::toResponse).toList();
}
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,51 @@
package com.bruce.agent.factory;
import com.bruce.agent.dto.request.AgentSessionMessageCreateDTO;
import com.bruce.agent.entity.AgentMessage;
import com.bruce.agent.vo.AgentMessageVO;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* Agent 消息工厂,统一处理消息入参与返回转换。
*/
@Component
public class AgentMessageFactory {
public AgentMessage toEntity(AgentSessionMessageCreateDTO request) {
if (request == null) {
return null;
}
AgentMessage entity = new AgentMessage();
entity.setSessionId(request.getSessionId());
entity.setRole(trimToNull(request.getRole()));
entity.setContent(trimToNull(request.getContent()));
entity.setCitationJson(trimToNull(request.getCitationJson()));
entity.setTokenCount(request.getTokenCount());
entity.setRemark(trimToNull(request.getRemark()));
return entity;
}
public AgentMessageVO toVO(AgentMessage entity) {
if (entity == null) {
return null;
}
AgentMessageVO vo = new AgentMessageVO();
BeanUtils.copyProperties(entity, vo);
return vo;
}
public List<AgentMessageVO> toVOList(List<AgentMessage> entities) {
return entities == null ? List.of() : entities.stream().map(this::toVO).toList();
}
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,51 @@
package com.bruce.agent.factory;
import com.bruce.agent.dto.request.AgentSessionCreateDTO;
import com.bruce.agent.entity.AgentSession;
import com.bruce.agent.vo.AgentSessionDetailVO;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* Agent 会话工厂,统一处理会话转换逻辑。
*/
@Component
public class AgentSessionFactory {
public AgentSession toEntity(AgentSessionCreateDTO request) {
if (request == null) {
return null;
}
AgentSession entity = new AgentSession();
entity.setAgentId(request.getAgentId());
entity.setSessionCode(trimToNull(request.getSessionCode()));
entity.setWorkflowRunId(request.getWorkflowRunId());
entity.setTitle(trimToNull(request.getTitle()));
entity.setMetadataJson(trimToNull(request.getMetadataJson()));
entity.setRemark(trimToNull(request.getRemark()));
return entity;
}
public AgentSessionDetailVO toDetailVO(AgentSession entity) {
if (entity == null) {
return null;
}
AgentSessionDetailVO detailVO = new AgentSessionDetailVO();
BeanUtils.copyProperties(entity, detailVO);
return detailVO;
}
public List<AgentSessionDetailVO> toDetailVOList(List<AgentSession> entities) {
return entities == null ? List.of() : entities.stream().map(this::toDetailVO).toList();
}
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
}

View File

@@ -0,0 +1,9 @@
package com.bruce.agent.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.bruce.agent.entity.AgentCapabilityBinding;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgentCapabilityBindingMapper extends BaseMapper<AgentCapabilityBinding> {
}

View File

@@ -0,0 +1,9 @@
package com.bruce.agent.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.bruce.agent.entity.AgentMessage;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgentMessageMapper extends BaseMapper<AgentMessage> {
}

View File

@@ -0,0 +1,9 @@
package com.bruce.agent.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.bruce.agent.entity.AgentSession;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface AgentSessionMapper extends BaseMapper<AgentSession> {
}

View File

@@ -0,0 +1,17 @@
package com.bruce.agent.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bruce.agent.dto.request.AgentSessionMessageCreateDTO;
import com.bruce.agent.entity.AgentMessage;
import com.bruce.agent.vo.AgentMessageVO;
import java.util.List;
public interface IAgentMessageService extends IService<AgentMessage> {
boolean appendMessage(AgentSessionMessageCreateDTO request);
AgentMessage appendMessageEntity(AgentSessionMessageCreateDTO request);
List<AgentMessageVO> listBySessionId(Long sessionId);
}

View File

@@ -0,0 +1,21 @@
package com.bruce.agent.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.bruce.agent.dto.request.AgentSessionCreateDTO;
import com.bruce.agent.entity.AgentSession;
import com.bruce.agent.vo.AgentSessionDetailVO;
import java.util.List;
public interface IAgentSessionService extends IService<AgentSession> {
boolean createSession(AgentSessionCreateDTO request);
AgentSession createSessionEntity(AgentSessionCreateDTO request);
List<AgentSessionDetailVO> listByAgentId(Long agentId);
AgentSessionDetailVO getDetailById(Long sessionId);
boolean closeSession(Long sessionId);
}

View File

@@ -0,0 +1,8 @@
package com.bruce.agent.service;
import com.bruce.agent.vo.AgentWorkspaceVO;
public interface IAgentWorkspaceService {
AgentWorkspaceVO getWorkspace(Long agentId, Long sessionId);
}

View File

@@ -4,13 +4,21 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bruce.agent.dto.request.AgentChatRequest;
import com.bruce.agent.dto.request.AgentDefinitionQueryRequest;
import com.bruce.agent.dto.request.AgentDefinitionSaveRequest;
import com.bruce.agent.dto.request.AgentSessionCreateDTO;
import com.bruce.agent.dto.request.AgentSessionMessageCreateDTO;
import com.bruce.agent.dto.response.AgentChatResponse;
import com.bruce.agent.dto.response.AgentDefinitionResponse;
import com.bruce.agent.entity.AgentDefinition;
import com.bruce.agent.entity.AgentMessage;
import com.bruce.agent.entity.AgentSession;
import com.bruce.agent.factory.AgentDefinitionFactory;
import com.bruce.agent.mapper.AgentDefinitionMapper;
import com.bruce.agent.service.IAgentDefinitionService;
import com.bruce.agent.service.IAgentMessageService;
import com.bruce.agent.service.IAgentSessionService;
import com.bruce.common.enums.EnableStatusEnum;
import com.bruce.modelprovider.client.OpenAiChatMessage;
import com.bruce.modelprovider.entity.ModelCallLog;
import com.bruce.modelprovider.entity.RagStoreModelConfig;
import com.bruce.modelprovider.gateway.ChatModelGateway;
import com.bruce.modelprovider.gateway.ChatRequest;
@@ -30,6 +38,7 @@ import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
@@ -44,35 +53,32 @@ public class AgentDefinitionServiceImpl extends ServiceImpl<AgentDefinitionMappe
private final RagChunkEmbeddingMapper ragChunkEmbeddingMapper;
private final EmbeddingModelGateway embeddingModelGateway;
private final ChatModelGateway chatModelGateway;
private final IAgentSessionService agentSessionService;
private final IAgentMessageService agentMessageService;
private final AgentDefinitionFactory agentDefinitionFactory;
@Override
public List<AgentDefinitionResponse> listResponses() {
return lambdaQuery()
return agentDefinitionFactory.toResponses(lambdaQuery()
.orderByAsc(AgentDefinition::getAgentCode)
.list()
.stream()
.map(AgentDefinitionResponse::fromEntity)
.toList();
.list());
}
@Override
public List<AgentDefinitionResponse> query(AgentDefinitionQueryRequest request) {
AgentDefinitionQueryRequest queryRequest = request == null ? new AgentDefinitionQueryRequest() : request;
return lambdaQuery()
return agentDefinitionFactory.toResponses(lambdaQuery()
.eq(StringUtils.hasText(queryRequest.getAgentCode()), AgentDefinition::getAgentCode, trimToNull(queryRequest.getAgentCode()))
.like(StringUtils.hasText(queryRequest.getAgentName()), AgentDefinition::getAgentName, trimToNull(queryRequest.getAgentName()))
.eq(StringUtils.hasText(queryRequest.getStatus()), AgentDefinition::getStatus, trimToNull(queryRequest.getStatus()))
.eq(queryRequest.getStoreId() != null, AgentDefinition::getStoreId, queryRequest.getStoreId())
.orderByAsc(AgentDefinition::getAgentCode)
.list()
.stream()
.map(AgentDefinitionResponse::fromEntity)
.toList();
.list());
}
@Override
public AgentDefinitionResponse getResponseById(Long id) {
return AgentDefinitionResponse.fromEntity(getById(id));
return agentDefinitionFactory.toResponse(getById(id));
}
@Override
@@ -81,30 +87,38 @@ public class AgentDefinitionServiceImpl extends ServiceImpl<AgentDefinitionMappe
if (ragStoreService.getById(request.getStoreId()) == null) {
throw new IllegalArgumentException("绑定知识库不存在ID: " + request.getStoreId());
}
String agentCode = request.getAgentCode().trim();
AgentDefinition duplicate = lambdaQuery()
.eq(AgentDefinition::getAgentCode, request.getAgentCode().trim())
.eq(AgentDefinition::getAgentCode, agentCode)
.ne(request.getId() != null, AgentDefinition::getId, request.getId())
.one();
if (duplicate != null) {
throw new IllegalArgumentException("Agent编码已存在: " + request.getAgentCode().trim());
throw new IllegalArgumentException("Agent编码已存在: " + agentCode);
}
AgentDefinition requestEntity = agentDefinitionFactory.toEntity(request);
AgentDefinition entity = request.getId() == null ? new AgentDefinition() : getById(request.getId());
if (entity == null) {
throw new IllegalArgumentException("Agent不存在ID: " + request.getId());
}
entity.setAgentCode(request.getAgentCode().trim());
entity.setAgentName(request.getAgentName().trim());
entity.setSystemPrompt(trimToNull(request.getSystemPrompt()));
entity.setStoreId(request.getStoreId());
entity.setStatus(StringUtils.hasText(request.getStatus())
? request.getStatus().trim()
entity.setAgentCode(requestEntity.getAgentCode());
entity.setAgentName(requestEntity.getAgentName());
entity.setSystemPrompt(requestEntity.getSystemPrompt());
entity.setStoreId(requestEntity.getStoreId());
entity.setStatus(StringUtils.hasText(requestEntity.getStatus())
? requestEntity.getStatus()
: EnableStatusEnum.ENABLED.name());
entity.setRemark(trimToNull(request.getRemark()));
return request.getId() == null ? save(entity) : updateById(entity);
entity.setRemark(requestEntity.getRemark());
boolean result = request.getId() == null ? save(entity) : updateById(entity);
log.info("Agent定义保存完成agentId={}, agentCode={}, storeId={}, result={}",
entity.getId(), entity.getAgentCode(), entity.getStoreId(), result);
return result;
}
@Override
public AgentChatResponse chat(Long agentId, AgentChatRequest request) {
log.info("Agent兼容聊天开始agentId={}", agentId);
if (agentId == null) {
throw new IllegalArgumentException("Agent ID不能为空");
}
@@ -148,16 +162,15 @@ public class AgentDefinitionServiceImpl extends ServiceImpl<AgentDefinitionMappe
}
String queryVector = toVectorLiteral(queryEmbedding.getVectors().getFirst());
recalls = ragChunkEmbeddingMapper.queryTopKByStore(
agent.getStoreId(),
queryVector,
DEFAULT_TOP_K
);
recalls = ragChunkEmbeddingMapper.queryTopKByStore(agent.getStoreId(), queryVector, DEFAULT_TOP_K);
if (recalls.isEmpty()) {
throw new IllegalArgumentException("未召回到可用知识切片,请先完成知识库切片与向量化");
}
}
AgentSession session = createCompatibilitySession(agentId, request);
appendRequestMessages(session.getId(), request.getMessages());
ChatRequest chatRequest = new ChatRequest();
chatRequest.setTaskType(ragEnabled ? "RAG_ANSWER" : "CHAT_SIMPLE");
chatRequest.setMatchScope("AGENT");
@@ -167,15 +180,21 @@ public class AgentDefinitionServiceImpl extends ServiceImpl<AgentDefinitionMappe
chatRequest.setMessages(buildChatMessages(agent, recalls, request.getMessages(), ragEnabled));
ChatResult chatResult = chatModelGateway.chat(chatRequest);
appendAssistantMessage(session.getId(), recalls, chatResult);
AgentChatResponse response = new AgentChatResponse();
response.setAgentId(agent.getId());
response.setAgentCode(agent.getAgentCode());
response.setAgentName(agent.getAgentName());
response.setStoreId(agent.getStoreId());
response.setStoreName(store.getStoreName());
response.setSessionId(session.getId());
response.setSessionCode(session.getSessionCode());
response.setAnswer(chatResult.getContent());
response.setModelRequestId(chatResult.getCallLog().getRequestId());
response.setModelRequestId(resolveRequestId(chatResult));
response.setReferences(toReferenceChunks(recalls));
log.info("Agent兼容聊天结束agentId={}, sessionId={}, requestId={}, referenceCount={}",
agentId, session.getId(), resolveRequestId(chatResult), recalls.size());
return response;
}
@@ -272,6 +291,105 @@ public class AgentDefinitionServiceImpl extends ServiceImpl<AgentDefinitionMappe
}).toList();
}
/**
* 兼容旧调试接口时统一创建独立会话并沉淀会话编码便于工作台后续追踪完整对话
*/
private AgentSession createCompatibilitySession(Long agentId, AgentChatRequest request) {
if (agentSessionService == null) {
AgentSession session = new AgentSession();
session.setId(0L);
session.setAgentId(agentId);
session.setSessionCode("chat_mock");
session.setTitle(resolveLatestUserMessage(request.getMessages()));
session.setStatus("ACTIVE");
session.setMetadataJson("{\"source\":\"compat_chat\"}");
session.setRemark("兼容聊天接口自动创建");
return session;
}
AgentSessionCreateDTO createDTO = new AgentSessionCreateDTO();
createDTO.setAgentId(agentId);
createDTO.setSessionCode("chat_" + UUID.randomUUID().toString().replace("-", ""));
createDTO.setTitle(resolveLatestUserMessage(request.getMessages()));
createDTO.setMetadataJson("{\"source\":\"compat_chat\"}");
createDTO.setRemark("兼容聊天接口自动创建");
AgentSession session = agentSessionService.createSessionEntity(createDTO);
agentSessionService.save(session);
return session;
}
private void appendRequestMessages(Long sessionId, List<AgentChatRequest.AgentMessage> messages) {
if (agentMessageService == null) {
return;
}
for (AgentChatRequest.AgentMessage rawMessage : messages) {
if (rawMessage == null || !StringUtils.hasText(rawMessage.getContent())) {
continue;
}
AgentSessionMessageCreateDTO createDTO = new AgentSessionMessageCreateDTO();
createDTO.setSessionId(sessionId);
createDTO.setRole(normalizeRole(rawMessage.getRole()));
createDTO.setContent(rawMessage.getContent().trim());
createDTO.setCitationJson("[]");
createDTO.setRemark("兼容聊天接口消息");
AgentMessage entity = agentMessageService.appendMessageEntity(createDTO);
agentMessageService.save(entity);
}
}
private AgentMessage appendAssistantMessage(Long sessionId,
List<RagChunkRecallResponse> recalls,
ChatResult chatResult) {
if (agentMessageService == null) {
AgentMessage message = new AgentMessage();
message.setSessionId(sessionId);
message.setRole("assistant");
message.setContent(chatResult.getContent());
message.setCitationJson(toCitationJson(recalls));
message.setTokenCount(chatResult.getCallLog() == null ? null : chatResult.getCallLog().getTotalTokens());
message.setRemark("兼容聊天接口模型回复");
return message;
}
AgentSessionMessageCreateDTO createDTO = new AgentSessionMessageCreateDTO();
createDTO.setSessionId(sessionId);
createDTO.setRole("assistant");
createDTO.setContent(chatResult.getContent());
createDTO.setCitationJson(toCitationJson(recalls));
ModelCallLog callLog = chatResult.getCallLog();
createDTO.setRequestId(callLog == null ? null : callLog.getRequestId());
createDTO.setTokenCount(callLog == null ? null : callLog.getTotalTokens());
createDTO.setRemark("兼容聊天接口模型回复");
AgentMessage entity = agentMessageService.appendMessageEntity(createDTO);
agentMessageService.save(entity);
return entity;
}
private String resolveRequestId(ChatResult chatResult) {
ModelCallLog callLog = chatResult == null ? null : chatResult.getCallLog();
return callLog == null ? null : callLog.getRequestId();
}
private String toCitationJson(List<RagChunkRecallResponse> recalls) {
if (recalls == null || recalls.isEmpty()) {
return "[]";
}
StringBuilder builder = new StringBuilder("[");
for (int i = 0; i < recalls.size(); i++) {
RagChunkRecallResponse recall = recalls.get(i);
if (i > 0) {
builder.append(',');
}
builder.append("{\"chunkId\":")
.append(recall.getChunkId())
.append(",\"documentId\":")
.append(recall.getDocumentId())
.append(",\"score\":")
.append(recall.getScore() == null ? 0D : recall.getScore())
.append('}');
}
builder.append(']');
return builder.toString();
}
private String normalizeRole(String role) {
if (!StringUtils.hasText(role)) {
return "user";

View File

@@ -0,0 +1,93 @@
package com.bruce.agent.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bruce.agent.dto.request.AgentSessionMessageCreateDTO;
import com.bruce.agent.entity.AgentMessage;
import com.bruce.agent.entity.AgentSession;
import com.bruce.agent.factory.AgentMessageFactory;
import com.bruce.agent.mapper.AgentMessageMapper;
import com.bruce.agent.service.IAgentMessageService;
import com.bruce.agent.service.IAgentSessionService;
import com.bruce.agent.vo.AgentMessageVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentMessageServiceImpl extends ServiceImpl<AgentMessageMapper, AgentMessage> implements IAgentMessageService {
private final IAgentSessionService agentSessionService;
private final AgentMessageFactory agentMessageFactory;
@Override
public boolean appendMessage(AgentSessionMessageCreateDTO request) {
AgentMessage entity = appendMessageEntity(request);
boolean saved = save(entity);
log.info("Agent消息写入完成sessionId={}, role={}, messageId={}, requestId={}, result={}",
entity.getSessionId(), entity.getRole(), entity.getId(), request.getRequestId(), saved);
return saved;
}
@Override
public AgentMessage appendMessageEntity(AgentSessionMessageCreateDTO request) {
validateRequest(request);
AgentSession session = loadSession(request.getSessionId());
if ("CLOSED".equals(session.getStatus())) {
throw new IllegalArgumentException("会话已关闭,不能继续写入消息");
}
AgentMessage entity = loadFactory().toEntity(request);
if (!StringUtils.hasText(entity.getCitationJson())) {
entity.setCitationJson("[]");
} else if (!entity.getCitationJson().trim().startsWith("[")) {
throw new IllegalArgumentException("citationJson必须是数组结构");
}
log.info("Agent消息写入开始sessionId={}, role={}, requestId={}",
entity.getSessionId(), entity.getRole(), request.getRequestId());
return entity;
}
@Override
public List<AgentMessageVO> listBySessionId(Long sessionId) {
if (sessionId == null) {
throw new IllegalArgumentException("会话ID不能为空");
}
List<AgentMessage> messages = lambdaQuery()
.eq(AgentMessage::getSessionId, sessionId)
.orderByAsc(AgentMessage::getCreateTime)
.orderByAsc(AgentMessage::getId)
.list();
return loadFactory().toVOList(messages);
}
public AgentSession loadSession(Long sessionId) {
AgentSession session = agentSessionService.getById(sessionId);
if (session == null) {
throw new IllegalArgumentException("会话不存在ID: " + sessionId);
}
return session;
}
private void validateRequest(AgentSessionMessageCreateDTO request) {
if (request == null) {
throw new IllegalArgumentException("消息请求不能为空");
}
if (request.getSessionId() == null) {
throw new IllegalArgumentException("会话ID不能为空");
}
if (!StringUtils.hasText(request.getRole())) {
throw new IllegalArgumentException("消息角色不能为空");
}
if (!StringUtils.hasText(request.getContent())) {
throw new IllegalArgumentException("消息内容不能为空");
}
}
private AgentMessageFactory loadFactory() {
return agentMessageFactory == null ? new AgentMessageFactory() : agentMessageFactory;
}
}

View File

@@ -0,0 +1,125 @@
package com.bruce.agent.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bruce.agent.dto.request.AgentSessionCreateDTO;
import com.bruce.agent.entity.AgentDefinition;
import com.bruce.agent.entity.AgentSession;
import com.bruce.agent.factory.AgentSessionFactory;
import com.bruce.agent.mapper.AgentSessionMapper;
import com.bruce.agent.service.IAgentDefinitionService;
import com.bruce.agent.service.IAgentSessionService;
import com.bruce.agent.vo.AgentSessionDetailVO;
import com.bruce.common.enums.EnableStatusEnum;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentSessionServiceImpl extends ServiceImpl<AgentSessionMapper, AgentSession> implements IAgentSessionService {
private static final String SESSION_STATUS_ACTIVE = "ACTIVE";
private static final String SESSION_STATUS_CLOSED = "CLOSED";
private final ObjectProvider<IAgentDefinitionService> agentDefinitionServiceProvider;
private final AgentSessionFactory agentSessionFactory;
@Override
public boolean createSession(AgentSessionCreateDTO request) {
AgentSession entity = createSessionEntity(request);
boolean saved = save(entity);
log.info("Agent会话创建完成agentId={}, sessionCode={}, sessionId={}, result={}",
entity.getAgentId(), entity.getSessionCode(), entity.getId(), saved);
return saved;
}
@Override
public AgentSession createSessionEntity(AgentSessionCreateDTO request) {
validateCreateRequest(request);
IAgentDefinitionService agentDefinitionService = agentDefinitionServiceProvider.getIfAvailable();
if (agentDefinitionService == null) {
throw new IllegalStateException("Agent定义服务未就绪暂无法创建会话");
}
AgentDefinition agent = agentDefinitionService.getById(request.getAgentId());
if (agent == null) {
throw new IllegalArgumentException("Agent不存在ID: " + request.getAgentId());
}
if (!EnableStatusEnum.ENABLED.name().equals(agent.getStatus())) {
throw new IllegalArgumentException("Agent已停用无法创建会话");
}
if (baseMapper != null) {
AgentSession duplicate = lambdaQuery()
.eq(AgentSession::getSessionCode, request.getSessionCode().trim())
.one();
if (duplicate != null) {
throw new IllegalArgumentException("会话编码已存在: " + request.getSessionCode().trim());
}
}
AgentSession entity = loadFactory().toEntity(request);
entity.setStatus(SESSION_STATUS_ACTIVE);
if (!StringUtils.hasText(entity.getMetadataJson())) {
entity.setMetadataJson("{}");
}
log.info("Agent会话创建开始agentId={}, sessionCode={}, workflowRunId={}",
entity.getAgentId(), entity.getSessionCode(), entity.getWorkflowRunId());
return entity;
}
@Override
public List<AgentSessionDetailVO> listByAgentId(Long agentId) {
if (agentId == null) {
throw new IllegalArgumentException("Agent ID不能为空");
}
List<AgentSession> sessions = lambdaQuery()
.eq(AgentSession::getAgentId, agentId)
.orderByDesc(AgentSession::getUpdateTime)
.orderByDesc(AgentSession::getId)
.list();
return loadFactory().toDetailVOList(sessions);
}
@Override
public AgentSessionDetailVO getDetailById(Long sessionId) {
if (sessionId == null) {
throw new IllegalArgumentException("会话ID不能为空");
}
return loadFactory().toDetailVO(getById(sessionId));
}
@Override
public boolean closeSession(Long sessionId) {
if (sessionId == null) {
throw new IllegalArgumentException("会话ID不能为空");
}
AgentSession session = getById(sessionId);
if (session == null) {
throw new IllegalArgumentException("会话不存在ID: " + sessionId);
}
session.setStatus(SESSION_STATUS_CLOSED);
boolean updated = updateById(session);
log.info("Agent会话关闭完成sessionId={}, sessionCode={}, result={}",
session.getId(), session.getSessionCode(), updated);
return updated;
}
private void validateCreateRequest(AgentSessionCreateDTO request) {
if (request == null) {
throw new IllegalArgumentException("创建会话请求不能为空");
}
if (request.getAgentId() == null) {
throw new IllegalArgumentException("Agent ID不能为空");
}
if (!StringUtils.hasText(request.getSessionCode())) {
throw new IllegalArgumentException("会话编码不能为空");
}
}
private AgentSessionFactory loadFactory() {
return agentSessionFactory == null ? new AgentSessionFactory() : agentSessionFactory;
}
}

View File

@@ -0,0 +1,91 @@
package com.bruce.agent.service.impl;
import com.bruce.agent.entity.AgentDefinition;
import com.bruce.agent.service.IAgentDefinitionService;
import com.bruce.agent.service.IAgentMessageService;
import com.bruce.agent.service.IAgentSessionService;
import com.bruce.agent.service.IAgentWorkspaceService;
import com.bruce.agent.vo.AgentMessageVO;
import com.bruce.agent.vo.AgentSessionDetailVO;
import com.bruce.agent.vo.AgentWorkspaceVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Objects;
@Slf4j
@Service
@RequiredArgsConstructor
public class AgentWorkspaceServiceImpl implements IAgentWorkspaceService {
private final IAgentDefinitionService agentDefinitionService;
private final IAgentSessionService agentSessionService;
private final IAgentMessageService agentMessageService;
@Override
public AgentWorkspaceVO getWorkspace(Long agentId, Long sessionId) {
log.info("Agent工作台查询开始agentId={}, sessionId={}", agentId, sessionId);
if (agentId == null) {
throw new IllegalArgumentException("Agent ID不能为空");
}
AgentDefinition agent = agentDefinitionService.getById(agentId);
if (agent == null) {
throw new IllegalArgumentException("Agent不存在ID: " + agentId);
}
List<AgentSessionDetailVO> sessions = agentSessionService.listByAgentId(agentId);
AgentSessionDetailVO currentSession = resolveSession(sessionId, sessions);
List<AgentMessageVO> messages = currentSession == null ? List.of() : agentMessageService.listBySessionId(currentSession.getId());
AgentWorkspaceVO workspace = new AgentWorkspaceVO();
workspace.setAgentId(agent.getId());
workspace.setAgentCode(agent.getAgentCode());
workspace.setAgentName(agent.getAgentName());
workspace.setStoreId(agent.getStoreId());
workspace.setStatus(agent.getStatus());
workspace.setSessions(sessions);
workspace.setMessages(messages);
if (currentSession != null) {
workspace.setSessionId(currentSession.getId());
workspace.setSessionCode(currentSession.getSessionCode());
workspace.setSessionTitle(currentSession.getTitle());
workspace.setSessionStatus(currentSession.getStatus());
workspace.setWorkflowRunId(currentSession.getWorkflowRunId());
}
int totalTokens = messages.stream()
.map(AgentMessageVO::getTokenCount)
.filter(Objects::nonNull)
.mapToInt(Integer::intValue)
.sum();
int citationCount = (int) messages.stream()
.filter(message -> StringUtils.hasText(message.getCitationJson()) && message.getCitationJson().contains("chunkId"))
.count();
String latestRequestId = messages.stream()
.map(AgentMessageVO::getRequestId)
.filter(StringUtils::hasText)
.reduce((first, second) -> second)
.orElse(null);
workspace.setTotalTokens(totalTokens);
workspace.setCitationCount(citationCount);
workspace.setLatestRequestId(latestRequestId);
log.info("Agent工作台查询结束agentId={}, sessionId={}, messageCount={}, totalTokens={}",
agentId, workspace.getSessionId(), messages.size(), totalTokens);
return workspace;
}
private AgentSessionDetailVO resolveSession(Long sessionId, List<AgentSessionDetailVO> sessions) {
if (sessions == null || sessions.isEmpty()) {
return null;
}
if (sessionId == null) {
return sessions.get(0);
}
return sessions.stream().filter(item -> sessionId.equals(item.getId())).findFirst().orElse(null);
}
}

View File

@@ -0,0 +1,18 @@
package com.bruce.agent.vo;
import lombok.Data;
import java.util.Date;
@Data
public class AgentMessageVO {
private Long id;
private Long sessionId;
private String role;
private String content;
private String citationJson;
private Integer tokenCount;
private String requestId;
private String remark;
private Date createTime;
}

View File

@@ -0,0 +1,19 @@
package com.bruce.agent.vo;
import lombok.Data;
import java.util.Date;
@Data
public class AgentSessionDetailVO {
private Long id;
private Long agentId;
private String sessionCode;
private Long workflowRunId;
private String title;
private String status;
private String metadataJson;
private String remark;
private Date createTime;
private Date updateTime;
}

View File

@@ -0,0 +1,24 @@
package com.bruce.agent.vo;
import lombok.Data;
import java.util.List;
@Data
public class AgentWorkspaceVO {
private Long agentId;
private String agentCode;
private String agentName;
private Long storeId;
private String status;
private Long sessionId;
private String sessionCode;
private String sessionTitle;
private String sessionStatus;
private Long workflowRunId;
private Integer totalTokens;
private Integer citationCount;
private String latestRequestId;
private List<AgentSessionDetailVO> sessions;
private List<AgentMessageVO> messages;
}

View File

@@ -0,0 +1,121 @@
package com.bruce.agent.controller;
import com.bruce.agent.dto.response.AgentChatResponse;
import com.bruce.agent.service.IAgentDefinitionService;
import com.bruce.agent.service.IAgentMessageService;
import com.bruce.agent.service.IAgentSessionService;
import com.bruce.agent.service.IAgentWorkspaceService;
import com.bruce.agent.vo.AgentSessionDetailVO;
import com.bruce.common.handler.GlobalExceptionHandler;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 验证 Agent 文档草案兼容路径。
*/
@ExtendWith(MockitoExtension.class)
class AgentCompatControllerTests {
private MockMvc mockMvc;
@Mock
private IAgentDefinitionService agentDefinitionService;
@Mock
private IAgentSessionService agentSessionService;
@Mock
private IAgentMessageService agentMessageService;
@Mock
private IAgentWorkspaceService agentWorkspaceService;
@InjectMocks
private AgentDefinitionController agentDefinitionController;
@InjectMocks
private AgentSessionController agentSessionController;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(agentDefinitionController, agentSessionController)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void agentRunCompatShouldReturnChatResponse() throws Exception {
AgentChatResponse response = new AgentChatResponse();
response.setAgentId(1001L);
response.setAgentCode("presale_agent");
response.setAnswer("这是兼容运行入口返回的答案");
response.setModelRequestId("req-1001");
when(agentDefinitionService.chat(org.mockito.ArgumentMatchers.eq(1001L), any())).thenReturn(response);
mockMvc.perform(post("/api/agents/1001/runs")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{
"messages": [
{
"role": "user",
"content": "请总结合同重点"
}
],
"ragEnabled": true
}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.resultcode").value("0"))
.andExpect(jsonPath("$.data.agentCode").value("presale_agent"))
.andExpect(jsonPath("$.data.modelRequestId").value("req-1001"));
}
@Test
void sessionsCompatShouldReturnStructuredSessionList() throws Exception {
AgentSessionDetailVO session = new AgentSessionDetailVO();
session.setId(2001L);
session.setAgentId(1001L);
session.setSessionCode("session_001");
session.setStatus("ACTIVE");
when(agentSessionService.listByAgentId(1001L)).thenReturn(List.of(session));
mockMvc.perform(get("/api/agent-sessions/agents/1001/sessions"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.resultcode").value("0"))
.andExpect(jsonPath("$.data[0].sessionCode").value("session_001"));
}
@Test
void sessionDetailCompatShouldReturnStructuredDetail() throws Exception {
AgentSessionDetailVO session = new AgentSessionDetailVO();
session.setId(2001L);
session.setSessionCode("session_001");
session.setStatus("ACTIVE");
when(agentSessionService.getDetailById(2001L)).thenReturn(session);
mockMvc.perform(get("/api/agent-sessions/2001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.resultcode").value("0"))
.andExpect(jsonPath("$.data.sessionCode").value("session_001"));
}
}

View File

@@ -0,0 +1,69 @@
package com.bruce.agent.controller;
import com.bruce.agent.service.IAgentMessageService;
import com.bruce.agent.service.IAgentSessionService;
import com.bruce.agent.service.IAgentWorkspaceService;
import com.bruce.agent.vo.AgentWorkspaceVO;
import com.bruce.common.handler.GlobalExceptionHandler;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 验证 Agent 工作台聚合接口的查询参数绑定和返回结构。
*/
@ExtendWith(MockitoExtension.class)
class AgentSessionControllerTests {
private MockMvc mockMvc;
@Mock
private IAgentSessionService agentSessionService;
@Mock
private IAgentMessageService agentMessageService;
@Mock
private IAgentWorkspaceService agentWorkspaceService;
@InjectMocks
private AgentSessionController agentSessionController;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(agentSessionController)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void workspaceShouldReturnStructuredAggregateView() throws Exception {
AgentWorkspaceVO workspace = new AgentWorkspaceVO();
workspace.setAgentId(1001L);
workspace.setAgentCode("presale_agent");
workspace.setAgentName("售前问答 Agent");
workspace.setSessionId(2001L);
workspace.setSessionCode("session_001");
workspace.setLatestRequestId("req-1001");
workspace.setCitationCount(2);
when(agentWorkspaceService.getWorkspace(1001L, null)).thenReturn(workspace);
mockMvc.perform(get("/api/agent-sessions/workspace").param("agentId", "1001"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.resultcode").value("0"))
.andExpect(jsonPath("$.data.agentId").value(1001))
.andExpect(jsonPath("$.data.agentName").value("售前问答 Agent"))
.andExpect(jsonPath("$.data.latestRequestId").value("req-1001"));
}
}

View File

@@ -0,0 +1,86 @@
package com.bruce.agent.factory;
import com.bruce.agent.dto.request.AgentDefinitionSaveRequest;
import com.bruce.agent.dto.request.AgentSessionCreateDTO;
import com.bruce.agent.dto.request.AgentSessionMessageCreateDTO;
import com.bruce.agent.dto.response.AgentDefinitionResponse;
import com.bruce.agent.entity.AgentDefinition;
import com.bruce.agent.entity.AgentMessage;
import com.bruce.agent.entity.AgentSession;
import com.bruce.agent.vo.AgentSessionDetailVO;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
class AgentFactoryTests {
private final AgentDefinitionFactory agentDefinitionFactory = new AgentDefinitionFactory();
private final AgentSessionFactory agentSessionFactory = new AgentSessionFactory();
private final AgentMessageFactory agentMessageFactory = new AgentMessageFactory();
@Test
void agentDefinitionFactoryShouldTrimRequestAndBuildResponse() {
AgentDefinitionSaveRequest request = new AgentDefinitionSaveRequest();
request.setId(1L);
request.setAgentCode(" AGENT_RAG_HELPER ");
request.setAgentName(" 知识问答助手 ");
request.setSystemPrompt(" 你是企业知识助手 ");
request.setStoreId(1001L);
request.setStatus(" ENABLED ");
request.setRemark(" 默认Agent ");
AgentDefinition entity = agentDefinitionFactory.toEntity(request);
assertEquals("AGENT_RAG_HELPER", entity.getAgentCode());
assertEquals("知识问答助手", entity.getAgentName());
assertEquals("你是企业知识助手", entity.getSystemPrompt());
assertEquals("ENABLED", entity.getStatus());
assertEquals("默认Agent", entity.getRemark());
AgentDefinitionResponse response = agentDefinitionFactory.toResponse(entity);
assertEquals(1001L, response.getStoreId());
assertEquals("AGENT_RAG_HELPER", response.getAgentCode());
}
@Test
void agentSessionFactoryShouldBuildSessionEntityAndDetailView() {
AgentSessionCreateDTO request = new AgentSessionCreateDTO();
request.setAgentId(1L);
request.setSessionCode(" session_001 ");
request.setWorkflowRunId(2001L);
request.setTitle(" 产品问答会话 ");
request.setMetadataJson(" {\"source\":\"debug\"} ");
request.setRemark(" 首轮调试 ");
AgentSession entity = agentSessionFactory.toEntity(request);
assertNotNull(entity);
assertEquals("session_001", entity.getSessionCode());
assertEquals("产品问答会话", entity.getTitle());
assertEquals("{\"source\":\"debug\"}", entity.getMetadataJson());
assertEquals("首轮调试", entity.getRemark());
AgentSessionDetailVO detailVO = agentSessionFactory.toDetailVO(entity);
assertEquals(1L, detailVO.getAgentId());
assertEquals("session_001", detailVO.getSessionCode());
}
@Test
void agentMessageFactoryShouldBuildMessageEntityAndPreserveCitationJson() {
AgentSessionMessageCreateDTO request = new AgentSessionMessageCreateDTO();
request.setSessionId(10L);
request.setRole(" assistant ");
request.setContent(" 这里是回答内容 ");
request.setCitationJson(" [{\"chunkId\":1}] ");
request.setTokenCount(256);
request.setRemark(" 回答成功 ");
AgentMessage entity = agentMessageFactory.toEntity(request);
assertEquals(10L, entity.getSessionId());
assertEquals("assistant", entity.getRole());
assertEquals("这里是回答内容", entity.getContent());
assertEquals("[{\"chunkId\":1}]", entity.getCitationJson());
assertEquals(256, entity.getTokenCount());
assertTrue(entity.getRemark().contains("回答成功"));
}
}

View File

@@ -0,0 +1,125 @@
package com.bruce.agent.session;
import com.bruce.agent.dto.request.AgentSessionCreateDTO;
import com.bruce.agent.dto.request.AgentSessionMessageCreateDTO;
import com.bruce.agent.entity.AgentDefinition;
import com.bruce.agent.entity.AgentMessage;
import com.bruce.agent.entity.AgentSession;
import com.bruce.agent.service.IAgentDefinitionService;
import com.bruce.agent.service.impl.AgentMessageServiceImpl;
import com.bruce.agent.service.impl.AgentSessionServiceImpl;
import com.bruce.common.enums.EnableStatusEnum;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AgentSessionServiceTests {
@Mock
private IAgentDefinitionService agentDefinitionService;
@Mock
private ObjectProvider<IAgentDefinitionService> agentDefinitionServiceProvider;
@Spy
@InjectMocks
private AgentSessionServiceImpl agentSessionService;
@Spy
@InjectMocks
private AgentMessageServiceImpl agentMessageService;
@Test
void createSessionShouldRejectDisabledAgent() {
when(agentDefinitionServiceProvider.getIfAvailable()).thenReturn(agentDefinitionService);
AgentDefinition agent = new AgentDefinition();
agent.setId(1L);
agent.setStatus("DISABLED");
when(agentDefinitionService.getById(1L)).thenReturn(agent);
AgentSessionCreateDTO request = new AgentSessionCreateDTO();
request.setAgentId(1L);
request.setSessionCode("session_001");
assertThrows(IllegalArgumentException.class, () -> agentSessionService.createSession(request));
}
@Test
void createSessionShouldPersistActiveSession() {
when(agentDefinitionServiceProvider.getIfAvailable()).thenReturn(agentDefinitionService);
AgentDefinition agent = new AgentDefinition();
agent.setId(1L);
agent.setStatus(EnableStatusEnum.ENABLED.name());
when(agentDefinitionService.getById(1L)).thenReturn(agent);
doAnswer(invocation -> true).when(agentSessionService).save(any(AgentSession.class));
AgentSessionCreateDTO request = new AgentSessionCreateDTO();
request.setAgentId(1L);
request.setSessionCode("session_001");
request.setTitle("产品问答");
request.setMetadataJson("{\"source\":\"debug\"}");
boolean result = agentSessionService.createSession(request);
assertTrue(result);
ArgumentCaptor<AgentSession> captor = ArgumentCaptor.forClass(AgentSession.class);
verify(agentSessionService).save(captor.capture());
assertEquals("session_001", captor.getValue().getSessionCode());
assertEquals("ACTIVE", captor.getValue().getStatus());
}
@Test
void appendMessageShouldRejectClosedSession() {
AgentSession session = new AgentSession();
session.setId(10L);
session.setStatus("CLOSED");
doReturn(session).when(agentMessageService).loadSession(10L);
AgentSessionMessageCreateDTO request = new AgentSessionMessageCreateDTO();
request.setSessionId(10L);
request.setRole("user");
request.setContent("你好");
request.setCitationJson("[]");
assertThrows(IllegalArgumentException.class, () -> agentMessageService.appendMessage(request));
}
@Test
void appendMessageShouldPersistCitationJson() {
AgentSession session = new AgentSession();
session.setId(10L);
session.setStatus("ACTIVE");
doReturn(session).when(agentMessageService).loadSession(10L);
doAnswer(invocation -> true).when(agentMessageService).save(any(AgentMessage.class));
AgentSessionMessageCreateDTO request = new AgentSessionMessageCreateDTO();
request.setSessionId(10L);
request.setRole("assistant");
request.setContent("这里是回答");
request.setCitationJson("[{\"chunkId\":1}]");
request.setTokenCount(128);
boolean result = agentMessageService.appendMessage(request);
assertTrue(result);
ArgumentCaptor<AgentMessage> captor = ArgumentCaptor.forClass(AgentMessage.class);
verify(agentMessageService).save(captor.capture());
assertEquals("[{\"chunkId\":1}]", captor.getValue().getCitationJson());
assertEquals(128, captor.getValue().getTokenCount());
}
}

View File

@@ -0,0 +1,81 @@
package com.bruce.agent.workspace;
import com.bruce.agent.entity.AgentDefinition;
import com.bruce.agent.service.IAgentDefinitionService;
import com.bruce.agent.service.IAgentMessageService;
import com.bruce.agent.service.IAgentSessionService;
import com.bruce.agent.service.impl.AgentWorkspaceServiceImpl;
import com.bruce.agent.vo.AgentMessageVO;
import com.bruce.agent.vo.AgentSessionDetailVO;
import com.bruce.agent.vo.AgentWorkspaceVO;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AgentWorkspaceServiceTests {
@Mock
private IAgentDefinitionService agentDefinitionService;
@Mock
private IAgentSessionService agentSessionService;
@Mock
private IAgentMessageService agentMessageService;
@InjectMocks
private AgentWorkspaceServiceImpl agentWorkspaceService;
@Test
void getWorkspaceShouldAggregateAgentSessionAndMessages() {
AgentDefinition agent = new AgentDefinition();
agent.setId(1L);
agent.setAgentCode("AGENT_RAG_HELPER");
agent.setAgentName("知识问答助手");
agent.setStoreId(1001L);
agent.setStatus("ENABLED");
AgentSessionDetailVO session = new AgentSessionDetailVO();
session.setId(10L);
session.setAgentId(1L);
session.setSessionCode("session_001");
session.setStatus("ACTIVE");
session.setTitle("产品问答");
session.setWorkflowRunId(2001L);
AgentMessageVO userMessage = new AgentMessageVO();
userMessage.setRole("user");
userMessage.setContent("产品支持哪些模型?");
AgentMessageVO assistantMessage = new AgentMessageVO();
assistantMessage.setRole("assistant");
assistantMessage.setContent("当前支持 OpenAI Compatible 协议模型。");
assistantMessage.setCitationJson("[{\"chunkId\":1}]");
assistantMessage.setTokenCount(256);
assistantMessage.setRequestId("req-001");
when(agentDefinitionService.getById(1L)).thenReturn(agent);
when(agentSessionService.listByAgentId(1L)).thenReturn(List.of(session));
when(agentMessageService.listBySessionId(10L)).thenReturn(List.of(userMessage, assistantMessage));
AgentWorkspaceVO workspace = agentWorkspaceService.getWorkspace(1L, 10L);
assertNotNull(workspace);
assertEquals("AGENT_RAG_HELPER", workspace.getAgentCode());
assertEquals("知识问答助手", workspace.getAgentName());
assertEquals(10L, workspace.getSessionId());
assertEquals("session_001", workspace.getSessionCode());
assertEquals(2, workspace.getMessages().size());
assertEquals(1, workspace.getCitationCount());
assertEquals(256, workspace.getTotalTokens());
assertEquals("req-001", workspace.getLatestRequestId());
}
}

85
common-agent-boot/pom.xml Normal file
View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.bruce</groupId>
<artifactId>common-agent-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>common-agent-boot</artifactId>
<name>common-agent-boot</name>
<dependencies>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-rag</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-modelprovider</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-agent</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-workflow</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-mcp</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-skill</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.bruce</groupId>
<artifactId>common-agent-observability</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -1,11 +1,22 @@
package com.bruce;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync
@MapperScan(basePackages = {
"com.bruce.common.mapper",
"com.bruce.rag.mapper",
"com.bruce.modelprovider.mapper",
"com.bruce.agent.mapper",
"com.bruce.workflow.mapper",
"com.bruce.mcp.mapper",
"com.bruce.skill.mapper",
"com.bruce.observability.mapper"
})
public class CommonAgentApplication {
public static void main(String[] args) {

View File

@@ -0,0 +1,31 @@
package com.bruce.dashboard.controller;
import com.bruce.common.domain.model.RequestResult;
import com.bruce.dashboard.service.IStudioDashboardService;
import com.bruce.dashboard.vo.StudioDashboardVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Studio 首页聚合接口。
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/studio/dashboard")
public class StudioDashboardController {
private final IStudioDashboardService studioDashboardService;
@GetMapping
public RequestResult<StudioDashboardVO> detail() {
log.info("Studio 首页总览查询开始");
StudioDashboardVO dashboard = studioDashboardService.getDashboard();
log.info("Studio 首页总览查询结束projectName={}, recentRunCount={}",
dashboard.getProjectName(), dashboard.getRecentRuns().size());
return RequestResult.success(dashboard);
}
}

View File

@@ -0,0 +1,14 @@
package com.bruce.dashboard.service;
import com.bruce.dashboard.vo.StudioDashboardVO;
/**
* Studio 总览工作台聚合服务。
*/
public interface IStudioDashboardService {
/**
* 汇总当前项目的发布旅程、运行摘要和风险提示。
*/
StudioDashboardVO getDashboard();
}

View File

@@ -0,0 +1,246 @@
package com.bruce.dashboard.service.impl;
import com.bruce.agent.dto.response.AgentDefinitionResponse;
import com.bruce.agent.service.IAgentDefinitionService;
import com.bruce.dashboard.service.IStudioDashboardService;
import com.bruce.dashboard.vo.StudioDashboardChecklistItemVO;
import com.bruce.dashboard.vo.StudioDashboardLifecycleStepVO;
import com.bruce.dashboard.vo.StudioDashboardMetricsVO;
import com.bruce.dashboard.vo.StudioDashboardRecentRunVO;
import com.bruce.dashboard.vo.StudioDashboardVO;
import com.bruce.mcp.service.IMcpServerService;
import com.bruce.modelprovider.service.IModelWorkspaceService;
import com.bruce.modelprovider.vo.ModelWorkspaceVO;
import com.bruce.observability.service.IObservabilityRunService;
import com.bruce.observability.vo.ObservabilityRunSummaryVO;
import com.bruce.rag.service.IKnowledgeWorkspaceService;
import com.bruce.rag.service.IRagStoreService;
import com.bruce.rag.vo.KnowledgeWorkspaceVO;
import com.bruce.skill.service.ISkillDefinitionService;
import com.bruce.workflow.service.IProjectService;
import com.bruce.workflow.service.IWorkflowDefinitionService;
import com.bruce.workflow.vo.ProjectVO;
import com.bruce.workflow.vo.WorkflowDefinitionVO;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.List;
/**
* Studio 首页聚合实现。
* <p>
* 该服务只汇总现有模块已经稳定的主数据和运行摘要,不引入新的存储表。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StudioDashboardServiceImpl implements IStudioDashboardService {
private final IProjectService projectService;
private final IWorkflowDefinitionService workflowDefinitionService;
private final IObservabilityRunService observabilityRunService;
private final IModelWorkspaceService modelWorkspaceService;
private final IRagStoreService ragStoreService;
private final IKnowledgeWorkspaceService knowledgeWorkspaceService;
private final ObjectProvider<IAgentDefinitionService> agentDefinitionServiceProvider;
private final IMcpServerService mcpServerService;
private final ISkillDefinitionService skillDefinitionService;
@Override
public StudioDashboardVO getDashboard() {
log.info("Studio 首页聚合开始");
List<ProjectVO> projects = projectService.listProjects();
ProjectVO currentProject = projects.isEmpty() ? null : projects.get(0);
List<WorkflowDefinitionVO> workflows = currentProject == null ? List.of() : workflowDefinitionService.listByProjectId(currentProject.getId());
List<ObservabilityRunSummaryVO> recentRuns = observabilityRunService.listRecentRuns();
ModelWorkspaceVO modelWorkspace = modelWorkspaceService.getWorkspace();
IAgentDefinitionService agentDefinitionService = agentDefinitionServiceProvider.getIfAvailable();
List<AgentDefinitionResponse> agents = agentDefinitionService == null ? List.of() : agentDefinitionService.listResponses();
int mcpServerCount = mcpServerService.listServers().size();
int skillCount = skillDefinitionService.listDefinitions().size();
KnowledgeWorkspaceVO knowledgeWorkspace = null;
if (!ragStoreService.listResponses().isEmpty()) {
Long firstStoreId = ragStoreService.listResponses().get(0).getId();
knowledgeWorkspace = knowledgeWorkspaceService.getWorkspace(firstStoreId);
}
StudioDashboardVO dashboard = new StudioDashboardVO();
dashboard.setProjectName(currentProject == null ? "Common Agent Studio" : currentProject.getProjectName());
dashboard.setEnvironment(currentProject == null ? "Dev" : currentProject.getEnvironment());
dashboard.setPublishStatus(currentProject == null ? "DRAFT" : currentProject.getPublishStatus());
dashboard.setLifecycleSteps(buildLifecycleSteps(knowledgeWorkspace, workflows, agents, recentRuns));
dashboard.setReadinessChecklist(buildChecklist(knowledgeWorkspace, workflows, agents, modelWorkspace, mcpServerCount, skillCount));
dashboard.setMetrics(buildMetrics(recentRuns));
dashboard.setRecentRuns(buildRecentRuns(recentRuns, workflows, agents));
dashboard.setWarningTitle(buildWarningTitle(modelWorkspace, workflows));
dashboard.setWarningMessage(buildWarningMessage(modelWorkspace, workflows, knowledgeWorkspace));
log.info("Studio 首页聚合结束projectName={}, workflowCount={}, runCount={}",
dashboard.getProjectName(), workflows.size(), recentRuns.size());
return dashboard;
}
private List<StudioDashboardLifecycleStepVO> buildLifecycleSteps(KnowledgeWorkspaceVO knowledgeWorkspace,
List<WorkflowDefinitionVO> workflows,
List<AgentDefinitionResponse> agents,
List<ObservabilityRunSummaryVO> recentRuns) {
List<StudioDashboardLifecycleStepVO> steps = new ArrayList<>();
steps.add(step("知识接入", "上传、解析、切片、向量化", knowledgeWorkspace != null && knowledgeWorkspace.getDocumentCount() > 0 ? "done" : "idle"));
steps.add(step("能力编排", "Workflow 连接模型、工具与 Skill", workflows.isEmpty() ? "idle" : "running"));
steps.add(step("对话调试", "验证引用、成本、延迟与回答质量", agents.isEmpty() ? "idle" : "running"));
steps.add(step("发布观测", "版本快照、运行追踪、异常排查", recentRuns.isEmpty() ? "idle" : "done"));
return steps;
}
private List<StudioDashboardChecklistItemVO> buildChecklist(KnowledgeWorkspaceVO knowledgeWorkspace,
List<WorkflowDefinitionVO> workflows,
List<AgentDefinitionResponse> agents,
ModelWorkspaceVO modelWorkspace,
int mcpServerCount,
int skillCount) {
List<StudioDashboardChecklistItemVO> items = new ArrayList<>();
items.add(checkItem("知识库已绑定 Embedding 模型", knowledgeWorkspace != null && knowledgeWorkspace.getEmbeddingModelId() != null));
items.add(checkItem("Workflow 已存在可编辑草稿", !workflows.isEmpty()));
items.add(checkItem("Agent 已绑定默认知识库与能力", !agents.isEmpty()));
items.add(checkItem("MCP / Skill 基础能力已接入", mcpServerCount > 0 && skillCount > 0));
items.add(checkItem("模型路由已配置至少一个启用规则", modelWorkspace.getEnabledRouteRuleCount() != null && modelWorkspace.getEnabledRouteRuleCount() > 0));
return items;
}
private StudioDashboardMetricsVO buildMetrics(List<ObservabilityRunSummaryVO> recentRuns) {
StudioDashboardMetricsVO metrics = new StudioDashboardMetricsVO();
metrics.setTodayRunCount(recentRuns.size());
long successCount = recentRuns.stream().filter(run -> "SUCCESS".equals(run.getStatus())).count();
double successRate = recentRuns.isEmpty() ? 100D : successCount * 100.0 / recentRuns.size();
metrics.setSuccessRate(roundDouble(successRate));
metrics.setP50Latency(formatP50Latency(recentRuns));
metrics.setEstimatedCost(formatCost(recentRuns));
return metrics;
}
private List<StudioDashboardRecentRunVO> buildRecentRuns(List<ObservabilityRunSummaryVO> recentRuns,
List<WorkflowDefinitionVO> workflows,
List<AgentDefinitionResponse> agents) {
return recentRuns.stream().limit(5).map(run -> {
StudioDashboardRecentRunVO item = new StudioDashboardRecentRunVO();
item.setId(run.getRequestId());
item.setName(resolveRunName(run.getWorkflowId(), workflows, agents));
item.setType(run.getWorkflowId() == null ? "Agent" : "Workflow");
item.setStatus(formatRunStatus(run.getStatus()));
item.setLatency(formatDuration(run.getDurationMs()));
item.setCost("¥" + roundBigDecimal(run.getEstimatedCost() == null ? BigDecimal.ZERO : run.getEstimatedCost()));
return item;
}).toList();
}
private String buildWarningTitle(ModelWorkspaceVO modelWorkspace, List<WorkflowDefinitionVO> workflows) {
if (modelWorkspace.getEnabledRouteRuleCount() == null || modelWorkspace.getEnabledRouteRuleCount() == 0) {
return "发布前仍需补齐模型路由";
}
if (workflows.isEmpty()) {
return "发布前仍需创建至少一个 Workflow";
}
return "生产发布前仍需确认路由兜底";
}
private String buildWarningMessage(ModelWorkspaceVO modelWorkspace,
List<WorkflowDefinitionVO> workflows,
KnowledgeWorkspaceVO knowledgeWorkspace) {
if (modelWorkspace.getRecentFailedCallCount() != null && modelWorkspace.getRecentFailedCallCount() > 0) {
return "最近存在失败模型调用,建议先补齐 fallback 模型并复核错误上下文。";
}
if (knowledgeWorkspace != null && knowledgeWorkspace.getPendingTaskCount() != null && knowledgeWorkspace.getPendingTaskCount() > 0) {
return "当前知识库仍有待索引文档,建议完成索引后再进行发布联调。";
}
if (workflows.isEmpty()) {
return "当前项目尚无可试跑 Workflow建议先完成最小链路编排。";
}
return "AGENT_PLAN 任务建议补齐 fallback 模型和最大延迟阈值后再发布。";
}
private StudioDashboardLifecycleStepVO step(String name, String description, String status) {
StudioDashboardLifecycleStepVO step = new StudioDashboardLifecycleStepVO();
step.setName(name);
step.setDescription(description);
step.setStatus(status);
return step;
}
private StudioDashboardChecklistItemVO checkItem(String label, boolean done) {
StudioDashboardChecklistItemVO item = new StudioDashboardChecklistItemVO();
item.setLabel(label);
item.setDone(done);
return item;
}
private String resolveRunName(Long workflowId, List<WorkflowDefinitionVO> workflows, List<AgentDefinitionResponse> agents) {
if (workflowId != null) {
return workflows.stream()
.filter(workflow -> workflowId.equals(workflow.getId()))
.map(WorkflowDefinitionVO::getWorkflowName)
.findFirst()
.orElse("Workflow 运行");
}
return agents.isEmpty() ? "Agent 调试会话" : agents.get(0).getAgentName();
}
private String formatRunStatus(String status) {
if ("SUCCESS".equals(status)) {
return "成功";
}
if ("FAILED".equals(status)) {
return "失败";
}
if ("RUNNING".equals(status)) {
return "运行中";
}
return status == null ? "-" : status;
}
private String formatDuration(Integer durationMs) {
if (durationMs == null) {
return "-";
}
if (durationMs >= 1000) {
return roundBigDecimal(BigDecimal.valueOf(durationMs).divide(BigDecimal.valueOf(1000), 2, RoundingMode.HALF_UP)) + "s";
}
return durationMs + "ms";
}
private String formatP50Latency(List<ObservabilityRunSummaryVO> recentRuns) {
if (recentRuns.isEmpty()) {
return "-";
}
List<Integer> durations = recentRuns.stream()
.map(ObservabilityRunSummaryVO::getDurationMs)
.filter(value -> value != null && value > 0)
.sorted()
.toList();
if (durations.isEmpty()) {
return "-";
}
Integer p50 = durations.get(durations.size() / 2);
return formatDuration(p50);
}
private String formatCost(List<ObservabilityRunSummaryVO> recentRuns) {
BigDecimal total = recentRuns.stream()
.map(ObservabilityRunSummaryVO::getEstimatedCost)
.filter(cost -> cost != null && cost.compareTo(BigDecimal.ZERO) > 0)
.reduce(BigDecimal.ZERO, BigDecimal::add);
return "¥" + roundBigDecimal(total);
}
private Double roundDouble(double value) {
return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP).doubleValue();
}
private String roundBigDecimal(BigDecimal value) {
return value.setScale(2, RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
}
}

View File

@@ -0,0 +1,12 @@
package com.bruce.dashboard.vo;
import lombok.Data;
/**
* Studio 发布就绪项。
*/
@Data
public class StudioDashboardChecklistItemVO {
private String label;
private Boolean done;
}

View File

@@ -0,0 +1,13 @@
package com.bruce.dashboard.vo;
import lombok.Data;
/**
* Studio 总览生命周期步骤。
*/
@Data
public class StudioDashboardLifecycleStepVO {
private String name;
private String description;
private String status;
}

View File

@@ -0,0 +1,14 @@
package com.bruce.dashboard.vo;
import lombok.Data;
/**
* Studio 运行指标摘要。
*/
@Data
public class StudioDashboardMetricsVO {
private Integer todayRunCount;
private Double successRate;
private String p50Latency;
private String estimatedCost;
}

View File

@@ -0,0 +1,16 @@
package com.bruce.dashboard.vo;
import lombok.Data;
/**
* Studio 最近运行摘要。
*/
@Data
public class StudioDashboardRecentRunVO {
private String id;
private String name;
private String type;
private String status;
private String latency;
private String cost;
}

View File

@@ -0,0 +1,22 @@
package com.bruce.dashboard.vo;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* Studio 首页聚合视图。
*/
@Data
public class StudioDashboardVO {
private String projectName;
private String environment;
private String publishStatus;
private List<StudioDashboardLifecycleStepVO> lifecycleSteps = new ArrayList<>();
private List<StudioDashboardChecklistItemVO> readinessChecklist = new ArrayList<>();
private StudioDashboardMetricsVO metrics;
private List<StudioDashboardRecentRunVO> recentRuns = new ArrayList<>();
private String warningTitle;
private String warningMessage;
}

View File

@@ -0,0 +1,15 @@
spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://110.42.106.130:5431/common_agent?currentSchema=common_agent
username: common_agent
password: common_agent
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
common:
attachment:
base-path: /data/common-agent/attachments

View File

@@ -0,0 +1,67 @@
package com.bruce.dashboard;
import com.bruce.common.handler.GlobalExceptionHandler;
import com.bruce.dashboard.controller.StudioDashboardController;
import com.bruce.dashboard.service.IStudioDashboardService;
import com.bruce.dashboard.vo.StudioDashboardMetricsVO;
import com.bruce.dashboard.vo.StudioDashboardVO;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* 验证 Studio 首页聚合接口响应结构,确保前端首页可稳定消费。
*/
@ExtendWith(MockitoExtension.class)
class StudioDashboardControllerTests {
private MockMvc mockMvc;
@Mock
private IStudioDashboardService studioDashboardService;
@InjectMocks
private StudioDashboardController studioDashboardController;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(studioDashboardController)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void detailShouldReturnStructuredDashboardView() throws Exception {
StudioDashboardMetricsVO metrics = new StudioDashboardMetricsVO();
metrics.setTodayRunCount(12);
metrics.setSuccessRate(98.5D);
metrics.setP50Latency("1.28s");
metrics.setEstimatedCost("¥4.82");
StudioDashboardVO dashboard = new StudioDashboardVO();
dashboard.setProjectName("Common Agent Studio");
dashboard.setEnvironment("Dev");
dashboard.setPublishStatus("DRAFT");
dashboard.setMetrics(metrics);
dashboard.setWarningTitle("发布前仍需补齐模型路由");
when(studioDashboardService.getDashboard()).thenReturn(dashboard);
mockMvc.perform(get("/api/studio/dashboard"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.resultcode").value("0"))
.andExpect(jsonPath("$.data.projectName").value("Common Agent Studio"))
.andExpect(jsonPath("$.data.environment").value("Dev"))
.andExpect(jsonPath("$.data.metrics.todayRunCount").value(12));
}
}

View File

@@ -0,0 +1,130 @@
package com.bruce.dashboard;
import com.bruce.agent.dto.response.AgentDefinitionResponse;
import com.bruce.agent.service.IAgentDefinitionService;
import com.bruce.dashboard.service.impl.StudioDashboardServiceImpl;
import com.bruce.dashboard.vo.StudioDashboardVO;
import com.bruce.mcp.service.IMcpServerService;
import com.bruce.modelprovider.service.IModelWorkspaceService;
import com.bruce.modelprovider.vo.ModelWorkspaceVO;
import com.bruce.observability.service.IObservabilityRunService;
import com.bruce.observability.vo.ObservabilityRunSummaryVO;
import com.bruce.rag.dto.response.RagStoreResponse;
import com.bruce.rag.service.IKnowledgeWorkspaceService;
import com.bruce.rag.service.IRagStoreService;
import com.bruce.rag.vo.KnowledgeWorkspaceVO;
import com.bruce.skill.service.ISkillDefinitionService;
import com.bruce.workflow.service.IProjectService;
import com.bruce.workflow.service.IWorkflowDefinitionService;
import com.bruce.workflow.vo.ProjectVO;
import com.bruce.workflow.vo.WorkflowDefinitionVO;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.beans.factory.ObjectProvider;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class StudioDashboardServiceTests {
@Mock
private IProjectService projectService;
@Mock
private IWorkflowDefinitionService workflowDefinitionService;
@Mock
private IObservabilityRunService observabilityRunService;
@Mock
private IModelWorkspaceService modelWorkspaceService;
@Mock
private IRagStoreService ragStoreService;
@Mock
private IKnowledgeWorkspaceService knowledgeWorkspaceService;
@Mock
private IAgentDefinitionService agentDefinitionService;
@Mock
private ObjectProvider<IAgentDefinitionService> agentDefinitionServiceProvider;
@Mock
private IMcpServerService mcpServerService;
@Mock
private ISkillDefinitionService skillDefinitionService;
@InjectMocks
private StudioDashboardServiceImpl studioDashboardService;
@Test
void getDashboardShouldAggregateLifecycleReadinessAndRecentRuns() {
ProjectVO project = new ProjectVO();
project.setId(101L);
project.setProjectName("Common Agent Studio");
project.setEnvironment("Dev");
project.setPublishStatus("DRAFT");
WorkflowDefinitionVO workflow = new WorkflowDefinitionVO();
workflow.setId(201L);
workflow.setWorkflowName("合同知识召回");
ObservabilityRunSummaryVO run = new ObservabilityRunSummaryVO();
run.setRequestId("req-1001");
run.setWorkflowId(201L);
run.setStatus("SUCCESS");
run.setDurationMs(1420);
run.setEstimatedCost(BigDecimal.valueOf(0.018));
ModelWorkspaceVO modelWorkspace = new ModelWorkspaceVO();
modelWorkspace.setEnabledRouteRuleCount(2);
modelWorkspace.setRecentFailedCallCount(0);
RagStoreResponse store = new RagStoreResponse();
store.setId(1001L);
KnowledgeWorkspaceVO knowledgeWorkspace = new KnowledgeWorkspaceVO();
knowledgeWorkspace.setEmbeddingModelId(88L);
knowledgeWorkspace.setDocumentCount(9);
knowledgeWorkspace.setPendingTaskCount(1);
AgentDefinitionResponse agent = new AgentDefinitionResponse();
agent.setId(301L);
agent.setAgentName("售前问答 Agent");
when(projectService.listProjects()).thenReturn(List.of(project));
when(workflowDefinitionService.listByProjectId(101L)).thenReturn(List.of(workflow));
when(observabilityRunService.listRecentRuns()).thenReturn(List.of(run));
when(modelWorkspaceService.getWorkspace()).thenReturn(modelWorkspace);
when(ragStoreService.listResponses()).thenReturn(List.of(store));
when(knowledgeWorkspaceService.getWorkspace(1001L)).thenReturn(knowledgeWorkspace);
when(agentDefinitionServiceProvider.getIfAvailable()).thenReturn(agentDefinitionService);
when(agentDefinitionService.listResponses()).thenReturn(List.of(agent));
when(mcpServerService.listServers()).thenReturn(List.of());
when(skillDefinitionService.listDefinitions()).thenReturn(List.of());
StudioDashboardVO dashboard = studioDashboardService.getDashboard();
assertNotNull(dashboard);
assertEquals("Common Agent Studio", dashboard.getProjectName());
assertEquals("Dev", dashboard.getEnvironment());
assertEquals(4, dashboard.getLifecycleSteps().size());
assertEquals(5, dashboard.getReadinessChecklist().size());
assertEquals(1, dashboard.getMetrics().getTodayRunCount());
assertEquals(100D, dashboard.getMetrics().getSuccessRate());
assertEquals("1.42s", dashboard.getRecentRuns().get(0).getLatency());
assertEquals("合同知识召回", dashboard.getRecentRuns().get(0).getName());
assertEquals("当前知识库仍有待索引文档,建议完成索引后再进行发布联调。", dashboard.getWarningMessage());
}
}

View File

@@ -1,5 +1,6 @@
package com.bruce.common.config;
package com.bruce.integration.config;
import com.bruce.common.config.EntityAuditMetaObjectHandler;
import com.bruce.rag.entity.RagStore;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.junit.jupiter.api.Test;

View File

@@ -1,4 +1,4 @@
package com.bruce.common.enumconfig;
package com.bruce.integration.enumconfig;
import com.bruce.common.enums.CommonStatusEnum;
import com.bruce.common.enums.EnableStatusEnum;

View File

@@ -1,4 +1,4 @@
package com.bruce.common.enumconfig;
package com.bruce.integration.enumconfig;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.bruce.common.domain.entity.SysEnum;

View File

@@ -0,0 +1,90 @@
package com.bruce.integration.enumconfig;
import com.bruce.common.domain.entity.SysEnum;
import com.bruce.common.enums.PersistableSysEnumDefinition;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/**
* sys_enum 初始化测试的辅助工具。
* <p>
* 该类只服务于测试入口,用于把代码里的枚举定义组装成可落库的数据结构,
* 并在真正写库前完成组级唯一性校验。
*/
final class SysEnumDefinitionSyncSupport {
private SysEnumDefinitionSyncSupport() {
}
static EnumGroup groupOf(List<? extends PersistableSysEnumDefinition> definitions) {
if (definitions == null || definitions.isEmpty()) {
throw new IllegalArgumentException("枚举定义不能为空");
}
PersistableSysEnumDefinition first = definitions.getFirst();
validateGroupMembers(first, definitions);
validateUniqueValuesAndSorts(first, definitions);
return new EnumGroup(first.getCatalog(), first.getType(), List.copyOf(definitions));
}
static void validateUniqueGroupKeys(List<EnumGroup> groups) {
Set<String> keys = new HashSet<>();
for (EnumGroup group : groups) {
String key = group.catalog() + "/" + group.type();
if (!keys.add(key)) {
throw new IllegalArgumentException("存在重复的枚举分组: " + key);
}
}
}
static List<SysEnum> toEntities(EnumGroup group) {
return group.definitions().stream()
.map(item -> {
SysEnum sysEnum = new SysEnum();
sysEnum.setCatalog(group.catalog());
sysEnum.setType(group.type());
sysEnum.setName(item.getName());
sysEnum.setValue(item.getValue());
sysEnum.setStrvalue(item.getStrvalue());
sysEnum.setSort(item.getSort());
sysEnum.setRemark(item.getRemark());
return sysEnum;
})
.toList();
}
private static void validateGroupMembers(
PersistableSysEnumDefinition first,
List<? extends PersistableSysEnumDefinition> definitions
) {
for (PersistableSysEnumDefinition item : definitions) {
if (!first.getCatalog().equals(item.getCatalog()) || !first.getType().equals(item.getType())) {
throw new IllegalArgumentException("同一枚举组中的 catalog/type 必须一致");
}
}
}
private static void validateUniqueValuesAndSorts(
PersistableSysEnumDefinition first,
List<? extends PersistableSysEnumDefinition> definitions
) {
Set<Integer> values = new HashSet<>();
Set<Integer> sorts = new HashSet<>();
for (PersistableSysEnumDefinition item : definitions) {
if (!values.add(item.getValue())) {
throw new IllegalArgumentException("枚举值重复: " + first.getCatalog() + "/" + first.getType() + "/" + item.getValue());
}
if (!sorts.add(item.getSort())) {
throw new IllegalArgumentException("枚举排序重复: " + first.getCatalog() + "/" + first.getType() + "/" + item.getSort());
}
}
}
record EnumGroup(
String catalog,
String type,
List<? extends PersistableSysEnumDefinition> definitions
) {
}
}

View File

@@ -1,4 +1,4 @@
package com.bruce.common.enumconfig;
package com.bruce.integration.enumconfig;
import com.bruce.common.domain.entity.SysEnum;
import com.bruce.common.enums.EnableStatusEnum;

View File

@@ -0,0 +1,313 @@
package com.bruce.integration.schema;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.bruce.agent.entity.AgentCapabilityBinding;
import com.bruce.agent.entity.AgentDefinition;
import com.bruce.agent.entity.AgentMessage;
import com.bruce.agent.entity.AgentSession;
import com.bruce.common.domain.entity.SysAttachment;
import com.bruce.common.domain.entity.SysEnum;
import com.bruce.common.domain.model.BaseEntity;
import com.bruce.mcp.entity.McpCapability;
import com.bruce.mcp.entity.McpServer;
import com.bruce.modelprovider.entity.ModelCallLog;
import com.bruce.modelprovider.entity.ModelConfig;
import com.bruce.modelprovider.entity.ModelProvider;
import com.bruce.modelprovider.entity.ModelRouteRule;
import com.bruce.modelprovider.entity.RagStoreModelConfig;
import com.bruce.rag.entity.RagChunk;
import com.bruce.rag.entity.RagChunkEmbedding;
import com.bruce.rag.entity.RagDocument;
import com.bruce.rag.entity.RagDocumentParseResult;
import com.bruce.rag.entity.RagStore;
import com.bruce.skill.entity.SkillDefinition;
import com.bruce.skill.entity.SkillVersion;
import com.bruce.workflow.entity.StudioProject;
import com.bruce.workflow.entity.WorkflowDefinition;
import com.bruce.workflow.entity.WorkflowRun;
import com.bruce.workflow.entity.WorkflowRunStep;
import com.bruce.workflow.entity.WorkflowVersion;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 校验 SQL 建表脚本与实体字段映射保持一致。
* <p>
* 这组测试直接读取 script/sql 下的建表脚本,把数据库字段与 Java Entity 的映射关系做逐表比对,
* 用来补强此前偏结构性的 mapper/repository 验证。
*/
class SqlEntityMappingContractTests {
private static final Path SQL_DIR = Path.of("..", "script", "sql");
/**
* BaseEntity 约定的公共审计字段,需要在所有业务表脚本中保留。
*/
private static final Set<String> BASE_COLUMNS = Set.of(
"id", "create_by", "create_time", "update_by", "update_time", "version"
);
@Test
void entityMappedColumnsShouldExistInSqlScripts() throws IOException {
Map<String, Set<String>> tableColumns = loadTableColumns();
for (Class<?> entityClass : entityClasses()) {
TableName tableName = entityClass.getAnnotation(TableName.class);
assertNotNull(tableName, "实体缺少 @TableName: " + entityClass.getName());
String table = tableName.value();
Set<String> sqlColumns = tableColumns.get(table);
assertNotNull(sqlColumns, "SQL 脚本未找到表定义: " + table);
Set<String> entityColumns = collectEntityColumns(entityClass);
for (String column : entityColumns) {
assertTrue(sqlColumns.contains(column),
() -> "" + table + " 缺少实体映射字段 " + column + ",实体: " + entityClass.getSimpleName());
}
}
}
@Test
void sqlTablesShouldHaveExpectedEntityCoverage() throws IOException {
Map<String, Set<String>> tableColumns = loadTableColumns();
Set<String> entityTables = new LinkedHashSet<>();
for (Class<?> entityClass : entityClasses()) {
entityTables.add(entityClass.getAnnotation(TableName.class).value());
}
Set<String> expectedTables = Set.of(
"sys_enum",
"sys_attachment",
"rag_store",
"rag_document",
"rag_document_parse_result",
"rag_chunk",
"rag_chunk_embedding",
"agent_definition",
"agent_session",
"agent_message",
"agent_capability_binding",
"model_provider",
"model_config",
"model_route_rule",
"rag_store_model_config",
"model_call_log",
"studio_project",
"workflow_definition",
"workflow_version",
"workflow_run",
"workflow_run_step",
"mcp_server",
"mcp_capability",
"skill_definition",
"skill_version"
);
assertEquals(expectedTables, entityTables, "实体覆盖的表清单应与当前模块表一致");
assertTrue(tableColumns.keySet().containsAll(expectedTables), "SQL 脚本应覆盖全部模块表");
}
@Test
void entityTablesShouldRetainBaseAuditColumns() throws IOException {
Map<String, Set<String>> tableColumns = loadTableColumns();
for (Class<?> entityClass : entityClasses()) {
String table = entityClass.getAnnotation(TableName.class).value();
Set<String> sqlColumns = tableColumns.get(table);
assertNotNull(sqlColumns, "SQL 脚本未找到表定义: " + table);
assertTrue(sqlColumns.containsAll(BASE_COLUMNS),
() -> "" + table + " 缺少 BaseEntity 审计字段,实际字段: " + sqlColumns);
}
}
@Test
void sqlColumnParserShouldIgnoreConstraintsAndIndexes() throws IOException {
Map<String, Set<String>> tableColumns = loadTableColumns();
Collection<Set<String>> allColumns = tableColumns.values();
assertFalse(allColumns.stream().flatMap(Set::stream).anyMatch(column -> column.startsWith("constraint")),
"列解析不应把约束名当成字段");
assertFalse(allColumns.stream().flatMap(Set::stream).anyMatch(column -> column.startsWith("foreign")),
"列解析不应把外键定义当成字段");
}
private Map<String, Set<String>> loadTableColumns() throws IOException {
Map<String, Set<String>> tableColumns = new LinkedHashMap<>();
Pattern createTablePattern = Pattern.compile("CREATE TABLE(?: IF NOT EXISTS)?\\s+([a-zA-Z0-9_]+)\\s*\\(",
Pattern.CASE_INSENSITIVE);
for (Path sqlFile : sqlFiles()) {
List<String> lines = Files.readAllLines(sqlFile, StandardCharsets.UTF_8);
String currentTable = null;
Set<String> currentColumns = null;
int nesting = 0;
for (String rawLine : lines) {
String line = rawLine.trim();
if (line.isEmpty() || line.startsWith("--")) {
continue;
}
if (currentTable == null) {
Matcher matcher = createTablePattern.matcher(line);
if (matcher.find()) {
currentTable = matcher.group(1).toLowerCase(Locale.ROOT);
currentColumns = new LinkedHashSet<>();
tableColumns.put(currentTable, currentColumns);
nesting = 1;
}
continue;
}
nesting += count(line, '(');
nesting -= count(line, ')');
if (!line.startsWith("CONSTRAINT")
&& !line.startsWith("PRIMARY KEY")
&& !line.startsWith("FOREIGN KEY")
&& !line.startsWith("UNIQUE")
&& !line.startsWith("CHECK")) {
String column = extractColumnName(line);
if (column != null) {
currentColumns.add(column);
}
}
if (nesting <= 0) {
currentTable = null;
currentColumns = null;
nesting = 0;
}
}
}
return tableColumns;
}
private List<Path> sqlFiles() throws IOException {
List<Path> sqlFiles = new ArrayList<>();
try (var paths = Files.list(SQL_DIR)) {
paths.filter(path -> path.getFileName().toString().endsWith(".sql"))
.sorted()
.forEach(sqlFiles::add);
}
return sqlFiles;
}
private String extractColumnName(String line) {
String sanitized = line.replace(",", "").trim();
if (sanitized.isEmpty()) {
return null;
}
int firstSpace = sanitized.indexOf(' ');
if (firstSpace <= 0) {
return null;
}
String column = sanitized.substring(0, firstSpace).trim();
if (!column.matches("[a-zA-Z_][a-zA-Z0-9_]*")) {
return null;
}
return column.toLowerCase(Locale.ROOT);
}
private int count(String text, char target) {
int result = 0;
for (int index = 0; index < text.length(); index++) {
if (text.charAt(index) == target) {
result++;
}
}
return result;
}
private Set<String> collectEntityColumns(Class<?> entityClass) {
Set<String> columns = new LinkedHashSet<>();
for (Field field : allFields(entityClass)) {
if (Modifier.isStatic(field.getModifiers()) || Modifier.isTransient(field.getModifiers())) {
continue;
}
TableField tableField = field.getAnnotation(TableField.class);
String column = tableField == null || tableField.value().isBlank()
? camelToSnake(field.getName())
: tableField.value();
columns.add(column.toLowerCase(Locale.ROOT));
}
return columns;
}
private List<Field> allFields(Class<?> entityClass) {
List<Field> fields = new ArrayList<>();
Class<?> current = entityClass;
while (current != null && current != Object.class) {
fields.addAll(Arrays.asList(current.getDeclaredFields()));
if (current == BaseEntity.class) {
break;
}
current = current.getSuperclass();
}
return fields;
}
private String camelToSnake(String value) {
StringBuilder builder = new StringBuilder();
for (int index = 0; index < value.length(); index++) {
char current = value.charAt(index);
if (Character.isUpperCase(current)) {
if (index > 0) {
builder.append('_');
}
builder.append(Character.toLowerCase(current));
} else {
builder.append(current);
}
}
return builder.toString();
}
private List<Class<?>> entityClasses() {
return List.of(
SysEnum.class,
SysAttachment.class,
RagStore.class,
RagDocument.class,
RagDocumentParseResult.class,
RagChunk.class,
RagChunkEmbedding.class,
AgentDefinition.class,
AgentSession.class,
AgentMessage.class,
AgentCapabilityBinding.class,
ModelProvider.class,
ModelConfig.class,
ModelRouteRule.class,
RagStoreModelConfig.class,
ModelCallLog.class,
StudioProject.class,
WorkflowDefinition.class,
WorkflowVersion.class,
WorkflowRun.class,
WorkflowRunStep.class,
McpServer.class,
McpCapability.class,
SkillDefinition.class,
SkillVersion.class
);
}
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.bruce</groupId>
<artifactId>common-agent-parent</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<artifactId>common-agent-common</artifactId>
<name>common-agent-common</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-core</artifactId>
</dependency>
<dependency>
<groupId>org.apache.tika</groupId>
<artifactId>tika-parsers-standard-package</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -3,26 +3,39 @@ package com.bruce.common.controller;
import com.bruce.common.domain.model.RequestResult;
import com.bruce.common.dto.request.SysAttachmentUploadRequest;
import com.bruce.common.dto.response.SysAttachmentResponse;
import com.bruce.common.factory.SysAttachmentFactory;
import com.bruce.common.service.ISysAttachmentService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "系统附件管理")
@Slf4j
@RestController
@RequestMapping("/api/attachments")
@RequiredArgsConstructor
public class SysAttachmentController {
@Autowired
private ISysAttachmentService sysAttachmentService;
private final ISysAttachmentService sysAttachmentService;
private final SysAttachmentFactory sysAttachmentFactory;
@Operation(summary = "上传附件")
@PostMapping("/upload")
public RequestResult<SysAttachmentResponse> upload(@ModelAttribute SysAttachmentUploadRequest request) {
return RequestResult.success(SysAttachmentResponse.fromEntity(sysAttachmentService.upload(request)));
log.info("上传附件开始sourceType={}, sourceId={}",
request == null ? null : request.getSourceType(),
request == null ? null : request.getSourceId());
SysAttachmentResponse response = sysAttachmentFactory.toResponse(sysAttachmentService.upload(request));
log.info("上传附件结束attachmentId={}, sourceType={}, sourceId={}",
response == null ? null : response.getId(),
request == null ? null : request.getSourceType(),
request == null ? null : request.getSourceId());
return RequestResult.success(response);
}
}

View File

@@ -1,9 +1,7 @@
package com.bruce.common.dto.response;
import com.bruce.common.domain.entity.SysAttachment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.beans.BeanUtils;
@Data
@Schema(description = "系统附件响应")
@@ -41,13 +39,4 @@ public class SysAttachmentResponse {
@Schema(description = "备注")
private String remark;
public static SysAttachmentResponse fromEntity(SysAttachment entity) {
if (entity == null) {
return null;
}
SysAttachmentResponse response = new SysAttachmentResponse();
BeanUtils.copyProperties(entity, response);
return response;
}
}

View File

@@ -1,9 +1,7 @@
package com.bruce.common.dto.response;
import com.bruce.common.domain.entity.SysEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.beans.BeanUtils;
@Data
@Schema(description = "系统枚举响应")
@@ -32,13 +30,4 @@ public class SysEnumResponse {
@Schema(description = "备注")
private String remark;
public static SysEnumResponse fromEntity(SysEnum entity) {
if (entity == null) {
return null;
}
SysEnumResponse response = new SysEnumResponse();
BeanUtils.copyProperties(entity, response);
return response;
}
}

View File

@@ -0,0 +1,22 @@
package com.bruce.common.factory;
import com.bruce.common.domain.entity.SysAttachment;
import com.bruce.common.dto.response.SysAttachmentResponse;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Component;
/**
* 系统附件工厂,集中处理附件元数据出参转换。
*/
@Component
public class SysAttachmentFactory {
public SysAttachmentResponse toResponse(SysAttachment entity) {
if (entity == null) {
return null;
}
SysAttachmentResponse response = new SysAttachmentResponse();
BeanUtils.copyProperties(entity, response);
return response;
}
}

View File

@@ -0,0 +1,63 @@
package com.bruce.common.factory;
import com.bruce.common.domain.entity.SysEnum;
import com.bruce.common.dto.request.SysEnumBatchSaveRequest;
import com.bruce.common.dto.request.SysEnumSaveRequest;
import com.bruce.common.dto.response.SysEnumResponse;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* 系统枚举工厂,统一负责请求对象、实体对象和响应对象之间的转换。
*/
@Component
public class SysEnumFactory {
/**
* 将保存请求转换为实体,避免在服务层散落字段拷贝逻辑。
*/
public SysEnum toEntity(SysEnumSaveRequest request) {
if (request == null) {
return null;
}
SysEnum entity = new SysEnum();
BeanUtils.copyProperties(request, entity);
return entity;
}
/**
* 将批量请求中的单个枚举项转换为实体catalog/type 由外层分组统一提供。
*/
public SysEnum toEntity(String catalog, String type, SysEnumBatchSaveRequest.Item item) {
if (item == null) {
return null;
}
SysEnum entity = new SysEnum();
entity.setCatalog(catalog);
entity.setType(type);
entity.setName(item.getName());
entity.setValue(item.getValue());
entity.setStrvalue(item.getStrvalue());
entity.setSort(item.getSort());
entity.setRemark(item.getRemark());
return entity;
}
/**
* 将实体转换为返回对象,保持接口层不直接暴露实体。
*/
public SysEnumResponse toResponse(SysEnum entity) {
if (entity == null) {
return null;
}
SysEnumResponse response = new SysEnumResponse();
BeanUtils.copyProperties(entity, response);
return response;
}
public List<SysEnumResponse> toResponses(List<SysEnum> entities) {
return entities == null ? List.of() : entities.stream().map(this::toResponse).toList();
}
}

View File

@@ -6,6 +6,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.bind.MissingServletRequestParameterException;
@Slf4j
@RestControllerAdvice
@@ -17,6 +19,15 @@ public class GlobalExceptionHandler {
return buildResponse(HttpStatus.BAD_REQUEST, exception.getMessage());
}
@ExceptionHandler({
MissingServletRequestParameterException.class,
MethodArgumentTypeMismatchException.class
})
public ResponseEntity<RequestResult<Void>> handleBadRequest(Exception exception) {
log.warn("GlobalExceptionHandler.handleBadRequest, message={}", exception.getMessage(), exception);
return buildResponse(HttpStatus.BAD_REQUEST, "请求参数不合法");
}
@ExceptionHandler(Exception.class)
public ResponseEntity<RequestResult<Void>> handleException(Exception exception) {
log.error("GlobalExceptionHandler.handleException", exception);

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