feat(webchat): explicit empty-session creation endpoint POST /sessions

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.
This commit is contained in:
倪程伟 2026-06-17 22:56:14 +08:00 committed by matevip
parent bdda9c7357
commit d84be668fa
3 changed files with 359 additions and 3 deletions

View File

@ -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;
/**
* 显式创建一条访客会话线程空会话
* <p>
* {@code POST /stream} 的隐式 getOrCreate 互补本端点先建一条 message_count=0
* 的占位线程调用方拿到 {@code sessionId/conversationId/visitorToken} 之后再决定
* 何时通过 {@code /stream} 发首条消息鉴权为访客的<b>首次接触</b>仅校验
* {@code X-MC-Key}不要求 {@code X-MC-Visitor-Token}后端会签发并回传 token
* 调用方在后续 GET/PUT/DELETE 上必须回带
* <p>
* 行为
* <ul>
* <li>幂等{@code sessionId} 与该 visitor 已有线程冲突 直接返回现有线程
* 不报错不覆盖 title</li>
* <li>配额 (渠道, visitor) 未活跃空线程 {@value MAX_EMPTY_SESSIONS_PER_VISITOR}
* 超出返回 409已存在的线程走幂等路径不受配额限制</li>
* <li>title 非空时写入为空时落默认 "新对话"首条 user 消息仍会按现有规则截取</li>
* </ul>
*/
@Operation(summary = "显式创建访客会话线程(空会话)")
@PostMapping("/sessions")
public R<Map<String, Object>> 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<String, Object> 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<String, Object> 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;
}
/**
* 列出某访客的会话线程
* <p>
@ -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; 1100 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;
}
}

View File

@ -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.
* <p>
* 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<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);
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;
}

View File

@ -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.
* <p>
* Covers the four behaviors promised in issue #351:
* <ol>
* <li>happy path inserts an empty thread and returns sessionId/conversationId/
* visitorToken;</li>
* <li>a caller-supplied title is persisted and survives the first /stream
* user message (saveMessage's "title-derive" guard must not fire);</li>
* <li>re-creating with a colliding sessionId is idempotent 200, no title
* clobber;</li>
* <li>the empty-session quota ( 5) is enforced with a clear 409.</li>
* </ol>
*/
@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<Map<String, Object>> r = controller.createSession(API_KEY, req("visitorA", "s1", null));
assertThat(r.getCode()).isEqualTo(200);
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> r = controller.createSession("bogus-key", req("visitorF", "s1", null));
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("illegal sessionId charset → 400")
void rejectsIllegalSessionId() {
R<Map<String, Object>> 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<Map<String, Object>> 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<WebChatController.WebChatSessionView>) (Object) r.getData()))
.extracting(WebChatController.WebChatSessionView::getSessionId)
.contains("s-listed");
}
}