Add test IM broadcast group prefix
This commit is contained in:
parent
6d35f421c0
commit
f5f2b828c6
@ -40,11 +40,20 @@ type ParsedGroup struct {
|
|||||||
// GlobalBroadcastGroupID 生成 App 全局播报群 ID。
|
// GlobalBroadcastGroupID 生成 App 全局播报群 ID。
|
||||||
// app_code 先 Normalize 再进入 GroupID,确保 HTTP、gRPC、outbox 和腾讯回调看到的是同一租户标识。
|
// app_code 先 Normalize 再进入 GroupID,确保 HTTP、gRPC、outbox 和腾讯回调看到的是同一租户标识。
|
||||||
func GlobalBroadcastGroupID(value string) (string, error) {
|
func GlobalBroadcastGroupID(value string) (string, error) {
|
||||||
|
return GlobalBroadcastGroupIDWithPrefix("", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GlobalBroadcastGroupIDWithPrefix 生成带环境前缀的 App 全局播报群 ID。
|
||||||
|
func GlobalBroadcastGroupIDWithPrefix(prefix string, value string) (string, error) {
|
||||||
|
prefix = strings.TrimSpace(prefix)
|
||||||
|
if err := validateGroupIDPrefix(prefix); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
app := appcode.Normalize(value)
|
app := appcode.Normalize(value)
|
||||||
if err := validateAppCodeForGroupID(app); err != nil {
|
if err := validateAppCodeForGroupID(app); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
groupID := "hy_" + app + globalSuffix
|
groupID := prefix + "hy_" + app + globalSuffix
|
||||||
if len(groupID) > roomid.MaxStringIDLength {
|
if len(groupID) > roomid.MaxStringIDLength {
|
||||||
return "", fmt.Errorf("global broadcast group_id is too long")
|
return "", fmt.Errorf("global broadcast group_id is too long")
|
||||||
}
|
}
|
||||||
@ -54,6 +63,15 @@ func GlobalBroadcastGroupID(value string) (string, error) {
|
|||||||
// RegionBroadcastGroupID 生成区域播报群 ID。
|
// RegionBroadcastGroupID 生成区域播报群 ID。
|
||||||
// region_id <= 0 被拒绝,GLOBAL/未知区域不能变成一个可 join 的区域群。
|
// region_id <= 0 被拒绝,GLOBAL/未知区域不能变成一个可 join 的区域群。
|
||||||
func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
|
func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
|
||||||
|
return RegionBroadcastGroupIDWithPrefix("", value, regionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 ID。
|
||||||
|
func RegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int64) (string, error) {
|
||||||
|
prefix = strings.TrimSpace(prefix)
|
||||||
|
if err := validateGroupIDPrefix(prefix); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
app := appcode.Normalize(value)
|
app := appcode.Normalize(value)
|
||||||
if err := validateAppCodeForGroupID(app); err != nil {
|
if err := validateAppCodeForGroupID(app); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@ -61,7 +79,7 @@ func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
|
|||||||
if regionID <= 0 {
|
if regionID <= 0 {
|
||||||
return "", fmt.Errorf("region_id must be positive")
|
return "", fmt.Errorf("region_id must be positive")
|
||||||
}
|
}
|
||||||
groupID := "hy_" + app + regionToken + strconv.FormatInt(regionID, 10)
|
groupID := prefix + "hy_" + app + regionToken + strconv.FormatInt(regionID, 10)
|
||||||
if len(groupID) > roomid.MaxStringIDLength {
|
if len(groupID) > roomid.MaxStringIDLength {
|
||||||
return "", fmt.Errorf("region broadcast group_id is too long")
|
return "", fmt.Errorf("region broadcast group_id is too long")
|
||||||
}
|
}
|
||||||
@ -76,6 +94,42 @@ func Parse(groupID string) ParsedGroup {
|
|||||||
return ParsedGroup{Kind: KindUnknown}
|
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.ValidStringID(groupID) {
|
||||||
|
return ParsedGroup{Kind: KindRoom, RoomID: groupID}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
return parseBroadcastGroupID(after)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(groupID, "hy_") && strings.Contains(groupID, "_bc_") {
|
||||||
|
return ParsedGroup{Kind: KindUnknown}
|
||||||
|
}
|
||||||
|
if roomid.ValidStringID(groupID) {
|
||||||
|
return ParsedGroup{Kind: KindRoom, RoomID: groupID}
|
||||||
|
}
|
||||||
|
return ParsedGroup{Kind: KindUnknown}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBroadcastGroupID(groupID string) ParsedGroup {
|
||||||
if strings.HasPrefix(groupID, "hy_") && strings.HasSuffix(groupID, globalSuffix) {
|
if strings.HasPrefix(groupID, "hy_") && strings.HasSuffix(groupID, globalSuffix) {
|
||||||
app := strings.TrimSuffix(strings.TrimPrefix(groupID, "hy_"), globalSuffix)
|
app := strings.TrimSuffix(strings.TrimPrefix(groupID, "hy_"), globalSuffix)
|
||||||
if validateAppCodeForGroupID(app) == nil {
|
if validateAppCodeForGroupID(app) == nil {
|
||||||
@ -98,12 +152,32 @@ func Parse(groupID string) ParsedGroup {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if roomid.ValidStringID(groupID) {
|
|
||||||
return ParsedGroup{Kind: KindRoom, RoomID: groupID}
|
|
||||||
}
|
|
||||||
return ParsedGroup{Kind: KindUnknown}
|
return ParsedGroup{Kind: KindUnknown}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func validateAppCodeForGroupID(app string) error {
|
||||||
if app == "" {
|
if app == "" {
|
||||||
return fmt.Errorf("app_code is required")
|
return fmt.Errorf("app_code is required")
|
||||||
|
|||||||
@ -42,3 +42,35 @@ func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) {
|
|||||||
t.Fatal("app_code with spaces must be rejected")
|
t.Fatal("app_code with spaces must be rejected")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBroadcastGroupIDWithPrefix(t *testing.T) {
|
||||||
|
global, err := GlobalBroadcastGroupIDWithPrefix("test_", "lalu")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GlobalBroadcastGroupIDWithPrefix failed: %v", err)
|
||||||
|
}
|
||||||
|
if global != "test_hy_lalu_bc_g" {
|
||||||
|
t.Fatalf("prefixed global group mismatch: %s", global)
|
||||||
|
}
|
||||||
|
region, err := RegionBroadcastGroupIDWithPrefix("test_", "lalu", 1001)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RegionBroadcastGroupIDWithPrefix failed: %v", err)
|
||||||
|
}
|
||||||
|
if region != "test_hy_lalu_bc_r_1001" {
|
||||||
|
t.Fatalf("prefixed region group mismatch: %s", region)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedGlobal := ParseWithPrefix(global, "test_")
|
||||||
|
if parsedGlobal.Kind != KindGlobalBroadcast || parsedGlobal.AppCode != "lalu" {
|
||||||
|
t.Fatalf("prefixed global parse mismatch: %+v", parsedGlobal)
|
||||||
|
}
|
||||||
|
parsedRegion := ParseWithPrefix(region, "test_")
|
||||||
|
if parsedRegion.Kind != KindRegionBroadcast || parsedRegion.AppCode != "lalu" || parsedRegion.RegionID != 1001 {
|
||||||
|
t.Fatalf("prefixed region parse mismatch: %+v", parsedRegion)
|
||||||
|
}
|
||||||
|
if parsed := ParseWithPrefix("hy_lalu_bc_r_1001", "test_"); parsed.Kind != KindUnknown {
|
||||||
|
t.Fatalf("unprefixed broadcast group must be rejected when prefix is configured: %+v", parsed)
|
||||||
|
}
|
||||||
|
if parsed := ParseWithPrefix("room-1001", "test_"); parsed.Kind != KindRoom || parsed.RoomID != "room-1001" {
|
||||||
|
t.Fatalf("room parse with prefix mismatch: %+v", parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -34,6 +34,7 @@ tencent_im:
|
|||||||
admin_user_sig_ttl: "24h"
|
admin_user_sig_ttl: "24h"
|
||||||
endpoint: "adminapisgp.im.qcloud.com"
|
endpoint: "adminapisgp.im.qcloud.com"
|
||||||
group_type: "ChatRoom"
|
group_type: "ChatRoom"
|
||||||
|
group_id_prefix: "test_"
|
||||||
request_timeout: "5s"
|
request_timeout: "5s"
|
||||||
broadcast:
|
broadcast:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -136,6 +136,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry,
|
WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry,
|
||||||
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
|
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
|
||||||
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
|
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
|
||||||
|
GroupIDPrefix: cfg.TencentIM.GroupIDPrefix,
|
||||||
}, repository, broadcastPublisher, client.NewGRPCRegionSource(userConn))
|
}, repository, broadcastPublisher, client.NewGRPCRegionSource(userConn))
|
||||||
broadcastSvc.SetSenderProfileSource(client.NewGRPCUserProfileSource(userConn))
|
broadcastSvc.SetSenderProfileSource(client.NewGRPCUserProfileSource(userConn))
|
||||||
luckyGiftSvc := luckygiftservice.New(repository,
|
luckyGiftSvc := luckygiftservice.New(repository,
|
||||||
|
|||||||
@ -87,6 +87,7 @@ type TencentIMConfig struct {
|
|||||||
AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"`
|
AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"`
|
||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
GroupType string `yaml:"group_type"`
|
GroupType string `yaml:"group_type"`
|
||||||
|
GroupIDPrefix string `yaml:"group_id_prefix"`
|
||||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,6 +273,7 @@ func Load(path string) (Config, error) {
|
|||||||
if cfg.TencentIM.GroupType == "" {
|
if cfg.TencentIM.GroupType == "" {
|
||||||
cfg.TencentIM.GroupType = tencentim.DefaultGroupType
|
cfg.TencentIM.GroupType = tencentim.DefaultGroupType
|
||||||
}
|
}
|
||||||
|
cfg.TencentIM.GroupIDPrefix = strings.TrimSpace(cfg.TencentIM.GroupIDPrefix)
|
||||||
if cfg.TencentIM.AdminUserSigTTL <= 0 {
|
if cfg.TencentIM.AdminUserSigTTL <= 0 {
|
||||||
cfg.TencentIM.AdminUserSigTTL = 24 * time.Hour
|
cfg.TencentIM.AdminUserSigTTL = 24 * time.Hour
|
||||||
}
|
}
|
||||||
|
|||||||
@ -72,6 +72,7 @@ type Config struct {
|
|||||||
WorkerMaxRetry int
|
WorkerMaxRetry int
|
||||||
WorkerPollInterval time.Duration
|
WorkerPollInterval time.Duration
|
||||||
EnsureGroupsOnStartup bool
|
EnsureGroupsOnStartup bool
|
||||||
|
GroupIDPrefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishInput 是服务端入队播报请求。
|
// PublishInput 是服务端入队播报请求。
|
||||||
@ -125,7 +126,7 @@ func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
|
|||||||
}
|
}
|
||||||
app := appcode.FromContext(ctx)
|
app := appcode.FromContext(ctx)
|
||||||
count := 0
|
count := 0
|
||||||
globalID, err := imgroup.GlobalBroadcastGroupID(app)
|
globalID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, xerr.New(xerr.InvalidArgument, err.Error())
|
return 0, xerr.New(xerr.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
@ -147,7 +148,7 @@ func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
|
|||||||
// GLOBAL/未知区域不对应 IM 区域群,避免客户端加入一个语义不清的“0 区域群”。
|
// GLOBAL/未知区域不对应 IM 区域群,避免客户端加入一个语义不清的“0 区域群”。
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
groupID, err := imgroup.RegionBroadcastGroupID(app, regionID)
|
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app, regionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return count, xerr.New(xerr.InvalidArgument, err.Error())
|
return count, xerr.New(xerr.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
@ -165,7 +166,7 @@ func (s *Service) PublishRegionBroadcast(ctx context.Context, input PublishInput
|
|||||||
if input.RegionID <= 0 {
|
if input.RegionID <= 0 {
|
||||||
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "region_id is required")
|
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "region_id is required")
|
||||||
}
|
}
|
||||||
groupID, err := imgroup.RegionBroadcastGroupID(appcode.FromContext(ctx), input.RegionID)
|
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), input.RegionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
|
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
@ -183,7 +184,7 @@ func (s *Service) PublishRoomBroadcast(ctx context.Context, roomID string, input
|
|||||||
// PublishGlobalBroadcast 把全局播报写入持久化 outbox。
|
// PublishGlobalBroadcast 把全局播报写入持久化 outbox。
|
||||||
// 全局播报和区域播报共用同一张表,scope/group_id 决定最终投递目标。
|
// 全局播报和区域播报共用同一张表,scope/group_id 决定最终投递目标。
|
||||||
func (s *Service) PublishGlobalBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) {
|
func (s *Service) PublishGlobalBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) {
|
||||||
groupID, err := imgroup.GlobalBroadcastGroupID(appcode.FromContext(ctx))
|
groupID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
|
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
@ -203,7 +204,7 @@ func (s *Service) RemoveRegionBroadcastMember(ctx context.Context, userID int64,
|
|||||||
if s == nil || s.publisher == nil {
|
if s == nil || s.publisher == nil {
|
||||||
return "", false, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
|
return "", false, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
|
||||||
}
|
}
|
||||||
groupID, err := imgroup.RegionBroadcastGroupID(appcode.FromContext(ctx), regionID)
|
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), regionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, xerr.New(xerr.InvalidArgument, err.Error())
|
return "", false, xerr.New(xerr.InvalidArgument, err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -254,6 +254,36 @@ func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRegionBroadcastUsesConfiguredGroupPrefix(t *testing.T) {
|
||||||
|
repository := newFakeRepository()
|
||||||
|
publisher := &fakePublisher{}
|
||||||
|
service := New(Config{NodeID: "node-a", GroupIDPrefix: "test_"}, repository, publisher, nil)
|
||||||
|
|
||||||
|
result, err := service.PublishRegionBroadcast(appcode.WithContext(context.Background(), "lalu"), PublishInput{
|
||||||
|
EventID: "evt-prefixed",
|
||||||
|
BroadcastType: broadcastdomain.TypeSuperGift,
|
||||||
|
RegionID: 1001,
|
||||||
|
PayloadJSON: `{"ok":true}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PublishRegionBroadcast failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.GroupID != "test_hy_lalu_bc_r_1001" {
|
||||||
|
t.Fatalf("prefixed publish group mismatch: %+v", result)
|
||||||
|
}
|
||||||
|
if record := repository.records["evt-prefixed"]; record.GroupID != "test_hy_lalu_bc_r_1001" {
|
||||||
|
t.Fatalf("prefixed outbox group mismatch: %+v", record)
|
||||||
|
}
|
||||||
|
|
||||||
|
groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
|
||||||
|
}
|
||||||
|
if !removed || groupID != "test_hy_lalu_bc_r_1001" || publisher.deletedGroupID != "test_hy_lalu_bc_r_1001" {
|
||||||
|
t.Fatalf("prefixed remove mismatch: group_id=%s removed=%t deleted=%s", groupID, removed, publisher.deletedGroupID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
|
func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
body, err := proto.Marshal(gift)
|
body, err := proto.Marshal(gift)
|
||||||
|
|||||||
@ -99,6 +99,7 @@ tencent_im:
|
|||||||
user_sig_ttl: "24h"
|
user_sig_ttl: "24h"
|
||||||
endpoint: "adminapisgp.im.qcloud.com"
|
endpoint: "adminapisgp.im.qcloud.com"
|
||||||
request_timeout: "5s"
|
request_timeout: "5s"
|
||||||
|
group_id_prefix: "test_"
|
||||||
callback_url: ""
|
callback_url: ""
|
||||||
callback_auth_token: ""
|
callback_auth_token: ""
|
||||||
|
|
||||||
|
|||||||
@ -130,6 +130,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
UserSigTTL: cfg.TencentIM.UserSigTTL,
|
UserSigTTL: cfg.TencentIM.UserSigTTL,
|
||||||
AdminIdentifier: cfg.TencentIM.AdminIdentifier,
|
AdminIdentifier: cfg.TencentIM.AdminIdentifier,
|
||||||
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
|
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
|
||||||
|
GroupIDPrefix: cfg.TencentIM.GroupIDPrefix,
|
||||||
}, userProfileClient)
|
}, userProfileClient)
|
||||||
if cfg.TencentIM.SDKAppID > 0 && strings.TrimSpace(cfg.TencentIM.SecretKey) != "" {
|
if cfg.TencentIM.SDKAppID > 0 && strings.TrimSpace(cfg.TencentIM.SecretKey) != "" {
|
||||||
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||||
|
|||||||
@ -166,6 +166,8 @@ type TencentIMConfig struct {
|
|||||||
Endpoint string `yaml:"endpoint"`
|
Endpoint string `yaml:"endpoint"`
|
||||||
// RequestTimeout 是 gateway 调腾讯 IM REST 的请求超时。
|
// RequestTimeout 是 gateway 调腾讯 IM REST 的请求超时。
|
||||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||||
|
// GroupIDPrefix 给测试服等隔离环境使用,正式服为空以保持既有腾讯 IM 群不变。
|
||||||
|
GroupIDPrefix string `yaml:"group_id_prefix"`
|
||||||
// CallbackURL 是在腾讯云 IM 控制台配置的服务端回调地址。
|
// CallbackURL 是在腾讯云 IM 控制台配置的服务端回调地址。
|
||||||
CallbackURL string `yaml:"callback_url"`
|
CallbackURL string `yaml:"callback_url"`
|
||||||
// CallbackAuthToken 是腾讯云 IM 回调鉴权 token,只用于校验回调来源。
|
// CallbackAuthToken 是腾讯云 IM 回调鉴权 token,只用于校验回调来源。
|
||||||
@ -462,6 +464,7 @@ func (cfg *Config) Normalize() error {
|
|||||||
if cfg.TencentIM.RequestTimeout <= 0 {
|
if cfg.TencentIM.RequestTimeout <= 0 {
|
||||||
cfg.TencentIM.RequestTimeout = 5 * time.Second
|
cfg.TencentIM.RequestTimeout = 5 * time.Second
|
||||||
}
|
}
|
||||||
|
cfg.TencentIM.GroupIDPrefix = strings.TrimSpace(cfg.TencentIM.GroupIDPrefix)
|
||||||
cfg.TencentIM.CallbackURL = strings.TrimSpace(cfg.TencentIM.CallbackURL)
|
cfg.TencentIM.CallbackURL = strings.TrimSpace(cfg.TencentIM.CallbackURL)
|
||||||
cfg.TencentIM.CallbackAuthToken = strings.TrimSpace(cfg.TencentIM.CallbackAuthToken)
|
cfg.TencentIM.CallbackAuthToken = strings.TrimSpace(cfg.TencentIM.CallbackAuthToken)
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ type TencentIMConfig struct {
|
|||||||
SDKAppID int64
|
SDKAppID int64
|
||||||
AdminIdentifier string
|
AdminIdentifier string
|
||||||
CallbackAuthToken string
|
CallbackAuthToken string
|
||||||
|
GroupIDPrefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TencentRTCConfig 是腾讯云 RTC 服务端回调校验需要的最小配置。
|
// TencentRTCConfig 是腾讯云 RTC 服务端回调校验需要的最小配置。
|
||||||
|
|||||||
@ -92,7 +92,7 @@ func (h *Handler) handleTencentIMCallback(writer http.ResponseWriter, request *h
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleTencentIMJoinCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
func (h *Handler) handleTencentIMJoinCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
||||||
parsed := imgroup.Parse(body.GroupID)
|
parsed := imgroup.ParseWithPrefix(body.GroupID, h.tencentIM.GroupIDPrefix)
|
||||||
userID, ok := parseTencentIMUserID(body.Requestor)
|
userID, ok := parseTencentIMUserID(body.Requestor)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeTencentCallback(writer, http.StatusOK, 1, "join guard input invalid")
|
writeTencentCallback(writer, http.StatusOK, 1, "join guard input invalid")
|
||||||
@ -149,7 +149,7 @@ func (h *Handler) handleTencentIMBroadcastJoinCallback(writer http.ResponseWrite
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) handleTencentIMSendCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
func (h *Handler) handleTencentIMSendCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
||||||
parsed := imgroup.Parse(body.GroupID)
|
parsed := imgroup.ParseWithPrefix(body.GroupID, h.tencentIM.GroupIDPrefix)
|
||||||
switch parsed.Kind {
|
switch parsed.Kind {
|
||||||
case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast:
|
case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast:
|
||||||
if h.tencentIMCallbackSenderIsServer(body) {
|
if h.tencentIMCallbackSenderIsServer(body) {
|
||||||
|
|||||||
@ -63,6 +63,7 @@ type TencentIMConfig struct {
|
|||||||
UserSigTTL time.Duration
|
UserSigTTL time.Duration
|
||||||
AdminIdentifier string
|
AdminIdentifier string
|
||||||
CallbackAuthToken string
|
CallbackAuthToken string
|
||||||
|
GroupIDPrefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
// TencentIMAccountImporter 是 gateway 在签发 UserSig 前补偿导入账号的最小边界。
|
// TencentIMAccountImporter 是 gateway 在签发 UserSig 前补偿导入账号的最小边界。
|
||||||
@ -333,7 +334,7 @@ func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *htt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId())
|
joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId(), h.tencentIM.GroupIDPrefix)
|
||||||
if !ok {
|
if !ok {
|
||||||
// 生成 join_groups 失败代表服务端 app_code/region_id 配置异常,不能返回缺失群列表让客户端自行猜。
|
// 生成 join_groups 失败代表服务端 app_code/region_id 配置异常,不能返回缺失群列表让客户端自行猜。
|
||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
@ -363,8 +364,12 @@ type tencentIMJoinGroup struct {
|
|||||||
RegionID int64 `json:"region_id,omitempty"`
|
RegionID int64 `json:"region_id,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func imJoinGroupsForUser(app string, regionID int64) ([]tencentIMJoinGroup, bool) {
|
func imJoinGroupsForUser(app string, regionID int64, groupIDPrefix ...string) ([]tencentIMJoinGroup, bool) {
|
||||||
globalGroupID, err := imgroup.GlobalBroadcastGroupID(app)
|
prefix := ""
|
||||||
|
if len(groupIDPrefix) > 0 {
|
||||||
|
prefix = groupIDPrefix[0]
|
||||||
|
}
|
||||||
|
globalGroupID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(prefix, app)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@ -375,7 +380,7 @@ func imJoinGroupsForUser(app string, regionID int64) ([]tencentIMJoinGroup, bool
|
|||||||
}}
|
}}
|
||||||
if regionID > 0 {
|
if regionID > 0 {
|
||||||
// 区域群来自 user-service 当前 region_id,客户端不能自选区域群;区域变更后下一次 UserSig 返回新群。
|
// 区域群来自 user-service 当前 region_id,客户端不能自选区域群;区域变更后下一次 UserSig 返回新群。
|
||||||
regionGroupID, err := imgroup.RegionBroadcastGroupID(app, regionID)
|
regionGroupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(prefix, app, regionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5294,6 +5294,36 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
|
|||||||
t.Fatalf("region join group mismatch: %+v", groups[1])
|
t.Fatalf("region join group mismatch: %+v", groups[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prefixedRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
|
||||||
|
SDKAppID: 1400000000,
|
||||||
|
SecretKey: "secret",
|
||||||
|
UserSigTTL: time.Hour,
|
||||||
|
GroupIDPrefix: "test_",
|
||||||
|
}, profileClient).Routes(auth.NewVerifier("secret"))
|
||||||
|
prefixedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
|
||||||
|
prefixedRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
prefixedRecorder := httptest.NewRecorder()
|
||||||
|
prefixedRouter.ServeHTTP(prefixedRecorder, prefixedRequest)
|
||||||
|
if prefixedRecorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("prefixed status mismatch: got %d body=%s", prefixedRecorder.Code, prefixedRecorder.Body.String())
|
||||||
|
}
|
||||||
|
var prefixedResponse httpkit.ResponseEnvelope
|
||||||
|
if err := json.NewDecoder(prefixedRecorder.Body).Decode(&prefixedResponse); err != nil {
|
||||||
|
t.Fatalf("decode prefixed response failed: %v", err)
|
||||||
|
}
|
||||||
|
prefixedData, ok := prefixedResponse.Data.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("prefixed response data mismatch: %+v", prefixedResponse)
|
||||||
|
}
|
||||||
|
prefixedGroups, ok := prefixedData["join_groups"].([]any)
|
||||||
|
if !ok || len(prefixedGroups) != 2 {
|
||||||
|
t.Fatalf("prefixed join_groups mismatch: %+v", prefixedData["join_groups"])
|
||||||
|
}
|
||||||
|
prefixedRegionGroup, ok := prefixedGroups[1].(map[string]any)
|
||||||
|
if !ok || prefixedRegionGroup["group_id"] != "test_hy_lalu_bc_r_1001" {
|
||||||
|
t.Fatalf("prefixed region join group mismatch: %+v", prefixedGroups[1])
|
||||||
|
}
|
||||||
|
|
||||||
missingConfigRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
|
missingConfigRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
|
||||||
SDKAppID: 1400000000,
|
SDKAppID: 1400000000,
|
||||||
UserSigTTL: time.Hour,
|
UserSigTTL: time.Hour,
|
||||||
@ -5645,6 +5675,27 @@ func TestTencentIMBroadcastCallbacksUseRegionGuardAndServerOnlySend(t *testing.T
|
|||||||
assertTencentCallback(t, adminSendRecorder, http.StatusOK, 0)
|
assertTencentCallback(t, adminSendRecorder, http.StatusOK, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTencentIMBroadcastCallbacksUseConfiguredGroupPrefix(t *testing.T) {
|
||||||
|
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||||
|
router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
|
||||||
|
SDKAppID: 1400000000,
|
||||||
|
AdminIdentifier: "administrator",
|
||||||
|
GroupIDPrefix: "test_",
|
||||||
|
}, profileClient).Routes(auth.NewVerifier("secret"))
|
||||||
|
|
||||||
|
joinBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"test_hy_lalu_bc_r_1001","Requestor_Account":"42"}`)
|
||||||
|
joinRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(joinBody))
|
||||||
|
joinRecorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(joinRecorder, joinRequest)
|
||||||
|
assertTencentCallback(t, joinRecorder, http.StatusOK, 0)
|
||||||
|
|
||||||
|
oldGroupBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1001","Requestor_Account":"42"}`)
|
||||||
|
oldGroupRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(oldGroupBody))
|
||||||
|
oldGroupRecorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(oldGroupRecorder, oldGroupRequest)
|
||||||
|
assertTencentCallback(t, oldGroupRecorder, http.StatusOK, 1)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) {
|
func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) {
|
||||||
router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
|
router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
|
||||||
SDKAppID: 1400000000,
|
SDKAppID: 1400000000,
|
||||||
|
|||||||
@ -45,6 +45,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
UserIdentityClient: h.userIdentityClient,
|
UserIdentityClient: h.userIdentityClient,
|
||||||
UserProfileClient: h.userProfileClient,
|
UserProfileClient: h.userProfileClient,
|
||||||
UserHostClient: h.userHostClient,
|
UserHostClient: h.userHostClient,
|
||||||
|
IMGroupIDPrefix: h.tencentIM.GroupIDPrefix,
|
||||||
})
|
})
|
||||||
userAPI := userapi.New(userapi.Config{
|
userAPI := userapi.New(userapi.Config{
|
||||||
UserIdentityClient: h.userIdentityClient,
|
UserIdentityClient: h.userIdentityClient,
|
||||||
@ -97,6 +98,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
SDKAppID: h.tencentIM.SDKAppID,
|
SDKAppID: h.tencentIM.SDKAppID,
|
||||||
AdminIdentifier: h.tencentIM.AdminIdentifier,
|
AdminIdentifier: h.tencentIM.AdminIdentifier,
|
||||||
CallbackAuthToken: h.tencentIM.CallbackAuthToken,
|
CallbackAuthToken: h.tencentIM.CallbackAuthToken,
|
||||||
|
GroupIDPrefix: h.tencentIM.GroupIDPrefix,
|
||||||
},
|
},
|
||||||
TencentRTC: callbackapi.TencentRTCConfig{
|
TencentRTC: callbackapi.TencentRTCConfig{
|
||||||
SDKAppID: h.tencentRTC.SDKAppID,
|
SDKAppID: h.tencentRTC.SDKAppID,
|
||||||
|
|||||||
@ -16,6 +16,7 @@ type Handler struct {
|
|||||||
userIdentityClient client.UserIdentityClient
|
userIdentityClient client.UserIdentityClient
|
||||||
userProfileClient client.UserProfileClient
|
userProfileClient client.UserProfileClient
|
||||||
userHostClient client.UserHostClient
|
userHostClient client.UserHostClient
|
||||||
|
imGroupIDPrefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@ -25,6 +26,7 @@ type Config struct {
|
|||||||
UserIdentityClient client.UserIdentityClient
|
UserIdentityClient client.UserIdentityClient
|
||||||
UserProfileClient client.UserProfileClient
|
UserProfileClient client.UserProfileClient
|
||||||
UserHostClient client.UserHostClient
|
UserHostClient client.UserHostClient
|
||||||
|
IMGroupIDPrefix string
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(config Config) *Handler {
|
func New(config Config) *Handler {
|
||||||
@ -35,6 +37,7 @@ func New(config Config) *Handler {
|
|||||||
userIdentityClient: config.UserIdentityClient,
|
userIdentityClient: config.UserIdentityClient,
|
||||||
userProfileClient: config.UserProfileClient,
|
userProfileClient: config.UserProfileClient,
|
||||||
userHostClient: config.UserHostClient,
|
userHostClient: config.UserHostClient,
|
||||||
|
imGroupIDPrefix: config.IMGroupIDPrefix,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -192,7 +192,7 @@ func (h *Handler) getCurrentRegionIMGroup(writer http.ResponseWriter, request *h
|
|||||||
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
|
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupID, err := imgroup.RegionBroadcastGroupID(appcode.FromContext(request.Context()), user.GetRegionId())
|
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(h.imGroupIDPrefix, appcode.FromContext(request.Context()), user.GetRegionId())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
return
|
return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user