mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(webchat): pin + archive endpoints (epic #355 PR 3)
Two new session-state mutations, both following the rename endpoint's
shape (PUT + {flag: true|false} body + visitorId/sessionId query):
- PUT /api/v1/channels/webchat/sessions/pinned — flips mate_conversation.pinned
- PUT /api/v1/channels/webchat/sessions/archive — flips mate_conversation.archived
Archive complements delete as a "soft-close" — the thread stays on disk
(history preserved, addressable, downloadable) but is hidden from the
default /sessions listing. Pin makes a thread sort first in the visitor's
listing, mirroring the admin-console behavior.
Archive dominates pin: an archived+pinned thread is still hidden by
default. Callers opt back in via includeArchived=true (added in PR 1).
ConversationService gains setArchived(), mirroring the existing
setPinned() pattern.
WebChatArchivePinTest (@SpringBootTest, 6 cases):
- pin flips column + view reflects pinned=1
- archive hides from default listing, includeArchived=true shows it,
un-archive restores
- archived+pinned still hidden (archive dominates)
- malformed body / wrong type → 400
- unknown sessionId → 404
- bad token → 401
Part of epic #355.
This commit is contained in:
parent
bccc5767ed
commit
961ecad7f1
@ -509,6 +509,80 @@ public class WebChatController {
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 置顶 / 取消置顶某会话线程。Pinned 线程在访客的 /sessions 列表里排在最前
|
||||
* (沿用 {@link ConversationService#listWebchatConversations} 的 pinned DESC 排序)。
|
||||
*/
|
||||
@Operation(summary = "置顶 / 取消置顶会话线程")
|
||||
@PutMapping("/sessions/pinned")
|
||||
public R<Void> pinSession(
|
||||
@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, Object> body) {
|
||||
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");
|
||||
}
|
||||
Object v = body != null ? body.get("pinned") : null;
|
||||
if (!(v instanceof Boolean)) {
|
||||
return R.fail(400, "body must contain {pinned: true|false}");
|
||||
}
|
||||
conversationService.setPinned(conversationId, (Boolean) v);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档 / 取消归档某会话线程。归档后线程仍在 DB(历史保留、按 sessionId 寻址、文件可下载),
|
||||
* 但默认从 /sessions 列表隐藏;调用方需传 {@code includeArchived=true} 才能看到。
|
||||
*/
|
||||
@Operation(summary = "归档 / 取消归档会话线程")
|
||||
@PutMapping("/sessions/archive")
|
||||
public R<Void> archiveSession(
|
||||
@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, Object> body) {
|
||||
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");
|
||||
}
|
||||
Object v = body != null ? body.get("archived") : null;
|
||||
if (!(v instanceof Boolean)) {
|
||||
return R.fail(400, "body must contain {archived: true|false}");
|
||||
}
|
||||
conversationService.setArchived(conversationId, (Boolean) v);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load this visitor's session threads (own namespace only), mapped to the
|
||||
* compact view. Sorted as {@code listConversations} returns them (pinned
|
||||
|
||||
@ -635,6 +635,21 @@ public class ConversationService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive or unarchive a conversation (webchat soft-close). Mirrors
|
||||
* {@link #setPinned}: archived threads stay on disk (history preserved,
|
||||
* addressable, downloadable) but are excluded from default listings; the
|
||||
* caller opts back in via {@code includeArchived=true}.
|
||||
*/
|
||||
public void setArchived(String conversationId, boolean archived) {
|
||||
ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper<ConversationEntity>()
|
||||
.eq(ConversationEntity::getConversationId, conversationId));
|
||||
if (conv != null) {
|
||||
conv.setArchived(archived ? 1 : 0);
|
||||
conversationMapper.updateById(conv);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a conversation's stream status ({@code running} / {@code idle}).
|
||||
*
|
||||
|
||||
@ -0,0 +1,171 @@
|
||||
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.channel.webchat.WebChatController.WebChatSessionView;
|
||||
import vip.mate.common.result.R;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* End-to-end verification of the pin and archive endpoints (epic #355 PR 3).
|
||||
* Both endpoints share the same shape (PUT with {flag: true|false} body +
|
||||
* query visitorId/sessionId) and the same auth chain as the other session
|
||||
* mutations. Tests assert the persisted column flips AND that
|
||||
* {@link WebChatController#listSessions} reflects the change accordingly.
|
||||
*/
|
||||
@SpringBootTest(
|
||||
classes = MateClawApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE
|
||||
)
|
||||
@TestPropertySource(properties = {
|
||||
"spring.datasource.url=jdbc:h2:mem:webchat_pinarch_${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 WebChatArchivePinTest {
|
||||
|
||||
private static final String SECRET = "webchat-it-secret-0123456789";
|
||||
private static final String API_KEY = "testkey1abcdefgh";
|
||||
private static final long CHANNEL_ID = 9_147_501L;
|
||||
private static final long AGENT_ID = 9_147_5011L;
|
||||
|
||||
@Autowired private WebChatController controller;
|
||||
@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-pinarch-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) {
|
||||
WebChatCreateSessionRequest r = new WebChatCreateSessionRequest();
|
||||
r.setVisitorId(visitorId);
|
||||
r.setSessionId(sessionId);
|
||||
return r;
|
||||
}
|
||||
|
||||
private String tokenFor(String visitorId) {
|
||||
return WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, visitorId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /sessions/pinned flips the column + view reports pinned=1")
|
||||
void pinFlipsColumn() {
|
||||
controller.createSession(API_KEY, req("vPin", "s1"));
|
||||
String cid = WebChatController.deriveConversationId(API_KEY, "vPin", "s1");
|
||||
|
||||
R<Void> r = controller.pinSession(API_KEY, tokenFor("vPin"), "vPin", "s1",
|
||||
Map.of("pinned", true));
|
||||
assertThat(r.getCode()).isEqualTo(200);
|
||||
|
||||
Integer col = jdbc.queryForObject(
|
||||
"SELECT pinned FROM mate_conversation WHERE conversation_id = ?",
|
||||
Integer.class, cid);
|
||||
assertThat(col).isEqualTo(1);
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
R<List<WebChatSessionView>> list = (R<List<WebChatSessionView>>) (R<?>)
|
||||
controller.listSessions(API_KEY, tokenFor("vPin"), "vPin", false);
|
||||
assertThat(list.getData().get(0).getPinned()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("PUT /sessions/archive hides the thread from default listing")
|
||||
void archiveHidesFromDefaultListing() {
|
||||
controller.createSession(API_KEY, req("vArch", "s1"));
|
||||
|
||||
R<Void> r = controller.archiveSession(API_KEY, tokenFor("vArch"), "vArch", "s1",
|
||||
Map.of("archived", true));
|
||||
assertThat(r.getCode()).isEqualTo(200);
|
||||
|
||||
// Default listing: empty.
|
||||
@SuppressWarnings("unchecked")
|
||||
R<List<WebChatSessionView>> def = (R<List<WebChatSessionView>>) (R<?>)
|
||||
controller.listSessions(API_KEY, tokenFor("vArch"), "vArch", false);
|
||||
assertThat(def.getData()).isEmpty();
|
||||
|
||||
// includeArchived=true: shows up with archived=1.
|
||||
@SuppressWarnings("unchecked")
|
||||
R<List<WebChatSessionView>> all = (R<List<WebChatSessionView>>) (R<?>)
|
||||
controller.listSessions(API_KEY, tokenFor("vArch"), "vArch", true);
|
||||
assertThat(all.getData()).hasSize(1);
|
||||
assertThat(all.getData().get(0).getArchived()).isEqualTo(1);
|
||||
|
||||
// Un-archive restores visibility.
|
||||
controller.archiveSession(API_KEY, tokenFor("vArch"), "vArch", "s1",
|
||||
Map.of("archived", false));
|
||||
@SuppressWarnings("unchecked")
|
||||
R<List<WebChatSessionView>> back = (R<List<WebChatSessionView>>) (R<?>)
|
||||
controller.listSessions(API_KEY, tokenFor("vArch"), "vArch", false);
|
||||
assertThat(back.getData()).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("archived + pinned thread still hidden by default (archive dominates)")
|
||||
void archiveDominatesPin() {
|
||||
controller.createSession(API_KEY, req("vBoth", "s1"));
|
||||
controller.pinSession(API_KEY, tokenFor("vBoth"), "vBoth", "s1",
|
||||
Map.of("pinned", true));
|
||||
controller.archiveSession(API_KEY, tokenFor("vBoth"), "vBoth", "s1",
|
||||
Map.of("archived", true));
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
R<List<WebChatSessionView>> def = (R<List<WebChatSessionView>>) (R<?>)
|
||||
controller.listSessions(API_KEY, tokenFor("vBoth"), "vBoth", false);
|
||||
assertThat(def.getData()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("missing or wrong-typed body → 400")
|
||||
void rejectsMalformedBody() {
|
||||
controller.createSession(API_KEY, req("vBad", "s1"));
|
||||
// Wrong type:
|
||||
R<Void> r1 = controller.pinSession(API_KEY, tokenFor("vBad"), "vBad", "s1",
|
||||
Map.of("pinned", "yes"));
|
||||
assertThat(r1.getCode()).isEqualTo(400);
|
||||
// Wrong key:
|
||||
R<Void> r2 = controller.archiveSession(API_KEY, tokenFor("vBad"), "vBad", "s1",
|
||||
Map.of("flag", true));
|
||||
assertThat(r2.getCode()).isEqualTo(400);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("unknown sessionId → 404 (no probing)")
|
||||
void rejectsUnknownSession() {
|
||||
R<Void> r = controller.pinSession(API_KEY, tokenFor("vGhost"), "vGhost", "ghost",
|
||||
Map.of("pinned", true));
|
||||
assertThat(r.getCode()).isEqualTo(404);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("bad token → 401")
|
||||
void rejectsBadToken() {
|
||||
controller.createSession(API_KEY, req("vTok", "s1"));
|
||||
R<Void> r = controller.archiveSession(API_KEY, "bogus", "vTok", "s1",
|
||||
Map.of("archived", true));
|
||||
assertThat(r.getCode()).isEqualTo(401);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user