fix(webchat): list sessions whose conversationId hashed (long ids)

When webchat:<key8>:<visitorId>:<sessionId> exceeds 64 chars the
conversationId folds visitorId+sessionId into an unrecoverable hash, so
the thread fell outside listSessions' conversationId-prefix filter and its
sessionId could not be recovered — the thread was invisible and
unaddressable (common with a UUID visitorId + a >10-char sessionId).

Persist the sessionId on creation (new nullable webchat_session_id column)
and enumerate by username + channel prefix (webchat:<key8>:), which also
matches the hashed form. sessionId is read from the column, falling back to
parsing the conversationId only for legacy rows.

Adds a @SpringBootTest covering listing (incl. the hashed thread), message
pagination, session paging/search, rename, and token rejection end-to-end.

Refs matevip/mateclaw#346
This commit is contained in:
倪程伟 2026-06-17 22:09:57 +08:00 committed by matevip
parent 4842a2208a
commit ee3e391977
8 changed files with 222 additions and 5 deletions

View File

@ -166,7 +166,8 @@ public class WebChatController {
// 创建或获取会话workspace agent 获取
var webAgent = agentService.getAgent(resolvedAgentId);
Long webWsId = webAgent != null ? webAgent.getWorkspaceId() : 1L;
var conv = conversationService.getOrCreateConversation(conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId);
var conv = conversationService.getOrCreateWebchatConversation(
conversationId, resolvedAgentId, webchatUsername(visitorId), webWsId, effectiveSessionId);
// 保存用户消息含访客本轮引用的附件附件元数据一律服务端按 fileId 回查
// 不信客户端传入path 用于 Agent 侧工具读取对外消息视图会被剥离
@ -392,23 +393,50 @@ public class WebChatController {
* Load this visitor's session threads (own namespace only), mapped to the
* compact view. Sorted as {@code listConversations} returns them (pinned
* desc, last-active desc). Shared by the list and paginated endpoints.
* <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
* for legacy rows created before that column existed.
*/
private List<WebChatSessionView> loadVisitorSessions(String apiKey, String visitorId) {
String base = deriveConversationId(apiKey, visitorId, null);
String prefix = base + ":";
String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":";
String owner = webchatUsername(visitorId);
return conversationService.listConversations(owner).stream()
.filter(c -> c.getConversationId() != null
&& owner.equals(c.getUsername())
&& (c.getConversationId().equals(base) || c.getConversationId().startsWith(prefix)))
&& c.getConversationId().startsWith(channelPrefix))
.map(c -> {
String cid = c.getConversationId();
String sid = cid.equals(base) ? null : cid.substring(prefix.length());
String sid = recoverSessionId(c, base);
return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), c.getMessageCount());
})
.collect(Collectors.toList());
}
/**
* Recover a thread's sessionId. Prefers the persisted column; for legacy
* rows (column null) falls back to parsing the non-hashed conversationId.
* Returns null for the default (no-session) thread and for legacy hashed rows
* whose sessionId can no longer be reconstructed.
*/
private String recoverSessionId(vip.mate.workspace.conversation.vo.ConversationVO c, String base) {
if (c.getWebchatSessionId() != null) {
return c.getWebchatSessionId();
}
String cid = c.getConversationId();
if (cid.equals(base)) {
return null;
}
String prefix = base + ":";
if (cid.startsWith(prefix)) {
return cid.substring(prefix.length());
}
return null;
}
/**
* 获取某会话线程的消息列表支持分页
* <p>不传 limit 时返回全部消息向后兼容 limit 返回最新 limit + hasMore

View File

@ -300,6 +300,26 @@ public class ConversationService {
return conv;
}
/**
* WebChat get-or-create that also records the thread's {@code sessionId} on
* insert, so the visitor's /sessions listing can recover it even when the
* conversationId hashes (long visitorId + sessionId). The session id is
* written only when the row is first created; an existing row is left as-is.
*/
@Transactional
public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId,
String username, Long workspaceId,
String sessionId) {
boolean existed = conversationMapper.selectOne(new LambdaQueryWrapper<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId)) != null;
ConversationEntity conv = getOrCreateConversation(conversationId, agentId, username, workspaceId);
if (!existed && sessionId != null && !sessionId.isBlank() && conv.getWebchatSessionId() == null) {
conv.setWebchatSessionId(sessionId);
conversationMapper.updateById(conv);
}
return conv;
}
/**
* Create a child conversation (delegation scenario), linking it back to
* its parent via {@code parentConversationId}.

View File

@ -61,6 +61,14 @@ public class ConversationEntity {
/** Model id this conversation is pinned to. See {@link #modelProvider}. */
private String modelName;
/**
* WebChat per-thread sessionId (see V147 migration). Persisted so it can be
* recovered for the visitor's /sessions listing even when the conversationId
* hashes (long visitorId + sessionId folds into an unrecoverable hash). NULL
* for non-webchat rows and for a visitor's default (no-session) thread.
*/
private String webchatSessionId;
/**
* Per-conversation progress notebook JSON (see V100 migration).
* <p>

View File

@ -68,6 +68,7 @@ public class ConversationVO extends ConversationEntity {
vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0);
vo.setModelProvider(entity.getModelProvider());
vo.setModelName(entity.getModelName());
vo.setWebchatSessionId(entity.getWebchatSessionId());
vo.setCreateTime(entity.getCreateTime());
vo.setUpdateTime(entity.getUpdateTime());
// 补充关联字段

View File

@ -0,0 +1,6 @@
-- WebChat per-thread sessionId, persisted so it can be recovered even when the
-- conversationId hashes (visitorId + sessionId > 64 chars folds into a hash,
-- which is otherwise unrecoverable — making the thread invisible/unaddressable
-- in the visitor's /sessions listing). NULL for non-webchat rows and for a
-- visitor's default (no-session) thread.
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS webchat_session_id VARCHAR(64);

View File

@ -0,0 +1,3 @@
-- See the H2 copy for context. KingbaseES (PostgreSQL) supports
-- ADD COLUMN IF NOT EXISTS natively.
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS webchat_session_id VARCHAR(64);

View File

@ -0,0 +1,15 @@
-- See the H2 file for context. MySQL 8.0 doesn't support
-- `ADD COLUMN IF NOT EXISTS`, so the existence check goes through
-- INFORMATION_SCHEMA + a prepared statement.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_conversation'
AND COLUMN_NAME = 'webchat_session_id'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE mate_conversation ADD COLUMN webchat_session_id VARCHAR(64) NULL COMMENT ''WebChat per-thread sessionId (recoverable even when conversationId hashes)''',
'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -0,0 +1,136 @@
package vip.mate.channel.webchat;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.TestPropertySource;
import vip.mate.MateClawApplication;
import vip.mate.channel.webchat.WebChatController.WebChatSessionView;
import vip.mate.common.result.R;
import vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end verification of the WebChat visitor session-management endpoints
* against a booted context + real H2 (migrations incl. V147 run). Exercises the
* controller's real auth (channel lookup + visitor-token HMAC), pagination,
* keyword search, rename, and the key case that a thread whose
* conversationId hashed (long visitorId + sessionId) is still listed with its
* sessionId recovered from the persisted column.
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:webchat_sess_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1",
"spring.ai.dashscope.api-key=test-key",
"spring.main.web-application-type=none",
"mateclaw.jwt.secret=webchat-it-secret-0123456789"
})
class WebChatSessionManagementTest {
private static final String SECRET = "webchat-it-secret-0123456789";
private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1"
private static final String VISITOR = "visitorAAAA";
private static final long CHANNEL_ID = 9_147_001L;
// Long enough that "webchat:testkey1:visitorAAAA:<this>" exceeds 64 chars and hashes.
private static final String LONG_SESSION = "session-1234567890-abcdefghij-klmnopqrst";
@Autowired private WebChatController controller;
@Autowired private ConversationService conversationService;
@Autowired private JdbcTemplate jdbc;
private String token;
@BeforeEach
void setUp() {
jdbc.update("DELETE FROM mate_channel WHERE id = ?", CHANNEL_ID);
jdbc.update("INSERT INTO mate_channel (id, name, channel_type, config_json, enabled, " +
"workspace_id, create_time, update_time, deleted) " +
"VALUES (?, 'wc', 'webchat', ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)",
CHANNEL_ID, "{\"api_key\":\"" + API_KEY + "\"}");
String owner = WebChatController.webchatUsername(VISITOR);
// default thread (no sessionId)
conversationService.getOrCreateWebchatConversation(
WebChatController.deriveConversationId(API_KEY, VISITOR, null), null, owner, 1L, null);
// short sessioned thread
conversationService.getOrCreateWebchatConversation(
WebChatController.deriveConversationId(API_KEY, VISITOR, "s1"), null, owner, 1L, "s1");
// long sessioned thread conversationId hashes
conversationService.getOrCreateWebchatConversation(
WebChatController.deriveConversationId(API_KEY, VISITOR, LONG_SESSION), null, owner, 1L, LONG_SESSION);
token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, VISITOR);
}
@Test
@DisplayName("listSessions includes the hashed long-id thread with its sessionId recovered")
@SuppressWarnings("unchecked")
void listsHashedThread() {
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, token, VISITOR);
assertThat(r.getCode()).isEqualTo(200);
List<WebChatSessionView> sessions = r.getData();
assertThat(sessions).hasSize(3);
assertThat(sessions).extracting(WebChatSessionView::getSessionId)
.containsExactlyInAnyOrder(null, "s1", LONG_SESSION);
}
@Test
@DisplayName("bad visitor token is rejected")
@SuppressWarnings("unchecked")
void rejectsBadToken() {
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, "bogus", VISITOR);
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("pageSessions paginates and keyword-searches by title")
void pagesAndSearches() {
R<Map<String, Object>> page = controller.pageSessions(API_KEY, token, VISITOR, 1, 2, null);
assertThat(page.getCode()).isEqualTo(200);
assertThat(page.getData().get("total")).isEqualTo(3L);
assertThat((List<?>) page.getData().get("items")).hasSize(2);
// Rename one thread, then search for it.
controller.renameSession(API_KEY, token, VISITOR, "s1", Map.of("title", "QuarterlyReport"));
R<Map<String, Object>> hit = controller.pageSessions(API_KEY, token, VISITOR, 1, 20, "quarterly");
assertThat((List<?>) hit.getData().get("items")).hasSize(1);
}
@Test
@DisplayName("rename updates the thread title")
void renames() {
R<Void> r = controller.renameSession(API_KEY, token, VISITOR, "s1", Map.of("title", "Renamed"));
assertThat(r.getCode()).isEqualTo(200);
String cid = WebChatController.deriveConversationId(API_KEY, VISITOR, "s1");
String title = jdbc.queryForObject(
"SELECT title FROM mate_conversation WHERE conversation_id = ?", String.class, cid);
assertThat(title).isEqualTo("Renamed");
}
@Test
@DisplayName("sessionMessages paginates with hasMore")
@SuppressWarnings("unchecked")
void paginatesMessages() {
String cid = WebChatController.deriveConversationId(API_KEY, VISITOR, "s1");
conversationService.saveMessage(cid, "user", "m1");
conversationService.saveMessage(cid, "assistant", "m2");
conversationService.saveMessage(cid, "user", "m3");
R<?> r = controller.sessionMessages(API_KEY, token, VISITOR, "s1", null, 2);
assertThat(r.getCode()).isEqualTo(200);
Map<String, Object> data = (Map<String, Object>) r.getData();
assertThat((List<?>) data.get("messages")).hasSize(2);
assertThat(data.get("hasMore")).isEqualTo(true);
}
}