From 6a4c3b3bf6c69c7732f953a6a49bc8aaa09b955b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Thu, 23 Jul 2026 18:15:21 +0800 Subject: [PATCH 1/2] fix(channel): scope webchat conversationId by channel id (#558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deriveConversationId identified a channel by the first 8 chars of its apiKey, but all generated keys share the 11-char prefix "mc_webchat_", so the slice was the constant "mc_webch" for every channel. Two channels with the same visitorId derived the SAME conversationId, collapsing their sessions together — channel isolation was entirely lost. Embed the stable channel DB id (channel.getId()) into the conversationId instead, and keep pre-fix sessions visible via read-time fallback: - deriveConversationId now takes Long channelId; new format webchat::[:] - legacyConversationId preserves the old key8 derivation for resolving existing webchat:mc_webch:... rows; resolveSessionConversationId prefers the current id and falls back to legacy - loadVisitorSessions matches both the channel and legacy prefixes, and recoverSessionId parses against both bases so legacy sessions stay listable/readable. Already-collided rows cannot be retroactively split. Collided legacy data is not migrated (pure code fix, no Flyway). Adds ConversationService.conversationExists for the resolver. Tests: migrates all call sites to channelId; adds WebChatChannelIsolation (cross-channel collision regression) and WebChatLegacySessionCompat (read-time fallback). Full WebChat* suite green (111 tests). --- .../channel/webchat/WebChatController.java | 115 +++++++++++----- .../WebChatApprovalInteractionTest.java | 8 +- .../webchat/WebChatArchivePinTest.java | 2 +- .../webchat/WebChatAttachmentE2ETest.java | 8 +- .../webchat/WebChatChannelIsolationTest.java | 123 ++++++++++++++++++ .../webchat/WebChatCreateSessionTest.java | 4 +- .../WebChatLegacySessionCompatTest.java | 102 +++++++++++++++ .../webchat/WebChatRegenerateTest.java | 8 +- .../webchat/WebChatSchemaFieldsTest.java | 8 +- .../webchat/WebChatSessionManagementTest.java | 10 +- .../webchat/WebChatStopStreamTest.java | 2 +- .../channel/webchat/WebChatStreamE2ETest.java | 12 +- .../webchat/WebChatVisitorTokenTest.java | 26 +++- 13 files changed, 360 insertions(+), 68 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatChannelIsolationTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatLegacySessionCompatTest.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 1383ede26..da849d5c8 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 @@ -159,7 +159,7 @@ public SseEmitter chatStream( sendErrorAndComplete(emitter, ex.getMessage()); return emitter; } - String conversationId = deriveConversationId(apiKey, visitorId, effectiveSessionId); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, effectiveSessionId); // Server-issued, unforgeable proof that this caller owns this visitorId. Returned in the // meta event below; the session-management endpoints require it back (see verifyVisitorToken). final String visitorToken = computeVisitorToken(visitorTokenSecret, channel.getId(), visitorId); @@ -621,7 +621,7 @@ public R> createSession( return R.fail(400, "title 不合法(1-100 字)"); } - String conversationId = deriveConversationId(apiKey, visitorId, sessionId); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sessionId); String owner = webchatUsername(visitorId); // Idempotency: existing thread is returned as-is. Title and every other @@ -636,7 +636,7 @@ public R> createSession( // Quota: count empty threads this visitor already holds on this channel. // loadVisitorSessions already scopes to (channel prefix ∩ visitor owner). - long emptyCount = loadVisitorSessions(apiKey, visitorId).stream() + long emptyCount = loadVisitorSessions(channel.getId(), apiKey, visitorId).stream() .filter(s -> s.getMessageCount() == null || s.getMessageCount() == 0) .count(); if (emptyCount >= MAX_EMPTY_SESSIONS_PER_VISITOR) { @@ -698,7 +698,7 @@ public R> listSessions( if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) { return R.fail(401, "Invalid or missing visitor token"); } - return R.ok(loadVisitorSessions(apiKey, visitorId, includeArchived)); + return R.ok(loadVisitorSessions(channel.getId(), apiKey, visitorId, includeArchived)); } /** @@ -771,7 +771,7 @@ public R renameSession( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -810,7 +810,7 @@ public R pinSession( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -849,7 +849,7 @@ public R archiveSession( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -875,8 +875,8 @@ public R archiveSession( * 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) { - return loadVisitorSessions(apiKey, visitorId, false); + private List loadVisitorSessions(Long channelId, String apiKey, String visitorId) { + return loadVisitorSessions(channelId, apiKey, visitorId, false); } /** @@ -887,10 +887,16 @@ private List loadVisitorSessions(String apiKey, String visit * against the "≤ 5 empty threads" quota (the visitor already declared * they're done with them). */ - private List loadVisitorSessions(String apiKey, String visitorId, + private List loadVisitorSessions(Long channelId, String apiKey, String visitorId, boolean includeArchived) { - String base = deriveConversationId(apiKey, visitorId, null); - String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":"; + String base = deriveConversationId(channelId, visitorId, null); + String legacyBase = legacyConversationId(apiKey, visitorId, null); + String channelPrefix = "webchat:" + channelId + ":"; + // Backward compat (#558): pre-fix conversations were keyed by the apiKey's first + // 8 chars (always "mc_webch" for generated keys), which collided across channels. + // Keep matching that legacy prefix so existing sessions stay visible; they remain + // shared across channels (already-collided rows cannot be split retroactively). + String legacyPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":"; String owner = webchatUsername(visitorId); // Query is scoped to this visitor's own rows only (no system rows), so // listing a visitor's threads doesn't load every IM/cron conversation. @@ -898,12 +904,13 @@ private List loadVisitorSessions(String apiKey, String visit // '_' / '%' in the api key's first 8 chars can't act as a LIKE wildcard. return conversationService.listWebchatConversations(owner).stream() .filter(c -> c.getConversationId() != null - && c.getConversationId().startsWith(channelPrefix)) + && (c.getConversationId().startsWith(channelPrefix) + || c.getConversationId().startsWith(legacyPrefix))) .filter(c -> includeArchived || c.getArchived() == null || c.getArchived() == 0) .map(c -> { - String sid = recoverSessionId(c, base); + String sid = recoverSessionId(c, base, legacyBase); return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), c.getMessageCount(), c.getPinned() != null ? c.getPinned() : 0, @@ -915,21 +922,25 @@ private List loadVisitorSessions(String apiKey, String visit /** * Recover a thread's sessionId. Prefers the persisted column; for legacy - * rows (column null) falls back to parsing the non-hashed conversationId. + * rows (column null) falls back to parsing the non-hashed conversationId + * against both the current (channel-scoped) and legacy (apiKey-prefix) bases. * 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.model.ConversationEntity c, String base) { + private String recoverSessionId(vip.mate.workspace.conversation.model.ConversationEntity c, + String base, String legacyBase) { if (c.getWebchatSessionId() != null) { return c.getWebchatSessionId(); } String cid = c.getConversationId(); - if (cid.equals(base)) { + if (cid.equals(base) || cid.equals(legacyBase)) { return null; } - String prefix = base + ":"; - if (cid.startsWith(prefix)) { - return cid.substring(prefix.length()); + for (String b : new String[]{base, legacyBase}) { + String prefix = b + ":"; + if (cid.startsWith(prefix)) { + return cid.substring(prefix.length()); + } } return null; } @@ -961,7 +972,7 @@ public R sessionMessages( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -1015,7 +1026,7 @@ public R deleteSession( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -1060,7 +1071,7 @@ public R> stopSession( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); // ownsConversation is the existence + ownership guard: an unknown sessionId // maps to a conversationId that either doesn't exist or belongs to someone // else — both return 404 so the caller can't probe the namespace. @@ -1136,7 +1147,7 @@ public R> denySession( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -1204,7 +1215,7 @@ public SseEmitter approveSession( sendErrorAndComplete(emitter, ex.getMessage()); return emitter; } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { sendErrorAndComplete(emitter, "Session not found"); return emitter; @@ -1419,7 +1430,7 @@ public SseEmitter regenerateSession( sendErrorAndComplete(emitter, ex.getMessage()); return emitter; } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { sendErrorAndComplete(emitter, "Session not found"); return emitter; @@ -1486,7 +1497,7 @@ public R> uploadFile( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, vid, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, vid, sid); try { WebChatFileService.StagedFile stored = fileService.store(conversationId, file); audit(channel, vid, "webchat.upload-file", conversationId, @@ -1531,7 +1542,7 @@ public ResponseEntity downloadFile( } catch (IllegalArgumentException ex) { return ResponseEntity.badRequest().build(); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = resolveSessionConversationId(channel.getId(), apiKey, visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return ResponseEntity.status(404).build(); } @@ -1647,12 +1658,34 @@ private String normalizeVisitorId(String raw) { } /** - * 由服务端拼装 conversationId,始终钳在 key + visitor 命名空间内。 + * 由服务端拼装 conversationId,始终钳在 channel + visitor 命名空间内。 * 绝不接受调用方传入的裸 conversationId。 - *

conversation_id 列为 VARCHAR(64);当 visitorId + sessionId 过长导致超出列宽时, - * 把可变部分折叠为稳定哈希,保证 id 唯一且有界(否则 INSERT 会在 /stream 处 500)。 + *

渠道身份用 {@code channel.getId()}(稳定唯一的 DB 主键)体现。不能用 apiKey 前缀: + * 所有生成的 key 共享 11 位前缀 {@code mc_webchat_},截取短于它的子串(旧实现取前 8 位) + * 对每个渠道都是同一个常量 {@code mc_webch},渠道维度隔离完全失效(#558)。 + *

conversation_id 列为 VARCHAR(64);当 channelId + visitorId + sessionId 过长导致超出 + * 列宽时,把可变部分折叠为稳定哈希,并按 channelId 长度自适应截断哈希,保证 id 唯一且有界 + * (否则 INSERT 会在 /stream 处 500)。 + */ + static String deriveConversationId(Long channelId, String visitorId, String sessionId) { + String chan = String.valueOf(channelId); + String full = "webchat:" + chan + ":" + visitorId + (sessionId != null ? ":" + sessionId : ""); + if (full.length() <= 64) { + return full; + } + int hashLen = 64 - ("webchat:".length() + chan.length() + ":#".length()); + String digest = sha256Hex(visitorId + "\0" + (sessionId == null ? "" : sessionId)); + return "webchat:" + chan + ":#" + + digest.substring(0, Math.max(8, Math.min(hashLen, digest.length()))); + } + + /** + * 修复前(#558 之前)的 conversationId 派生规则:取 apiKey 前 8 位当渠道标识。 + *

仅为读时兼容保留——用于识别修复前已落盘的 {@code webchat:mc_webch:...} 遗留会话, + * 使其仍可被列出与操作({@link #resolveSessionConversationId} / {@link #loadVisitorSessions})。 + * 新会话一律走 {@link #deriveConversationId(Long, String, String)},切勿用本方法生成新 id。 */ - static String deriveConversationId(String apiKey, String visitorId, String sessionId) { + static String legacyConversationId(String apiKey, String visitorId, String sessionId) { String key8 = apiKey.substring(0, Math.min(8, apiKey.length())); String full = "webchat:" + key8 + ":" + visitorId + (sessionId != null ? ":" + sessionId : ""); if (full.length() <= 64) { @@ -1662,6 +1695,24 @@ static String deriveConversationId(String apiKey, String visitorId, String sessi + sha256Hex(visitorId + "\0" + (sessionId == null ? "" : sessionId)).substring(0, 40); } + /** + * 解析会话线程的 conversationId:优先当前(channel 维度)格式;若不存在则回退到修复前的 + * 遗留格式({@link #legacyConversationId});两者都不存在时返回当前格式(新建会话走新格式)。 + *

这样修复前已落盘的遗留会话仍可被读取/续接而不会「消失」;新会话则按渠道正确隔离。 + * 遗留的撞键会话(同 visitorId 跨渠道已合并为同一行)无法回溯拆分,维持原状(#558)。 + */ + private String resolveSessionConversationId(Long channelId, String apiKey, String visitorId, String sessionId) { + String current = deriveConversationId(channelId, visitorId, sessionId); + if (conversationService.conversationExists(current)) { + return current; + } + String legacy = legacyConversationId(apiKey, visitorId, sessionId); + if (!legacy.equals(current) && conversationService.conversationExists(legacy)) { + return legacy; + } + return current; + } + /** * 由 visitorId 派生 username(mate_conversation.username,VARCHAR(64))。 * 同样在超长时折叠为哈希,避免 username 溢出列宽。 diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java index f2cb57d46..f184459f9 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatApprovalInteractionTest.java @@ -79,7 +79,7 @@ private String tokenFor(String visitorId) { private String seedPending(String visitorId, String sessionId) { controller.createSession(API_KEY, req(visitorId, sessionId)); - String cid = WebChatController.deriveConversationId(API_KEY, visitorId, sessionId); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, visitorId, sessionId); // The actor stored on the approval is the webchat username, mirroring // how chatStream sets it via webchatUsername(visitorId). String actor = "webchat:" + API_KEY.substring(0, 8) + ":" + visitorId; @@ -94,7 +94,7 @@ private String seedPending(String visitorId, String sessionId) { @DisplayName("deny resolves a pending approval and broadcasts tool_approval_resolved") void denyResolvesPending() { String pendingId = seedPending("visitorA", "s1"); - String cid = WebChatController.deriveConversationId(API_KEY, "visitorA", "s1"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorA", "s1"); // Register the stream so the broadcast has a live subscriber state. streamTracker.register(cid); @@ -173,7 +173,7 @@ void denyRejectsCrossVisitorPendingId() { assertThat(r.getCode()).isEqualTo(404); // The victim's approval is untouched. - String cidVictim = WebChatController.deriveConversationId(API_KEY, "victimX", "s1"); + String cidVictim = WebChatController.deriveConversationId(CHANNEL_ID, "victimX", "s1"); var stillPending = approvalService.getPending(pendingIdVictim); assertThat(stillPending).as("victim's approval must not be resolved by attacker").isPresent(); assertThat(stillPending.get().getStatus()).isEqualTo("pending"); @@ -196,7 +196,7 @@ void denyRejectsMismatchedPendingId() { @DisplayName("stop denies pending approvals on the conversation (approval sweep)") void stopSweepsPendingApprovals() { seedPending("visitorF", "s1"); - String cid = WebChatController.deriveConversationId(API_KEY, "visitorF", "s1"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorF", "s1"); // A pending approval exists before stop. assertThat(approvalService.findPendingByConversation(cid)).isNotNull(); diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java index dad31bdc3..3b60e3682 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java @@ -75,7 +75,7 @@ private String tokenFor(String visitorId) { @DisplayName("PUT /sessions/pinned flips the column + view reports pinned=1") void pinFlipsColumn() { controller.createSession(API_KEY, req("vPin", "s1")); - String cid = WebChatController.deriveConversationId(API_KEY, "vPin", "s1"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "vPin", "s1"); R r = controller.pinSession(API_KEY, tokenFor("vPin"), "vPin", "s1", Map.of("pinned", true)); diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java index 1d6136c2a..a9d1f2f57 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatAttachmentE2ETest.java @@ -272,7 +272,7 @@ void uploadThenStreamAttachesPath() throws Exception { stream(visitorId, sessionId, "please read the attached", "[\"" + fileId + "\"]"); - String cid = WebChatController.deriveConversationId(API_KEY, visitorId, sessionId); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, visitorId, sessionId); String parts = lastUserContentParts(cid); assertThat(parts).isNotNull(); // Text part is present. @@ -299,7 +299,7 @@ void unknownAttachmentIdDropped() throws Exception { String visitorId = "vAtt-unknown"; stream(visitorId, null, "hello", "[\"totally-bogus-file-id\"]"); - String cid = WebChatController.deriveConversationId(API_KEY, visitorId, null); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, visitorId, null); String parts = lastUserContentParts(cid); assertThat(parts).isNotNull(); assertThat(parts).contains("\"type\":\"text\""); @@ -320,11 +320,11 @@ void foreignAttachmentIdDropped() throws Exception { stream(visitorB, null, "trying to grab alice's file", "[\"" + aliceFileId + "\"]"); // B's conversation's user message has no file part. - String bobCid = WebChatController.deriveConversationId(API_KEY, visitorB, null); + String bobCid = WebChatController.deriveConversationId(CHANNEL_ID, visitorB, null); String bobParts = lastUserContentParts(bobCid); assertThat(bobParts).doesNotContain("\"type\":\"file\""); // Alice's conversation is untouched — no user message there at all. - String aliceCid = WebChatController.deriveConversationId(API_KEY, visitorA, null); + String aliceCid = WebChatController.deriveConversationId(CHANNEL_ID, visitorA, null); Integer aliceMsgCount = jdbc.queryForObject( "SELECT COUNT(*) FROM mate_message WHERE conversation_id = ? AND role = 'user'", Integer.class, aliceCid); diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatChannelIsolationTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatChannelIsolationTest.java new file mode 100644 index 000000000..b92fa5eca --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatChannelIsolationTest.java @@ -0,0 +1,123 @@ +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 static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression for ISSUE #558: WebChat channel session isolation. + *

+ * The bug: {@code deriveConversationId} identified a channel by the first 8 + * characters of its apiKey. All generated keys share the 11-char prefix + * {@code mc_webchat_}, so the 8-char slice was the constant {@code mc_webch} for + * every channel — two channels with the same visitorId derived the SAME + * conversationId, and their sessions collided / cross-leaked in + * {@code listSessions}. + *

+ * The fix embeds the stable channel DB id ({@code channel.getId()}) into the + * conversationId. This test seeds two channels whose apiKeys share their first + * 8 chars (mirroring the collision) plus the same visitorId, then asserts each + * channel's {@code listSessions} returns only its own sessions. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_iso_${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 WebChatChannelIsolationTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + // Both keys start with "mc_webchat_" → identical 8-char slice "mc_webch", + // reproducing the #558 collision under the pre-fix derivation. + private static final String API_KEY_A = "mc_webchat_aaa"; + private static final String API_KEY_B = "mc_webchat_bbb"; + private static final long CHANNEL_A = 9_155_001L; + private static final long CHANNEL_B = 9_155_002L; + private static final long AGENT_ID = 9_155_011L; + private static final String VISITOR = "sharedVisitor"; + + @Autowired private WebChatController controller; + @Autowired private ConversationService conversationService; + @Autowired private JdbcTemplate jdbc; + + @BeforeEach + void setUp() { + jdbc.update("DELETE FROM mate_channel WHERE id IN (?, ?)", CHANNEL_A, CHANNEL_B); + 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-iso-agent', 'react', '', 10, TRUE, 1, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + AGENT_ID); + seedChannel(CHANNEL_A, API_KEY_A); + seedChannel(CHANNEL_B, API_KEY_B); + + String owner = WebChatController.webchatUsername(VISITOR); + // Channel A gets two sessions; Channel B gets one — all under the same visitor. + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s1"), null, owner, 1L, "a-s1"); + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s2"), null, owner, 1L, "a-s2"); + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(CHANNEL_B, VISITOR, "b-s1"), null, owner, 1L, "b-s1"); + } + + private void seedChannel(long id, String apiKey) { + jdbc.update("INSERT INTO mate_channel (id, name, channel_type, agent_id, config_json, enabled, " + + "workspace_id, create_time, update_time, deleted) " + + "VALUES (?, ?, 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, "wc-" + id, AGENT_ID, "{\"api_key\":\"" + apiKey + "\"}"); + } + + private String tokenFor(long channelId) { + return WebChatController.computeVisitorToken(SECRET, channelId, VISITOR); + } + + @Test + @DisplayName("two channels sharing an apiKey prefix isolate their sessions (#558)") + @SuppressWarnings("unchecked") + void channelsWithSharedKeyPrefixAreIsolated() { + // Channel A lists only its own sessions. + R> ra = (R>) (R) + controller.listSessions(API_KEY_A, tokenFor(CHANNEL_A), VISITOR, false); + assertThat(ra.getCode()).isEqualTo(200); + assertThat(ra.getData()).extracting(WebChatSessionView::getSessionId) + .containsExactlyInAnyOrder("a-s1", "a-s2"); + + // Channel B lists only its own. + R> rb = (R>) (R) + controller.listSessions(API_KEY_B, tokenFor(CHANNEL_B), VISITOR, false); + assertThat(rb.getCode()).isEqualTo(200); + assertThat(rb.getData()).extracting(WebChatSessionView::getSessionId) + .containsExactly("b-s1"); + } + + @Test + @DisplayName("the two channels derive distinct conversationIds for one visitor") + void conversationIdsDifferAcrossChannels() { + // Under the #558 bug both calls returned the SAME id (webchat:mc_webch:...). + String cidA = WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "shared"); + String cidB = WebChatController.deriveConversationId(CHANNEL_B, VISITOR, "shared"); + assertThat(cidA).isNotEqualTo(cidB); + assertThat(cidA).startsWith("webchat:" + CHANNEL_A + ":"); + assertThat(cidB).startsWith("webchat:" + CHANNEL_B + ":"); + } +} 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 03b568c88..d3143fde7 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 @@ -85,7 +85,7 @@ void createsEmptySession() { Map data = r.getData(); assertThat(data.get("sessionId")).isEqualTo("s1"); assertThat(data.get("conversationId")) - .isEqualTo(WebChatController.deriveConversationId(API_KEY, "visitorA", "s1")); + .isEqualTo(WebChatController.deriveConversationId(CHANNEL_ID, "visitorA", "s1")); assertThat(data.get("visitorToken")) .isEqualTo(WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorA")); // No title supplied → default placeholder, will be derived from first user message later. @@ -156,7 +156,7 @@ void enforcesQuota() { String owner = WebChatController.webchatUsername("visitorE"); for (int i = 1; i <= 5; i++) { conversationService.getOrCreateWebchatConversation( - WebChatController.deriveConversationId(API_KEY, "visitorE", "seed" + i), + WebChatController.deriveConversationId(CHANNEL_ID, "visitorE", "seed" + i), null, owner, 1L, "seed" + i); } diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatLegacySessionCompatTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatLegacySessionCompatTest.java new file mode 100644 index 000000000..89ffaf938 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatLegacySessionCompatTest.java @@ -0,0 +1,102 @@ +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 static org.assertj.core.api.Assertions.assertThat; + +/** + * Read-time backward compatibility for ISSUE #558. + *

+ * Conversations created before the fix were keyed by the legacy derivation + * {@code webchat::[:]} (key8 = first 8 apiKey chars). + * The fix cannot retroactively split rows that already collided, so it keeps + * them visible via read-time fallback in {@code loadVisitorSessions} and + * {@code resolveSessionConversationId}. + *

+ * This test seeds a legacy-format conversation (+ a message) directly into the + * DB and asserts the visitor can still list it and read its messages, proving + * the fallback path works and pre-fix sessions don't "disappear". + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_legacy_${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 WebChatLegacySessionCompatTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "mc_webchat_xxx"; // key8 = "mc_webch" (legacy collision prefix) + private static final long CHANNEL_ID = 9_156_001L; + private static final long AGENT_ID = 9_156_011L; + private static final String VISITOR = "legacyVisitor"; + + @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-legacy-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-legacy', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + + String owner = WebChatController.webchatUsername(VISITOR); + // A legacy conversationId exactly as the pre-fix derivation produced it. + // The persisted webchat_session_id column is left NULL to force the + // recoverSessionId fallback path (parse the legacy id). + String legacyCid = WebChatController.legacyConversationId(API_KEY, VISITOR, "legacy-s1"); + conversationService.getOrCreateWebchatConversation(legacyCid, null, owner, 1L, null); + conversationService.saveMessage(legacyCid, "user", "hello from the past"); + } + + @Test + @DisplayName("legacy webchat::... session stays visible in listSessions") + @SuppressWarnings("unchecked") + void legacySessionIsStillListed() { + R> r = (R>) (R) + controller.listSessions(API_KEY, tokenFor(), VISITOR, false); + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData()).extracting(WebChatSessionView::getSessionId) + .contains("legacy-s1"); + } + + @Test + @DisplayName("legacy session messages are readable via the resolver fallback") + void legacySessionMessagesReadable() { + R r = controller.sessionMessages(API_KEY, tokenFor(), VISITOR, "legacy-s1", null, 50); + assertThat(r.getCode()).isEqualTo(200); + @SuppressWarnings("unchecked") + List messages = (List) ((java.util.Map) r.getData()).get("messages"); + assertThat(messages).isNotEmpty(); + } + + private String tokenFor() { + return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, VISITOR); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java index fa1639578..7b5ae04e0 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatRegenerateTest.java @@ -103,7 +103,7 @@ void rejectsEmptyThread() throws InterruptedException { waitForEmitterToSettle(emitter); // No user message → sendErrorAndComplete fires synchronously; assistant // count stays 0. - String cid = WebChatController.deriveConversationId(API_KEY, "vEmpty", "s1"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "vEmpty", "s1"); assertThat(countAssistantMessages(cid)).isZero(); } @@ -111,7 +111,7 @@ void rejectsEmptyThread() throws InterruptedException { @DisplayName("regenerate deletes the last assistant reply") void deletesLastAssistantReply() throws InterruptedException { controller.createSession(API_KEY, req("vDel", "s1")); - String cid = WebChatController.deriveConversationId(API_KEY, "vDel", "s1"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "vDel", "s1"); conversationService.saveMessage(cid, "user", "hello"); conversationService.saveMessage(cid, "assistant", "first reply"); @@ -137,7 +137,7 @@ void rejectsBadToken() throws InterruptedException { waitForEmitterToSettle(emitter); // No way to read the SSE event body from a raw SseEmitter in a unit test; // the assertion is implicit — no DB changes happen on the auth-fail path. - String cid = WebChatController.deriveConversationId(API_KEY, "vTok", "s1"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "vTok", "s1"); assertThat(countAssistantMessages(cid)).isZero(); } @@ -157,7 +157,7 @@ void rejectsUnknownSession() throws InterruptedException { @DisplayName("regenerate uses the last user message as the seed") void seedsFromLastUserMessage() throws InterruptedException { controller.createSession(API_KEY, req("vSeed", "s1")); - String cid = WebChatController.deriveConversationId(API_KEY, "vSeed", "s1"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "vSeed", "s1"); conversationService.saveMessage(cid, "user", "first question"); conversationService.saveMessage(cid, "assistant", "first reply"); conversationService.saveMessage(cid, "user", "second question"); 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 index c27856db1..0601c3504 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java @@ -97,7 +97,7 @@ void revokedVisitorTableExists() { @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"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorArch", "s1"); Integer before = jdbc.queryForObject( "SELECT archived FROM mate_conversation WHERE conversation_id = ?", @@ -116,7 +116,7 @@ void archivedColumnReadWrite() { 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"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorView", "s1"); conversationService.setPinned(cid, true); R> r = controller.listSessions( @@ -136,7 +136,7 @@ void archivedHiddenByDefault() { controller.createSession(API_KEY, req("visitorHide", "active")); controller.createSession(API_KEY, req("visitorHide", "stale")); - String staleCid = WebChatController.deriveConversationId(API_KEY, "visitorHide", "stale"); + String staleCid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorHide", "stale"); jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", staleCid); // Default: only "active" is returned. @@ -159,7 +159,7 @@ void archivedExcludedFromQuota() { // 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); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorQuota", "arch" + i); conversationService.getOrCreateWebchatConversation( cid, AGENT_ID, owner, 1L, "arch" + i); jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid); 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 80210a3cf..b2bd34c76 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 @@ -61,13 +61,13 @@ void setUp() { String owner = WebChatController.webchatUsername(VISITOR); // default thread (no sessionId) conversationService.getOrCreateWebchatConversation( - WebChatController.deriveConversationId(API_KEY, VISITOR, null), null, owner, 1L, null); + WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, null), null, owner, 1L, null); // short sessioned thread conversationService.getOrCreateWebchatConversation( - WebChatController.deriveConversationId(API_KEY, VISITOR, "s1"), null, owner, 1L, "s1"); + WebChatController.deriveConversationId(CHANNEL_ID, 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); + WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, LONG_SESSION), null, owner, 1L, LONG_SESSION); token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, VISITOR); } @@ -112,7 +112,7 @@ 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 cid = WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, "s1"); String title = jdbc.queryForObject( "SELECT title FROM mate_conversation WHERE conversation_id = ?", String.class, cid); assertThat(title).isEqualTo("Renamed"); @@ -122,7 +122,7 @@ void renames() { @DisplayName("sessionMessages paginates with hasMore") @SuppressWarnings("unchecked") void paginatesMessages() { - String cid = WebChatController.deriveConversationId(API_KEY, VISITOR, "s1"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, "s1"); conversationService.saveMessage(cid, "user", "m1"); conversationService.saveMessage(cid, "assistant", "m2"); conversationService.saveMessage(cid, "user", "m3"); diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java index f2561a884..44886853f 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStopStreamTest.java @@ -81,7 +81,7 @@ private String tokenFor(String visitorId) { @DisplayName("stop actually disposes the active subscription (chatStream wiring works)") void stopsActiveStream() { controller.createSession(API_KEY, req("visitorA", "s1")); - String cid = WebChatController.deriveConversationId(API_KEY, "visitorA", "s1"); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorA", "s1"); // Simulate what WebChatController.chatStream does right after .subscribe(): // register the run + bind the Disposable so requestStop() can dispose it. diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java index 9453abbc5..47513dc0b 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatStreamE2ETest.java @@ -242,7 +242,7 @@ void happyPath() throws Exception { .contains("\"sessionId\":null"); // Concatenated assistant reply persisted exactly once. - String cid = WebChatController.deriveConversationId(API_KEY, visitorId, null); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, visitorId, null); assertThat(countUserMessages(cid)).isEqualTo(1); assertThat(countAssistantMessages(cid)).isEqualTo(1); assertThat(lastAssistantContent(cid)).isEqualTo("Hello world!"); @@ -278,7 +278,7 @@ void multiChunkReply() throws Exception { assertThat(byName.get("meta")).hasSize(1); // Persisted assistant message: only the concatenated content (no thinking). - String cid = WebChatController.deriveConversationId(API_KEY, visitorId, null); + String cid = WebChatController.deriveConversationId(CHANNEL_ID, visitorId, null); assertThat(lastAssistantContent(cid)).isEqualTo("Final answer."); // Usage attribution lands on the row. @@ -303,9 +303,11 @@ void badApiKey() throws Exception { assertThat(err.name).isEqualTo("error"); assertThat(err.data).contains("Invalid API Key"); - // No conversation was created → no rows anywhere. + // No conversation was created → no rows anywhere. The bogus key never + // resolves a channel, so the channelId passed here is irrelevant; we just + // need a conversationId that maps to no persisted row. assertThat(countAssistantMessages( - WebChatController.deriveConversationId("bogus-key-not-registered", "vBad", null))).isZero(); + WebChatController.deriveConversationId(-1L, "vBad", null))).isZero(); } @Test @@ -358,7 +360,7 @@ void explicitSessionId() throws Exception { assertThat(meta.data) .contains("\"sessionId\":\"" + sessionId + "\"") .contains("\"conversationId\":\"" + - WebChatController.deriveConversationId(API_KEY, visitorId, sessionId) + "\""); + WebChatController.deriveConversationId(CHANNEL_ID, visitorId, sessionId) + "\""); } @Test diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java index 1ea13da22..0955a6f20 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatVisitorTokenTest.java @@ -124,19 +124,33 @@ void verify_rejectsTamperedExpiration() { @Test void deriveConversationId_staysWithin64_forLongInputs() { - String id = WebChatController.deriveConversationId("apikey1234567890", "v".repeat(120), "s".repeat(64)); + String id = WebChatController.deriveConversationId(9147001L, "v".repeat(120), "s".repeat(64)); assertTrue(id.length() <= 64, "conversationId must fit VARCHAR(64), got " + id.length()); // a legitimate 64-char sessionId alone already overflows the old scheme - String id2 = WebChatController.deriveConversationId("apikey1234567890", "alice", "s".repeat(64)); + String id2 = WebChatController.deriveConversationId(9147001L, "alice", "s".repeat(64)); assertTrue(id2.length() <= 64, "conversationId must fit VARCHAR(64), got " + id2.length()); } @Test void deriveConversationId_unchanged_forShortInputs() { - assertEquals("webchat:apikey12:alice:s1", - WebChatController.deriveConversationId("apikey1234567890", "alice", "s1")); - assertEquals("webchat:apikey12:alice", - WebChatController.deriveConversationId("apikey1234567890", "alice", null)); + assertEquals("webchat:9147001:alice:s1", + WebChatController.deriveConversationId(9147001L, "alice", "s1")); + assertEquals("webchat:9147001:alice", + WebChatController.deriveConversationId(9147001L, "alice", null)); + } + + // ===== #558: channel isolation — the channelId token must drive the prefix ===== + + @Test + void deriveConversationId_isChannelScoped_notApiKeyScoped() { + // The #558 bug: two channels whose apiKeys share a prefix (all generated keys + // start with "mc_webchat_") derived the SAME conversationId for one visitor, + // so sessions collided across channels. The channelId token must differ. + String chanA = WebChatController.deriveConversationId(1L, "alice", "s1"); + String chanB = WebChatController.deriveConversationId(2L, "alice", "s1"); + assertNotEquals(chanA, chanB, "different channels must derive different ids"); + assertEquals("webchat:1:alice:s1", chanA); + assertEquals("webchat:2:alice:s1", chanB); } @Test From 9b76fff1b7ca46ab3e88d9089ad7e6a63dc0ba9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Thu, 23 Jul 2026 19:05:58 +0800 Subject: [PATCH 2/2] fix(channel): persist channel_id to close legacy cross-channel leak (#558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The read-time fallback in the previous commit still leaked: the legacy prefix webchat:mc_webch: is the same constant for every channel (all generated keys share the 8-char slice), and username doesn't encode the channel either, so channel A's listSessions matched channel B's legacy sessions for the same visitor. Reproduced by an isolation test that seeds two channels sharing an apiKey prefix. Root fix: persist the owning channel on each webchat row. - Flyway V171 (h2/mysql/kingbase): add channel_id BIGINT NULL to mate_conversation. No inline backfill — pre-fix rows can't be reliably reconstructed and are left NULL. - ConversationEntity: add channelId field. - getOrCreateWebchatConversation: new overload carries channelId, written on insert; chatStream and createSession now pass channel.getId(). - listWebchatConversations(username, channelId): DB filters channel_id = ? OR channel_id IS NULL (channel-scoped rows exactly, pre-fix rows still returned for in-memory legacy-prefix matching). - loadVisitorSessions: channel_id-present rows are accepted as-is (fully isolated by the query); NULL rows fall through to the legacy-base startsWith, which keeps already-collided rows visible (they cannot be split) without leaking the new channel-scoped rows. Limitation (documented + tested): pre-fix collided rows (channel_id NULL, shared cid) remain visible across the channels that collided under the old key8 derivation — this is unrecoverable dirty data, not a regression. New sessions are strictly isolated. Tests: WebChatChannelIsolationTest now seeds channel_id-persisted rows and asserts channel B's session does NOT appear in channel A's listing (the regression the prior commit failed), plus a shared-legacy row that correctly stays visible to both. Full WebChat* suite green (114 tests). --- .../channel/webchat/WebChatController.java | 41 +++++++----- .../conversation/ConversationService.java | 41 +++++++++++- .../model/ConversationEntity.java | 11 ++++ .../h2/V171__conversation_channel_id.sql | 7 ++ .../V171__conversation_channel_id.sql | 3 + .../mysql/V171__conversation_channel_id.sql | 15 +++++ .../webchat/WebChatChannelIsolationTest.java | 66 +++++++++++++++---- .../webchat/WebChatCreateSessionTest.java | 2 +- .../webchat/WebChatSchemaFieldsTest.java | 2 +- .../webchat/WebChatSessionManagementTest.java | 6 +- 10 files changed, 159 insertions(+), 35 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V171__conversation_channel_id.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V171__conversation_channel_id.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V171__conversation_channel_id.sql 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 da849d5c8..e3fa0034f 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 @@ -189,7 +189,8 @@ public SseEmitter chatStream( var webAgent = agentService.getAgent(resolvedAgentId); Long webWsId = webAgent != null ? webAgent.getWorkspaceId() : 1L; var conv = conversationService.getOrCreateWebchatConversation( - conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId, effectiveSessionId); + conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId, + effectiveSessionId, null, channel.getId()); // 保存用户消息(含访客本轮引用的附件)。附件元数据一律服务端按 fileId 回查, // 不信客户端传入;path 用于 Agent 侧工具读取,对外消息视图会被剥离。 @@ -645,7 +646,7 @@ public R> createSession( } ConversationEntity conv = conversationService.getOrCreateWebchatConversation( - conversationId, agentId, owner, channel.getWorkspaceId(), sessionId, title); + conversationId, agentId, owner, channel.getWorkspaceId(), sessionId, title, channel.getId()); audit(channel, visitorId, "webchat.create-session", conversationId, "{\"sessionId\":\"" + sessionId + "\",\"idempotent\":false}"); return R.ok(buildCreateSessionResponse(conv, sessionId, channel.getId(), visitorId)); @@ -892,20 +893,30 @@ private List loadVisitorSessions(Long channelId, String apiK String base = deriveConversationId(channelId, visitorId, null); String legacyBase = legacyConversationId(apiKey, visitorId, null); String channelPrefix = "webchat:" + channelId + ":"; - // Backward compat (#558): pre-fix conversations were keyed by the apiKey's first - // 8 chars (always "mc_webch" for generated keys), which collided across channels. - // Keep matching that legacy prefix so existing sessions stay visible; they remain - // shared across channels (already-collided rows cannot be split retroactively). - String legacyPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":"; String owner = webchatUsername(visitorId); - // Query is scoped to this visitor's own rows only (no system rows), so - // listing a visitor's threads doesn't load every IM/cron conversation. - // The channel prefix is matched in-memory with a literal startsWith so a - // '_' / '%' in the api key's first 8 chars can't act as a LIKE wildcard. - return conversationService.listWebchatConversations(owner).stream() - .filter(c -> c.getConversationId() != null - && (c.getConversationId().startsWith(channelPrefix) - || c.getConversationId().startsWith(legacyPrefix))) + // Channel isolation (#558): the DB query returns rows whose channel_id + // matches exactly (fully isolated) PLUS rows where channel_id IS NULL + // (pre-fix rows whose channel could not be reconstructed). The NULL rows + // are inherently shared across channels that collided under the old key8 + // derivation and cannot be split retroactively, so they are kept visible + // by a literal startsWith against this visitor's own legacy base — they + // don't leak the *new* (channel-scoped) rows, which the DB already gated. + return conversationService.listWebchatConversations(owner, channelId).stream() + .filter(c -> { + if (c.getConversationId() == null) { + return false; + } + // channel_id present → already channel-scoped by the query; accept. + if (c.getChannelId() != null) { + return true; + } + // pre-fix row (channel_id NULL): accept only if it belongs to this + // visitor's namespace under either the current or legacy derivation. + return c.getConversationId().startsWith(channelPrefix) + || c.getConversationId().startsWith(legacyBase + ":") + || c.getConversationId().equals(legacyBase) + || c.getConversationId().equals(base); + }) .filter(c -> includeArchived || c.getArchived() == null || c.getArchived() == 0) 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 dab5143a9..450a562e8 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 @@ -363,7 +363,7 @@ public ConversationEntity getOrCreateConversation(String conversationId, Long ag public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId, String username, Long workspaceId, String sessionId) { - return getOrCreateWebchatConversation(conversationId, agentId, username, workspaceId, sessionId, null); + return getOrCreateWebchatConversation(conversationId, agentId, username, workspaceId, sessionId, null, null); } /** @@ -381,6 +381,22 @@ public ConversationEntity getOrCreateWebchatConversation(String conversationId, public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId, String username, Long workspaceId, String sessionId, String title) { + return getOrCreateWebchatConversation(conversationId, agentId, username, workspaceId, sessionId, title, null); + } + + /** + * WebChat get-or-create that also persists the owning {@code channelId} on + * insert (see V171, #558), so /sessions can filter by channel exactly + * instead of matching the collision-prone conversationId prefix. + *

+ * {@code channelId} is written only when the row is first created; an + * existing row is left as-is (its channel, or lack of one for pre-fix + * rows, is never retroactively rewritten here). + */ + @Transactional + public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId, + String username, Long workspaceId, + String sessionId, String title, Long channelId) { boolean existed = conversationMapper.selectOne(new LambdaQueryWrapper() .eq(ConversationEntity::getConversationId, conversationId)) != null; ConversationEntity conv = getOrCreateConversation(conversationId, agentId, username, workspaceId); @@ -394,6 +410,10 @@ public ConversationEntity getOrCreateWebchatConversation(String conversationId, conv.setTitle(title.trim()); dirty = true; } + if (channelId != null && conv.getChannelId() == null) { + conv.setChannelId(channelId); + dirty = true; + } if (dirty) { conversationMapper.updateById(conv); } @@ -419,6 +439,25 @@ public List listWebchatConversations(String username) { .orderByDesc(ConversationEntity::getLastActiveTime)); } + /** + * Channel-scoped variant of {@link #listWebchatConversations(String)} (#558). + * Returns rows for this visitor whose {@code channel_id} matches exactly, + * plus pre-fix rows where {@code channel_id IS NULL} (which could + * not be reconstructed). The caller must still apply a legacy-prefix + * {@code startsWith} filter in-memory on those NULL rows, since their + * channel can no longer be determined and they are inherently shared across + * channels that collided under the old key8 derivation. + */ + public List listWebchatConversations(String username, Long channelId) { + return conversationMapper.selectList(new LambdaQueryWrapper() + .eq(ConversationEntity::getUsername, username) + .isNull(ConversationEntity::getParentConversationId) + .and(w -> w.eq(ConversationEntity::getChannelId, channelId) + .or().isNull(ConversationEntity::getChannelId)) + .orderByDesc(ConversationEntity::getPinned) + .orderByDesc(ConversationEntity::getLastActiveTime)); + } + /** * 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 22f168735..cf5092aec 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 @@ -79,6 +79,17 @@ public class ConversationEntity { */ private String webchatSessionId; + /** + * WebChat channel that owns this conversation (see V171 migration, #558). + * Lets /sessions filter by channel exactly instead of matching the + * conversationId prefix, which collided across channels (all generated + * apiKeys share the 8-char slice "mc_webch"). Set on creation by webchat + * endpoints; NULL for non-webchat rows and for pre-fix rows whose channel + * can no longer be reconstructed (those remain visible across channels + * until backfilled). + */ + private Long channelId; + /** * Per-conversation progress notebook JSON (see V100 migration). *

diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V171__conversation_channel_id.sql b/mateclaw-server/src/main/resources/db/migration/h2/V171__conversation_channel_id.sql new file mode 100644 index 000000000..0238afc59 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V171__conversation_channel_id.sql @@ -0,0 +1,7 @@ +-- WebChat channel isolation (#558): persist the channel that owns each webchat +-- conversation so /sessions can filter by channel exactly, instead of matching +-- the conversationId prefix (which collided across channels because all generated +-- apiKeys share the 8-char slice "mc_webch"). NULL for non-webchat rows and for +-- pre-fix webchat rows whose channel can no longer be reconstructed (those remain +-- visible across channels until backfilled by an ops script). +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS channel_id BIGINT; diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V171__conversation_channel_id.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V171__conversation_channel_id.sql new file mode 100644 index 000000000..0b76fde5a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V171__conversation_channel_id.sql @@ -0,0 +1,3 @@ +-- See the H2 file for context. KingbaseES (PostgreSQL) supports +-- ADD COLUMN IF NOT EXISTS natively. +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS channel_id BIGINT; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V171__conversation_channel_id.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V171__conversation_channel_id.sql new file mode 100644 index 000000000..1be54fd75 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V171__conversation_channel_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 = 'channel_id' +); +SET @ddl := IF(@col_exists = 0, + 'ALTER TABLE mate_conversation ADD COLUMN channel_id BIGINT NULL COMMENT ''WebChat channel id for exact channel-scoped /sessions filtering (#558); NULL for non-webchat and pre-fix rows''', + 'SELECT 1'); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatChannelIsolationTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatChannelIsolationTest.java index b92fa5eca..c5c07694c 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatChannelIsolationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatChannelIsolationTest.java @@ -26,10 +26,18 @@ * conversationId, and their sessions collided / cross-leaked in * {@code listSessions}. *

- * The fix embeds the stable channel DB id ({@code channel.getId()}) into the - * conversationId. This test seeds two channels whose apiKeys share their first - * 8 chars (mirroring the collision) plus the same visitorId, then asserts each - * channel's {@code listSessions} returns only its own sessions. + * The fix (V171 {@code channel_id} column) persists the owning channel on each + * webchat row, so {@code listSessions} filters by {@code channel_id} exactly. + *

+ * Two isolation dimensions are covered: + *

    + *
  1. Channel-scoped rows (channel_id persisted): strictly isolated — + * channel A never sees channel B's sessions. This is the regression that + * the pre-fix code failed.
  2. + *
  3. Pre-fix collided rows (channel_id NULL, shared legacy cid): these + * cannot be split retroactively and remain visible across channels that + * collided. This is an explicitly documented limitation, not a bug.
  4. + *
*/ @SpringBootTest( classes = MateClawApplication.class, @@ -71,13 +79,23 @@ void setUp() { seedChannel(CHANNEL_B, API_KEY_B); String owner = WebChatController.webchatUsername(VISITOR); - // Channel A gets two sessions; Channel B gets one — all under the same visitor. + // Channel-scoped rows (channel_id persisted) — the new, correctly-isolated path. + // Seeded through the channelId-aware overload exactly like createSession/chatStream do. conversationService.getOrCreateWebchatConversation( - WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s1"), null, owner, 1L, "a-s1"); + WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s1"), + null, owner, 1L, "a-s1", null, CHANNEL_A); conversationService.getOrCreateWebchatConversation( - WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s2"), null, owner, 1L, "a-s2"); + WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s2"), + null, owner, 1L, "a-s2", null, CHANNEL_A); conversationService.getOrCreateWebchatConversation( - WebChatController.deriveConversationId(CHANNEL_B, VISITOR, "b-s1"), null, owner, 1L, "b-s1"); + WebChatController.deriveConversationId(CHANNEL_B, VISITOR, "b-s1"), + null, owner, 1L, "b-s1", null, CHANNEL_B); + + // One pre-fix collided row: a legacy cid (webchat:mc_webch:...) with channel_id + // left NULL. Both channels derive the SAME legacy cid for this visitor, so this + // single row is shared — it must stay visible to both (cannot be split). + String legacyCid = WebChatController.legacyConversationId(API_KEY_A, VISITOR, "shared-legacy"); + conversationService.getOrCreateWebchatConversation(legacyCid, null, owner, 1L, null); } private void seedChannel(long id, String apiKey) { @@ -92,22 +110,27 @@ private String tokenFor(long channelId) { } @Test - @DisplayName("two channels sharing an apiKey prefix isolate their sessions (#558)") + @DisplayName("channel-scoped rows are strictly isolated across channels (#558)") @SuppressWarnings("unchecked") - void channelsWithSharedKeyPrefixAreIsolated() { - // Channel A lists only its own sessions. + void channelScopedRowsAreIsolated() { + // Channel A lists only its own channel-scoped sessions + the shared legacy row. R> ra = (R>) (R) controller.listSessions(API_KEY_A, tokenFor(CHANNEL_A), VISITOR, false); assertThat(ra.getCode()).isEqualTo(200); assertThat(ra.getData()).extracting(WebChatSessionView::getSessionId) - .containsExactlyInAnyOrder("a-s1", "a-s2"); + .containsExactlyInAnyOrder("a-s1", "a-s2", "shared-legacy"); + // Critically: channel B's b-s1 does NOT leak into channel A. + assertThat(ra.getData()).extracting(WebChatSessionView::getSessionId) + .doesNotContain("b-s1"); - // Channel B lists only its own. + // Channel B lists only its own + the same shared legacy row. R> rb = (R>) (R) controller.listSessions(API_KEY_B, tokenFor(CHANNEL_B), VISITOR, false); assertThat(rb.getCode()).isEqualTo(200); assertThat(rb.getData()).extracting(WebChatSessionView::getSessionId) - .containsExactly("b-s1"); + .containsExactlyInAnyOrder("b-s1", "shared-legacy"); + assertThat(rb.getData()).extracting(WebChatSessionView::getSessionId) + .doesNotContain("a-s1", "a-s2"); } @Test @@ -120,4 +143,19 @@ void conversationIdsDifferAcrossChannels() { assertThat(cidA).startsWith("webchat:" + CHANNEL_A + ":"); assertThat(cidB).startsWith("webchat:" + CHANNEL_B + ":"); } + + @Test + @DisplayName("persisted channel_id is recorded on creation") + void channelIdIsPersisted() { + // Direct DB check: the channel-scoped rows carry the right channel_id. + Long aChannel = jdbc.queryForObject( + "SELECT channel_id FROM mate_conversation WHERE conversation_id = ?", + Long.class, WebChatController.deriveConversationId(CHANNEL_A, VISITOR, "a-s1")); + assertThat(aChannel).isEqualTo(CHANNEL_A); + Long legacyChannel = jdbc.queryForObject( + "SELECT channel_id FROM mate_conversation WHERE conversation_id = ?", + Long.class, + WebChatController.legacyConversationId(API_KEY_A, VISITOR, "shared-legacy")); + assertThat(legacyChannel).isNull(); // pre-fix row, not backfilled + } } 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 d3143fde7..074b5b5f5 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 @@ -157,7 +157,7 @@ void enforcesQuota() { for (int i = 1; i <= 5; i++) { conversationService.getOrCreateWebchatConversation( WebChatController.deriveConversationId(CHANNEL_ID, "visitorE", "seed" + i), - null, owner, 1L, "seed" + i); + null, owner, 1L, "seed" + i, null, CHANNEL_ID); } R> r = controller.createSession(API_KEY, req("visitorE", "s-new", null)); 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 index 0601c3504..f7f0c8399 100644 --- a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatSchemaFieldsTest.java @@ -161,7 +161,7 @@ void archivedExcludedFromQuota() { for (int i = 1; i <= 5; i++) { String cid = WebChatController.deriveConversationId(CHANNEL_ID, "visitorQuota", "arch" + i); conversationService.getOrCreateWebchatConversation( - cid, AGENT_ID, owner, 1L, "arch" + i); + cid, AGENT_ID, owner, 1L, "arch" + i, null, CHANNEL_ID); jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid); } 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 b2bd34c76..543a9d943 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 @@ -61,13 +61,13 @@ void setUp() { String owner = WebChatController.webchatUsername(VISITOR); // default thread (no sessionId) conversationService.getOrCreateWebchatConversation( - WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, null), null, owner, 1L, null); + WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, null), null, owner, 1L, null, null, CHANNEL_ID); // short sessioned thread conversationService.getOrCreateWebchatConversation( - WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, "s1"), null, owner, 1L, "s1"); + WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, "s1"), null, owner, 1L, "s1", null, CHANNEL_ID); // long sessioned thread → conversationId hashes conversationService.getOrCreateWebchatConversation( - WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, LONG_SESSION), null, owner, 1L, LONG_SESSION); + WebChatController.deriveConversationId(CHANNEL_ID, VISITOR, LONG_SESSION), null, owner, 1L, LONG_SESSION, null, CHANNEL_ID); token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, VISITOR); }