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 位
+ * 生成会话标识后缀。
+ *
+ * - 群聊:直接使用完整 {@code chatId}(全局唯一)。旧实现用 {@code {appId后4}_{chatId后8}}
+ * 截断后缀,不同群的 {@code chatId} 后 8 位可能相同 → 会话串台。改用完整 chatId 消除碰撞。
+ * 存量旧会话不重写,由 {@link #resolveGroupSessionSuffix} 做读时别名回退。
+ * - 私聊:保持原状(取 {@code openId} 后 12 位)。注意私聊路径下该后缀实际不参与
+ * conversationId——{@link #buildConversationId} 对 DM 直接用完整 {@code senderOpenId},
+ * 故私聊会话 ID 不受本次改动影响。
+ *
*/
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;
+ }
+
+ /**
+ * 群会话后缀的读时别名回退(不重写存量):
+ *
+ * - 新群(两个 key 都无会话)→ 用完整 chatId 的 canonical key;
+ * - 已迁移群(canonical key 已有会话)→ 用 canonical;
+ * - 存量群(canonical 无、legacy 有)→ 沿用 legacy key,历史无缝延续。
+ *
+ */
+ // 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);
+ }
+}