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