package service import ( "context" "math" "strings" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/giftlimits" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/state" ) const ( // 机器人送礼量远高于礼物配置变更频率;短缓存把每次展示的 wallet/MySQL 只读查询收敛为每 App/区域每分钟一次。 robotGiftCatalogTTL = time.Minute // wallet 故障只短暂负缓存,既让同批机器人共享失败,也能在服务恢复后迅速重试。 robotGiftCatalogErrorTTL = time.Second robotGiftCatalogQueryTimeout = 3 * time.Second robotGiftCatalogPageSize = int32(500) robotGiftCatalogMaxPages = int32(20) ) type robotGiftCatalogKey struct { appCode string regionID int64 } type robotGiftCatalogEntry struct { expiresAtMS int64 gifts map[string]*walletv1.GiftConfig loadErr error } type robotGiftCatalogLoad struct { done chan struct{} } type RobotSendGiftInput struct { Meta *roomv1.RequestMeta TargetUserID int64 GiftID string GiftCount int32 PoolID string RobotUserIDs []int64 SyntheticRewardCoins int64 SyntheticMultiplierPPM int64 // RealRoomHeat 是历史字段名;当前只表示“真人房机器人”校验范围,绝不再产生真实热度或任何持久化贡献。 RealRoomHeat bool } type robotGiftOptions struct { Enabled bool RobotUserIDs []int64 SyntheticRewardCoins int64 SyntheticMultiplierPPM int64 // RealRoomHeat 仅供 validateBeforeDebit 区分专属机器人房和真人房机器人池,不能进入状态/账务结算。 RealRoomHeat bool } func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (*roomv1.SendGiftResponse, error) { req := &roomv1.SendGiftRequest{ Meta: input.Meta, TargetUserId: input.TargetUserID, TargetUserIds: []int64{input.TargetUserID}, GiftId: strings.TrimSpace(input.GiftID), GiftCount: input.GiftCount, TargetType: "user", PoolId: input.PoolID, } ctx = contextFromMeta(ctx, req.GetMeta()) flow := newGiftFlow(s, ctx, req, giftSendOptions{ Robot: robotGiftOptions{ Enabled: true, RobotUserIDs: input.RobotUserIDs, SyntheticRewardCoins: input.SyntheticRewardCoins, SyntheticMultiplierPPM: input.SyntheticMultiplierPPM, RealRoomHeat: input.RealRoomHeat, }, }) // RobotGift 永远是纯展示语义。RealRoomHeat 只兼容真人房机器人参与者校验,不能再授权热度、榜单、火箭或统计落库。 flow.cmd.RobotGift = true flow.cmd.RobotWalletGift = true if flow.cmd.RoomID() == "" || flow.cmd.ActorUserID() <= 0 || !giftlimits.ValidCommandID(flow.cmd.ID()) { return nil, xerr.New(xerr.InvalidArgument, "command meta is incomplete") } if s.isDraining() { return nil, xerr.New(xerr.Unavailable, "room service is draining") } // 只读取当前 Room Cell 做 presence 与机器人池校验;ensureCell 仅续租 Redis owner lease, // 不进入 mutateRoom,因此不会写 room_gift_operations、command log、snapshot、账本或房间统计表。 snapshot, leaseToken, err := s.robotGiftOwnerSnapshot(ctx, flow.cmd.RoomID()) if err != nil { return nil, err } current := state.FromProto(snapshot) if err := flow.validateBeforeDebit(current); err != nil { return nil, err } // 礼物目录调用是只读快照查询,替代历史 DebitRobotGift;返回值仅用于 IM 图标、动画、类型和展示价值。 giftConfig, err := s.robotGiftConfig(ctx, flow.cmd.GiftID, snapshot.GetVisibleRegionId()) if err != nil { return nil, err } billing, err := robotGiftDisplayBilling(giftConfig, flow.cmd.GiftCount) if err != nil { return nil, err } targetBillings := []giftTargetBilling{{ TargetUserID: flow.cmd.TargetUserID, CommandID: flow.cmd.ID(), Billing: billing, }} targetGiftValues := map[int64]int64{} if target := current.OnlineUsers[flow.cmd.TargetUserID]; target != nil { // 纯展示礼物不能改变麦位/在线用户累计值;事件携带当前值,客户端不会把机器人价值误并入真实状态。 targetGiftValues[flow.cmd.TargetUserID] = target.GiftValue } now := s.clock.Now() records, err := buildRoomGiftSentRecords(flow.cmd.RoomID(), snapshot.GetVersion(), now, flow.cmd, RoomMeta{ RoomID: flow.cmd.RoomID(), VisibleRegionID: snapshot.GetVisibleRegionId(), }, targetBillings, targetGiftValues) if err != nil { return nil, err } var luckyGift *roomv1.LuckyGiftDrawResult var luckyGifts []*roomv1.LuckyGiftDrawResult if flow.cmd.SyntheticLuckyGift { luckyGift = syntheticLuckyGiftResult(flow.cmd, targetBillings, now) if luckyGift != nil { luckyGifts = []*roomv1.LuckyGiftDrawResult{luckyGift} drawRecord, buildErr := outbox.Build(flow.cmd.RoomID(), "RoomRobotLuckyGiftDrawn", snapshot.GetVersion(), now, &roomeventsv1.RoomRobotLuckyGiftDrawn{ DrawId: luckyGift.GetDrawId(), CommandId: flow.cmd.ID(), SenderUserId: flow.cmd.ActorUserID(), TargetUserId: flow.cmd.TargetUserID, GiftId: flow.cmd.GiftID, GiftCount: flow.cmd.GiftCount, PoolId: flow.cmd.PoolID, MultiplierPpm: luckyGift.GetMultiplierPpm(), BaseRewardCoins: luckyGift.GetBaseRewardCoins(), EffectiveRewardCoins: luckyGift.GetEffectiveRewardCoins(), CreatedAtMs: luckyGift.GetCreatedAtMs(), VisibleRegionId: snapshot.GetVisibleRegionId(), IsRobot: true, Synthetic: true, }) if buildErr != nil { return nil, buildErr } records = append(records, drawRecord) } } // outbox.Record 在这里仅是复用 protobuf 信封的内存载体;不调用 repository/outboxPublisher, // 只经过采样器后直接交给生产环境的 TencentIMPublisher,因此不会生成任何 RocketMQ 消息。 records = s.sampleRobotDisplayRecords(ctx, records) if len(records) > 0 { // wallet 目录查询最多持续 3 秒;发布前用同一 token 再做 fencing,防止查询期间房间已被另一节点接管。 if err := s.verifyRobotGiftOwner(ctx, flow.cmd.RoomID(), leaseToken); err != nil { return nil, err } } s.publishRobotDisplayRecordsBestEffort(ctx, records) return &roomv1.SendGiftResponse{ Result: commandResult(true, snapshot.GetVersion(), now), RoomHeat: snapshot.GetHeat(), GiftRank: snapshot.GetGiftRank(), Room: snapshot, Rocket: snapshot.GetRocket(), LuckyGift: luckyGift, LuckyGifts: luckyGifts, }, nil } func (s *Service) robotGiftOwnerSnapshot(ctx context.Context, roomID string) (*roomv1.RoomSnapshot, string, error) { if s.directory == nil { // 极简内部测试允许没有 lease 目录;生产 HealthCheck 会拒绝这种装配。 snapshot, err := s.currentSnapshot(ctx, roomID) if err != nil { return nil, "", err } if snapshot == nil { return nil, "", xerr.New(xerr.NotFound, "room not found") } return snapshot, "", nil } roomCell, lease, err := s.ensureCell(ctx, roomID) if err != nil { return nil, "", err } current, _, err := roomCell.Snapshot(ctx) if err != nil { return nil, "", err } return snapshotWithApp(ctx, current.ToProto()), lease.LeaseToken, nil } func (s *Service) verifyRobotGiftOwner(ctx context.Context, roomID string, leaseToken string) error { if s.directory == nil { return nil } owned, err := s.directory.VerifyOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, leaseToken, s.clock.Now()) if err != nil { return err } if !owned { return xerr.New(xerr.Conflict, "room owner lease changed before robot display") } return nil } func (s *Service) robotGiftConfig(ctx context.Context, giftID string, regionID int64) (*walletv1.GiftConfig, error) { key := robotGiftCatalogKey{appCode: appcode.FromContext(ctx), regionID: regionID} for { nowMS := s.clock.Now().UnixMilli() s.robotGiftCatalogMu.Lock() if cached, exists := s.robotGiftCatalog[key]; exists && cached.expiresAtMS > nowMS { s.robotGiftCatalogMu.Unlock() if cached.loadErr != nil { return nil, cached.loadErr } return robotGiftConfigOrNotFound(cached.gifts[giftID]) } if loading := s.robotGiftCatalogLoads[key]; loading != nil { done := loading.done s.robotGiftCatalogMu.Unlock() select { case <-done: // loader 已把成功或短负缓存结果写入 catalog;循环一次统一读取。 continue case <-ctx.Done(): return nil, ctx.Err() } } if s.robotGiftCatalogLoads == nil { s.robotGiftCatalogLoads = make(map[robotGiftCatalogKey]*robotGiftCatalogLoad) } loading := &robotGiftCatalogLoad{done: make(chan struct{})} s.robotGiftCatalogLoads[key] = loading s.robotGiftCatalogMu.Unlock() // loader 脱离首个 caller 的取消信号但仍受 3 秒 RPC timeout;所有调用者都按自己的 ctx 等待同一 done。 go s.completeRobotGiftCatalogLoad(context.WithoutCancel(ctx), key, loading) select { case <-loading.done: continue case <-ctx.Done(): return nil, ctx.Err() } } } func (s *Service) completeRobotGiftCatalogLoad(ctx context.Context, key robotGiftCatalogKey, loading *robotGiftCatalogLoad) { // 网络查询在全局缓存锁外执行;同 key 等待 done,不同 key 可并行请求 wallet。 gifts, loadErr := s.loadRobotGiftCatalog(ctx, key) now := s.clock.Now() expiresAt := now.Add(robotGiftCatalogTTL) if loadErr != nil { expiresAt = now.Add(robotGiftCatalogErrorTTL) } s.robotGiftCatalogMu.Lock() if s.robotGiftCatalog == nil { s.robotGiftCatalog = make(map[robotGiftCatalogKey]robotGiftCatalogEntry) } s.robotGiftCatalog[key] = robotGiftCatalogEntry{expiresAtMS: expiresAt.UnixMilli(), gifts: gifts, loadErr: loadErr} delete(s.robotGiftCatalogLoads, key) close(loading.done) s.pruneRobotGiftCatalogLocked(now.UnixMilli()) s.robotGiftCatalogMu.Unlock() } func (s *Service) pruneRobotGiftCatalogLocked(nowMS int64) { if len(s.robotGiftCatalog) <= 1024 { return } for key, entry := range s.robotGiftCatalog { if entry.expiresAtMS <= nowMS && s.robotGiftCatalogLoads[key] == nil { delete(s.robotGiftCatalog, key) } } } func robotGiftConfigOrNotFound(gift *walletv1.GiftConfig) (*walletv1.GiftConfig, error) { if gift == nil { return nil, xerr.New(xerr.NotFound, "robot gift config is not active in room region") } return gift, nil } func (s *Service) loadRobotGiftCatalog(ctx context.Context, key robotGiftCatalogKey) (map[string]*walletv1.GiftConfig, error) { client, ok := s.wallet.(integration.WalletGiftCatalogClient) if !ok || client == nil { return nil, xerr.New(xerr.Unavailable, "wallet gift catalog is not configured") } queryCtx, cancel := context.WithTimeout(ctx, robotGiftCatalogQueryTimeout) defer cancel() gifts := make(map[string]*walletv1.GiftConfig) var loaded int64 for page := int32(1); page <= robotGiftCatalogMaxPages; page++ { resp, err := client.ListGiftConfigs(queryCtx, &walletv1.ListGiftConfigsRequest{ AppCode: key.appCode, Page: page, PageSize: robotGiftCatalogPageSize, ActiveOnly: true, RegionId: key.regionID, FilterRegion: true, }) if err != nil { return nil, err } for _, gift := range resp.GetGifts() { if id := strings.TrimSpace(gift.GetGiftId()); id != "" { gifts[id] = gift } } loaded += int64(len(resp.GetGifts())) if loaded >= resp.GetTotal() || len(resp.GetGifts()) < int(robotGiftCatalogPageSize) { return gifts, nil } } return nil, xerr.New(xerr.Unavailable, "wallet gift catalog exceeds robot display limit") } func robotGiftDisplayBilling(gift *walletv1.GiftConfig, giftCount int32) (*walletv1.DebitGiftResponse, error) { if gift == nil || giftCount <= 0 { return nil, xerr.New(xerr.InvalidArgument, "robot gift display config is invalid") } coinSpent, err := checkedRobotGiftDisplayValue(gift.GetCoinPrice(), giftCount) if err != nil { return nil, err } resource := gift.GetResource() iconURL := "" animationURL := "" name := strings.TrimSpace(gift.GetName()) if resource != nil { iconURL = strings.TrimSpace(resource.GetPreviewUrl()) if iconURL == "" { iconURL = strings.TrimSpace(resource.GetAssetUrl()) } animationURL = strings.TrimSpace(resource.GetAnimationUrl()) if name == "" { name = strings.TrimSpace(resource.GetName()) } } // 该返回对象只是复用 RoomGiftSent 构造器的展示快照,不包含 receipt/transaction/balance,也没有任何账务含义。 // HeatValue 必须保持 0:Flutter 会把 gift_value 增加到成员和麦位的本地值,纯展示事件不能制造短暂的假贡献。 return &walletv1.DebitGiftResponse{ CoinSpent: coinSpent, ChargeAmount: coinSpent, HeatValue: 0, GiftTypeCode: gift.GetGiftTypeCode(), CpRelationType: gift.GetCpRelationType(), GiftName: name, GiftIconUrl: iconURL, GiftAnimationUrl: animationURL, GiftEffectTypes: append([]string(nil), gift.GetEffectTypes()...), PriceVersion: gift.GetPriceVersion(), }, nil } func checkedRobotGiftDisplayValue(unitValue int64, giftCount int32) (int64, error) { if unitValue < 0 || giftCount <= 0 || unitValue > math.MaxInt64/int64(giftCount) { return 0, xerr.New(xerr.InvalidArgument, "robot gift display value is invalid") } return unitValue * int64(giftCount), nil } func requireRobotGiftParticipants(current *state.RoomState, senderUserID int64, targetUserIDs []int64, runtimeRobotUserIDs []int64, requireRobotRoom bool) error { if current == nil { return xerr.New(xerr.PermissionDenied, "robot gift requires room state") } if requireRobotRoom && (current == nil || current.RoomExt[roomExtRobotRoomKey] != "true") { return xerr.New(xerr.PermissionDenied, "robot gift requires robot room") } allowed := parseInt64CSV(current.RoomExt[roomExtRobotUserIDsKey]) for _, userID := range runtimeRobotUserIDs { if userID > 0 { allowed[userID] = true } } if !allowed[senderUserID] { return xerr.New(xerr.PermissionDenied, "robot sender is not allowed") } for _, targetUserID := range targetUserIDs { if !allowed[targetUserID] { return xerr.New(xerr.PermissionDenied, "robot gift target is not allowed") } } return nil } func normalizeGiftTargetType(raw string) string { raw = strings.TrimSpace(raw) switch raw { case "", "user", "all_mic", "all_room", "couple": return raw default: return raw } }