mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +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;
|
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
|
* Build a sender-attribution tag for group messages. Returns
|
||||||
* {@code [@senderName]} when the message is from a multi-user channel
|
* {@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
|
// 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.
|
// not match, ReadFileTool / DocumentExtractTool cannot find the cached file.
|
||||||
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
|
String shortSuffix = generateShortSessionSuffix(chatId, senderOpenId, isGroup);
|
||||||
|
// 群会话改用完整 chatId,但存量旧会话仍在 legacy 后缀下:读时别名回退,
|
||||||
|
// 让升级前已存在的群沿用旧 conversationId 延续,不重写存量行。
|
||||||
|
if (isGroup && chatId != null) {
|
||||||
|
shortSuffix = resolveGroupSessionSuffix(chatId);
|
||||||
|
}
|
||||||
String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup);
|
String conversationId = buildConversationId(shortSuffix, senderOpenId, isGroup);
|
||||||
|
|
||||||
String stagedUploadPath = null;
|
String stagedUploadPath = null;
|
||||||
@ -1556,19 +1561,22 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 会话 ID 优化 ====================
|
// ==================== 会话 ID ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成更短的会话标识后缀
|
* 生成会话标识后缀。
|
||||||
* - 群聊:app_id 后 4 位 + "_" + chat_id 后 8 位
|
* <ul>
|
||||||
* - 私聊:open_id 后 12 位
|
* <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) {
|
private String generateShortSessionSuffix(String chatId, String openId, boolean isGroup) {
|
||||||
if (isGroup && chatId != null) {
|
if (isGroup && chatId != null) {
|
||||||
String appId = getConfigString("app_id", "");
|
return chatId;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
if (openId != null) {
|
if (openId != null) {
|
||||||
return openId.length() >= 12 ? openId.substring(openId.length() - 12) : openId;
|
return openId.length() >= 12 ? openId.substring(openId.length() - 12) : openId;
|
||||||
@ -1579,6 +1587,49 @@ public class FeishuChannelAdapter extends AbstractChannelAdapter implements Stre
|
|||||||
return null;
|
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
|
* Compute the conversationId that {@link ChannelMessageRouter} would
|
||||||
* derive for this chat, so we can save inbound files to the matching
|
* 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