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..ba7bc341e 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 = deriveConversationId(channel.getId(), 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 = deriveConversationId(channel.getId(), 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(), 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(), 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 = deriveConversationId(channel.getId(), 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 = deriveConversationId(channel.getId(), 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 = deriveConversationId(channel.getId(), visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -868,15 +868,15 @@ public R archiveSession( * 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 + * Enumeration is keyed by the visitor's username plus the channel-scoped + * prefix ({@code webchat::}), so it isolates this channel's + * threads from other channels sharing the same visitorId (#558). 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) { - return loadVisitorSessions(apiKey, visitorId, false); + private List loadVisitorSessions(Long channelId, String visitorId) { + return loadVisitorSessions(channelId, visitorId, false); } /** @@ -887,15 +887,15 @@ 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 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 channelPrefix = "webchat:" + channelId + ":"; 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. + // '_' / '%' in the channelId can't act as a LIKE wildcard. return conversationService.listWebchatConversations(owner).stream() .filter(c -> c.getConversationId() != null && c.getConversationId().startsWith(channelPrefix)) @@ -961,7 +961,7 @@ public R sessionMessages( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = deriveConversationId(channel.getId(), visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -1015,7 +1015,7 @@ public R deleteSession( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = deriveConversationId(channel.getId(), visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -1060,7 +1060,7 @@ public R> stopSession( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = deriveConversationId(channel.getId(), 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 +1136,7 @@ public R> denySession( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = deriveConversationId(channel.getId(), visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return R.fail(404, "Session not found"); } @@ -1204,7 +1204,7 @@ public SseEmitter approveSession( sendErrorAndComplete(emitter, ex.getMessage()); return emitter; } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = deriveConversationId(channel.getId(), visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { sendErrorAndComplete(emitter, "Session not found"); return emitter; @@ -1419,7 +1419,7 @@ public SseEmitter regenerateSession( sendErrorAndComplete(emitter, ex.getMessage()); return emitter; } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = deriveConversationId(channel.getId(), visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { sendErrorAndComplete(emitter, "Session not found"); return emitter; @@ -1486,7 +1486,7 @@ public R> uploadFile( } catch (IllegalArgumentException ex) { return R.fail(400, ex.getMessage()); } - String conversationId = deriveConversationId(apiKey, vid, sid); + String conversationId = deriveConversationId(channel.getId(), vid, sid); try { WebChatFileService.StagedFile stored = fileService.store(conversationId, file); audit(channel, vid, "webchat.upload-file", conversationId, @@ -1531,7 +1531,7 @@ public ResponseEntity downloadFile( } catch (IllegalArgumentException ex) { return ResponseEntity.badRequest().build(); } - String conversationId = deriveConversationId(apiKey, visitorId, sid); + String conversationId = deriveConversationId(channel.getId(), visitorId, sid); if (!ownsConversation(conversationId, visitorId)) { return ResponseEntity.status(404).build(); } @@ -1647,19 +1647,17 @@ private String normalizeVisitorId(String raw) { } /** - * 由服务端拼装 conversationId,始终钳在 key + visitor 命名空间内。 + * 由服务端拼装 conversationId,始终钳在 channel + visitor 命名空间内。 * 绝不接受调用方传入的裸 conversationId。 - *

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

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

{@code conversation_id} 列已拓宽为 VARCHAR(128)(V171),{@code webchat::[:]} + * 在实际输入下不会超限,故不再需要哈希折叠。 */ - static String deriveConversationId(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) { - return full; - } - return "webchat:" + key8 + ":#" - + sha256Hex(visitorId + "\0" + (sessionId == null ? "" : sessionId)).substring(0, 40); + static String deriveConversationId(Long channelId, String visitorId, String sessionId) { + return "webchat:" + channelId + ":" + visitorId + (sessionId != null ? ":" + sessionId : ""); } /** 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..ff750d271 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatChannelIsolationTest.java @@ -0,0 +1,126 @@ +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 cross-leaked in {@code listSessions}. + *

+ * The fix scopes the conversationId by {@code channel.getId()} (the stable DB + * primary key), mirroring the IM-channel fix in {@code e294b325}. Two channels + * sharing an apiKey prefix now derive distinct ids. + */ +@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"); + // Critically: channel B's session does NOT leak into channel A. + assertThat(ra.getData()).extracting(WebChatSessionView::getSessionId) + .doesNotContain("b-s1"); + + // 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"); + assertThat(rb.getData()).extracting(WebChatSessionView::getSessionId) + .doesNotContain("a-s1", "a-s2"); + } + + @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/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..6225436a9 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,10 @@ 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. assertThat(countAssistantMessages( - WebChatController.deriveConversationId("bogus-key-not-registered", "vBad", null))).isZero(); + WebChatController.deriveConversationId(-1L, "vBad", null))).isZero(); } @Test @@ -358,7 +359,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..eda3699cd 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 @@ -120,23 +120,28 @@ void verify_rejectsTamperedExpiration() { assertFalse(WebChatController.verifyVisitorTokenSignature(SECRET, CHANNEL, VISITOR, tampered)); } - // ============ conversationId / username 边界(避免溢出 VARCHAR(64) → /stream 500)============ + // ============ conversationId / username 边界(conversation_id 已拓宽为 VARCHAR(128),#558)============ @Test - void deriveConversationId_staysWithin64_forLongInputs() { - String id = WebChatController.deriveConversationId("apikey1234567890", "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)); - assertTrue(id2.length() <= 64, "conversationId must fit VARCHAR(64), got " + id2.length()); + void deriveConversationId_fits128_forLongInputs() { + // V171 widened conversation_id to VARCHAR(128); the new channel-scoped format + // webchat::[:] no longer hashes, so a long + // visitor+session must still fit 128. + String id = WebChatController.deriveConversationId(9147001L, "v".repeat(60), "s".repeat(50)); + assertTrue(id.length() <= 128, "conversationId must fit VARCHAR(128), got " + id.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)); + void deriveConversationId_format_and_channelScoping() { + assertEquals("webchat:9147001:alice:s1", + WebChatController.deriveConversationId(9147001L, "alice", "s1")); + assertEquals("webchat:9147001:alice", + WebChatController.deriveConversationId(9147001L, "alice", null)); + // The #558 bug: two channels sharing an apiKey prefix derived the SAME id. + // The channelId token must differ. + assertNotEquals( + WebChatController.deriveConversationId(1L, "alice", "s1"), + WebChatController.deriveConversationId(2L, "alice", "s1")); } @Test