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:
+ *
+ * - happy path inserts an empty thread and returns sessionId/conversationId/
+ * visitorToken;
+ * - a caller-supplied title is persisted and survives the first /stream
+ * user message (saveMessage's "title-derive" guard must not fire);
+ * - re-creating with a colliding sessionId is idempotent — 200, no title
+ * clobber;
+ * - the empty-session quota (≤ 5) is enforced with a clear 409.
+ *
+ */
+@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