mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
4842a2208a
commit
ee3e391977
@ -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;
|
||||
|
||||
@ -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}.
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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());
|
||||
// 补充关联字段
|
||||
|
||||
@ -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);
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user