Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -621,7 +621,7 @@ public R<Map<String, Object>> 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
Expand All @@ -636,7 +636,7 @@ public R<Map<String, Object>> 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) {
Expand Down Expand Up @@ -698,7 +698,7 @@ public R<List<WebChatSessionView>> 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));
}

/**
Expand Down Expand Up @@ -771,7 +771,7 @@ public R<Void> 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");
}
Expand Down Expand Up @@ -810,7 +810,7 @@ public R<Void> 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");
}
Expand Down Expand Up @@ -849,7 +849,7 @@ public R<Void> 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");
}
Expand All @@ -868,15 +868,15 @@ public R<Void> archiveSession(
* compact view. Sorted as {@code listConversations} returns them (pinned
* desc, last-active desc). Shared by the list and paginated endpoints.
* <p>
* Enumeration is keyed by the visitor's username plus the channel prefix
* ({@code webchat:<key8>:}) 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:<channelId>:}), 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<WebChatSessionView> loadVisitorSessions(String apiKey, String visitorId) {
return loadVisitorSessions(apiKey, visitorId, false);
private List<WebChatSessionView> loadVisitorSessions(Long channelId, String visitorId) {
return loadVisitorSessions(channelId, visitorId, false);
}

/**
Expand All @@ -887,15 +887,15 @@ private List<WebChatSessionView> loadVisitorSessions(String apiKey, String visit
* against the "≤ 5 empty threads" quota (the visitor already declared
* they're done with them).
*/
private List<WebChatSessionView> loadVisitorSessions(String apiKey, String visitorId,
private List<WebChatSessionView> 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))
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -1015,7 +1015,7 @@ public R<Void> 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");
}
Expand Down Expand Up @@ -1060,7 +1060,7 @@ public R<Map<String, Object>> 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.
Expand Down Expand Up @@ -1136,7 +1136,7 @@ public R<Map<String, Object>> 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");
}
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1486,7 +1486,7 @@ public R<Map<String, Object>> 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,
Expand Down Expand Up @@ -1531,7 +1531,7 @@ public ResponseEntity<Resource> 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();
}
Expand Down Expand Up @@ -1647,19 +1647,17 @@ private String normalizeVisitorId(String raw) {
}

/**
* 由服务端拼装 conversationId,始终钳在 key + visitor 命名空间内。
* 由服务端拼装 conversationId,始终钳在 channel + visitor 命名空间内。
* 绝不接受调用方传入的裸 conversationId。
* <p>conversation_id 列为 VARCHAR(64);当 visitorId + sessionId 过长导致超出列宽时,
* 把可变部分折叠为稳定哈希,保证 id 唯一且有界(否则 INSERT 会在 /stream 处 500)。
* <p>渠道身份用 {@code channelId}(稳定唯一的 DB 主键)体现,与 IM 渠道的
* {@code {channelType}:{channelId}:{identifier}} 模式同构(#558)。不能用 apiKey 前缀:
* 所有生成的 key 共享 11 位前缀 {@code mc_webchat_},截取短于它的子串对每个渠道都是
* 同一个常量 {@code mc_webch},渠道维度隔离完全失效。
* <p>{@code conversation_id} 列已拓宽为 VARCHAR(128)(V171),{@code webchat:<channelId>:<visitor>[:<session>]}
* 在实际输入下不会超限,故不再需要哈希折叠。
*/
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 : "");
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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");
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void> r = controller.pinSession(API_KEY, tokenFor("vPin"), "vPin", "s1",
Map.of("pinned", true));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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\"");
Expand All @@ -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);
Expand Down
Loading