hyapp-server/pkg/imgroup/imgroup.go
2026-07-04 01:53:10 +08:00

287 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 全局播报群 IDv2AVChatRoom
// app_code 先 Normalize 再进入 GroupID确保 HTTP、gRPC、outbox 和腾讯回调看到的是同一租户标识。
func GlobalBroadcastGroupID(value string) (string, error) {
return GlobalBroadcastGroupIDWithPrefix("", value)
}
// GlobalBroadcastGroupIDWithPrefix 生成带环境前缀的 App 全局播报群 IDv2AVChatRoom
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 生成区域播报群 IDv2AVChatRoom
// region_id <= 0 被拒绝GLOBAL/未知区域不能变成一个可 join 的区域群。
func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
return RegionBroadcastGroupIDWithPrefix("", value, regionID)
}
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 IDv2AVChatRoom
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_<app>_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_<id>` 当成非法区域。两个版本解析为同一个 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
}