From d84be668fadd2a9084da10c22aa8d74789475e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Wed, 17 Jun 2026 22:56:14 +0800 Subject: [PATCH] feat(webchat): explicit empty-session creation endpoint POST /sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complements the implicit getOrCreate in /stream: lets a caller pre-create an empty thread (message_count = 0) and receive sessionId / conversationId / visitorToken up front, then decide when to send the first message via /stream. Mirrors how downstream CRM/ticketing systems model "create the conversation object first, message later". Auth is the visitor's first touch — only X-MC-Key is required (no X-MC-Visitor-Token, which the visitor can't have yet); the server signs and returns a fresh visitorToken the caller must echo back on subsequent GET/PUT/DELETE. Behavior (issue #351): - Idempotent on sessionId collision → returns the existing thread 200, does NOT clobber title. - Empty-session quota ≤ 5 per (channel, visitor); 409 with a clear message when exceeded. Existing rows are exempt (re-create is idempotent). - Caller-supplied title (1-100 chars) is persisted; absent title leaves the default "新对话" so the first /stream user message still derives it. getOrCreateWebchatConversation now accepts an optional title and only writes it on insert (existing rows untouched). - agentId override mirrors /stream's workspace check. ConversationService.getOrCreateWebchatConversation gains a title-aware overload; the original 5-arg signature delegates with title = null. End-to-end coverage in WebChatCreateSessionTest (@SpringBootTest, H2 with V147 migration): happy path, caller-title survives first user message, default-title still derived, idempotent collision, quota 409, bad API key 401, illegal sessionId/title 400, listed after creation. --- .../channel/webchat/WebChatController.java | 124 +++++++++++ .../conversation/ConversationService.java | 34 ++- .../webchat/WebChatCreateSessionTest.java | 204 ++++++++++++++++++ 3 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.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 215042ee..3e00cfce 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 @@ -288,6 +288,114 @@ public class WebChatController { )); } + /** Cap on how many empty (message_count = 0) threads one visitor may hold on a + * channel at once. Guards against pathologic clients churning placeholder + * sessions without ever sending a message. */ + private static final int MAX_EMPTY_SESSIONS_PER_VISITOR = 5; + + /** + * 显式创建一条访客会话线程(空会话)。 + *

+ * 与 {@code POST /stream} 的隐式 getOrCreate 互补:本端点先建一条 message_count=0 + * 的占位线程,调用方拿到 {@code sessionId/conversationId/visitorToken} 之后,再决定 + * 何时通过 {@code /stream} 发首条消息。鉴权为访客的首次接触:仅校验 + * {@code X-MC-Key},不要求 {@code X-MC-Visitor-Token},后端会签发并回传 token, + * 调用方在后续 GET/PUT/DELETE 上必须回带。 + *

+ * 行为: + *

+ */ + @Operation(summary = "显式创建访客会话线程(空会话)") + @PostMapping("/sessions") + public R> createSession( + @RequestHeader("X-MC-Key") String apiKey, + @RequestBody(required = false) WebChatCreateSessionRequest request) { + + ChannelEntity channel = resolveChannel(apiKey); + if (channel == null) { + return R.fail(401, "Invalid API Key"); + } + + // Resolve agent: explicit request.agentId overrides channel's bound agent, + // but must belong to channel's workspace (mirrors /stream). + final Long agentId; + if (request != null && request.getAgentId() != null) { + var requested = agentService.getAgent(request.getAgentId()); + if (requested == null) { + return R.fail(400, "Requested agent not found"); + } + if (channel.getWorkspaceId() != null && requested.getWorkspaceId() != null + && !channel.getWorkspaceId().equals(requested.getWorkspaceId())) { + return R.fail(400, "Requested agent does not belong to this channel's workspace"); + } + agentId = request.getAgentId(); + } else { + agentId = channel.getAgentId(); + if (agentId == null) { + return R.fail(400, "No agent configured for this WebChat channel"); + } + } + + final String visitorId; + final String sessionId; + try { + visitorId = normalizeVisitorId(request != null ? request.getVisitorId() : null); + sessionId = normalizeSessionId(request != null ? request.getSessionId() : null); + } catch (IllegalArgumentException ex) { + return R.fail(400, ex.getMessage()); + } + + String title = (request != null && request.getTitle() != null) ? request.getTitle().trim() : null; + if (title != null && (title.isEmpty() || title.length() > 100)) { + return R.fail(400, "title 不合法(1-100 字)"); + } + + String conversationId = deriveConversationId(apiKey, visitorId, sessionId); + String owner = webchatUsername(visitorId); + + // Idempotency: existing thread is returned as-is. Title and every other + // field are left untouched — a re-create call must not clobber a previously + // set title. Existing rows are exempt from the empty-session quota. + ConversationEntity existing = conversationService.findByConversationId(conversationId); + if (existing != null && owner.equals(existing.getUsername())) { + return R.ok(buildCreateSessionResponse(existing, sessionId, channel.getId(), visitorId)); + } + + // 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() + .filter(s -> s.getMessageCount() == null || s.getMessageCount() == 0) + .count(); + if (emptyCount >= MAX_EMPTY_SESSIONS_PER_VISITOR) { + return R.fail(409, "未活跃会话数已达上限(" + MAX_EMPTY_SESSIONS_PER_VISITOR + + "),请先发送消息或删除旧会话"); + } + + ConversationEntity conv = conversationService.getOrCreateWebchatConversation( + conversationId, agentId, owner, channel.getWorkspaceId(), sessionId, title); + return R.ok(buildCreateSessionResponse(conv, sessionId, channel.getId(), visitorId)); + } + + private Map buildCreateSessionResponse(ConversationEntity conv, String sessionId, + Long channelId, String visitorId) { + String visitorToken = computeVisitorToken(visitorTokenSecret, channelId, visitorId); + // LinkedHashMap (not Map.of) because Map.of rejects null and we want a + // stable key order for the response payload. + Map m = new java.util.LinkedHashMap<>(); + m.put("sessionId", sessionId != null ? sessionId : ""); + m.put("conversationId", conv.getConversationId()); + m.put("visitorToken", visitorToken); + m.put("title", conv.getTitle() != null ? conv.getTitle() : ""); + m.put("createTime", conv.getCreateTime()); + return m; + } + /** * 列出某访客的会话线程 *

@@ -889,4 +997,20 @@ public class WebChatController { private LocalDateTime lastActiveTime; private Integer messageCount; } + + /** Body for {@code POST /sessions} — explicitly create an empty thread. */ + @lombok.Data + public static class WebChatCreateSessionRequest { + /** Optional; server mints a UUID when absent (same convention as /stream). */ + private String visitorId; + /** Optional; server generates one when absent. Whitelisted charset, ≤ 64 chars. */ + private String sessionId; + /** Optional; 1–100 chars when non-blank, otherwise left null so the first + * /stream message still derives the title (mirrors PUT /sessions/title rules). */ + private String title; + /** Optional; override the channel's bound agent. Must belong to the channel's + * workspace. Only applied on first creation — once the thread exists, a + * different agentId is ignored. */ + private Long agentId; + } } 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 ad1045bd..12761663 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 @@ -310,12 +310,40 @@ public class ConversationService { public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId, String username, Long workspaceId, String sessionId) { + return getOrCreateWebchatConversation(conversationId, agentId, username, workspaceId, sessionId, null); + } + + /** + * WebChat get-or-create with an optional caller-supplied title. + *

+ * When the row is freshly inserted and {@code title} is non-blank, it + * overrides the default {@code "新对话"}; otherwise the default is kept and + * {@link #saveMessage} will still derive a title from the first user + * message. An existing row is never rewritten — neither {@code sessionId} + * nor {@code title} are clobbered, so a session created via + * {@code POST /sessions} with a caller-supplied title keeps that title + * when the first {@code /stream} message later lands. + */ + @Transactional + public ConversationEntity getOrCreateWebchatConversation(String conversationId, Long agentId, + String username, Long workspaceId, + String sessionId, String title) { boolean existed = conversationMapper.selectOne(new LambdaQueryWrapper() .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); + if (!existed) { + boolean dirty = false; + if (sessionId != null && !sessionId.isBlank() && conv.getWebchatSessionId() == null) { + conv.setWebchatSessionId(sessionId); + dirty = true; + } + if (title != null && !title.isBlank()) { + conv.setTitle(title.trim()); + dirty = true; + } + if (dirty) { + conversationMapper.updateById(conv); + } } return conv; } 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 new file mode 100644 index 00000000..c557f55c --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatCreateSessionTest.java @@ -0,0 +1,204 @@ +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.WebChatCreateSessionRequest; +import vip.mate.common.result.R; +import vip.mate.workspace.conversation.ConversationService; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end verification of {@code POST /api/v1/channels/webchat/sessions} + * (explicit empty-session creation) against a booted context + real H2 with + * migrations (incl. V147 {@code webchat_session_id}) applied. + *

+ * Covers the four behaviors promised in issue #351: + *

    + *
  1. happy path inserts an empty thread and returns sessionId/conversationId/ + * visitorToken;
  2. + *
  3. a caller-supplied title is persisted and survives the first /stream + * user message (saveMessage's "title-derive" guard must not fire);
  4. + *
  5. re-creating with a colliding sessionId is idempotent — 200, no title + * clobber;
  6. + *
  7. the empty-session quota (≤ 5) is enforced with a clear 409.
  8. + *
+ */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:webchat_create_sess_${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 WebChatCreateSessionTest { + + private static final String SECRET = "webchat-it-secret-0123456789"; + private static final String API_KEY = "testkey1abcdefgh"; // key8 = "testkey1" + private static final long CHANNEL_ID = 9_147_101L; + private static final long AGENT_ID = 9_147_1011L; + + @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-test-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', 'webchat', ?, ?, TRUE, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + CHANNEL_ID, AGENT_ID, "{\"api_key\":\"" + API_KEY + "\"}"); + } + + private WebChatCreateSessionRequest req(String visitorId, String sessionId, String title) { + WebChatCreateSessionRequest r = new WebChatCreateSessionRequest(); + r.setVisitorId(visitorId); + r.setSessionId(sessionId); + r.setTitle(title); + return r; + } + + @Test + @DisplayName("createSession inserts an empty thread and returns all required fields") + void createsEmptySession() { + R> r = controller.createSession(API_KEY, req("visitorA", "s1", null)); + assertThat(r.getCode()).isEqualTo(200); + Map data = r.getData(); + assertThat(data.get("sessionId")).isEqualTo("s1"); + assertThat(data.get("conversationId")) + .isEqualTo(WebChatController.deriveConversationId(API_KEY, "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. + assertThat(data.get("title")).isEqualTo("新对话"); + assertThat(data.get("createTime")).isNotNull(); + + // Row actually persisted with message_count = 0. + Integer count = jdbc.queryForObject( + "SELECT message_count FROM mate_conversation WHERE conversation_id = ?", + Integer.class, data.get("conversationId")); + assertThat(count).isZero(); + } + + @Test + @DisplayName("caller-supplied title survives the first /stream user message") + void titleSurvivesFirstMessage() { + String cid = (String) controller + .createSession(API_KEY, req("visitorB", "s-title", "Quarterly Report")) + .getData().get("conversationId"); + + // Simulate /stream saving the first user message. + conversationService.saveMessage(cid, "user", "随便说点什么,看看会不会把 title 覆盖掉"); + + String persisted = jdbc.queryForObject( + "SELECT title FROM mate_conversation WHERE conversation_id = ?", + String.class, cid); + assertThat(persisted).isEqualTo("Quarterly Report"); + } + + @Test + @DisplayName("default-title thread still derives its title from the first user message") + void defaultTitleIsDerivedFromFirstMessage() { + String cid = (String) controller + .createSession(API_KEY, req("visitorC", "s-default", null)) + .getData().get("conversationId"); + + conversationService.saveMessage(cid, "user", "今天天气不错"); + + String persisted = jdbc.queryForObject( + "SELECT title FROM mate_conversation WHERE conversation_id = ?", + String.class, cid); + assertThat(persisted).isEqualTo("今天天气不错"); + } + + @Test + @DisplayName("re-create with colliding sessionId is idempotent — no title clobber") + void isIdempotentOnCollision() { + // First call creates with a caller title. + controller.createSession(API_KEY, req("visitorD", "s-collide", "OriginalTitle")); + + // Second call tries to re-create the same sessionId with a different title. + R> r = controller + .createSession(API_KEY, req("visitorD", "s-collide", "AttemptedOverride")); + assertThat(r.getCode()).isEqualTo(200); + assertThat(r.getData().get("title")).isEqualTo("OriginalTitle"); + + String persisted = jdbc.queryForObject( + "SELECT title FROM mate_conversation WHERE conversation_id = ?", + String.class, r.getData().get("conversationId")); + assertThat(persisted).isEqualTo("OriginalTitle"); + } + + @Test + @DisplayName("empty-session quota (≤ 5) is enforced with a 409") + void enforcesQuota() { + // Pre-seed 5 empty threads directly through the service (bypasses the controller + // quota so we can verify the controller is the gate, not the service). + String owner = WebChatController.webchatUsername("visitorE"); + for (int i = 1; i <= 5; i++) { + conversationService.getOrCreateWebchatConversation( + WebChatController.deriveConversationId(API_KEY, "visitorE", "seed" + i), + null, owner, 1L, "seed" + i); + } + + R> r = controller.createSession(API_KEY, req("visitorE", "s-new", null)); + assertThat(r.getCode()).isEqualTo(409); + assertThat(r.getMsg()).contains("未活跃会话数已达上限"); + } + + @Test + @DisplayName("bad API Key → 401") + void rejectsBadApiKey() { + R> r = controller.createSession("bogus-key", req("visitorF", "s1", null)); + assertThat(r.getCode()).isEqualTo(401); + } + + @Test + @DisplayName("illegal sessionId charset → 400") + void rejectsIllegalSessionId() { + R> r = controller + .createSession(API_KEY, req("visitorG", "has space", null)); + assertThat(r.getCode()).isEqualTo(400); + } + + @Test + @DisplayName("illegal title length (>100) → 400") + void rejectsOverlongTitle() { + R> r = controller + .createSession(API_KEY, req("visitorH", "s1", "x".repeat(101))); + assertThat(r.getCode()).isEqualTo(400); + } + + @Test + @DisplayName("once a session is created, listSessions sees it (with the recovered sessionId)") + @SuppressWarnings("unchecked") + void createdSessionIsListable() { + controller.createSession(API_KEY, req("visitorI", "s-listed", null)); + + String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorI"); + R r = controller.listSessions(API_KEY, token, "visitorI"); + assertThat(r.getCode()).isEqualTo(200); + assertThat(((java.util.List) (Object) r.getData())) + .extracting(WebChatController.WebChatSessionView::getSessionId) + .contains("s-listed"); + } +}