package service import ( "context" "fmt" "log/slog" "math/rand" "sort" "strings" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/xerr" ) const ( humanRobotLuckyPoolID = "human_robot_lucky_display" humanRobotSuperLuckyPoolID = "human_robot_super_lucky_display" defaultHumanRobotCandidateRoomMaxOnline = int32(5) defaultHumanRobotRoomFullStopOnline = int32(10) defaultHumanRobotRoomTargetMinOnline = int32(8) defaultHumanRobotRoomTargetMaxOnline = int32(10) defaultHumanRobotStayMinMS = int64(60 * 1000) defaultHumanRobotStayMaxMS = int64(10 * 60 * 1000) defaultHumanRobotReplaceMinMS = int64(60 * 1000) defaultHumanRobotReplaceMaxMS = int64(3 * 60 * 1000) defaultHumanRobotNormalGiftIntervalMS = int64(10 * 1000) defaultHumanRobotNormalGiftIntervalMinMS = int64(1 * 1000) defaultHumanRobotNormalGiftIntervalMaxMS = int64(20 * 1000) defaultHumanRobotLuckyPauseMinMS = int64(5 * 1000) defaultHumanRobotLuckyPauseMaxMS = int64(20 * 1000) defaultHumanRobotLuckyComboMin = int64(1) defaultHumanRobotLuckyComboMax = int64(3) defaultHumanRobotMaxGiftSenders = int64(1) ) func (s *Service) AdminGetHumanRoomRobotConfig(ctx context.Context, req *roomv1.AdminGetHumanRoomRobotConfigRequest) (*roomv1.AdminGetHumanRoomRobotConfigResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) config, exists, err := s.repository.GetHumanRoomRobotConfig(ctx) if err != nil { return nil, err } if !exists { config = defaultHumanRoomRobotConfig(appcode.FromContext(ctx), s.clock.Now().UnixMilli()) } return &roomv1.AdminGetHumanRoomRobotConfigResponse{Config: humanRoomRobotConfigToProto(config), ServerTimeMs: s.clock.Now().UnixMilli()}, nil } func (s *Service) AdminUpdateHumanRoomRobotConfig(ctx context.Context, req *roomv1.AdminUpdateHumanRoomRobotConfigRequest) (*roomv1.AdminUpdateHumanRoomRobotConfigResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) config, err := normalizeHumanRoomRobotConfig(appcode.FromContext(ctx), req.GetConfig(), req.GetAdminId(), s.clock.Now().UnixMilli()) if err != nil { return nil, err } saved, err := s.repository.UpsertHumanRoomRobotConfig(ctx, config) if err != nil { return nil, err } if !saved.Enabled { s.stopAllHumanRoomRobotRuntimes() } return &roomv1.AdminUpdateHumanRoomRobotConfigResponse{Config: humanRoomRobotConfigToProto(saved), ServerTimeMs: s.clock.Now().UnixMilli()}, nil } // RunHumanRoomRobotRuntimeManager 定期把各国家空闲机器人补进低人数真人房间。 func (s *Service) RunHumanRoomRobotRuntimeManager(ctx context.Context, interval time.Duration) { if interval <= 0 { interval = 15 * time.Second } logx.Info(ctx, "human_room_robot_runtime_manager_started", slog.Int64("interval_ms", interval.Milliseconds())) ticker := time.NewTicker(interval) defer ticker.Stop() s.scanHumanRoomRobotRuntimes(ctx) for { select { case <-ctx.Done(): s.stopAllHumanRoomRobotRuntimes() return case <-ticker.C: s.scanHumanRoomRobotRuntimes(ctx) } } } func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) { config, exists, err := s.repository.GetHumanRoomRobotConfig(ctx) if err != nil { logx.Warn(ctx, "human_room_robot_config_load_failed", slog.String("error", err.Error())) return } if !exists { return } config = withHumanRoomRobotDefaults(config) if !config.Enabled { s.stopAllHumanRoomRobotRuntimes() return } countryLimits := humanRoomRobotCountryLimits(config) runtimeCounts := s.reconcileHumanRoomRobotRuntimeLimits(config, countryLimits) pools, err := s.loadHumanRoomRobotPools(ctx, config.AppCode) if err != nil { logx.Warn(ctx, "human_room_robot_pool_load_failed", slog.String("error", err.Error())) return } config.CountryPools = pools for _, pool := range config.CountryPools { countryCode := normalizeRoomCountryCode(pool.CountryCode) if countryCode == "" || len(pool.RobotUserIDs) == 0 { continue } if config.CountryLimitEnabled { maxRooms := countryLimits[countryCode] if maxRooms <= 0 || runtimeCounts[countryCode] >= maxRooms { continue } } // 国家行里的房主白名单只收窄当前国家的候选 SQL,不额外读取 Room Cell;旧的全局白名单只作为未配置国家行白名单时的兼容回退。 rooms, err := s.repository.ListHumanRobotCandidateRooms(ctx, config.AppCode, countryCode, config.CandidateRoomMaxOnline, config.RoomTargetMaxOnline, 50, humanRoomRobotAllowedOwnersForCountry(config, countryCode)) if err != nil { logx.Warn(ctx, "human_room_robot_candidate_rooms_failed", slog.String("country_code", countryCode), slog.String("error", err.Error())) continue } for _, room := range rooms { if config.CountryLimitEnabled && runtimeCounts[countryCode] >= countryLimits[countryCode] { break } if s.humanRoomRobotRuntimeConfigChanged(room.RoomID, config.UpdatedAtMS) { s.stopHumanRoomRobotRuntime(room.RoomID) } if s.humanRoomRobotRuntimeExists(room.RoomID) { continue } if s.startHumanRoomRobotRuntime(ctx, config, countryCode, room.RoomID, pool.RobotUserIDs) { runtimeCounts[countryCode]++ } } } } func (s *Service) loadHumanRoomRobotPools(ctx context.Context, appCode string) ([]HumanRoomRobotCountryPool, error) { if s.humanRobotPoolProvider == nil { return nil, xerr.New(xerr.Unavailable, "human room robot pool provider is not configured") } items, err := s.humanRobotPoolProvider.ListHumanRoomRobotPools(ctx, appCode) if err != nil { return nil, err } pools := make([]HumanRoomRobotCountryPool, 0, len(items)) for _, item := range items { countryCode := normalizeRoomCountryCode(item.CountryCode) ids := normalizeRobotUserIDs(item.RobotUserIDs) if countryCode == "" || len(ids) == 0 { continue } pools = append(pools, HumanRoomRobotCountryPool{CountryCode: countryCode, RobotUserIDs: ids}) } return pools, nil } func (s *Service) startHumanRoomRobotRuntime(parent context.Context, config HumanRoomRobotConfig, countryCode string, roomID string, pool []int64) bool { if parent.Err() != nil || roomID == "" || len(pool) == 0 { return false } s.humanRobotRuntimeMu.Lock() if s.humanRobotRuntimes == nil { s.humanRobotRuntimes = make(map[string]robotRoomRuntime) } if _, exists := s.humanRobotRuntimes[roomID]; exists { s.humanRobotRuntimeMu.Unlock() return false } s.humanRobotRuntimeMu.Unlock() roomCtx := appcode.WithContext(parent, config.AppCode) targetOnline := randomHumanRoomTargetOnline(config) selected, err := s.prepareHumanRoomRobots(roomCtx, config, roomID, pool, targetOnline) if err != nil { logx.Warn(roomCtx, "human_room_robot_prepare_failed", slog.String("room_id", roomID), slog.String("country_code", countryCode), slog.String("error", err.Error())) return false } if len(selected) == 0 { return false } s.humanRobotRuntimeMu.Lock() if _, exists := s.humanRobotRuntimes[roomID]; exists { s.humanRobotRuntimeMu.Unlock() return false } ctx, cancel := context.WithCancel(roomCtx) token := fmt.Sprintf("%s:%d", roomID, time.Now().UTC().UnixNano()) s.humanRobotRuntimes[roomID] = robotRoomRuntime{cancel: cancel, token: token, countryCode: countryCode, configUpdatedAtMS: config.UpdatedAtMS} s.humanRobotRuntimeMu.Unlock() logx.Info(ctx, "human_room_robot_runtime_started", slog.String("room_id", roomID), slog.String("country_code", countryCode), slog.Int("target_online", targetOnline), slog.Int("active_robot_count", len(selected)), ) go func() { defer s.clearHumanRoomRobotRuntime(roomID, token) s.runHumanRoomRobotRuntime(ctx, config, roomID, pool, selected, targetOnline) }() return true } func (s *Service) prepareHumanRoomRobots(ctx context.Context, config HumanRoomRobotConfig, roomID string, pool []int64, targetOnline int) ([]int64, error) { snapshot, err := s.currentSnapshot(ctx, roomID) if err != nil { return nil, err } if snapshot.GetStatus() != "active" { return nil, nil } online := make(map[int64]bool, len(snapshot.GetOnlineUsers())) for _, user := range snapshot.GetOnlineUsers() { online[user.GetUserId()] = true } humanOnline := humanRoomRealOnlineCount(snapshot.GetOnlineUsers(), allHumanRoomRobotIDs(config)) if humanOnline >= int(config.CandidateRoomMaxOnline) || len(online) >= targetOnline { return nil, nil } limit := targetOnline - len(online) if limit <= 0 { return nil, nil } freeSeats := freeSeatNumbers(snapshot.GetMicSeats()) if len(freeSeats) < limit { limit = len(freeSeats) } if limit <= 0 { return nil, nil } rng := rand.New(rand.NewSource(time.Now().UnixNano())) candidates := append([]int64(nil), pool...) rng.Shuffle(len(candidates), func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] }) selected := make([]int64, 0, limit) for _, userID := range candidates { if len(selected) >= limit { break } if userID <= 0 || online[userID] || s.robotHasActiveRoomPresence(ctx, userID, roomID) { continue } seatNo := freeSeats[len(selected)] if err := s.joinHumanRoomRobot(ctx, roomID, userID, seatNo, fmt.Sprintf("initial:%d:%d", userID, time.Now().UTC().UnixNano())); err != nil { logx.Warn(ctx, "human_room_robot_initial_join_failed", slog.String("room_id", roomID), slog.Int64("robot_user_id", userID), slog.String("error", err.Error())) continue } selected = append(selected, userID) } return selected, nil } func (s *Service) runHumanRoomRobotRuntime(ctx context.Context, config HumanRoomRobotConfig, roomID string, pool []int64, initialActive []int64, targetOnline int) { activity := newRobotRoomActivity(pool) for _, userID := range initialActive { activity.markActive(userID) } markInitialHumanRoomGiftSenders(activity, initialActive, int(config.MaxGiftSenders)) giftSlots := make(chan struct{}, int(max(1, int(config.MaxGiftSenders)))) for _, userID := range pool { userID := userID go s.runHumanRobotPresenceLoop(ctx, config, roomID, pool, activity, userID, targetOnline) go s.runHumanRobotNormalGiftLoop(ctx, config, roomID, activity, giftSlots, userID) go s.runHumanRobotLuckyGiftLoop(ctx, config, roomID, activity, giftSlots, userID, config.LuckyGiftIDs, humanRobotLuckyPoolID, "lucky", 5_000_000) go s.runHumanRobotLuckyGiftLoop(ctx, config, roomID, activity, giftSlots, userID, config.SuperLuckyGiftIDs, humanRobotSuperLuckyPoolID, "super_lucky", 20_000_000) } go s.runHumanRobotFillLoop(ctx, config, roomID, pool, activity, targetOnline) <-ctx.Done() } func (s *Service) runHumanRobotFillLoop(ctx context.Context, config HumanRoomRobotConfig, roomID string, pool []int64, activity *robotRoomActivity, targetOnline int) { rng := rand.New(rand.NewSource(time.Now().UnixNano())) timer := time.NewTimer(time.Duration(1+rng.Intn(3)) * time.Second) defer timer.Stop() for { select { case <-ctx.Done(): return case <-timer.C: s.fillHumanRoomRobotsOnce(ctx, config, roomID, pool, activity, rng, targetOnline) timer.Reset(time.Duration(5+rng.Intn(5)) * time.Second) } } } func (s *Service) fillHumanRoomRobotsOnce(ctx context.Context, config HumanRoomRobotConfig, roomID string, pool []int64, activity *robotRoomActivity, rng *rand.Rand, targetOnline int) { snapshot, err := s.currentSnapshot(ctx, roomID) if err != nil || snapshot.GetStatus() != "active" { return } humanOnline := humanRoomRealOnlineCount(snapshot.GetOnlineUsers(), allHumanRoomRobotIDs(config)) if humanOnline >= int(config.CandidateRoomMaxOnline) || len(snapshot.GetOnlineUsers()) >= targetOnline { return } limit := targetOnline - len(snapshot.GetOnlineUsers()) freeSeats := freeSeatNumbers(snapshot.GetMicSeats()) if len(freeSeats) < limit { limit = len(freeSeats) } if limit <= 0 { return } online := make(map[int64]bool, len(snapshot.GetOnlineUsers())) for _, user := range snapshot.GetOnlineUsers() { online[user.GetUserId()] = true } candidates := append([]int64(nil), pool...) rng.Shuffle(len(candidates), func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] }) joined := 0 for _, userID := range candidates { if joined >= limit { return } if userID <= 0 || online[userID] || activity.isActive(userID) || s.robotHasActiveRoomPresence(ctx, userID, roomID) { continue } seatNo := freeSeats[joined] if err := s.joinHumanRoomRobot(ctx, roomID, userID, seatNo, fmt.Sprintf("fill:%d:%d", userID, time.Now().UTC().UnixNano())); err != nil { logx.Warn(ctx, "human_room_robot_fill_join_failed", slog.String("room_id", roomID), slog.Int64("robot_user_id", userID), slog.String("error", err.Error())) continue } activity.markActive(userID) activity.markGiftSenderWithinLimit(userID, int(config.MaxGiftSenders)) online[userID] = true joined++ } } func (s *Service) runHumanRobotPresenceLoop(ctx context.Context, config HumanRoomRobotConfig, roomID string, pool []int64, activity *robotRoomActivity, userID int64, targetOnline int) { if userID <= 0 { return } rng := rand.New(rand.NewSource(time.Now().UnixNano() + userID*43)) for { if !activity.isActive(userID) { if !waitRobotRuntimeDelay(ctx, time.Duration(300+rng.Intn(700))*time.Millisecond) { return } continue } stay := time.Duration(randomInt64Range(rng, config.RobotStayMinMS, config.RobotStayMaxMS)) * time.Millisecond if !waitRobotRuntimeDelay(ctx, stay) { return } // 送礼名额绑定在房间内的少数机器人身上;送礼机器人离开时,补位机器人接替这个名额,保证房间内送礼者数量稳定且不会扩散到所有机器人。 wasGiftSender := activity.isGiftSender(userID) if !activity.markInactive(userID) { continue } seatNo := s.humanRoomRobotSeatNo(ctx, roomID, userID) if err := s.leaveHumanRoomRobot(ctx, roomID, userID); err != nil { logx.Warn(ctx, "human_room_robot_leave_failed", slog.String("room_id", roomID), slog.Int64("robot_user_id", userID), slog.String("error", err.Error())) activity.markActive(userID) if wasGiftSender { activity.markGiftSender(userID) } continue } delay := time.Duration(randomInt64Range(rng, config.RobotReplaceMinMS, config.RobotReplaceMaxMS)) * time.Millisecond if !waitRobotRuntimeDelay(ctx, delay) { return } replacementID := s.pickHumanRoomReplacement(ctx, config, roomID, pool, activity, userID) if replacementID <= 0 { continue } if seatNo <= 0 { seatNo = s.firstHumanRoomFreeSeatNo(ctx, roomID) } if seatNo <= 0 || !s.humanRoomNeedsRobot(ctx, config, roomID, targetOnline) { continue } if err := s.joinHumanRoomRobot(ctx, roomID, replacementID, seatNo, fmt.Sprintf("replace:%d:%d", replacementID, time.Now().UTC().UnixNano())); err != nil { logx.Warn(ctx, "human_room_robot_replace_join_failed", slog.String("room_id", roomID), slog.Int64("replacement_user_id", replacementID), slog.String("error", err.Error())) continue } activity.markActive(replacementID) if wasGiftSender || activity.giftSenderCount() < int(config.MaxGiftSenders) { activity.markGiftSenderWithinLimit(replacementID, int(config.MaxGiftSenders)) } } } func (s *Service) runHumanRobotNormalGiftLoop(ctx context.Context, config HumanRoomRobotConfig, roomID string, activity *robotRoomActivity, giftSlots chan struct{}, senderID int64) { if config.NormalGiftIntervalMinMS <= 0 || config.NormalGiftIntervalMaxMS <= 0 || len(config.GiftIDs) == 0 { return } rng := rand.New(rand.NewSource(time.Now().UnixNano() + senderID*59)) timer := time.NewTimer(humanRoomRobotNormalGiftDelay(config, rng)) defer timer.Stop() for { select { case <-ctx.Done(): return case <-timer.C: if activity.isActive(senderID) && activity.isGiftSender(senderID) { targetID := randomRobotTarget(rng, activity.activeIDs(), senderID) giftID := randomString(rng, config.GiftIDs) s.sendHumanRoomRobotGiftBestEffort(ctx, config, roomID, activity, giftSlots, senderID, targetID, giftID, "", "normal", 0) } timer.Reset(humanRoomRobotNormalGiftDelay(config, rng)) } } } func (s *Service) runHumanRobotLuckyGiftLoop(ctx context.Context, config HumanRoomRobotConfig, roomID string, activity *robotRoomActivity, giftSlots chan struct{}, senderID int64, giftIDs []string, poolID string, kind string, multiplierPPM int64) { if len(giftIDs) == 0 || config.LuckyComboMax <= 0 { return } rng := rand.New(rand.NewSource(time.Now().UnixNano() + senderID*71)) for { if !activity.isActive(senderID) || !activity.isGiftSender(senderID) { if !waitRobotRuntimeDelay(ctx, time.Duration(300+rng.Intn(700))*time.Millisecond) { return } continue } combo := randomInt64Range(rng, config.LuckyComboMin, config.LuckyComboMax) for i := int64(0); i < combo; i++ { if !activity.isActive(senderID) || !activity.isGiftSender(senderID) { break } targetID := randomRobotTarget(rng, activity.activeIDs(), senderID) giftID := randomString(rng, giftIDs) s.sendHumanRoomRobotGiftBestEffort(ctx, config, roomID, activity, giftSlots, senderID, targetID, giftID, poolID, kind, multiplierPPM) if !waitRobotGiftPace(ctx, rng) { return } } pause := time.Duration(randomInt64Range(rng, config.LuckyPauseMinMS, config.LuckyPauseMaxMS)) * time.Millisecond if !waitRobotRuntimeDelay(ctx, pause) { return } } } func (s *Service) sendHumanRoomRobotGiftBestEffort(ctx context.Context, config HumanRoomRobotConfig, roomID string, activity *robotRoomActivity, giftSlots chan struct{}, senderID int64, targetID int64, giftID string, poolID string, kind string, multiplierPPM int64) { if !activity.isActive(senderID) || !activity.isGiftSender(senderID) || !activity.isActive(targetID) || giftID == "" { return } select { case giftSlots <- struct{}{}: defer func() { <-giftSlots }() default: return } _, err := s.RobotSendGift(ctx, RobotSendGiftInput{ Meta: humanRoomRobotCommandMeta(config, roomID, senderID, fmt.Sprintf("gift:%s:%d:%d", kind, targetID, time.Now().UTC().UnixNano())), TargetUserID: targetID, GiftID: giftID, GiftCount: 1, PoolID: poolID, RobotUserIDs: activity.pool, SyntheticMultiplierPPM: multiplierPPM, RealRoomHeat: true, }) if err != nil { reconcileHumanRoomRobotGiftPresence(activity, senderID, targetID, err) logx.Warn(ctx, "human_room_robot_send_gift_failed", slog.String("room_id", roomID), slog.String("kind", kind), slog.Int64("sender_user_id", senderID), slog.Int64("target_user_id", targetID), slog.String("gift_id", giftID), slog.String("error", err.Error())) } } func reconcileHumanRoomRobotGiftPresence(activity *robotRoomActivity, senderID int64, targetID int64, err error) { if activity == nil || err == nil || xerr.CodeOf(err) != xerr.NotFound { return } message := xerr.MessageOf(err) switch { case strings.Contains(message, "sender not in room"): // 送礼前不额外读 snapshot,避免把每次礼物尝试都放大成一次 Room Cell/恢复查询; // 直接复用 RobotSendGift 在 Room Cell 内的强一致校验结果,把已离房 sender 从运行时活跃集合移除。 activity.markInactive(senderID) case strings.Contains(message, "target not in room"): // target 已经不在 Room Cell presence 时继续保留 active 会造成后续随机命中反复失败; // 这里只更新本地 human-room 运行时状态,补位仍由 fill/presence 循环按原有节奏执行。 activity.markInactive(targetID) } } func markInitialHumanRoomGiftSenders(activity *robotRoomActivity, initialActive []int64, maxSenders int) { if activity == nil || maxSenders <= 0 || len(initialActive) == 0 { return } rng := rand.New(rand.NewSource(time.Now().UnixNano())) candidates := append([]int64(nil), initialActive...) rng.Shuffle(len(candidates), func(i int, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] }) for _, userID := range candidates { if activity.giftSenderCount() >= maxSenders { return } activity.markGiftSenderWithinLimit(userID, maxSenders) } } func humanRoomRobotNormalGiftDelay(config HumanRoomRobotConfig, rng *rand.Rand) time.Duration { minMS := config.NormalGiftIntervalMinMS maxMS := config.NormalGiftIntervalMaxMS if minMS <= 0 && maxMS <= 0 && config.NormalGiftIntervalMS > 0 { minMS = config.NormalGiftIntervalMS maxMS = config.NormalGiftIntervalMS } if minMS <= 0 { minMS = defaultHumanRobotNormalGiftIntervalMinMS } if maxMS < minMS { maxMS = minMS } return time.Duration(randomInt64Range(rng, minMS, maxMS)) * time.Millisecond } func randomHumanRoomTargetOnline(config HumanRoomRobotConfig) int { minOnline := config.RoomTargetMinOnline maxOnline := config.RoomTargetMaxOnline if minOnline <= 0 && maxOnline <= 0 && config.RoomFullStopOnline > 0 { minOnline = config.RoomFullStopOnline maxOnline = config.RoomFullStopOnline } if minOnline <= 0 { minOnline = defaultHumanRobotRoomTargetMinOnline } if maxOnline < minOnline { maxOnline = minOnline } rng := rand.New(rand.NewSource(time.Now().UnixNano())) return int(randomInt64Range(rng, int64(minOnline), int64(maxOnline))) } func (s *Service) joinHumanRoomRobot(ctx context.Context, roomID string, userID int64, seatNo int32, suffix string) error { if _, err := s.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: humanRoomRobotCommandMeta(HumanRoomRobotConfig{AppCode: appcode.FromContext(ctx)}, roomID, userID, "join:"+suffix), Role: "audience", ActorIsRobot: true, }); err != nil { return err } if _, err := s.RobotVirtualMicUp(ctx, RobotMicUpInput{ Meta: humanRoomRobotCommandMeta(HumanRoomRobotConfig{AppCode: appcode.FromContext(ctx)}, roomID, userID, "mic:"+suffix), TargetUserID: userID, SeatNo: seatNo, }); err != nil { if xerr.CodeOf(err) == xerr.Conflict && strings.Contains(xerr.MessageOf(err), "already on seat") { return nil } return err } return nil } func (s *Service) leaveHumanRoomRobot(ctx context.Context, roomID string, userID int64) error { _, err := s.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{ Meta: humanRoomRobotCommandMeta(HumanRoomRobotConfig{AppCode: appcode.FromContext(ctx)}, roomID, userID, fmt.Sprintf("leave:%d:%d", userID, time.Now().UTC().UnixNano())), }) return err } func (s *Service) pickHumanRoomReplacement(ctx context.Context, config HumanRoomRobotConfig, roomID string, pool []int64, activity *robotRoomActivity, departingUserID int64) int64 { active := make(map[int64]bool, len(activity.activeIDs())) for _, id := range activity.activeIDs() { active[id] = true } rng := rand.New(rand.NewSource(time.Now().UnixNano())) candidates := append([]int64(nil), pool...) rng.Shuffle(len(candidates), func(i, j int) { candidates[i], candidates[j] = candidates[j], candidates[i] }) for _, userID := range candidates { if userID <= 0 || active[userID] || userID == departingUserID || s.robotHasActiveRoomPresence(ctx, userID, roomID) { continue } return userID } if !s.robotHasActiveRoomPresence(ctx, departingUserID, roomID) { return departingUserID } return 0 } func (s *Service) humanRoomNeedsRobot(ctx context.Context, config HumanRoomRobotConfig, roomID string, targetOnline int) bool { snapshot, err := s.currentSnapshot(ctx, roomID) if err != nil || snapshot.GetStatus() != "active" { return false } humanOnline := humanRoomRealOnlineCount(snapshot.GetOnlineUsers(), allHumanRoomRobotIDs(config)) return humanOnline < int(config.CandidateRoomMaxOnline) && len(snapshot.GetOnlineUsers()) < targetOnline } func (s *Service) robotHasActiveRoomPresence(ctx context.Context, userID int64, allowedRoomID string) bool { presence, exists, err := s.repository.GetCurrentRoomPresence(ctx, userID) if err != nil || !exists { return false } return presence.RoomID != "" && presence.RoomID != allowedRoomID } func (s *Service) humanRoomRobotSeatNo(ctx context.Context, roomID string, userID int64) int32 { snapshot, err := s.currentSnapshot(ctx, roomID) if err != nil { return 0 } for _, seat := range snapshot.GetMicSeats() { if seat.GetUserId() == userID { return seat.GetSeatNo() } } return 0 } func (s *Service) firstHumanRoomFreeSeatNo(ctx context.Context, roomID string) int32 { snapshot, err := s.currentSnapshot(ctx, roomID) if err != nil { return 0 } seats := freeSeatNumbers(snapshot.GetMicSeats()) if len(seats) == 0 { return 0 } return seats[0] } func (s *Service) reconcileHumanRoomRobotRuntimeLimits(config HumanRoomRobotConfig, countryLimits map[string]int) map[string]int { counts := make(map[string]int, len(countryLimits)) cancels := make([]context.CancelFunc, 0) s.humanRobotRuntimeMu.Lock() for roomID, runtime := range s.humanRobotRuntimes { countryCode := normalizeRoomCountryCode(runtime.countryCode) // 配置版本变化后直接取消旧 runtime,保证停留、礼物、国家限额等行为不会继续沿用旧快照。 if runtime.configUpdatedAtMS > 0 && config.UpdatedAtMS > 0 && runtime.configUpdatedAtMS != config.UpdatedAtMS { if runtime.cancel != nil { cancels = append(cancels, runtime.cancel) } delete(s.humanRobotRuntimes, roomID) continue } // 国家限额开启后,未配置国家和超过本国家房间上限的 runtime 都必须停掉,避免旧扫描结果继续占用真人房。 if config.CountryLimitEnabled { limit := countryLimits[countryCode] if countryCode == "" || limit <= 0 || counts[countryCode] >= limit { if runtime.cancel != nil { cancels = append(cancels, runtime.cancel) } delete(s.humanRobotRuntimes, roomID) continue } } counts[countryCode]++ } s.humanRobotRuntimeMu.Unlock() for _, cancel := range cancels { cancel() } return counts } func (s *Service) humanRoomRobotRuntimeExists(roomID string) bool { s.humanRobotRuntimeMu.Lock() defer s.humanRobotRuntimeMu.Unlock() _, exists := s.humanRobotRuntimes[roomID] return exists } func (s *Service) humanRoomRobotRuntimeConfigChanged(roomID string, updatedAtMS int64) bool { if updatedAtMS <= 0 { return false } s.humanRobotRuntimeMu.Lock() defer s.humanRobotRuntimeMu.Unlock() runtime, exists := s.humanRobotRuntimes[roomID] return exists && runtime.configUpdatedAtMS > 0 && runtime.configUpdatedAtMS != updatedAtMS } func (s *Service) stopHumanRoomRobotRuntime(roomID string) { s.humanRobotRuntimeMu.Lock() runtime := s.humanRobotRuntimes[roomID] delete(s.humanRobotRuntimes, roomID) s.humanRobotRuntimeMu.Unlock() if runtime.cancel != nil { runtime.cancel() } } func (s *Service) clearHumanRoomRobotRuntime(roomID string, token string) { s.humanRobotRuntimeMu.Lock() defer s.humanRobotRuntimeMu.Unlock() runtime, exists := s.humanRobotRuntimes[roomID] if !exists || runtime.token != token { return } delete(s.humanRobotRuntimes, roomID) } func (s *Service) stopAllHumanRoomRobotRuntimes() { s.humanRobotRuntimeMu.Lock() cancels := make([]context.CancelFunc, 0, len(s.humanRobotRuntimes)) for roomID, runtime := range s.humanRobotRuntimes { if runtime.cancel != nil { cancels = append(cancels, runtime.cancel) } delete(s.humanRobotRuntimes, roomID) } s.humanRobotRuntimeMu.Unlock() for _, cancel := range cancels { cancel() } } func normalizeHumanRoomRobotConfig(appCode string, input *roomv1.AdminHumanRoomRobotConfig, adminID uint64, nowMS int64) (HumanRoomRobotConfig, error) { if input == nil { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "human room robot config is required") } countryRules, err := normalizeHumanRoomRobotCountryRules(input.GetCountryRules()) if err != nil { return HumanRoomRobotConfig{}, err } config := HumanRoomRobotConfig{ AppCode: appcode.Normalize(appCode), Enabled: input.GetEnabled(), CandidateRoomMaxOnline: input.GetCandidateRoomMaxOnline(), RoomFullStopOnline: input.GetRoomFullStopOnline(), RoomTargetMinOnline: input.GetRoomTargetMinOnline(), RoomTargetMaxOnline: input.GetRoomTargetMaxOnline(), RobotStayMinMS: input.GetRobotStayMinMs(), RobotStayMaxMS: input.GetRobotStayMaxMs(), RobotReplaceMinMS: input.GetRobotReplaceMinMs(), RobotReplaceMaxMS: input.GetRobotReplaceMaxMs(), NormalGiftIntervalMS: input.GetNormalGiftIntervalMs(), NormalGiftIntervalMinMS: input.GetNormalGiftIntervalMinMs(), NormalGiftIntervalMaxMS: input.GetNormalGiftIntervalMaxMs(), GiftIDs: normalizeStringSet(input.GetGiftIds()), LuckyGiftIDs: normalizeStringSet(input.GetLuckyGiftIds()), SuperLuckyGiftIDs: normalizeStringSet(input.GetSuperLuckyGiftIds()), LuckyComboMin: input.GetLuckyComboMin(), LuckyComboMax: input.GetLuckyComboMax(), LuckyPauseMinMS: input.GetLuckyPauseMinMs(), LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(), MaxGiftSenders: input.GetMaxGiftSenders(), AllowedOwnerIDs: normalizeStringSet(input.GetAllowedOwnerIds()), CountryLimitEnabled: input.GetCountryLimitEnabled(), CountryRules: countryRules, UpdatedByAdminID: adminID, CreatedAtMS: input.GetCreatedAtMs(), UpdatedAtMS: nowMS, } config = withHumanRoomRobotDefaults(config) if config.CandidateRoomMaxOnline <= 0 || config.RoomTargetMinOnline <= 0 || config.RoomTargetMaxOnline < config.RoomTargetMinOnline { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "room online thresholds are invalid") } if config.RobotStayMinMS <= 0 || config.RobotStayMaxMS < config.RobotStayMinMS { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "robot stay range is invalid") } if config.RobotReplaceMinMS < 0 || config.RobotReplaceMaxMS < config.RobotReplaceMinMS { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "robot replace range is invalid") } if config.NormalGiftIntervalMinMS <= 0 || config.NormalGiftIntervalMaxMS < config.NormalGiftIntervalMinMS { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "normal gift interval range is invalid") } if config.Enabled && len(config.GiftIDs) == 0 { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "gift_ids is required") } if config.Enabled && len(config.LuckyGiftIDs) == 0 && len(config.SuperLuckyGiftIDs) == 0 { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "lucky gift ids are required") } if config.LuckyComboMin <= 0 || config.LuckyComboMax < config.LuckyComboMin { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "lucky combo range is invalid") } if config.LuckyPauseMinMS <= 0 || config.LuckyPauseMaxMS < config.LuckyPauseMinMS { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "lucky pause range is invalid") } if config.MaxGiftSenders <= 0 { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "max_gift_senders is required") } if config.Enabled && config.CountryLimitEnabled && len(config.CountryRules) == 0 { return HumanRoomRobotConfig{}, xerr.New(xerr.InvalidArgument, "country_rules is required") } return config, nil } func withHumanRoomRobotDefaults(config HumanRoomRobotConfig) HumanRoomRobotConfig { if config.AppCode == "" { config.AppCode = appcode.Default } if config.CandidateRoomMaxOnline <= 0 { config.CandidateRoomMaxOnline = defaultHumanRobotCandidateRoomMaxOnline } if config.RoomTargetMinOnline <= 0 && config.RoomTargetMaxOnline <= 0 { if config.RoomFullStopOnline > 0 { config.RoomTargetMinOnline = config.RoomFullStopOnline config.RoomTargetMaxOnline = config.RoomFullStopOnline } else { config.RoomTargetMinOnline = defaultHumanRobotRoomTargetMinOnline config.RoomTargetMaxOnline = defaultHumanRobotRoomTargetMaxOnline } } if config.RoomFullStopOnline <= 0 { config.RoomFullStopOnline = config.RoomTargetMaxOnline } if config.RobotStayMinMS <= 0 && config.RobotStayMaxMS <= 0 { config.RobotStayMinMS = defaultHumanRobotStayMinMS config.RobotStayMaxMS = defaultHumanRobotStayMaxMS } if config.RobotReplaceMinMS == 0 && config.RobotReplaceMaxMS == 0 { config.RobotReplaceMinMS = defaultHumanRobotReplaceMinMS config.RobotReplaceMaxMS = defaultHumanRobotReplaceMaxMS } if config.NormalGiftIntervalMinMS <= 0 && config.NormalGiftIntervalMaxMS <= 0 { if config.NormalGiftIntervalMS > 0 { config.NormalGiftIntervalMinMS = config.NormalGiftIntervalMS config.NormalGiftIntervalMaxMS = config.NormalGiftIntervalMS } else { config.NormalGiftIntervalMinMS = defaultHumanRobotNormalGiftIntervalMinMS config.NormalGiftIntervalMaxMS = defaultHumanRobotNormalGiftIntervalMaxMS } } if config.NormalGiftIntervalMS <= 0 { config.NormalGiftIntervalMS = config.NormalGiftIntervalMaxMS } if config.LuckyComboMin <= 0 && config.LuckyComboMax <= 0 { config.LuckyComboMin = defaultHumanRobotLuckyComboMin config.LuckyComboMax = defaultHumanRobotLuckyComboMax } if config.LuckyPauseMinMS <= 0 && config.LuckyPauseMaxMS <= 0 { config.LuckyPauseMinMS = defaultHumanRobotLuckyPauseMinMS config.LuckyPauseMaxMS = defaultHumanRobotLuckyPauseMaxMS } if config.MaxGiftSenders <= 0 { config.MaxGiftSenders = defaultHumanRobotMaxGiftSenders } return config } func defaultHumanRoomRobotConfig(appCode string, nowMS int64) HumanRoomRobotConfig { return withHumanRoomRobotDefaults(HumanRoomRobotConfig{AppCode: appcode.Normalize(appCode), CreatedAtMS: nowMS, UpdatedAtMS: nowMS}) } func normalizeHumanRoomRobotCountryRules(values []*roomv1.AdminHumanRoomRobotCountryRule) ([]HumanRoomRobotCountryRule, error) { seen := make(map[string]bool, len(values)) rules := make([]HumanRoomRobotCountryRule, 0, len(values)) for _, value := range values { countryCode := normalizeRoomCountryCode(value.GetCountryCode()) if countryCode == "" { return nil, xerr.New(xerr.InvalidArgument, "country_code is invalid") } if seen[countryCode] { return nil, xerr.New(xerr.InvalidArgument, "country_code is duplicated") } if value.GetMaxRoomCount() <= 0 { return nil, xerr.New(xerr.InvalidArgument, "country max_room_count is invalid") } seen[countryCode] = true rules = append(rules, HumanRoomRobotCountryRule{ CountryCode: countryCode, MaxRoomCount: value.GetMaxRoomCount(), AllowedOwnerIDs: normalizeStringSet(value.GetAllowedOwnerIds()), }) } sort.Slice(rules, func(i, j int) bool { return rules[i].CountryCode < rules[j].CountryCode }) return rules, nil } func humanRoomRobotCountryLimits(config HumanRoomRobotConfig) map[string]int { limits := make(map[string]int, len(config.CountryRules)) if !config.CountryLimitEnabled { return limits } for _, rule := range config.CountryRules { countryCode := normalizeRoomCountryCode(rule.CountryCode) if countryCode == "" || rule.MaxRoomCount <= 0 { continue } // 同国家重复规则在保存阶段已被拒绝;这里保留最后一次赋值,保证历史脏数据不会导致 panic。 limits[countryCode] = int(rule.MaxRoomCount) } return limits } func humanRoomRobotAllowedOwnersForCountry(config HumanRoomRobotConfig, countryCode string) []string { countryCode = normalizeRoomCountryCode(countryCode) for _, rule := range config.CountryRules { if normalizeRoomCountryCode(rule.CountryCode) != countryCode { continue } // 国家规则里填写房主 ID 时,它是当前国家的精确白名单;为空时允许继续使用历史全局白名单。 if len(rule.AllowedOwnerIDs) > 0 { return append([]string(nil), rule.AllowedOwnerIDs...) } return append([]string(nil), config.AllowedOwnerIDs...) } return append([]string(nil), config.AllowedOwnerIDs...) } func humanRoomRobotConfigToProto(config HumanRoomRobotConfig) *roomv1.AdminHumanRoomRobotConfig { config = withHumanRoomRobotDefaults(config) out := &roomv1.AdminHumanRoomRobotConfig{ AppCode: config.AppCode, Enabled: config.Enabled, CandidateRoomMaxOnline: config.CandidateRoomMaxOnline, RoomFullStopOnline: config.RoomFullStopOnline, RoomTargetMinOnline: config.RoomTargetMinOnline, RoomTargetMaxOnline: config.RoomTargetMaxOnline, RobotStayMinMs: config.RobotStayMinMS, RobotStayMaxMs: config.RobotStayMaxMS, RobotReplaceMinMs: config.RobotReplaceMinMS, RobotReplaceMaxMs: config.RobotReplaceMaxMS, NormalGiftIntervalMs: config.NormalGiftIntervalMS, NormalGiftIntervalMinMs: config.NormalGiftIntervalMinMS, NormalGiftIntervalMaxMs: config.NormalGiftIntervalMaxMS, GiftIds: append([]string(nil), config.GiftIDs...), LuckyGiftIds: append([]string(nil), config.LuckyGiftIDs...), SuperLuckyGiftIds: append([]string(nil), config.SuperLuckyGiftIDs...), LuckyComboMin: config.LuckyComboMin, LuckyComboMax: config.LuckyComboMax, LuckyPauseMinMs: config.LuckyPauseMinMS, LuckyPauseMaxMs: config.LuckyPauseMaxMS, MaxGiftSenders: config.MaxGiftSenders, AllowedOwnerIds: append([]string(nil), config.AllowedOwnerIDs...), CountryLimitEnabled: config.CountryLimitEnabled, UpdatedByAdminId: config.UpdatedByAdminID, CreatedAtMs: config.CreatedAtMS, UpdatedAtMs: config.UpdatedAtMS, } for _, rule := range config.CountryRules { out.CountryRules = append(out.CountryRules, &roomv1.AdminHumanRoomRobotCountryRule{ CountryCode: rule.CountryCode, MaxRoomCount: rule.MaxRoomCount, AllowedOwnerIds: append([]string(nil), rule.AllowedOwnerIDs...), }) } return out } func humanRoomRobotCommandMeta(config HumanRoomRobotConfig, roomID string, actorUserID int64, suffix string) *roomv1.RequestMeta { commandID := fmt.Sprintf("human-room-robot:%s:%s", roomID, strings.TrimSpace(suffix)) if len(commandID) > 128 { commandID = commandID[:128] } return &roomv1.RequestMeta{ RequestId: commandID, CommandId: commandID, ActorUserId: actorUserID, RoomId: roomID, AppCode: config.AppCode, SentAtMs: time.Now().UTC().UnixMilli(), } } func humanRoomRealOnlineCount(users []*roomv1.RoomUser, robotIDs map[int64]bool) int { count := 0 for _, user := range users { if user.GetUserId() <= 0 || robotIDs[user.GetUserId()] { continue } count++ } return count } func allHumanRoomRobotIDs(config HumanRoomRobotConfig) map[int64]bool { out := make(map[int64]bool) for _, pool := range config.CountryPools { for _, id := range pool.RobotUserIDs { if id > 0 { out[id] = true } } } return out } func freeSeatNumbers(seats []*roomv1.SeatState) []int32 { out := make([]int32, 0, len(seats)) for _, seat := range seats { if seat.GetSeatNo() > 0 && seat.GetUserId() == 0 && !seat.GetLocked() { out = append(out, seat.GetSeatNo()) } } return out }