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:
倪程伟 2026-06-18 00:30:02 +08:00 committed by matevip
parent bccc5767ed
commit 961ecad7f1
3 changed files with 260 additions and 0 deletions

View File

@ -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

View File

@ -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}).
*

View File

@ -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);
}
}