mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
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:
parent
594880bd64
commit
4842a2208a
@ -36,7 +36,7 @@ import vip.mate.memory.event.ConversationCompletionPublisher;
|
|||||||
import vip.mate.workspace.conversation.ConversationService;
|
import vip.mate.workspace.conversation.ConversationService;
|
||||||
import vip.mate.workspace.conversation.model.ConversationEntity;
|
import vip.mate.workspace.conversation.model.ConversationEntity;
|
||||||
import vip.mate.workspace.conversation.model.MessageContentPart;
|
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.io.IOException;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@ -306,32 +306,63 @@ public class WebChatController {
|
|||||||
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
|
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
|
||||||
return R.fail(401, "Invalid or missing visitor token");
|
return R.fail(401, "Invalid or missing visitor token");
|
||||||
}
|
}
|
||||||
String base = deriveConversationId(apiKey, visitorId, null);
|
return R.ok(loadVisitorSessions(apiKey, visitorId));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取某会话线程的消息列表
|
* 分页 + 关键词搜索某访客的会话线程。
|
||||||
|
* <p>访客的会话集是按 visitor 命名空间限定的(数量有界),故在内存里做关键词过滤与分页。
|
||||||
|
* keyword 不区分大小写、匹配标题子串。
|
||||||
*/
|
*/
|
||||||
@Operation(summary = "获取会话消息")
|
@Operation(summary = "分页查询访客会话线程")
|
||||||
@GetMapping("/sessions/messages")
|
@GetMapping("/sessions/page")
|
||||||
public R<List<MessageVO>> sessionMessages(
|
public R<Map<String, Object>> pageSessions(
|
||||||
@RequestHeader("X-MC-Key") String apiKey,
|
@RequestHeader("X-MC-Key") String apiKey,
|
||||||
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
|
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
|
||||||
@RequestParam String visitorId,
|
@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);
|
ChannelEntity channel = resolveChannel(apiKey);
|
||||||
if (channel == null) {
|
if (channel == null) {
|
||||||
return R.fail(401, "Invalid API Key");
|
return R.fail(401, "Invalid API Key");
|
||||||
@ -349,8 +380,91 @@ public class WebChatController {
|
|||||||
if (!ownsConversation(conversationId, visitorId)) {
|
if (!ownsConversation(conversationId, visitorId)) {
|
||||||
return R.fail(404, "Session not found");
|
return R.fail(404, "Session not found");
|
||||||
}
|
}
|
||||||
// External view: strip server-side file paths before handing messages to the visitor.
|
String title = body != null && body.get("title") != null ? body.get("title").trim() : "";
|
||||||
return R.ok(conversationService.listMessageViewsExternal(conversationId));
|
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
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -862,7 +862,16 @@ public class ConversationService {
|
|||||||
* fileName} / {@code contentType} to render and download attachments.
|
* fileName} / {@code contentType} to render and download attachments.
|
||||||
*/
|
*/
|
||||||
public List<MessageVO> listMessageViewsExternal(String conversationId) {
|
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 -> {
|
.map(message -> {
|
||||||
List<MessageContentPart> parts = parseMessageParts(message);
|
List<MessageContentPart> parts = parseMessageParts(message);
|
||||||
parts.forEach(p -> {
|
parts.forEach(p -> {
|
||||||
|
|||||||
@ -81,4 +81,15 @@ class ConversationServiceExternalViewTest {
|
|||||||
assertThat(views.get(0).getContentParts().get(0).getPath()).isEqualTo(SECRET_PATH);
|
assertThat(views.get(0).getContentParts().get(0).getPath()).isEqualTo(SECRET_PATH);
|
||||||
assertThat(views.get(0).getContent()).contains(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user