From ee3e3919773fdd2313267cd163d3787e7932d4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Wed, 17 Jun 2026 22:09:57 +0800 Subject: [PATCH] fix(webchat): list sessions whose conversationId hashed (long ids) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When webchat::: exceeds 64 chars the conversationId folds visitorId+sessionId into an unrecoverable hash, so the thread fell outside listSessions' conversationId-prefix filter and its sessionId could not be recovered — the thread was invisible and unaddressable (common with a UUID visitorId + a >10-char sessionId). Persist the sessionId on creation (new nullable webchat_session_id column) and enumerate by username + channel prefix (webchat::), which also matches the hashed form. sessionId is read from the column, falling back to parsing the conversationId only for legacy rows. Adds a @SpringBootTest covering listing (incl. the hashed thread), message pagination, session paging/search, rename, and token rejection end-to-end. Refs matevip/mateclaw#346 --- .../channel/webchat/WebChatController.java | 38 ++++- .../conversation/ConversationService.java | 20 +++ .../model/ConversationEntity.java | 8 ++ .../conversation/vo/ConversationVO.java | 1 + .../migration/h2/V147__webchat_session_id.sql | 6 + .../kingbase/V147__webchat_session_id.sql | 3 + .../mysql/V147__webchat_session_id.sql | 15 ++ .../webchat/WebChatSessionManagementTest.java | 136 ++++++++++++++++++ 8 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V147__webchat_session_id.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V147__webchat_session_id.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V147__webchat_session_id.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java 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 dea37f98..f1a22f15 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 @@ -166,7 +166,8 @@ public class WebChatController { // 创建或获取会话(workspace 从 agent 获取) var webAgent = agentService.getAgent(resolvedAgentId); Long webWsId = webAgent != null ? webAgent.getWorkspaceId() : 1L; - var conv = conversationService.getOrCreateConversation(conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId); + var conv = conversationService.getOrCreateWebchatConversation( + conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId, effectiveSessionId); // 保存用户消息(含访客本轮引用的附件)。附件元数据一律服务端按 fileId 回查, // 不信客户端传入;path 用于 Agent 侧工具读取,对外消息视图会被剥离。 @@ -392,23 +393,50 @@ public class WebChatController { * Load this visitor's session threads (own namespace only), mapped to the * compact view. Sorted as {@code listConversations} returns them (pinned * desc, last-active desc). Shared by the list and paginated endpoints. + *

+ * Enumeration is keyed by the visitor's username plus the channel prefix + * ({@code webchat::}) rather than the full conversationId prefix, so it + * still catches threads whose conversationId hashed (long visitorId + + * sessionId). The sessionId is read from the persisted {@code webchatSessionId} + * column (set on creation) and only falls back to parsing the conversationId + * for legacy rows created before that column existed. */ private List loadVisitorSessions(String apiKey, String visitorId) { String base = deriveConversationId(apiKey, visitorId, null); - String prefix = base + ":"; + String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":"; String owner = webchatUsername(visitorId); return conversationService.listConversations(owner).stream() .filter(c -> c.getConversationId() != null && owner.equals(c.getUsername()) - && (c.getConversationId().equals(base) || c.getConversationId().startsWith(prefix))) + && c.getConversationId().startsWith(channelPrefix)) .map(c -> { - String cid = c.getConversationId(); - String sid = cid.equals(base) ? null : cid.substring(prefix.length()); + String sid = recoverSessionId(c, base); return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), c.getMessageCount()); }) .collect(Collectors.toList()); } + /** + * Recover a thread's sessionId. Prefers the persisted column; for legacy + * rows (column null) falls back to parsing the non-hashed conversationId. + * Returns null for the default (no-session) thread and for legacy hashed rows + * whose sessionId can no longer be reconstructed. + */ + private String recoverSessionId(vip.mate.workspace.conversation.vo.ConversationVO c, String base) { + if (c.getWebchatSessionId() != null) { + return c.getWebchatSessionId(); + } + String cid = c.getConversationId(); + if (cid.equals(base)) { + return null; + } + String prefix = base + ":"; + if (cid.startsWith(prefix)) { + return cid.substring(prefix.length()); + } + return null; + } + /** * 获取某会话线程的消息列表(支持分页)。 *

不传 limit 时返回全部消息(向后兼容);传 limit 返回最新 limit 条 + hasMore; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 585187cc..8853c76a 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -300,6 +300,26 @@ public class ConversationService { return conv; } + /** + * WebChat get-or-create that also records the thread's {@code sessionId} on + * insert, so the visitor's /sessions listing can recover it even when the + * conversationId hashes (long visitorId + sessionId). The session id is + * written only when the row is first created; an existing row is left as-is. + */ + @Transactional + public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId, + String username, Long workspaceId, + String sessionId) { + boolean existed = conversationMapper.selectOne(new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, conversationId)) != null; + ConversationEntity conv = getOrCreateConversation(conversationId, agentId, username, workspaceId); + if (!existed && sessionId != null && !sessionId.isBlank() && conv.getWebchatSessionId() == null) { + conv.setWebchatSessionId(sessionId); + conversationMapper.updateById(conv); + } + return conv; + } + /** * Create a child conversation (delegation scenario), linking it back to * its parent via {@code parentConversationId}. 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 2242e49e..09236d4d 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 @@ -61,6 +61,14 @@ public class ConversationEntity { /** Model id this conversation is pinned to. See {@link #modelProvider}. */ private String modelName; + /** + * WebChat per-thread sessionId (see V147 migration). Persisted so it can be + * recovered for the visitor's /sessions listing even when the conversationId + * hashes (long visitorId + sessionId folds into an unrecoverable hash). NULL + * for non-webchat rows and for a visitor's default (no-session) thread. + */ + private String webchatSessionId; + /** * Per-conversation progress notebook JSON (see V100 migration). *

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 1b35bd05..3f583827 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 @@ -68,6 +68,7 @@ public class ConversationVO extends ConversationEntity { vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0); vo.setModelProvider(entity.getModelProvider()); vo.setModelName(entity.getModelName()); + vo.setWebchatSessionId(entity.getWebchatSessionId()); vo.setCreateTime(entity.getCreateTime()); vo.setUpdateTime(entity.getUpdateTime()); // 补充关联字段 diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V147__webchat_session_id.sql b/mateclaw-server/src/main/resources/db/migration/h2/V147__webchat_session_id.sql new file mode 100644 index 00000000..8d2ff752 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V147__webchat_session_id.sql @@ -0,0 +1,6 @@ +-- WebChat per-thread sessionId, persisted so it can be recovered even when the +-- conversationId hashes (visitorId + sessionId > 64 chars folds into a hash, +-- which is otherwise unrecoverable — making the thread invisible/unaddressable +-- in the visitor's /sessions listing). NULL for non-webchat rows and for a +-- visitor's default (no-session) thread. +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS webchat_session_id VARCHAR(64); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V147__webchat_session_id.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V147__webchat_session_id.sql new file mode 100644 index 00000000..36c7f171 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V147__webchat_session_id.sql @@ -0,0 +1,3 @@ +-- See the H2 copy for context. KingbaseES (PostgreSQL) supports +-- ADD COLUMN IF NOT EXISTS natively. +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS webchat_session_id VARCHAR(64); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V147__webchat_session_id.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V147__webchat_session_id.sql new file mode 100644 index 00000000..1b7b02f1 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V147__webchat_session_id.sql @@ -0,0 +1,15 @@ +-- See the H2 file for context. MySQL 8.0 doesn't support +-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through +-- INFORMATION_SCHEMA + a prepared statement. +SET @col_exists := ( + SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_conversation' + AND COLUMN_NAME = 'webchat_session_id' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_conversation ADD COLUMN webchat_session_id VARCHAR(64) NULL COMMENT ''WebChat per-thread sessionId (recoverable even when conversationId hashes)''', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; 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 new file mode 100644 index 00000000..c0fe81f2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSessionManagementTest.java @@ -0,0 +1,136 @@ +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.WebChatSessionView; +import vip.mate.common.result.R; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of the WebChat visitor session-management endpoints + * against a booted context + real H2 (migrations incl. V147 run). Exercises the + * controller's real auth (channel lookup + visitor-token HMAC), pagination, + * keyword search, rename, and — the key case — that a thread whose + * conversationId hashed (long visitorId + sessionId) is still listed with its + * sessionId recovered from the persisted column. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_sess_test_${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 WebChatSessionManagementTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" + private static final String VISITOR = "visitorAAAA"; + private static final long CHANNEL_ID = 9_147_001L; + // Long enough that "webchat:testkey1:visitorAAAA:" exceeds 64 chars and hashes. + private static final String LONG_SESSION = "session-1234567890-abcdefghij-klmnopqrst"; + + @Autowired private WebChatController controller; + @Autowired private ConversationService conversationService; + @Autowired private JdbcTemplate jdbc; + + private String token; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID); + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, 'wc', 'webchat', ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + + String owner = WebChatController.webchatUsername(VISITOR); + // default thread (no sessionId) + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(API_KEY, VISITOR, null), null, owner, 1L, null); + // short sessioned thread + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(API_KEY, VISITOR, "s1"), null, owner, 1L, "s1"); + // long sessioned thread → conversationId hashes + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(API_KEY, VISITOR, LONG_SESSION), null, owner, 1L, LONG_SESSION); + + token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, VISITOR); + } + + @Test + @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); + assertThat(r.getCode()).isEqualTo(200); + List sessions = r.getData(); + assertThat(sessions).hasSize(3); + assertThat(sessions).extracting(WebChatSessionView::getSessionId) + .containsExactlyInAnyOrder(null, "s1", LONG_SESSION); + } + + @Test + @DisplayName("bad visitor token is rejected") + @SuppressWarnings("unchecked") + void rejectsBadToken() { + R> r = (R>) (R) controller.listSessions(API_KEY, "bogus", VISITOR); + 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); + 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"); + assertThat((List) hit.getData().get("items")).hasSize(1); + } + + @Test + @DisplayName("rename updates the thread title") + void renames() { + R r = controller.renameSession(API_KEY, token, VISITOR, "s1", Map.of("title", "Renamed")); + assertThat(r.getCode()).isEqualTo(200); + + String cid = WebChatController.deriveConversationId(API_KEY, VISITOR, "s1"); + String title = jdbc.queryForObject( + "SELECT title FROM mate_conversation WHERE conversation_id = ?", String.class, cid); + assertThat(title).isEqualTo("Renamed"); + } + + @Test + @DisplayName("sessionMessages paginates with hasMore") + @SuppressWarnings("unchecked") + void paginatesMessages() { + String cid = WebChatController.deriveConversationId(API_KEY, VISITOR, "s1"); + conversationService.saveMessage(cid, "user", "m1"); + conversationService.saveMessage(cid, "assistant", "m2"); + conversationService.saveMessage(cid, "user", "m3"); + + R r = controller.sessionMessages(API_KEY, token, VISITOR, "s1", null, 2); + assertThat(r.getCode()).isEqualTo(200); + Map data = (Map) r.getData(); + assertThat((List) data.get("messages")).hasSize(2); + assertThat(data.get("hasMore")).isEqualTo(true); + } +}