2026-05-29 19:56:14 +08:00

531 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

// Package broadcast 实现 activity-service 拥有的腾讯云 IM 播报能力。
// 该包只处理群生命周期、播报入队、outbox 投递和 room 事件消费策略,不拥有房间状态和资金事实。
package broadcast
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/imgroup"
"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)
}
// Config 保存播报策略和 worker 参数。
// SuperGiftMinValue 是产品策略阈值Worker* 是 outbox 投递吞吐和故障恢复参数。
type Config struct {
NodeID string
SuperGiftMinValue int64
WorkerBatchSize int
WorkerLockTTL time.Duration
WorkerMaxRetry int
WorkerPollInterval time.Duration
EnsureGroupsOnStartup bool
}
// 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
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
}
}
// 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
globalID, err := imgroup.GlobalBroadcastGroupID(app)
if err != nil {
return 0, xerr.New(xerr.InvalidArgument, err.Error())
}
if err := s.publisher.EnsureGroup(ctx, broadcastGroupSpec(globalID)); err != nil {
return count, err
}
count++
if s.regionSource == nil {
// 没有区域源时至少保证全局群存在;区域群等待下次有 user-service 依赖的 reconciler 补齐。
return count, nil
}
regionIDs, err := s.regionSource.ListActiveRegionIDs(ctx)
if err != nil {
return count, err
}
for _, regionID := range regionIDs {
if regionID <= 0 {
// GLOBAL/未知区域不对应 IM 区域群避免客户端加入一个语义不清的“0 区域群”。
continue
}
groupID, err := imgroup.RegionBroadcastGroupID(app, regionID)
if err != nil {
return count, xerr.New(xerr.InvalidArgument, err.Error())
}
if err := s.publisher.EnsureGroup(ctx, broadcastGroupSpec(groupID)); err != nil {
return count, err
}
count++
}
return count, nil
}
// 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.RegionBroadcastGroupID(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.GlobalBroadcastGroupID(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.RegionBroadcastGroupID(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
}
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 只接受已序列化 JSONevent_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++
// 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++
}
return result, nil
}
// HandleRoomEvent 把已提交的 room outbox 事实转换为服务端播报。
// 只有 RoomGiftSent 且满足区域和礼物价值阈值时才生成区域播报,避免把展示策略侵入 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() == "RoomTreasureCountdownStarted" {
return s.handleRoomTreasureCountdown(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.GetVisibleRegionId() <= 0 || gift.GetGiftValue() < s.cfg.SuperGiftMinValue {
// visible_region_id 来自房间/主播可见区域,不能用客户端当前 IP 或本地时区推断。
return result, nil
}
broadcastEventID := superGiftBroadcastEventID(envelope, &gift)
payloadJSON, err := superGiftPayload(envelope, &gift, broadcastEventID, s.now().UTC().UnixMilli())
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) handleRoomTreasureCountdown(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
var treasure roomeventsv1.RoomTreasureCountdownStarted
if err := proto.Unmarshal(envelope.GetBody(), &treasure); err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
switch treasure.GetBroadcastScope() {
case broadcastdomain.ScopeRegion:
if treasure.GetVisibleRegionId() <= 0 {
return result, nil
}
case broadcastdomain.ScopeGlobal:
default:
return result, nil
}
broadcastEventID := roomTreasureBroadcastEventID(envelope, &treasure)
payloadJSON, err := roomTreasurePayload(envelope, &treasure, broadcastEventID, s.now().UTC().UnixMilli())
if err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
var published broadcastdomain.PublishResult
if treasure.GetBroadcastScope() == broadcastdomain.ScopeGlobal {
published, err = s.PublishGlobalBroadcast(ctx, PublishInput{
EventID: broadcastEventID,
BroadcastType: broadcastdomain.TypeRoomTreasure,
PayloadJSON: payloadJSON,
})
} else {
published, err = s.PublishRegionBroadcast(ctx, PublishInput{
EventID: broadcastEventID,
BroadcastType: broadcastdomain.TypeRoomTreasure,
RegionID: treasure.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
}
// 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 broadcastGroupSpec(groupID string) tencentim.GroupSpec {
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) (string, error) {
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(),
"target_user_id": gift.GetTargetUserId(),
"gift_id": gift.GetGiftId(),
"gift_count": gift.GetGiftCount(),
"gift_value": gift.GetGiftValue(),
"sent_at_ms": sentAtMS,
"room_version": envelope.GetRoomVersion(),
"source_event_id": envelope.GetEventId(),
"action": map[string]any{
"type": "enter_room",
"room_id": envelope.GetRoomId(),
},
}
encoded, err := json.Marshal(payload)
return string(encoded), err
}
func roomTreasureBroadcastEventID(envelope *roomeventsv1.EventEnvelope, treasure *roomeventsv1.RoomTreasureCountdownStarted) string {
if strings.TrimSpace(treasure.GetBoxId()) != "" {
return fmt.Sprintf("room_treasure_broadcast:%s:%s", envelope.GetRoomId(), treasure.GetBoxId())
}
return "room_treasure_broadcast:" + envelope.GetEventId()
}
func roomTreasurePayload(envelope *roomeventsv1.EventEnvelope, treasure *roomeventsv1.RoomTreasureCountdownStarted, eventID string, sentAtMS int64) (string, error) {
payload := map[string]any{
"event_id": eventID,
"broadcast_type": broadcastdomain.TypeRoomTreasure,
"scope": treasure.GetBroadcastScope(),
"app_code": envelope.GetAppCode(),
"region_id": treasure.GetVisibleRegionId(),
"room_id": envelope.GetRoomId(),
"box_id": treasure.GetBoxId(),
"level": treasure.GetLevel(),
"open_at_ms": treasure.GetOpenAtMs(),
"top1_user_id": treasure.GetTop1UserId(),
"igniter_user_id": treasure.GetIgniterUserId(),
"sent_at_ms": sentAtMS,
"room_version": envelope.GetRoomVersion(),
"source_event_id": envelope.GetEventId(),
"current_progress": treasure.GetCurrentProgress(),
"energy_threshold": treasure.GetEnergyThreshold(),
"countdown_started_at_ms": treasure.GetCountdownStartedAtMs(),
"action": map[string]any{
"type": "enter_room",
"room_id": envelope.GetRoomId(),
},
}
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
}