feat(webchat): archive flag + revoked-visitor table + view fields (epic #355 PR 1)

Schema foundations for the rest of the epic. Three additions:

1. mate_conversation.archived — INT default 0. Lets a visitor "soft-close"
   a thread: stays on disk (history preserved, addressable, downloadable)
   but excluded from default /sessions listing. Pinned/archived are
   orthogonal; archive dominates (archived+pinned still hidden by default).

2. webchat_revoked_visitor — registry table consumed in PR 2 by the
   WebChatTokenRevocationService. Unique on (channel_id, visitor_id, deleted)
   so re-revoke is idempotent and deleted=1 un-revokes. Migration written
   for all three DBs (h2 IF NOT EXISTS, MySQL INFORMATION_SCHEMA guard,
   KingbaseES native IF NOT EXISTS) following the V147 pattern.

3. WebChatSessionView gains pinned/archived/streamStatus so the visitor-
   side listing surfaces the same state the admin console sees. Closes
   the "field exposure" gap from the epic.

loadVisitorSessions gains an includeArchived flag (default false) — the
default hides archived threads and excludes them from the empty-session
quota, since the visitor already declared they're done with them.
GET /sessions and GET /sessions/page thread an `includeArchived=true`
query param through.

Tests (WebChatSchemaFieldsTest, @SpringBootTest + H2 + V148):
- revoked-visitor table is queryable
- archived column is read/write
- view exposes pinned/archived/streamStatus
- archived hidden by default, visible with includeArchived=true
- archived empty threads don't saturate the 5-thread quota

Regression: WebChatCreateSessionTest (9/9), WebChatSessionManagementTest
(5/5) — both updated for the new includeArchived param.

Part of epic #355.
This commit is contained in:
倪程伟 2026-06-17 23:50:22 +08:00 committed by matevip
parent 23c1d49241
commit 332f3339a9
9 changed files with 304 additions and 10 deletions

View File

@ -413,7 +413,8 @@ public class WebChatController {
public R<List<WebChatSessionView>> listSessions(
@RequestHeader("X-MC-Key") String apiKey,
@RequestHeader(value = "X-MC-Visitor-Token", required = false) String visitorToken,
@RequestParam String visitorId) {
@RequestParam String visitorId,
@RequestParam(defaultValue = "false") boolean includeArchived) {
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
return R.fail(401, "Invalid API Key");
@ -421,7 +422,7 @@ public class WebChatController {
if (!verifyVisitorToken(visitorTokenSecret, channel.getId(), visitorId, visitorToken)) {
return R.fail(401, "Invalid or missing visitor token");
}
return R.ok(loadVisitorSessions(apiKey, visitorId));
return R.ok(loadVisitorSessions(apiKey, visitorId, includeArchived));
}
/**
@ -437,7 +438,8 @@ public class WebChatController {
@RequestParam String visitorId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword) {
@RequestParam(required = false) String keyword,
@RequestParam(defaultValue = "false") boolean includeArchived) {
ChannelEntity channel = resolveChannel(apiKey);
if (channel == null) {
return R.fail(401, "Invalid API Key");
@ -448,7 +450,7 @@ public class WebChatController {
if (page < 1) page = 1;
if (size < 1 || size > 200) size = 20;
List<WebChatSessionView> all = loadVisitorSessions(apiKey, visitorId);
List<WebChatSessionView> all = loadVisitorSessions(apiKey, visitorId, includeArchived);
if (keyword != null && !keyword.isBlank()) {
String kw = keyword.trim().toLowerCase(java.util.Locale.ROOT);
all = all.stream()
@ -516,6 +518,19 @@ public class WebChatController {
* for legacy rows created before that column existed.
*/
private List<WebChatSessionView> loadVisitorSessions(String apiKey, String visitorId) {
return loadVisitorSessions(apiKey, visitorId, false);
}
/**
* Overload that lets the caller opt into archived threads. By default
* (used by /sessions listing and the empty-session quota check) archived
* rows are filtered out they still exist on disk and are addressable
* by sessionId, but don't pollute the active listing and don't count
* against the "≤ 5 empty threads" quota (the visitor already declared
* they're done with them).
*/
private List<WebChatSessionView> loadVisitorSessions(String apiKey, String visitorId,
boolean includeArchived) {
String base = deriveConversationId(apiKey, visitorId, null);
String channelPrefix = "webchat:" + apiKey.substring(0, Math.min(8, apiKey.length())) + ":";
String owner = webchatUsername(visitorId);
@ -526,9 +541,16 @@ public class WebChatController {
return conversationService.listWebchatConversations(owner).stream()
.filter(c -> c.getConversationId() != null
&& c.getConversationId().startsWith(channelPrefix))
.filter(c -> includeArchived
|| c.getArchived() == null
|| c.getArchived() == 0)
.map(c -> {
String sid = recoverSessionId(c, base);
return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(), c.getMessageCount());
return new WebChatSessionView(sid, c.getTitle(), c.getLastActiveTime(),
c.getMessageCount(),
c.getPinned() != null ? c.getPinned() : 0,
c.getArchived() != null ? c.getArchived() : 0,
c.getStreamStatus() != null ? c.getStreamStatus() : "idle");
})
.collect(Collectors.toList());
}
@ -1047,6 +1069,12 @@ public class WebChatController {
private String title;
private LocalDateTime lastActiveTime;
private Integer messageCount;
/** 1 if the visitor pinned this thread, 0 otherwise. */
private Integer pinned;
/** 1 if the visitor archived this thread, 0 otherwise. */
private Integer archived;
/** {@code running} if a stream is in progress on this thread, else {@code idle}. */
private String streamStatus;
}
/** Body for {@code POST /sessions} — explicitly create an empty thread. */

View File

@ -51,6 +51,16 @@ public class ConversationEntity {
/** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */
private Integer pinned;
/**
* Archive flag (webchat): 0 = active (default), 1 = archived.
* Archived threads stay in the DB (history preserved, still addressable
* by sessionId, downloadable) but are excluded from the default
* /sessions listing. A visitor opts into seeing them via
* {@code includeArchived=true}. Archive dominates pin an archived
* AND pinned thread is still hidden by default.
*/
private Integer archived;
/**
* Provider id of the model this conversation is pinned to. NULL means
* "inherit" fall back to the agent's model override, then the global

View File

@ -66,6 +66,7 @@ public class ConversationVO extends ConversationEntity {
vo.setLastActiveTime(entity.getLastActiveTime());
vo.setWorkspaceId(entity.getWorkspaceId());
vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0);
vo.setArchived(entity.getArchived() != null ? entity.getArchived() : 0);
vo.setModelProvider(entity.getModelProvider());
vo.setModelName(entity.getModelName());
vo.setWebchatSessionId(entity.getWebchatSessionId());

View File

@ -0,0 +1,33 @@
-- V148: webchat visitor-session archive flag + visitor-token revocation registry.
--
-- 1) mate_conversation.archived — INT, 0 (default) = active, 1 = archived.
-- Lets a visitor "soft-close" a thread: it stays in the DB (history
-- preserved, downloadable, addressable by sessionId) but is excluded
-- from the default /sessions listing. Pinned/archived are orthogonal:
-- archive dominates (an archived+pinned thread is still hidden by default).
--
-- 2) webchat_revoked_visitor — registry of visitors whose visitorToken HMAC
-- is no longer accepted on management endpoints (list/messages/title/
-- delete/stop/upload/regenerate). /stream is intentionally NOT bound by
-- this: a revoked visitor can still start a fresh /stream, which mints
-- a new token; the revocation applies to the old token presented on
-- management endpoints. The (channel_id, visitor_id) pair is unique among
-- non-deleted rows so a re-revoke is idempotent; setting deleted=1
-- un-revokes.
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS archived INT NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS webchat_revoked_visitor (
id BIGINT NOT NULL PRIMARY KEY,
channel_id BIGINT NOT NULL,
visitor_id VARCHAR(128) NOT NULL,
revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reason VARCHAR(255),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_webchat_revoked_visitor
ON webchat_revoked_visitor (channel_id, visitor_id, deleted);
CREATE INDEX IF NOT EXISTS idx_webchat_revoked_visitor_lookup
ON webchat_revoked_visitor (channel_id, visitor_id, deleted);

View File

@ -0,0 +1,19 @@
-- V148: webchat visitor-session archive flag + visitor-token revocation registry (KingbaseES / PostgreSQL).
-- See the H2 copy for full context.
ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS archived INT NOT NULL DEFAULT 0;
CREATE TABLE IF NOT EXISTS webchat_revoked_visitor (
id BIGINT NOT NULL PRIMARY KEY,
channel_id BIGINT NOT NULL,
visitor_id VARCHAR(128) NOT NULL,
revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reason VARCHAR(255),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX IF NOT EXISTS uk_webchat_revoked_visitor
ON webchat_revoked_visitor (channel_id, visitor_id, deleted);
CREATE INDEX IF NOT EXISTS idx_webchat_revoked_visitor_lookup
ON webchat_revoked_visitor (channel_id, visitor_id, deleted);

View File

@ -0,0 +1,30 @@
-- V148: webchat visitor-session archive flag + visitor-token revocation registry (MySQL).
-- See the H2 copy for full context.
-- 1) archived column — MySQL 8.0 has no ADD COLUMN IF NOT EXISTS.
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'mate_conversation'
AND COLUMN_NAME = 'archived'
);
SET @ddl := IF(@col_exists = 0,
'ALTER TABLE mate_conversation ADD COLUMN archived INT NOT NULL DEFAULT 0 COMMENT ''webchat: 0 = active, 1 = archived (hidden from default /sessions listing)''',
'SELECT 1');
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 2) revoked-visitor registry
CREATE TABLE IF NOT EXISTS webchat_revoked_visitor (
id BIGINT NOT NULL PRIMARY KEY,
channel_id BIGINT NOT NULL,
visitor_id VARCHAR(128) NOT NULL,
revoked_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reason VARCHAR(255),
create_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted INT NOT NULL DEFAULT 0,
UNIQUE KEY uk_webchat_revoked_visitor (channel_id, visitor_id, deleted),
KEY idx_webchat_revoked_visitor_lookup (channel_id, visitor_id, deleted)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -195,7 +195,7 @@ class WebChatCreateSessionTest {
controller.createSession(API_KEY, req("visitorI", "s-listed", null));
String token = WebChatController.computeVisitorToken(SECRET, CHANNEL_ID, "visitorI");
R<?> r = controller.listSessions(API_KEY, token, "visitorI");
R<?> r = controller.listSessions(API_KEY, token, "visitorI", false);
assertThat(r.getCode()).isEqualTo(200);
assertThat(((java.util.List<WebChatController.WebChatSessionView>) (Object) r.getData()))
.extracting(WebChatController.WebChatSessionView::getSessionId)

View File

@ -0,0 +1,173 @@
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 vip.mate.workspace.conversation.ConversationService;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* End-to-end verification of the V148 schema additions and the
* {@code archived} filtering / view-field exposure that rides on them:
* <ul>
* <li>{@code mate_conversation.archived} column exists and is read/write.</li>
* <li>{@code webchat_revoked_visitor} table exists (full DDL validation
* happens implicitly Flyway would have failed to apply the migration
* otherwise; here we only verify the table is queryable).</li>
* <li>{@link WebChatSessionView} now carries {@code pinned/archived/
* streamStatus}, so the visitor-side listing surfaces the same state
* the admin console sees.</li>
* <li>{@code loadVisitorSessions} filters out archived threads by default;
* {@code includeArchived=true} opts back in.</li>
* </ul>
*/
@SpringBootTest(
classes = MateClawApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE
)
@TestPropertySource(properties = {
"spring.datasource.url=jdbc:h2:mem:webchat_schema_${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 WebChatSchemaFieldsTest {
private static final String SECRET = "webchat-it-secret-0123456789";
private static final String API_KEY = "testkey1abcdefgh";
private static final long CHANNEL_ID = 9_147_301L;
private static final long AGENT_ID = 9_147_3011L;
@Autowired private WebChatController controller;
@Autowired private ConversationService conversationService;
@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-schema-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("revoked-visitor table is queryable (DDL applied)")
void revokedVisitorTableExists() {
// Insert + read back a row to prove the table + columns are live.
jdbc.update("INSERT INTO webchat_revoked_visitor (id, channel_id, visitor_id, reason) " +
"VALUES (?, ?, ?, ?)", 9991L, CHANNEL_ID, "schema-probe", "test");
Integer count = jdbc.queryForObject(
"SELECT COUNT(*) FROM webchat_revoked_visitor WHERE channel_id = ? AND visitor_id = ?",
Integer.class, CHANNEL_ID, "schema-probe");
assertThat(count).isEqualTo(1);
jdbc.update("DELETE FROM webchat_revoked_visitor WHERE id = ?", 9991L);
}
@Test
@DisplayName("archived column on mate_conversation is read/write")
void archivedColumnReadWrite() {
controller.createSession(API_KEY, req("visitorArch", "s1"));
String cid = WebChatController.deriveConversationId(API_KEY, "visitorArch", "s1");
Integer before = jdbc.queryForObject(
"SELECT archived FROM mate_conversation WHERE conversation_id = ?",
Integer.class, cid);
assertThat(before).isZero();
jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid);
Integer after = jdbc.queryForObject(
"SELECT archived FROM mate_conversation WHERE conversation_id = ?",
Integer.class, cid);
assertThat(after).isEqualTo(1);
}
@Test
@DisplayName("WebChatSessionView exposes pinned/archived/streamStatus")
void viewExposesNewFields() {
controller.createSession(API_KEY, req("visitorView", "s1"));
// Flip pinned via the service (endpoint comes in PR 3) so we can assert the view mirrors it.
String cid = WebChatController.deriveConversationId(API_KEY, "visitorView", "s1");
conversationService.setPinned(cid, true);
R<List<WebChatSessionView>> r = controller.listSessions(
API_KEY, tokenFor("visitorView"), "visitorView", false);
assertThat(r.getCode()).isEqualTo(200);
assertThat(r.getData()).hasSize(1);
WebChatSessionView view = r.getData().get(0);
assertThat(view.getPinned()).isEqualTo(1);
assertThat(view.getArchived()).isZero();
assertThat(view.getStreamStatus()).isEqualTo("idle");
}
@Test
@DisplayName("archived threads are hidden from /sessions by default")
@SuppressWarnings("unchecked")
void archivedHiddenByDefault() {
controller.createSession(API_KEY, req("visitorHide", "active"));
controller.createSession(API_KEY, req("visitorHide", "stale"));
String staleCid = WebChatController.deriveConversationId(API_KEY, "visitorHide", "stale");
jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", staleCid);
// Default: only "active" is returned.
R<?> def = controller.listSessions(API_KEY, tokenFor("visitorHide"), "visitorHide", false);
assertThat(((List<WebChatSessionView>) def.getData()))
.extracting(WebChatSessionView::getSessionId)
.containsExactly("active");
// includeArchived=true: both.
R<?> all = controller.listSessions(API_KEY, tokenFor("visitorHide"), "visitorHide", true);
assertThat(((List<WebChatSessionView>) all.getData()))
.extracting(WebChatSessionView::getSessionId)
.containsExactlyInAnyOrder("active", "stale");
}
@Test
@DisplayName("archived empty threads don't count against the 5-empty-session quota")
void archivedExcludedFromQuota() {
// Pre-seed 5 archived empty threads + verify a 6th (active) creation still succeeds
// the quota gate filters archived out, so the active count is 0 here.
String owner = WebChatController.webchatUsername("visitorQuota");
for (int i = 1; i <= 5; i++) {
String cid = WebChatController.deriveConversationId(API_KEY, "visitorQuota", "arch" + i);
conversationService.getOrCreateWebchatConversation(
cid, AGENT_ID, owner, 1L, "arch" + i);
jdbc.update("UPDATE mate_conversation SET archived = 1 WHERE conversation_id = ?", cid);
}
R<?> r = controller.createSession(API_KEY, req("visitorQuota", "fresh"));
assertThat(r.getCode())
.as("archived threads must not saturate the empty-session quota")
.isEqualTo(200);
}
}

View File

@ -76,7 +76,7 @@ class WebChatSessionManagementTest {
@DisplayName("listSessions includes the hashed long-id thread with its sessionId recovered")
@SuppressWarnings("unchecked")
void listsHashedThread() {
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, token, VISITOR);
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, token, VISITOR, false);
assertThat(r.getCode()).isEqualTo(200);
List<WebChatSessionView> sessions = r.getData();
assertThat(sessions).hasSize(3);
@ -88,21 +88,21 @@ class WebChatSessionManagementTest {
@DisplayName("bad visitor token is rejected")
@SuppressWarnings("unchecked")
void rejectsBadToken() {
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, "bogus", VISITOR);
R<List<WebChatSessionView>> r = (R<List<WebChatSessionView>>) (R<?>) controller.listSessions(API_KEY, "bogus", VISITOR, false);
assertThat(r.getCode()).isEqualTo(401);
}
@Test
@DisplayName("pageSessions paginates and keyword-searches by title")
void pagesAndSearches() {
R<Map<String, Object>> page = controller.pageSessions(API_KEY, token, VISITOR, 1, 2, null);
R<Map<String, Object>> page = controller.pageSessions(API_KEY, token, VISITOR, 1, 2, null, false);
assertThat(page.getCode()).isEqualTo(200);
assertThat(page.getData().get("total")).isEqualTo(3L);
assertThat((List<?>) page.getData().get("items")).hasSize(2);
// Rename one thread, then search for it.
controller.renameSession(API_KEY, token, VISITOR, "s1", Map.of("title", "QuarterlyReport"));
R<Map<String, Object>> hit = controller.pageSessions(API_KEY, token, VISITOR, 1, 20, "quarterly");
R<Map<String, Object>> hit = controller.pageSessions(API_KEY, token, VISITOR, 1, 20, "quarterly", false);
assertThat((List<?>) hit.getData().get("items")).hasSize(1);
}