mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
bdda9c7357
commit
d84be668fa
@ -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; 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user