From 093b9e990899fd25312c4f03e9f93f780de2e4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Thu, 18 Jun 2026 01:10:15 +0800 Subject: [PATCH] feat(webchat): audit visitor-side writes via AuditEventService.recordAs (epic #355 PR 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuditEventService gains recordAs(actor, workspaceId, ...) — an overload that takes an explicit actor string instead of deriving one from SecurityContext. Used for non-MateClaw principals (currently just webchat visitors), where there is no Spring Security auth and the default record() path would write "system". WebChatController injects AuditEventService and adds an audit() helper that constructs the canonical actor string "webchat::" so audit-event searches can filter by channel or visitor. Eight write endpoints now log an audit row on success: webchat.create-session, webchat.rename-session, webchat.pin-session, webchat.archive-session, webchat.delete-session, webchat.stop-session, webchat.regenerate-session, webchat.upload-file /stream is intentionally NOT audited — message-volume noise, and conversationService.saveMessage already leaves a durable trail. Each row carries: - username = webchat:: - action = webchat. - resource = CONVERSATION / - detailJson = {sessionId, ...action-specific fields} WebChatAuditTrailTest (@SpringBootTest, 2 cases): - createSession lands a row with the exact actor string + action - rename + pin + archive + stop each leave a row (4 distinct actions) Audit insert is async; tests poll up to 3s for the row to appear. Regression: 9 webchat test classes (64 tests) green. Part of epic #355. --- .../mate/audit/service/AuditEventService.java | 31 +++++ .../channel/webchat/WebChatController.java | 33 +++++ .../webchat/WebChatAuditTrailTest.java | 117 ++++++++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java 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 index 669c0e5b..daa0632c 100644 --- a/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java +++ b/mateclaw-server/src/main/java/vip/mate/audit/service/AuditEventService.java @@ -63,6 +63,37 @@ public class AuditEventService { } } + /** + * 异步记录审计事件,显式指定 actor(而非从 SecurityContext 推导)。 + *

用于非 MateClaw 用户的写操作 —— 当前主要是 webchat 访客。actor 形如 + * {@code "webchat::"},{@code userId} 落 0(访客没有 MateClaw 账户)。 + * IP / User-Agent 仍尽量从当前请求抓取(webEnvironment=NONE 下为 null,可接受)。 + */ + public void recordAs(String actor, Long workspaceId, String action, String resourceType, + String resourceId, String resourceName, String detailJson) { + AuditEventEntity event = new AuditEventEntity(); + event.setUsername(actor != null ? actor : "system"); + event.setUserId(0L); + event.setAction(action); + event.setResourceType(resourceType); + event.setResourceId(resourceId); + event.setResourceName(resourceName); + event.setDetailJson(detailJson); + event.setWorkspaceId(workspaceId); + event.setCreateTime(LocalDateTime.now()); + 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)); + } + } catch (Exception ignored) { + // 异步或非 web 上下文:跳过 IP/UA + } + insertAsync(event); + } + @Async void insertAsync(AuditEventEntity event) { try { diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 0dc02f74..58c3858a 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -74,6 +74,7 @@ public class WebChatController { private final vip.mate.memory.identity.MemoryOwnerResolver memoryOwnerResolver; private final WebChatFileService fileService; private final WebChatTokenRevocationService tokenRevocationService; + private final vip.mate.audit.service.AuditEventService auditService; /** Visitor-token TTL in seconds (7 days). Mirrors GeneratedFileCache's TTL. */ static final long VISITOR_TOKEN_TTL_SECONDS = 7 * 24 * 3600L; @@ -374,6 +375,8 @@ public class WebChatController { // set title. Existing rows are exempt from the empty-session quota. ConversationEntity existing = conversationService.findByConversationId(conversationId); if (existing != null && owner.equals(existing.getUsername())) { + audit(channel, visitorId, "webchat.create-session", conversationId, + "{\"sessionId\":\"" + sessionId + "\",\"idempotent\":true}"); return R.ok(buildCreateSessionResponse(existing, sessionId, channel.getId(), visitorId)); } @@ -389,6 +392,8 @@ public class WebChatController { ConversationEntity conv = conversationService.getOrCreateWebchatConversation( conversationId, agentId, owner, channel.getWorkspaceId(), sessionId, title); + audit(channel, visitorId, "webchat.create-session", conversationId, + "{\"sessionId\":\"" + sessionId + "\",\"idempotent\":false}"); return R.ok(buildCreateSessionResponse(conv, sessionId, channel.getId(), visitorId)); } @@ -406,6 +411,19 @@ public class WebChatController { return m; } + /** + * Audit a visitor-side write. Actor is {@code "webchat::"} + * so audit searches can filter by channel / visitor. detailJson should be + * a JSON object capturing whatever the operator would need to reconstruct + * the call (sessionId, before/after state, etc). + */ + private void audit(ChannelEntity channel, String visitorId, String action, + String conversationId, String detailJson) { + String actor = "webchat:" + channel.getId() + ":" + visitorId; + auditService.recordAs(actor, channel.getWorkspaceId(), + action, "CONVERSATION", conversationId, null, detailJson); + } + /** * 列出某访客的会话线程 *

@@ -508,6 +526,8 @@ public class WebChatController { return R.fail(400, "标题不合法(1-100 字)"); } conversationService.renameConversation(conversationId, title); + audit(channel, visitorId, "webchat.rename-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"title\":\"" + title + "\"}"); return R.ok(); } @@ -545,6 +565,8 @@ public class WebChatController { return R.fail(400, "body must contain {pinned: true|false}"); } conversationService.setPinned(conversationId, (Boolean) v); + audit(channel, visitorId, "webchat.pin-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"pinned\":" + v + "}"); return R.ok(); } @@ -582,6 +604,8 @@ public class WebChatController { return R.fail(400, "body must contain {archived: true|false}"); } conversationService.setArchived(conversationId, (Boolean) v); + audit(channel, visitorId, "webchat.archive-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"archived\":" + v + "}"); return R.ok(); } @@ -742,6 +766,8 @@ public class WebChatController { return R.fail(404, "Session not found"); } conversationService.deleteConversation(conversationId); + audit(channel, visitorId, "webchat.delete-session", conversationId, + "{\"sessionId\":\"" + sid + "\"}"); return R.ok(); } @@ -787,6 +813,8 @@ public class WebChatController { boolean stopped = streamTracker.requestStop(conversationId); log.info("[WebChat] Stop requested: conversationId={}, visitor={}, stopped={}", conversationId, visitorId, stopped); + audit(channel, visitorId, "webchat.stop-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"stopped\":" + stopped + "}"); return R.ok(Map.of("stopped", stopped)); } @@ -847,6 +875,8 @@ public class WebChatController { log.info("[WebChat] Regenerate: conversationId={}, visitor={}, seedMessageId={}", conversationId, visitorId, lastUser.getId()); + audit(channel, visitorId, "webchat.regenerate-session", conversationId, + "{\"sessionId\":\"" + sid + "\",\"seedMessageId\":" + lastUser.getId() + "}"); // Reuse chatStream: it'll resolve the agent again (cheap), re-derive // conversationId, saveMessage user (new id, same content), and start @@ -894,6 +924,9 @@ public class WebChatController { String conversationId = deriveConversationId(apiKey, vid, sid); try { WebChatFileService.StagedFile stored = fileService.store(conversationId, file); + audit(channel, vid, "webchat.upload-file", conversationId, + "{\"sessionId\":\"" + sid + "\",\"fileId\":\"" + stored.storedName() + + "\",\"size\":" + stored.size() + "}"); return R.ok(Map.of( "fileId", stored.storedName(), "fileName", stored.originalName(), diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java new file mode 100644 index 00000000..b47f2fda --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAuditTrailTest.java @@ -0,0 +1,117 @@ +package vip.mate.channel.webchat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.channel.webchat.WebChatController.WebChatCreateSessionRequest; +import vip.mate.common.result.R; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies every visitor-side write lands in {@code mate_audit_event} with + * the right actor ({@code "webchat::"}) and action + * prefix ({@code webchat.*}). + * + *

{@code AuditEventService.recordAs} writes asynchronously — the test + * polls briefly for the row to appear rather than asserting synchronously. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_audit_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none", + "mateclaw.jwt.secret=webchat-it-secret-0123456789" +}) +class WebChatAuditTrailTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_147_701L; + private static final long AGENT_ID = 9_147_7011L; + + @Autowired private WebChatController controller; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("DELETE FROM mate_agent WHERE id = ?", AGENT_ID); + jdbc.update("DELETE FROM mate_audit_event WHERE resource_id = ?", String.valueOf(CHANNEL_ID)); + jdbc.update( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-audit-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + return r; + } + + private String tokenFor(String visitorId) { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId); + } + + private long awaitAuditCount(String action, long expected, long timeoutMs) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + Integer c = jdbc.queryForObject( + "SELECT COUNT(*) FROM mate_audit_event WHERE action = ?", + Integer.class, action); + if (c != null && c >= expected) return c; + Thread.sleep(50); + } + return -1; + } + + @Test + @DisplayName("createSession lands an audit row with the right actor + action") + void createSessionIsAudited() throws InterruptedException { + R r = controller.createSession(API_KEY, req("vAudit", "s1")); + assertThat(r.getCode()).isEqualTo(200); + + long count = awaitAuditCount("webchat.create-session", 1, 3_000); + assertThat(count).isGreaterThan(0); + + String actor = jdbc.queryForObject( + "SELECT username FROM mate_audit_event WHERE action = 'webchat.create-session' ORDER BY create_time DESC LIMIT 1", + String.class); + assertThat(actor).isEqualTo("webchat:" + CHANNEL_ID + ":vAudit"); + } + + @Test + @DisplayName("rename + pin + archive + stop each leave an audit row") + void stateMutationsAreAudited() throws InterruptedException { + controller.createSession(API_KEY, req("vState", "s1")); + String token = tokenFor("vState"); + + controller.renameSession(API_KEY, token, "vState", "s1", Map.of("title", "Renamed")); + controller.pinSession(API_KEY, token, "vState", "s1", Map.of("pinned", true)); + controller.archiveSession(API_KEY, token, "vState", "s1", Map.of("archived", true)); + controller.stopSession(API_KEY, token, "vState", "s1"); + + assertThat(awaitAuditCount("webchat.rename-session", 1, 3_000)).isGreaterThan(0); + assertThat(awaitAuditCount("webchat.pin-session", 1, 3_000)).isGreaterThan(0); + assertThat(awaitAuditCount("webchat.archive-session", 1, 3_000)).isGreaterThan(0); + assertThat(awaitAuditCount("webchat.stop-session", 1, 3_000)).isGreaterThan(0); + } +}