refactor(feishu): 群会话 ID 改用完整 chatId 避免后缀碰撞

旧实现群会话 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
This commit is contained in:
倪程伟 2026-06-09 09:25:18 +08:00 committed by matevip
parent c362d12425
commit a40868eff2
3 changed files with 167 additions and 8 deletions

View File

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

View File

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

View File

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