mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
c362d12425
commit
a40868eff2
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user