// Package broadcast 实现 activity-service 拥有的腾讯云 IM 播报能力。 // 该包只处理群生命周期、播报入队、outbox 投递和 room 事件消费策略,不拥有房间状态和资金事实。 package broadcast import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strings" "time" "google.golang.org/protobuf/proto" 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" ) const ( defaultBroadcastBatchSize = 100 defaultBroadcastLockTTL = 30 * time.Second defaultBroadcastMaxRetry = 8 ) // Repository 是播报消息的持久化 outbox 边界。 // service 只通过这个接口写入/抢占/标记记录,确保腾讯 REST 失败后仍可重试和人工补偿。 type Repository interface { SaveBroadcastOutbox(ctx context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) ClaimPendingBroadcastOutbox(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, maxRetry int) ([]broadcastdomain.OutboxRecord, error) MarkBroadcastOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error MarkBroadcastOutboxFailed(ctx context.Context, eventID string, workerID string, nowMS int64, nextRetryAtMS int64, maxRetry int, lastError string) error } // Publisher 隐藏腾讯云 IM REST 细节,只暴露 activity-service 真正拥有的建群和发群自定义消息能力。 type Publisher interface { EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error PublishGroupCustomMessage(ctx context.Context, message tencentim.CustomGroupMessage) error DeleteGroupMember(ctx context.Context, groupID string, userID int64) error } // RegionSource 从 user-service 读取 active 区域。 // 区域归属是用户主数据,activity-service 不复制区域配置,只在建群 reconciler 中读取。 type RegionSource interface { ListActiveRegionIDs(ctx context.Context) ([]int64, error) } // SenderProfileSource 只读取播报 IM 展示所需的公开资料快照。 // 资金和红包状态仍来自 wallet outbox,用户资料 owner 仍是 user-service。 type SenderProfileSource interface { GetSenderProfile(ctx context.Context, userID int64) (SenderProfile, error) } // SenderProfile 是播报 IM payload 的发送者展示字段。 // activity-service 不持久化该资料,只在消费事实时组装一次播报消息。 type SenderProfile struct { UserID int64 Account string Nickname string Avatar string AvatarFrameURL string RegionCode string } // Config 保存播报策略和 worker 参数。 // SuperGiftMinValue 是产品策略阈值;Worker* 是 outbox 投递吞吐和故障恢复参数。 type Config struct { NodeID string SuperGiftMinValue int64 WorkerBatchSize int WorkerLockTTL time.Duration WorkerMaxRetry int WorkerPollInterval time.Duration EnsureGroupsOnStartup bool // EnsureGroupsInterval 是常驻 worker 内的定时建群 reconcile 周期;<=0 表示只在启动时补偿一次。 // 新区域上线、启动时腾讯 REST 抖动都靠这个周期兜底,不再依赖人工调用 gRPC 补群。 EnsureGroupsInterval time.Duration GroupIDPrefix string } // PublishInput 是服务端入队播报请求。 // 调用方必须提供稳定 EventID;重复请求会命中 outbox 幂等,而不是重复发 IM。 type PublishInput struct { EventID string BroadcastType string RegionID int64 PayloadJSON string DeliverAtMS int64 } // Service 拥有播报群建群补偿、播报入队和 outbox 投递决策。 // 它不直接向客户端返回 UI 文案,只生产带 action 的结构化 JSON,让客户端自行决定展示形态。 type Service struct { cfg Config repository Repository publisher Publisher regionSource RegionSource senderProfileSource SenderProfileSource now func() time.Time } // New 创建播报服务。 // publisher 可以为 nil,此时仍允许消费事件并写 outbox,但真正投递会 fail-closed;本地环境默认不误发腾讯云消息。 func New(cfg Config, repository Repository, publisher Publisher, regionSource RegionSource) *Service { cfg = normalizeConfig(cfg) return &Service{cfg: cfg, repository: repository, publisher: publisher, regionSource: regionSource, now: time.Now} } // SetClock 注入确定性时间,测试中用它验证 payload 和 outbox 的 Unix ms 字段。 func (s *Service) SetClock(now func() time.Time) { if now != nil { s.now = now } } // SetSenderProfileSource 注入 user-service 资料读取边界。 // 测试或离线环境可以不注入,此时红包 IM payload 仍会带 user_id/account fallback。 func (s *Service) SetSenderProfileSource(source SenderProfileSource) { if s != nil { s.senderProfileSource = source } } // 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 { errs = append(errs, fmt.Errorf("ensure global group %s: %w", globalID, err)) } else { count++ } if s.regionSource == nil { // 没有区域源时至少保证全局群存在;区域群等待下次有 user-service 依赖的 reconciler 补齐。 return count, errors.Join(errs...) } regionIDs, err := s.regionSource.ListActiveRegionIDs(ctx) if err != nil { errs = append(errs, fmt.Errorf("list active regions: %w", err)) return count, errors.Join(errs...) } for _, regionID := range regionIDs { if regionID <= 0 { // GLOBAL/未知区域不对应 IM 区域群,避免客户端加入一个语义不清的“0 区域群”。 continue } groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app, regionID) if err != nil { errs = append(errs, fmt.Errorf("region %d group_id: %w", regionID, err)) continue } if err := s.publisher.EnsureGroup(ctx, s.broadcastGroupSpec(groupID)); err != nil { errs = append(errs, fmt.Errorf("ensure region group %s: %w", groupID, err)) continue } count++ } return count, errors.Join(errs...) } // PublishRegionBroadcast 把区域播报写入持久化 outbox。 // 这里不直接调用腾讯 REST,保证调用方提交成功后即使进程崩溃也能由 worker 继续投递。 func (s *Service) PublishRegionBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) { if input.RegionID <= 0 { return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "region_id is required") } groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), input.RegionID) if err != nil { return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error()) } return s.enqueue(ctx, broadcastdomain.ScopeRegion, groupID, input) } func (s *Service) PublishRoomBroadcast(ctx context.Context, roomID string, input PublishInput) (broadcastdomain.PublishResult, error) { roomID = strings.TrimSpace(roomID) if roomID == "" { return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "room_id is required") } return s.enqueue(ctx, broadcastdomain.ScopeRoom, roomID, input) } // PublishGlobalBroadcast 把全局播报写入持久化 outbox。 // 全局播报和区域播报共用同一张表,scope/group_id 决定最终投递目标。 func (s *Service) PublishGlobalBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) { groupID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx)) if err != nil { return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error()) } return s.enqueue(ctx, broadcastdomain.ScopeGlobal, groupID, input) } // RemoveRegionBroadcastMember 把一个用户从旧区域播报群移除。 // 这是成员关系更新,不是区域群生命周期操作;区域群仍由 reconciler 长期维护,其他用户不受影响。 func (s *Service) RemoveRegionBroadcastMember(ctx context.Context, userID int64, regionID int64) (string, bool, error) { if userID <= 0 { return "", false, xerr.New(xerr.InvalidArgument, "user_id is required") } if regionID <= 0 { // GLOBAL/无区域没有对应区域播报群,调用方跨区域比较时可以安全把 0 传进来。 return "", false, nil } if s == nil || s.publisher == nil { return "", false, xerr.New(xerr.Unavailable, "broadcast publisher is not configured") } groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), regionID) if err != nil { return "", false, xerr.New(xerr.InvalidArgument, err.Error()) } 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 } func (s *Service) enqueue(ctx context.Context, scope string, groupID string, input PublishInput) (broadcastdomain.PublishResult, error) { if s == nil || s.repository == nil { return broadcastdomain.PublishResult{}, xerr.New(xerr.Unavailable, "broadcast repository is not configured") } input.EventID = strings.TrimSpace(input.EventID) input.BroadcastType = strings.TrimSpace(input.BroadcastType) if input.EventID == "" || input.BroadcastType == "" { return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "event_id and broadcast_type are required") } nowMS := s.now().UTC().UnixMilli() payloadJSON, err := normalizedPayloadJSON(input.PayloadJSON, map[string]any{ "event_id": input.EventID, "broadcast_type": input.BroadcastType, "scope": scope, "app_code": appcode.FromContext(ctx), "region_id": input.RegionID, "sent_at_ms": nowMS, }) if err != nil { return broadcastdomain.PublishResult{}, err } // outbox 记录保存的是完整 JSON,而不是只保存引用 ID;客户端收到 IM 后无需再同步请求才能完成入口跳转。 record := broadcastdomain.OutboxRecord{ AppCode: appcode.FromContext(ctx), EventID: input.EventID, Scope: scope, GroupID: groupID, BroadcastType: input.BroadcastType, PayloadJSON: payloadJSON, Status: broadcastdomain.StatusPending, NextRetryAtMS: nowMS, CreatedAtMS: nowMS, UpdatedAtMS: nowMS, } if input.DeliverAtMS > nowMS { record.NextRetryAtMS = input.DeliverAtMS } created, saved, err := s.repository.SaveBroadcastOutbox(ctx, record) if err != nil { return broadcastdomain.PublishResult{}, err } return broadcastdomain.PublishResult{EventID: saved.EventID, GroupID: saved.GroupID, Status: saved.Status, Created: created}, nil } // ProcessPendingBroadcasts 抢占一批待投递 outbox,发送到腾讯 IM,并更新成功/重试状态。 // claim 和 mark 分开做:抢占阶段保护多 worker 并发,发送阶段允许单条失败不影响同批其他消息。 func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOptions) (broadcastdomain.ProcessResult, error) { if s == nil || s.repository == nil { return broadcastdomain.ProcessResult{}, xerr.New(xerr.Unavailable, "broadcast repository is not configured") } if s.publisher == nil { return broadcastdomain.ProcessResult{}, xerr.New(xerr.Unavailable, "broadcast publisher is not configured") } options = normalizeWorkerOptions(options, s.cfg) nowMS := s.now().UTC().UnixMilli() records, err := s.repository.ClaimPendingBroadcastOutbox(ctx, options.WorkerID, nowMS, nowMS+options.LockTTL.Milliseconds(), options.BatchSize, options.MaxRetry) if err != nil { return broadcastdomain.ProcessResult{}, err } result := broadcastdomain.ProcessResult{ClaimedCount: len(records), HasMore: len(records) >= options.BatchSize} for _, record := range records { // 腾讯 REST 只接受已序列化 JSON;event_id/desc/ext 让客户端和日志都能快速识别消息类型。 err := s.publisher.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{ GroupID: record.GroupID, EventID: record.EventID, Desc: record.BroadcastType, Ext: "im_broadcast", PayloadJSON: json.RawMessage(record.PayloadJSON), }) 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 } if err := s.repository.MarkBroadcastOutboxDelivered(ctx, record.EventID, s.now().UTC().UnixMilli()); err != nil { return result, err } result.SuccessCount++ s.dualSendLegacyBroadcast(ctx, record) } return result, nil } // dualSendLegacyBroadcast 在播报群从 v1(ChatRoom) 切到 v2(AVChatRoom) 的过渡期内, // 把已成功投递到 v2 群的全局/区域播报补发一份到 v1 旧群:老客户端在重新拉取 join 列表前 // 仍停留在旧群里。补发尽力而为,失败不影响主投递的成功状态。 // 单个用户不会重复收到:新登录只加入 v2 群,存量会话只在 v1 群。 func (s *Service) dualSendLegacyBroadcast(ctx context.Context, record broadcastdomain.OutboxRecord) { parsed := imgroup.ParseWithPrefix(record.GroupID, s.cfg.GroupIDPrefix) 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 == record.GroupID 说明这条记录本身就是迁移前入队的 v1 群消息,无需再补发。 if err != nil || legacyID == record.GroupID { return } _ = s.publisher.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{ GroupID: legacyID, EventID: record.EventID + "_legacy", Desc: record.BroadcastType, Ext: "im_broadcast", PayloadJSON: json.RawMessage(record.PayloadJSON), }) } // HandleRoomEvent 把已提交的 room outbox 事实转换为服务端播报。 // 礼物、火箭和锁房只使用 room outbox 中的稳定事实,避免把展示策略侵入 Room Cell 主链路。 func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) { if envelope == nil { return broadcastdomain.ConsumeRoomEventResult{}, xerr.New(xerr.InvalidArgument, "room event envelope is required") } eventCtx := appcode.WithContext(ctx, envelope.GetAppCode()) result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped} if envelope.GetEventType() == "RoomRocketIgnited" { return s.handleRoomRocketIgnited(eventCtx, envelope) } if envelope.GetEventType() == "RoomRocketRewardGranted" { return s.handleRoomRocketRewardGranted(eventCtx, envelope) } if envelope.GetEventType() == "RoomPasswordChanged" { return s.handleRoomPasswordChanged(eventCtx, envelope) } if envelope.GetEventType() != "RoomGiftSent" { // 当前只消费礼物、房间火箭和锁房事实;未来运营事件应显式增加事件类型分支和测试。 return result, nil } var gift roomeventsv1.RoomGiftSent if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } if gift.GetIsRobotGift() { return result, nil } if gift.GetVisibleRegionId() <= 0 || !s.shouldBroadcastGift(&gift) { // visible_region_id 来自房间/主播可见区域,不能用客户端当前 IP 或本地时区推断。 return result, nil } broadcastEventID := superGiftBroadcastEventID(envelope, &gift) // 区域播报需要携带双方展示资料;资料服务不可用时返回错误,避免发出缺头像昵称的误导性大礼消息。 senderProfile, err := s.broadcastUserProfile(ctx, gift.GetSenderUserId()) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } receiverProfile, err := s.broadcastUserProfile(ctx, gift.GetTargetUserId()) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } payloadJSON, err := superGiftPayload(envelope, &gift, broadcastEventID, s.now().UTC().UnixMilli(), senderProfile, receiverProfile) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } published, err := s.PublishRegionBroadcast(eventCtx, PublishInput{ EventID: broadcastEventID, BroadcastType: broadcastdomain.TypeSuperGift, RegionID: gift.GetVisibleRegionId(), PayloadJSON: payloadJSON, }) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } // 返回上游 event_id 和下游 broadcast_event_id,方便排查“房间事件已消费但播报是否生成”。 return broadcastdomain.ConsumeRoomEventResult{ EventID: envelope.GetEventId(), Status: published.Status, BroadcastEventID: published.EventID, BroadcastCreated: published.Created, }, nil } func (s *Service) shouldBroadcastGift(gift *roomeventsv1.RoomGiftSent) bool { if gift == nil { return false } // 老规则仍按礼物总价值触发;新规则额外允许后台显式标记 global_broadcast 的低价礼物触发区域播报。 if gift.GetGiftValue() >= s.cfg.SuperGiftMinValue { return true } for _, effectType := range gift.GetGiftEffectTypes() { if strings.TrimSpace(strings.ToLower(effectType)) == "global_broadcast" { return true } } return false } func (s *Service) broadcastUserProfile(ctx context.Context, userID int64) (SenderProfile, error) { profile := SenderProfile{UserID: userID} // 没有 user_id 或未注入资料源时保留 user_id 兜底,保证测试和无资料源部署不会阻断原有播报链路。 if userID <= 0 || s.senderProfileSource == nil { return profile, nil } resolved, err := s.senderProfileSource.GetSenderProfile(ctx, userID) if err != nil { return SenderProfile{}, err } if resolved.UserID <= 0 { resolved.UserID = userID } return resolved, nil } func (s *Service) handleRoomPasswordChanged(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) { result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped} var event roomeventsv1.RoomPasswordChanged if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } if event.GetVisibleRegionId() <= 0 { // 区域通知必须有明确区域,不能把无区域房间退化成全局播报扩大影响面。 return result, nil } broadcastEventID := roomPasswordChangedBroadcastEventID(envelope) payloadJSON, err := roomPasswordChangedPayload(envelope, &event, broadcastEventID, s.now().UTC().UnixMilli()) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } published, err := s.PublishRegionBroadcast(ctx, PublishInput{ EventID: broadcastEventID, BroadcastType: broadcastdomain.TypeRoomPasswordChanged, RegionID: event.GetVisibleRegionId(), PayloadJSON: payloadJSON, }) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } return broadcastdomain.ConsumeRoomEventResult{ EventID: envelope.GetEventId(), Status: published.Status, BroadcastEventID: published.EventID, BroadcastCreated: published.Created, }, nil } func (s *Service) handleRoomRocketIgnited(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) { result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped} var rocket roomeventsv1.RoomRocketIgnited if err := proto.Unmarshal(envelope.GetBody(), &rocket); err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } switch rocket.GetBroadcastScope() { case broadcastdomain.ScopeRegion: if rocket.GetVisibleRegionId() <= 0 { return result, nil } case broadcastdomain.ScopeGlobal: default: return result, nil } broadcastEventID := roomRocketBroadcastEventID(envelope, &rocket) igniterProfile := SenderProfile{UserID: rocket.GetIgniterUserId()} if rocket.GetIgniterUserId() > 0 && s.senderProfileSource != nil { profile, err := s.senderProfileSource.GetSenderProfile(ctx, rocket.GetIgniterUserId()) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } igniterProfile = profile } payloadJSON, err := roomRocketPayload(envelope, &rocket, broadcastEventID, s.now().UTC().UnixMilli(), igniterProfile) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } var published broadcastdomain.PublishResult if rocket.GetBroadcastScope() == broadcastdomain.ScopeGlobal { published, err = s.PublishGlobalBroadcast(ctx, PublishInput{ EventID: broadcastEventID, BroadcastType: broadcastdomain.TypeRoomRocket, PayloadJSON: payloadJSON, }) } else { published, err = s.PublishRegionBroadcast(ctx, PublishInput{ EventID: broadcastEventID, BroadcastType: broadcastdomain.TypeRoomRocket, RegionID: rocket.GetVisibleRegionId(), PayloadJSON: payloadJSON, }) } if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } return broadcastdomain.ConsumeRoomEventResult{ EventID: envelope.GetEventId(), Status: published.Status, BroadcastEventID: published.EventID, BroadcastCreated: published.Created, }, nil } func (s *Service) handleRoomRocketRewardGranted(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) { result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped} var event roomeventsv1.RoomRocketRewardGranted if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } if envelope.GetRoomId() == "" || event.GetRocketId() == "" || len(event.GetRewards()) == 0 { return result, nil } profiles := make(map[int64]SenderProfile, len(event.GetRewards())) if s.senderProfileSource != nil { for _, reward := range event.GetRewards() { userID := reward.GetUserId() if userID <= 0 { continue } if _, exists := profiles[userID]; exists { continue } profile, err := s.senderProfileSource.GetSenderProfile(ctx, userID) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } profiles[userID] = profile } } broadcastEventID := roomRocketRewardBroadcastEventID(envelope, &event) payloadJSON, err := roomRocketRewardPayload(envelope, &event, broadcastEventID, s.now().UTC().UnixMilli(), profiles) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } published, err := s.PublishRoomBroadcast(ctx, envelope.GetRoomId(), PublishInput{ EventID: broadcastEventID, BroadcastType: broadcastdomain.TypeRoomRocketReward, PayloadJSON: payloadJSON, }) if err != nil { return broadcastdomain.ConsumeRoomEventResult{}, err } return broadcastdomain.ConsumeRoomEventResult{ EventID: envelope.GetEventId(), Status: published.Status, BroadcastEventID: published.EventID, BroadcastCreated: published.Created, }, nil } // WorkerOptions 描述一次 worker 批处理请求;cron 和常驻 worker 都走同一套参数归一化。 type WorkerOptions struct { WorkerID string BatchSize int LockTTL time.Duration MaxRetry int PollInterval time.Duration } func normalizeConfig(cfg Config) Config { cfg.NodeID = strings.TrimSpace(cfg.NodeID) if cfg.NodeID == "" { cfg.NodeID = "activity-broadcast" } if cfg.SuperGiftMinValue <= 0 { cfg.SuperGiftMinValue = 100000 } if cfg.WorkerBatchSize <= 0 { cfg.WorkerBatchSize = defaultBroadcastBatchSize } if cfg.WorkerLockTTL <= 0 { cfg.WorkerLockTTL = defaultBroadcastLockTTL } if cfg.WorkerMaxRetry <= 0 { cfg.WorkerMaxRetry = defaultBroadcastMaxRetry } if cfg.WorkerPollInterval <= 0 { cfg.WorkerPollInterval = time.Second } return cfg } func normalizeWorkerOptions(options WorkerOptions, cfg Config) WorkerOptions { if strings.TrimSpace(options.WorkerID) == "" { options.WorkerID = cfg.NodeID + "-broadcast" } if options.BatchSize <= 0 { options.BatchSize = cfg.WorkerBatchSize } if options.LockTTL <= 0 { options.LockTTL = cfg.WorkerLockTTL } if options.MaxRetry <= 0 { options.MaxRetry = cfg.WorkerMaxRetry } if options.PollInterval <= 0 { options.PollInterval = cfg.WorkerPollInterval } return options } func (s *Service) broadcastGroupSpec(groupID string) tencentim.GroupSpec { 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, } } return tencentim.GroupSpec{ GroupID: groupID, Name: groupID, Type: tencentim.DefaultGroupType, // 入群表面允许 FreeAccess,真正准入由腾讯回调回查 user-service;这样客户端 SDK 体验简单,权限仍在服务端。 ApplyJoinOption: "FreeAccess", } } func normalizedPayloadJSON(input string, forced map[string]any) (string, error) { input = strings.TrimSpace(input) if input == "" { input = "{}" } var payload map[string]any if err := json.Unmarshal([]byte(input), &payload); err != nil { return "", xerr.New(xerr.InvalidArgument, "payload_json is invalid") } if payload == nil { payload = map[string]any{} } for key, value := range forced { if key == "region_id" && value.(int64) <= 0 { // 全局播报不写 region_id,避免客户端误把 0 当作一个真实区域。 continue } // forced 字段覆盖调用方 payload,保证 event_id/scope/app_code/sent_at_ms 不被外部请求伪造。 payload[key] = value } encoded, err := json.Marshal(payload) if err != nil { return "", err } return string(encoded), nil } func superGiftBroadcastEventID(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) string { if strings.TrimSpace(gift.GetCommandId()) != "" { // command_id 是 SendGift 幂等键,用它生成播报 event_id 可避免同一房间事件重放时重复播报。 return fmt.Sprintf("gift_broadcast:%s:%s", envelope.GetRoomId(), gift.GetCommandId()) } // 兼容没有 command_id 的旧/测试事件;当前开发阶段仍以 envelope event_id 保持幂等。 return "gift_broadcast:" + envelope.GetEventId() } func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent, eventID string, sentAtMS int64, sender SenderProfile, receiver SenderProfile) (string, error) { // payload 同时保留扁平字段和 sender/receiver 对象,旧客户端继续读原字段,新客户端可直接取完整用户资料。 payload := map[string]any{ "event_id": eventID, "broadcast_type": broadcastdomain.TypeSuperGift, "scope": broadcastdomain.ScopeRegion, "app_code": envelope.GetAppCode(), "region_id": gift.GetVisibleRegionId(), "room_id": envelope.GetRoomId(), "sender_user_id": gift.GetSenderUserId(), "sender_display_user_id": sender.Account, "sender_nickname": sender.Nickname, "sender_avatar": sender.Avatar, "sender_avatar_frame_url": sender.AvatarFrameURL, "target_user_id": gift.GetTargetUserId(), "receiver_user_id": gift.GetTargetUserId(), "receiver_display_user_id": receiver.Account, "receiver_nickname": receiver.Nickname, "receiver_avatar": receiver.Avatar, "receiver_avatar_frame_url": receiver.AvatarFrameURL, "gift_id": gift.GetGiftId(), "gift_name": gift.GetGiftName(), "gift_icon_url": gift.GetGiftIconUrl(), "gift_animation_url": gift.GetGiftAnimationUrl(), "gift_effect_types": gift.GetGiftEffectTypes(), "gift_count": gift.GetGiftCount(), "gift_value": gift.GetGiftValue(), "sent_at_ms": sentAtMS, "room_version": envelope.GetRoomVersion(), "source_event_id": envelope.GetEventId(), "sender": broadcastProfilePayload(sender), "receiver": broadcastProfilePayload(receiver), "action": map[string]any{ "type": "enter_room", "room_id": envelope.GetRoomId(), }, } encoded, err := json.Marshal(payload) return string(encoded), err } func broadcastProfilePayload(profile SenderProfile) map[string]any { // 嵌套资料对象只放展示字段,不放钱包或权限数据,避免 IM 广播泄露服务端内部状态。 return map[string]any{ "user_id": profile.UserID, "display_user_id": profile.Account, "nickname": profile.Nickname, "avatar": profile.Avatar, "avatar_frame_url": profile.AvatarFrameURL, } } func roomRocketBroadcastEventID(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv1.RoomRocketIgnited) string { if strings.TrimSpace(rocket.GetRocketId()) != "" { return fmt.Sprintf("room_rocket_broadcast:%s:%s", envelope.GetRoomId(), rocket.GetRocketId()) } return "room_rocket_broadcast:" + envelope.GetEventId() } func roomRocketRewardBroadcastEventID(envelope *roomeventsv1.EventEnvelope, event *roomeventsv1.RoomRocketRewardGranted) string { if strings.TrimSpace(event.GetRocketId()) != "" { return fmt.Sprintf("room_rocket_reward:%s:%s", envelope.GetRoomId(), event.GetRocketId()) } return "room_rocket_reward:" + envelope.GetEventId() } func roomPasswordChangedBroadcastEventID(envelope *roomeventsv1.EventEnvelope) string { return "room_password_changed:" + envelope.GetEventId() } func roomPasswordChangedPayload(envelope *roomeventsv1.EventEnvelope, event *roomeventsv1.RoomPasswordChanged, eventID string, sentAtMS int64) (string, error) { payload := map[string]any{ "event_id": eventID, "broadcast_type": broadcastdomain.TypeRoomPasswordChanged, "scope": broadcastdomain.ScopeRegion, "app_code": envelope.GetAppCode(), "region_id": event.GetVisibleRegionId(), "room_id": envelope.GetRoomId(), "actor_user_id": event.GetActorUserId(), "locked": event.GetLocked(), "sent_at_ms": sentAtMS, "room_version": envelope.GetRoomVersion(), "source_event_id": envelope.GetEventId(), "action": map[string]any{ "type": "refresh_room", "room_id": envelope.GetRoomId(), }, } encoded, err := json.Marshal(payload) return string(encoded), err } func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv1.RoomRocketIgnited, eventID string, sentAtMS int64, igniter SenderProfile) (string, error) { payload := map[string]any{ "event_id": eventID, "broadcast_type": broadcastdomain.TypeRoomRocket, "scope": rocket.GetBroadcastScope(), "app_code": envelope.GetAppCode(), "region_id": rocket.GetVisibleRegionId(), "room_id": envelope.GetRoomId(), "room_short_id": rocket.GetRoomShortId(), "rocket_id": rocket.GetRocketId(), "rocket_level": rocket.GetLevel(), "rocket_cover_url": rocket.GetRocketCoverUrl(), "launch_at_ms": rocket.GetLaunchAtMs(), "top1_user_id": rocket.GetTop1UserId(), "igniter_user_id": rocket.GetIgniterUserId(), "igniter": map[string]any{ "user_id": fmt.Sprintf("%d", rocket.GetIgniterUserId()), "short_id": strings.TrimSpace(igniter.Account), "nickname": strings.TrimSpace(igniter.Nickname), "avatar": strings.TrimSpace(igniter.Avatar), }, "sent_at_ms": sentAtMS, "room_version": envelope.GetRoomVersion(), "source_event_id": envelope.GetEventId(), "current_fuel": rocket.GetCurrentFuel(), "fuel_threshold": rocket.GetFuelThreshold(), "ignited_at_ms": rocket.GetIgnitedAtMs(), "action": map[string]any{ "type": "enter_room", "room_id": envelope.GetRoomId(), }, } encoded, err := json.Marshal(payload) return string(encoded), err } func roomRocketRewardPayload(envelope *roomeventsv1.EventEnvelope, event *roomeventsv1.RoomRocketRewardGranted, eventID string, sentAtMS int64, profiles map[int64]SenderProfile) (string, error) { rewards := make([]map[string]any, 0, len(event.GetRewards())) for _, reward := range event.GetRewards() { userID := reward.GetUserId() profile := profiles[userID] rewards = append(rewards, map[string]any{ "reward_role": reward.GetRewardRole(), "user_id": fmt.Sprintf("%d", userID), "user": map[string]any{ "user_id": fmt.Sprintf("%d", userID), "short_id": strings.TrimSpace(profile.Account), "nickname": strings.TrimSpace(profile.Nickname), "avatar": strings.TrimSpace(profile.Avatar), }, "reward_item_id": reward.GetRewardItemId(), "resource_group_id": reward.GetResourceGroupId(), "display_name": reward.GetDisplayName(), "icon_url": reward.GetIconUrl(), "grant_id": reward.GetGrantId(), "status": reward.GetStatus(), }) } payload := map[string]any{ "event_id": eventID, "broadcast_type": broadcastdomain.TypeRoomRocketReward, "scope": broadcastdomain.ScopeRoom, "app_code": envelope.GetAppCode(), "room_id": envelope.GetRoomId(), "rocket_id": event.GetRocketId(), "rocket_level": event.GetLevel(), "rewards": rewards, "sent_at_ms": sentAtMS, "room_version": envelope.GetRoomVersion(), "source_event_id": envelope.GetEventId(), } encoded, err := json.Marshal(payload) return string(encoded), err } func trimError(value string) string { value = strings.TrimSpace(value) if len(value) > 512 { return value[:512] } return value }