From a40868eff2fd96e4bc3884de0bafa9846c25abe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=80=AA=E7=A8=8B=E4=BC=9F?= Date: Tue, 9 Jun 2026 09:25:18 +0800 Subject: [PATCH] =?UTF-8?q?refactor(feishu):=20=E7=BE=A4=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=20ID=20=E6=94=B9=E7=94=A8=E5=AE=8C=E6=95=B4=20chatId=20?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=90=8E=E7=BC=80=E7=A2=B0=E6=92=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 旧实现群会话 conversationId = feishu:{appId后4}_{chatId后8},截断后缀 存在碰撞风险:不同群 chatId 后 8 位相同 → 消息落进同一会话、上下文串台。 群会话改用完整 chatId(feishu:{chatId})消除碰撞。 存量迁移(读时别名回退,不重写存量行): - 旧后缀不可逆推完整 chatId,故不做一次性回填; - 但每条入站群消息都带完整 chatId + appId,可在路由前重算 legacy key; - 新群 / 已迁移群 → 用 feishu:{chatId};存量群(canonical 无、legacy 有) → 沿用 legacy key,历史无缝延续,零停机、零破坏性写。 - ChannelMessageRouter 增只读 conversationExists(id)(委托 findByConversationId)。 - 私聊不受影响(DM 经 buildConversationId 直接用完整 senderOpenId,后缀本就不参与)。 Closes #299 --- .../mate/channel/ChannelMessageRouter.java | 9 ++ .../channel/feishu/FeishuChannelAdapter.java | 67 +++++++++++-- .../channel/feishu/FeishuSessionIdTest.java | 99 +++++++++++++++++++ 3 files changed, 167 insertions(+), 8 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSessionIdTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index d0ecc33c..17adde6b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -1310,6 +1310,15 @@ public class ChannelMessageRouter { return message.getChannelType() + ":" + identifier; } + /** + * Read-only existence check for a conversation by its logical id. Used by + * adapters that need to alias a legacy conversationId scheme to a new one + * without rewriting stored rows (e.g. Feishu group session-id migration). + */ + public boolean conversationExists(String conversationId) { + return conversationService.findByConversationId(conversationId) != null; + } + /** * Build a sender-attribution tag for group messages. Returns * {@code [@senderName]} when the message is from a multi-user channel diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java index 0fc583dc..0bea5a1b 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/FeishuChannelAdapter.java @@ -1116,6 +1116,11 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre // prompt only exposes the file name (not its path) to the model — so if this id does // not match, ReadFileTool / DocumentExtractTool cannot find the cached file. String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup); + // 群会话改用完整 chatId,但存量旧会话仍在 legacy 后缀下:读时别名回退, + // 让升级前已存在的群沿用旧 conversationId 延续,不重写存量行。 + if (isGroup && chatId != null) { + shortSuffix = resolveGroupSessionSuffix(chatId); + } String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup); String stagedUploadPath = null; @@ -1556,19 +1561,22 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return null; } - // ==================== 会话 ID 优化 ==================== + // ==================== 会话 ID ==================== /** - * 生成更短的会话标识后缀 - * - 群聊:app_id 后 4 位 + "_" + chat_id 后 8 位 - * - 私聊:open_id 后 12 位 + * 生成会话标识后缀。 + * */ private String generateShortSessionSuffix(String chatId, String openId, boolean isGroup) { if (isGroup && chatId != null) { - String appId = getConfigString("app_id", ""); - String appSuffix = appId.length() >= 4 ? appId.substring(appId.length() - 4) : appId; - String chatSuffix = chatId.length() >= 8 ? chatId.substring(chatId.length() - 8) : chatId; - return appSuffix + "_" + chatSuffix; + return chatId; } if (openId != null) { return openId.length() >= 12 ? openId.substring(openId.length() - 12) : openId; @@ -1579,6 +1587,49 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre return null; } + /** + * 旧群会话后缀算法:{@code {appId后4}_{chatId后8}}。仅用于读时别名回退—— + * 在不重写存量行的前提下,让升级前已存在的群会话沿用旧 conversationId 无缝延续。 + */ + // Package-private for testing. + String legacyGroupSuffix(String chatId) { + if (chatId == null) return null; + String appId = getConfigString("app_id", ""); + String appSuffix = appId.length() >= 4 ? appId.substring(appId.length() - 4) : appId; + String chatSuffix = chatId.length() >= 8 ? chatId.substring(chatId.length() - 8) : chatId; + return appSuffix + "_" + chatSuffix; + } + + /** + * 群会话后缀的读时别名回退(不重写存量): + * + */ + // Package-private for testing. + String resolveGroupSessionSuffix(String chatId) { + String canonical = chatId; + String legacy = legacyGroupSuffix(chatId); + if (legacy == null || legacy.equals(canonical)) return canonical; + boolean canonicalExists = messageRouter.conversationExists(CHANNEL_TYPE + ":" + canonical); + boolean legacyExists = !canonicalExists + && messageRouter.conversationExists(CHANNEL_TYPE + ":" + legacy); + String picked = pickGroupSessionSuffix(canonical, legacy, canonicalExists, legacyExists); + if (picked.equals(legacy)) { + log.info("[feishu] Reusing legacy group session id for chat={} (read-time alias, no migration write)", chatId); + } + return picked; + } + + /** Package-private for testing: 纯选择逻辑——存量 legacy 会话存在且尚未迁移时沿用 legacy,否则用 canonical。 */ + static String pickGroupSessionSuffix(String canonical, String legacy, + boolean canonicalExists, boolean legacyExists) { + if (!canonicalExists && legacyExists) return legacy; + return canonical; + } + /** * Compute the conversationId that {@link ChannelMessageRouter} would * derive for this chat, so we can save inbound files to the matching diff --git a/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSessionIdTest.java b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSessionIdTest.java new file mode 100644 index 00000000..b09fcd18 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/feishu/FeishuSessionIdTest.java @@ -0,0 +1,99 @@ +package vip.mate.channel.feishu; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.channel.ChannelMessageRouter; +import vip.mate.channel.model.ChannelEntity; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * #299:群会话 ID 改用完整 chatId 避免后缀碰撞,存量旧会话用读时别名回退(不重写)。 + */ +class FeishuSessionIdTest { + + private static final String CHANNEL = FeishuChannelAdapter.CHANNEL_TYPE; // "feishu" + private static final String APP_ID = "cli_a1b2c3d4e5f6"; + + // ==================== legacyGroupSuffix:旧后缀算法 + 碰撞演示 ==================== + + @Test + void legacySuffix_format_appLast4UnderscoreChatLast8() { + FeishuChannelAdapter adapter = newAdapter(mock(ChannelMessageRouter.class)); + // appId 后 4 = "e5f6"; chatId 后 8 = "11112222" + assertEquals("e5f6_11112222", adapter.legacyGroupSuffix("oc_aaaa11112222")); + } + + @Test + void legacySuffix_collides_whileFullChatIdDoesNot() { + FeishuChannelAdapter adapter = newAdapter(mock(ChannelMessageRouter.class)); + String chatA = "oc_AAAA_11112222"; + String chatB = "oc_BBBB_11112222"; // 不同群,但 chatId 后 8 位相同 + // 旧后缀碰撞(这正是 #299 要修的 bug)…… + assertEquals(adapter.legacyGroupSuffix(chatA), adapter.legacyGroupSuffix(chatB)); + // ……而完整 chatId 不碰撞。 + assertNotEquals(chatA, chatB); + } + + // ==================== pickGroupSessionSuffix:纯选择逻辑 ==================== + + @Test + void pick_newGroup_neitherExists_usesCanonical() { + assertEquals("oc_full", FeishuChannelAdapter.pickGroupSessionSuffix( + "oc_full", "e5f6_ocfull99", false, false)); + } + + @Test + void pick_canonicalAlreadyExists_usesCanonical() { + assertEquals("oc_full", FeishuChannelAdapter.pickGroupSessionSuffix( + "oc_full", "e5f6_ocfull99", true, false)); + } + + @Test + void pick_onlyLegacyExists_reusesLegacy() { + assertEquals("e5f6_ocfull99", FeishuChannelAdapter.pickGroupSessionSuffix( + "oc_full", "e5f6_ocfull99", false, true)); + } + + // ==================== resolveGroupSessionSuffix:读时别名回退(含路由查找) ==================== + + @Test + void resolve_newGroup_returnsFullChatId() { + ChannelMessageRouter router = mock(ChannelMessageRouter.class); + when(router.conversationExists(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + FeishuChannelAdapter adapter = newAdapter(router); + assertEquals("oc_aaaa11112222", adapter.resolveGroupSessionSuffix("oc_aaaa11112222")); + } + + @Test + void resolve_canonicalExists_returnsFullChatId() { + ChannelMessageRouter router = mock(ChannelMessageRouter.class); + when(router.conversationExists(CHANNEL + ":oc_aaaa11112222")).thenReturn(true); + FeishuChannelAdapter adapter = newAdapter(router); + assertEquals("oc_aaaa11112222", adapter.resolveGroupSessionSuffix("oc_aaaa11112222")); + } + + @Test + void resolve_legacyConversationExists_reusesLegacyId() { + ChannelMessageRouter router = mock(ChannelMessageRouter.class); + when(router.conversationExists(CHANNEL + ":oc_aaaa11112222")).thenReturn(false); + when(router.conversationExists(CHANNEL + ":e5f6_11112222")).thenReturn(true); + FeishuChannelAdapter adapter = newAdapter(router); + // 存量群:canonical 无、legacy 有 → 沿用 legacy,历史无缝延续。 + assertEquals("e5f6_11112222", adapter.resolveGroupSessionSuffix("oc_aaaa11112222")); + } + + // ==================== helpers ==================== + + private static FeishuChannelAdapter newAdapter(ChannelMessageRouter router) { + ChannelEntity e = new ChannelEntity(); + e.setId(1L); + e.setChannelType("feishu"); + e.setConfigJson("{\"app_id\":\"" + APP_ID + "\",\"app_secret\":\"y\"}"); + return new FeishuChannelAdapter( + e, router, new ObjectMapper(), + null, null, null, null, null, null, null); + } +}