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