Compare commits
10 Commits
8f7ffd6cc9
...
edc3babe6b
| Author | SHA1 | Date | |
|---|---|---|---|
| edc3babe6b | |||
| d5d239ae3a | |||
| 73237507e9 | |||
| c8245ba0d6 | |||
| 1d401c6841 | |||
| 92b0a971f2 | |||
| 15808b8569 | |||
| 91e05a26cd | |||
| eb64af9d50 | |||
| ebe0fc5a12 |
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -20,6 +21,12 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Agent 会话控制器。
|
||||
* <p>
|
||||
* 负责会话创建、详情、消息查询与工作台聚合查询,保持前端只消费 DTO / VO 契约。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/agent-sessions")
|
||||
@RequiredArgsConstructor
|
||||
@@ -31,16 +38,37 @@ public class AgentSessionController {
|
||||
|
||||
@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));
|
||||
}
|
||||
|
||||
@@ -48,12 +76,15 @@ public class AgentSessionController {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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;
|
||||
|
||||
@@ -25,7 +26,7 @@ public class AgentSessionServiceImpl extends ServiceImpl<AgentSessionMapper, Age
|
||||
private static final String SESSION_STATUS_ACTIVE = "ACTIVE";
|
||||
private static final String SESSION_STATUS_CLOSED = "CLOSED";
|
||||
|
||||
private final IAgentDefinitionService agentDefinitionService;
|
||||
private final ObjectProvider<IAgentDefinitionService> agentDefinitionServiceProvider;
|
||||
private final AgentSessionFactory agentSessionFactory;
|
||||
|
||||
@Override
|
||||
@@ -40,6 +41,10 @@ public class AgentSessionServiceImpl extends ServiceImpl<AgentSessionMapper, Age
|
||||
@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());
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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;
|
||||
@@ -32,6 +33,9 @@ class AgentSessionServiceTests {
|
||||
@Mock
|
||||
private IAgentDefinitionService agentDefinitionService;
|
||||
|
||||
@Mock
|
||||
private ObjectProvider<IAgentDefinitionService> agentDefinitionServiceProvider;
|
||||
|
||||
@Spy
|
||||
@InjectMocks
|
||||
private AgentSessionServiceImpl agentSessionService;
|
||||
@@ -42,6 +46,7 @@ class AgentSessionServiceTests {
|
||||
|
||||
@Test
|
||||
void createSessionShouldRejectDisabledAgent() {
|
||||
when(agentDefinitionServiceProvider.getIfAvailable()).thenReturn(agentDefinitionService);
|
||||
AgentDefinition agent = new AgentDefinition();
|
||||
agent.setId(1L);
|
||||
agent.setStatus("DISABLED");
|
||||
@@ -56,6 +61,7 @@ class AgentSessionServiceTests {
|
||||
|
||||
@Test
|
||||
void createSessionShouldPersistActiveSession() {
|
||||
when(agentDefinitionServiceProvider.getIfAvailable()).thenReturn(agentDefinitionService);
|
||||
AgentDefinition agent = new AgentDefinition();
|
||||
agent.setId(1L);
|
||||
agent.setStatus(EnableStatusEnum.ENABLED.name());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.bruce.dashboard.service;
|
||||
|
||||
import com.bruce.dashboard.vo.StudioDashboardVO;
|
||||
|
||||
/**
|
||||
* Studio 总览工作台聚合服务。
|
||||
*/
|
||||
public interface IStudioDashboardService {
|
||||
|
||||
/**
|
||||
* 汇总当前项目的发布旅程、运行摘要和风险提示。
|
||||
*/
|
||||
StudioDashboardVO getDashboard();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.bruce.dashboard.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* Studio 发布就绪项。
|
||||
*/
|
||||
@Data
|
||||
public class StudioDashboardChecklistItemVO {
|
||||
private String label;
|
||||
private Boolean done;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.bruce.common.controller;
|
||||
|
||||
import com.bruce.common.dto.response.SysEnumResponse;
|
||||
import com.bruce.common.handler.GlobalExceptionHandler;
|
||||
import com.bruce.common.service.ISysEnumService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 验证系统枚举控制器的基础接口契约,确保前端依赖的 RequestResult 结构稳定。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SysEnumControllerTests {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private ISysEnumService sysEnumService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
SysEnumController controller = new SysEnumController();
|
||||
ReflectionTestUtils.setField(controller, "sysEnumService", sysEnumService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryForManagementShouldReturnStructuredResult() throws Exception {
|
||||
SysEnumResponse response = new SysEnumResponse();
|
||||
response.setId(1L);
|
||||
response.setCatalog("common");
|
||||
response.setType("enable_status");
|
||||
response.setName("启用");
|
||||
response.setValue(1);
|
||||
response.setStrvalue("ENABLED");
|
||||
|
||||
when(sysEnumService.listForManagement(any())).thenReturn(List.of(response));
|
||||
|
||||
mockMvc.perform(post("/api/sys-enum/queryForManagement")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"catalog": "common",
|
||||
"type": "enable_status"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data[0].catalog").value("common"))
|
||||
.andExpect(jsonPath("$.data[0].strvalue").value("ENABLED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailShouldReturnBadRequestWhenIdMissing() throws Exception {
|
||||
mockMvc.perform(get("/api/sys-enum/detail"))
|
||||
.andExpect(status().isBadRequest());
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ import com.bruce.mcp.service.IMcpCapabilityService;
|
||||
import com.bruce.mcp.service.IMcpImportService;
|
||||
import com.bruce.mcp.service.IMcpServerService;
|
||||
import com.bruce.mcp.service.IMcpWorkspaceService;
|
||||
import com.bruce.mcp.vo.McpCapabilityVO;
|
||||
import com.bruce.mcp.vo.McpServerVO;
|
||||
import com.bruce.mcp.vo.McpCapabilityVO;
|
||||
import com.bruce.mcp.vo.McpWorkspaceVO;
|
||||
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;
|
||||
@@ -21,6 +22,12 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP 接入控制器。
|
||||
* <p>
|
||||
* 负责导入服务、查询服务与能力、以及工作台聚合视图查询。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/mcp")
|
||||
@RequiredArgsConstructor
|
||||
@@ -33,16 +40,28 @@ public class McpImportController {
|
||||
|
||||
@PostMapping("/import")
|
||||
public RequestResult<Boolean> importServer(@RequestBody McpImportDTO request) {
|
||||
log.info("MCP服务导入开始,serverCode={}, importType={}", request.getServerCode(), request.getImportType());
|
||||
return RequestResult.success(mcpImportService.importServer(request));
|
||||
}
|
||||
|
||||
@GetMapping("/servers")
|
||||
public RequestResult<List<McpServerVO>> listServers() {
|
||||
log.info("MCP服务列表查询开始");
|
||||
return RequestResult.success(mcpServerService.listServers());
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容前端实现文档中的 query 路径。
|
||||
*/
|
||||
@PostMapping("/servers/query")
|
||||
public RequestResult<List<McpServerVO>> queryServers() {
|
||||
log.info("MCP服务列表兼容查询开始");
|
||||
return RequestResult.success(mcpServerService.listServers());
|
||||
}
|
||||
|
||||
@GetMapping("/servers/{serverId}/capabilities")
|
||||
public RequestResult<List<McpCapabilityVO>> listCapabilities(@PathVariable("serverId") Long serverId) {
|
||||
log.info("MCP能力列表查询开始,serverId={}", serverId);
|
||||
return RequestResult.success(mcpCapabilityService.listByServerId(serverId));
|
||||
}
|
||||
|
||||
@@ -51,16 +70,19 @@ public class McpImportController {
|
||||
*/
|
||||
@GetMapping("/servers/code/{serverCode}/capabilities")
|
||||
public RequestResult<List<McpCapabilityVO>> listCapabilitiesByServerCode(@PathVariable("serverCode") String serverCode) {
|
||||
log.info("MCP能力列表按编码查询开始,serverCode={}", serverCode);
|
||||
return RequestResult.success(mcpCapabilityService.listByServerCode(serverCode));
|
||||
}
|
||||
|
||||
@PostMapping("/capabilities/save")
|
||||
public RequestResult<Boolean> saveCapability(@RequestBody McpCapabilitySaveDTO request) {
|
||||
log.info("MCP能力保存开始,serverId={}, capabilityCode={}", request.getServerId(), request.getCapabilityCode());
|
||||
return RequestResult.success(mcpCapabilityService.saveCapability(request));
|
||||
}
|
||||
|
||||
@GetMapping("/workspace")
|
||||
public RequestResult<McpWorkspaceVO> workspace(@RequestParam("serverId") Long serverId) {
|
||||
log.info("MCP工作台查询开始,serverId={}", serverId);
|
||||
return RequestResult.success(mcpWorkspaceService.getWorkspace(serverId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.bruce.mcp.controller;
|
||||
|
||||
import com.bruce.common.handler.GlobalExceptionHandler;
|
||||
import com.bruce.mcp.service.IMcpCapabilityService;
|
||||
import com.bruce.mcp.service.IMcpImportService;
|
||||
import com.bruce.mcp.service.IMcpServerService;
|
||||
import com.bruce.mcp.service.IMcpWorkspaceService;
|
||||
import com.bruce.mcp.vo.McpServerVO;
|
||||
import com.bruce.mcp.vo.McpWorkspaceVO;
|
||||
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.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* 验证 MCP 工作台查询接口的基础契约。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class McpImportControllerTests {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private IMcpImportService mcpImportService;
|
||||
|
||||
@Mock
|
||||
private IMcpServerService mcpServerService;
|
||||
|
||||
@Mock
|
||||
private IMcpCapabilityService mcpCapabilityService;
|
||||
|
||||
@Mock
|
||||
private IMcpWorkspaceService mcpWorkspaceService;
|
||||
|
||||
@InjectMocks
|
||||
private McpImportController mcpImportController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(mcpImportController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void workspaceShouldReturnStructuredWorkspaceView() throws Exception {
|
||||
McpWorkspaceVO workspace = new McpWorkspaceVO();
|
||||
workspace.setServerId(301L);
|
||||
workspace.setServerCode("jira_server");
|
||||
workspace.setServerName("Jira 服务");
|
||||
workspace.setImportType("URL");
|
||||
workspace.setHealthStatus("HEALTHY");
|
||||
|
||||
when(mcpWorkspaceService.getWorkspace(301L)).thenReturn(workspace);
|
||||
|
||||
mockMvc.perform(get("/api/mcp/workspace").param("serverId", "301"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.serverId").value(301))
|
||||
.andExpect(jsonPath("$.data.serverCode").value("jira_server"))
|
||||
.andExpect(jsonPath("$.data.healthStatus").value("HEALTHY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryServersCompatShouldReturnStructuredServerList() throws Exception {
|
||||
McpServerVO server = new McpServerVO();
|
||||
server.setId(301L);
|
||||
server.setServerCode("jira_server");
|
||||
server.setServerName("Jira 服务");
|
||||
|
||||
when(mcpServerService.listServers()).thenReturn(java.util.List.of(server));
|
||||
|
||||
mockMvc.perform(post("/api/mcp/servers/query"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data[0].serverCode").value("jira_server"));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import com.bruce.modelprovider.service.IModelConfigService;
|
||||
import com.bruce.modelprovider.service.IModelRouteRuleService;
|
||||
import com.bruce.modelprovider.service.IModelRouteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -25,9 +26,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
/**
|
||||
* ModelRouteServiceImpl,负责模型平台对应层的职责。
|
||||
*/
|
||||
@Slf4j
|
||||
public class ModelRouteServiceImpl implements IModelRouteService {
|
||||
|
||||
private final IModelRouteRuleService modelRouteRuleService;
|
||||
@@ -40,13 +39,12 @@ public class ModelRouteServiceImpl implements IModelRouteService {
|
||||
* @return 路由决策结果,包含主模型、备用模型和命中原因
|
||||
*/
|
||||
@Override
|
||||
/**
|
||||
* 方法 route,用于执行业务逻辑处理。
|
||||
*/
|
||||
public ModelRouteDecision route(ModelRouteContext context) {
|
||||
if (context == null) {
|
||||
throw new IllegalArgumentException("路由上下文不能为空");
|
||||
}
|
||||
log.info("模型路由决策开始,taskType={}, matchScope={}, scopeId={}, modelType={}",
|
||||
context.getTaskType(), context.getMatchScope(), context.getScopeId(), context.getRequiredModelType());
|
||||
ModelRouteRule rule = selectRule(context);
|
||||
if (rule == null) {
|
||||
ModelConfig defaultModel = modelConfigService.lambdaQuery()
|
||||
@@ -62,6 +60,8 @@ public class ModelRouteServiceImpl implements IModelRouteService {
|
||||
decision.setPrimaryModel(defaultModel);
|
||||
decision.setRouteStrategy("MANUAL");
|
||||
decision.setReason("命中模型类型默认模型");
|
||||
log.info("模型路由决策完成,taskType={}, strategy={}, primaryModelId={}, reason={}",
|
||||
context.getTaskType(), decision.getRouteStrategy(), defaultModel.getId(), decision.getReason());
|
||||
return decision;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,8 @@ public class ModelRouteServiceImpl implements IModelRouteService {
|
||||
decision.setFallbackModels(fallbackModels);
|
||||
decision.setRouteStrategy(rule.getRouteStrategy());
|
||||
decision.setReason("命中规则: " + rule.getRouteCode());
|
||||
log.info("模型路由决策完成,taskType={}, routeCode={}, primaryModelId={}, fallbackCount={}",
|
||||
context.getTaskType(), rule.getRouteCode(), primary.getId(), fallbackModels.size());
|
||||
return decision;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.bruce.modelprovider.controller;
|
||||
|
||||
import com.bruce.common.handler.GlobalExceptionHandler;
|
||||
import com.bruce.modelprovider.service.IModelWorkspaceService;
|
||||
import com.bruce.modelprovider.vo.ModelWorkspaceVO;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 验证模型工作台聚合接口的基础返回契约。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ModelWorkspaceControllerTests {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private IModelWorkspaceService modelWorkspaceService;
|
||||
|
||||
@InjectMocks
|
||||
private ModelWorkspaceController modelWorkspaceController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(modelWorkspaceController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWorkspaceShouldReturnStructuredAggregateView() throws Exception {
|
||||
ModelWorkspaceVO workspace = new ModelWorkspaceVO();
|
||||
workspace.setProviderCount(3);
|
||||
workspace.setModelCount(5);
|
||||
workspace.setRouteRuleCount(2);
|
||||
workspace.setRecentFailedCallCount(1);
|
||||
|
||||
when(modelWorkspaceService.getWorkspace()).thenReturn(workspace);
|
||||
|
||||
mockMvc.perform(get("/api/model/workspace"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.providerCount").value(3))
|
||||
.andExpect(jsonPath("$.data.modelCount").value(5))
|
||||
.andExpect(jsonPath("$.data.routeRuleCount").value(2));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.bruce.observability.vo.ObservabilityModelCallSummaryVO;
|
||||
import com.bruce.observability.vo.ObservabilityRunSummaryVO;
|
||||
import com.bruce.observability.vo.ObservabilityTraceVO;
|
||||
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.RequestMapping;
|
||||
@@ -20,6 +21,7 @@ import java.util.List;
|
||||
/**
|
||||
* 运行观测控制器,聚合 Workflow、Agent 和模型调用信息,返回脱敏摘要。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/observability")
|
||||
@RequiredArgsConstructor
|
||||
@@ -31,21 +33,25 @@ public class ObservabilityTraceController {
|
||||
|
||||
@GetMapping("/runs")
|
||||
public RequestResult<List<ObservabilityRunSummaryVO>> runs() {
|
||||
log.info("运行观测列表查询开始");
|
||||
return RequestResult.success(observabilityRunService.listRecentRuns());
|
||||
}
|
||||
|
||||
@GetMapping("/runs/{requestId}")
|
||||
public RequestResult<ObservabilityTraceVO> trace(@PathVariable("requestId") String requestId) {
|
||||
log.info("运行追踪查询开始,requestId={}", requestId);
|
||||
return RequestResult.success(observabilityTraceService.getTrace(requestId));
|
||||
}
|
||||
|
||||
@GetMapping("/model-calls")
|
||||
public RequestResult<List<ObservabilityModelCallSummaryVO>> modelCalls(@RequestParam("requestId") String requestId) {
|
||||
log.info("模型调用摘要查询开始,requestId={}", requestId);
|
||||
return RequestResult.success(observabilityTraceService.listModelCalls(requestId));
|
||||
}
|
||||
|
||||
@GetMapping("/runs/{requestId}/export")
|
||||
public RequestResult<ObservabilityExportVO> export(@PathVariable("requestId") String requestId) {
|
||||
log.info("运行追踪导出开始,requestId={}", requestId);
|
||||
return RequestResult.success(observabilityExportService.exportTrace(requestId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.bruce.observability.controller;
|
||||
|
||||
import com.bruce.common.handler.GlobalExceptionHandler;
|
||||
import com.bruce.observability.service.IObservabilityExportService;
|
||||
import com.bruce.observability.service.IObservabilityRunService;
|
||||
import com.bruce.observability.service.IObservabilityTraceService;
|
||||
import com.bruce.observability.vo.ObservabilityTraceVO;
|
||||
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 java.math.BigDecimal;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 验证运行观测 Trace 接口的路径参数绑定和返回结构。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ObservabilityTraceControllerTests {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private IObservabilityRunService observabilityRunService;
|
||||
|
||||
@Mock
|
||||
private IObservabilityTraceService observabilityTraceService;
|
||||
|
||||
@Mock
|
||||
private IObservabilityExportService observabilityExportService;
|
||||
|
||||
@InjectMocks
|
||||
private ObservabilityTraceController observabilityTraceController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(observabilityTraceController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void traceShouldReturnStructuredTraceView() throws Exception {
|
||||
ObservabilityTraceVO trace = new ObservabilityTraceVO();
|
||||
trace.setRequestId("req-1001");
|
||||
trace.setWorkflowStatus("SUCCESS");
|
||||
trace.setModelCallCount(2);
|
||||
trace.setEstimatedCost(BigDecimal.valueOf(0.18));
|
||||
|
||||
when(observabilityTraceService.getTrace("req-1001")).thenReturn(trace);
|
||||
|
||||
mockMvc.perform(get("/api/observability/runs/req-1001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.requestId").value("req-1001"))
|
||||
.andExpect(jsonPath("$.data.workflowStatus").value("SUCCESS"))
|
||||
.andExpect(jsonPath("$.data.modelCallCount").value(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.bruce.rag.controller;
|
||||
|
||||
import com.bruce.common.domain.model.RequestResult;
|
||||
import com.bruce.rag.dto.request.IngestionRunCreateRequest;
|
||||
import com.bruce.rag.service.IIngestionRunService;
|
||||
import com.bruce.rag.vo.IngestionRunVO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 文件解析管道聚合接口。
|
||||
*/
|
||||
@Tag(name = "文件解析管道")
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/api/knowledge/ingestion-runs")
|
||||
public class IngestionRunController {
|
||||
|
||||
private final IIngestionRunService ingestionRunService;
|
||||
|
||||
@Operation(summary = "创建文件解析管道聚合视图")
|
||||
@PostMapping
|
||||
public RequestResult<IngestionRunVO> create(@Valid @RequestBody IngestionRunCreateRequest request) {
|
||||
log.info("文件解析管道创建开始,storeId={}, documentId={}", request.getStoreId(), request.getDocumentId());
|
||||
IngestionRunVO view = ingestionRunService.createRun(request);
|
||||
log.info("文件解析管道创建结束,runId={}, storeId={}, documentId={}",
|
||||
view.getRunId(), view.getStoreId(), view.getDocumentId());
|
||||
return RequestResult.success(view);
|
||||
}
|
||||
|
||||
@Operation(summary = "查询文件解析管道聚合视图")
|
||||
@GetMapping("/{runId}")
|
||||
public RequestResult<IngestionRunVO> detail(@PathVariable("runId") String runId,
|
||||
@RequestParam("storeId") Long storeId,
|
||||
@RequestParam("documentId") Long documentId) {
|
||||
log.info("文件解析管道查询开始,runId={}, storeId={}, documentId={}", runId, storeId, documentId);
|
||||
IngestionRunVO view = ingestionRunService.getRun(storeId, documentId);
|
||||
view.setRunId(runId);
|
||||
log.info("文件解析管道查询结束,runId={}, storeId={}, documentId={}, stepCount={}, logCount={}",
|
||||
runId, storeId, documentId, view.getSteps().size(), view.getLogs().size());
|
||||
return RequestResult.success(view);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.bruce.rag.dto.request;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 文件解析管道创建请求。
|
||||
* <p>
|
||||
* 首轮实现不额外落摄取运行表,而是基于知识库与文档主数据生成可追踪的聚合 runId,
|
||||
* 让前端能够按统一入口进入管道详情页。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "文件解析管道创建请求")
|
||||
public class IngestionRunCreateRequest {
|
||||
|
||||
@NotNull(message = "知识库ID不能为空")
|
||||
@Schema(description = "知识库ID")
|
||||
private Long storeId;
|
||||
|
||||
@NotNull(message = "文档ID不能为空")
|
||||
@Schema(description = "文档ID")
|
||||
private Long documentId;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.bruce.rag.service;
|
||||
|
||||
import com.bruce.rag.dto.request.IngestionRunCreateRequest;
|
||||
import com.bruce.rag.vo.IngestionRunVO;
|
||||
|
||||
/**
|
||||
* 文件解析管道聚合服务。
|
||||
*/
|
||||
public interface IIngestionRunService {
|
||||
|
||||
/**
|
||||
* 创建文件解析管道视图入口。
|
||||
*/
|
||||
IngestionRunVO createRun(IngestionRunCreateRequest request);
|
||||
|
||||
/**
|
||||
* 按知识库和文档聚合摄取流水线视图。
|
||||
*/
|
||||
IngestionRunVO getRun(Long storeId, Long documentId);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package com.bruce.rag.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.bruce.modelprovider.dto.response.RagStoreModelConfigResponse;
|
||||
import com.bruce.modelprovider.service.IRagStoreModelConfigService;
|
||||
import com.bruce.rag.dto.request.IngestionRunCreateRequest;
|
||||
import com.bruce.rag.dto.response.RagDocumentResponse;
|
||||
import com.bruce.rag.dto.response.RagStoreResponse;
|
||||
import com.bruce.rag.entity.RagChunk;
|
||||
import com.bruce.rag.entity.RagChunkEmbedding;
|
||||
import com.bruce.rag.entity.RagDocumentParseResult;
|
||||
import com.bruce.rag.service.IIngestionRunService;
|
||||
import com.bruce.rag.service.IRagChunkEmbeddingService;
|
||||
import com.bruce.rag.service.IRagChunkService;
|
||||
import com.bruce.rag.service.IRagDocumentParseResultService;
|
||||
import com.bruce.rag.service.IRagDocumentService;
|
||||
import com.bruce.rag.service.IRagStoreService;
|
||||
import com.bruce.rag.vo.IngestionRunFileVO;
|
||||
import com.bruce.rag.vo.IngestionRunLogVO;
|
||||
import com.bruce.rag.vo.IngestionRunStepVO;
|
||||
import com.bruce.rag.vo.IngestionRunVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 文件解析管道聚合实现。
|
||||
* <p>
|
||||
* 首轮实现保持“主数据优先”,直接复用文档、解析快照、切片和向量结果生成前端可消费的聚合视图。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class IngestionRunServiceImpl implements IIngestionRunService {
|
||||
|
||||
private static final String PARSE_STATUS_PARSED = "PARSED";
|
||||
private static final String PARSE_STATUS_FAILED = "FAILED";
|
||||
private static final String INDEX_STATUS_INDEXED = "INDEXED";
|
||||
private static final String STEP_DONE = "done";
|
||||
private static final String STEP_RUNNING = "running";
|
||||
private static final String STEP_BLOCKED = "blocked";
|
||||
private static final String STEP_IDLE = "idle";
|
||||
|
||||
private final IRagStoreService ragStoreService;
|
||||
private final IRagDocumentService ragDocumentService;
|
||||
private final IRagDocumentParseResultService ragDocumentParseResultService;
|
||||
private final IRagChunkService ragChunkService;
|
||||
private final IRagChunkEmbeddingService ragChunkEmbeddingService;
|
||||
private final IRagStoreModelConfigService ragStoreModelConfigService;
|
||||
|
||||
@Override
|
||||
public IngestionRunVO createRun(IngestionRunCreateRequest request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("文件解析管道创建请求不能为空");
|
||||
}
|
||||
log.info("文件解析管道创建聚合开始,storeId={}, documentId={}", request.getStoreId(), request.getDocumentId());
|
||||
IngestionRunVO view = getRun(request.getStoreId(), request.getDocumentId());
|
||||
view.setRunId(buildRunId(request.getStoreId(), request.getDocumentId()));
|
||||
log.info("文件解析管道创建聚合结束,runId={}, storeId={}, documentId={}",
|
||||
view.getRunId(), view.getStoreId(), view.getDocumentId());
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IngestionRunVO getRun(Long storeId, Long documentId) {
|
||||
log.info("文件解析管道聚合开始,storeId={}, documentId={}", storeId, documentId);
|
||||
if (storeId == null || documentId == null) {
|
||||
throw new IllegalArgumentException("知识库ID和文档ID不能为空");
|
||||
}
|
||||
|
||||
RagStoreResponse store = ragStoreService.getResponseById(storeId);
|
||||
if (store == null) {
|
||||
throw new IllegalArgumentException("知识库不存在,ID: " + storeId);
|
||||
}
|
||||
RagDocumentResponse document = ragDocumentService.getResponseById(documentId);
|
||||
if (document == null || !storeId.equals(document.getStoreId())) {
|
||||
throw new IllegalArgumentException("文档不存在或不属于当前知识库,documentId: " + documentId);
|
||||
}
|
||||
|
||||
RagDocumentParseResult parseResult = ragDocumentParseResultService.getByDocumentId(documentId);
|
||||
List<RagChunk> chunks = ragChunkService.list(new LambdaQueryWrapper<RagChunk>()
|
||||
.eq(RagChunk::getDocumentId, documentId)
|
||||
.eq(RagChunk::getEnabled, true)
|
||||
.orderByAsc(RagChunk::getChunkIndex));
|
||||
List<RagChunkEmbedding> embeddings = ragChunkEmbeddingService.list(new LambdaQueryWrapper<RagChunkEmbedding>()
|
||||
.eq(RagChunkEmbedding::getDocumentId, documentId)
|
||||
.eq(RagChunkEmbedding::getEnabled, true)
|
||||
.orderByDesc(RagChunkEmbedding::getCreateTime));
|
||||
RagStoreModelConfigResponse config = ragStoreModelConfigService.getByStoreId(storeId);
|
||||
|
||||
IngestionRunVO view = new IngestionRunVO();
|
||||
view.setStoreId(storeId);
|
||||
view.setDocumentId(documentId);
|
||||
view.setStoreCode(store.getStoreCode());
|
||||
view.setStoreName(store.getStoreName());
|
||||
view.setFiles(List.of(toFileVO(document)));
|
||||
view.setSteps(buildSteps(document, parseResult, chunks, embeddings, config));
|
||||
view.setParsedTextPreview(buildParsedPreview(parseResult));
|
||||
view.setChunkPreview(buildChunkPreview(chunks, config));
|
||||
view.setLogs(buildLogs(document, parseResult, chunks, embeddings));
|
||||
|
||||
if (config != null) {
|
||||
view.setChunkStrategy(config.getChunkStrategy());
|
||||
view.setChunkSize(config.getChunkSize());
|
||||
view.setChunkOverlap(config.getChunkOverlap());
|
||||
view.setEmbeddingModelId(config.getEmbeddingModelId());
|
||||
view.setEmbeddingDimension(config.getEmbeddingDimension());
|
||||
}
|
||||
|
||||
log.info("文件解析管道聚合结束,storeId={}, documentId={}, chunkCount={}, embeddingCount={}",
|
||||
storeId, documentId, chunks.size(), embeddings.size());
|
||||
return view;
|
||||
}
|
||||
|
||||
private String buildRunId(Long storeId, Long documentId) {
|
||||
return "ingestion-" + safeNumber(storeId) + "-" + safeNumber(documentId);
|
||||
}
|
||||
|
||||
private IngestionRunFileVO toFileVO(RagDocumentResponse document) {
|
||||
IngestionRunFileVO file = new IngestionRunFileVO();
|
||||
file.setDocumentId(document.getId());
|
||||
file.setAttachmentId(document.getAttachmentId());
|
||||
file.setFileName(document.getDocumentTitle());
|
||||
file.setParseStatus(document.getParseStatus());
|
||||
file.setIndexStatus(document.getIndexStatus());
|
||||
file.setErrorMessage(document.getErrorMessage());
|
||||
return file;
|
||||
}
|
||||
|
||||
private List<IngestionRunStepVO> buildSteps(RagDocumentResponse document,
|
||||
RagDocumentParseResult parseResult,
|
||||
List<RagChunk> chunks,
|
||||
List<RagChunkEmbedding> embeddings,
|
||||
RagStoreModelConfigResponse config) {
|
||||
List<IngestionRunStepVO> steps = new ArrayList<>();
|
||||
steps.add(step("上传", "文件已入库并创建 rag_document", STEP_DONE));
|
||||
steps.add(step("解析", parseDescription(parseResult), parseStepStatus(document)));
|
||||
steps.add(step("切片", chunkDescription(chunks, config), chunkStepStatus(document, chunks)));
|
||||
steps.add(step("向量化", embeddingDescription(embeddings, config), embeddingStepStatus(document, chunks, embeddings)));
|
||||
steps.add(step("可检索", retrievableDescription(document), retrievableStepStatus(document)));
|
||||
return steps;
|
||||
}
|
||||
|
||||
private List<IngestionRunLogVO> buildLogs(RagDocumentResponse document,
|
||||
RagDocumentParseResult parseResult,
|
||||
List<RagChunk> chunks,
|
||||
List<RagChunkEmbedding> embeddings) {
|
||||
List<IngestionRunLogVO> logs = new ArrayList<>();
|
||||
logs.add(log(document.getCreateTime(), "INFO", "上传文件并创建 rag_document 记录"));
|
||||
if (parseResult != null) {
|
||||
logs.add(log(parseResult.getCreateTime(), "INFO",
|
||||
"解析完成,文本长度 " + safeNumber(parseResult.getTextLength()) + ",页数 " + safeNumber(parseResult.getPageCount())));
|
||||
} else if (PARSE_STATUS_FAILED.equals(document.getParseStatus())) {
|
||||
logs.add(log(document.getUpdateTime(), "WARN", defaultText(document.getErrorMessage(), "解析失败,等待重试")));
|
||||
} else {
|
||||
logs.add(log(document.getUpdateTime(), "INFO", "解析尚未产出快照,等待执行"));
|
||||
}
|
||||
|
||||
if (!chunks.isEmpty()) {
|
||||
logs.add(log(chunks.get(chunks.size() - 1).getCreateTime(), "INFO", "切片完成,共生成 " + chunks.size() + " 个 rag_chunk"));
|
||||
} else {
|
||||
logs.add(log(document.getUpdateTime(), "INFO", "切片尚未执行或等待解析成功"));
|
||||
}
|
||||
|
||||
if (!embeddings.isEmpty()) {
|
||||
RagChunkEmbedding latestEmbedding = embeddings.get(0);
|
||||
logs.add(log(latestEmbedding.getCreateTime(), "INFO",
|
||||
"向量化完成,已写入 " + embeddings.size() + " 条 rag_chunk_embedding"));
|
||||
} else {
|
||||
logs.add(log(document.getUpdateTime(), "INFO", "向量化尚未执行或等待切片完成"));
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
private IngestionRunStepVO step(String name, String description, String status) {
|
||||
IngestionRunStepVO step = new IngestionRunStepVO();
|
||||
step.setName(name);
|
||||
step.setDescription(description);
|
||||
step.setStatus(status);
|
||||
return step;
|
||||
}
|
||||
|
||||
private IngestionRunLogVO log(Date time, String level, String message) {
|
||||
IngestionRunLogVO log = new IngestionRunLogVO();
|
||||
log.setTime(formatTime(time));
|
||||
log.setLevel(level);
|
||||
log.setMessage(message);
|
||||
return log;
|
||||
}
|
||||
|
||||
private String parseDescription(RagDocumentParseResult parseResult) {
|
||||
if (parseResult == null) {
|
||||
return "等待解析快照写入 rag_document_parse_result";
|
||||
}
|
||||
return "解析完成,文本长度 " + safeNumber(parseResult.getTextLength()) + ",页数 " + safeNumber(parseResult.getPageCount());
|
||||
}
|
||||
|
||||
private String chunkDescription(List<RagChunk> chunks, RagStoreModelConfigResponse config) {
|
||||
if (chunks.isEmpty()) {
|
||||
return "等待根据策略生成 rag_chunk";
|
||||
}
|
||||
return "已生成 " + chunks.size() + " 个切片,chunk_size=" + safeNumber(config == null ? null : config.getChunkSize())
|
||||
+ ",overlap=" + safeNumber(config == null ? null : config.getChunkOverlap());
|
||||
}
|
||||
|
||||
private String embeddingDescription(List<RagChunkEmbedding> embeddings, RagStoreModelConfigResponse config) {
|
||||
if (embeddings.isEmpty()) {
|
||||
return "等待向量化并写入 rag_chunk_embedding";
|
||||
}
|
||||
String modelText = config == null || config.getEmbeddingModelId() == null ? "-" : String.valueOf(config.getEmbeddingModelId());
|
||||
return "已生成 " + embeddings.size() + " 条向量,模型 " + modelText + ",维度 "
|
||||
+ safeNumber(config == null ? null : config.getEmbeddingDimension());
|
||||
}
|
||||
|
||||
private String retrievableDescription(RagDocumentResponse document) {
|
||||
if (INDEX_STATUS_INDEXED.equals(document.getIndexStatus())) {
|
||||
return "索引已完成,当前文档可参与检索召回";
|
||||
}
|
||||
return "等待索引完成后进入可检索状态";
|
||||
}
|
||||
|
||||
private String parseStepStatus(RagDocumentResponse document) {
|
||||
if (PARSE_STATUS_PARSED.equals(document.getParseStatus())) {
|
||||
return STEP_DONE;
|
||||
}
|
||||
if (PARSE_STATUS_FAILED.equals(document.getParseStatus())) {
|
||||
return STEP_BLOCKED;
|
||||
}
|
||||
return STEP_RUNNING;
|
||||
}
|
||||
|
||||
private String chunkStepStatus(RagDocumentResponse document, List<RagChunk> chunks) {
|
||||
if (!chunks.isEmpty()) {
|
||||
return STEP_DONE;
|
||||
}
|
||||
if (PARSE_STATUS_FAILED.equals(document.getParseStatus())) {
|
||||
return STEP_IDLE;
|
||||
}
|
||||
return PARSE_STATUS_PARSED.equals(document.getParseStatus()) ? STEP_RUNNING : STEP_IDLE;
|
||||
}
|
||||
|
||||
private String embeddingStepStatus(RagDocumentResponse document, List<RagChunk> chunks, List<RagChunkEmbedding> embeddings) {
|
||||
if (!embeddings.isEmpty()) {
|
||||
return STEP_DONE;
|
||||
}
|
||||
if (PARSE_STATUS_FAILED.equals(document.getParseStatus())) {
|
||||
return STEP_IDLE;
|
||||
}
|
||||
return chunks.isEmpty() ? STEP_IDLE : STEP_RUNNING;
|
||||
}
|
||||
|
||||
private String retrievableStepStatus(RagDocumentResponse document) {
|
||||
return INDEX_STATUS_INDEXED.equals(document.getIndexStatus()) ? STEP_DONE : STEP_IDLE;
|
||||
}
|
||||
|
||||
private String buildParsedPreview(RagDocumentParseResult parseResult) {
|
||||
if (parseResult == null || parseResult.getParsedText() == null) {
|
||||
return "暂无解析文本预览";
|
||||
}
|
||||
return truncate(parseResult.getParsedText(), 180);
|
||||
}
|
||||
|
||||
private String buildChunkPreview(List<RagChunk> chunks, RagStoreModelConfigResponse config) {
|
||||
if (chunks.isEmpty()) {
|
||||
return "暂无切片预览";
|
||||
}
|
||||
RagChunk firstChunk = chunks.get(0);
|
||||
return "chunk_size=" + safeNumber(config == null ? null : config.getChunkSize())
|
||||
+ ", overlap=" + safeNumber(config == null ? null : config.getChunkOverlap())
|
||||
+ ", strategy=" + safeNumber(config == null ? null : config.getChunkStrategy())
|
||||
+ "。预览:" + truncate(defaultText(firstChunk.getChunkSummary(), firstChunk.getChunkContent()), 140);
|
||||
}
|
||||
|
||||
private String truncate(String text, int limit) {
|
||||
if (text == null || text.length() <= limit) {
|
||||
return text;
|
||||
}
|
||||
return text.substring(0, limit) + "...";
|
||||
}
|
||||
|
||||
private String defaultText(String preferred, String fallback) {
|
||||
return preferred == null || preferred.isBlank() ? fallback : preferred;
|
||||
}
|
||||
|
||||
private String safeNumber(Number value) {
|
||||
return value == null ? "-" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private String formatTime(Date time) {
|
||||
Date value = time == null ? new Date() : time;
|
||||
return new SimpleDateFormat("HH:mm:ss", Locale.CHINA).format(value);
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,21 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bruce.rag.entity.RagChunkEmbedding;
|
||||
import com.bruce.rag.mapper.RagChunkEmbeddingMapper;
|
||||
import com.bruce.rag.service.IRagChunkEmbeddingService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 切片向量服务基础实现。
|
||||
* <p>
|
||||
* 当前模块首轮以主数据补全为主,这里保留统一的服务落点,便于后续继续承接向量写入、
|
||||
* 重建索引和状态审计日志。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RagChunkEmbeddingServiceImpl extends ServiceImpl<RagChunkEmbeddingMapper, RagChunkEmbedding>
|
||||
implements IRagChunkEmbeddingService {
|
||||
|
||||
public RagChunkEmbeddingServiceImpl() {
|
||||
log.debug("RAG切片向量服务初始化完成");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,20 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bruce.rag.entity.RagChunk;
|
||||
import com.bruce.rag.mapper.RagChunkMapper;
|
||||
import com.bruce.rag.service.IRagChunkService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 切片服务基础实现。
|
||||
* <p>
|
||||
* 当前阶段主要复用 MyBatis-Plus 通用能力承载切片主数据访问,
|
||||
* 统一保留服务层入口以便后续扩展切片重建和日志审计。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class RagChunkServiceImpl extends ServiceImpl<RagChunkMapper, RagChunk> implements IRagChunkService {
|
||||
|
||||
public RagChunkServiceImpl() {
|
||||
log.debug("RAG切片服务初始化完成");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.bruce.rag.entity.RagDocumentParseResult;
|
||||
import com.bruce.rag.mapper.RagDocumentParseResultMapper;
|
||||
import com.bruce.rag.service.IRagDocumentParseResultService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.util.DigestUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
@@ -17,6 +18,12 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 文档解析快照服务实现。
|
||||
* <p>
|
||||
* 负责把解析结果固化为文档快照,供切片与后续索引链路复用。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class RagDocumentParseResultServiceImpl extends ServiceImpl<RagDocumentParseResultMapper, RagDocumentParseResult>
|
||||
@@ -29,6 +36,7 @@ public class RagDocumentParseResultServiceImpl extends ServiceImpl<RagDocumentPa
|
||||
if (documentId == null) {
|
||||
return null;
|
||||
}
|
||||
log.info("查询文档解析快照开始,documentId={}", documentId);
|
||||
return getOne(Wrappers.<RagDocumentParseResult>lambdaQuery()
|
||||
.eq(RagDocumentParseResult::getDocumentId, documentId)
|
||||
.last("limit 1"));
|
||||
@@ -39,6 +47,8 @@ public class RagDocumentParseResultServiceImpl extends ServiceImpl<RagDocumentPa
|
||||
if (storeId == null || documentId == null || parseResult == null) {
|
||||
throw new IllegalArgumentException("保存解析快照参数不完整");
|
||||
}
|
||||
log.info("保存文档解析快照开始,storeId={}, documentId={}, textLength={}",
|
||||
storeId, documentId, parseResult.getTextLength());
|
||||
RagDocumentParseResult existing = getByDocumentId(documentId);
|
||||
RagDocumentParseResult snapshot = existing == null ? new RagDocumentParseResult() : existing;
|
||||
snapshot.setStoreId(storeId);
|
||||
@@ -56,6 +66,8 @@ public class RagDocumentParseResultServiceImpl extends ServiceImpl<RagDocumentPa
|
||||
} else {
|
||||
updateById(snapshot);
|
||||
}
|
||||
log.info("保存文档解析快照完成,storeId={}, documentId={}, parseVersion={}, snapshotId={}",
|
||||
storeId, documentId, snapshot.getParseVersion(), snapshot.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -91,6 +103,7 @@ public class RagDocumentParseResultServiceImpl extends ServiceImpl<RagDocumentPa
|
||||
Map<String, Object> payload = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||
return objectMapper.writeValueAsString(payload);
|
||||
} catch (Exception e) {
|
||||
log.error("解析元数据序列化失败,metadataKeys={}", metadata == null ? 0 : metadata.keySet(), e);
|
||||
throw new IllegalStateException("解析元数据序列化失败", e);
|
||||
}
|
||||
}
|
||||
@@ -103,6 +116,7 @@ public class RagDocumentParseResultServiceImpl extends ServiceImpl<RagDocumentPa
|
||||
return objectMapper.readValue(metadataJson, new TypeReference<>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
log.error("解析元数据反序列化失败,metadataJson={}", metadataJson, e);
|
||||
throw new IllegalStateException("解析元数据反序列化失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.bruce.rag.vo;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 摄取流水线文件摘要。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "摄取流水线文件摘要")
|
||||
public class IngestionRunFileVO {
|
||||
|
||||
@Schema(description = "文档ID")
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
private Long documentId;
|
||||
|
||||
@Schema(description = "附件ID")
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
private Long attachmentId;
|
||||
|
||||
@Schema(description = "文件名称")
|
||||
private String fileName;
|
||||
|
||||
@Schema(description = "解析状态")
|
||||
private String parseStatus;
|
||||
|
||||
@Schema(description = "索引状态")
|
||||
private String indexStatus;
|
||||
|
||||
@Schema(description = "错误信息")
|
||||
private String errorMessage;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.bruce.rag.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 摄取流水线日志视图。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "摄取流水线日志视图")
|
||||
public class IngestionRunLogVO {
|
||||
|
||||
@Schema(description = "日志时间")
|
||||
private String time;
|
||||
|
||||
@Schema(description = "日志级别")
|
||||
private String level;
|
||||
|
||||
@Schema(description = "日志内容")
|
||||
private String message;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.bruce.rag.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 摄取流水线阶段视图。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "摄取流水线阶段视图")
|
||||
public class IngestionRunStepVO {
|
||||
|
||||
@Schema(description = "阶段名称")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "阶段说明")
|
||||
private String description;
|
||||
|
||||
@Schema(description = "阶段状态")
|
||||
private String status;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.bruce.rag.vo;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件解析管道聚合视图。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "文件解析管道聚合视图")
|
||||
public class IngestionRunVO {
|
||||
|
||||
@Schema(description = "运行标识")
|
||||
private String runId;
|
||||
|
||||
@Schema(description = "知识库ID")
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
private Long storeId;
|
||||
|
||||
@Schema(description = "文档ID")
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
private Long documentId;
|
||||
|
||||
@Schema(description = "知识库编码")
|
||||
private String storeCode;
|
||||
|
||||
@Schema(description = "知识库名称")
|
||||
private String storeName;
|
||||
|
||||
@Schema(description = "文件摘要列表")
|
||||
private List<IngestionRunFileVO> files = new ArrayList<>();
|
||||
|
||||
@Schema(description = "流水线阶段")
|
||||
private List<IngestionRunStepVO> steps = new ArrayList<>();
|
||||
|
||||
@Schema(description = "解析文本预览")
|
||||
private String parsedTextPreview;
|
||||
|
||||
@Schema(description = "切片预览")
|
||||
private String chunkPreview;
|
||||
|
||||
@Schema(description = "切片策略")
|
||||
private Integer chunkStrategy;
|
||||
|
||||
@Schema(description = "切片长度")
|
||||
private Integer chunkSize;
|
||||
|
||||
@Schema(description = "切片重叠")
|
||||
private Integer chunkOverlap;
|
||||
|
||||
@Schema(description = "Embedding 模型ID")
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
private Long embeddingModelId;
|
||||
|
||||
@Schema(description = "Embedding 维度")
|
||||
private Integer embeddingDimension;
|
||||
|
||||
@Schema(description = "任务日志")
|
||||
private List<IngestionRunLogVO> logs = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.bruce.rag.controller;
|
||||
|
||||
import com.bruce.common.handler.GlobalExceptionHandler;
|
||||
import com.bruce.rag.service.IIngestionRunService;
|
||||
import com.bruce.rag.vo.IngestionRunVO;
|
||||
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 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;
|
||||
|
||||
/**
|
||||
* 验证文件解析管道聚合接口的请求绑定与响应结构。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IngestionRunControllerTests {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private IIngestionRunService ingestionRunService;
|
||||
|
||||
@InjectMocks
|
||||
private IngestionRunController ingestionRunController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(ingestionRunController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void createShouldReturnStructuredRunView() throws Exception {
|
||||
IngestionRunVO run = new IngestionRunVO();
|
||||
run.setRunId("ingestion-1001-11");
|
||||
run.setStoreId(1001L);
|
||||
run.setDocumentId(11L);
|
||||
|
||||
when(ingestionRunService.createRun(any())).thenReturn(run);
|
||||
|
||||
mockMvc.perform(post("/api/knowledge/ingestion-runs")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"storeId": 1001,
|
||||
"documentId": 11
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.runId").value("ingestion-1001-11"))
|
||||
.andExpect(jsonPath("$.data.storeId").value("1001"))
|
||||
.andExpect(jsonPath("$.data.documentId").value("11"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailShouldReturnStructuredRunView() throws Exception {
|
||||
IngestionRunVO run = new IngestionRunVO();
|
||||
run.setRunId("run-20260601");
|
||||
run.setStoreId(1001L);
|
||||
run.setDocumentId(11L);
|
||||
|
||||
when(ingestionRunService.getRun(1001L, 11L)).thenReturn(run);
|
||||
|
||||
mockMvc.perform(get("/api/knowledge/ingestion-runs/run-20260601")
|
||||
.param("storeId", "1001")
|
||||
.param("documentId", "11"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.runId").value("run-20260601"))
|
||||
.andExpect(jsonPath("$.data.storeId").value("1001"))
|
||||
.andExpect(jsonPath("$.data.documentId").value("11"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.bruce.rag.controller;
|
||||
|
||||
import com.bruce.common.handler.GlobalExceptionHandler;
|
||||
import com.bruce.rag.service.IKnowledgeWorkspaceService;
|
||||
import com.bruce.rag.vo.KnowledgeWorkspaceVO;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 验证知识工作台聚合接口的路径参数绑定和响应结构。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class KnowledgeWorkspaceControllerTests {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private IKnowledgeWorkspaceService knowledgeWorkspaceService;
|
||||
|
||||
@InjectMocks
|
||||
private KnowledgeWorkspaceController knowledgeWorkspaceController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(knowledgeWorkspaceController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getWorkspaceShouldReturnStructuredWorkspaceView() throws Exception {
|
||||
KnowledgeWorkspaceVO workspace = new KnowledgeWorkspaceVO();
|
||||
workspace.setStoreId(1001L);
|
||||
workspace.setStoreCode("PROD_DOC");
|
||||
workspace.setStoreName("产品制度库");
|
||||
workspace.setDocumentCount(9);
|
||||
workspace.setHealthScore(88);
|
||||
|
||||
when(knowledgeWorkspaceService.getWorkspace(1001L)).thenReturn(workspace);
|
||||
|
||||
mockMvc.perform(get("/api/knowledge/workspaces/1001"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.storeId").value("1001"))
|
||||
.andExpect(jsonPath("$.data.storeName").value("产品制度库"))
|
||||
.andExpect(jsonPath("$.data.healthScore").value(88));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.bruce.rag.ingestion;
|
||||
|
||||
import com.bruce.modelprovider.dto.response.RagStoreModelConfigResponse;
|
||||
import com.bruce.modelprovider.service.IRagStoreModelConfigService;
|
||||
import com.bruce.rag.dto.request.IngestionRunCreateRequest;
|
||||
import com.bruce.rag.dto.response.RagDocumentResponse;
|
||||
import com.bruce.rag.dto.response.RagStoreResponse;
|
||||
import com.bruce.rag.entity.RagChunk;
|
||||
import com.bruce.rag.entity.RagChunkEmbedding;
|
||||
import com.bruce.rag.entity.RagDocumentParseResult;
|
||||
import com.bruce.rag.service.IRagChunkEmbeddingService;
|
||||
import com.bruce.rag.service.IRagChunkService;
|
||||
import com.bruce.rag.service.IRagDocumentParseResultService;
|
||||
import com.bruce.rag.service.IRagDocumentService;
|
||||
import com.bruce.rag.service.IRagStoreService;
|
||||
import com.bruce.rag.service.impl.IngestionRunServiceImpl;
|
||||
import com.bruce.rag.vo.IngestionRunVO;
|
||||
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.Date;
|
||||
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 IngestionRunServiceTests {
|
||||
|
||||
@Mock
|
||||
private IRagStoreService ragStoreService;
|
||||
|
||||
@Mock
|
||||
private IRagDocumentService ragDocumentService;
|
||||
|
||||
@Mock
|
||||
private IRagDocumentParseResultService ragDocumentParseResultService;
|
||||
|
||||
@Mock
|
||||
private IRagChunkService ragChunkService;
|
||||
|
||||
@Mock
|
||||
private IRagChunkEmbeddingService ragChunkEmbeddingService;
|
||||
|
||||
@Mock
|
||||
private IRagStoreModelConfigService ragStoreModelConfigService;
|
||||
|
||||
@InjectMocks
|
||||
private IngestionRunServiceImpl ingestionRunService;
|
||||
|
||||
@Test
|
||||
void createRunShouldGenerateStableRunId() {
|
||||
RagStoreResponse store = new RagStoreResponse();
|
||||
store.setId(1001L);
|
||||
store.setStoreCode("PROD_DOC");
|
||||
store.setStoreName("产品制度库");
|
||||
|
||||
RagDocumentResponse document = new RagDocumentResponse();
|
||||
document.setId(11L);
|
||||
document.setStoreId(1001L);
|
||||
document.setDocumentTitle("售前方案模板.pdf");
|
||||
document.setParseStatus("PARSED");
|
||||
document.setIndexStatus("INDEXED");
|
||||
document.setCreateTime(new Date(1748780000000L));
|
||||
document.setUpdateTime(new Date(1748780300000L));
|
||||
|
||||
when(ragStoreService.getResponseById(1001L)).thenReturn(store);
|
||||
when(ragDocumentService.getResponseById(11L)).thenReturn(document);
|
||||
when(ragDocumentParseResultService.getByDocumentId(11L)).thenReturn(null);
|
||||
when(ragChunkService.list(org.mockito.ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<RagChunk>>any()))
|
||||
.thenReturn(List.of());
|
||||
when(ragChunkEmbeddingService.list(org.mockito.ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<RagChunkEmbedding>>any()))
|
||||
.thenReturn(List.of());
|
||||
when(ragStoreModelConfigService.getByStoreId(1001L)).thenReturn(null);
|
||||
|
||||
IngestionRunCreateRequest request = new IngestionRunCreateRequest();
|
||||
request.setStoreId(1001L);
|
||||
request.setDocumentId(11L);
|
||||
|
||||
IngestionRunVO view = ingestionRunService.createRun(request);
|
||||
|
||||
assertNotNull(view);
|
||||
assertEquals("ingestion-1001-11", view.getRunId());
|
||||
assertEquals(1001L, view.getStoreId());
|
||||
assertEquals(11L, view.getDocumentId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void getRunShouldAggregatePipelinePreviewAndLogs() {
|
||||
RagStoreResponse store = new RagStoreResponse();
|
||||
store.setId(1001L);
|
||||
store.setStoreCode("PROD_DOC");
|
||||
store.setStoreName("产品制度库");
|
||||
|
||||
RagDocumentResponse document = new RagDocumentResponse();
|
||||
document.setId(11L);
|
||||
document.setStoreId(1001L);
|
||||
document.setAttachmentId(901L);
|
||||
document.setDocumentTitle("售前方案模板.pdf");
|
||||
document.setParseStatus("PARSED");
|
||||
document.setIndexStatus("INDEXED");
|
||||
document.setCreateTime(new Date(1748780000000L));
|
||||
document.setUpdateTime(new Date(1748780300000L));
|
||||
|
||||
RagDocumentParseResult parseResult = new RagDocumentParseResult();
|
||||
parseResult.setDocumentId(11L);
|
||||
parseResult.setParsedText("私有化部署章节应覆盖基础设施、网络、安全与运维边界。平台需说明模型服务商、知识库索引策略与日志留存周期。");
|
||||
parseResult.setTextLength(1280);
|
||||
parseResult.setPageCount(12);
|
||||
parseResult.setCreateTime(new Date(1748780100000L));
|
||||
|
||||
RagChunk chunk = new RagChunk();
|
||||
chunk.setDocumentId(11L);
|
||||
chunk.setChunkIndex(24);
|
||||
chunk.setChunkSummary("chunk_size=800, overlap=120, strategy=FIXED_LENGTH");
|
||||
chunk.setChunkContent("该切片将进入 rag_chunk 并在向量化后写入 rag_chunk_embedding。");
|
||||
chunk.setCreateTime(new Date(1748780200000L));
|
||||
|
||||
RagChunkEmbedding embedding = new RagChunkEmbedding();
|
||||
embedding.setDocumentId(11L);
|
||||
embedding.setEmbeddingDimension(1024);
|
||||
embedding.setCreateTime(new Date(1748780250000L));
|
||||
|
||||
RagStoreModelConfigResponse config = new RagStoreModelConfigResponse();
|
||||
config.setStoreId(1001L);
|
||||
config.setEmbeddingModelId(88L);
|
||||
config.setEmbeddingDimension(1024);
|
||||
config.setChunkStrategy(1);
|
||||
config.setChunkSize(800);
|
||||
config.setChunkOverlap(120);
|
||||
|
||||
when(ragStoreService.getResponseById(1001L)).thenReturn(store);
|
||||
when(ragDocumentService.getResponseById(11L)).thenReturn(document);
|
||||
when(ragDocumentParseResultService.getByDocumentId(11L)).thenReturn(parseResult);
|
||||
when(ragChunkService.list(org.mockito.ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<RagChunk>>any()))
|
||||
.thenReturn(List.of(chunk));
|
||||
when(ragChunkEmbeddingService.list(org.mockito.ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<RagChunkEmbedding>>any()))
|
||||
.thenReturn(List.of(embedding));
|
||||
when(ragStoreModelConfigService.getByStoreId(1001L)).thenReturn(config);
|
||||
|
||||
IngestionRunVO view = ingestionRunService.getRun(1001L, 11L);
|
||||
|
||||
assertNotNull(view);
|
||||
assertEquals(1001L, view.getStoreId());
|
||||
assertEquals(11L, view.getDocumentId());
|
||||
assertEquals("PROD_DOC", view.getStoreCode());
|
||||
assertEquals("产品制度库", view.getStoreName());
|
||||
assertEquals(1, view.getFiles().size());
|
||||
assertEquals(5, view.getSteps().size());
|
||||
assertEquals("done", view.getSteps().get(1).getStatus());
|
||||
assertEquals("done", view.getSteps().get(2).getStatus());
|
||||
assertEquals("done", view.getSteps().get(3).getStatus());
|
||||
assertEquals("done", view.getSteps().get(4).getStatus());
|
||||
assertEquals(88L, view.getEmbeddingModelId());
|
||||
assertEquals(1024, view.getEmbeddingDimension());
|
||||
assertEquals(800, view.getChunkSize());
|
||||
assertEquals(120, view.getChunkOverlap());
|
||||
assertEquals(4, view.getLogs().size());
|
||||
assertEquals("售前方案模板.pdf", view.getFiles().get(0).getFileName());
|
||||
assertEquals(true, view.getParsedTextPreview().contains("私有化部署章节"));
|
||||
assertEquals(true, view.getChunkPreview().contains("chunk_size=800"));
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import com.bruce.skill.service.ISkillWorkspaceService;
|
||||
import com.bruce.skill.vo.SkillVersionVO;
|
||||
import com.bruce.skill.vo.SkillWorkspaceVO;
|
||||
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.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
@@ -18,6 +20,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
/**
|
||||
* Skill 工作台接口,首轮对齐前端原型中的详情、草稿、测试、发布和归档能力。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/skills")
|
||||
@RequiredArgsConstructor
|
||||
@@ -28,30 +31,45 @@ public class SkillWorkspaceController {
|
||||
|
||||
@GetMapping("/{skillCode}")
|
||||
public RequestResult<SkillWorkspaceVO> detail(@PathVariable("skillCode") String skillCode) {
|
||||
log.info("Skill工作台详情查询开始,skillCode={}", skillCode);
|
||||
return RequestResult.success(skillWorkspaceService.getWorkspace(skillCode));
|
||||
}
|
||||
|
||||
@PostMapping("/{skillCode}/draft")
|
||||
public RequestResult<Boolean> saveDraft(@PathVariable("skillCode") String skillCode,
|
||||
@RequestBody SkillVersionSaveDTO request) {
|
||||
log.info("Skill草稿保存开始,skillCode={}, versionNo={}", skillCode, request.getVersionNo());
|
||||
return RequestResult.success(skillVersionService.saveDraft(skillCode, request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容前端实现文档中的 PUT 草稿保存路径。
|
||||
*/
|
||||
@PutMapping("/{skillCode}/draft")
|
||||
public RequestResult<Boolean> saveDraftCompat(@PathVariable("skillCode") String skillCode,
|
||||
@RequestBody SkillVersionSaveDTO request) {
|
||||
log.info("Skill草稿兼容保存开始,skillCode={}, versionNo={}", skillCode, request.getVersionNo());
|
||||
return RequestResult.success(skillVersionService.saveDraft(skillCode, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{skillCode}/test")
|
||||
public RequestResult<SkillVersionVO> test(@PathVariable("skillCode") String skillCode,
|
||||
@RequestBody SkillVersionSaveDTO request) {
|
||||
log.info("Skill测试执行开始,skillCode={}, versionNo={}", skillCode, request.getVersionNo());
|
||||
return RequestResult.success(skillVersionService.test(skillCode, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{skillCode}/publish")
|
||||
public RequestResult<Boolean> publish(@PathVariable("skillCode") String skillCode,
|
||||
@RequestBody SkillVersionSaveDTO request) {
|
||||
log.info("Skill发布开始,skillCode={}, versionNo={}", skillCode, request.getVersionNo());
|
||||
return RequestResult.success(skillVersionService.publish(skillCode, request));
|
||||
}
|
||||
|
||||
@PostMapping("/{skillCode}/archive")
|
||||
public RequestResult<Boolean> archive(@PathVariable("skillCode") String skillCode,
|
||||
@RequestParam("versionNo") Integer versionNo) {
|
||||
log.info("Skill归档开始,skillCode={}, versionNo={}", skillCode, versionNo);
|
||||
return RequestResult.success(skillVersionService.archive(skillCode, versionNo));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.bruce.skill.controller;
|
||||
|
||||
import com.bruce.common.handler.GlobalExceptionHandler;
|
||||
import com.bruce.skill.dto.SkillVersionSaveDTO;
|
||||
import com.bruce.skill.service.ISkillVersionService;
|
||||
import com.bruce.skill.service.ISkillWorkspaceService;
|
||||
import com.bruce.skill.vo.SkillWorkspaceVO;
|
||||
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.request.MockMvcRequestBuilders.put;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* 验证 Skill 工作台详情接口的基础返回结构。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class SkillWorkspaceControllerTests {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private ISkillWorkspaceService skillWorkspaceService;
|
||||
|
||||
@Mock
|
||||
private ISkillVersionService skillVersionService;
|
||||
|
||||
@InjectMocks
|
||||
private SkillWorkspaceController skillWorkspaceController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(skillWorkspaceController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailShouldReturnStructuredWorkspaceView() throws Exception {
|
||||
SkillWorkspaceVO workspace = new SkillWorkspaceVO();
|
||||
workspace.setSkillId(401L);
|
||||
workspace.setSkillCode("resume_extract");
|
||||
workspace.setSkillName("简历抽取");
|
||||
workspace.setStatus("PUBLISHED");
|
||||
|
||||
when(skillWorkspaceService.getWorkspace("resume_extract")).thenReturn(workspace);
|
||||
|
||||
mockMvc.perform(get("/api/skills/resume_extract"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.skillId").value(401))
|
||||
.andExpect(jsonPath("$.data.skillCode").value("resume_extract"))
|
||||
.andExpect(jsonPath("$.data.status").value("PUBLISHED"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveDraftCompatShouldReturnSuccess() throws Exception {
|
||||
when(skillVersionService.saveDraft(org.mockito.ArgumentMatchers.eq("resume_extract"),
|
||||
org.mockito.ArgumentMatchers.any(SkillVersionSaveDTO.class))).thenReturn(true);
|
||||
|
||||
mockMvc.perform(put("/api/skills/resume_extract/draft")
|
||||
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"versionNo": 2,
|
||||
"promptText": "提取候选人经历"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.bruce.workflow.dto.ProjectSaveDTO;
|
||||
import com.bruce.workflow.service.IProjectService;
|
||||
import com.bruce.workflow.vo.ProjectVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -14,6 +15,12 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Studio 项目空间控制器。
|
||||
* <p>
|
||||
* 负责项目列表、详情和保存接口,对外统一返回 Project 相关 VO。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/studio-projects")
|
||||
@RequiredArgsConstructor
|
||||
@@ -23,16 +30,19 @@ public class ProjectController {
|
||||
|
||||
@GetMapping("/list")
|
||||
public RequestResult<List<ProjectVO>> list() {
|
||||
log.info("Studio项目列表查询开始");
|
||||
return RequestResult.success(projectService.listProjects());
|
||||
}
|
||||
|
||||
@GetMapping("/detail")
|
||||
public RequestResult<ProjectVO> detail(@RequestParam("id") Long id) {
|
||||
log.info("Studio项目详情查询开始,projectId={}", id);
|
||||
return RequestResult.success(projectService.getProject(id));
|
||||
}
|
||||
|
||||
@PostMapping("/save")
|
||||
public RequestResult<Boolean> save(@RequestBody ProjectSaveDTO request) {
|
||||
log.info("Studio项目保存开始,projectId={}, projectCode={}", request.getId(), request.getProjectCode());
|
||||
return RequestResult.success(projectService.saveProject(request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import com.bruce.workflow.dto.WorkflowDefinitionSaveDTO;
|
||||
import com.bruce.workflow.service.IWorkflowDefinitionService;
|
||||
import com.bruce.workflow.vo.WorkflowDefinitionVO;
|
||||
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;
|
||||
@@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/workflows")
|
||||
@RequiredArgsConstructor
|
||||
@@ -33,8 +36,26 @@ public class WorkflowDefinitionController {
|
||||
return RequestResult.success(workflowDefinitionService.getDefinition(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容前端实现文档中的 REST 详情路径。
|
||||
*/
|
||||
@GetMapping("/{workflowId}")
|
||||
public RequestResult<WorkflowDefinitionVO> detailByPath(@PathVariable("workflowId") Long workflowId) {
|
||||
log.info("Workflow定义详情查询开始,workflowId={}", workflowId);
|
||||
return RequestResult.success(workflowDefinitionService.getDefinition(workflowId));
|
||||
}
|
||||
|
||||
@PostMapping("/definition/save")
|
||||
public RequestResult<Boolean> save(@RequestBody WorkflowDefinitionSaveDTO request) {
|
||||
return RequestResult.success(workflowDefinitionService.saveDefinition(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容前端实现文档中的草稿保存路径。
|
||||
*/
|
||||
@PostMapping("/save-draft")
|
||||
public RequestResult<Boolean> saveDraft(@RequestBody WorkflowDefinitionSaveDTO request) {
|
||||
log.info("Workflow草稿保存开始,workflowId={}, workflowCode={}", request.getId(), request.getWorkflowCode());
|
||||
return RequestResult.success(workflowDefinitionService.saveDefinition(request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.bruce.workflow.dto.WorkflowRunCreateDTO;
|
||||
import com.bruce.workflow.service.IWorkflowRunService;
|
||||
import com.bruce.workflow.vo.WorkflowRunVO;
|
||||
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;
|
||||
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/workflow-runs")
|
||||
@RequiredArgsConstructor
|
||||
@@ -30,4 +32,25 @@ public class WorkflowRunController {
|
||||
public RequestResult<List<WorkflowRunVO>> list(@PathVariable("workflowId") Long workflowId) {
|
||||
return RequestResult.success(workflowRunService.listRecentByWorkflowId(workflowId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容前端实现文档中的资源化运行创建路径。
|
||||
*/
|
||||
@PostMapping("/compat/workflows/{workflowId}/runs")
|
||||
public RequestResult<Boolean> createCompat(@PathVariable("workflowId") Long workflowId,
|
||||
@RequestBody WorkflowRunCreateDTO request) {
|
||||
request.setWorkflowId(workflowId);
|
||||
log.info("Workflow运行创建开始,workflowId={}, versionId={}, requestId={}",
|
||||
workflowId, request.getWorkflowVersionId(), request.getRequestId());
|
||||
return RequestResult.success(workflowRunService.createRun(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容前端实现文档中的按运行ID查询路径。
|
||||
*/
|
||||
@GetMapping("/compat/workflows/runs/{runId}")
|
||||
public RequestResult<WorkflowRunVO> detailCompat(@PathVariable("runId") Long runId) {
|
||||
log.info("Workflow运行详情查询开始,runId={}", runId);
|
||||
return RequestResult.success(workflowRunService.getRunById(runId));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.bruce.workflow.dto.WorkflowVersionSaveDTO;
|
||||
import com.bruce.workflow.service.IWorkflowVersionService;
|
||||
import com.bruce.workflow.vo.WorkflowVersionVO;
|
||||
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;
|
||||
@@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/workflow-versions")
|
||||
@RequiredArgsConstructor
|
||||
@@ -30,4 +32,15 @@ public class WorkflowVersionController {
|
||||
public RequestResult<Boolean> publish(@RequestBody WorkflowVersionSaveDTO request) {
|
||||
return RequestResult.success(workflowVersionService.publishVersion(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容前端实现文档中的资源化发布路径。
|
||||
*/
|
||||
@PostMapping("/compat/workflows/{workflowId}/publish")
|
||||
public RequestResult<Boolean> publishCompat(@PathVariable("workflowId") Long workflowId,
|
||||
@RequestBody WorkflowVersionSaveDTO request) {
|
||||
request.setWorkflowId(workflowId);
|
||||
log.info("Workflow版本发布开始,workflowId={}, versionNo={}", workflowId, request.getVersionNo());
|
||||
return RequestResult.success(workflowVersionService.publishVersion(request));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,19 @@ import com.bruce.common.domain.model.RequestResult;
|
||||
import com.bruce.workflow.service.IWorkflowWorkspaceService;
|
||||
import com.bruce.workflow.vo.WorkflowWorkspaceVO;
|
||||
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.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Workflow 工作台聚合接口。
|
||||
* <p>
|
||||
* 该接口面向前端原型页面,负责把项目、流程、版本与最近运行记录收口成单一视图,
|
||||
* 避免页面自行拼接多个底层管理接口。
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/workflow-workspace")
|
||||
@RequiredArgsConstructor
|
||||
@@ -19,6 +27,14 @@ public class WorkflowWorkspaceController {
|
||||
@GetMapping("/detail")
|
||||
public RequestResult<WorkflowWorkspaceVO> detail(@RequestParam("projectId") Long projectId,
|
||||
@RequestParam(value = "workflowId", required = false) Long workflowId) {
|
||||
return RequestResult.success(workflowWorkspaceService.getWorkspace(projectId, workflowId));
|
||||
log.info("Workflow工作台查询开始,projectId={}, workflowId={}", projectId, workflowId);
|
||||
WorkflowWorkspaceVO workspace = workflowWorkspaceService.getWorkspace(projectId, workflowId);
|
||||
log.info("Workflow工作台查询结束,projectId={}, workflowId={}, workflowCount={}, versionCount={}, recentRunCount={}",
|
||||
projectId,
|
||||
workflowId,
|
||||
workspace.getWorkflows() == null ? 0 : workspace.getWorkflows().size(),
|
||||
workspace.getVersions() == null ? 0 : workspace.getVersions().size(),
|
||||
workspace.getRecentRuns() == null ? 0 : workspace.getRecentRuns().size());
|
||||
return RequestResult.success(workspace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ public interface IWorkflowRunService extends IService<WorkflowRun> {
|
||||
boolean createRun(WorkflowRunCreateDTO request);
|
||||
|
||||
List<WorkflowRunVO> listRecentByWorkflowId(Long workflowId);
|
||||
|
||||
WorkflowRunVO getRunById(Long runId);
|
||||
}
|
||||
|
||||
@@ -52,6 +52,18 @@ public class WorkflowRunServiceImpl extends ServiceImpl<WorkflowRunMapper, Workf
|
||||
return workflowRunFactory.toVOList(runs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WorkflowRunVO getRunById(Long runId) {
|
||||
if (runId == null) {
|
||||
throw new IllegalArgumentException("Workflow运行ID不能为空");
|
||||
}
|
||||
WorkflowRun run = getById(runId);
|
||||
if (run == null) {
|
||||
return null;
|
||||
}
|
||||
return workflowRunFactory.toVO(run);
|
||||
}
|
||||
|
||||
private void validateRequest(WorkflowRunCreateDTO request) {
|
||||
if (request == null) {
|
||||
throw new IllegalArgumentException("Workflow运行请求不能为空");
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.bruce.workflow;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.bruce.common.domain.model.RequestResult;
|
||||
import com.bruce.workflow.controller.ProjectController;
|
||||
import com.bruce.workflow.controller.WorkflowDefinitionController;
|
||||
import com.bruce.workflow.controller.WorkflowRunController;
|
||||
import com.bruce.workflow.controller.WorkflowVersionController;
|
||||
import com.bruce.workflow.dto.ProjectSaveDTO;
|
||||
import com.bruce.workflow.dto.WorkflowDefinitionSaveDTO;
|
||||
import com.bruce.workflow.dto.WorkflowRunCreateDTO;
|
||||
import com.bruce.workflow.dto.WorkflowVersionSaveDTO;
|
||||
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 com.bruce.workflow.mapper.StudioProjectMapper;
|
||||
import com.bruce.workflow.mapper.WorkflowDefinitionMapper;
|
||||
import com.bruce.workflow.mapper.WorkflowRunMapper;
|
||||
import com.bruce.workflow.mapper.WorkflowRunStepMapper;
|
||||
import com.bruce.workflow.mapper.WorkflowVersionMapper;
|
||||
import com.bruce.workflow.service.IProjectService;
|
||||
import com.bruce.workflow.service.IWorkflowDefinitionService;
|
||||
import com.bruce.workflow.service.IWorkflowRunService;
|
||||
import com.bruce.workflow.service.IWorkflowVersionService;
|
||||
import com.bruce.workflow.service.impl.ProjectServiceImpl;
|
||||
import com.bruce.workflow.service.impl.WorkflowDefinitionServiceImpl;
|
||||
import com.bruce.workflow.service.impl.WorkflowRunServiceImpl;
|
||||
import com.bruce.workflow.service.impl.WorkflowVersionServiceImpl;
|
||||
import com.bruce.workflow.vo.ProjectVO;
|
||||
import com.bruce.workflow.vo.WorkflowDefinitionVO;
|
||||
import com.bruce.workflow.vo.WorkflowRunVO;
|
||||
import com.bruce.workflow.vo.WorkflowVersionVO;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class WorkflowComponentStructureTests {
|
||||
|
||||
@Test
|
||||
void workflowComponentsShouldReuseMybatisPlusBaseTypes() {
|
||||
assertTrue(BaseMapper.class.isAssignableFrom(StudioProjectMapper.class));
|
||||
assertTrue(BaseMapper.class.isAssignableFrom(WorkflowDefinitionMapper.class));
|
||||
assertTrue(BaseMapper.class.isAssignableFrom(WorkflowVersionMapper.class));
|
||||
assertTrue(BaseMapper.class.isAssignableFrom(WorkflowRunMapper.class));
|
||||
assertTrue(BaseMapper.class.isAssignableFrom(WorkflowRunStepMapper.class));
|
||||
|
||||
assertTrue(IService.class.isAssignableFrom(IProjectService.class));
|
||||
assertTrue(IService.class.isAssignableFrom(IWorkflowDefinitionService.class));
|
||||
assertTrue(IService.class.isAssignableFrom(IWorkflowVersionService.class));
|
||||
assertTrue(IService.class.isAssignableFrom(IWorkflowRunService.class));
|
||||
|
||||
assertTrue(ServiceImpl.class.isAssignableFrom(ProjectServiceImpl.class));
|
||||
assertTrue(ServiceImpl.class.isAssignableFrom(WorkflowDefinitionServiceImpl.class));
|
||||
assertTrue(ServiceImpl.class.isAssignableFrom(WorkflowVersionServiceImpl.class));
|
||||
assertTrue(ServiceImpl.class.isAssignableFrom(WorkflowRunServiceImpl.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void workflowControllersShouldExposeRequestResultMethods() throws NoSuchMethodException {
|
||||
Method projectListMethod = ProjectController.class.getMethod("list");
|
||||
Method projectDetailMethod = ProjectController.class.getMethod("detail", Long.class);
|
||||
Method projectSaveMethod = ProjectController.class.getMethod("save", ProjectSaveDTO.class);
|
||||
|
||||
Method definitionListMethod = WorkflowDefinitionController.class.getMethod("list", Long.class);
|
||||
Method definitionDetailMethod = WorkflowDefinitionController.class.getMethod("detail", Long.class);
|
||||
Method definitionSaveMethod = WorkflowDefinitionController.class.getMethod("save", WorkflowDefinitionSaveDTO.class);
|
||||
|
||||
Method versionListMethod = WorkflowVersionController.class.getMethod("list", Long.class);
|
||||
Method versionPublishMethod = WorkflowVersionController.class.getMethod("publish", WorkflowVersionSaveDTO.class);
|
||||
|
||||
Method runCreateMethod = WorkflowRunController.class.getMethod("create", WorkflowRunCreateDTO.class);
|
||||
Method runListMethod = WorkflowRunController.class.getMethod("list", Long.class);
|
||||
|
||||
assertEquals(RequestResult.class, projectListMethod.getReturnType());
|
||||
assertEquals(RequestResult.class, projectDetailMethod.getReturnType());
|
||||
assertEquals(RequestResult.class, projectSaveMethod.getReturnType());
|
||||
assertEquals(RequestResult.class, definitionListMethod.getReturnType());
|
||||
assertEquals(RequestResult.class, definitionDetailMethod.getReturnType());
|
||||
assertEquals(RequestResult.class, definitionSaveMethod.getReturnType());
|
||||
assertEquals(RequestResult.class, versionListMethod.getReturnType());
|
||||
assertEquals(RequestResult.class, versionPublishMethod.getReturnType());
|
||||
assertEquals(RequestResult.class, runCreateMethod.getReturnType());
|
||||
assertEquals(RequestResult.class, runListMethod.getReturnType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void workflowServicesShouldExposeStructuredResults() throws NoSuchMethodException {
|
||||
Method projectListMethod = IProjectService.class.getMethod("listProjects");
|
||||
Method projectDetailMethod = IProjectService.class.getMethod("getProject", Long.class);
|
||||
Method projectSaveMethod = IProjectService.class.getMethod("saveProject", ProjectSaveDTO.class);
|
||||
|
||||
Method definitionListMethod = IWorkflowDefinitionService.class.getMethod("listDefinitions");
|
||||
Method definitionByProjectMethod = IWorkflowDefinitionService.class.getMethod("listByProjectId", Long.class);
|
||||
Method definitionDetailMethod = IWorkflowDefinitionService.class.getMethod("getDefinition", Long.class);
|
||||
Method definitionSaveMethod = IWorkflowDefinitionService.class.getMethod("saveDefinition", WorkflowDefinitionSaveDTO.class);
|
||||
|
||||
Method versionListMethod = IWorkflowVersionService.class.getMethod("listByWorkflowId", Long.class);
|
||||
Method versionPublishMethod = IWorkflowVersionService.class.getMethod("publishVersion", WorkflowVersionSaveDTO.class);
|
||||
|
||||
Method runCreateMethod = IWorkflowRunService.class.getMethod("createRun", WorkflowRunCreateDTO.class);
|
||||
Method runListMethod = IWorkflowRunService.class.getMethod("listRecentByWorkflowId", Long.class);
|
||||
|
||||
assertEquals(List.class, projectListMethod.getReturnType());
|
||||
assertEquals(ProjectVO.class, projectDetailMethod.getReturnType());
|
||||
assertEquals(boolean.class, projectSaveMethod.getReturnType());
|
||||
assertEquals(List.class, definitionListMethod.getReturnType());
|
||||
assertEquals(List.class, definitionByProjectMethod.getReturnType());
|
||||
assertEquals(WorkflowDefinitionVO.class, definitionDetailMethod.getReturnType());
|
||||
assertEquals(boolean.class, definitionSaveMethod.getReturnType());
|
||||
assertEquals(List.class, versionListMethod.getReturnType());
|
||||
assertEquals(boolean.class, versionPublishMethod.getReturnType());
|
||||
assertEquals(boolean.class, runCreateMethod.getReturnType());
|
||||
assertEquals(List.class, runListMethod.getReturnType());
|
||||
assertEquals(ProjectVO.class, ProjectVO.class);
|
||||
assertEquals(WorkflowDefinitionVO.class, WorkflowDefinitionVO.class);
|
||||
assertEquals(WorkflowVersionVO.class, WorkflowVersionVO.class);
|
||||
assertEquals(WorkflowRunVO.class, WorkflowRunVO.class);
|
||||
assertEquals(StudioProject.class, StudioProject.class);
|
||||
assertEquals(WorkflowDefinition.class, WorkflowDefinition.class);
|
||||
assertEquals(WorkflowVersion.class, WorkflowVersion.class);
|
||||
assertEquals(WorkflowRun.class, WorkflowRun.class);
|
||||
assertEquals(WorkflowRunStep.class, WorkflowRunStep.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.bruce.workflow.controller;
|
||||
|
||||
import com.bruce.common.handler.GlobalExceptionHandler;
|
||||
import com.bruce.workflow.service.IWorkflowDefinitionService;
|
||||
import com.bruce.workflow.service.IWorkflowRunService;
|
||||
import com.bruce.workflow.service.IWorkflowVersionService;
|
||||
import com.bruce.workflow.vo.WorkflowDefinitionVO;
|
||||
import com.bruce.workflow.vo.WorkflowRunVO;
|
||||
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 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;
|
||||
|
||||
/**
|
||||
* 验证 Workflow 文档草案兼容路径的返回契约。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WorkflowCompatControllerTests {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private IWorkflowDefinitionService workflowDefinitionService;
|
||||
|
||||
@Mock
|
||||
private IWorkflowVersionService workflowVersionService;
|
||||
|
||||
@Mock
|
||||
private IWorkflowRunService workflowRunService;
|
||||
|
||||
@InjectMocks
|
||||
private WorkflowDefinitionController workflowDefinitionController;
|
||||
|
||||
@InjectMocks
|
||||
private WorkflowVersionController workflowVersionController;
|
||||
|
||||
@InjectMocks
|
||||
private WorkflowRunController workflowRunController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(
|
||||
workflowDefinitionController,
|
||||
workflowVersionController,
|
||||
workflowRunController
|
||||
)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void workflowDetailCompatShouldReturnStructuredDefinition() throws Exception {
|
||||
WorkflowDefinitionVO detail = new WorkflowDefinitionVO();
|
||||
detail.setId(201L);
|
||||
detail.setWorkflowCode("workflow-support-rag");
|
||||
detail.setWorkflowName("合同知识召回");
|
||||
|
||||
when(workflowDefinitionService.getDefinition(201L)).thenReturn(detail);
|
||||
|
||||
mockMvc.perform(get("/api/workflows/201"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.workflowCode").value("workflow-support-rag"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void saveDraftCompatShouldDelegateToDefinitionService() throws Exception {
|
||||
when(workflowDefinitionService.saveDefinition(any())).thenReturn(true);
|
||||
|
||||
mockMvc.perform(post("/api/workflows/save-draft")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"id": 201,
|
||||
"projectId": 101,
|
||||
"workflowCode": "workflow-support-rag",
|
||||
"workflowName": "合同知识召回"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void publishCompatShouldDelegateToVersionService() throws Exception {
|
||||
when(workflowVersionService.publishVersion(any())).thenReturn(true);
|
||||
|
||||
mockMvc.perform(post("/api/workflow-versions/compat/workflows/201/publish")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content("""
|
||||
{
|
||||
"versionNo": 3,
|
||||
"snapshotName": "v3"
|
||||
}
|
||||
"""))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data").value(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runCompatShouldReturnStructuredRunDetail() throws Exception {
|
||||
WorkflowRunVO run = new WorkflowRunVO();
|
||||
run.setId(301L);
|
||||
run.setWorkflowId(201L);
|
||||
run.setRequestId("req-1001");
|
||||
run.setStatus("SUCCESS");
|
||||
|
||||
when(workflowRunService.getRunById(301L)).thenReturn(run);
|
||||
|
||||
mockMvc.perform(get("/api/workflow-runs/compat/workflows/runs/301"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.requestId").value("req-1001"))
|
||||
.andExpect(jsonPath("$.data.status").value("SUCCESS"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.bruce.workflow.controller;
|
||||
|
||||
import com.bruce.common.handler.GlobalExceptionHandler;
|
||||
import com.bruce.workflow.service.IWorkflowWorkspaceService;
|
||||
import com.bruce.workflow.vo.WorkflowWorkspaceVO;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 验证 Workflow 工作台接口的查询参数绑定和返回结构。
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class WorkflowWorkspaceControllerTests {
|
||||
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@Mock
|
||||
private IWorkflowWorkspaceService workflowWorkspaceService;
|
||||
|
||||
@InjectMocks
|
||||
private WorkflowWorkspaceController workflowWorkspaceController;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(workflowWorkspaceController)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void detailShouldReturnStructuredWorkspaceView() throws Exception {
|
||||
WorkflowWorkspaceVO workspace = new WorkflowWorkspaceVO();
|
||||
workspace.setProjectId(101L);
|
||||
workspace.setProjectCode("studio");
|
||||
workspace.setWorkflowId(201L);
|
||||
workspace.setWorkflowCode("workflow-support-rag");
|
||||
workspace.setWorkflowName("合同知识召回");
|
||||
|
||||
when(workflowWorkspaceService.getWorkspace(101L, 201L)).thenReturn(workspace);
|
||||
|
||||
mockMvc.perform(get("/api/workflow-workspace/detail")
|
||||
.param("projectId", "101")
|
||||
.param("workflowId", "201"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.resultcode").value("0"))
|
||||
.andExpect(jsonPath("$.data.projectId").value(101))
|
||||
.andExpect(jsonPath("$.data.workflowCode").value("workflow-support-rag"))
|
||||
.andExpect(jsonPath("$.data.workflowName").value("合同知识召回"));
|
||||
}
|
||||
}
|
||||
327
docs/模块完成度审计.md
Normal file
327
docs/模块完成度审计.md
Normal file
@@ -0,0 +1,327 @@
|
||||
# Common Agent Studio 模块完成度审计
|
||||
|
||||
## 1. 审计范围
|
||||
|
||||
本次审计以当前仓库最新状态为准,核对以下资料与实现是否一致:
|
||||
|
||||
- `docs/需求分析/*`
|
||||
- `docs/设计文档/*`
|
||||
- `docs/数据库设计/*`
|
||||
- `docs/后端实现文档/*`
|
||||
- `docs/前端实现文档/*`
|
||||
- `script/sql/*.sql`
|
||||
|
||||
审计目标不是重复设计方案,而是确认当前代码、接口、测试与文档约束之间的对应关系,并标记仍需持续关注的风险点。
|
||||
|
||||
## 2. 总体结论
|
||||
|
||||
- Maven 多模块单体骨架已落地,根 `pom.xml` 已拆分 `common-agent-boot`、`common-agent-common`、`common-agent-rag`、`common-agent-modelprovider`、`common-agent-agent`、`common-agent-workflow`、`common-agent-mcp`、`common-agent-skill`、`common-agent-observability`。
|
||||
- 后端已按业务域拆分模块,每个业务模块均已形成 `controller / service / service/impl / mapper / entity / dto / vo / factory` 主体结构。
|
||||
- `sys_enum`、整型枚举协议、`PersistableSysEnumDefinition` 契约、`BaseEntity` 审计习惯均保持不变。
|
||||
- 前端 Studio 九个工作台页面均已对接真实 API,API 层与页面单测已覆盖主要聚合接口。
|
||||
- 旧管理接口继续兼容;Studio 原型新增聚合接口用于支撑工作台页面,不替代低层 CRUD 接口。
|
||||
|
||||
## 2.1 目标逐条验收矩阵
|
||||
|
||||
以下矩阵直接对照本次目标中的显式要求,标记当前证据状态:
|
||||
|
||||
| 验收项 | 当前状态 | 主要证据 |
|
||||
|--------|----------|----------|
|
||||
| 以 `docs/需求分析`、`docs/设计文档`、`docs/数据库设计`、`docs/后端实现文档`、`docs/前端实现文档` 为实现依据 | 已核对 | 本文档第 1 节与模块核对章节;`docs/数据库设计/9.模块一致性校验.md` |
|
||||
| 后端为 Maven 多模块单体架构 | 已实现 | 根 `pom.xml` 的 9 个子模块;`common-agent-boot` 作为启动模块 |
|
||||
| 模块按业务域拆分,而不是按技术类型散落 | 已实现 | `common-agent-common`、`rag`、`modelprovider`、`agent`、`workflow`、`mcp`、`skill`、`observability` 子模块 |
|
||||
| `sys_enum` 设计与整型枚举协议保持不变 | 已实现 | `script/sql/1.enum.sql`、`script/sql/18.studio_enum.sql`、`PersistableSysEnumDefinition`、`EnumDefinitionTests` |
|
||||
| Entity 与 SQL 表结构一致 | 已强验证 | `SqlEntityMappingContractTests` + `docs/数据库设计/9.模块一致性校验.md` |
|
||||
| Controller 不直接暴露 Entity,统一 DTO/VO/RequestResult | 已实现 | 各模块控制器签名;controller/component 测试 |
|
||||
| Factory/Assembler 承担 DTO/Entity/VO 转换 | 已实现 | 各模块 `factory` 包与 `*FactoryTests` |
|
||||
| Service 承担业务逻辑、状态流转、校验 | 已实现 | 各模块 `service/impl` 与 `*ServiceTests` |
|
||||
| Mapper 使用 MyBatis-Plus,数据访问与脚本命名一致 | 基本实现,证据中等 | `BaseMapper` 结构测试 + SQL/Entity 契约测试;真实 DB 读写集成仍偏少 |
|
||||
| 核心流程补中文注释与中文业务日志 | 基本实现,证据中等 | `1d401c68` 提交 + 关键控制器/服务实现;未做全仓逐文件注释覆盖率检测 |
|
||||
| 每模块补测试并运行 | 已实现 | 当前后端测试文件 68 个;前端 API/页面测试 25 个;多轮 reactor/vitest/build 验证 |
|
||||
| 前端 Studio 页面与真实 API 对接 | 已实现 | 9 个工作台页面、16 个 API 单测、9 个页面单测、`npm run build` 通过 |
|
||||
| 旧接口兼容且新增聚合接口支撑工作台 | 已实现 | RAG/Agent/Workflow/MCP/Skill 文档草案兼容路径与聚合接口 |
|
||||
| 输出模块完成总结、测试结果、本地提交 | 已持续执行 | 最近提交记录:`c8245ba0`、`73237507`、`d5d239ae` 等 |
|
||||
|
||||
说明:
|
||||
|
||||
- 上表中“基本实现,证据中等”的项目,不表示发现功能缺失,而是提示当前最强证据还不如“脚本/实体一致性”那样直接。
|
||||
- 目前最薄弱的仍是“真实数据库读写集成测试”和“全仓中文日志/注释覆盖率的自动化证明”。
|
||||
|
||||
## 3. 模块核对
|
||||
|
||||
### 3.1 common
|
||||
|
||||
已核对内容:
|
||||
|
||||
- 实体与 SQL 对应:
|
||||
- `sys_enum` -> `common-agent-common/src/main/java/com/bruce/common/domain/entity/SysEnum.java`
|
||||
- `sys_attachment` -> `common-agent-common/src/main/java/com/bruce/common/domain/entity/SysAttachment.java`
|
||||
- 基础模型:
|
||||
- `BaseEntity`、`RequestResult<T>`、全局异常、审计配置均位于 `com.bruce.common`
|
||||
- 接口:
|
||||
- `POST /api/sys-enum/list`
|
||||
- `POST /api/sys-enum/query`
|
||||
- `POST /api/sys-enum/queryForManagement`
|
||||
- `GET /api/sys-enum/detail`
|
||||
- `POST /api/sys-enum/save`
|
||||
- `POST /api/sys-enum/batchSave`
|
||||
- `POST /api/sys-enum/delete`
|
||||
- `POST /api/attachments/upload`
|
||||
- 文档解析:
|
||||
- `document/parse` 与 `document/parse/impl` 已提供解析抽象和解析器工厂
|
||||
|
||||
证据:
|
||||
|
||||
- 代码:`common-agent-common/src/main/java/com/bruce/common/controller/*`
|
||||
- 测试:
|
||||
- `common-agent-common/src/test/java/com/bruce/common/controller/SysEnumControllerTests.java`
|
||||
- `common-agent-common/src/test/java/com/bruce/common/factory/*`
|
||||
- `common-agent-common/src/test/java/com/bruce/common/document/parse/*`
|
||||
- `common-agent-common/src/test/java/com/bruce/common/handler/GlobalExceptionHandlerTests.java`
|
||||
|
||||
### 3.2 rag
|
||||
|
||||
已核对内容:
|
||||
|
||||
- 实体已覆盖:
|
||||
- `rag_store`
|
||||
- `rag_document`
|
||||
- `rag_document_parse_result`
|
||||
- `rag_chunk`
|
||||
- `rag_chunk_embedding`
|
||||
- 旧接口保持兼容:
|
||||
- `/api/rag/store/*`
|
||||
- `/api/rag/documents/*`
|
||||
- 聚合接口已提供:
|
||||
- `GET /api/knowledge/workspaces/{storeId}`
|
||||
- `POST /api/knowledge/ingestion-runs`
|
||||
- `GET /api/knowledge/ingestion-runs/{runId}`
|
||||
- 受控最小运行链路已落地:
|
||||
- 批量上传
|
||||
- 手动解析
|
||||
- 手动切片
|
||||
- 向量化记录聚合查询
|
||||
|
||||
本轮补充:
|
||||
|
||||
- 新增 `POST /api/knowledge/ingestion-runs`,与前端实现文档草案保持一致。
|
||||
|
||||
证据:
|
||||
|
||||
- 代码:
|
||||
- `common-agent-rag/src/main/java/com/bruce/rag/controller/*`
|
||||
- `common-agent-rag/src/main/java/com/bruce/rag/service/impl/IngestionRunServiceImpl.java`
|
||||
- 测试:
|
||||
- `common-agent-rag/src/test/java/com/bruce/rag/controller/IngestionRunControllerTests.java`
|
||||
- `common-agent-rag/src/test/java/com/bruce/rag/ingestion/IngestionRunServiceTests.java`
|
||||
- `common-agent-rag/src/test/java/com/bruce/rag/workspace/KnowledgeWorkspaceServiceTests.java`
|
||||
- 其余 `Rag*Tests`
|
||||
|
||||
### 3.3 modelprovider
|
||||
|
||||
已核对内容:
|
||||
|
||||
- 实体已覆盖:
|
||||
- `model_provider`
|
||||
- `model_config`
|
||||
- `model_route_rule`
|
||||
- `rag_store_model_config`
|
||||
- `model_call_log`
|
||||
- 控制器已覆盖:
|
||||
- `ModelProviderController`
|
||||
- `ModelConfigController`
|
||||
- `ModelRouteRuleController`
|
||||
- `RagStoreModelConfigController`
|
||||
- `ModelCallLogController`
|
||||
- `ModelWorkspaceController`
|
||||
- 网关抽象已位于 `gateway`
|
||||
|
||||
证据:
|
||||
|
||||
- 代码:`common-agent-modelprovider/src/main/java/com/bruce/modelprovider/**/*`
|
||||
- 测试:
|
||||
- `common-agent-modelprovider/src/test/java/com/bruce/modelprovider/controller/ModelWorkspaceControllerTests.java`
|
||||
- `common-agent-modelprovider/src/test/java/com/bruce/modelprovider/workspace/ModelWorkspaceServiceTests.java`
|
||||
- `common-agent-modelprovider/src/test/java/com/bruce/modelprovider/factory/ModelProviderFactoryTests.java`
|
||||
|
||||
### 3.4 agent
|
||||
|
||||
已核对内容:
|
||||
|
||||
- 实体已覆盖:
|
||||
- `agent_definition`
|
||||
- `agent_session`
|
||||
- `agent_message`
|
||||
- `agent_capability_binding`
|
||||
- 兼容接口与会话接口并存:
|
||||
- Agent 定义管理
|
||||
- `/api/agents/{agentId}/chat`
|
||||
- `/api/agent-sessions/*`
|
||||
- 工作台聚合接口
|
||||
|
||||
证据:
|
||||
|
||||
- 代码:`common-agent-agent/src/main/java/com/bruce/agent/**/*`
|
||||
- 测试:
|
||||
- `common-agent-agent/src/test/java/com/bruce/agent/controller/AgentSessionControllerTests.java`
|
||||
- `common-agent-agent/src/test/java/com/bruce/agent/session/AgentSessionServiceTests.java`
|
||||
- `common-agent-agent/src/test/java/com/bruce/agent/workspace/AgentWorkspaceServiceTests.java`
|
||||
|
||||
### 3.5 workflow
|
||||
|
||||
已核对内容:
|
||||
|
||||
- 实体已覆盖:
|
||||
- `studio_project`
|
||||
- `workflow_definition`
|
||||
- `workflow_version`
|
||||
- `workflow_run`
|
||||
- `workflow_run_step`
|
||||
- 控制器已覆盖:
|
||||
- `ProjectController`
|
||||
- `WorkflowDefinitionController`
|
||||
- `WorkflowVersionController`
|
||||
- `WorkflowRunController`
|
||||
- `WorkflowWorkspaceController`
|
||||
- 前端工作台聚合使用 `GET /api/workflow-workspace/detail`;
|
||||
旧管理接口继续承担定义、版本与运行管理能力。
|
||||
- 文档草案兼容路径已补充:
|
||||
- `GET /api/workflows/{workflowId}`
|
||||
- `POST /api/workflows/save-draft`
|
||||
- `POST /api/workflow-versions/compat/workflows/{workflowId}/publish`
|
||||
- `POST /api/workflow-runs/compat/workflows/{workflowId}/runs`
|
||||
- `GET /api/workflow-runs/compat/workflows/runs/{runId}`
|
||||
|
||||
证据:
|
||||
|
||||
- 代码:`common-agent-workflow/src/main/java/com/bruce/workflow/**/*`
|
||||
- 测试:
|
||||
- `common-agent-workflow/src/test/java/com/bruce/workflow/controller/WorkflowCompatControllerTests.java`
|
||||
- `common-agent-workflow/src/test/java/com/bruce/workflow/controller/WorkflowWorkspaceControllerTests.java`
|
||||
- `common-agent-workflow/src/test/java/com/bruce/workflow/workspace/WorkflowWorkspaceServiceTests.java`
|
||||
- `common-agent-workflow/src/test/java/com/bruce/workflow/version/WorkflowVersionServiceTests.java`
|
||||
- `common-agent-workflow/src/test/java/com/bruce/workflow/WorkflowComponentStructureTests.java`
|
||||
|
||||
### 3.6 mcp
|
||||
|
||||
已核对内容:
|
||||
|
||||
- 实体已覆盖:
|
||||
- `mcp_server`
|
||||
- `mcp_capability`
|
||||
- 接口已覆盖:
|
||||
- `POST /api/mcp/import`
|
||||
- `GET /api/mcp/servers`
|
||||
- `POST /api/mcp/servers/query`
|
||||
- `GET /api/mcp/servers/{serverId}/capabilities`
|
||||
- `GET /api/mcp/servers/code/{serverCode}/capabilities`
|
||||
- `POST /api/mcp/capabilities/save`
|
||||
- `GET /api/mcp/workspace`
|
||||
|
||||
证据:
|
||||
|
||||
- 代码:`common-agent-mcp/src/main/java/com/bruce/mcp/**/*`
|
||||
- 测试:
|
||||
- `common-agent-mcp/src/test/java/com/bruce/mcp/controller/McpImportControllerTests.java`
|
||||
- `common-agent-mcp/src/test/java/com/bruce/mcp/importing/McpImportServiceTests.java`
|
||||
- `common-agent-mcp/src/test/java/com/bruce/mcp/workspace/McpWorkspaceServiceTests.java`
|
||||
|
||||
### 3.7 skill
|
||||
|
||||
已核对内容:
|
||||
|
||||
- 实体已覆盖:
|
||||
- `skill_definition`
|
||||
- `skill_version`
|
||||
- 工作台接口已覆盖:
|
||||
- `GET /api/skills/{skillCode}`
|
||||
- `POST /api/skills/{skillCode}/draft`
|
||||
- `PUT /api/skills/{skillCode}/draft`
|
||||
- `POST /api/skills/{skillCode}/test`
|
||||
- `POST /api/skills/{skillCode}/publish`
|
||||
- `POST /api/skills/{skillCode}/archive`
|
||||
|
||||
证据:
|
||||
|
||||
- 代码:`common-agent-skill/src/main/java/com/bruce/skill/**/*`
|
||||
- 测试:
|
||||
- `common-agent-skill/src/test/java/com/bruce/skill/controller/SkillWorkspaceControllerTests.java`
|
||||
- `common-agent-skill/src/test/java/com/bruce/skill/version/SkillVersionServiceTests.java`
|
||||
- `common-agent-skill/src/test/java/com/bruce/skill/workspace/SkillWorkspaceServiceTests.java`
|
||||
|
||||
### 3.8 observability
|
||||
|
||||
已核对内容:
|
||||
|
||||
- 聚合来源已复用:
|
||||
- `workflow_run`
|
||||
- `workflow_run_step`
|
||||
- `model_call_log`
|
||||
- `agent_session`
|
||||
- `agent_message`
|
||||
- 接口已覆盖:
|
||||
- `GET /api/observability/runs`
|
||||
- `GET /api/observability/runs/{requestId}`
|
||||
- `GET /api/observability/model-calls`
|
||||
- `GET /api/observability/runs/{requestId}/export`
|
||||
- 导出接口返回脱敏摘要对象
|
||||
|
||||
证据:
|
||||
|
||||
- 代码:`common-agent-observability/src/main/java/com/bruce/observability/**/*`
|
||||
- 测试:
|
||||
- `common-agent-observability/src/test/java/com/bruce/observability/controller/ObservabilityTraceControllerTests.java`
|
||||
- `common-agent-observability/src/test/java/com/bruce/observability/trace/ObservabilityTraceServiceTests.java`
|
||||
|
||||
## 4. 前端对接核对
|
||||
|
||||
已核对内容:
|
||||
|
||||
- Studio 页面:
|
||||
- `frontend/src/pages/studio/StudioDashboardPage.vue`
|
||||
- `frontend/src/pages/studio/KnowledgeWorkspacePage.vue`
|
||||
- `frontend/src/pages/studio/IngestionPipelinePage.vue`
|
||||
- `frontend/src/pages/studio/ModelWorkspacePage.vue`
|
||||
- `frontend/src/pages/studio/WorkflowBuilderPage.vue`
|
||||
- `frontend/src/pages/studio/AgentWorkspacePage.vue`
|
||||
- `frontend/src/pages/studio/McpImportPage.vue`
|
||||
- `frontend/src/pages/studio/SkillWorkspacePage.vue`
|
||||
- `frontend/src/pages/studio/ObservabilityPage.vue`
|
||||
- API 封装已覆盖:
|
||||
- `frontend/src/api/*.ts`
|
||||
- 页面/API 单测已覆盖:
|
||||
- `frontend/src/pages/studio/__tests__/*`
|
||||
- `frontend/src/api/__tests__/*`
|
||||
|
||||
## 4.1 文档草案路径兼容收口
|
||||
|
||||
为减少“文档草案路径”和“现有聚合接口路径”之间的偏差,当前已额外补齐以下兼容入口:
|
||||
|
||||
- Workflow:
|
||||
- `GET /api/workflows/{workflowId}`
|
||||
- `POST /api/workflows/save-draft`
|
||||
- `POST /api/workflow-versions/compat/workflows/{workflowId}/publish`
|
||||
- `POST /api/workflow-runs/compat/workflows/{workflowId}/runs`
|
||||
- `GET /api/workflow-runs/compat/workflows/runs/{runId}`
|
||||
- Agent:
|
||||
- `POST /api/agents/{agentId}/runs`
|
||||
- `GET /api/agent-sessions/agents/{agentId}/sessions`
|
||||
- `GET /api/agent-sessions/{sessionId}`
|
||||
- MCP:
|
||||
- `POST /api/mcp/servers/query`
|
||||
- Skill:
|
||||
- `PUT /api/skills/{skillCode}/draft`
|
||||
|
||||
## 5. 当前仍需持续关注的风险
|
||||
|
||||
- 当前多数“mapper / repository 验证”仍以结构契约测试为主,真实数据库集成测试覆盖度有限。
|
||||
- 当前实现仍保留“旧管理接口 + Studio 聚合接口 + 文档草案兼容路径”的三轨并行方式,能力已覆盖,但后续如进入正式 API 收敛阶段,仍建议选定长期主路径并逐步淘汰别名。
|
||||
- 现有运行链路以“主数据优先 + 最小可运行”实现为主,复杂分支调度、远程 MCP 实时执行编排、重型运行器能力仍适合后续继续增强。
|
||||
- 当前尚未形成“逐文件自动扫描中文日志/注释完整性”的机械化证明,现阶段主要依赖关键实现抽查、提交记录与模块测试覆盖。
|
||||
|
||||
## 6. 本次审计后的新增变更
|
||||
|
||||
- 新增 `POST /api/knowledge/ingestion-runs`,补齐前端实现文档中的摄取运行创建入口。
|
||||
- 补充 `IngestionRunControllerTests` 与前端 `ingestion.spec.ts` 创建接口测试。
|
||||
- 补充 `WorkflowWorkspaceController` 中文注释与标准化业务日志。
|
||||
- 补充 Workflow / Agent / MCP / Skill 的文档草案兼容路径与控制器测试。
|
||||
@@ -16,7 +16,9 @@ describe('attachments api', () => {
|
||||
sourceId: '1001',
|
||||
});
|
||||
|
||||
const [url, body] = vi.mocked(post).mock.calls[0];
|
||||
const firstCall = vi.mocked(post).mock.calls[0];
|
||||
expect(firstCall).toBeDefined();
|
||||
const [url, body] = firstCall as [string, FormData];
|
||||
expect(url).toBe('/attachments/upload');
|
||||
expect(body).toBeInstanceOf(FormData);
|
||||
|
||||
|
||||
50
frontend/src/api/__tests__/ingestion.spec.ts
Normal file
50
frontend/src/api/__tests__/ingestion.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getMock = vi.fn();
|
||||
const postMock = vi.fn();
|
||||
|
||||
vi.mock('../request', () => ({
|
||||
get: getMock,
|
||||
post: postMock,
|
||||
}));
|
||||
|
||||
describe('ingestion api', () => {
|
||||
afterEach(() => {
|
||||
getMock.mockReset();
|
||||
postMock.mockReset();
|
||||
});
|
||||
|
||||
it('creates ingestion run aggregate with store and document payload', async () => {
|
||||
postMock.mockResolvedValue({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: { runId: 'ingestion-1001-11' },
|
||||
});
|
||||
|
||||
const { createIngestionRun } = await import('../ingestion');
|
||||
await createIngestionRun('1001', '11');
|
||||
|
||||
expect(postMock).toHaveBeenCalledWith('/knowledge/ingestion-runs', {
|
||||
storeId: '1001',
|
||||
documentId: '11',
|
||||
});
|
||||
});
|
||||
|
||||
it('requests ingestion run aggregate with store and document params', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: { runId: 'run-20260601' },
|
||||
});
|
||||
|
||||
const { getIngestionRun } = await import('../ingestion');
|
||||
await getIngestionRun('run-20260601', '1001', '11');
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/knowledge/ingestion-runs/run-20260601', {
|
||||
params: {
|
||||
storeId: '1001',
|
||||
documentId: '11',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
26
frontend/src/api/__tests__/studioDashboard.spec.ts
Normal file
26
frontend/src/api/__tests__/studioDashboard.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getMock = vi.fn();
|
||||
|
||||
vi.mock('../request', () => ({
|
||||
get: getMock,
|
||||
}));
|
||||
|
||||
describe('studioDashboard api', () => {
|
||||
afterEach(() => {
|
||||
getMock.mockReset();
|
||||
});
|
||||
|
||||
it('requests studio dashboard aggregate', async () => {
|
||||
getMock.mockResolvedValue({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: { projectName: 'Common Agent Studio' },
|
||||
});
|
||||
|
||||
const { getStudioDashboard } = await import('../studioDashboard');
|
||||
await getStudioDashboard();
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/studio/dashboard');
|
||||
});
|
||||
});
|
||||
56
frontend/src/api/ingestion.ts
Normal file
56
frontend/src/api/ingestion.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { get, post } from './request';
|
||||
|
||||
export interface IngestionRunFile {
|
||||
documentId: string;
|
||||
attachmentId?: string | null;
|
||||
fileName?: string | null;
|
||||
parseStatus?: string | null;
|
||||
indexStatus?: string | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface IngestionRunStep {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
export interface IngestionRunLog {
|
||||
time?: string | null;
|
||||
level?: string | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
export interface IngestionRun {
|
||||
runId: string;
|
||||
storeId: string;
|
||||
documentId: string;
|
||||
storeCode?: string | null;
|
||||
storeName?: string | null;
|
||||
files: IngestionRunFile[];
|
||||
steps: IngestionRunStep[];
|
||||
parsedTextPreview?: string | null;
|
||||
chunkPreview?: string | null;
|
||||
chunkStrategy?: number | null;
|
||||
chunkSize?: number | null;
|
||||
chunkOverlap?: number | null;
|
||||
embeddingModelId?: string | null;
|
||||
embeddingDimension?: number | null;
|
||||
logs: IngestionRunLog[];
|
||||
}
|
||||
|
||||
export function createIngestionRun(storeId: string, documentId: string) {
|
||||
return post<IngestionRun>('/knowledge/ingestion-runs', {
|
||||
storeId,
|
||||
documentId,
|
||||
});
|
||||
}
|
||||
|
||||
export function getIngestionRun(runId: string, storeId: string, documentId: string) {
|
||||
return get<IngestionRun>(`/knowledge/ingestion-runs/${runId}`, {
|
||||
params: {
|
||||
storeId,
|
||||
documentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -40,7 +40,9 @@ export interface ModelRouteRule {
|
||||
matchScope: string;
|
||||
scopeId?: string;
|
||||
primaryModelId: string;
|
||||
primaryModelCode?: string;
|
||||
fallbackModelIdsJson?: string;
|
||||
fallbackModelCode?: string;
|
||||
routeStrategy: string;
|
||||
maxLatencyMs?: number;
|
||||
enabled: boolean;
|
||||
|
||||
44
frontend/src/api/studioDashboard.ts
Normal file
44
frontend/src/api/studioDashboard.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { get } from './request';
|
||||
|
||||
export interface StudioDashboardLifecycleStep {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
export interface StudioDashboardChecklistItem {
|
||||
label: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface StudioDashboardMetrics {
|
||||
todayRunCount: number;
|
||||
successRate: number;
|
||||
p50Latency?: string | null;
|
||||
estimatedCost?: string | null;
|
||||
}
|
||||
|
||||
export interface StudioDashboardRecentRun {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
type?: string | null;
|
||||
status?: string | null;
|
||||
latency?: string | null;
|
||||
cost?: string | null;
|
||||
}
|
||||
|
||||
export interface StudioDashboard {
|
||||
projectName?: string | null;
|
||||
environment?: string | null;
|
||||
publishStatus?: string | null;
|
||||
lifecycleSteps: StudioDashboardLifecycleStep[];
|
||||
readinessChecklist: StudioDashboardChecklistItem[];
|
||||
metrics: StudioDashboardMetrics;
|
||||
recentRuns: StudioDashboardRecentRun[];
|
||||
warningTitle?: string | null;
|
||||
warningMessage?: string | null;
|
||||
}
|
||||
|
||||
export function getStudioDashboard() {
|
||||
return get<StudioDashboard>('/studio/dashboard');
|
||||
}
|
||||
@@ -1,7 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ChatDotRound, Coin, Timer } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import { chatMessages, citations, traceSteps } from '@/data/studioMock';
|
||||
import type { AgentMessageRecord, AgentWorkspace } from '@/api/agent';
|
||||
import { appendAgentMessage, getAgentWorkspace } from '@/api/agent';
|
||||
|
||||
const loading = ref(false);
|
||||
const agentId = ref('1001');
|
||||
const workspace = ref<AgentWorkspace | null>(null);
|
||||
const draftMessage = ref('');
|
||||
|
||||
const citations = computed(() => {
|
||||
return (workspace.value?.messages ?? [])
|
||||
.filter((message) => message.citationJson?.includes('chunkId'))
|
||||
.map((message, index) => ({
|
||||
key: `${message.id || index}`,
|
||||
title: `引用 ${index + 1}`,
|
||||
text: message.citationJson ?? '',
|
||||
}));
|
||||
});
|
||||
|
||||
const latestAssistantMessage = computed(() => {
|
||||
const assistantMessages = (workspace.value?.messages ?? []).filter((item) => item.role === 'assistant');
|
||||
return assistantMessages.length > 0 ? assistantMessages[assistantMessages.length - 1] : null;
|
||||
});
|
||||
|
||||
function roleLabel(role: AgentMessageRecord['role']) {
|
||||
if (role === 'user') {
|
||||
return '用户';
|
||||
}
|
||||
if (role === 'assistant') {
|
||||
return 'Agent';
|
||||
}
|
||||
return '系统';
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getAgentWorkspace(agentId.value);
|
||||
workspace.value = response.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (!workspace.value?.sessionId || !draftMessage.value.trim()) {
|
||||
return;
|
||||
}
|
||||
await appendAgentMessage(workspace.value.sessionId, {
|
||||
role: 'user',
|
||||
content: draftMessage.value.trim(),
|
||||
});
|
||||
ElMessage.success('调试消息已写入会话');
|
||||
draftMessage.value = '';
|
||||
await loadWorkspace();
|
||||
}
|
||||
|
||||
function formatCost(tokens?: number) {
|
||||
if (!tokens) {
|
||||
return '¥0.000';
|
||||
}
|
||||
return `¥${(tokens / 100000).toFixed(3)}`;
|
||||
}
|
||||
|
||||
onMounted(loadWorkspace);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -11,38 +76,47 @@ import { chatMessages, citations, traceSteps } from '@/data/studioMock';
|
||||
<p class="studio-kicker">AgentWorkspaceView</p>
|
||||
<h1>Agent 对话调试</h1>
|
||||
</div>
|
||||
<el-button type="primary">发布 Agent</el-button>
|
||||
<el-button type="primary">{{ workspace?.status || 'Draft' }}</el-button>
|
||||
</header>
|
||||
|
||||
<div class="agent-layout">
|
||||
<div class="agent-layout" v-loading="loading">
|
||||
<section class="studio-panel chat-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>售前问答 Agent</h2>
|
||||
<span>POST /api/agents/1001/runs</span>
|
||||
<h2>{{ workspace?.agentName || 'Agent 工作台' }}</h2>
|
||||
<span>GET /api/agent-sessions/workspace</span>
|
||||
</div>
|
||||
<el-tag>Draft</el-tag>
|
||||
<el-tag>{{ workspace?.sessionStatus || workspace?.status || 'DRAFT' }}</el-tag>
|
||||
</div>
|
||||
<div class="message-list">
|
||||
<article v-for="message in chatMessages" :key="message.content" :class="message.role">
|
||||
<strong>{{ message.role === 'user' ? '用户' : 'Agent' }}</strong>
|
||||
<article
|
||||
v-for="message in workspace?.messages ?? []"
|
||||
:key="message.id || message.content"
|
||||
:class="message.role"
|
||||
:data-test="`agent-message-${message.role}`"
|
||||
>
|
||||
<strong>{{ roleLabel(message.role) }}</strong>
|
||||
<p>{{ message.content }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="chat-composer">
|
||||
<span>输入调试问题,运行会写入 agent_session / agent_message 草案</span>
|
||||
<el-button type="primary"><el-icon><ChatDotRound /></el-icon> 发送</el-button>
|
||||
<span>输入调试问题,写入当前 agent_session / agent_message</span>
|
||||
<input v-model="draftMessage" data-test="agent-message-input" type="text" />
|
||||
<el-button data-test="agent-send-message" type="primary" @click="sendMessage">
|
||||
<el-icon><ChatDotRound /></el-icon>
|
||||
发送
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="studio-panel citation-panel">
|
||||
<div class="panel-heading compact">
|
||||
<h2>引用切片</h2>
|
||||
<span>3 个来源</span>
|
||||
<span>{{ workspace?.citationCount || 0 }} 个来源</span>
|
||||
</div>
|
||||
<article v-for="citation in citations" :key="citation.title" class="citation-card">
|
||||
<article v-for="citation in citations" :key="citation.key" class="citation-card" data-test="agent-citation-card">
|
||||
<strong>{{ citation.title }}</strong>
|
||||
<el-tag type="success">score {{ citation.score }}</el-tag>
|
||||
<el-tag type="success">引用</el-tag>
|
||||
<p>{{ citation.text }}</p>
|
||||
</article>
|
||||
</aside>
|
||||
@@ -50,17 +124,17 @@ import { chatMessages, citations, traceSteps } from '@/data/studioMock';
|
||||
<aside class="studio-panel run-inspector">
|
||||
<div class="panel-heading compact">
|
||||
<h2>运行追踪</h2>
|
||||
<span>modelRequestId: f4215d</span>
|
||||
<span data-test="agent-latest-request-id">requestId: {{ workspace?.latestRequestId || '-' }}</span>
|
||||
</div>
|
||||
<div class="metric-mini">
|
||||
<span><el-icon><Timer /></el-icon> 1.42s</span>
|
||||
<span><el-icon><Coin /></el-icon> ¥0.018</span>
|
||||
<span>1,248 tokens</span>
|
||||
<span><el-icon><Timer /></el-icon> {{ workspace?.sessionStatus || '-' }}</span>
|
||||
<span><el-icon><Coin /></el-icon> {{ formatCost(workspace?.totalTokens) }}</span>
|
||||
<span>{{ workspace?.totalTokens || 0 }} tokens</span>
|
||||
</div>
|
||||
<ol class="log-list">
|
||||
<li v-for="step in traceSteps" :key="step.node">
|
||||
<time>{{ step.duration }}</time>
|
||||
<span>{{ step.node }} · {{ step.output }}</span>
|
||||
<li v-if="latestAssistantMessage">
|
||||
<time>{{ workspace?.sessionCode || '-' }}</time>
|
||||
<span>{{ latestAssistantMessage.content }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
@@ -1,29 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { UploadFilled } from '@element-plus/icons-vue';
|
||||
|
||||
import { ingestionSteps } from '@/data/studioMock';
|
||||
import type { IngestionRun } from '@/api/ingestion';
|
||||
import { getIngestionRun } from '@/api/ingestion';
|
||||
|
||||
const loading = ref(false);
|
||||
const runId = ref('run-20260601');
|
||||
const storeId = ref('1001');
|
||||
const documentId = ref('11');
|
||||
const ingestionRun = ref<IngestionRun | null>(null);
|
||||
|
||||
const primaryFile = computed(() => ingestionRun.value?.files?.[0] ?? null);
|
||||
|
||||
function stepClass(status?: string | null) {
|
||||
if (status === 'done') {
|
||||
return 'is-done';
|
||||
}
|
||||
if (status === 'running') {
|
||||
return 'is-running';
|
||||
}
|
||||
if (status === 'blocked') {
|
||||
return 'is-blocked';
|
||||
}
|
||||
return 'is-idle';
|
||||
}
|
||||
|
||||
async function loadRun() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getIngestionRun(runId.value, storeId.value, documentId.value);
|
||||
ingestionRun.value = response.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadRun);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="studio-page ingestion-page">
|
||||
<header class="page-title-row">
|
||||
<div>
|
||||
<p class="studio-kicker">IngestionPipelineView</p>
|
||||
<p class="studio-kicker">IngestionRunView</p>
|
||||
<h1>文件解析管道</h1>
|
||||
</div>
|
||||
<el-button type="primary">启动索引任务</el-button>
|
||||
</header>
|
||||
|
||||
<div class="ingestion-layout">
|
||||
<div class="ingestion-layout" v-loading="loading">
|
||||
<section class="studio-panel upload-panel">
|
||||
<div class="upload-dropzone">
|
||||
<el-icon><UploadFilled /></el-icon>
|
||||
<strong>拖拽文件到这里</strong>
|
||||
<span>支持 PDF / Word / Excel / Markdown / TXT,上传后自动创建 ingestion run。</span>
|
||||
<strong>{{ primaryFile?.fileName || '拖拽文件到这里' }}</strong>
|
||||
<span>
|
||||
支持 PDF / Word / Excel / Markdown / TXT,上传后自动创建 ingestion run。
|
||||
</span>
|
||||
<el-button type="primary">选择文件</el-button>
|
||||
</div>
|
||||
<div class="pipeline-timeline">
|
||||
<article v-for="step in ingestionSteps" :key="step.name" :class="`is-${step.status}`">
|
||||
<article
|
||||
v-for="step in ingestionRun?.steps ?? []"
|
||||
:key="step.name"
|
||||
:class="stepClass(step.status)"
|
||||
:data-test="`ingestion-step-${step.name}`"
|
||||
>
|
||||
<div class="timeline-dot" />
|
||||
<div class="timeline-content">
|
||||
<strong>{{ step.name }}</strong>
|
||||
@@ -36,36 +78,40 @@ import { ingestionSteps } from '@/data/studioMock';
|
||||
<section class="studio-panel preview-panel">
|
||||
<div class="panel-heading">
|
||||
<h2>解析与切片预览</h2>
|
||||
<span>GET /api/knowledge/ingestion-runs/run-20260531</span>
|
||||
<span>GET /api/knowledge/ingestion-runs/{{ ingestionRun?.runId || runId }}</span>
|
||||
</div>
|
||||
<div class="preview-split">
|
||||
<article>
|
||||
<h3>解析文本</h3>
|
||||
<p>私有化部署章节应覆盖基础设施、网络、安全与运维边界。平台需说明模型服务商、知识库索引策略与日志留存周期...</p>
|
||||
<p data-test="ingestion-parsed-preview">{{ ingestionRun?.parsedTextPreview || '暂无解析文本预览' }}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h3>切片 #24</h3>
|
||||
<p>chunk_size=800, overlap=120, strategy=FIXED_LENGTH。该切片将进入 rag_chunk 并在向量化后写入 rag_chunk_embedding。</p>
|
||||
<h3>切片预览</h3>
|
||||
<p data-test="ingestion-chunk-preview">{{ ingestionRun?.chunkPreview || '暂无切片预览' }}</p>
|
||||
</article>
|
||||
</div>
|
||||
<div class="pipeline-controls">
|
||||
<label>切片策略 <strong>固定长度</strong></label>
|
||||
<label>Chunk Size <strong>800</strong></label>
|
||||
<label>Overlap <strong>120</strong></label>
|
||||
<label>Embedding <strong>Qwen3 1024d</strong></label>
|
||||
<label>切片策略 <strong>{{ ingestionRun?.chunkStrategy ?? '-' }}</strong></label>
|
||||
<label>Chunk Size <strong>{{ ingestionRun?.chunkSize ?? '-' }}</strong></label>
|
||||
<label>Overlap <strong>{{ ingestionRun?.chunkOverlap ?? '-' }}</strong></label>
|
||||
<label>Embedding <strong>{{ ingestionRun?.embeddingModelId || '-' }} / {{ ingestionRun?.embeddingDimension || '-' }} 维</strong></label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="studio-panel task-log-panel">
|
||||
<div class="panel-heading compact">
|
||||
<h2>任务日志</h2>
|
||||
<span>run-20260531</span>
|
||||
<span>{{ ingestionRun?.runId || runId }}</span>
|
||||
</div>
|
||||
<ol class="log-list">
|
||||
<li><time>23:08:12</time><span>上传 4 个文件并创建 rag_document</span></li>
|
||||
<li><time>23:08:24</time><span>Tika 解析完成 3 个文件</span></li>
|
||||
<li class="warn"><time>23:08:31</time><span>服务条款更新.md 编码检测失败,等待重试</span></li>
|
||||
<li><time>23:08:40</time><span>切片任务进行中 68 / 119</span></li>
|
||||
<li
|
||||
v-for="item in ingestionRun?.logs ?? []"
|
||||
:key="`${item.time}-${item.message}`"
|
||||
:class="{ warn: item.level === 'WARN' }"
|
||||
:data-test="`ingestion-log-${item.time}`"
|
||||
>
|
||||
<time>{{ item.time }}</time><span>{{ item.message }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { DataAnalysis, Document, Setting } from '@element-plus/icons-vue';
|
||||
|
||||
import { knowledgeDocuments, knowledgeStores } from '@/data/studioMock';
|
||||
import type { KnowledgeWorkspace } from '@/api/knowledgeWorkspace';
|
||||
import { getKnowledgeWorkspace } from '@/api/knowledgeWorkspace';
|
||||
|
||||
const loading = ref(false);
|
||||
const activeStoreId = ref('1001');
|
||||
const workspace = ref<KnowledgeWorkspace | null>(null);
|
||||
|
||||
const stores = computed(() => (workspace.value ? [workspace.value] : []));
|
||||
|
||||
function statusLabel(status?: string | null) {
|
||||
if (status === 'ENABLED' || status === '可检索') {
|
||||
return '可检索';
|
||||
}
|
||||
return status || '-';
|
||||
}
|
||||
|
||||
async function loadWorkspace(storeId = activeStoreId.value) {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getKnowledgeWorkspace(storeId);
|
||||
workspace.value = response.data;
|
||||
activeStoreId.value = response.data.storeId;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => loadWorkspace());
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -14,61 +42,63 @@ import { knowledgeDocuments, knowledgeStores } from '@/data/studioMock';
|
||||
<el-button type="primary">新建知识库</el-button>
|
||||
</header>
|
||||
|
||||
<div class="three-column-layout">
|
||||
<div class="three-column-layout" v-loading="loading">
|
||||
<aside class="studio-panel collection-rail">
|
||||
<div class="panel-heading compact">
|
||||
<h2>知识集合</h2>
|
||||
<span>{{ knowledgeStores.length }} 个库</span>
|
||||
<span>{{ stores.length }} 个库</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="store in knowledgeStores"
|
||||
:key="store.id"
|
||||
v-for="store in stores"
|
||||
:key="store.storeId"
|
||||
class="collection-item"
|
||||
:class="{ active: store.id === '1001' }"
|
||||
:class="{ active: String(store.storeId) === String(activeStoreId) }"
|
||||
:data-test="`knowledge-store-${store.storeId}`"
|
||||
@click="loadWorkspace(String(store.storeId))"
|
||||
>
|
||||
<strong>{{ store.name }}</strong>
|
||||
<span>{{ store.docs }} 文档 · 健康度 {{ store.health }}%</span>
|
||||
<em>{{ store.status }}</em>
|
||||
<strong>{{ store.storeName }}</strong>
|
||||
<span>{{ store.documentCount }} 文档 · 健康度 {{ store.healthScore }}%</span>
|
||||
<em>{{ statusLabel(store.status) }}</em>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main class="studio-panel knowledge-main">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>产品制度库</h2>
|
||||
<h2>{{ workspace?.storeName || '知识工作台' }}</h2>
|
||||
<span>绑定旧数据语义:rag_store / rag_document / rag_chunk_embedding</span>
|
||||
</div>
|
||||
<el-tag type="success">可检索</el-tag>
|
||||
<el-tag type="success">{{ statusLabel(workspace?.status) }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="config-grid">
|
||||
<article>
|
||||
<el-icon><Setting /></el-icon>
|
||||
<strong>Embedding 模型</strong>
|
||||
<span>Qwen3-Embedding · 1024 维</span>
|
||||
<span>{{ workspace?.embeddingModelId || '-' }} · {{ workspace?.embeddingDimension || '-' }} 维</span>
|
||||
</article>
|
||||
<article>
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
<strong>检索配置</strong>
|
||||
<span>TopK 6 · Score ≥ 0.72 · Rerank 关闭</span>
|
||||
<span>切片策略 {{ workspace?.chunkStrategy || '-' }} · Chunk {{ workspace?.chunkSize || '-' }}</span>
|
||||
</article>
|
||||
<article>
|
||||
<el-icon><Document /></el-icon>
|
||||
<strong>索引版本</strong>
|
||||
<span>index_version 14 · Draft 快照</span>
|
||||
<span>index_version {{ workspace?.indexVersion || '-' }}</span>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="document-table">
|
||||
<div class="table-row table-head">
|
||||
<span>文档</span><span>解析</span><span>索引</span><span>切片</span><span>更新</span>
|
||||
<span>文档</span><span>解析</span><span>索引</span><span>启用</span><span>更新</span>
|
||||
</div>
|
||||
<div v-for="doc in knowledgeDocuments" :key="doc.id" class="table-row">
|
||||
<strong>{{ doc.name }}</strong>
|
||||
<div v-for="doc in workspace?.documents ?? []" :key="doc.documentId" class="table-row" :data-test="`knowledge-document-${doc.documentId}`">
|
||||
<strong>{{ doc.documentTitle }}</strong>
|
||||
<span>{{ doc.parseStatus }}</span>
|
||||
<span>{{ doc.indexStatus }}</span>
|
||||
<span>{{ doc.chunks }}</span>
|
||||
<span>{{ doc.updatedAt }}</span>
|
||||
<span>{{ doc.enabled ? '是' : '否' }}</span>
|
||||
<span>{{ doc.updateTime || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -80,13 +110,13 @@ import { knowledgeDocuments, knowledgeStores } from '@/data/studioMock';
|
||||
</div>
|
||||
<dl class="inspector-list">
|
||||
<dt>Workspace API</dt>
|
||||
<dd>GET /api/knowledge/workspaces/1001</dd>
|
||||
<dd>GET /api/knowledge/workspaces/{{ workspace?.storeId || '-' }}</dd>
|
||||
<dt>文档健康度</dt>
|
||||
<dd>96% · 1 个解析失败</dd>
|
||||
<dd>{{ workspace?.healthScore || 0 }}% · {{ workspace?.parseFailedDocumentCount || 0 }} 个解析失败</dd>
|
||||
<dt>待处理任务</dt>
|
||||
<dd>2 个文档等待向量化</dd>
|
||||
<dd>{{ workspace?.pendingTaskCount || 0 }} 个文档等待处理</dd>
|
||||
<dt>发布影响</dt>
|
||||
<dd>更新后需要 Workflow 重新验证引用质量</dd>
|
||||
<dd>{{ workspace?.publishImpact || '-' }}</dd>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -66,8 +66,9 @@ async function loadServers() {
|
||||
try {
|
||||
const response = await listMcpServers();
|
||||
servers.value = response.data ?? [];
|
||||
if (!selectedServerCode.value && servers.value.length > 0) {
|
||||
selectedServerCode.value = servers.value[0].serverCode;
|
||||
const firstServer = servers.value[0];
|
||||
if (!selectedServerCode.value && firstServer?.serverCode) {
|
||||
selectedServerCode.value = firstServer.serverCode;
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -193,7 +194,7 @@ onMounted(async () => {
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="selectedServer" class="capability-summary" data-test="mcp-selected-server">
|
||||
当前服务:{{ selectedServer.serverName }} / {{ selectedServer.importType }} / {{ selectedServer.healthStatus }}
|
||||
当前服务:{{ selectedServer?.serverName }} / {{ selectedServer?.importType }} / {{ selectedServer?.healthStatus }}
|
||||
</p>
|
||||
<div class="capability-grid" v-loading="capabilityLoading">
|
||||
<article v-for="item in capabilities" :key="item.capabilityCode" :data-test="`mcp-capability-${item.capabilityCode}`">
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { modelRoutes } from '@/data/studioMock';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import type { ModelWorkspace } from '@/api/modelWorkspace';
|
||||
import { getModelWorkspace } from '@/api/modelWorkspace';
|
||||
|
||||
const loading = ref(false);
|
||||
const workspace = ref<ModelWorkspace | null>(null);
|
||||
|
||||
function routeStatus(enabled?: boolean) {
|
||||
return enabled ? '启用' : '草稿';
|
||||
}
|
||||
|
||||
async function loadWorkspace() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getModelWorkspace();
|
||||
workspace.value = response.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadWorkspace);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -12,23 +34,28 @@ import { modelRoutes } from '@/data/studioMock';
|
||||
<el-button type="primary">新增路由</el-button>
|
||||
</header>
|
||||
|
||||
<div class="studio-panel model-panel">
|
||||
<div class="studio-panel model-panel" v-loading="loading">
|
||||
<div class="panel-heading">
|
||||
<h2>任务路由规则</h2>
|
||||
<span>保留 model_provider / model_config / model_route_rule 语义</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<div><strong>{{ workspace?.providerCount || 0 }}</strong><span>服务商</span></div>
|
||||
<div><strong>{{ workspace?.modelCount || 0 }}</strong><span>模型</span></div>
|
||||
<div><strong>{{ workspace?.routeRuleCount || 0 }}</strong><span>路由规则</span></div>
|
||||
<div><strong>{{ workspace?.recentFailedCallCount || 0 }}</strong><span>最近失败</span></div>
|
||||
</div>
|
||||
<div class="document-table">
|
||||
<div class="table-row table-head">
|
||||
<span>任务</span><span>主模型</span><span>Fallback</span><span>最大延迟</span><span>状态</span>
|
||||
<span>任务</span><span>主模型</span><span>Fallback</span><span>状态</span>
|
||||
</div>
|
||||
<div v-for="route in modelRoutes" :key="route.task" class="table-row">
|
||||
<strong>{{ route.task }}</strong>
|
||||
<span>{{ route.primary }}</span>
|
||||
<span>{{ route.fallback }}</span>
|
||||
<span>{{ route.latency }}</span>
|
||||
<div v-for="route in workspace?.routes ?? []" :key="route.id || route.taskType" class="table-row" :data-test="`model-route-${route.taskType}`">
|
||||
<strong>{{ route.taskType }}</strong>
|
||||
<span>{{ route.primaryModelCode || route.primaryModelId }}</span>
|
||||
<span>{{ route.fallbackModelCode || route.fallbackModelIdsJson || '无' }}</span>
|
||||
<span class="status-cell">
|
||||
<span class="status-pill" :class="route.status === '启用' ? 'is-success' : 'is-warning'">
|
||||
{{ route.status }}
|
||||
<span class="status-pill" :class="routeStatus(route.enabled) === '启用' ? 'is-success' : 'is-warning'">
|
||||
{{ routeStatus(route.enabled) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -47,8 +47,9 @@ async function loadRuns() {
|
||||
try {
|
||||
const response = await listObservabilityRuns();
|
||||
runs.value = response.data ?? [];
|
||||
if (runs.value.length > 0) {
|
||||
await loadTrace(runs.value[0].requestId);
|
||||
const firstRun = runs.value[0];
|
||||
if (firstRun?.requestId) {
|
||||
await loadTrace(firstRun.requestId);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
|
||||
@@ -1,14 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ArrowRight, Check, Warning } from '@element-plus/icons-vue';
|
||||
|
||||
import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMock';
|
||||
import type { StudioDashboard } from '@/api/studioDashboard';
|
||||
import { getStudioDashboard } from '@/api/studioDashboard';
|
||||
|
||||
const loading = ref(false);
|
||||
const dashboard = ref<StudioDashboard | null>(null);
|
||||
|
||||
const metrics = computed(() => dashboard.value?.metrics);
|
||||
|
||||
function formatPublishStatus(status?: string | null) {
|
||||
if (status === 'PUBLISHED') {
|
||||
return 'Published';
|
||||
}
|
||||
if (status === 'DRAFT') {
|
||||
return 'Draft';
|
||||
}
|
||||
return status || 'Draft';
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getStudioDashboard();
|
||||
dashboard.value = response.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDashboard);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="studio-page dashboard-page">
|
||||
<section class="studio-page dashboard-page" v-loading="loading">
|
||||
<header class="studio-hero">
|
||||
<div>
|
||||
<p class="studio-kicker">项目 / Common Agent Studio</p>
|
||||
<p class="studio-kicker">项目 / {{ dashboard?.projectName || 'Common Agent Studio' }}</p>
|
||||
<h1>从知识接入到 Agent 发布的一体化工作台</h1>
|
||||
<p>
|
||||
使用新的聚合 ViewModel 驱动原型:知识资产、Workflow、MCP、Skill、Agent 调试与观测都围绕一次发布旅程组织。
|
||||
@@ -21,13 +50,19 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
|
||||
</header>
|
||||
|
||||
<div class="lifecycle-strip">
|
||||
<article v-for="(step, index) in lifecycleSteps" :key="step.name" class="lifecycle-step" :class="`is-${step.status}`">
|
||||
<article
|
||||
v-for="(step, index) in dashboard?.lifecycleSteps ?? []"
|
||||
:key="step.name"
|
||||
class="lifecycle-step"
|
||||
:class="`is-${step.status}`"
|
||||
:data-test="`dashboard-lifecycle-${step.name}`"
|
||||
>
|
||||
<div class="step-index">{{ index + 1 }}</div>
|
||||
<div>
|
||||
<strong>{{ step.name }}</strong>
|
||||
<span>{{ step.description }}</span>
|
||||
</div>
|
||||
<el-icon v-if="index < lifecycleSteps.length - 1"><ArrowRight /></el-icon>
|
||||
<el-icon v-if="dashboard?.lifecycleSteps && index < dashboard.lifecycleSteps.length - 1"><ArrowRight /></el-icon>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -38,10 +73,15 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
|
||||
<h2>发布就绪检查</h2>
|
||||
<span>ViewModel: StudioDashboardView</span>
|
||||
</div>
|
||||
<el-tag type="warning">Draft</el-tag>
|
||||
<el-tag type="warning">{{ formatPublishStatus(dashboard?.publishStatus) }}</el-tag>
|
||||
</div>
|
||||
<ul class="check-list">
|
||||
<li v-for="item in readinessChecklist" :key="item.label" :class="{ done: item.done }">
|
||||
<li
|
||||
v-for="item in dashboard?.readinessChecklist ?? []"
|
||||
:key="item.label"
|
||||
:class="{ done: item.done }"
|
||||
:data-test="`dashboard-check-${item.label}`"
|
||||
>
|
||||
<el-icon>
|
||||
<Check v-if="item.done" />
|
||||
<span v-else class="pending-dot" />
|
||||
@@ -54,13 +94,13 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
|
||||
<section class="studio-panel metrics-panel">
|
||||
<div class="panel-heading">
|
||||
<h2>运行概览</h2>
|
||||
<span>环境: Dev</span>
|
||||
<span>环境: {{ dashboard?.environment || 'Dev' }}</span>
|
||||
</div>
|
||||
<div class="metric-row">
|
||||
<div><strong>27</strong><span>今日运行</span></div>
|
||||
<div><strong>96.4%</strong><span>成功率</span></div>
|
||||
<div><strong>1.28s</strong><span>P50 延迟</span></div>
|
||||
<div><strong>¥4.82</strong><span>预估成本</span></div>
|
||||
<div><strong>{{ metrics?.todayRunCount ?? 0 }}</strong><span>今日运行</span></div>
|
||||
<div><strong>{{ metrics?.successRate ?? 0 }}%</strong><span>成功率</span></div>
|
||||
<div><strong>{{ metrics?.p50Latency || '-' }}</strong><span>P50 延迟</span></div>
|
||||
<div><strong>{{ metrics?.estimatedCost || '-' }}</strong><span>预估成本</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -73,7 +113,12 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
|
||||
<div class="run-row run-head">
|
||||
<span>名称</span><span>类型</span><span>状态</span><span>延迟</span><span>成本</span>
|
||||
</div>
|
||||
<div v-for="run in recentRuns" :key="run.id" class="run-row">
|
||||
<div
|
||||
v-for="run in dashboard?.recentRuns ?? []"
|
||||
:key="run.id"
|
||||
class="run-row"
|
||||
:data-test="`dashboard-run-${run.id}`"
|
||||
>
|
||||
<strong>{{ run.name }}</strong>
|
||||
<span>{{ run.type }}</span>
|
||||
<span class="status-cell">
|
||||
@@ -90,8 +135,8 @@ import { lifecycleSteps, readinessChecklist, recentRuns } from '@/data/studioMoc
|
||||
<section class="studio-panel warning-panel">
|
||||
<el-icon><Warning /></el-icon>
|
||||
<div>
|
||||
<h2>生产发布前仍需确认路由兜底</h2>
|
||||
<p>AGENT_PLAN 任务当前只有草稿路由,建议补齐 fallback 模型和最大延迟阈值。</p>
|
||||
<h2>{{ dashboard?.warningTitle || '生产发布前仍需确认路由兜底' }}</h2>
|
||||
<p>{{ dashboard?.warningMessage || 'AGENT_PLAN 任务当前只有草稿路由,建议补齐 fallback 模型和最大延迟阈值。' }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,27 +1,83 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Connection, Cpu, VideoPlay } from '@element-plus/icons-vue';
|
||||
|
||||
import { traceSteps, workflowEdges, workflowNodes } from '@/data/studioMock';
|
||||
import type { WorkflowWorkspace } from '@/api/workflow';
|
||||
import { getWorkflowWorkspace } from '@/api/workflow';
|
||||
|
||||
const nodeById = Object.fromEntries(workflowNodes.map((node) => [node.id, node]));
|
||||
const canvasEdges = workflowEdges.flatMap((edge) => {
|
||||
const from = nodeById[edge.from];
|
||||
const to = nodeById[edge.to];
|
||||
const loading = ref(false);
|
||||
const projectId = ref('101');
|
||||
const workflowId = ref('201');
|
||||
const workspace = ref<WorkflowWorkspace | null>(null);
|
||||
|
||||
if (!from || !to) {
|
||||
const nodeLibrary = ['Start', 'LLM', 'Knowledge Retrieval', 'MCP Tool', 'Skill', 'Condition', 'Answer'];
|
||||
|
||||
const workflowNodes = computed(() => {
|
||||
const graphJson = workspace.value?.versions?.[0]?.graphJson;
|
||||
if (!graphJson) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(graphJson) as { nodes?: Array<Record<string, unknown>> };
|
||||
return (parsed.nodes ?? []).map((node, index) => ({
|
||||
id: String(node.id ?? `node-${index}`),
|
||||
type: String(node.type ?? 'NODE'),
|
||||
label: String(node.label ?? node.id ?? `Node ${index + 1}`),
|
||||
description: String(node.description ?? node.prompt ?? ''),
|
||||
x: Number(node.x ?? 10 + index * 18),
|
||||
y: Number(node.y ?? 42),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
id: `${edge.from}-${edge.to}`,
|
||||
const workflowEdges = computed(() => {
|
||||
const graphJson = workspace.value?.versions?.[0]?.graphJson;
|
||||
if (!graphJson) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(graphJson) as { edges?: Array<Record<string, unknown>> };
|
||||
return (parsed.edges ?? []).map((edge, index) => ({
|
||||
id: `edge-${index}`,
|
||||
from: String(edge.from ?? edge.source ?? ''),
|
||||
to: String(edge.to ?? edge.target ?? ''),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const canvasEdges = computed(() => {
|
||||
const nodeById = Object.fromEntries(workflowNodes.value.map((node) => [node.id, node]));
|
||||
return workflowEdges.value.flatMap((edge) => {
|
||||
const from = nodeById[edge.from];
|
||||
const to = nodeById[edge.to];
|
||||
if (!from || !to) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
id: edge.id,
|
||||
x1: from.x + 5,
|
||||
y1: from.y + 4,
|
||||
x2: to.x,
|
||||
y2: to.y + 4,
|
||||
},
|
||||
];
|
||||
}];
|
||||
});
|
||||
});
|
||||
|
||||
async function loadWorkspace() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await getWorkflowWorkspace(projectId.value, workflowId.value);
|
||||
workspace.value = response.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadWorkspace);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -37,26 +93,20 @@ const canvasEdges = workflowEdges.flatMap((edge) => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workflow-layout">
|
||||
<div class="workflow-layout" v-loading="loading">
|
||||
<aside class="studio-panel node-library">
|
||||
<div class="panel-heading compact">
|
||||
<h2>节点库</h2>
|
||||
<span>JSON Graph</span>
|
||||
</div>
|
||||
<button>Start</button>
|
||||
<button>LLM</button>
|
||||
<button>Knowledge Retrieval</button>
|
||||
<button>MCP Tool</button>
|
||||
<button>Skill</button>
|
||||
<button>Condition</button>
|
||||
<button>Answer</button>
|
||||
<button v-for="node in nodeLibrary" :key="node">{{ node }}</button>
|
||||
</aside>
|
||||
|
||||
<main class="studio-panel workflow-canvas">
|
||||
<div class="canvas-toolbar">
|
||||
<span><el-icon><Connection /></el-icon> workflow-support-rag</span>
|
||||
<span>版本快照 v7</span>
|
||||
<span>环境: Dev</span>
|
||||
<span><el-icon><Connection /></el-icon> {{ workspace?.workflowCode || 'workflow' }}</span>
|
||||
<span>版本快照 v{{ workspace?.currentPublishedVersionNo || workspace?.versions?.[0]?.versionNo || '-' }}</span>
|
||||
<span>环境: {{ workspace?.environment || 'Dev' }}</span>
|
||||
</div>
|
||||
<div class="canvas-surface">
|
||||
<svg class="edge-layer" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
@@ -73,8 +123,9 @@ const canvasEdges = workflowEdges.flatMap((edge) => {
|
||||
v-for="node in workflowNodes"
|
||||
:key="node.id"
|
||||
class="workflow-node"
|
||||
:class="{ selected: node.id === 'llm' }"
|
||||
:class="{ selected: node.type === 'LLM' }"
|
||||
:style="{ left: `${node.x}%`, top: `${node.y}%` }"
|
||||
:data-test="`workflow-node-${node.id}`"
|
||||
>
|
||||
<span>{{ node.type }}</span>
|
||||
<strong>{{ node.label }}</strong>
|
||||
@@ -83,9 +134,9 @@ const canvasEdges = workflowEdges.flatMap((edge) => {
|
||||
</div>
|
||||
<div class="run-trace-drawer">
|
||||
<strong>Run Trace</strong>
|
||||
<div v-for="step in traceSteps" :key="step.node">
|
||||
<span>{{ step.node }}</span>
|
||||
<em>{{ step.status }} · {{ step.duration }} · {{ step.output }}</em>
|
||||
<div v-for="run in workspace?.recentRuns ?? []" :key="run.requestId" :data-test="`workflow-run-${run.requestId}`">
|
||||
<span>{{ run.requestId }}</span>
|
||||
<em>{{ run.status }} · {{ run.durationMs || 0 }}ms · {{ run.outputJson || '-' }}</em>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -93,17 +144,17 @@ const canvasEdges = workflowEdges.flatMap((edge) => {
|
||||
<aside class="studio-panel inspector-panel">
|
||||
<div class="panel-heading compact">
|
||||
<h2>节点 Inspector</h2>
|
||||
<span>LLM</span>
|
||||
<span>{{ workflowNodes[0]?.type || 'LLM' }}</span>
|
||||
</div>
|
||||
<dl class="inspector-list">
|
||||
<dt>任务类型</dt>
|
||||
<dd>RAG_ANSWER</dd>
|
||||
<dt>输入 Schema</dt>
|
||||
<dd>question, retrieved_chunks, conversation</dd>
|
||||
<dt>输出 Schema</dt>
|
||||
<dd>answer, citations, safety_flags</dd>
|
||||
<dt>路由策略</dt>
|
||||
<dd>primary qwen-plus / fallback deepseek-v3</dd>
|
||||
<dt>工作流</dt>
|
||||
<dd>{{ workspace?.workflowName || '-' }}</dd>
|
||||
<dt>发布状态</dt>
|
||||
<dd>{{ workspace?.publishStatus || '-' }}</dd>
|
||||
<dt>当前版本</dt>
|
||||
<dd>v{{ workspace?.currentPublishedVersionNo || workspace?.versions?.[0]?.versionNo || '-' }}</dd>
|
||||
<dt>最近请求</dt>
|
||||
<dd>{{ workspace?.latestRequestId || '-' }}</dd>
|
||||
</dl>
|
||||
<button class="blue-command"><el-icon><Cpu /></el-icon> 打开模型路由</button>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import AgentWorkspacePage from '../AgentWorkspacePage.vue';
|
||||
import { appendAgentMessage, getAgentWorkspace } from '@/api/agent';
|
||||
|
||||
vi.mock('@/api/agent', () => ({
|
||||
getAgentWorkspace: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
agentId: '1001',
|
||||
agentCode: 'sales_agent',
|
||||
agentName: '售前问答 Agent',
|
||||
storeId: '2001',
|
||||
status: 'DRAFT',
|
||||
sessionId: '3001',
|
||||
sessionCode: 'session-001',
|
||||
sessionStatus: 'ACTIVE',
|
||||
totalTokens: 1248,
|
||||
citationCount: 1,
|
||||
latestRequestId: 'req-1001',
|
||||
sessions: [],
|
||||
messages: [
|
||||
{
|
||||
id: '1',
|
||||
sessionId: '3001',
|
||||
role: 'user',
|
||||
content: '如果客户要求私有化部署,需要说明哪些内容?',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
sessionId: '3001',
|
||||
role: 'assistant',
|
||||
content: '建议说明部署拓扑、权限边界和日志留存策略。',
|
||||
citationJson: '[{"chunkId":"c-1"}]',
|
||||
tokenCount: 612,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
appendAgentMessage: vi.fn(() => Promise.resolve({ resultcode: '0', message: null, data: true })),
|
||||
listAgents: vi.fn(),
|
||||
queryAgents: vi.fn(),
|
||||
getAgentById: vi.fn(),
|
||||
saveAgent: vi.fn(),
|
||||
deleteAgent: vi.fn(),
|
||||
chatWithAgent: vi.fn(),
|
||||
createAgentSession: vi.fn(),
|
||||
getAgentSessionById: vi.fn(),
|
||||
listAgentMessages: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('AgentWorkspacePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads workspace messages and citations from backend api', async () => {
|
||||
const wrapper = mount(AgentWorkspacePage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(getAgentWorkspace).toHaveBeenCalledWith('1001');
|
||||
expect(wrapper.text()).toContain('售前问答 Agent');
|
||||
expect(wrapper.text()).toContain('建议说明部署拓扑');
|
||||
expect(wrapper.findAll('[data-test="agent-citation-card"]').length).toBe(1);
|
||||
});
|
||||
|
||||
it('appends debug message through backend api', async () => {
|
||||
const wrapper = mount(AgentWorkspacePage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
await wrapper.get('[data-test="agent-message-input"]').setValue('补充说明日志留存周期');
|
||||
await wrapper.get('[data-test="agent-send-message"]').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(appendAgentMessage).toHaveBeenCalledWith('3001', {
|
||||
role: 'user',
|
||||
content: '补充说明日志留存周期',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import IngestionPipelinePage from '../IngestionPipelinePage.vue';
|
||||
import { getIngestionRun } from '@/api/ingestion';
|
||||
|
||||
vi.mock('@/api/ingestion', () => ({
|
||||
getIngestionRun: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
runId: 'run-20260601',
|
||||
storeId: '1001',
|
||||
documentId: '11',
|
||||
storeName: '产品制度库',
|
||||
files: [
|
||||
{
|
||||
documentId: '11',
|
||||
fileName: '售前方案模板.pdf',
|
||||
parseStatus: 'PARSED',
|
||||
indexStatus: 'INDEXED',
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
{ name: '上传', description: '文件已入库并创建 rag_document', status: 'done' },
|
||||
{ name: '解析', description: '解析完成,文本长度 1280,页数 12', status: 'done' },
|
||||
{ name: '切片', description: '已生成 24 个切片,chunk_size=800,overlap=120', status: 'running' },
|
||||
],
|
||||
parsedTextPreview: '私有化部署章节应覆盖基础设施、网络、安全与运维边界。',
|
||||
chunkPreview: 'chunk_size=800, overlap=120, strategy=1。预览:该切片将进入 rag_chunk 并在向量化后写入 rag_chunk_embedding。',
|
||||
chunkStrategy: 1,
|
||||
chunkSize: 800,
|
||||
chunkOverlap: 120,
|
||||
embeddingModelId: '88',
|
||||
embeddingDimension: 1024,
|
||||
logs: [
|
||||
{ time: '23:08:12', level: 'INFO', message: '上传文件并创建 rag_document 记录' },
|
||||
{ time: '23:08:24', level: 'INFO', message: '解析完成,文本长度 1280,页数 12' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
describe('IngestionPipelinePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads ingestion aggregate from backend api', async () => {
|
||||
const wrapper = mount(IngestionPipelinePage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(getIngestionRun).toHaveBeenCalledWith('run-20260601', '1001', '11');
|
||||
expect(wrapper.text()).toContain('售前方案模板.pdf');
|
||||
expect(wrapper.text()).toContain('私有化部署章节应覆盖基础设施');
|
||||
expect(wrapper.text()).toContain('上传文件并创建 rag_document 记录');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import KnowledgeWorkspacePage from '../KnowledgeWorkspacePage.vue';
|
||||
import { getKnowledgeWorkspace } from '@/api/knowledgeWorkspace';
|
||||
|
||||
vi.mock('@/api/knowledgeWorkspace', () => ({
|
||||
getKnowledgeWorkspace: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
storeId: '1001',
|
||||
storeCode: 'PROD_DOC',
|
||||
storeName: '产品制度库',
|
||||
status: 'ENABLED',
|
||||
documentCount: 9,
|
||||
parsedDocumentCount: 6,
|
||||
parseFailedDocumentCount: 1,
|
||||
indexedDocumentCount: 5,
|
||||
pendingTaskCount: 2,
|
||||
healthScore: 96,
|
||||
embeddingModelId: '88',
|
||||
embeddingDimension: 1024,
|
||||
chunkStrategy: 1,
|
||||
chunkSize: 800,
|
||||
indexVersion: 14,
|
||||
publishImpact: '更新后需要 Workflow 重新验证引用质量',
|
||||
documents: [
|
||||
{
|
||||
documentId: '11',
|
||||
documentTitle: '售前方案模板.pdf',
|
||||
parseStatus: 'PARSED',
|
||||
indexStatus: 'INDEXED',
|
||||
enabled: true,
|
||||
updateTime: '2026-06-01 10:00:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
describe('KnowledgeWorkspacePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads knowledge workspace aggregate from backend api', async () => {
|
||||
const wrapper = mount(KnowledgeWorkspacePage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(getKnowledgeWorkspace).toHaveBeenCalledWith('1001');
|
||||
expect(wrapper.text()).toContain('产品制度库');
|
||||
expect(wrapper.text()).toContain('96%');
|
||||
expect(wrapper.find('[data-test="knowledge-document-11"]').text()).toContain('售前方案模板.pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import ModelWorkspacePage from '../ModelWorkspacePage.vue';
|
||||
import { getModelWorkspace } from '@/api/modelWorkspace';
|
||||
|
||||
vi.mock('@/api/modelWorkspace', () => ({
|
||||
getModelWorkspace: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
providerCount: 3,
|
||||
healthyProviderCount: 2,
|
||||
unhealthyProviderCount: 1,
|
||||
modelCount: 5,
|
||||
enabledModelCount: 4,
|
||||
routeRuleCount: 2,
|
||||
enabledRouteRuleCount: 1,
|
||||
recentFailedCallCount: 2,
|
||||
providers: [],
|
||||
models: [],
|
||||
routes: [
|
||||
{
|
||||
id: '11',
|
||||
taskType: 'RAG_ANSWER',
|
||||
primaryModelCode: 'qwen-plus',
|
||||
fallbackModelCode: 'deepseek-v3',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
failedCallSummaries: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
describe('ModelWorkspacePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads model workspace aggregate from backend api', async () => {
|
||||
const wrapper = mount(ModelWorkspacePage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(getModelWorkspace).toHaveBeenCalled();
|
||||
expect(wrapper.text()).toContain('3');
|
||||
expect(wrapper.find('[data-test="model-route-RAG_ANSWER"]').text()).toContain('qwen-plus');
|
||||
expect(wrapper.find('[data-test="model-route-RAG_ANSWER"]').text()).toContain('deepseek-v3');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import StudioDashboardPage from '../StudioDashboardPage.vue';
|
||||
import { getStudioDashboard } from '@/api/studioDashboard';
|
||||
|
||||
vi.mock('@/api/studioDashboard', () => ({
|
||||
getStudioDashboard: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
projectName: 'Common Agent Studio',
|
||||
environment: 'Dev',
|
||||
publishStatus: 'DRAFT',
|
||||
lifecycleSteps: [
|
||||
{ name: '知识接入', description: '上传、解析、切片、向量化', status: 'done' },
|
||||
{ name: '能力编排', description: 'Workflow 连接模型、工具与 Skill', status: 'running' },
|
||||
],
|
||||
readinessChecklist: [
|
||||
{ label: '知识库已绑定 Embedding 模型', done: true },
|
||||
{ label: 'Workflow 已存在可编辑草稿', done: false },
|
||||
],
|
||||
metrics: {
|
||||
todayRunCount: 27,
|
||||
successRate: 96.4,
|
||||
p50Latency: '1.28s',
|
||||
estimatedCost: '¥4.82',
|
||||
},
|
||||
recentRuns: [
|
||||
{ id: 'req-1001', name: '售前问答 Agent', type: 'Agent', status: '成功', latency: '1.42s', cost: '¥0.018' },
|
||||
],
|
||||
warningTitle: '生产发布前仍需确认路由兜底',
|
||||
warningMessage: 'AGENT_PLAN 任务建议补齐 fallback 模型和最大延迟阈值后再发布。',
|
||||
},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
describe('StudioDashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads dashboard aggregate from backend api', async () => {
|
||||
const wrapper = mount(StudioDashboardPage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(getStudioDashboard).toHaveBeenCalled();
|
||||
expect(wrapper.text()).toContain('Common Agent Studio');
|
||||
expect(wrapper.text()).toContain('96.4%');
|
||||
expect(wrapper.text()).toContain('售前问答 Agent');
|
||||
expect(wrapper.text()).toContain('AGENT_PLAN 任务建议补齐 fallback 模型');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import ElementPlus from 'element-plus';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import WorkflowBuilderPage from '../WorkflowBuilderPage.vue';
|
||||
import { getWorkflowWorkspace } from '@/api/workflow';
|
||||
|
||||
vi.mock('@/api/workflow', () => ({
|
||||
getWorkflowWorkspace: vi.fn(() =>
|
||||
Promise.resolve({
|
||||
resultcode: '0',
|
||||
message: null,
|
||||
data: {
|
||||
projectId: '101',
|
||||
projectCode: 'studio_demo',
|
||||
projectName: '演示项目',
|
||||
environment: 'DEV',
|
||||
publishStatus: 'PUBLISHED',
|
||||
workflowId: '201',
|
||||
workflowCode: 'workflow-support-rag',
|
||||
workflowName: '知识问答流程',
|
||||
workflowStatus: 'ENABLED',
|
||||
currentPublishedVersionNo: 7,
|
||||
latestRequestId: 'req-2001',
|
||||
latestDurationMs: 860,
|
||||
workflows: [],
|
||||
versions: [
|
||||
{
|
||||
id: '301',
|
||||
workflowId: '201',
|
||||
versionNo: 7,
|
||||
snapshotName: '知识问答发布版',
|
||||
graphJson:
|
||||
'{"nodes":[{"id":"start","type":"START","label":"Start","x":4,"y":42},{"id":"llm","type":"LLM","label":"LLM","x":47,"y":42}],"edges":[{"from":"start","to":"llm"}]}',
|
||||
publishStatus: 'PUBLISHED',
|
||||
},
|
||||
],
|
||||
recentRuns: [
|
||||
{
|
||||
id: '401',
|
||||
workflowId: '201',
|
||||
workflowVersionId: '301',
|
||||
requestId: 'req-2001',
|
||||
status: 'SUCCESS',
|
||||
durationMs: 860,
|
||||
outputJson: '{"answer":"done"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
),
|
||||
listWorkflowDefinitions: vi.fn(),
|
||||
getWorkflowDefinition: vi.fn(),
|
||||
saveWorkflowDefinition: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('WorkflowBuilderPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('loads workflow workspace and renders graph nodes from backend api', async () => {
|
||||
const wrapper = mount(WorkflowBuilderPage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(getWorkflowWorkspace).toHaveBeenCalledWith('101', '201');
|
||||
expect(wrapper.text()).toContain('workflow-support-rag');
|
||||
expect(wrapper.find('[data-test="workflow-node-start"]').text()).toContain('Start');
|
||||
expect(wrapper.find('[data-test="workflow-node-llm"]').text()).toContain('LLM');
|
||||
});
|
||||
|
||||
it('renders recent run trace from workspace aggregate', async () => {
|
||||
const wrapper = mount(WorkflowBuilderPage, {
|
||||
global: {
|
||||
plugins: [ElementPlus],
|
||||
},
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(wrapper.find('[data-test="workflow-run-req-2001"]').text()).toContain('SUCCESS');
|
||||
expect(wrapper.find('[data-test="workflow-run-req-2001"]').text()).toContain('860ms');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user