// Package imgroup 定义腾讯云 IM GroupID 的唯一生成和解析规则。 // gateway、room-service、activity-service 必须共享这里的规则,避免某个服务手写字符串后把播报群误当房间群。 package imgroup import ( "fmt" "strconv" "strings" "hyapp/pkg/appcode" "hyapp/pkg/roomid" ) const ( // KindUnknown 表示 GroupID 既不是本项目播报群,也不是合法房间群;回调层必须 fail-closed。 KindUnknown Kind = "unknown" // KindRoom 保持已有房间群规则:腾讯 IM GroupID 直接等于内部 room_id。 KindRoom Kind = "room" // KindGlobalBroadcast 是 App 维度全局播报群,只允许服务端管理员账号发消息。 KindGlobalBroadcast Kind = "global_broadcast" // KindRegionBroadcast 是区域播报群,用户只能加入自己 user-service 归属的区域群。 KindRegionBroadcast Kind = "region_broadcast" // globalSuffix/regionToken 是可解析协议的一部分;不要在业务代码里重复拼接这些片段。 // globalSuffix 是 v1 全局播报群后缀:ChatRoom 类型受套餐成员上限约束(线上已触发 10038), // 仅保留解析与过渡期双发能力,新群一律生成 v2。 globalSuffix = "_bc_g" // globalSuffixV2 是 v2 全局播报群后缀:AVChatRoom 类型,成员数不受套餐上限约束。 globalSuffixV2 = "_bc_g_v2" // regionToken 是 v1 区域播报群片段:与 v1 全局群同样受 ChatRoom 成员上限约束, // 仅保留解析与过渡期双发能力,新群一律生成 v2。 regionToken = "_bc_r_" // regionTokenV2 是 v2 区域播报群片段:AVChatRoom 类型,成员数不受套餐上限约束。 regionTokenV2 = "_bc_r_v2_" ) // Kind 是回调分流和客户端 join 列表使用的稳定分类,不直接暴露腾讯云原始字符串判断。 type Kind string // ParsedGroup 是 GroupID 的强类型解析结果;只有对应 Kind 的字段才有业务含义。 type ParsedGroup struct { Kind Kind AppCode string RegionID int64 RoomID string } // GlobalBroadcastGroupID 生成 App 全局播报群 ID(v2,AVChatRoom)。 // app_code 先 Normalize 再进入 GroupID,确保 HTTP、gRPC、outbox 和腾讯回调看到的是同一租户标识。 func GlobalBroadcastGroupID(value string) (string, error) { return GlobalBroadcastGroupIDWithPrefix("", value) } // GlobalBroadcastGroupIDWithPrefix 生成带环境前缀的 App 全局播报群 ID(v2,AVChatRoom)。 func GlobalBroadcastGroupIDWithPrefix(prefix string, value string) (string, error) { return globalBroadcastGroupIDWithSuffix(prefix, value, globalSuffixV2) } // LegacyGlobalBroadcastGroupIDWithPrefix 生成 v1 全局播报群 ID。 // 仅供过渡期双发使用:老客户端在拉取新 join 列表前仍停留在 v1 群里。 func LegacyGlobalBroadcastGroupIDWithPrefix(prefix string, value string) (string, error) { return globalBroadcastGroupIDWithSuffix(prefix, value, globalSuffix) } func globalBroadcastGroupIDWithSuffix(prefix string, value string, suffix string) (string, error) { prefix = strings.TrimSpace(prefix) if err := validateGroupIDPrefix(prefix); err != nil { return "", err } app := appcode.Normalize(value) if err := validateAppCodeForGroupID(app); err != nil { return "", err } groupID := prefix + "hy_" + app + suffix if len(groupID) > roomid.MaxStringIDLength { return "", fmt.Errorf("global broadcast group_id is too long") } return groupID, nil } // RegionBroadcastGroupID 生成区域播报群 ID(v2,AVChatRoom)。 // region_id <= 0 被拒绝,GLOBAL/未知区域不能变成一个可 join 的区域群。 func RegionBroadcastGroupID(value string, regionID int64) (string, error) { return RegionBroadcastGroupIDWithPrefix("", value, regionID) } // RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 ID(v2,AVChatRoom)。 func RegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int64) (string, error) { return regionBroadcastGroupIDWithToken(prefix, value, regionID, regionTokenV2) } // LegacyRegionBroadcastGroupIDWithPrefix 生成 v1 区域播报群 ID。 // 仅供过渡期双发和旧群成员移除使用:老客户端在拉取新 join 列表前仍停留在 v1 群里。 func LegacyRegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int64) (string, error) { return regionBroadcastGroupIDWithToken(prefix, value, regionID, regionToken) } func regionBroadcastGroupIDWithToken(prefix string, value string, regionID int64, token string) (string, error) { prefix = strings.TrimSpace(prefix) if err := validateGroupIDPrefix(prefix); err != nil { return "", err } app := appcode.Normalize(value) if err := validateAppCodeForGroupID(app); err != nil { return "", err } if regionID <= 0 { return "", fmt.Errorf("region_id must be positive") } groupID := prefix + "hy_" + app + token + strconv.FormatInt(regionID, 10) if len(groupID) > roomid.MaxStringIDLength { return "", fmt.Errorf("region broadcast group_id is too long") } return groupID, nil } // RoomGroupID 生成腾讯云 IM 房间群 ID。 func RoomGroupID(value string) (string, error) { return RoomGroupIDWithPrefix("", value) } // RoomGroupIDWithPrefix 生成带环境前缀的腾讯云 IM 房间群 ID。 func RoomGroupIDWithPrefix(prefix string, value string) (string, error) { prefix = strings.TrimSpace(prefix) if err := validateGroupIDPrefix(prefix); err != nil { return "", err } room := strings.TrimSpace(value) if !roomid.ValidStringID(room) { return "", fmt.Errorf("room_id is invalid for group_id") } // 房间群 GroupID 必须和内部 room_id 保持一致;线上已有腾讯 IM 群也是这个 ID,不能在投递边界改写。 groupID := prefix + room if len(groupID) > roomid.MaxStringIDLength { return "", fmt.Errorf("room group_id is too long") } return groupID, nil } // Parse 在调用任何 room guard 前完成 GroupID 分类。 // 解析顺序必须先播报群、后房间群;否则 hy__bc_* 这类合法短字符串可能被 roomid 规则吞掉。 func Parse(groupID string) ParsedGroup { groupID = strings.TrimSpace(groupID) if groupID == "" { return ParsedGroup{Kind: KindUnknown} } if parsed := parseBroadcastGroupID(groupID); parsed.Kind != KindUnknown { return parsed } if strings.HasPrefix(groupID, "hy_") && strings.Contains(groupID, "_bc_") { return ParsedGroup{Kind: KindUnknown} } if roomID, ok := RoomIDFromRoomGroupID(groupID); ok { return ParsedGroup{Kind: KindRoom, RoomID: roomID} } return ParsedGroup{Kind: KindUnknown} } // ParseWithPrefix 使用配置的环境前缀解析腾讯云回调里的 GroupID。 func ParseWithPrefix(groupID string, prefix string) ParsedGroup { groupID = strings.TrimSpace(groupID) prefix = strings.TrimSpace(prefix) if groupID == "" || validateGroupIDPrefix(prefix) != nil { return ParsedGroup{Kind: KindUnknown} } if prefix == "" { return Parse(groupID) } if after, ok := strings.CutPrefix(groupID, prefix); ok { if parsed := parseBroadcastGroupID(after); parsed.Kind != KindUnknown { return parsed } if roomID, ok := RoomIDFromRoomGroupID(after); ok { return ParsedGroup{Kind: KindRoom, RoomID: roomID} } return ParsedGroup{Kind: KindUnknown} } if strings.HasPrefix(groupID, "hy_") && strings.Contains(groupID, "_bc_") { return ParsedGroup{Kind: KindUnknown} } if roomID, ok := RoomIDFromRoomGroupID(groupID); ok { return ParsedGroup{Kind: KindRoom, RoomID: roomID} } return ParsedGroup{Kind: KindUnknown} } // RoomIDFromRoomGroupID 把腾讯云 IM 房间群 ID 还原成内部 room_id。 func RoomIDFromRoomGroupID(groupID string) (string, bool) { groupID = strings.TrimSpace(groupID) if !roomid.ValidStringID(groupID) { return "", false } return groupID, true } func parseBroadcastGroupID(groupID string) ParsedGroup { // v2 后缀必须先于 v1 判断:v2 群和 v1 群解析为同一个 Kind,调用方不感知版本。 if strings.HasPrefix(groupID, "hy_") && strings.HasSuffix(groupID, globalSuffixV2) { app := strings.TrimSuffix(strings.TrimPrefix(groupID, "hy_"), globalSuffixV2) if validateAppCodeForGroupID(app) == nil { return ParsedGroup{Kind: KindGlobalBroadcast, AppCode: app} } return ParsedGroup{Kind: KindUnknown} } if strings.HasPrefix(groupID, "hy_") && strings.HasSuffix(groupID, globalSuffix) { app := strings.TrimSuffix(strings.TrimPrefix(groupID, "hy_"), globalSuffix) if validateAppCodeForGroupID(app) == nil { return ParsedGroup{Kind: KindGlobalBroadcast, AppCode: app} } return ParsedGroup{Kind: KindUnknown} } if after, ok := strings.CutPrefix(groupID, "hy_"); ok { body := after // v2 片段必须先于 v1 判断:v1 的 `_bc_r_` 是 v2 `_bc_r_v2_` 的前缀, // 先按 v1 解析会把 v2 群的 `v2_` 当成非法区域。两个版本解析为同一个 Kind,调用方不感知版本。 if parsed, matched := parseRegionBroadcastBody(body, regionTokenV2); matched { return parsed } if parsed, matched := parseRegionBroadcastBody(body, regionToken); matched { return parsed } } return ParsedGroup{Kind: KindUnknown} } func parseRegionBroadcastBody(body string, token string) (ParsedGroup, bool) { tokenIndex := strings.LastIndex(body, token) if tokenIndex < 0 { return ParsedGroup{Kind: KindUnknown}, false } app := body[:tokenIndex] rawRegion := body[tokenIndex+len(token):] regionID, err := strconv.ParseInt(rawRegion, 10, 64) if err == nil && regionID > 0 && validateAppCodeForGroupID(app) == nil { return ParsedGroup{Kind: KindRegionBroadcast, AppCode: app, RegionID: regionID}, true } return ParsedGroup{Kind: KindUnknown}, true } func validateGroupIDPrefix(prefix string) error { if prefix == "" { return nil } for i := 0; i < len(prefix); i++ { char := prefix[i] if char >= 'a' && char <= 'z' { continue } if char >= 'A' && char <= 'Z' { continue } if char >= '0' && char <= '9' { continue } if char == '_' || char == '-' { continue } return fmt.Errorf("group_id_prefix contains unsupported character") } return nil } func validateAppCodeForGroupID(app string) error { if app == "" { return fmt.Errorf("app_code is required") } // GroupID 是腾讯云侧长期存在的外部标识,只允许稳定 ASCII 子集,避免大小写、空格或 Unicode 造成不可逆映射。 for i := 0; i < len(app); i++ { char := app[i] if char >= 'a' && char <= 'z' { continue } if char >= '0' && char <= '9' { continue } if char == '_' || char == '-' { continue } return fmt.Errorf("app_code contains unsupported character") } return nil }