diff --git a/docs/CP关系服务端开发文档.md b/docs/CP关系服务端开发文档.md index 2270d7b9..4abdad9f 100644 --- a/docs/CP关系服务端开发文档.md +++ b/docs/CP关系服务端开发文档.md @@ -272,7 +272,7 @@ sequenceDiagram | `cp_relationship_created` | 区域群 | 关系建立后发送区域播报 | | 无 | 无 | 解除关系成功不发 IM,App 根据解除接口返回刷新 | -C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A,拒绝为 B;同意会发 `B -> A` 和 `A -> B` 两条。区域 IM 群 ID 使用 `hy__bc_r_`,本地测试可通过 `group_id_prefix` 加前缀。 +C2C 使用腾讯云 IM `From_Account` 表达会话方向:申请为 A,拒绝为 B;同意会发 `B -> A` 和 `A -> B` 两条。区域 IM 群 ID 使用 `hy__bc_r_v2_`(v1 旧群 `hy__bc_r_` 仅保留过渡期双发),本地测试可通过 `group_id_prefix` 加前缀。 ## 12. 错误码 diff --git a/docs/IM全局与区域广播架构.md b/docs/IM全局与区域广播架构.md index 2bcedf12..187e87bf 100644 --- a/docs/IM全局与区域广播架构.md +++ b/docs/IM全局与区域广播架构.md @@ -57,8 +57,15 @@ GroupID 必须稳定、短、可解析,并满足腾讯 IM 自定义 GroupID | Group Type | GroupID | Meaning | | --- | --- | --- | | room | `` | 现有房间群,必须满足 `pkg/roomid` 规则 | -| global_broadcast | `hy__bc_g` | App 内全局播报群 | -| region_broadcast | `hy__bc_r_` | App 内指定区域播报群 | +| global_broadcast | `hy__bc_g_v2`(v1: `hy__bc_g`) | App 内全局播报群 | +| region_broadcast | `hy__bc_r_v2_`(v1: `hy__bc_r_`) | 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 旧群补发一份 `_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__bc_g) - C->>IM: joinGroup(hy__bc_r_) + C->>IM: joinGroup(hy__bc_g_v2) + C->>IM: joinGroup(hy__bc_r_v2_) ``` `/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 } diff --git a/docs/语音房客户端接口流程.md b/docs/语音房客户端接口流程.md index 4a2562b1..ed53274a 100644 --- a/docs/语音房客户端接口流程.md +++ b/docs/语音房客户端接口流程.md @@ -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 } diff --git a/pkg/imgroup/imgroup.go b/pkg/imgroup/imgroup.go index 6ee2d56c..06afa240 100644 --- a/pkg/imgroup/imgroup.go +++ b/pkg/imgroup/imgroup.go @@ -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_` 当成非法区域。两个版本解析为同一个 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 diff --git a/pkg/imgroup/imgroup_test.go b/pkg/imgroup/imgroup_test.go index f1ecaa08..483c3ae2 100644 --- a/pkg/imgroup/imgroup_test.go +++ b/pkg/imgroup/imgroup_test.go @@ -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" { diff --git a/services/activity-service/configs/config.docker.yaml b/services/activity-service/configs/config.docker.yaml index 4fb277dc..d8b70b9b 100644 --- a/services/activity-service/configs/config.docker.yaml +++ b/services/activity-service/configs/config.docker.yaml @@ -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 diff --git a/services/activity-service/configs/config.tencent.example.yaml b/services/activity-service/configs/config.tencent.example.yaml index 8fe523d2..6179fb7d 100644 --- a/services/activity-service/configs/config.tencent.example.yaml +++ b/services/activity-service/configs/config.tencent.example.yaml @@ -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: diff --git a/services/activity-service/configs/config.yaml b/services/activity-service/configs/config.yaml index d17344ad..90a9425b 100644 --- a/services/activity-service/configs/config.yaml +++ b/services/activity-service/configs/config.yaml @@ -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 diff --git a/services/activity-service/internal/app/services.go b/services/activity-service/internal/app/services.go index ddf92f32..2efc3d1e 100644 --- a/services/activity-service/internal/app/services.go +++ b/services/activity-service/internal/app/services.go @@ -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) diff --git a/services/activity-service/internal/config/config.go b/services/activity-service/internal/config/config.go index 9a89404a..860cd5ee 100644 --- a/services/activity-service/internal/config/config.go +++ b/services/activity-service/internal/config/config.go @@ -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, diff --git a/services/activity-service/internal/domain/broadcast/broadcast.go b/services/activity-service/internal/domain/broadcast/broadcast.go index 7efa4d54..502a043e 100644 --- a/services/activity-service/internal/domain/broadcast/broadcast.go +++ b/services/activity-service/internal/domain/broadcast/broadcast.go @@ -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。 diff --git a/services/activity-service/internal/service/broadcast/service.go b/services/activity-service/internal/service/broadcast/service.go index 2cc1a7a7..91ef5ef0 100644 --- a/services/activity-service/internal/service/broadcast/service.go +++ b/services/activity-service/internal/service/broadcast/service.go @@ -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, diff --git a/services/activity-service/internal/service/broadcast/service_mysql_test.go b/services/activity-service/internal/service/broadcast/service_mysql_test.go index 72402a66..042654da 100644 --- a/services/activity-service/internal/service/broadcast/service_mysql_test.go +++ b/services/activity-service/internal/service/broadcast/service_mysql_test.go @@ -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)) } diff --git a/services/activity-service/internal/service/broadcast/service_test.go b/services/activity-service/internal/service/broadcast/service_test.go index c3c0bb36..9f8b78c9 100644 --- a/services/activity-service/internal/service/broadcast/service_test.go +++ b/services/activity-service/internal/service/broadcast/service_test.go @@ -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 diff --git a/services/activity-service/internal/service/broadcast/worker.go b/services/activity-service/internal/service/broadcast/worker.go index e99092a4..d9213912 100644 --- a/services/activity-service/internal/service/broadcast/worker.go +++ b/services/activity-service/internal/service/broadcast/worker.go @@ -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)) + } +} diff --git a/services/activity-service/internal/service/luckygift/service.go b/services/activity-service/internal/service/luckygift/service.go index 35c0ec66..c46de7b0 100644 --- a/services/activity-service/internal/service/luckygift/service.go +++ b/services/activity-service/internal/service/luckygift/service.go @@ -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 { diff --git a/services/activity-service/internal/service/luckygift/service_test.go b/services/activity-service/internal/service/luckygift/service_test.go index d2e654d8..2975a2a8 100644 --- a/services/activity-service/internal/service/luckygift/service_test.go +++ b/services/activity-service/internal/service/luckygift/service_test.go @@ -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 { diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 1296c8a1..2dae4c3f 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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]) } diff --git a/services/notice-service/internal/modules/cpnotice/service.go b/services/notice-service/internal/modules/cpnotice/service.go index e327db18..3712a5fd 100644 --- a/services/notice-service/internal/modules/cpnotice/service.go +++ b/services/notice-service/internal/modules/cpnotice/service.go @@ -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 diff --git a/services/notice-service/internal/modules/cpnotice/service_test.go b/services/notice-service/internal/modules/cpnotice/service_test.go index 80afac4d..590e27f0 100644 --- a/services/notice-service/internal/modules/cpnotice/service_test.go +++ b/services/notice-service/internal/modules/cpnotice/service_test.go @@ -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) }