diff --git a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java index 44d364e9..027107fe 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/webchat/WebChatController.java @@ -509,6 +509,80 @@ public class WebChatController { return R.ok(); } + /** + * 置顶 / 取消置顶某会话线程。Pinned 线程在访客的 /sessions 列表里排在最前 + * (沿用 {@link ConversationService#listWebchatConversations} 的 pinned DESC 排序)。 + */ + @Operation(summary = "置顶 / 取消置顶会话线程") + @PutMapping("/sessions/pinned") + public R 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 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 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 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 diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 12761663..1cb3a114 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -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() + .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}). * diff --git a/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java new file mode 100644 index 00000000..dad31bdc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/webchat/WebChatArchivePinTest.java @@ -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 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 = (R>) (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 r = controller.archiveSession(API_KEY, tokenFor("vArch"), "vArch", "s1", + Map.of("archived", true)); + assertThat(r.getCode()).isEqualTo(200); + + // Default listing: empty. + @SuppressWarnings("unchecked") + R> def = (R>) (R) + controller.listSessions(API_KEY, tokenFor("vArch"), "vArch", false); + assertThat(def.getData()).isEmpty(); + + // includeArchived=true: shows up with archived=1. + @SuppressWarnings("unchecked") + R> all = (R>) (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> back = (R>) (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> def = (R>) (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 r1 = controller.pinSession(API_KEY, tokenFor("vBad"), "vBad", "s1", + Map.of("pinned", "yes")); + assertThat(r1.getCode()).isEqualTo(400); + // Wrong key: + R 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 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 r = controller.archiveSession(API_KEY, "bogus", "vTok", "s1", + Map.of("archived", true)); + assertThat(r.getCode()).isEqualTo(401); + } +}