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 54931265..03d1bf81 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 @@ -413,7 +413,8 @@ public class WebChatController { public R> listSessions( @RequestHeader("X-MC-Key") String apiKey, @RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken, - @RequestParam String visitorId) { + @RequestParam String visitorId, + @RequestParam(defaultValue = "false") boolean includeArchived) { ChannelEntity channel = resolveChannel(apiKey); if (channel == null) { return R.fail(401, "Invalid API Key"); @@ -421,7 +422,7 @@ public class WebChatController { if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { return R.fail(401, "Invalid or missing visitor token"); } - return R.ok(loadVisitorSessions(apiKey, visitorId)); + return R.ok(loadVisitorSessions(apiKey, visitorId, includeArchived)); } /** @@ -437,7 +438,8 @@ public class WebChatController { @RequestParam String visitorId, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, - @RequestParam(required = false) String keyword) { + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "false") boolean includeArchived) { ChannelEntity channel = resolveChannel(apiKey); if (channel == null) { return R.fail(401, "Invalid API Key"); @@ -448,7 +450,7 @@ public class WebChatController { if (page < 1) page = 1; if (size < 1 || size > 200) size = 20; - List all = loadVisitorSessions(apiKey, visitorId); + List all = loadVisitorSessions(apiKey, visitorId, includeArchived); if (keyword != null && !keyword.isBlank()) { String kw = keyword.trim().toLowerCase(java.util.Locale.ROOT); all = all.stream() @@ -516,6 +518,19 @@ public class WebChatController { * for legacy rows created before that column existed. */ private List loadVisitorSessions(String apiKey, String visitorId) { + return loadVisitorSessions(apiKey, visitorId, false); + } + + /** + * Overload that lets the caller opt into archived threads. By default + * (used by /sessions listing and the empty-session quota check) archived + * rows are filtered out — they still exist on disk and are addressable + * by sessionId, but don't pollute the active listing and don't count + * against the "≤ 5 empty threads" quota (the visitor already declared + * they're done with them). + */ + private List loadVisitorSessions(String apiKey, String visitorId, + boolean includeArchived) { String base = deriveConversationId(apiKey, visitorId, null); String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":"; String owner = webchatUsername(visitorId); @@ -526,9 +541,16 @@ public class WebChatController { return conversationService.listWebchatConversations(owner).stream() .filter(c -> c.getConversationId() != null && c.getConversationId().startsWith(channelPrefix)) + .filter(c -> includeArchived + || c.getArchived() == null + || c.getArchived() == 0) .map(c -> { String sid = recoverSessionId(c, base); - return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), c.getMessageCount()); + return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), + c.getMessageCount(), + c.getPinned() != null ? c.getPinned() : 0, + c.getArchived() != null ? c.getArchived() : 0, + c.getStreamStatus() != null ? c.getStreamStatus() : "idle"); }) .collect(Collectors.toList()); } @@ -1047,6 +1069,12 @@ public class WebChatController { private String title; private LocalDateTime lastActiveTime; private Integer messageCount; + /** 1 if the visitor pinned this thread, 0 otherwise. */ + private Integer pinned; + /** 1 if the visitor archived this thread, 0 otherwise. */ + private Integer archived; + /** {@code running} if a stream is in progress on this thread, else {@code idle}. */ + private String streamStatus; } /** Body for {@code POST /sessions} — explicitly create an empty thread. */ diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java index 09236d4d..22f16873 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java @@ -51,6 +51,16 @@ public class ConversationEntity { /** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */ private Integer pinned; + /** + * Archive flag (webchat): 0 = active (default), 1 = archived. + * Archived threads stay in the DB (history preserved, still addressable + * by sessionId, downloadable) but are excluded from the default + * /sessions listing. A visitor opts into seeing them via + * {@code includeArchived=true}. Archive dominates pin — an archived + * AND pinned thread is still hidden by default. + */ + private Integer archived; + /** * Provider id of the model this conversation is pinned to. NULL means * "inherit" — fall back to the agent's model override, then the global diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java index 3f583827..44be0d79 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java @@ -66,6 +66,7 @@ public class ConversationVO extends ConversationEntity { vo.setLastActiveTime(entity.getLastActiveTime()); vo.setWorkspaceId(entity.getWorkspaceId()); vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0); + vo.setArchived(entity.getArchived() != null ? entity.getArchived() : 0); vo.setModelProvider(entity.getModelProvider()); vo.setModelName(entity.getModelName()); vo.setWebchatSessionId(entity.getWebchatSessionId()); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V148__webchat_archive_and_revocation.sql b/mateclaw-server/src/main/resources/db/migration/h2/V148__webchat_archive_and_revocation.sql new file mode 100644 index 00000000..4e0a91c7 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V148__webchat_archive_and_revocation.sql @@ -0,0 +1,33 @@ +-- V148: webchat visitor-session archive flag + visitor-token revocation registry. +-- +-- 1) mate_conversation.archived — INT, 0 (default) = active, 1 = archived. +-- Lets a visitor "soft-close" a thread: it stays in the DB (history +-- preserved, downloadable, addressable by sessionId) but is excluded +-- from the default /sessions listing. Pinned/archived are orthogonal: +-- archive dominates (an archived+pinned thread is still hidden by default). +-- +-- 2) webchat_revoked_visitor — registry of visitors whose visitorToken HMAC +-- is no longer accepted on management endpoints (list/messages/title/ +-- delete/stop/upload/regenerate). /stream is intentionally NOT bound by +-- this: a revoked visitor can still start a fresh /stream, which mints +-- a new token; the revocation applies to the old token presented on +-- management endpoints. The (channel_id, visitor_id) pair is unique among +-- non-deleted rows so a re-revoke is idempotent; setting deleted=1 +-- un-revokes. + +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS archived INT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS webchat_revoked_visitor ( + id BIGINT NOT NULL PRIMARY KEY, + channel_id BIGINT NOT NULL, + visitor_id VARCHAR(128) NOT NULL, + revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reason VARCHAR(255), + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_webchat_revoked_visitor + ON webchat_revoked_visitor (channel_id, visitor_id, deleted); +CREATE INDEX IF NOT EXISTS idx_webchat_revoked_visitor_lookup + ON webchat_revoked_visitor (channel_id, visitor_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V148__webchat_archive_and_revocation.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V148__webchat_archive_and_revocation.sql new file mode 100644 index 00000000..5a77a682 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V148__webchat_archive_and_revocation.sql @@ -0,0 +1,19 @@ +-- V148: webchat visitor-session archive flag + visitor-token revocation registry (KingbaseES / PostgreSQL). +-- See the H2 copy for full context. + +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS archived INT NOT NULL DEFAULT 0; + +CREATE TABLE IF NOT EXISTS webchat_revoked_visitor ( + id BIGINT NOT NULL PRIMARY KEY, + channel_id BIGINT NOT NULL, + visitor_id VARCHAR(128) NOT NULL, + revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reason VARCHAR(255), + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_webchat_revoked_visitor + ON webchat_revoked_visitor (channel_id, visitor_id, deleted); +CREATE INDEX IF NOT EXISTS idx_webchat_revoked_visitor_lookup + ON webchat_revoked_visitor (channel_id, visitor_id, deleted); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V148__webchat_archive_and_revocation.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V148__webchat_archive_and_revocation.sql new file mode 100644 index 00000000..f9fce31c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V148__webchat_archive_and_revocation.sql @@ -0,0 +1,30 @@ +-- V148: webchat visitor-session archive flag + visitor-token revocation registry (MySQL). +-- See the H2 copy for full context. + +-- 1) archived column — MySQL 8.0 has no ADD COLUMN IF NOT EXISTS. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_conversation' + AND COLUMN_NAME = 'archived' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_conversation ADD COLUMN archived INT NOT NULL DEFAULT 0 COMMENT ''webchat: 0 = active, 1 = archived (hidden from default /sessions listing)''', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 2) revoked-visitor registry +CREATE TABLE IF NOT EXISTS webchat_revoked_visitor ( + id BIGINT NOT NULL PRIMARY KEY, + channel_id BIGINT NOT NULL, + visitor_id VARCHAR(128) NOT NULL, + revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + reason VARCHAR(255), + create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted INT NOT NULL DEFAULT 0, + UNIQUE KEY uk_webchat_revoked_visitor (channel_id, visitor_id, deleted), + KEY idx_webchat_revoked_visitor_lookup (channel_id, visitor_id, deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java index c557f55c..03b568c8 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java @@ -195,7 +195,7 @@ class WebChatCreateSessionTest { controller.createSession(API_KEY, req("visitorI", "s-listed", null)); String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorI"); - R r = controller.listSessions(API_KEY, token, "visitorI"); + R r = controller.listSessions(API_KEY, token, "visitorI", false); assertThat(r.getCode()).isEqualTo(200); assertThat(((java.util.List) (Object) r.getData())) .extracting(WebChatController.WebChatSessionView::getSessionId) diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java new file mode 100644 index 00000000..c27856db --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java @@ -0,0 +1,173 @@ +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.channel.webchat.WebChatController.WebChatSessionView; +import vip.mate.common.result.R; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of the V148 schema additions and the + * {@code archived} filtering / view-field exposure that rides on them: + *
    + *
  • {@code mate_conversation.archived} column exists and is read/write.
  • + *
  • {@code webchat_revoked_visitor} table exists (full DDL validation + * happens implicitly — Flyway would have failed to apply the migration + * otherwise; here we only verify the table is queryable).
  • + *
  • {@link WebChatSessionView} now carries {@code pinned/archived/ + * streamStatus}, so the visitor-side listing surfaces the same state + * the admin console sees.
  • + *
  • {@code loadVisitorSessions} filters out archived threads by default; + * {@code includeArchived=true} opts back in.
  • + *
+ */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_schema_${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 WebChatSchemaFieldsTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; + private static final long CHANNEL_ID = 9_147_301L; + private static final long AGENT_ID = 9_147_3011L; + + @Autowired private WebChatController controller; + @Autowired private ConversationService conversationService; + @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( + "MERGE INTO mate_agent (id, name, agent_type, system_prompt, max_iterations, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, 'wc-schema-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); + } + + @Test + @DisplayName("revoked-visitor table is queryable (DDL applied)") + void revokedVisitorTableExists() { + // Insert + read back a row to prove the table + columns are live. + jdbc.update("INSERT INTO webchat_revoked_visitor (id, channel_id, visitor_id, reason) " + + "VALUES (?, ?, ?, ?)", 9991L, CHANNEL_ID, "schema-probe", "test"); + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ?", + Integer.class, CHANNEL_ID, "schema-probe"); + assertThat(count).isEqualTo(1); + jdbc.update("DELETE FROM webchat_revoked_visitor WHERE id = ?", 9991L); + } + + @Test + @DisplayName("archived column on mate_conversation is read/write") + void archivedColumnReadWrite() { + controller.createSession(API_KEY, req("visitorArch", "s1")); + String cid = WebChatController.deriveConversationId(API_KEY, "visitorArch", "s1"); + + Integer before = jdbc.queryForObject( + "SELECT archived FROM mate_conversation WHERE conversation_id = ?", + Integer.class, cid); + assertThat(before).isZero(); + + jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid); + Integer after = jdbc.queryForObject( + "SELECT archived FROM mate_conversation WHERE conversation_id = ?", + Integer.class, cid); + assertThat(after).isEqualTo(1); + } + + @Test + @DisplayName("WebChatSessionView exposes pinned/archived/streamStatus") + void viewExposesNewFields() { + controller.createSession(API_KEY, req("visitorView", "s1")); + // Flip pinned via the service (endpoint comes in PR 3) so we can assert the view mirrors it. + String cid = WebChatController.deriveConversationId(API_KEY, "visitorView", "s1"); + conversationService.setPinned(cid, true); + + R> r = controller.listSessions( + API_KEY, tokenFor("visitorView"), "visitorView", false); + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData()).hasSize(1); + WebChatSessionView view = r.getData().get(0); + assertThat(view.getPinned()).isEqualTo(1); + assertThat(view.getArchived()).isZero(); + assertThat(view.getStreamStatus()).isEqualTo("idle"); + } + + @Test + @DisplayName("archived threads are hidden from /sessions by default") + @SuppressWarnings("unchecked") + void archivedHiddenByDefault() { + controller.createSession(API_KEY, req("visitorHide", "active")); + controller.createSession(API_KEY, req("visitorHide", "stale")); + + String staleCid = WebChatController.deriveConversationId(API_KEY, "visitorHide", "stale"); + jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", staleCid); + + // Default: only "active" is returned. + R def = controller.listSessions(API_KEY, tokenFor("visitorHide"), "visitorHide", false); + assertThat(((List) def.getData())) + .extracting(WebChatSessionView::getSessionId) + .containsExactly("active"); + + // includeArchived=true: both. + R all = controller.listSessions(API_KEY, tokenFor("visitorHide"), "visitorHide", true); + assertThat(((List) all.getData())) + .extracting(WebChatSessionView::getSessionId) + .containsExactlyInAnyOrder("active", "stale"); + } + + @Test + @DisplayName("archived empty threads don't count against the 5-empty-session quota") + void archivedExcludedFromQuota() { + // Pre-seed 5 archived empty threads + verify a 6th (active) creation still succeeds — + // the quota gate filters archived out, so the active count is 0 here. + String owner = WebChatController.webchatUsername("visitorQuota"); + for (int i = 1; i <= 5; i++) { + String cid = WebChatController.deriveConversationId(API_KEY, "visitorQuota", "arch" + i); + conversationService.getOrCreateWebchatConversation( + cid, AGENT_ID, owner, 1L, "arch" + i); + jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid); + } + + R r = controller.createSession(API_KEY, req("visitorQuota", "fresh")); + assertThat(r.getCode()) + .as("archived threads must not saturate the empty-session quota") + .isEqualTo(200); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java index c0fe81f2..80210a3c 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java @@ -76,7 +76,7 @@ class WebChatSessionManagementTest { @DisplayName("listSessions includes the hashed long-id thread with its sessionId recovered") @SuppressWarnings("unchecked") void listsHashedThread() { - R> r = (R>) (R) controller.listSessions(API_KEY, token, VISITOR); + R> r = (R>) (R) controller.listSessions(API_KEY, token, VISITOR, false); assertThat(r.getCode()).isEqualTo(200); List sessions = r.getData(); assertThat(sessions).hasSize(3); @@ -88,21 +88,21 @@ class WebChatSessionManagementTest { @DisplayName("bad visitor token is rejected") @SuppressWarnings("unchecked") void rejectsBadToken() { - R> r = (R>) (R) controller.listSessions(API_KEY, "bogus", VISITOR); + R> r = (R>) (R) controller.listSessions(API_KEY, "bogus", VISITOR, false); assertThat(r.getCode()).isEqualTo(401); } @Test @DisplayName("pageSessions paginates and keyword-searches by title") void pagesAndSearches() { - R> page = controller.pageSessions(API_KEY, token, VISITOR, 1, 2, null); + R> page = controller.pageSessions(API_KEY, token, VISITOR, 1, 2, null, false); assertThat(page.getCode()).isEqualTo(200); assertThat(page.getData().get("total")).isEqualTo(3L); assertThat((List) page.getData().get("items")).hasSize(2); // Rename one thread, then search for it. controller.renameSession(API_KEY, token, VISITOR, "s1", Map.of("title", "QuarterlyReport")); - R> hit = controller.pageSessions(API_KEY, token, VISITOR, 1, 20, "quarterly"); + R> hit = controller.pageSessions(API_KEY, token, VISITOR, 1, 20, "quarterly", false); assertThat((List) hit.getData().get("items")).hasSize(1); }