Compare commits

...

10 Commits

72 changed files with 4213 additions and 137 deletions

View File

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

View File

@@ -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));
}
}

View File

@@ -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());

View File

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

View File

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

View File

@@ -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());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);

View File

@@ -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());
}
}

View File

@@ -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));
}
}

View File

@@ -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"));
}
}

View File

@@ -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;
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);
}
}

View File

@@ -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切片向量服务初始化完成");
}
}

View File

@@ -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切片服务初始化完成");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<>();
}

View File

@@ -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"));
}
}

View File

@@ -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));
}
}

View File

@@ -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"));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -12,4 +12,6 @@ public interface IWorkflowRunService extends IService<WorkflowRun> {
boolean createRun(WorkflowRunCreateDTO request);
List<WorkflowRunVO> listRecentByWorkflowId(Long workflowId);
WorkflowRunVO getRunById(Long runId);
}

View File

@@ -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运行请求不能为空");

View File

@@ -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);
}
}

View File

@@ -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"));
}
}

View File

@@ -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("合同知识召回"));
}
}

View 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 九个工作台页面均已对接真实 APIAPI 层与页面单测已覆盖主要聚合接口。
- 旧管理接口继续兼容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 的文档草案兼容路径与控制器测试。

View File

@@ -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);

View 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',
},
});
});
});

View 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');
});
});

View 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,
},
});
}

View File

@@ -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;

View 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');
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}`">

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 驱动原型知识资产WorkflowMCPSkillAgent 调试与观测都围绕一次发布旅程组织
@@ -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>

View File

@@ -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>

View File

@@ -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: '补充说明日志留存周期',
});
});
});

View File

@@ -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=800overlap=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 记录');
});
});

View File

@@ -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');
});
});

View File

@@ -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');
});
});

View File

@@ -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 模型');
});
});

View File

@@ -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');
});
});