feat(webchat): message pagination, session list paging/search, rename

Bring the webchat visitor session API closer to the admin console's:

- GET /sessions/messages gains beforeId + limit. With a limit it returns
  {messages, hasMore} (latest N, then pull-up for older) using the
  external path-stripped view; without it, the full list as before.
- GET /sessions/page paginates + keyword-searches a visitor's threads
  (in-memory: a visitor's thread set is bounded to its own namespace).
- PUT /sessions/title renames a thread (1-100 chars).

All keep the webchat auth model (API key + visitor token, server-derived
conversationId, ownership guard). Message views go through the shared
toExternalMessageViews helper so the paginated path is sanitized too.

Refs matevip/mateclaw#346
This commit is contained in:
倪程伟 2026-06-17 21:54:00 +08:00 committed by matevip
parent 594880bd64
commit 4842a2208a
3 changed files with 157 additions and 23 deletions

View File

@ -36,7 +36,7 @@ import vip.mate.memory.event.ConversationCompletionPublisher;
import vip.mate.workspace.conversation.ConversationService;
import vip.mate.workspace.conversation.model.ConversationEntity;
import vip.mate.workspace.conversation.model.MessageContentPart;
import vip.mate.workspace.conversation.vo.MessageVO;
import vip.mate.workspace.conversation.model.MessageEntity;
import java.io.IOException;
import java.time.LocalDateTime;
@ -306,32 +306,63 @@ public class WebChatController {
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
return R.fail(401, "Invalid or missing visitor token");
}
String base = deriveConversationId(apiKey, visitorId, null);
String prefix = base + ":";
String owner = webchatUsername(visitorId);
List<WebChatSessionView> sessions = conversationService.listConversations(owner).stream()
.filter(c -> c.getConversationId() != null
&& owner.equals(c.getUsername())
&& (c.getConversationId().equals(base) || c.getConversationId().startsWith(prefix)))
.map(c -> {
String cid = c.getConversationId();
String sid = cid.equals(base) ? null : cid.substring(prefix.length());
return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), c.getMessageCount());
})
.collect(Collectors.toList());
return R.ok(sessions);
return R.ok(loadVisitorSessions(apiKey, visitorId));
}
/**
* 获取某会话线程的消息列表
* 分页 + 关键词搜索某访客的会话线程
* <p>访客的会话集是按 visitor 命名空间限定的数量有界故在内存里做关键词过滤与分页
* keyword 不区分大小写匹配标题子串
*/
@Operation(summary = "获取会话消息")
@GetMapping("/sessions/messages")
public R<List<MessageVO>> sessionMessages(
@Operation(summary = "分页查询访客会话线程")
@GetMapping("/sessions/page")
public R<Map<String, Object>> pageSessions(
@RequestHeader("X-MC-Key") String apiKey,
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
@RequestParam String visitorId,
@RequestParam(required = false) String sessionId) {
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword) {
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
return R.fail(401, "Invalid API Key");
}
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
return R.fail(401, "Invalid or missing visitor token");
}
if (page < 1) page = 1;
if (size < 1 || size > 200) size = 20;
List<WebChatSessionView> all = loadVisitorSessions(apiKey, visitorId);
if (keyword != null && !keyword.isBlank()) {
String kw = keyword.trim().toLowerCase(java.util.Locale.ROOT);
all = all.stream()
.filter(s -> s.getTitle() != null && s.getTitle().toLowerCase(java.util.Locale.ROOT).contains(kw))
.collect(Collectors.toList());
}
long total = all.size();
int from = Math.min((page - 1) * size, all.size());
int to = Math.min(from + size, all.size());
List<WebChatSessionView> pageItems = all.subList(from, to);
return R.ok(Map.of(
"items", pageItems,
"total", total,
"page", page,
"size", size
));
}
/**
* 重命名某会话线程标题非空长度 100
*/
@Operation(summary = "重命名会话线程")
@PutMapping("/sessions/title")
public R<Void> renameSession(
@RequestHeader("X-MC-Key") String apiKey,
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
@RequestParam String visitorId,
@RequestParam(required = false) String sessionId,
@RequestBody Map<String, String> body) {
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
return R.fail(401, "Invalid API Key");
@ -349,8 +380,91 @@ public class WebChatController {
if (!ownsConversation(conversationId, visitorId)) {
return R.fail(404, "Session not found");
}
// External view: strip server-side file paths before handing messages to the visitor.
return R.ok(conversationService.listMessageViewsExternal(conversationId));
String title = body != null && body.get("title") != null ? body.get("title").trim() : "";
if (title.isEmpty() || title.length() > 100) {
return R.fail(400, "标题不合法1-100 字)");
}
conversationService.renameConversation(conversationId, title);
return R.ok();
}
/**
* 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.
*/
private List<WebChatSessionView> loadVisitorSessions(String apiKey, String visitorId) {
String base = deriveConversationId(apiKey, visitorId, null);
String prefix = base + ":";
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)))
.map(c -> {
String cid = c.getConversationId();
String sid = cid.equals(base) ? null : cid.substring(prefix.length());
return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), c.getMessageCount());
})
.collect(Collectors.toList());
}
/**
* 获取某会话线程的消息列表支持分页
* <p>不传 limit 时返回全部消息向后兼容 limit 返回最新 limit + hasMore
* beforeId + limit 时返回该 ID 之前的 limit 上拉加载更早消息
*/
@Operation(summary = "获取会话消息(支持分页)")
@GetMapping("/sessions/messages")
public R<?> sessionMessages(
@RequestHeader("X-MC-Key") String apiKey,
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
@RequestParam String visitorId,
@RequestParam(required = false) String sessionId,
@RequestParam(required = false) Long beforeId,
@RequestParam(required = false) Integer limit) {
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
return R.fail(401, "Invalid API Key");
}
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
return R.fail(401, "Invalid or missing visitor token");
}
String sid;
try {
sid = normalizeSessionId(sessionId);
} catch (IllegalArgumentException ex) {
return R.fail(400, ex.getMessage());
}
String conversationId = deriveConversationId(apiKey, visitorId, sid);
if (!ownsConversation(conversationId, visitorId)) {
return R.fail(404, "Session not found");
}
// Backward-compatible: no limit full external (path-stripped) list.
if (limit == null || limit <= 0) {
return R.ok(conversationService.listMessageViewsExternal(conversationId));
}
// Paginated: mirror ConversationController#listMessages but with the
// external view so visitors never see server-side file paths.
List<MessageEntity> messages;
boolean hasMore;
if (beforeId != null) {
messages = conversationService.listMessagesBefore(conversationId, beforeId, limit + 1);
hasMore = messages.size() > limit;
if (hasMore) {
messages = messages.subList(messages.size() - limit, messages.size());
}
} else {
long total = conversationService.countMessages(conversationId);
messages = conversationService.listRecentMessages(conversationId, limit);
hasMore = total > limit;
}
return R.ok(Map.of(
"messages", conversationService.toExternalMessageViews(messages),
"hasMore", hasMore
));
}
/**

View File

@ -862,7 +862,16 @@ public class ConversationService {
* fileName} / {@code contentType} to render and download attachments.
*/
public List<MessageVO> listMessageViewsExternal(String conversationId) {
return listMessages(conversationId).stream()
return toExternalMessageViews(listMessages(conversationId));
}
/**
* Map already-loaded message entities to external (path-stripped) views.
* Shared by the full-list and paginated webchat paths so sanitization stays
* in one place.
*/
public List<MessageVO> toExternalMessageViews(List<MessageEntity> messages) {
return messages.stream()
.map(message -> {
List<MessageContentPart> parts = parseMessageParts(message);
parts.forEach(p -> {

View File

@ -81,4 +81,15 @@ class ConversationServiceExternalViewTest {
assertThat(views.get(0).getContentParts().get(0).getPath()).isEqualTo(SECRET_PATH);
assertThat(views.get(0).getContent()).contains(SECRET_PATH);
}
@Test
@DisplayName("toExternalMessageViews strips path on a pre-loaded list (paginated path)")
void toExternalMessageViewsStripsPath() {
// Used by the paginated webchat endpoint, which loads entities itself.
List<MessageVO> views = service.toExternalMessageViews(List.of(fileMessage()));
assertThat(views).hasSize(1);
assertThat(views.get(0).getContentParts().get(0).getPath()).isNull();
assertThat(views.get(0).getContent()).doesNotContain(SECRET_PATH);
}
}