切换播报群
This commit is contained in:
parent
ec35fcad5e
commit
0a072894ec
@ -272,7 +272,7 @@ sequenceDiagram
|
||||
| `cp_relationship_created` | 区域群 | 关系建立后发送区域播报 |
|
||||
| 无 | 无 | 解除关系成功不发 IM,App 根据解除接口返回刷新 |
|
||||
|
||||
C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A,拒绝为 B;同意会发 `B -> A` 和 `A -> B` 两条。区域 IM 群 ID 使用 `hy_<app_code>_bc_r_<region_id>`,本地测试可通过 `group_id_prefix` 加前缀。
|
||||
C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A,拒绝为 B;同意会发 `B -> A` 和 `A -> B` 两条。区域 IM 群 ID 使用 `hy_<app_code>_bc_r_v2_<region_id>`(v1 旧群 `hy_<app_code>_bc_r_<region_id>` 仅保留过渡期双发),本地测试可通过 `group_id_prefix` 加前缀。
|
||||
|
||||
## 12. 错误码
|
||||
|
||||
|
||||
@ -57,8 +57,15 @@ GroupID 必须稳定、短、可解析,并满足腾讯 IM 自定义 GroupID
|
||||
| Group Type | GroupID | Meaning |
|
||||
| --- | --- | --- |
|
||||
| room | `<room_id>` | 现有房间群,必须满足 `pkg/roomid` 规则 |
|
||||
| global_broadcast | `hy_<app_code>_bc_g` | App 内全局播报群 |
|
||||
| region_broadcast | `hy_<app_code>_bc_r_<region_id>` | App 内指定区域播报群 |
|
||||
| global_broadcast | `hy_<app_code>_bc_g_v2`(v1: `hy_<app_code>_bc_g`) | App 内全局播报群 |
|
||||
| region_broadcast | `hy_<app_code>_bc_r_v2_<region_id>`(v1: `hy_<app_code>_bc_r_<region_id>`) | App 内指定区域播报群 |
|
||||
|
||||
v1 → v2 迁移(2026-07):
|
||||
|
||||
- v1 播报群是 ChatRoom 类型,受套餐成员上限约束(标准版 2000 人),线上已触发 10038 拒绝加群;全局群与区域群先后都迁移到 AVChatRoom 类型的 v2 群(成员无上限、免审批)。
|
||||
- `imgroup.GlobalBroadcastGroupID*` / `RegionBroadcastGroupID*` 只生成 v2 ID;v1 ID 仅能由 `Legacy*` 变体生成,用于过渡期双发和旧群成员移除。
|
||||
- 解析层对 v1/v2 返回同一个 Kind,调用方不感知版本;reconciler 只 ensure v2 群,v1 旧群不再维护。
|
||||
- 过渡期 outbox 投递成功后会向对应 v1 旧群补发一份 `<event_id>_legacy` 副本,覆盖尚未重新拉取 join 列表的存量会话;老客户端全部换代后可移除双发。
|
||||
|
||||
规则:
|
||||
|
||||
@ -123,8 +130,8 @@ sequenceDiagram
|
||||
U-->>G: user.region_id, profile_completed
|
||||
G-->>C: UserSig + join_groups
|
||||
C->>IM: login(user_id, user_sig)
|
||||
C->>IM: joinGroup(hy_<app>_bc_g)
|
||||
C->>IM: joinGroup(hy_<app>_bc_r_<region_id>)
|
||||
C->>IM: joinGroup(hy_<app>_bc_g_v2)
|
||||
C->>IM: joinGroup(hy_<app>_bc_r_v2_<region_id>)
|
||||
```
|
||||
|
||||
`/api/v1/im/usersig` response 增加 `join_groups`,客户端必须按返回列表加入播报群:
|
||||
@ -137,11 +144,11 @@ sequenceDiagram
|
||||
"expire_at_ms": 1770000000000,
|
||||
"join_groups": [
|
||||
{
|
||||
"group_id": "hy_lalu_bc_g",
|
||||
"group_id": "hy_lalu_bc_g_v2",
|
||||
"type": "global_broadcast"
|
||||
},
|
||||
{
|
||||
"group_id": "hy_lalu_bc_r_1001",
|
||||
"group_id": "hy_lalu_bc_r_v2_1001",
|
||||
"type": "region_broadcast",
|
||||
"region_id": 1001
|
||||
}
|
||||
|
||||
@ -410,11 +410,11 @@ Response `data`:
|
||||
"expire_at_ms": 1778086400000,
|
||||
"join_groups": [
|
||||
{
|
||||
"group_id": "hy_lalu_bc_g",
|
||||
"group_id": "hy_lalu_bc_g_v2",
|
||||
"type": "global_broadcast"
|
||||
},
|
||||
{
|
||||
"group_id": "hy_lalu_bc_r_1001",
|
||||
"group_id": "hy_lalu_bc_r_v2_1001",
|
||||
"type": "region_broadcast",
|
||||
"region_id": 1001
|
||||
}
|
||||
|
||||
@ -27,7 +27,11 @@ const (
|
||||
globalSuffix = "_bc_g"
|
||||
// globalSuffixV2 是 v2 全局播报群后缀:AVChatRoom 类型,成员数不受套餐上限约束。
|
||||
globalSuffixV2 = "_bc_g_v2"
|
||||
regionToken = "_bc_r_"
|
||||
// regionToken 是 v1 区域播报群片段:与 v1 全局群同样受 ChatRoom 成员上限约束,
|
||||
// 仅保留解析与过渡期双发能力,新群一律生成 v2。
|
||||
regionToken = "_bc_r_"
|
||||
// regionTokenV2 是 v2 区域播报群片段:AVChatRoom 类型,成员数不受套餐上限约束。
|
||||
regionTokenV2 = "_bc_r_v2_"
|
||||
)
|
||||
|
||||
// Kind 是回调分流和客户端 join 列表使用的稳定分类,不直接暴露腾讯云原始字符串判断。
|
||||
@ -74,14 +78,24 @@ func globalBroadcastGroupIDWithSuffix(prefix string, value string, suffix string
|
||||
return groupID, nil
|
||||
}
|
||||
|
||||
// RegionBroadcastGroupID 生成区域播报群 ID。
|
||||
// RegionBroadcastGroupID 生成区域播报群 ID(v2,AVChatRoom)。
|
||||
// region_id <= 0 被拒绝,GLOBAL/未知区域不能变成一个可 join 的区域群。
|
||||
func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
|
||||
return RegionBroadcastGroupIDWithPrefix("", value, regionID)
|
||||
}
|
||||
|
||||
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 ID。
|
||||
// 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
|
||||
@ -93,7 +107,7 @@ func RegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int6
|
||||
if regionID <= 0 {
|
||||
return "", fmt.Errorf("region_id must be positive")
|
||||
}
|
||||
groupID := prefix + "hy_" + app + regionToken + strconv.FormatInt(regionID, 10)
|
||||
groupID := prefix + "hy_" + app + token + strconv.FormatInt(regionID, 10)
|
||||
if len(groupID) > roomid.MaxStringIDLength {
|
||||
return "", fmt.Errorf("region broadcast group_id is too long")
|
||||
}
|
||||
@ -200,21 +214,33 @@ func parseBroadcastGroupID(groupID string) ParsedGroup {
|
||||
|
||||
if after, ok := strings.CutPrefix(groupID, "hy_"); ok {
|
||||
body := after
|
||||
tokenIndex := strings.LastIndex(body, regionToken)
|
||||
if tokenIndex >= 0 {
|
||||
app := body[:tokenIndex]
|
||||
rawRegion := body[tokenIndex+len(regionToken):]
|
||||
regionID, err := strconv.ParseInt(rawRegion, 10, 64)
|
||||
if err == nil && regionID > 0 && validateAppCodeForGroupID(app) == nil {
|
||||
return ParsedGroup{Kind: KindRegionBroadcast, AppCode: app, RegionID: regionID}
|
||||
}
|
||||
return ParsedGroup{Kind: KindUnknown}
|
||||
// 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
|
||||
|
||||
@ -32,13 +32,26 @@ func TestBroadcastGroupIDGenerateAndParse(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("RegionBroadcastGroupID failed: %v", err)
|
||||
}
|
||||
if region != "hy_lalu_bc_r_1001" {
|
||||
if region != "hy_lalu_bc_r_v2_1001" {
|
||||
t.Fatalf("region group mismatch: %s", region)
|
||||
}
|
||||
parsedRegion := Parse(region)
|
||||
if parsedRegion.Kind != KindRegionBroadcast || parsedRegion.AppCode != "lalu" || parsedRegion.RegionID != 1001 {
|
||||
t.Fatalf("region parse mismatch: %+v", parsedRegion)
|
||||
}
|
||||
|
||||
// v1 旧区域群在过渡期仍要能解析成同一 Kind:双发、切区移除和腾讯回调都依赖这一点。
|
||||
legacyRegion, err := LegacyRegionBroadcastGroupIDWithPrefix("", "lalu", 1001)
|
||||
if err != nil {
|
||||
t.Fatalf("LegacyRegionBroadcastGroupIDWithPrefix failed: %v", err)
|
||||
}
|
||||
if legacyRegion != "hy_lalu_bc_r_1001" {
|
||||
t.Fatalf("legacy region group mismatch: %s", legacyRegion)
|
||||
}
|
||||
parsedLegacyRegion := Parse(legacyRegion)
|
||||
if parsedLegacyRegion.Kind != KindRegionBroadcast || parsedLegacyRegion.AppCode != "lalu" || parsedLegacyRegion.RegionID != 1001 {
|
||||
t.Fatalf("legacy region parse mismatch: %+v", parsedLegacyRegion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) {
|
||||
@ -48,9 +61,15 @@ func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) {
|
||||
if parsed := Parse("hy_lalu_bc_r_0"); parsed.Kind != KindUnknown {
|
||||
t.Fatalf("zero region must be rejected: %+v", parsed)
|
||||
}
|
||||
if parsed := Parse("hy_lalu_bc_r_v2_0"); parsed.Kind != KindUnknown {
|
||||
t.Fatalf("zero v2 region must be rejected: %+v", parsed)
|
||||
}
|
||||
if _, err := RegionBroadcastGroupID("lalu", 0); err == nil {
|
||||
t.Fatal("zero region must not generate a region broadcast group")
|
||||
}
|
||||
if _, err := LegacyRegionBroadcastGroupIDWithPrefix("", "lalu", 0); err == nil {
|
||||
t.Fatal("zero region must not generate a legacy region broadcast group")
|
||||
}
|
||||
if _, err := GlobalBroadcastGroupID("bad app"); err == nil {
|
||||
t.Fatal("app_code with spaces must be rejected")
|
||||
}
|
||||
@ -83,9 +102,19 @@ func TestBroadcastGroupIDWithPrefix(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("RegionBroadcastGroupIDWithPrefix failed: %v", err)
|
||||
}
|
||||
if region != "test_hy_lalu_bc_r_1001" {
|
||||
if region != "test_hy_lalu_bc_r_v2_1001" {
|
||||
t.Fatalf("prefixed region group mismatch: %s", region)
|
||||
}
|
||||
legacyRegion, err := LegacyRegionBroadcastGroupIDWithPrefix("test_", "lalu", 1001)
|
||||
if err != nil {
|
||||
t.Fatalf("LegacyRegionBroadcastGroupIDWithPrefix failed: %v", err)
|
||||
}
|
||||
if legacyRegion != "test_hy_lalu_bc_r_1001" {
|
||||
t.Fatalf("prefixed legacy region group mismatch: %s", legacyRegion)
|
||||
}
|
||||
if parsedLegacy := ParseWithPrefix(legacyRegion, "test_"); parsedLegacy.Kind != KindRegionBroadcast || parsedLegacy.RegionID != 1001 {
|
||||
t.Fatalf("prefixed legacy region parse mismatch: %+v", parsedLegacy)
|
||||
}
|
||||
|
||||
parsedGlobal := ParseWithPrefix(global, "test_")
|
||||
if parsedGlobal.Kind != KindGlobalBroadcast || parsedGlobal.AppCode != "lalu" {
|
||||
|
||||
@ -66,6 +66,7 @@ broadcast:
|
||||
worker_lock_ttl: "30s"
|
||||
worker_max_retry: 8
|
||||
ensure_groups_on_startup: true
|
||||
ensure_groups_interval: "30m"
|
||||
rocketmq:
|
||||
# Docker 测试环境使用 compose RocketMQ 消费 wallet_outbox 红包事实。
|
||||
enabled: true
|
||||
|
||||
@ -66,6 +66,7 @@ broadcast:
|
||||
worker_lock_ttl: "30s"
|
||||
worker_max_retry: 8
|
||||
ensure_groups_on_startup: true
|
||||
ensure_groups_interval: "30m"
|
||||
rocketmq:
|
||||
enabled: true
|
||||
name_servers:
|
||||
|
||||
@ -66,6 +66,7 @@ broadcast:
|
||||
worker_lock_ttl: "30s"
|
||||
worker_max_retry: 8
|
||||
ensure_groups_on_startup: true
|
||||
ensure_groups_interval: "30m"
|
||||
rocketmq:
|
||||
# 本地默认走 RocketMQ fanout,和 Docker/testbox/线上保持同一条 outbox 链路。
|
||||
enabled: true
|
||||
|
||||
@ -93,6 +93,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
|
||||
WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry,
|
||||
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
|
||||
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
|
||||
EnsureGroupsInterval: cfg.Broadcast.EnsureGroupsInterval,
|
||||
GroupIDPrefix: cfg.TencentIM.GroupIDPrefix,
|
||||
}, repository, broadcastPublisher, activityclient.NewGRPCRegionSource(clients.userConn))
|
||||
userProfileSource := activityclient.NewGRPCUserProfileSource(clients.userConn)
|
||||
|
||||
@ -173,6 +173,9 @@ type BroadcastConfig struct {
|
||||
WorkerLockTTL time.Duration `yaml:"worker_lock_ttl"`
|
||||
WorkerMaxRetry int `yaml:"worker_max_retry"`
|
||||
EnsureGroupsOnStartup bool `yaml:"ensure_groups_on_startup"`
|
||||
// EnsureGroupsInterval 是播报群定时 reconcile 周期;0 表示只在启动时补偿一次。
|
||||
// 新区域上线或启动补偿失败时靠它自动补群,不再依赖人工调用 gRPC。
|
||||
EnsureGroupsInterval time.Duration `yaml:"ensure_groups_interval"`
|
||||
}
|
||||
|
||||
// RocketMQConfig 描述 activity-service 消费房间事实的 MQ 连接。
|
||||
@ -298,6 +301,7 @@ func Default() Config {
|
||||
WorkerLockTTL: 30 * time.Second,
|
||||
WorkerMaxRetry: 8,
|
||||
EnsureGroupsOnStartup: true,
|
||||
EnsureGroupsInterval: 30 * time.Minute,
|
||||
},
|
||||
RocketMQ: defaultRocketMQConfig(),
|
||||
MySQLAutoMigrate: false,
|
||||
|
||||
@ -20,7 +20,7 @@ const (
|
||||
TypeRoomRocketReward = "room_rocket_reward_granted"
|
||||
// TypeRoomPasswordChanged 是锁房状态变化的区域通知,客户端用它刷新房间锁状态入口。
|
||||
TypeRoomPasswordChanged = "room_password_changed"
|
||||
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前只让 5 倍及以上大奖进入播报 outbox。
|
||||
// TypeLuckyGiftBigWin 是幸运礼物中奖区域飘屏;当前只让 10 倍及以上大奖进入播报 outbox。
|
||||
TypeLuckyGiftBigWin = "lucky_gift_big_win"
|
||||
|
||||
// StatusPending 表示消息已持久化,尚未被 worker claim。
|
||||
|
||||
@ -5,7 +5,9 @@ package broadcast
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -13,6 +15,7 @@ import (
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/imgroup"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/pkg/xerr"
|
||||
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
|
||||
@ -73,7 +76,10 @@ type Config struct {
|
||||
WorkerMaxRetry int
|
||||
WorkerPollInterval time.Duration
|
||||
EnsureGroupsOnStartup bool
|
||||
GroupIDPrefix string
|
||||
// EnsureGroupsInterval 是常驻 worker 内的定时建群 reconcile 周期;<=0 表示只在启动时补偿一次。
|
||||
// 新区域上线、启动时腾讯 REST 抖动都靠这个周期兜底,不再依赖人工调用 gRPC 补群。
|
||||
EnsureGroupsInterval time.Duration
|
||||
GroupIDPrefix string
|
||||
}
|
||||
|
||||
// PublishInput 是服务端入队播报请求。
|
||||
@ -121,28 +127,32 @@ func (s *Service) SetSenderProfileSource(source SenderProfileSource) {
|
||||
|
||||
// EnsureBroadcastGroups 幂等创建全局播报群和所有 active 区域播报群。
|
||||
// 这个动作放在启动/定时 reconciler,不放在 /im/usersig 路径,避免用户登录被外部腾讯 REST 拖慢。
|
||||
// 单个群创建失败不中断其余群的补偿:全局群配额问题不应让所有区域群一起缺失,错误聚合后返回。
|
||||
func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
|
||||
if s == nil || s.publisher == nil {
|
||||
return 0, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
|
||||
}
|
||||
app := appcode.FromContext(ctx)
|
||||
count := 0
|
||||
var errs []error
|
||||
globalID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app)
|
||||
if err != nil {
|
||||
return 0, xerr.New(xerr.InvalidArgument, err.Error())
|
||||
}
|
||||
if err := s.publisher.EnsureGroup(ctx, s.broadcastGroupSpec(globalID)); err != nil {
|
||||
return count, err
|
||||
errs = append(errs, fmt.Errorf("ensure global group %s: %w", globalID, err))
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
count++
|
||||
|
||||
if s.regionSource == nil {
|
||||
// 没有区域源时至少保证全局群存在;区域群等待下次有 user-service 依赖的 reconciler 补齐。
|
||||
return count, nil
|
||||
return count, errors.Join(errs...)
|
||||
}
|
||||
regionIDs, err := s.regionSource.ListActiveRegionIDs(ctx)
|
||||
if err != nil {
|
||||
return count, err
|
||||
errs = append(errs, fmt.Errorf("list active regions: %w", err))
|
||||
return count, errors.Join(errs...)
|
||||
}
|
||||
for _, regionID := range regionIDs {
|
||||
if regionID <= 0 {
|
||||
@ -151,14 +161,16 @@ func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
|
||||
}
|
||||
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app, regionID)
|
||||
if err != nil {
|
||||
return count, xerr.New(xerr.InvalidArgument, err.Error())
|
||||
errs = append(errs, fmt.Errorf("region %d group_id: %w", regionID, err))
|
||||
continue
|
||||
}
|
||||
if err := s.publisher.EnsureGroup(ctx, s.broadcastGroupSpec(groupID)); err != nil {
|
||||
return count, err
|
||||
errs = append(errs, fmt.Errorf("ensure region group %s: %w", groupID, err))
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
return count, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// PublishRegionBroadcast 把区域播报写入持久化 outbox。
|
||||
@ -212,6 +224,11 @@ func (s *Service) RemoveRegionBroadcastMember(ctx context.Context, userID int64,
|
||||
if err := s.publisher.DeleteGroupMember(ctx, groupID, userID); err != nil {
|
||||
return groupID, false, err
|
||||
}
|
||||
// 过渡期老客户端可能仍在 v1 旧区域群里,切区时一并尽力移除;
|
||||
// 用户不在旧群或旧群不存在都会报错,不能因此阻断已成功的 v2 移除主流程。
|
||||
if legacyID, legacyErr := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), regionID); legacyErr == nil && legacyID != groupID {
|
||||
_ = s.publisher.DeleteGroupMember(ctx, legacyID, userID)
|
||||
}
|
||||
return groupID, true, nil
|
||||
}
|
||||
|
||||
@ -286,6 +303,16 @@ func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOp
|
||||
})
|
||||
if err != nil {
|
||||
result.FailureCount++
|
||||
// 单条投递失败必须落日志:区域群缺失/满员这类腾讯侧错误此前只写 last_error 列,
|
||||
// 重试烧完后记录无声沉底为 failed,线上排障只能翻表。attempt 达到上限时标记 final 便于告警。
|
||||
logx.Error(ctx, "broadcast_outbox_deliver_failed", err,
|
||||
slog.String("event_id", record.EventID),
|
||||
slog.String("group_id", record.GroupID),
|
||||
slog.String("scope", record.Scope),
|
||||
slog.String("broadcast_type", record.BroadcastType),
|
||||
slog.Int("attempt_count", record.AttemptCount+1),
|
||||
slog.Int("max_retry", options.MaxRetry),
|
||||
slog.Bool("final", record.AttemptCount+1 >= options.MaxRetry))
|
||||
// Mark 失败只记录为 best-effort:当前记录锁会过期,下一轮 worker 仍可重新接管。
|
||||
_ = s.repository.MarkBroadcastOutboxFailed(ctx, record.EventID, options.WorkerID, s.now().UTC().UnixMilli(), s.now().UTC().Add(options.PollInterval).UnixMilli(), options.MaxRetry, trimError(err.Error()))
|
||||
continue
|
||||
@ -294,21 +321,28 @@ func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOp
|
||||
return result, err
|
||||
}
|
||||
result.SuccessCount++
|
||||
s.dualSendLegacyGlobal(ctx, record)
|
||||
s.dualSendLegacyBroadcast(ctx, record)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// dualSendLegacyGlobal 在全局播报群从 v1(ChatRoom) 切到 v2(AVChatRoom) 的过渡期内,
|
||||
// 把已成功投递到 v2 群的全局播报补发一份到 v1 旧群:老客户端在重新拉取 join 列表前
|
||||
// dualSendLegacyBroadcast 在播报群从 v1(ChatRoom) 切到 v2(AVChatRoom) 的过渡期内,
|
||||
// 把已成功投递到 v2 群的全局/区域播报补发一份到 v1 旧群:老客户端在重新拉取 join 列表前
|
||||
// 仍停留在旧群里。补发尽力而为,失败不影响主投递的成功状态。
|
||||
// 单个用户不会重复收到:新登录只加入 v2 群,存量会话只在 v1 群。
|
||||
func (s *Service) dualSendLegacyGlobal(ctx context.Context, record broadcastdomain.OutboxRecord) {
|
||||
func (s *Service) dualSendLegacyBroadcast(ctx context.Context, record broadcastdomain.OutboxRecord) {
|
||||
parsed := imgroup.ParseWithPrefix(record.GroupID, s.cfg.GroupIDPrefix)
|
||||
if parsed.Kind != imgroup.KindGlobalBroadcast {
|
||||
var legacyID string
|
||||
var err error
|
||||
switch parsed.Kind {
|
||||
case imgroup.KindGlobalBroadcast:
|
||||
legacyID, err = imgroup.LegacyGlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode)
|
||||
case imgroup.KindRegionBroadcast:
|
||||
legacyID, err = imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID)
|
||||
default:
|
||||
return
|
||||
}
|
||||
legacyID, err := imgroup.LegacyGlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode)
|
||||
// legacyID == record.GroupID 说明这条记录本身就是迁移前入队的 v1 群消息,无需再补发。
|
||||
if err != nil || legacyID == record.GroupID {
|
||||
return
|
||||
}
|
||||
@ -605,17 +639,18 @@ func normalizeWorkerOptions(options WorkerOptions, cfg Config) WorkerOptions {
|
||||
}
|
||||
|
||||
func (s *Service) broadcastGroupSpec(groupID string) tencentim.GroupSpec {
|
||||
if imgroup.ParseWithPrefix(groupID, s.cfg.GroupIDPrefix).Kind == imgroup.KindGlobalBroadcast {
|
||||
// 全局播报群使用 AVChatRoom:ChatRoom 的套餐成员上限(标准版 2000 人)曾把新设备
|
||||
// 挡在群外(10038),AVChatRoom 成员无上限、免审批,也不再需要定时清理成员。
|
||||
switch imgroup.ParseWithPrefix(groupID, s.cfg.GroupIDPrefix).Kind {
|
||||
case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast:
|
||||
// 播报群统一使用 AVChatRoom:ChatRoom 的套餐成员上限(标准版 2000 人)曾把新设备
|
||||
// 挡在群外(10038),全局群先踩坑迁移,区域群成员同样只进不出,是同一失败模式。
|
||||
// AVChatRoom 成员无上限、免审批,也不再需要定时清理成员。
|
||||
// reconciler 只生成 v2 群 ID,v1 旧群不再 ensure,过渡期由双发覆盖存量会话。
|
||||
return tencentim.GroupSpec{
|
||||
GroupID: groupID,
|
||||
Name: groupID,
|
||||
Type: tencentim.BroadcastGroupType,
|
||||
}
|
||||
}
|
||||
// 区域播报群暂保持 ChatRoom:AVChatRoom 的并发群数量受套餐配额限制,而区域群数量 =
|
||||
// 品牌数 × 区域数;先由离线成员定时清理兜底,确认配额后再整体迁移。
|
||||
return tencentim.GroupSpec{
|
||||
GroupID: groupID,
|
||||
Name: groupID,
|
||||
|
||||
@ -53,7 +53,7 @@ func TestBroadcastServiceUsesRealMySQLOutbox(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("read real mysql outbox failed: %v", err)
|
||||
}
|
||||
if !exists || record.Status != broadcastdomain.StatusPending || record.GroupID != "hy_lalu_bc_r_210" {
|
||||
if !exists || record.Status != broadcastdomain.StatusPending || record.GroupID != "hy_lalu_bc_r_v2_210" {
|
||||
t.Fatalf("unexpected pending outbox record: exists=%v record=%+v", exists, record)
|
||||
}
|
||||
t.Logf("real mysql pending record: event_id=%s group_id=%s status=%s", record.EventID, record.GroupID, record.Status)
|
||||
@ -68,10 +68,12 @@ func TestBroadcastServiceUsesRealMySQLOutbox(t *testing.T) {
|
||||
if result.ClaimedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 {
|
||||
t.Fatalf("unexpected process result: %+v", result)
|
||||
}
|
||||
if len(publisher.messages) != 1 {
|
||||
t.Fatalf("expected one Tencent message, got %d", len(publisher.messages))
|
||||
// v2 区域群投递成功后过渡期会向 v1 旧群补发一条 _legacy 副本。
|
||||
if len(publisher.messages) != 2 {
|
||||
t.Fatalf("expected v2 message plus legacy copy, got %d", len(publisher.messages))
|
||||
}
|
||||
assertPublishedMessage(t, publisher.messages[0], "hy_lalu_bc_r_210", created.BroadcastEventID)
|
||||
assertPublishedMessage(t, publisher.messages[0], "hy_lalu_bc_r_v2_210", created.BroadcastEventID)
|
||||
assertPublishedMessage(t, publisher.messages[1], "hy_lalu_bc_r_210", created.BroadcastEventID+"_legacy")
|
||||
|
||||
delivered, exists, err := repository.Repository.GetBroadcastOutbox(ctx, created.BroadcastEventID)
|
||||
if err != nil {
|
||||
@ -89,13 +91,18 @@ func TestRedPacketCreatedWalletEventUsesRealMySQLOutbox(t *testing.T) {
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_456_000) })
|
||||
ctx := appcode.WithContext(context.Background(), "lalu")
|
||||
|
||||
// 只有 delayed 且带 room_id/region_id 的红包事实才会产生房间 CREATED 加区域飘屏两条记录。
|
||||
event := broadcastdomain.RedPacketCreatedEvent{
|
||||
AppCode: "lalu",
|
||||
EventID: "wallet-red-packet-created-real",
|
||||
TransactionID: "wallet-tx-red-packet-real",
|
||||
CommandID: "cmd-red-packet-real",
|
||||
PacketID: "rp-real",
|
||||
PacketType: "delayed",
|
||||
RoomID: "room-310",
|
||||
RegionID: 310,
|
||||
PayloadJSON: `{"packet_id":"rp-real","room_id":"room-310","region_id":310}`,
|
||||
OpenAtMS: 1_700_000_755_000,
|
||||
CreatedAtMS: 1_700_000_455_000,
|
||||
}
|
||||
if err := service.ConsumeRedPacketCreatedEvent(ctx, event); err != nil {
|
||||
@ -105,14 +112,21 @@ func TestRedPacketCreatedWalletEventUsesRealMySQLOutbox(t *testing.T) {
|
||||
t.Fatalf("duplicate ConsumeRedPacketCreatedEvent failed: %v", err)
|
||||
}
|
||||
|
||||
record, exists, err := repository.Repository.GetBroadcastOutbox(ctx, event.EventID)
|
||||
roomRecord, exists, err := repository.Repository.GetBroadcastOutbox(ctx, event.EventID)
|
||||
if err != nil {
|
||||
t.Fatalf("read red packet broadcast outbox failed: %v", err)
|
||||
t.Fatalf("read red packet room broadcast outbox failed: %v", err)
|
||||
}
|
||||
if !exists || record.BroadcastType != broadcastdomain.TypeRedPacket || record.GroupID != "hy_lalu_bc_r_310" || record.Status != broadcastdomain.StatusPending {
|
||||
t.Fatalf("unexpected red packet broadcast record: exists=%v record=%+v", exists, record)
|
||||
if !exists || roomRecord.BroadcastType != broadcastdomain.TypeRedPacket || roomRecord.GroupID != "room-310" || roomRecord.Status != broadcastdomain.StatusPending {
|
||||
t.Fatalf("unexpected red packet room broadcast record: exists=%v record=%+v", exists, roomRecord)
|
||||
}
|
||||
|
||||
record, exists, err := repository.Repository.GetBroadcastOutbox(ctx, event.EventID+":region_created")
|
||||
if err != nil {
|
||||
t.Fatalf("read red packet region broadcast outbox failed: %v", err)
|
||||
}
|
||||
if !exists || record.BroadcastType != broadcastdomain.TypeRedPacket || record.GroupID != "hy_lalu_bc_r_v2_310" || record.Status != broadcastdomain.StatusPending {
|
||||
t.Fatalf("unexpected red packet region broadcast record: exists=%v record=%+v", exists, record)
|
||||
}
|
||||
assertJSONField(t, record.PayloadJSON, "packet_id", "rp-real")
|
||||
assertJSONField(t, record.PayloadJSON, "region_id", float64(310))
|
||||
}
|
||||
|
||||
|
||||
@ -43,7 +43,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
|
||||
t.Fatalf("unexpected result: %+v", result)
|
||||
}
|
||||
record := repository.records[result.BroadcastEventID]
|
||||
if record.GroupID != "hy_lalu_bc_r_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
|
||||
if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
|
||||
t.Fatalf("unexpected broadcast record: %+v", record)
|
||||
}
|
||||
var payload map[string]any
|
||||
@ -90,7 +90,7 @@ func TestHandleRoomGiftSentCreatesRegionBroadcastForTaggedGiftBelowThreshold(t *
|
||||
t.Fatalf("tagged gift should create broadcast: %+v", result)
|
||||
}
|
||||
record := repository.records[result.BroadcastEventID]
|
||||
if record.GroupID != "hy_lalu_bc_r_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
|
||||
if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift {
|
||||
t.Fatalf("unexpected tagged gift broadcast record: %+v", record)
|
||||
}
|
||||
}
|
||||
@ -113,7 +113,7 @@ func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) {
|
||||
t.Fatalf("unexpected password broadcast result: %+v", result)
|
||||
}
|
||||
record := repository.records[result.BroadcastEventID]
|
||||
if record.GroupID != "hy_lalu_bc_r_1001" || record.Scope != broadcastdomain.ScopeRegion || record.BroadcastType != broadcastdomain.TypeRoomPasswordChanged {
|
||||
if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.Scope != broadcastdomain.ScopeRegion || record.BroadcastType != broadcastdomain.TypeRoomPasswordChanged {
|
||||
t.Fatalf("unexpected password broadcast record: %+v", record)
|
||||
}
|
||||
var payload map[string]any
|
||||
@ -270,7 +270,7 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
|
||||
if roomCreated.GroupID != "9001" || roomCreated.Scope != broadcastdomain.ScopeRoom || roomCreated.NextRetryAtMS != nowMS {
|
||||
t.Fatalf("room created outbox mismatch: %+v", roomCreated)
|
||||
}
|
||||
if regionCreated.GroupID != "hy_lalu_bc_r_310" || regionCreated.Scope != broadcastdomain.ScopeRegion || regionCreated.NextRetryAtMS != nowMS {
|
||||
if regionCreated.GroupID != "hy_lalu_bc_r_v2_310" || regionCreated.Scope != broadcastdomain.ScopeRegion || regionCreated.NextRetryAtMS != nowMS {
|
||||
t.Fatalf("region created outbox mismatch: %+v", regionCreated)
|
||||
}
|
||||
if openDue.GroupID != "9001" || openDue.Scope != broadcastdomain.ScopeRoom || openDue.NextRetryAtMS != openAtMS {
|
||||
@ -289,9 +289,14 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessPendingBroadcasts before open failed: %v", err)
|
||||
}
|
||||
if result.SuccessCount != 2 || len(publisher.messages) != 2 || containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
|
||||
// v2 区域群投递成功后过渡期会向 v1 旧群补发一条 _legacy 副本,所以消息比 outbox 记录多一条。
|
||||
if result.SuccessCount != 2 || len(publisher.messages) != 3 || containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
|
||||
t.Fatalf("before open only room/region CREATED should be sent: result=%+v messages=%+v", result, publisher.messages)
|
||||
}
|
||||
legacyCopy := findCustomMessageEvent(publisher.messages, event.EventID+":region_created_legacy")
|
||||
if legacyCopy == nil || legacyCopy.GroupID != "hy_lalu_bc_r_310" {
|
||||
t.Fatalf("region broadcast should dual-send to legacy v1 group: %+v", publisher.messages)
|
||||
}
|
||||
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(openAtMS) })
|
||||
result, err = service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
|
||||
@ -311,11 +316,12 @@ func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
|
||||
}
|
||||
if !removed || groupID != "hy_lalu_bc_r_1001" {
|
||||
if !removed || groupID != "hy_lalu_bc_r_v2_1001" {
|
||||
t.Fatalf("unexpected remove result: group_id=%s removed=%t", groupID, removed)
|
||||
}
|
||||
if publisher.deletedGroupID != "hy_lalu_bc_r_1001" || publisher.deletedUserID != 42 {
|
||||
t.Fatalf("unexpected deleted member: group_id=%s user_id=%d", publisher.deletedGroupID, publisher.deletedUserID)
|
||||
// 切区必须同时清 v2 主群和过渡期 v1 旧群的成员关系。
|
||||
if len(publisher.deletedGroupIDs) != 2 || publisher.deletedGroupIDs[0] != "hy_lalu_bc_r_v2_1001" || publisher.deletedGroupIDs[1] != "hy_lalu_bc_r_1001" || publisher.deletedUserID != 42 {
|
||||
t.Fatalf("unexpected deleted member: group_ids=%v user_id=%d", publisher.deletedGroupIDs, publisher.deletedUserID)
|
||||
}
|
||||
|
||||
groupID, removed, err = service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 0)
|
||||
@ -341,10 +347,10 @@ func TestRegionBroadcastUsesConfiguredGroupPrefix(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("PublishRegionBroadcast failed: %v", err)
|
||||
}
|
||||
if result.GroupID != "test_hy_lalu_bc_r_1001" {
|
||||
if result.GroupID != "test_hy_lalu_bc_r_v2_1001" {
|
||||
t.Fatalf("prefixed publish group mismatch: %+v", result)
|
||||
}
|
||||
if record := repository.records["evt-prefixed"]; record.GroupID != "test_hy_lalu_bc_r_1001" {
|
||||
if record := repository.records["evt-prefixed"]; record.GroupID != "test_hy_lalu_bc_r_v2_1001" {
|
||||
t.Fatalf("prefixed outbox group mismatch: %+v", record)
|
||||
}
|
||||
|
||||
@ -352,8 +358,34 @@ func TestRegionBroadcastUsesConfiguredGroupPrefix(t *testing.T) {
|
||||
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)
|
||||
if !removed || groupID != "test_hy_lalu_bc_r_v2_1001" || len(publisher.deletedGroupIDs) != 2 || publisher.deletedGroupIDs[0] != "test_hy_lalu_bc_r_v2_1001" || publisher.deletedGroupIDs[1] != "test_hy_lalu_bc_r_1001" {
|
||||
t.Fatalf("prefixed remove mismatch: group_id=%s removed=%t deleted=%v", groupID, removed, publisher.deletedGroupIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBroadcastGroupsUsesAVChatRoomAndSurvivesGlobalFailure(t *testing.T) {
|
||||
publisher := &fakePublisher{ensureErrors: map[string]error{"hy_lalu_bc_g_v2": errors.New("quota exceeded")}}
|
||||
service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, fakeRegionSource{regionIDs: []int64{310, 320}})
|
||||
|
||||
count, err := service.EnsureBroadcastGroups(appcode.WithContext(context.Background(), "lalu"))
|
||||
if err == nil {
|
||||
t.Fatal("global group failure must be reported")
|
||||
}
|
||||
// 全局群失败不再阻断区域群补偿:两个区域群仍应建成。
|
||||
if count != 2 {
|
||||
t.Fatalf("region groups must still be ensured after global failure: count=%d", count)
|
||||
}
|
||||
if len(publisher.ensuredGroups) != 3 {
|
||||
t.Fatalf("expected global+2 region ensure calls: %+v", publisher.ensuredGroups)
|
||||
}
|
||||
for _, spec := range publisher.ensuredGroups[1:] {
|
||||
// 区域群一律生成 v2 并使用 AVChatRoom(无 ApplyJoinOption),不再受 ChatRoom 成员上限约束。
|
||||
if spec.Type != tencentim.BroadcastGroupType || spec.ApplyJoinOption != "" {
|
||||
t.Fatalf("region group must be AVChatRoom without apply_join_option: %+v", spec)
|
||||
}
|
||||
}
|
||||
if publisher.ensuredGroups[1].GroupID != "hy_lalu_bc_r_v2_310" || publisher.ensuredGroups[2].GroupID != "hy_lalu_bc_r_v2_320" {
|
||||
t.Fatalf("region group ids mismatch: %+v", publisher.ensuredGroups)
|
||||
}
|
||||
}
|
||||
|
||||
@ -408,12 +440,16 @@ func redPacketPayloadData(t *testing.T, payloadJSON string) map[string]any {
|
||||
}
|
||||
|
||||
func containsCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) bool {
|
||||
for _, message := range messages {
|
||||
if message.EventID == eventID {
|
||||
return true
|
||||
return findCustomMessageEvent(messages, eventID) != nil
|
||||
}
|
||||
|
||||
func findCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) *tencentim.CustomGroupMessage {
|
||||
for index := range messages {
|
||||
if messages[index].EventID == eventID {
|
||||
return &messages[index]
|
||||
}
|
||||
}
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeRepository struct {
|
||||
@ -479,13 +515,16 @@ func (r *fakeRepository) MarkBroadcastOutboxFailed(_ context.Context, eventID st
|
||||
}
|
||||
|
||||
type fakePublisher struct {
|
||||
messages []tencentim.CustomGroupMessage
|
||||
deletedGroupID string
|
||||
deletedUserID int64
|
||||
messages []tencentim.CustomGroupMessage
|
||||
ensuredGroups []tencentim.GroupSpec
|
||||
ensureErrors map[string]error
|
||||
deletedGroupIDs []string
|
||||
deletedUserID int64
|
||||
}
|
||||
|
||||
func (p *fakePublisher) EnsureGroup(context.Context, tencentim.GroupSpec) error {
|
||||
return nil
|
||||
func (p *fakePublisher) EnsureGroup(_ context.Context, spec tencentim.GroupSpec) error {
|
||||
p.ensuredGroups = append(p.ensuredGroups, spec)
|
||||
return p.ensureErrors[spec.GroupID]
|
||||
}
|
||||
|
||||
func (p *fakePublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error {
|
||||
@ -494,11 +533,19 @@ func (p *fakePublisher) PublishGroupCustomMessage(_ context.Context, message ten
|
||||
}
|
||||
|
||||
func (p *fakePublisher) DeleteGroupMember(_ context.Context, groupID string, userID int64) error {
|
||||
p.deletedGroupID = groupID
|
||||
p.deletedGroupIDs = append(p.deletedGroupIDs, groupID)
|
||||
p.deletedUserID = userID
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeRegionSource struct {
|
||||
regionIDs []int64
|
||||
}
|
||||
|
||||
func (s fakeRegionSource) ListActiveRegionIDs(context.Context) ([]int64, error) {
|
||||
return s.regionIDs, nil
|
||||
}
|
||||
|
||||
type fakeSenderProfileSource struct {
|
||||
profile SenderProfile
|
||||
profiles map[int64]SenderProfile
|
||||
|
||||
@ -14,14 +14,19 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
|
||||
options = normalizeWorkerOptions(options, s.cfg)
|
||||
if s.cfg.EnsureGroupsOnStartup {
|
||||
// 建群失败不退出进程:下一轮部署/定时任务可继续补偿,已经入队的消息仍保留在 MySQL。
|
||||
if _, err := s.EnsureBroadcastGroups(ctx); err != nil && ctx.Err() == nil {
|
||||
logx.Error(ctx, "broadcast_group_reconcile_failed", err, slog.String("worker_id", options.WorkerID))
|
||||
}
|
||||
s.reconcileBroadcastGroups(ctx, options)
|
||||
}
|
||||
ticker := time.NewTicker(options.PollInterval)
|
||||
defer ticker.Stop()
|
||||
lastReconcile := s.now()
|
||||
|
||||
for {
|
||||
// 定时 reconcile 兜底新区域上线和启动时腾讯 REST 抖动:只跑一次的启动补偿失败后,
|
||||
// 缺失的区域群会让该区域全部播报静默 failed 沉底,不能等下一次部署才补群。
|
||||
if s.cfg.EnsureGroupsInterval > 0 && s.now().Sub(lastReconcile) >= s.cfg.EnsureGroupsInterval {
|
||||
lastReconcile = s.now()
|
||||
s.reconcileBroadcastGroups(ctx, options)
|
||||
}
|
||||
if _, err := s.ProcessPendingBroadcasts(ctx, options); err != nil && ctx.Err() == nil {
|
||||
// 批处理失败只影响当前轮询,outbox 记录没有被删除,下一轮会按锁和重试状态重新处理。
|
||||
logx.Error(ctx, "broadcast_outbox_batch_failed", err, slog.String("worker_id", options.WorkerID))
|
||||
@ -33,3 +38,9 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) reconcileBroadcastGroups(ctx context.Context, options WorkerOptions) {
|
||||
if _, err := s.EnsureBroadcastGroups(ctx); err != nil && ctx.Err() == nil {
|
||||
logx.Error(ctx, "broadcast_group_reconcile_failed", err, slog.String("worker_id", options.WorkerID))
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,7 +67,8 @@ type RegionBroadcaster interface {
|
||||
PublishRegionBroadcast(ctx context.Context, input broadcastservice.PublishInput) (broadcastdomain.PublishResult, error)
|
||||
}
|
||||
|
||||
const luckyGiftRegionBroadcastMinMultiplierPPM = 5_000_000
|
||||
// luckyGiftBroadcastMinMultiplierPPM 是房间飘屏和区域飘屏共用的大奖门槛;10 倍以下的普通返奖两类飘屏都不发。
|
||||
const luckyGiftBroadcastMinMultiplierPPM = 10_000_000
|
||||
|
||||
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。
|
||||
type Service struct {
|
||||
@ -574,6 +575,10 @@ func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDr
|
||||
if s.publisher == nil || payload.EffectiveRewardCoins <= 0 {
|
||||
return nil
|
||||
}
|
||||
// 房间飘屏与区域飘屏同门槛:10 倍以下的普通返奖不再发房间中奖 IM,中奖用户余额仍靠钱包私信同步。
|
||||
if payload.MultiplierPPM < luckyGiftBroadcastMinMultiplierPPM {
|
||||
return nil
|
||||
}
|
||||
body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
@ -736,9 +741,9 @@ type luckyGiftDrawnPayload struct {
|
||||
}
|
||||
|
||||
func shouldPublishLuckyGiftRegionBroadcast(payload luckyGiftDrawnPayload) bool {
|
||||
// 区域飘屏只承担大奖氛围,不再把 1x/2x/3x 的普通返奖广播到整区,避免高频低价值 IM 干扰用户。
|
||||
// 区域飘屏只承担大奖氛围,10 倍以下的普通返奖不广播到整区,避免高频低价值 IM 干扰用户。
|
||||
// 跨房展示没有当前房间成员资料可兜底,缺 sender_name 时宁可跳过,也不能再让 Flutter 退回 `User {id}`。
|
||||
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= luckyGiftRegionBroadcastMinMultiplierPPM && strings.TrimSpace(payload.SenderName) != ""
|
||||
return payload.VisibleRegionID > 0 && payload.EffectiveRewardCoins > 0 && payload.MultiplierPPM >= luckyGiftBroadcastMinMultiplierPPM && strings.TrimSpace(payload.SenderName) != ""
|
||||
}
|
||||
|
||||
func luckyGiftRoomMessagePayload(payload luckyGiftDrawnPayload) map[string]any {
|
||||
|
||||
@ -168,7 +168,7 @@ func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing.T) {
|
||||
func TestProcessPendingDrawOutboxCreditsWalletAndSkipsRoomDisplayBelowTenTimes(t *testing.T) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event_type": "lucky_gift_drawn",
|
||||
"event_id": "lucky_gift_drawn:lucky_draw_test",
|
||||
@ -215,7 +215,9 @@ func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing
|
||||
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 || wallet.last.GetCountryId() != 15 {
|
||||
t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
|
||||
}
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_test", "lucky_draw_test", 300)
|
||||
if publisher.last.EventID != "" {
|
||||
t.Fatalf("3x lucky gift must not publish room display: %+v", publisher.last)
|
||||
}
|
||||
if broadcaster.last.EventID != "" {
|
||||
t.Fatalf("3x lucky gift must not enter region broadcast: %+v", broadcaster.last)
|
||||
}
|
||||
@ -225,13 +227,13 @@ func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomDisplay(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProfile(t *testing.T) {
|
||||
func TestProcessPendingDrawOutboxPublishesTenTimesRegionBroadcastWithSenderProfile(t *testing.T) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event_type": "lucky_gift_drawn",
|
||||
"event_id": "lucky_gift_drawn:lucky_draw_5x",
|
||||
"event_id": "lucky_gift_drawn:lucky_draw_10x",
|
||||
"app_code": "lalu",
|
||||
"draw_id": "lucky_draw_5x",
|
||||
"command_id": "cmd-gift-5x",
|
||||
"draw_id": "lucky_draw_10x",
|
||||
"command_id": "cmd-gift-10x",
|
||||
"pool_id": "super_lucky",
|
||||
"user_id": 42,
|
||||
"sender_user_id": 42,
|
||||
@ -242,14 +244,14 @@ func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProf
|
||||
"gift_id": "rose",
|
||||
"gift_count": 1,
|
||||
"coin_spent": 100,
|
||||
"effective_reward_coins": 500,
|
||||
"multiplier_ppm": luckyGiftRegionBroadcastMinMultiplierPPM,
|
||||
"effective_reward_coins": 1000,
|
||||
"multiplier_ppm": luckyGiftBroadcastMinMultiplierPPM,
|
||||
"created_at_ms": 1760000000000,
|
||||
})
|
||||
repository := &fakeLuckyGiftRepository{
|
||||
outbox: []domain.DrawOutbox{{
|
||||
AppCode: "lalu",
|
||||
OutboxID: "lucky_lucky_draw_5x",
|
||||
OutboxID: "lucky_lucky_draw_10x",
|
||||
EventType: "LuckyGiftDrawn",
|
||||
PayloadJSON: string(payload),
|
||||
}},
|
||||
@ -276,7 +278,7 @@ func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProf
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed count mismatch: %d", processed)
|
||||
}
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_5x", "lucky_draw_5x", 500)
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_10x", "lucky_draw_10x", 1000)
|
||||
var roomPayload map[string]any
|
||||
if err := json.Unmarshal(publisher.last.PayloadJSON, &roomPayload); err != nil {
|
||||
t.Fatalf("room payload is invalid: %v", err)
|
||||
@ -284,8 +286,8 @@ func TestProcessPendingDrawOutboxPublishesFiveTimesRegionBroadcastWithSenderProf
|
||||
if roomPayload["sender_name"] != "Real Sender" || roomPayload["sender_display_user_id"] != "160042" {
|
||||
t.Fatalf("room payload must include sender display snapshot: %+v", roomPayload)
|
||||
}
|
||||
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_5x" || broadcaster.last.RegionID != 210 {
|
||||
t.Fatalf("5x lucky gift region broadcast mismatch: %+v", broadcaster.last)
|
||||
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_10x" || broadcaster.last.RegionID != 210 {
|
||||
t.Fatalf("10x lucky gift region broadcast mismatch: %+v", broadcaster.last)
|
||||
}
|
||||
var regionPayload map[string]any
|
||||
if err := json.Unmarshal([]byte(broadcaster.last.PayloadJSON), ®ionPayload); err != nil {
|
||||
@ -321,8 +323,8 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
|
||||
"gift_count": 3,
|
||||
"visible_region_id": 210,
|
||||
"coin_spent": 300,
|
||||
"effective_reward_coins": 800,
|
||||
"multiplier_ppm": 8000000,
|
||||
"effective_reward_coins": 3600,
|
||||
"multiplier_ppm": 12000000,
|
||||
"created_at_ms": 1760000000000,
|
||||
})
|
||||
repository := &fakeLuckyGiftRepository{
|
||||
@ -355,14 +357,14 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed count mismatch: %d", processed)
|
||||
}
|
||||
if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetCountryId() != 15 || wallet.last.GetVisibleRegionId() != 210 {
|
||||
if wallet.last == nil || wallet.last.GetAmount() != 3600 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetCountryId() != 15 || wallet.last.GetVisibleRegionId() != 210 {
|
||||
t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last)
|
||||
}
|
||||
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {
|
||||
t.Fatalf("all sub draws should be granted together, got %s", got)
|
||||
}
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 800, 0)
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_batch_1", "lucky_draw_batch_1", 800)
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 3600, 0)
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_batch_1", "lucky_draw_batch_1", 3600)
|
||||
if broadcaster.last.EventID != "lucky_gift_big_win:lucky_draw_batch_1" || broadcaster.last.RegionID != 210 {
|
||||
t.Fatalf("batch big win region broadcast mismatch: %+v", broadcaster.last)
|
||||
}
|
||||
@ -382,8 +384,8 @@ func TestProcessRewardSettlementOutboxPublishesRoomDisplayAfterWalletSettlement(
|
||||
"gift_id": "rose",
|
||||
"gift_count": 1,
|
||||
"coin_spent": 100,
|
||||
"effective_reward_coins": 300,
|
||||
"multiplier_ppm": 3000000,
|
||||
"effective_reward_coins": 1200,
|
||||
"multiplier_ppm": 12000000,
|
||||
"created_at_ms": 1760000000000,
|
||||
})
|
||||
repository := &fakeLuckyGiftRepository{
|
||||
@ -410,11 +412,11 @@ func TestProcessRewardSettlementOutboxPublishesRoomDisplayAfterWalletSettlement(
|
||||
if wallet.last == nil || repository.grantedDrawID != "lucky_draw_settle" {
|
||||
t.Fatalf("settlement should credit wallet and mark draw granted: wallet=%+v granted=%s", wallet.last, repository.grantedDrawID)
|
||||
}
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 300)
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_settle", "lucky_draw_settle", 1200)
|
||||
if broadcaster.last.EventID != "" {
|
||||
t.Fatalf("3x settlement lucky gift must not enter region broadcast: %+v", broadcaster.last)
|
||||
t.Fatalf("settlement without sender profile must not enter region broadcast: %+v", broadcaster.last)
|
||||
}
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 1200, 0)
|
||||
}
|
||||
|
||||
func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
|
||||
@ -442,8 +444,8 @@ func TestPublishLuckyGiftDrawnOmitsBatchDrawIDsFromRoomIM(t *testing.T) {
|
||||
CoinSpent: 99900,
|
||||
RuleVersion: 2,
|
||||
ExperiencePool: "advanced",
|
||||
SelectedTierID: "advanced_1x",
|
||||
MultiplierPPM: 1000000,
|
||||
SelectedTierID: "batch",
|
||||
MultiplierPPM: 12000000,
|
||||
BaseRewardCoins: 73983,
|
||||
RoomAtmosphereRewardCoins: 0,
|
||||
ActivitySubsidyCoins: 0,
|
||||
@ -482,8 +484,8 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisherFailure(t *testing.T) {
|
||||
"gift_id": "rose",
|
||||
"gift_count": 1,
|
||||
"coin_spent": 100,
|
||||
"effective_reward_coins": 300,
|
||||
"multiplier_ppm": 3000000,
|
||||
"effective_reward_coins": 1200,
|
||||
"multiplier_ppm": 12000000,
|
||||
"created_at_ms": 1760000000000,
|
||||
})
|
||||
repository := &fakeLuckyGiftRepository{
|
||||
@ -516,7 +518,7 @@ func TestProcessPendingDrawOutboxIgnoresRoomIMPublisherFailure(t *testing.T) {
|
||||
if publisher.last.EventID != "lucky_gift_drawn:lucky_draw_im_fail" {
|
||||
t.Fatalf("room IM should still be attempted before ignoring failure: %+v", publisher.last)
|
||||
}
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 300, 0)
|
||||
assertLuckyGiftWalletNotice(t, userPublisher.last, "42", "wallet_tx_lucky", 1200, 0)
|
||||
}
|
||||
|
||||
func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T) {
|
||||
@ -532,8 +534,8 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
|
||||
"gift_id": "rose",
|
||||
"gift_count": 1,
|
||||
"coin_spent": 100,
|
||||
"effective_reward_coins": 300,
|
||||
"multiplier_ppm": 3_000_000,
|
||||
"effective_reward_coins": 1200,
|
||||
"multiplier_ppm": 12_000_000,
|
||||
"created_at_ms": 1760000000000,
|
||||
})
|
||||
repository := &fakeLuckyGiftRepository{
|
||||
@ -566,7 +568,7 @@ func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T)
|
||||
if userPublisher.last.EventID != "" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
|
||||
t.Fatalf("already granted draw should not resend wallet notice and must deliver outbox: user_publisher=%+v delivered=%s", userPublisher.last, repository.deliveredOutboxID)
|
||||
}
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_granted", "lucky_draw_granted", 300)
|
||||
assertLuckyGiftRoomMessage(t, publisher.last, "room-1", "lucky_gift_drawn:lucky_draw_granted", "lucky_draw_granted", 1200)
|
||||
}
|
||||
|
||||
type fakeLuckyGiftRepository struct {
|
||||
|
||||
@ -10394,7 +10394,7 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
|
||||
t.Fatalf("join_groups mismatch: %+v", data["join_groups"])
|
||||
}
|
||||
regionGroup, ok := groups[1].(map[string]any)
|
||||
if !ok || regionGroup["group_id"] != "hy_lalu_bc_r_1001" || regionGroup["type"] != "region_broadcast" {
|
||||
if !ok || regionGroup["group_id"] != "hy_lalu_bc_r_v2_1001" || regionGroup["type"] != "region_broadcast" {
|
||||
t.Fatalf("region join group mismatch: %+v", groups[1])
|
||||
}
|
||||
|
||||
@ -10424,7 +10424,7 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
|
||||
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" {
|
||||
if !ok || prefixedRegionGroup["group_id"] != "test_hy_lalu_bc_r_v2_1001" {
|
||||
t.Fatalf("prefixed region join group mismatch: %+v", prefixedGroups[1])
|
||||
}
|
||||
|
||||
|
||||
@ -120,12 +120,7 @@ func (s *Service) publishDelivery(ctx context.Context, delivery CPDelivery, opti
|
||||
})
|
||||
case channelTencentIMRegion:
|
||||
// 区域群是长期播报群;发送前幂等创建,避免本地真实测试或新区域缺群导致关系主流程事实无法通知。
|
||||
if ensureErr := s.publisher.EnsureGroup(publishCtx, tencentim.GroupSpec{
|
||||
GroupID: delivery.GroupID,
|
||||
Name: delivery.GroupID,
|
||||
Type: tencentim.DefaultGroupType,
|
||||
ApplyJoinOption: "FreeAccess",
|
||||
}); ensureErr != nil {
|
||||
if ensureErr := s.publisher.EnsureGroup(publishCtx, s.regionGroupSpec(delivery.GroupID)); ensureErr != nil {
|
||||
err = ensureErr
|
||||
break
|
||||
}
|
||||
@ -136,6 +131,9 @@ func (s *Service) publishDelivery(ctx context.Context, delivery CPDelivery, opti
|
||||
Ext: "cp_region_broadcast",
|
||||
PayloadJSON: delivery.PayloadJSON,
|
||||
})
|
||||
if err == nil {
|
||||
s.dualSendLegacyRegion(publishCtx, delivery)
|
||||
}
|
||||
default:
|
||||
err = fmt.Errorf("unsupported cp notice channel %q", delivery.Channel)
|
||||
}
|
||||
@ -157,6 +155,47 @@ func (s *Service) publishDelivery(ctx context.Context, delivery CPDelivery, opti
|
||||
return s.markFailed(ctx, delivery, options, err)
|
||||
}
|
||||
|
||||
// regionGroupSpec 按群 ID 版本决定建群参数:v2 区域群是 AVChatRoom(ChatRoom 套餐成员上限
|
||||
// 曾在线上触发 10038 拒绝加群),迁移前入队的存量 v1 投递记录仍按 ChatRoom 幂等创建。
|
||||
func (s *Service) regionGroupSpec(groupID string) tencentim.GroupSpec {
|
||||
parsed := imgroup.ParseWithPrefix(groupID, s.cfg.GroupIDPrefix)
|
||||
if parsed.Kind == imgroup.KindRegionBroadcast {
|
||||
if legacyID, err := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID); err == nil && legacyID != groupID {
|
||||
return tencentim.GroupSpec{
|
||||
GroupID: groupID,
|
||||
Name: groupID,
|
||||
Type: tencentim.BroadcastGroupType,
|
||||
}
|
||||
}
|
||||
}
|
||||
return tencentim.GroupSpec{
|
||||
GroupID: groupID,
|
||||
Name: groupID,
|
||||
Type: tencentim.DefaultGroupType,
|
||||
ApplyJoinOption: "FreeAccess",
|
||||
}
|
||||
}
|
||||
|
||||
// dualSendLegacyRegion 在区域播报群 v1→v2 过渡期内,把已成功投递到 v2 群的 CP 播报补发到 v1 旧群,
|
||||
// 覆盖尚未重新拉取 join 列表的老客户端。补发尽力而为,失败不影响主投递状态,与 activity 播报双发同一模式。
|
||||
func (s *Service) dualSendLegacyRegion(ctx context.Context, delivery CPDelivery) {
|
||||
parsed := imgroup.ParseWithPrefix(delivery.GroupID, s.cfg.GroupIDPrefix)
|
||||
if parsed.Kind != imgroup.KindRegionBroadcast {
|
||||
return
|
||||
}
|
||||
legacyID, err := imgroup.LegacyRegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, parsed.AppCode, parsed.RegionID)
|
||||
if err != nil || legacyID == delivery.GroupID {
|
||||
return
|
||||
}
|
||||
_ = s.publisher.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{
|
||||
GroupID: legacyID,
|
||||
EventID: delivery.EventID + "_legacy",
|
||||
Desc: delivery.NoticeType,
|
||||
Ext: "cp_region_broadcast",
|
||||
PayloadJSON: delivery.PayloadJSON,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) markFailed(ctx context.Context, delivery CPDelivery, options CPNoticeWorkerOptions, cause error) error {
|
||||
nowMs := time.Now().UnixMilli()
|
||||
nextRetryCount := delivery.RetryCount + 1
|
||||
|
||||
@ -145,12 +145,20 @@ func TestProcessRelationshipCreatedPublishesRegionIM(t *testing.T) {
|
||||
if err != nil || !handled {
|
||||
t.Fatalf("ProcessUserOutboxMessage failed handled=%v err=%v", handled, err)
|
||||
}
|
||||
if len(publisher.ensuredGroups) != 1 || publisher.ensuredGroups[0].GroupID != "test_hy_lalu_bc_r_86" {
|
||||
if len(publisher.ensuredGroups) != 1 || publisher.ensuredGroups[0].GroupID != "test_hy_lalu_bc_r_v2_86" {
|
||||
t.Fatalf("expected region group ensure, got %+v", publisher.ensuredGroups)
|
||||
}
|
||||
if len(publisher.groupMessages) != 1 || publisher.groupMessages[0].GroupID != "test_hy_lalu_bc_r_86" {
|
||||
// v2 区域群是 AVChatRoom(不带 ApplyJoinOption),不再受 ChatRoom 套餐成员上限约束。
|
||||
if publisher.ensuredGroups[0].Type != tencentim.BroadcastGroupType || publisher.ensuredGroups[0].ApplyJoinOption != "" {
|
||||
t.Fatalf("v2 region group must be AVChatRoom: %+v", publisher.ensuredGroups[0])
|
||||
}
|
||||
// v2 群投递成功后过渡期向 v1 旧群补发一条 _legacy 副本。
|
||||
if len(publisher.groupMessages) != 2 || publisher.groupMessages[0].GroupID != "test_hy_lalu_bc_r_v2_86" {
|
||||
t.Fatalf("expected region group message, got %+v", publisher.groupMessages)
|
||||
}
|
||||
if publisher.groupMessages[1].GroupID != "test_hy_lalu_bc_r_86" || publisher.groupMessages[1].EventID != "cp_relationship:created:1002:1_legacy" {
|
||||
t.Fatalf("expected legacy region copy, got %+v", publisher.groupMessages[1])
|
||||
}
|
||||
if len(publisher.userMessages) != 0 {
|
||||
t.Fatalf("relationship created should not publish c2c: %+v", publisher.userMessages)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user