diff --git a/mateclaw-server/src/main/java/vip/mate/activity/ActivityFeedController.java b/mateclaw-server/src/main/java/vip/mate/activity/ActivityFeedController.java new file mode 100644 index 00000000..132b1183 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/activity/ActivityFeedController.java @@ -0,0 +1,166 @@ +package vip.mate.activity; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +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.approval.model.ToolApprovalEntity; +import vip.mate.approval.repository.ToolApprovalMapper; +import vip.mate.audit.model.AuditEventEntity; +import vip.mate.audit.service.AuditEventService; +import vip.mate.common.result.R; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * RFC-090 §4.5 / §7 — unified Activity feed. + * + *

Merges three sources into one chronologically-ordered stream: + *

+ * + *

Pagination is best-effort: each source is paged from index 0 + * up to {@code size * 2}, then the merged list is trimmed and offset + * in-memory. For workspaces with >>1k events / day a follow-up should + * push merging into SQL; this is good enough for v1. + */ +@Tag(name = "Activity Feed (RFC-090)") +@RestController +@RequestMapping("/api/v1/activity") +@RequiredArgsConstructor +public class ActivityFeedController { + + private final AuditEventService auditEventService; + private final ToolApprovalMapper toolApprovalMapper; + + @Operation(summary = "Unified activity feed (audit + approval + tool calls)") + @GetMapping("/feed") + public R> feed( + @RequestParam(required = false) Long workspaceId, + @RequestParam(required = false) String source, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size) { + if (size <= 0 || size > 200) size = 20; + if (page <= 0) page = 1; + + // Pull a chunk from each source large enough to cover the + // requested page after merge. We read at most page*size rows + // from each side; for stable cursors, future iterations should + // push the merge into SQL with a UNION ALL view. + int chunk = Math.max(size * page, 50); + + List rows = new ArrayList<>(); + + boolean wantAudit = source == null || source.isBlank() || "audit".equalsIgnoreCase(source); + boolean wantApproval = source == null || source.isBlank() || "approval".equalsIgnoreCase(source); + + if (wantAudit) { + try { + IPage audit = auditEventService.listEvents( + workspaceId, null, null, null, null, 1, chunk); + for (AuditEventEntity ev : audit.getRecords()) { + rows.add(fromAuditEvent(ev)); + } + } catch (Exception ignored) { /* surface silence > one-source crash */ } + } + if (wantApproval) { + try { + Page p = new Page<>(1, chunk); + LambdaQueryWrapper qw = new LambdaQueryWrapper() + .orderByDesc(ToolApprovalEntity::getCreatedAt); + IPage approvals = toolApprovalMapper.selectPage(p, qw); + for (ToolApprovalEntity ap : approvals.getRecords()) { + rows.add(fromApproval(ap)); + } + } catch (Exception ignored) { /* see above */ } + } + + rows.sort(Comparator.comparing(ActivityRow::time, Comparator.nullsLast(Comparator.reverseOrder()))); + + long total = rows.size(); + int from = Math.min((page - 1) * size, rows.size()); + int to = Math.min(from + size, rows.size()); + List sliced = rows.subList(from, to); + + Map resp = new LinkedHashMap<>(); + resp.put("page", page); + resp.put("size", size); + resp.put("total", total); + resp.put("records", sliced); + return R.ok(resp); + } + + private ActivityRow fromAuditEvent(AuditEventEntity ev) { + Map detail = new LinkedHashMap<>(); + detail.put("detailJson", ev.getDetailJson()); + detail.put("userAgent", ev.getUserAgent()); + detail.put("workspaceId", ev.getWorkspaceId()); + return new ActivityRow( + "audit-" + ev.getId(), + "audit", + ev.getCreateTime(), + ev.getUsername(), + ev.getAction(), + ev.getResourceType(), + ev.getResourceName() != null ? ev.getResourceName() : ev.getResourceId(), + ev.getIpAddress(), + detail); + } + + private ActivityRow fromApproval(ToolApprovalEntity ap) { + Map detail = new LinkedHashMap<>(); + detail.put("toolArguments", ap.getToolArguments()); + detail.put("summary", ap.getSummary()); + detail.put("maxSeverity", ap.getMaxSeverity()); + detail.put("status", ap.getStatus()); + detail.put("resolvedAt", ap.getResolvedAt()); + // Map approval status onto an audit-style action so the UI's + // existing action coloring (CREATE / DELETE / etc.) keeps + // working without a special case. + String action = "APPROVAL_" + (ap.getStatus() == null ? "PENDING" : ap.getStatus().toUpperCase()); + return new ActivityRow( + "approval-" + ap.getId(), + "approval", + ap.getCreatedAt(), + ap.getResolvedBy() != null ? ap.getResolvedBy() : ap.getRequesterName(), + action, + "TOOL_APPROVAL", + ap.getToolName(), + null, + detail); + } + + /** + * Wire-format row. Public record so Jackson serializes it directly + * without needing a separate DTO. + */ + public record ActivityRow( + String id, + String source, + LocalDateTime time, + String username, + String action, + String resourceType, + String resourceName, + String ipAddress, + Map detail + ) {} +} diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 15e0df61..3cfbe384 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -177,6 +177,12 @@ export const skillApi = { employees: (id: string | number) => http.get(`/skills/${id}/employees`), } +// ==================== Activity Feed (RFC-090 §4.5) ==================== +export const activityApi = { + feed: (params: { source?: string; page?: number; size?: number; workspaceId?: number } = {}) => + http.get('/activity/feed', { params }), +} + // ==================== ACP Endpoints (RFC-090 Phase 7) ==================== export const acpApi = { list: () => http.get('/acp/endpoints'), diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 749bae1d..582a338e 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -800,13 +800,19 @@ export default { }, activity: { title: 'Activity Log', - desc: 'View all create, update, and delete operations on workspace resources.', + desc: 'Unified feed: audit events + tool approvals (and successful tool calls when persistence lands).', allActions: 'All Actions', allResources: 'All Resources', + allSources: 'All Sources', + sourceAudit: 'Audit', + sourceApproval: 'Approval', + detailTitle: 'Event detail', + detailDetails: 'Detail', loading: 'Loading...', noEvents: 'No activity yet', columns: { time: 'Time', + source: 'Source', user: 'User', action: 'Action', resource: 'Resource', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 69bc1413..a13eb55d 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -800,13 +800,19 @@ export default { }, activity: { title: '操作日志', - desc: '查看工作区内所有资源的创建、修改、删除操作记录。', + desc: '聚合视图:审计事件 + 工具审批(成功 tool 调用待落库后自动接入)。', allActions: '全部操作', allResources: '全部资源', + allSources: '全部来源', + sourceAudit: '审计', + sourceApproval: '审批', + detailTitle: '事件详情', + detailDetails: '详情', loading: '加载中...', noEvents: '暂无操作记录', columns: { time: '时间', + source: '来源', user: '用户', action: '操作', resource: '资源类型', diff --git a/mateclaw-ui/src/views/Security/Activity/index.vue b/mateclaw-ui/src/views/Security/Activity/index.vue index b9851be3..3c81b767 100644 --- a/mateclaw-ui/src/views/Security/Activity/index.vue +++ b/mateclaw-ui/src/views/Security/Activity/index.vue @@ -15,15 +15,23 @@

- + + + + + - @@ -31,6 +39,7 @@ +
+ + +
+
{{ t('security.activity.columns.time') }}{{ formatTime(detailEvent.time || detailEvent.createTime) }}
+
{{ t('security.activity.columns.source') }}{{ detailEvent.source }}
+
{{ t('security.activity.columns.user') }}{{ detailEvent.username || '—' }}
+
{{ t('security.activity.columns.action') }}{{ detailEvent.action }}
+
{{ t('security.activity.columns.resource') }}{{ detailEvent.resourceType }}: {{ detailEvent.resourceName || detailEvent.resourceId || '—' }}
+
{{ t('security.activity.columns.ip') }}{{ detailEvent.ipAddress }}
+
+
{{ t('security.activity.detailDetails') }}
+
{{ JSON.stringify(detailEvent.detail, null, 2) }}
+
+
+
+