From 3506c55bc8cdc6caa4171080544c6c0ccc298bb0 Mon Sep 17 00:00:00 2001 From: matevip Date: Thu, 9 Apr 2026 11:58:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20Phase=203=20Sprint=201-3=20?= =?UTF-8?q?=E2=80=94=20permission,=20agent=20binding,=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vip/mate/agent/AgentGraphBuilder.java | 11 +- .../java/vip/mate/agent/AgentService.java | 7 + .../java/vip/mate/agent/AgentToolSet.java | 14 ++ .../controller/AgentBindingController.java | 90 ++++++++ .../binding/model/AgentSkillBinding.java | 22 ++ .../agent/binding/model/AgentToolBinding.java | 21 ++ .../repository/AgentSkillBindingMapper.java | 9 + .../repository/AgentToolBindingMapper.java | 9 + .../binding/service/AgentBindingService.java | 170 ++++++++++++++ .../agent/controller/AgentController.java | 18 +- .../controller/AuditEventController.java | 42 ++++ .../mate/audit/model/AuditEventEntity.java | 45 ++++ .../audit/repository/AuditEventMapper.java | 9 + .../mate/audit/service/AuditEventService.java | 162 +++++++++++++ .../channel/controller/ChannelController.java | 19 +- .../java/vip/mate/config/WebMvcConfig.java | 13 +- .../config/WorkspaceAccessInterceptor.java | 103 +++++++++ .../controller/DashboardController.java | 62 +++++ .../dashboard/model/CronJobRunEntity.java | 24 ++ .../dashboard/model/UsageDailyEntity.java | 25 ++ .../repository/CronJobRunMapper.java | 9 + .../repository/UsageDailyMapper.java | 9 + .../dashboard/service/CronJobRunService.java | 83 +++++++ .../dashboard/service/DashboardService.java | 120 ++++++++++ .../skill/runtime/SkillRuntimeService.java | 29 ++- .../mate/wiki/controller/WikiController.java | 23 ++ .../core/annotation/RequireWorkspaceRole.java | 24 ++ .../core/service/WorkspaceService.java | 34 +++ .../src/main/resources/db/schema-mysql.sql | 81 +++++++ .../src/main/resources/db/schema.sql | 84 +++++++ mateclaw-ui/src/api/index.ts | 31 +++ mateclaw-ui/src/i18n/locales/en-US.ts | 42 ++++ mateclaw-ui/src/i18n/locales/zh-CN.ts | 42 ++++ mateclaw-ui/src/router/index.ts | 12 + mateclaw-ui/src/views/Agents.vue | 152 ++++++++++++- mateclaw-ui/src/views/Dashboard.vue | 215 ++++++++++++++++++ .../src/views/Security/Activity/index.vue | 194 ++++++++++++++++ mateclaw-ui/src/views/Security/Layout.vue | 6 + mateclaw-ui/src/views/layout/MainLayout.vue | 5 + 39 files changed, 2058 insertions(+), 12 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentSkillBinding.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentToolBinding.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentSkillBindingMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentToolBindingMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/audit/controller/AuditEventController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/audit/model/AuditEventEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/audit/repository/AuditEventMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/config/WorkspaceAccessInterceptor.java create mode 100644 mateclaw-server/src/main/java/vip/mate/dashboard/controller/DashboardController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/dashboard/model/CronJobRunEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/dashboard/model/UsageDailyEntity.java create mode 100644 mateclaw-server/src/main/java/vip/mate/dashboard/repository/CronJobRunMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/dashboard/repository/UsageDailyMapper.java create mode 100644 mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/dashboard/service/DashboardService.java create mode 100644 mateclaw-server/src/main/java/vip/mate/workspace/core/annotation/RequireWorkspaceRole.java create mode 100644 mateclaw-ui/src/views/Dashboard.vue create mode 100644 mateclaw-ui/src/views/Security/Activity/index.vue diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index 491eb05f..35519688 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -51,6 +51,7 @@ import vip.mate.agent.graph.plan.edge.StepProgressDispatcher; import vip.mate.agent.graph.plan.node.*; import vip.mate.agent.graph.plan.state.PlanStateKeys; import vip.mate.agent.graph.state.MateClawStateKeys; +import vip.mate.agent.binding.service.AgentBindingService; import vip.mate.agent.model.AgentEntity; import vip.mate.config.GraphObservationProperties; import vip.mate.exception.MateClawException; @@ -92,6 +93,7 @@ import java.util.Set; public class AgentGraphBuilder { private final ToolRegistry toolRegistry; + private final AgentBindingService agentBindingService; private final SkillService skillService; private final vip.mate.skill.runtime.SkillRuntimeService skillRuntimeService; private final ConversationService conversationService; @@ -126,6 +128,10 @@ public class AgentGraphBuilder { // 过滤掉 denied 工具,使模型完全看不到它们(防止 prompt injection 利用 schema) toolSet = toolSet.withDeniedToolsFiltered(toolGuardConfigService.getDeniedTools()); + // Per-agent tool 绑定过滤:如果 agent 有自定义 tool 绑定,则只保留绑定的工具 + Set boundTools = agentBindingService.getBoundToolNames(entity.getId()); + toolSet = toolSet.withAllowedToolsOnly(boundTools); // null = 全局默认 + // 统一使用全局默认模型(AgentEntity.modelName 为历史残留字段,不参与运行时选择) ModelConfigEntity runtimeModel; try { @@ -533,8 +539,9 @@ public class AgentGraphBuilder { ? workspacePrompt : (entity.getSystemPrompt() != null ? entity.getSystemPrompt() : ""); - // 使用 skill runtime 构建技能增强(分层注入,不再全量拼接) - String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(); + // 使用 skill runtime 构建技能增强(per-agent 绑定过滤) + Set boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId()); + String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement(boundSkillIds); // 工具调用指导 String toolGuidance = """ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 7a60ac14..5f05ad25 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -81,6 +81,13 @@ public class AgentService { agentInstances.remove(id); } + /** + * 清除 Agent 运行时缓存(绑定变更后需调用,使下次对话重新构建 Agent) + */ + public void invalidateAgentCache(Long agentId) { + agentInstances.remove(agentId); + } + // ==================== 运行时入口 ==================== public String chat(Long agentId, String message, String conversationId) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java index 71d06c02..89a12a9f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentToolSet.java @@ -78,6 +78,20 @@ public class AgentToolSet { return new AgentToolSet(toolBeans, filtered); } + /** + * 仅保留指定名称的工具(白名单模式,用于 per-agent 绑定) + * + * @param allowedTools 允许的工具名集合(为 null 时直接返回 this,表示使用全局默认) + */ + public AgentToolSet withAllowedToolsOnly(Set allowedTools) { + if (allowedTools == null) { + return this; // null = 无绑定,使用全局默认 + } + List filtered = new ArrayList<>(callbacks); + filtered.removeIf(cb -> !allowedTools.contains(cb.getToolDefinition().name())); + return new AgentToolSet(toolBeans, filtered); + } + /** * 获取所有 ToolCallback */ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java new file mode 100644 index 00000000..b562ce33 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/controller/AgentBindingController.java @@ -0,0 +1,90 @@ +package vip.mate.agent.binding.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.agent.AgentService; +import vip.mate.agent.binding.model.AgentSkillBinding; +import vip.mate.agent.binding.model.AgentToolBinding; +import vip.mate.agent.binding.service.AgentBindingService; +import vip.mate.audit.service.AuditEventService; +import vip.mate.common.result.R; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; + +import java.util.List; +import java.util.Map; + +/** + * Agent 能力绑定接口 + * + * @author MateClaw Team + */ +@Tag(name = "Agent能力绑定") +@RestController +@RequestMapping("/api/v1/agents/{agentId}") +@RequiredArgsConstructor +public class AgentBindingController { + + private final AgentBindingService bindingService; + private final AgentService agentService; + private final AuditEventService auditEventService; + + // ==================== Skill Bindings ==================== + + @Operation(summary = "获取 Agent 已绑定的 Skills") + @GetMapping("/skills") + @RequireWorkspaceRole("viewer") + public R> listSkills(@PathVariable Long agentId) { + return R.ok(bindingService.listSkillBindings(agentId)); + } + + @Operation(summary = "批量设置 Agent 的 Skill 绑定") + @PutMapping("/skills") + @RequireWorkspaceRole("member") + public R setSkills(@PathVariable Long agentId, @RequestBody List skillIds) { + bindingService.setSkillBindings(agentId, skillIds); + agentService.invalidateAgentCache(agentId); + auditEventService.record("UPDATE", "AGENT_SKILL", String.valueOf(agentId), + "skills=" + skillIds.size(), null); + return R.ok(); + } + + @Operation(summary = "绑定单个 Skill") + @PostMapping("/skills/{skillId}") + @RequireWorkspaceRole("member") + public R bindSkill(@PathVariable Long agentId, @PathVariable Long skillId) { + AgentSkillBinding binding = bindingService.bindSkill(agentId, skillId); + agentService.invalidateAgentCache(agentId); + return R.ok(binding); + } + + @Operation(summary = "解绑单个 Skill") + @DeleteMapping("/skills/{skillId}") + @RequireWorkspaceRole("member") + public R unbindSkill(@PathVariable Long agentId, @PathVariable Long skillId) { + bindingService.unbindSkill(agentId, skillId); + agentService.invalidateAgentCache(agentId); + return R.ok(); + } + + // ==================== Tool Bindings ==================== + + @Operation(summary = "获取 Agent 已绑定的 Tools") + @GetMapping("/tools") + @RequireWorkspaceRole("viewer") + public R> listTools(@PathVariable Long agentId) { + return R.ok(bindingService.listToolBindings(agentId)); + } + + @Operation(summary = "批量设置 Agent 的 Tool 绑定") + @PutMapping("/tools") + @RequireWorkspaceRole("member") + public R setTools(@PathVariable Long agentId, @RequestBody List toolNames) { + bindingService.setToolBindings(agentId, toolNames); + agentService.invalidateAgentCache(agentId); + auditEventService.record("UPDATE", "AGENT_TOOL", String.valueOf(agentId), + "tools=" + toolNames.size(), null); + return R.ok(); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentSkillBinding.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentSkillBinding.java new file mode 100644 index 00000000..6170ebb4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentSkillBinding.java @@ -0,0 +1,22 @@ +package vip.mate.agent.binding.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("mate_agent_skill") +public class AgentSkillBinding { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long agentId; + private Long skillId; + private Boolean enabled; + private String configJson; + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentToolBinding.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentToolBinding.java new file mode 100644 index 00000000..055cf8e7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/model/AgentToolBinding.java @@ -0,0 +1,21 @@ +package vip.mate.agent.binding.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("mate_agent_tool") +public class AgentToolBinding { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long agentId; + private String toolName; + private Boolean enabled; + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + @TableLogic + private Integer deleted; +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentSkillBindingMapper.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentSkillBindingMapper.java new file mode 100644 index 00000000..9187b36b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentSkillBindingMapper.java @@ -0,0 +1,9 @@ +package vip.mate.agent.binding.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.agent.binding.model.AgentSkillBinding; + +@Mapper +public interface AgentSkillBindingMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentToolBindingMapper.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentToolBindingMapper.java new file mode 100644 index 00000000..620f5817 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/repository/AgentToolBindingMapper.java @@ -0,0 +1,9 @@ +package vip.mate.agent.binding.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.agent.binding.model.AgentToolBinding; + +@Mapper +public interface AgentToolBindingMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java new file mode 100644 index 00000000..d5fab91c --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -0,0 +1,170 @@ +package vip.mate.agent.binding.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.agent.binding.model.AgentSkillBinding; +import vip.mate.agent.binding.model.AgentToolBinding; +import vip.mate.agent.binding.repository.AgentSkillBindingMapper; +import vip.mate.agent.binding.repository.AgentToolBindingMapper; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Agent 能力绑定服务 + *

+ * 管理 Agent 与 Skill/Tool 的关联关系。 + * 当 Agent 没有任何绑定记录时,默认使用全局 enabled 的 tool/skill(向后兼容)。 + * 一旦有绑定记录,则严格按绑定列表过滤。 + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AgentBindingService { + + private final AgentSkillBindingMapper skillBindingMapper; + private final AgentToolBindingMapper toolBindingMapper; + + // ==================== Skill Bindings ==================== + + public List listSkillBindings(Long agentId) { + return skillBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(AgentSkillBinding::getAgentId, agentId) + .orderByAsc(AgentSkillBinding::getCreateTime)); + } + + /** + * 获取 Agent 绑定的 enabled skill ID 集合。 + * 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。 + */ + public Set getBoundSkillIds(Long agentId) { + List bindings = listSkillBindings(agentId); + if (bindings.isEmpty()) { + return null; // 无绑定 → 全局默认 + } + return bindings.stream() + .filter(b -> Boolean.TRUE.equals(b.getEnabled())) + .map(AgentSkillBinding::getSkillId) + .collect(Collectors.toSet()); + } + + public AgentSkillBinding bindSkill(Long agentId, Long skillId) { + // 检查是否已绑定 + AgentSkillBinding existing = skillBindingMapper.selectOne( + new LambdaQueryWrapper() + .eq(AgentSkillBinding::getAgentId, agentId) + .eq(AgentSkillBinding::getSkillId, skillId)); + if (existing != null) { + existing.setEnabled(true); + skillBindingMapper.updateById(existing); + return existing; + } + AgentSkillBinding binding = new AgentSkillBinding(); + binding.setAgentId(agentId); + binding.setSkillId(skillId); + binding.setEnabled(true); + skillBindingMapper.insert(binding); + return binding; + } + + public void unbindSkill(Long agentId, Long skillId) { + skillBindingMapper.delete( + new LambdaQueryWrapper() + .eq(AgentSkillBinding::getAgentId, agentId) + .eq(AgentSkillBinding::getSkillId, skillId)); + } + + /** + * 批量设置 Agent 的 skill 绑定(替换模式) + */ + public void setSkillBindings(Long agentId, List skillIds) { + // 删除旧绑定 + skillBindingMapper.delete( + new LambdaQueryWrapper() + .eq(AgentSkillBinding::getAgentId, agentId)); + // 创建新绑定 + if (skillIds != null) { + for (Long skillId : skillIds) { + AgentSkillBinding binding = new AgentSkillBinding(); + binding.setAgentId(agentId); + binding.setSkillId(skillId); + binding.setEnabled(true); + skillBindingMapper.insert(binding); + } + } + } + + // ==================== Tool Bindings ==================== + + public List listToolBindings(Long agentId) { + return toolBindingMapper.selectList( + new LambdaQueryWrapper() + .eq(AgentToolBinding::getAgentId, agentId) + .orderByAsc(AgentToolBinding::getCreateTime)); + } + + /** + * 获取 Agent 绑定的 enabled tool name 集合。 + * 返回 null 表示该 agent 没有自定义绑定(使用全局默认)。 + */ + public Set getBoundToolNames(Long agentId) { + List bindings = listToolBindings(agentId); + if (bindings.isEmpty()) { + return null; // 无绑定 → 全局默认 + } + return bindings.stream() + .filter(b -> Boolean.TRUE.equals(b.getEnabled())) + .map(AgentToolBinding::getToolName) + .collect(Collectors.toSet()); + } + + public AgentToolBinding bindTool(Long agentId, String toolName) { + AgentToolBinding existing = toolBindingMapper.selectOne( + new LambdaQueryWrapper() + .eq(AgentToolBinding::getAgentId, agentId) + .eq(AgentToolBinding::getToolName, toolName)); + if (existing != null) { + existing.setEnabled(true); + toolBindingMapper.updateById(existing); + return existing; + } + AgentToolBinding binding = new AgentToolBinding(); + binding.setAgentId(agentId); + binding.setToolName(toolName); + binding.setEnabled(true); + toolBindingMapper.insert(binding); + return binding; + } + + public void unbindTool(Long agentId, String toolName) { + toolBindingMapper.delete( + new LambdaQueryWrapper() + .eq(AgentToolBinding::getAgentId, agentId) + .eq(AgentToolBinding::getToolName, toolName)); + } + + /** + * 批量设置 Agent 的 tool 绑定(替换模式) + */ + public void setToolBindings(Long agentId, List toolNames) { + toolBindingMapper.delete( + new LambdaQueryWrapper() + .eq(AgentToolBinding::getAgentId, agentId)); + if (toolNames != null) { + for (String toolName : toolNames) { + AgentToolBinding binding = new AgentToolBinding(); + binding.setAgentId(agentId); + binding.setToolName(toolName); + binding.setEnabled(true); + toolBindingMapper.insert(binding); + } + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java index c8fd6bf5..c1f1c99f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/controller/AgentController.java @@ -9,7 +9,9 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import vip.mate.agent.AgentService; import vip.mate.agent.AgentState; import vip.mate.agent.model.AgentEntity; +import vip.mate.audit.service.AuditEventService; import vip.mate.common.result.R; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.io.IOException; import java.util.List; @@ -29,10 +31,12 @@ import java.util.concurrent.Executors; public class AgentController { private final AgentService agentService; + private final AuditEventService auditEventService; private final ExecutorService sseExecutor = Executors.newCachedThreadPool(); @Operation(summary = "获取Agent列表") @GetMapping + @RequireWorkspaceRole("viewer") public R> list( @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { if (workspaceId != null) { @@ -43,32 +47,42 @@ public class AgentController { @Operation(summary = "获取Agent详情") @GetMapping("/{id}") + @RequireWorkspaceRole("viewer") public R get(@PathVariable Long id) { return R.ok(agentService.getAgent(id)); } @Operation(summary = "创建Agent") @PostMapping + @RequireWorkspaceRole("member") public R create( @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, @RequestBody AgentEntity agent) { if (workspaceId != null) { agent.setWorkspaceId(workspaceId); } - return R.ok(agentService.createAgent(agent)); + AgentEntity created = agentService.createAgent(agent); + auditEventService.record("CREATE", "AGENT", String.valueOf(created.getId()), created.getName(), null); + return R.ok(created); } @Operation(summary = "更新Agent") @PutMapping("/{id}") + @RequireWorkspaceRole("member") public R update(@PathVariable Long id, @RequestBody AgentEntity agent) { agent.setId(id); - return R.ok(agentService.updateAgent(agent)); + AgentEntity updated = agentService.updateAgent(agent); + auditEventService.record("UPDATE", "AGENT", String.valueOf(id), updated.getName(), null); + return R.ok(updated); } @Operation(summary = "删除Agent") @DeleteMapping("/{id}") + @RequireWorkspaceRole("admin") public R delete(@PathVariable Long id) { + AgentEntity agent = agentService.getAgent(id); agentService.deleteAgent(id); + auditEventService.record("DELETE", "AGENT", String.valueOf(id), agent != null ? agent.getName() : null, null); return R.ok(); } diff --git a/mateclaw-server/src/main/java/vip/mate/audit/controller/AuditEventController.java b/mateclaw-server/src/main/java/vip/mate/audit/controller/AuditEventController.java new file mode 100644 index 00000000..c46a03bc --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/audit/controller/AuditEventController.java @@ -0,0 +1,42 @@ +package vip.mate.audit.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.web.bind.annotation.*; +import vip.mate.audit.model.AuditEventEntity; +import vip.mate.audit.service.AuditEventService; +import vip.mate.common.result.R; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; + +import java.time.LocalDateTime; + +/** + * 审计事件查询接口 + * + * @author MateClaw Team + */ +@Tag(name = "审计事件") +@RestController +@RequestMapping("/api/v1/audit") +@RequiredArgsConstructor +public class AuditEventController { + + private final AuditEventService auditEventService; + + @Operation(summary = "分页查询审计事件") + @GetMapping("/events") + @RequireWorkspaceRole("admin") + public R> listEvents( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @RequestParam(required = false) String action, + @RequestParam(required = false) String resourceType, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime startTime, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime endTime, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + return R.ok(auditEventService.listEvents(workspaceId, action, resourceType, startTime, endTime, page, size)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/audit/model/AuditEventEntity.java b/mateclaw-server/src/main/java/vip/mate/audit/model/AuditEventEntity.java new file mode 100644 index 00000000..3b951aeb --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/audit/model/AuditEventEntity.java @@ -0,0 +1,45 @@ +package vip.mate.audit.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 操作审计事件实体 + * + * @author MateClaw Team + */ +@Data +@TableName("mate_audit_event") +public class AuditEventEntity { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + private Long workspaceId; + + private Long userId; + + private String username; + + /** 操作类型:CREATE / UPDATE / DELETE / LOGIN / LOGOUT / ENABLE / DISABLE */ + private String action; + + /** 资源类型:AGENT / CHANNEL / SKILL / WIKI / NODE / MEMBER / WORKSPACE */ + private String resourceType; + + private String resourceId; + + private String resourceName; + + /** 变更详情 JSON */ + private String detailJson; + + private String ipAddress; + + private String userAgent; + + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/mateclaw-server/src/main/java/vip/mate/audit/repository/AuditEventMapper.java b/mateclaw-server/src/main/java/vip/mate/audit/repository/AuditEventMapper.java new file mode 100644 index 00000000..709b6f51 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/audit/repository/AuditEventMapper.java @@ -0,0 +1,9 @@ +package vip.mate.audit.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.audit.model.AuditEventEntity; + +@Mapper +public interface AuditEventMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java b/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java new file mode 100644 index 00000000..d0727828 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java @@ -0,0 +1,162 @@ +package vip.mate.audit.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import vip.mate.audit.model.AuditEventEntity; +import vip.mate.audit.repository.AuditEventMapper; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; + +import java.time.LocalDateTime; + +/** + * 操作审计服务 + *

+ * 异步记录用户对资源的 CRUD 操作,不阻塞业务请求。 + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuditEventService { + + private final AuditEventMapper auditEventMapper; + private final AuthService authService; + + /** + * 异步记录审计事件 + */ + @Async + public void record(String action, String resourceType, String resourceId, + String resourceName, String detailJson) { + try { + AuditEventEntity event = buildEvent(action, resourceType, resourceId, resourceName, detailJson); + if (event != null) { + auditEventMapper.insert(event); + } + } catch (Exception e) { + log.warn("Failed to record audit event: {}/{}/{}", action, resourceType, resourceId, e); + } + } + + /** + * 同步记录(用于必须确保落库的场景,如登录/登出) + */ + public void recordSync(String action, String resourceType, String resourceId, + String resourceName, String detailJson) { + AuditEventEntity event = buildEvent(action, resourceType, resourceId, resourceName, detailJson); + if (event != null) { + auditEventMapper.insert(event); + } + } + + /** + * 分页查询审计事件 + */ + public IPage listEvents(Long workspaceId, String action, String resourceType, + LocalDateTime startTime, LocalDateTime endTime, + int page, int size) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + if (workspaceId != null) { + wrapper.eq(AuditEventEntity::getWorkspaceId, workspaceId); + } + if (action != null && !action.isBlank()) { + wrapper.eq(AuditEventEntity::getAction, action); + } + if (resourceType != null && !resourceType.isBlank()) { + wrapper.eq(AuditEventEntity::getResourceType, resourceType); + } + if (startTime != null) { + wrapper.ge(AuditEventEntity::getCreateTime, startTime); + } + if (endTime != null) { + wrapper.le(AuditEventEntity::getCreateTime, endTime); + } + wrapper.orderByDesc(AuditEventEntity::getCreateTime); + return auditEventMapper.selectPage(new Page<>(page, size), wrapper); + } + + private AuditEventEntity buildEvent(String action, String resourceType, String resourceId, + String resourceName, String detailJson) { + AuditEventEntity event = new AuditEventEntity(); + event.setAction(action); + event.setResourceType(resourceType); + event.setResourceId(resourceId); + event.setResourceName(resourceName); + event.setDetailJson(detailJson); + event.setCreateTime(LocalDateTime.now()); + + // 从 SecurityContext 获取用户信息 + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) { + String username = auth.getName(); + event.setUsername(username); + UserEntity user = authService.findByUsername(username); + if (user != null) { + event.setUserId(user.getId()); + } else { + event.setUserId(0L); + } + } else { + event.setUsername("system"); + event.setUserId(0L); + } + + // 从 Request 获取 IP 和 User-Agent + try { + ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attrs != null) { + HttpServletRequest request = attrs.getRequest(); + event.setIpAddress(getClientIp(request)); + event.setUserAgent(truncate(request.getHeader("User-Agent"), 256)); + + // 从 header 获取 workspace ID + String wsHeader = request.getHeader("X-Workspace-Id"); + if (wsHeader != null && !wsHeader.isBlank()) { + try { + event.setWorkspaceId(Long.parseLong(wsHeader.trim())); + } catch (NumberFormatException ignored) { + event.setWorkspaceId(1L); + } + } else { + event.setWorkspaceId(1L); + } + } + } catch (Exception ignored) { + // 异步上下文可能无法获取 request + } + + return event; + } + + private String getClientIp(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isBlank() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (ip == null || ip.isBlank() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + // X-Forwarded-For 可能包含多个 IP,取第一个 + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return truncate(ip, 64); + } + + private String truncate(String s, int maxLen) { + if (s == null) return null; + return s.length() > maxLen ? s.substring(0, maxLen) : s; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java index 6f9a520b..c99ec140 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/controller/ChannelController.java @@ -7,7 +7,9 @@ import org.springframework.web.bind.annotation.*; import vip.mate.channel.ChannelManager; import vip.mate.channel.model.ChannelEntity; import vip.mate.channel.service.ChannelService; +import vip.mate.audit.service.AuditEventService; import vip.mate.common.result.R; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import java.util.List; import java.util.Map; @@ -28,7 +30,9 @@ public class ChannelController { private final ChannelService channelService; private final ChannelManager channelManager; + private final AuditEventService auditEventService; + @RequireWorkspaceRole("viewer") @Operation(summary = "获取渠道列表") @GetMapping public R> list( @@ -39,18 +43,21 @@ public class ChannelController { return R.ok(channelService.listChannels()); } + @RequireWorkspaceRole("viewer") @Operation(summary = "按类型获取渠道列表") @GetMapping("/type/{channelType}") public R> listByType(@PathVariable String channelType) { return R.ok(channelService.listChannelsByType(channelType)); } + @RequireWorkspaceRole("viewer") @Operation(summary = "获取渠道详情") @GetMapping("/{id}") public R get(@PathVariable Long id) { return R.ok(channelService.getChannel(id)); } + @RequireWorkspaceRole("admin") @Operation(summary = "创建渠道") @PostMapping public R create( @@ -59,9 +66,12 @@ public class ChannelController { if (workspaceId != null) { channel.setWorkspaceId(workspaceId); } - return R.ok(channelService.createChannel(channel)); + ChannelEntity created = channelService.createChannel(channel); + auditEventService.record("CREATE", "CHANNEL", String.valueOf(created.getId()), created.getName(), null); + return R.ok(created); } + @RequireWorkspaceRole("admin") @Operation(summary = "更新渠道") @PutMapping("/{id}") public R update(@PathVariable Long id, @RequestBody ChannelEntity channel) { @@ -69,18 +79,23 @@ public class ChannelController { ChannelEntity updated = channelService.updateChannel(channel); // 配置变更后热替换渠道(新 Adapter 就绪后才替换旧的,失败则保留旧的) channelManager.restartChannel(id); + auditEventService.record("UPDATE", "CHANNEL", String.valueOf(id), updated.getName(), null); return R.ok(updated); } + @RequireWorkspaceRole("admin") @Operation(summary = "删除渠道") @DeleteMapping("/{id}") public R delete(@PathVariable Long id) { // 先停止渠道再删除 + ChannelEntity channel = channelService.getChannel(id); channelManager.stopChannel(id); channelService.deleteChannel(id); + auditEventService.record("DELETE", "CHANNEL", String.valueOf(id), channel != null ? channel.getName() : null, null); return R.ok(); } + @RequireWorkspaceRole("admin") @Operation(summary = "启用/禁用渠道") @PutMapping("/{id}/toggle") public R toggle(@PathVariable Long id, @RequestParam boolean enabled) { @@ -91,9 +106,11 @@ public class ChannelController { } else { channelManager.stopChannel(id); } + auditEventService.record(enabled ? "ENABLE" : "DISABLE", "CHANNEL", String.valueOf(id), channel.getName(), null); return R.ok(channel); } + @RequireWorkspaceRole("viewer") @Operation(summary = "获取渠道运行状态") @GetMapping("/status") public R> status() { diff --git a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java index a92d2a55..f4f2a8fd 100644 --- a/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java +++ b/mateclaw-server/src/main/java/vip/mate/config/WebMvcConfig.java @@ -1,19 +1,30 @@ package vip.mate.config; +import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; /** - * Web MVC 配置(跨域等) + * Web MVC 配置(跨域、拦截器等) * * @author MateClaw Team */ @Configuration +@RequiredArgsConstructor @EnableConfigurationProperties({GraphObservationProperties.class, ConversationWindowProperties.class, ToolTimeoutProperties.class}) public class WebMvcConfig implements WebMvcConfigurer { + private final WorkspaceAccessInterceptor workspaceAccessInterceptor; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(workspaceAccessInterceptor) + .addPathPatterns("/api/**"); + } + @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") diff --git a/mateclaw-server/src/main/java/vip/mate/config/WorkspaceAccessInterceptor.java b/mateclaw-server/src/main/java/vip/mate/config/WorkspaceAccessInterceptor.java new file mode 100644 index 00000000..21881f5b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/config/WorkspaceAccessInterceptor.java @@ -0,0 +1,103 @@ +package vip.mate.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; +import vip.mate.workspace.core.service.WorkspaceService; + +/** + * Workspace 访问拦截器 + *

+ * 对标注了 {@link RequireWorkspaceRole} 的 Controller 方法,自动校验: + * 1. 当前用户已认证 + * 2. 请求中有 X-Workspace-Id header(否则使用默认 workspace=1) + * 3. 用户是该 workspace 的成员且角色 ≥ 注解要求的最低角色 + *

+ * 成员资格查询使用 Caffeine 缓存(60s TTL),避免每次请求查库。 + * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class WorkspaceAccessInterceptor implements HandlerInterceptor { + + private final WorkspaceService workspaceService; + private final AuthService authService; + + /** 默认 workspace ID(未传 header 时使用) */ + private static final long DEFAULT_WORKSPACE_ID = 1L; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + // 只拦截 Controller 方法 + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + // 检查方法是否标注了 @RequireWorkspaceRole + RequireWorkspaceRole annotation = handlerMethod.getMethodAnnotation(RequireWorkspaceRole.class); + if (annotation == null) { + return true; + } + + // 获取当前认证用户 + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth == null || !auth.isAuthenticated() || "anonymousUser".equals(auth.getPrincipal())) { + // 未认证的请求由 Spring Security 处理,这里不拦截 + return true; + } + + String username = auth.getName(); + UserEntity user = authService.findByUsername(username); + if (user == null) { + sendForbidden(response, "User not found"); + return false; + } + + // 系统管理员跳过 workspace 权限检查(全局 admin 角色) + if ("admin".equalsIgnoreCase(user.getRole())) { + return true; + } + + // 解析 workspace ID + long workspaceId = resolveWorkspaceId(request); + + // 检查成员资格 + 角色 + String minRole = annotation.value(); + if (!workspaceService.hasPermissionCached(workspaceId, user.getId(), minRole)) { + log.warn("Workspace access denied: user={}, workspaceId={}, requiredRole={}", username, workspaceId, minRole); + sendForbidden(response, "Workspace permission denied: requires " + minRole + " role"); + return false; + } + + return true; + } + + private long resolveWorkspaceId(HttpServletRequest request) { + String header = request.getHeader("X-Workspace-Id"); + if (header != null && !header.isBlank()) { + try { + return Long.parseLong(header.trim()); + } catch (NumberFormatException e) { + return DEFAULT_WORKSPACE_ID; + } + } + return DEFAULT_WORKSPACE_ID; + } + + private void sendForbidden(HttpServletResponse response, String message) throws Exception { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":403,\"msg\":\"" + message + "\",\"data\":null}"); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/controller/DashboardController.java b/mateclaw-server/src/main/java/vip/mate/dashboard/controller/DashboardController.java new file mode 100644 index 00000000..63abb670 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/controller/DashboardController.java @@ -0,0 +1,62 @@ +package vip.mate.dashboard.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import vip.mate.common.result.R; +import vip.mate.dashboard.model.CronJobRunEntity; +import vip.mate.dashboard.service.CronJobRunService; +import vip.mate.dashboard.service.DashboardService; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; + +import java.util.List; +import java.util.Map; + +/** + * Dashboard 统计接口 + * + * @author MateClaw Team + */ +@Tag(name = "Dashboard") +@RestController +@RequestMapping("/api/v1/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService dashboardService; + private final CronJobRunService cronJobRunService; + + @Operation(summary = "获取概览统计") + @GetMapping("/overview") + @RequireWorkspaceRole("viewer") + public R> overview( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return R.ok(dashboardService.getOverview(workspaceId)); + } + + @Operation(summary = "获取日用量趋势") + @GetMapping("/trend") + @RequireWorkspaceRole("viewer") + public R>> trend( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @RequestParam(defaultValue = "30") int days) { + return R.ok(dashboardService.getTrend(workspaceId, Math.min(days, 90))); + } + + @Operation(summary = "获取 CronJob 执行历史") + @GetMapping("/cron-runs/{cronJobId}") + @RequireWorkspaceRole("viewer") + public R> cronJobRuns( + @PathVariable Long cronJobId, + @RequestParam(defaultValue = "20") int limit) { + return R.ok(cronJobRunService.listByJobId(cronJobId, Math.min(limit, 100))); + } + + @Operation(summary = "获取最近执行记录") + @GetMapping("/cron-runs") + @RequireWorkspaceRole("viewer") + public R> recentRuns(@RequestParam(defaultValue = "20") int limit) { + return R.ok(cronJobRunService.listRecent(Math.min(limit, 100))); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/model/CronJobRunEntity.java b/mateclaw-server/src/main/java/vip/mate/dashboard/model/CronJobRunEntity.java new file mode 100644 index 00000000..fe5a8d3e --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/model/CronJobRunEntity.java @@ -0,0 +1,24 @@ +package vip.mate.dashboard.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@TableName("mate_cron_job_run") +public class CronJobRunEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long cronJobId; + private String conversationId; + /** running / completed / failed */ + private String status; + /** scheduled / manual */ + private String triggerType; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private String errorMessage; + private Integer tokenUsage; + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/model/UsageDailyEntity.java b/mateclaw-server/src/main/java/vip/mate/dashboard/model/UsageDailyEntity.java new file mode 100644 index 00000000..3127b71b --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/model/UsageDailyEntity.java @@ -0,0 +1,25 @@ +package vip.mate.dashboard.model; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Data +@TableName("mate_usage_daily") +public class UsageDailyEntity { + @TableId(type = IdType.ASSIGN_ID) + private Long id; + private Long workspaceId; + private Long agentId; + private LocalDate statDate; + private Integer conversationCount; + private Integer messageCount; + private Long totalTokens; + private Long promptTokens; + private Long completionTokens; + private Integer toolCallCount; + private Integer errorCount; + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; +} diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/repository/CronJobRunMapper.java b/mateclaw-server/src/main/java/vip/mate/dashboard/repository/CronJobRunMapper.java new file mode 100644 index 00000000..0b774f09 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/repository/CronJobRunMapper.java @@ -0,0 +1,9 @@ +package vip.mate.dashboard.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.dashboard.model.CronJobRunEntity; + +@Mapper +public interface CronJobRunMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/repository/UsageDailyMapper.java b/mateclaw-server/src/main/java/vip/mate/dashboard/repository/UsageDailyMapper.java new file mode 100644 index 00000000..61b4e5b8 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/repository/UsageDailyMapper.java @@ -0,0 +1,9 @@ +package vip.mate.dashboard.repository; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import vip.mate.dashboard.model.UsageDailyEntity; + +@Mapper +public interface UsageDailyMapper extends BaseMapper { +} diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java b/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java new file mode 100644 index 00000000..c0ac9ab1 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/service/CronJobRunService.java @@ -0,0 +1,83 @@ +package vip.mate.dashboard.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import vip.mate.dashboard.model.CronJobRunEntity; +import vip.mate.dashboard.repository.CronJobRunMapper; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * CronJob 执行历史服务 + * + * @author MateClaw Team + */ +@Service +@RequiredArgsConstructor +public class CronJobRunService { + + private final CronJobRunMapper runMapper; + + /** + * 记录一次执行开始 + */ + public CronJobRunEntity recordStart(Long cronJobId, String triggerType, String conversationId) { + CronJobRunEntity run = new CronJobRunEntity(); + run.setCronJobId(cronJobId); + run.setConversationId(conversationId); + run.setStatus("running"); + run.setTriggerType(triggerType); + run.setStartedAt(LocalDateTime.now()); + runMapper.insert(run); + return run; + } + + /** + * 记录执行完成 + */ + public void recordComplete(Long runId, Integer tokenUsage) { + CronJobRunEntity run = runMapper.selectById(runId); + if (run != null) { + run.setStatus("completed"); + run.setFinishedAt(LocalDateTime.now()); + run.setTokenUsage(tokenUsage); + runMapper.updateById(run); + } + } + + /** + * 记录执行失败 + */ + public void recordFailed(Long runId, String errorMessage) { + CronJobRunEntity run = runMapper.selectById(runId); + if (run != null) { + run.setStatus("failed"); + run.setFinishedAt(LocalDateTime.now()); + run.setErrorMessage(errorMessage); + runMapper.updateById(run); + } + } + + /** + * 查询某个 CronJob 的执行历史 + */ + public List listByJobId(Long cronJobId, int limit) { + return runMapper.selectList( + new LambdaQueryWrapper() + .eq(CronJobRunEntity::getCronJobId, cronJobId) + .orderByDesc(CronJobRunEntity::getStartedAt) + .last("LIMIT " + limit)); + } + + /** + * 查询最近的执行记录 + */ + public List listRecent(int limit) { + return runMapper.selectList( + new LambdaQueryWrapper() + .orderByDesc(CronJobRunEntity::getStartedAt) + .last("LIMIT " + limit)); + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/dashboard/service/DashboardService.java b/mateclaw-server/src/main/java/vip/mate/dashboard/service/DashboardService.java new file mode 100644 index 00000000..0ada1512 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/dashboard/service/DashboardService.java @@ -0,0 +1,120 @@ +package vip.mate.dashboard.service; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import vip.mate.workspace.conversation.model.ConversationEntity; +import vip.mate.workspace.conversation.model.MessageEntity; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.conversation.repository.MessageMapper; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; + +/** + * Dashboard 统计服务 + *

+ * 直接实时查询 mate_message / mate_conversation 表,不依赖预聚合。 + * + * @author MateClaw Team + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DashboardService { + + private final MessageMapper messageMapper; + private final ConversationMapper conversationMapper; + + /** + * 获取概览统计(今日/本周/本月)— 实时查询 + */ + public Map getOverview(Long workspaceId) { + LocalDate today = LocalDate.now(); + LocalDate weekStart = today.minusDays(today.getDayOfWeek().getValue() - 1); + LocalDate monthStart = today.withDayOfMonth(1); + + Map result = new LinkedHashMap<>(); + result.put("today", queryStats(workspaceId, today, today)); + result.put("thisWeek", queryStats(workspaceId, weekStart, today)); + result.put("thisMonth", queryStats(workspaceId, monthStart, today)); + return result; + } + + /** + * 获取日趋势数据(最近 N 天,按天聚合) + */ + public List> getTrend(Long workspaceId, int days) { + List> trend = new ArrayList<>(); + LocalDate today = LocalDate.now(); + for (int i = days - 1; i >= 0; i--) { + LocalDate date = today.minusDays(i); + Map dayStats = queryStats(workspaceId, date, date); + dayStats.put("date", date.toString()); + trend.add(dayStats); + } + return trend; + } + + private Map queryStats(Long workspaceId, LocalDate startDate, LocalDate endDate) { + LocalDateTime startTime = startDate.atStartOfDay(); + LocalDateTime endTime = endDate.atTime(LocalTime.MAX); + + // 对话数 + LambdaQueryWrapper convWrapper = new LambdaQueryWrapper() + .ge(ConversationEntity::getCreateTime, startTime) + .le(ConversationEntity::getCreateTime, endTime); + if (workspaceId != null) { + convWrapper.eq(ConversationEntity::getWorkspaceId, workspaceId); + } + long conversations = conversationMapper.selectCount(convWrapper); + + // 消息统计(只统计 assistant 消息的 token) + LambdaQueryWrapper msgWrapper = new LambdaQueryWrapper() + .ge(MessageEntity::getCreateTime, startTime) + .le(MessageEntity::getCreateTime, endTime) + .eq(MessageEntity::getDeleted, 0); + + // 总消息数 + long messages = messageMapper.selectCount(msgWrapper); + + // Token 统计(assistant 消息) + LambdaQueryWrapper tokenWrapper = new LambdaQueryWrapper() + .eq(MessageEntity::getRole, "assistant") + .ge(MessageEntity::getCreateTime, startTime) + .le(MessageEntity::getCreateTime, endTime) + .eq(MessageEntity::getDeleted, 0) + .select(MessageEntity::getPromptTokens, MessageEntity::getCompletionTokens); + + List assistantMessages = messageMapper.selectList(tokenWrapper); + + long totalTokens = 0, promptTokens = 0, completionTokens = 0; + for (MessageEntity m : assistantMessages) { + int pt = m.getPromptTokens() != null ? m.getPromptTokens() : 0; + int ct = m.getCompletionTokens() != null ? m.getCompletionTokens() : 0; + promptTokens += pt; + completionTokens += ct; + totalTokens += pt + ct; + } + + // Tool 调用数(role = tool 的消息) + LambdaQueryWrapper toolWrapper = new LambdaQueryWrapper() + .eq(MessageEntity::getRole, "tool") + .ge(MessageEntity::getCreateTime, startTime) + .le(MessageEntity::getCreateTime, endTime) + .eq(MessageEntity::getDeleted, 0); + long toolCalls = messageMapper.selectCount(toolWrapper); + + Map stats = new LinkedHashMap<>(); + stats.put("conversations", conversations); + stats.put("messages", messages); + stats.put("totalTokens", totalTokens); + stats.put("promptTokens", promptTokens); + stats.put("completionTokens", completionTokens); + stats.put("toolCalls", toolCalls); + return stats; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 9ab14f1f..761ba0c4 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -16,6 +16,7 @@ import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; import java.time.Duration; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; /** @@ -114,10 +115,34 @@ public class SkillRuntimeService { } /** - * 构建技能 prompt 增强片段(分层注入) + * 构建技能 prompt 增强片段(全局,向后兼容) */ public String buildSkillPromptEnhancement() { - List activeSkills = getActiveSkills(); + return buildSkillPromptEnhancement(null); + } + + /** + * 构建技能 prompt 增强片段(支持 per-agent 过滤) + * + * @param boundSkillIds Agent 绑定的 skill ID 集合。null 表示使用全局默认(无绑定)。 + * 非 null 时仅包含指定 ID 的 skill。 + */ + public String buildSkillPromptEnhancement(Set boundSkillIds) { + List activeSkills; + if (boundSkillIds != null) { + // Per-agent 过滤:从全局 enabled skills 中按 ID 过滤 + List enabledSkills = skillService.listEnabledSkills(); + activeSkills = enabledSkills.stream() + .filter(s -> boundSkillIds.contains(s.getId())) + .map(packageResolver::resolve) + .filter(ResolvedSkill::isEnabled) + .filter(ResolvedSkill::isRuntimeAvailable) + .filter(s -> !s.isSecurityBlocked()) + .filter(ResolvedSkill::isDependencyReady) + .collect(java.util.stream.Collectors.toList()); + } else { + activeSkills = getActiveSkills(); + } if (activeSkills.isEmpty()) { return ""; } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index d853d7a7..086bbb3d 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -7,6 +7,7 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import vip.mate.common.result.R; +import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.wiki.WikiProperties; import vip.mate.wiki.event.WikiProcessingEvent; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; @@ -48,6 +49,7 @@ public class WikiController { // ==================== Knowledge Base ==================== + @RequireWorkspaceRole("viewer") @Operation(summary = "获取所有知识库") @GetMapping("/knowledge-bases") public R> listKBs( @@ -58,6 +60,7 @@ public class WikiController { return R.ok(kbService.listAll()); } + @RequireWorkspaceRole("viewer") @Operation(summary = "获取知识库详情") @GetMapping("/knowledge-bases/{id}") public R getKB(@PathVariable Long id) { @@ -66,12 +69,14 @@ public class WikiController { return R.ok(kb); } + @RequireWorkspaceRole("viewer") @Operation(summary = "按 Agent 获取知识库") @GetMapping("/knowledge-bases/agent/{agentId}") public R> listKBsByAgent(@PathVariable Long agentId) { return R.ok(kbService.listByAgentId(agentId)); } + @RequireWorkspaceRole("member") @Operation(summary = "创建知识库") @PostMapping("/knowledge-bases") public R createKB(@RequestBody Map body) { @@ -81,6 +86,7 @@ public class WikiController { return R.ok(kbService.create(name, description, agentId)); } + @RequireWorkspaceRole("member") @Operation(summary = "更新知识库") @PutMapping("/knowledge-bases/{id}") public R updateKB(@PathVariable Long id, @RequestBody Map body) { @@ -90,6 +96,7 @@ public class WikiController { return R.ok(kbService.update(id, name, description, agentId)); } + @RequireWorkspaceRole("admin") @Operation(summary = "删除知识库") @DeleteMapping("/knowledge-bases/{id}") public R deleteKB(@PathVariable Long id) { @@ -97,6 +104,7 @@ public class WikiController { return R.ok(); } + @RequireWorkspaceRole("viewer") @Operation(summary = "获取知识库配置") @GetMapping("/knowledge-bases/{id}/config") public R> getConfig(@PathVariable Long id) { @@ -105,6 +113,7 @@ public class WikiController { return R.ok(Map.of("content", kb.getConfigContent() != null ? kb.getConfigContent() : "")); } + @RequireWorkspaceRole("member") @Operation(summary = "更新知识库配置") @PutMapping("/knowledge-bases/{id}/config") public R updateConfig(@PathVariable Long id, @RequestBody Map body) { @@ -114,6 +123,7 @@ public class WikiController { // ==================== Directory Scan ==================== + @RequireWorkspaceRole("member") @Operation(summary = "设置知识库关联目录") @PutMapping("/knowledge-bases/{id}/source-directory") public R setSourceDirectory(@PathVariable Long id, @RequestBody Map body) { @@ -122,6 +132,7 @@ public class WikiController { return R.ok(); } + @RequireWorkspaceRole("member") @Operation(summary = "扫描关联目录导入文件") @PostMapping("/knowledge-bases/{id}/scan") public R> scanDirectory(@PathVariable Long id) { @@ -136,12 +147,14 @@ public class WikiController { // ==================== Raw Materials ==================== + @RequireWorkspaceRole("viewer") @Operation(summary = "获取原始材料列表") @GetMapping("/knowledge-bases/{kbId}/raw") public R> listRaw(@PathVariable Long kbId) { return R.ok(rawService.listByKbId(kbId)); } + @RequireWorkspaceRole("member") @Operation(summary = "添加文本材料") @PostMapping("/knowledge-bases/{kbId}/raw/text") public R addRawText(@PathVariable Long kbId, @RequestBody Map body) { @@ -150,6 +163,7 @@ public class WikiController { return R.ok(rawService.addText(kbId, title, content)); } + @RequireWorkspaceRole("member") @Operation(summary = "上传文件材料") @PostMapping("/knowledge-bases/{kbId}/raw/upload") public R uploadRaw(@PathVariable Long kbId, @@ -182,6 +196,7 @@ public class WikiController { } } + @RequireWorkspaceRole("admin") @Operation(summary = "删除原始材料") @DeleteMapping("/knowledge-bases/{kbId}/raw/{rawId}") public R deleteRaw(@PathVariable Long kbId, @PathVariable Long rawId) { @@ -194,6 +209,7 @@ public class WikiController { return R.ok(); } + @RequireWorkspaceRole("member") @Operation(summary = "重新处理原始材料") @PostMapping("/knowledge-bases/{kbId}/raw/{rawId}/reprocess") public R reprocessRaw(@PathVariable Long kbId, @PathVariable Long rawId) { @@ -207,12 +223,14 @@ public class WikiController { // ==================== Wiki Pages ==================== + @RequireWorkspaceRole("viewer") @Operation(summary = "获取 Wiki 页面列表") @GetMapping("/knowledge-bases/{kbId}/pages") public R> listPages(@PathVariable Long kbId) { return R.ok(pageService.listByKbId(kbId)); } + @RequireWorkspaceRole("viewer") @Operation(summary = "获取 Wiki 页面内容") @GetMapping("/knowledge-bases/{kbId}/pages/{slug}") public R getPage(@PathVariable Long kbId, @PathVariable String slug) { @@ -221,6 +239,7 @@ public class WikiController { return R.ok(page); } + @RequireWorkspaceRole("member") @Operation(summary = "手动编辑 Wiki 页面") @PutMapping("/knowledge-bases/{kbId}/pages/{slug}") public R updatePage(@PathVariable Long kbId, @PathVariable String slug, @@ -228,6 +247,7 @@ public class WikiController { return R.ok(pageService.updatePageManually(kbId, slug, body.get("content"), body.get("summary"))); } + @RequireWorkspaceRole("admin") @Operation(summary = "删除 Wiki 页面") @DeleteMapping("/knowledge-bases/{kbId}/pages/{slug}") public R deletePage(@PathVariable Long kbId, @PathVariable String slug) { @@ -236,6 +256,7 @@ public class WikiController { return R.ok(); } + @RequireWorkspaceRole("viewer") @Operation(summary = "获取反向链接") @GetMapping("/knowledge-bases/{kbId}/pages/{slug}/backlinks") public R> getBacklinks(@PathVariable Long kbId, @PathVariable String slug) { @@ -244,6 +265,7 @@ public class WikiController { // ==================== Processing ==================== + @RequireWorkspaceRole("member") @Operation(summary = "触发知识库处理(异步)") @PostMapping("/knowledge-bases/{kbId}/process") public R> processKB(@PathVariable Long kbId) { @@ -254,6 +276,7 @@ public class WikiController { return R.ok(Map.of("queued", pending.size())); } + @RequireWorkspaceRole("viewer") @Operation(summary = "获取处理状态") @GetMapping("/knowledge-bases/{kbId}/processing-status") public R> getProcessingStatus(@PathVariable Long kbId) { diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/annotation/RequireWorkspaceRole.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/annotation/RequireWorkspaceRole.java new file mode 100644 index 00000000..9617cfd4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/annotation/RequireWorkspaceRole.java @@ -0,0 +1,24 @@ +package vip.mate.workspace.core.annotation; + +import java.lang.annotation.*; + +/** + * 声明式 Workspace 权限检查注解。 + *

+ * 标注在 Controller 方法上,拦截器会自动校验当前用户在请求 Workspace 中的角色 ≥ value()。 + * 如果方法参数或请求 Header 中没有 workspace 信息,走默认 workspace(id=1)。 + *

+ * 角色等级: owner(4) > admin(3) > member(2) > viewer(1) + * + * @author MateClaw Team + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RequireWorkspaceRole { + + /** + * 最低角色要求,默认 viewer(即只要是成员就可以访问) + */ + String value() default "viewer"; +} diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java index 3691a039..799a55a0 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java @@ -1,6 +1,8 @@ package vip.mate.workspace.core.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -11,6 +13,7 @@ import vip.mate.workspace.core.model.WorkspaceMemberEntity; import vip.mate.workspace.core.repository.WorkspaceMapper; import vip.mate.workspace.core.repository.WorkspaceMemberMapper; +import java.time.Duration; import java.util.List; /** @@ -29,6 +32,12 @@ public class WorkspaceService { /** 默认工作区 slug */ public static final String DEFAULT_SLUG = "default"; + /** 成员资格缓存:key = "workspaceId:userId",value = role string(null 表示非成员) */ + private final Cache membershipCache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(60)) + .maximumSize(1000) + .build(); + // ==================== 工作区 CRUD ==================== public List listAll() { @@ -141,6 +150,7 @@ public class WorkspaceService { member.setUserId(userId); member.setRole(role != null ? role : "member"); memberMapper.insert(member); + evictMembershipCache(workspaceId, userId); log.info("Added member to workspace: userId={}, workspaceId={}, role={}", userId, workspaceId, member.getRole()); return member; } @@ -155,6 +165,7 @@ public class WorkspaceService { } member.setRole(role); memberMapper.updateById(member); + evictMembershipCache(workspaceId, userId); return member; } @@ -167,6 +178,7 @@ public class WorkspaceService { throw new MateClawException("不能移除工作区拥有者"); } memberMapper.deleteById(member.getId()); + evictMembershipCache(workspaceId, userId); log.info("Removed member from workspace: userId={}, workspaceId={}", userId, workspaceId); } @@ -197,6 +209,28 @@ public class WorkspaceService { } } + /** + * 带缓存的权限检查(拦截器高频调用,避免每次请求查库) + */ + public boolean hasPermissionCached(Long workspaceId, Long userId, String minRole) { + String cacheKey = workspaceId + ":" + userId; + String role = membershipCache.get(cacheKey, k -> { + WorkspaceMemberEntity member = getMembership(workspaceId, userId); + return member != null ? member.getRole() : ""; + }); + if (role == null || role.isEmpty()) { + return false; + } + return roleLevel(role) >= roleLevel(minRole); + } + + /** + * 清除指定 workspace + user 的成员资格缓存(成员变更时调用) + */ + public void evictMembershipCache(Long workspaceId, Long userId) { + membershipCache.invalidate(workspaceId + ":" + userId); + } + private int roleLevel(String role) { return switch (role) { case "owner" -> 4; diff --git a/mateclaw-server/src/main/resources/db/schema-mysql.sql b/mateclaw-server/src/main/resources/db/schema-mysql.sql index 8f5950c8..ebf11388 100644 --- a/mateclaw-server/src/main/resources/db/schema-mysql.sql +++ b/mateclaw-server/src/main/resources/db/schema-mysql.sql @@ -564,3 +564,84 @@ DELIMITER ; CALL mate_add_workspace_id(); DROP PROCEDURE IF EXISTS mate_add_workspace_id; + +-- ============================================= +-- Agent-Skill / Agent-Tool 绑定表(Phase 3 Sprint 2) +-- ============================================= + +CREATE TABLE IF NOT EXISTS mate_agent_skill ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + skill_id BIGINT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + config_json TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_agent_skill (agent_id, skill_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS mate_agent_tool ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + tool_name VARCHAR(128) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_agent_tool (agent_id, tool_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================= +-- CronJob 执行历史(Phase 3 Sprint 3) +-- ============================================= +CREATE TABLE IF NOT EXISTS mate_cron_job_run ( + id BIGINT NOT NULL PRIMARY KEY, + cron_job_id BIGINT NOT NULL, + conversation_id VARCHAR(64), + status VARCHAR(32) NOT NULL, + trigger_type VARCHAR(32) NOT NULL DEFAULT 'scheduled', + started_at DATETIME NOT NULL, + finished_at DATETIME, + error_message TEXT, + token_usage INT DEFAULT 0, + create_time DATETIME NOT NULL, + INDEX idx_cron_run_job (cron_job_id, started_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS mate_usage_daily ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + agent_id BIGINT, + stat_date DATE NOT NULL, + conversation_count INT DEFAULT 0, + message_count INT DEFAULT 0, + total_tokens BIGINT DEFAULT 0, + prompt_tokens BIGINT DEFAULT 0, + completion_tokens BIGINT DEFAULT 0, + tool_call_count INT DEFAULT 0, + error_count INT DEFAULT 0, + create_time DATETIME NOT NULL, + UNIQUE KEY uk_usage_daily (workspace_id, agent_id, stat_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ============================================= +-- 操作审计事件表(Phase 3 Sprint 1) +-- ============================================= +CREATE TABLE IF NOT EXISTS mate_audit_event ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT, + user_id BIGINT NOT NULL, + username VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + resource_type VARCHAR(64) NOT NULL, + resource_id VARCHAR(128), + resource_name VARCHAR(256), + detail_json TEXT, + ip_address VARCHAR(64), + user_agent VARCHAR(256), + create_time DATETIME NOT NULL, + INDEX idx_audit_ws_time (workspace_id, create_time), + INDEX idx_audit_user (user_id), + INDEX idx_audit_resource (resource_type, resource_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/mateclaw-server/src/main/resources/db/schema.sql b/mateclaw-server/src/main/resources/db/schema.sql index d057307d..e1821777 100644 --- a/mateclaw-server/src/main/resources/db/schema.sql +++ b/mateclaw-server/src/main/resources/db/schema.sql @@ -565,3 +565,87 @@ ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT N ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; ALTER TABLE mate_tool ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; ALTER TABLE mate_skill ADD COLUMN IF NOT EXISTS workspace_id BIGINT NOT NULL DEFAULT 1; + +-- ============================================= +-- Agent-Skill / Agent-Tool 绑定表(Phase 3 Sprint 2) +-- ============================================= + +CREATE TABLE IF NOT EXISTS mate_agent_skill ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + skill_id BIGINT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + config_json TEXT, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_skill ON mate_agent_skill(agent_id, skill_id); + +CREATE TABLE IF NOT EXISTS mate_agent_tool ( + id BIGINT NOT NULL PRIMARY KEY, + agent_id BIGINT NOT NULL, + tool_name VARCHAR(128) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + create_time DATETIME NOT NULL, + update_time DATETIME NOT NULL, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_tool ON mate_agent_tool(agent_id, tool_name); + +-- ============================================= +-- CronJob 执行历史(Phase 3 Sprint 3) +-- ============================================= +CREATE TABLE IF NOT EXISTS mate_cron_job_run ( + id BIGINT NOT NULL PRIMARY KEY, + cron_job_id BIGINT NOT NULL, + conversation_id VARCHAR(64), + status VARCHAR(32) NOT NULL, + trigger_type VARCHAR(32) NOT NULL DEFAULT 'scheduled', + started_at DATETIME NOT NULL, + finished_at DATETIME, + error_message TEXT, + token_usage INT DEFAULT 0, + create_time DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_cron_run_job ON mate_cron_job_run(cron_job_id, started_at); + +-- ============================================= +-- 用量日统计(Phase 3 Sprint 3) +-- ============================================= +CREATE TABLE IF NOT EXISTS mate_usage_daily ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT NOT NULL, + agent_id BIGINT, + stat_date DATE NOT NULL, + conversation_count INT DEFAULT 0, + message_count INT DEFAULT 0, + total_tokens BIGINT DEFAULT 0, + prompt_tokens BIGINT DEFAULT 0, + completion_tokens BIGINT DEFAULT 0, + tool_call_count INT DEFAULT 0, + error_count INT DEFAULT 0, + create_time DATETIME NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_usage_daily ON mate_usage_daily(workspace_id, agent_id, stat_date); + +-- ============================================= +-- 操作审计事件表(Phase 3 Sprint 1) +-- ============================================= +CREATE TABLE IF NOT EXISTS mate_audit_event ( + id BIGINT NOT NULL PRIMARY KEY, + workspace_id BIGINT, + user_id BIGINT NOT NULL, + username VARCHAR(64) NOT NULL, + action VARCHAR(64) NOT NULL, + resource_type VARCHAR(64) NOT NULL, + resource_id VARCHAR(128), + resource_name VARCHAR(256), + detail_json TEXT, + ip_address VARCHAR(64), + user_agent VARCHAR(256), + create_time DATETIME NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_audit_ws_time ON mate_audit_event(workspace_id, create_time); +CREATE INDEX IF NOT EXISTS idx_audit_user ON mate_audit_event(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_resource ON mate_audit_event(resource_type, resource_id); diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 3544d40b..7cc154cc 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -395,3 +395,34 @@ export const workspaceTeamApi = { removeMember: (id: string | number, memberId: string | number) => http.delete(`/workspaces/${id}/members/${memberId}`), } + +// ==================== Agent Binding ==================== +export const agentBindingApi = { + listSkills: (agentId: string | number) => http.get(`/agents/${agentId}/skills`), + setSkills: (agentId: string | number, skillIds: number[]) => http.put(`/agents/${agentId}/skills`, skillIds), + bindSkill: (agentId: string | number, skillId: number) => http.post(`/agents/${agentId}/skills/${skillId}`), + unbindSkill: (agentId: string | number, skillId: number) => http.delete(`/agents/${agentId}/skills/${skillId}`), + listTools: (agentId: string | number) => http.get(`/agents/${agentId}/tools`), + setTools: (agentId: string | number, toolNames: string[]) => http.put(`/agents/${agentId}/tools`, toolNames), +} + +// ==================== Dashboard ==================== +export const dashboardApi = { + overview: () => http.get('/dashboard/overview'), + trend: (days = 30) => http.get('/dashboard/trend', { params: { days } }), + agentRanking: (days = 7, topN = 10) => http.get('/dashboard/agent-ranking', { params: { days, topN } }), + cronJobRuns: (cronJobId: string | number, limit = 20) => http.get(`/dashboard/cron-runs/${cronJobId}`, { params: { limit } }), + recentRuns: (limit = 20) => http.get('/dashboard/cron-runs', { params: { limit } }), +} + +// ==================== Audit Events ==================== +export const auditApi = { + listEvents: (params: { + action?: string + resourceType?: string + startTime?: string + endTime?: string + page?: number + size?: number + }) => http.get('/audit/events', { params }), +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 426845e7..d5989814 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -179,6 +179,7 @@ export default { timeHoursAgo: '{n}h ago', }, nav: { + dashboard: 'Dashboard', chat: 'Chat', control: 'Control', channels: 'Channels', @@ -607,6 +608,23 @@ export default { fileGuard: 'File Guard', auditLogs: 'Audit Logs', members: 'Members', + activity: 'Activity', + }, + activity: { + title: 'Activity Log', + desc: 'View all create, update, and delete operations on workspace resources.', + allActions: 'All Actions', + allResources: 'All Resources', + loading: 'Loading...', + noEvents: 'No activity yet', + columns: { + time: 'Time', + user: 'User', + action: 'Action', + resource: 'Resource', + name: 'Name', + ip: 'IP', + }, }, members: { title: 'Workspace Members', @@ -1364,4 +1382,28 @@ export default { screenshot: 'screenshot', }, }, + dashboard: { + title: 'Dashboard', + desc: 'System usage overview and runtime status', + conversations: 'Conversations', + messages: 'Messages', + tokens: 'Token Usage', + toolCalls: 'Tool Calls', + periodComparison: 'Period Comparison', + periods: { + today: 'Today', + thisWeek: 'This Week', + thisMonth: 'This Month', + }, + recentRuns: 'Recent Cron Job Runs', + noRuns: 'No runs yet', + runColumns: { + time: 'Time', + job: 'Job', + status: 'Status', + trigger: 'Trigger', + duration: 'Duration', + tokens: 'Tokens', + }, + }, } as const diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index c1e3ff81..f4a765ec 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -179,6 +179,7 @@ export default { timeHoursAgo: '{n} 小时前', }, nav: { + dashboard: '仪表盘', chat: '对话', control: '控制台', channels: '渠道', @@ -607,6 +608,23 @@ export default { fileGuard: '文件防护', auditLogs: '审计日志', members: '成员管理', + activity: '操作日志', + }, + activity: { + title: '操作日志', + desc: '查看工作区内所有资源的创建、修改、删除操作记录。', + allActions: '全部操作', + allResources: '全部资源', + loading: '加载中...', + noEvents: '暂无操作记录', + columns: { + time: '时间', + user: '用户', + action: '操作', + resource: '资源类型', + name: '名称', + ip: 'IP', + }, }, members: { title: '工作区成员', @@ -1374,4 +1392,28 @@ export default { screenshot: '截图', }, }, + dashboard: { + title: '仪表盘', + desc: '系统用量概览与运行状态', + conversations: '对话数', + messages: '消息数', + tokens: 'Token 消耗', + toolCalls: '工具调用', + periodComparison: '周期对比', + periods: { + today: '今日', + thisWeek: '本周', + thisMonth: '本月', + }, + recentRuns: '最近定时任务执行', + noRuns: '暂无执行记录', + runColumns: { + time: '时间', + job: '任务', + status: '状态', + trigger: '触发方式', + duration: '耗时', + tokens: 'Token', + }, + }, } as const diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index cba85a2e..7fcb5d34 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -15,6 +15,12 @@ const router = createRouter({ component: () => import('@/views/ChatConsole.vue'), meta: { title: 'Chat' }, }, + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: 'Dashboard' }, + }, { path: 'agents', name: 'Agents', @@ -157,6 +163,12 @@ const router = createRouter({ component: () => import('@/views/Security/AuditLogs/index.vue'), meta: { title: 'Security - Audit Logs' }, }, + { + path: 'activity', + name: 'SecurityActivity', + component: () => import('@/views/Security/Activity/index.vue'), + meta: { title: 'Security - Activity' }, + }, { path: 'members', name: 'SecurityMembers', diff --git a/mateclaw-ui/src/views/Agents.vue b/mateclaw-ui/src/views/Agents.vue index ec6e2cca..268c7c75 100644 --- a/mateclaw-ui/src/views/Agents.vue +++ b/mateclaw-ui/src/views/Agents.vue @@ -112,7 +112,23 @@