package service import ( "context" "fmt" "log/slog" "math/rand" "slices" "strings" "time" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/roomid" "hyapp/pkg/xerr" ) const ( robotRoomStatusActive = "active" robotRoomStatusStopped = "stopped" robotRoomMode = "voice" robotRoomLuckyPoolID = "robot_lucky_display" ) func (s *Service) AdminListRobotRooms(ctx context.Context, req *roomv1.AdminListRobotRoomsRequest) (*roomv1.AdminListRobotRoomsResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) items, total, err := s.repository.ListRobotRooms(ctx, RobotRoomListQuery{ AppCode: appcode.FromContext(ctx), Status: strings.TrimSpace(req.GetStatus()), Page: int(req.GetPage()), PageSize: int(req.GetPageSize()), }) if err != nil { return nil, err } resp := &roomv1.AdminListRobotRoomsResponse{Total: total, ServerTimeMs: s.clock.Now().UnixMilli()} for _, item := range items { resp.Rooms = append(resp.Rooms, robotRoomConfigToProto(item)) } return resp, nil } func (s *Service) AdminFilterAvailableRoomRobots(ctx context.Context, req *roomv1.AdminFilterAvailableRoomRobotsRequest) (*roomv1.AdminFilterAvailableRoomRobotsResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) ids := normalizeRobotUserIDs(req.GetUserIds()) occupied, err := s.repository.OccupiedRobotUserIDs(ctx, ids) if err != nil { return nil, err } resp := &roomv1.AdminFilterAvailableRoomRobotsResponse{ServerTimeMs: s.clock.Now().UnixMilli()} for _, userID := range ids { if occupied[userID] { resp.OccupiedUserIds = append(resp.OccupiedUserIds, userID) continue } resp.AvailableUserIds = append(resp.AvailableUserIds, userID) } slices.Sort(resp.AvailableUserIds) slices.Sort(resp.OccupiedUserIds) return resp, nil } func (s *Service) AdminCreateRobotRoom(ctx context.Context, req *roomv1.AdminCreateRobotRoomRequest) (*roomv1.AdminCreateRobotRoomResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) now := s.clock.Now() config, err := s.buildRobotRoomConfig(ctx, req, now) if err != nil { return nil, err } if err := s.bootstrapRobotRoom(ctx, config); err != nil { logx.Error(ctx, "admin_robot_room_bootstrap_failed", err, slog.String("room_id", config.RoomID), slog.Int64("owner_robot_user_id", config.OwnerRobotUserID), slog.Int("robot_count", len(config.RobotUserIDs)), ) return nil, err } saved, err := s.repository.CreateRobotRoomConfig(ctx, CreateRobotRoomConfigInput{Config: config, NowMS: now.UnixMilli()}) if err != nil { logx.Error(ctx, "admin_robot_room_config_save_failed", err, slog.String("room_id", config.RoomID), slog.Int64("owner_robot_user_id", config.OwnerRobotUserID), slog.Int("robot_count", len(config.RobotUserIDs)), ) return nil, err } s.startRobotRoomRuntime(ctx, saved) return &roomv1.AdminCreateRobotRoomResponse{Room: robotRoomConfigToProto(saved), ServerTimeMs: now.UnixMilli()}, nil } func (s *Service) AdminSetRobotRoomStatus(ctx context.Context, req *roomv1.AdminSetRobotRoomStatusRequest) (*roomv1.AdminSetRobotRoomStatusResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) roomID := strings.TrimSpace(req.GetMeta().GetRoomId()) status := normalizeRobotRoomStatus(req.GetStatus()) if roomID == "" { return nil, xerr.New(xerr.InvalidArgument, "room_id is required") } if status == "" { return nil, xerr.New(xerr.InvalidArgument, "robot room status is invalid") } config, exists, err := s.repository.UpdateRobotRoomStatus(ctx, roomID, status, s.clock.Now().UnixMilli()) if err != nil { return nil, err } if !exists { return nil, xerr.New(xerr.NotFound, "robot room not found") } if status == robotRoomStatusActive { s.startRobotRoomRuntime(ctx, config) } else { s.stopRobotRoomRuntime(roomID) } return &roomv1.AdminSetRobotRoomStatusResponse{Room: robotRoomConfigToProto(config), ServerTimeMs: s.clock.Now().UnixMilli()}, nil } func (s *Service) buildRobotRoomConfig(ctx context.Context, req *roomv1.AdminCreateRobotRoomRequest, now time.Time) (RobotRoomConfig, error) { ownerID := req.GetOwnerRobotUserId() if ownerID <= 0 { return RobotRoomConfig{}, xerr.New(xerr.InvalidArgument, "owner_robot_user_id is required") } candidates := normalizeRobotUserIDs(req.GetCandidateRobotUserIds()) containsOwner := false for _, userID := range candidates { if userID == ownerID { containsOwner = true break } } if !containsOwner { candidates = append(candidates, ownerID) } occupied, err := s.repository.OccupiedRobotUserIDs(ctx, candidates) if err != nil { return RobotRoomConfig{}, err } if occupied[ownerID] { return RobotRoomConfig{}, xerr.New(xerr.Conflict, "owner robot already assigned to active robot room") } available := make([]int64, 0, len(candidates)) for _, userID := range candidates { if !occupied[userID] { available = append(available, userID) } } minCount, maxCount := normalizeRobotCountRange(req.GetMinRobotCount(), req.GetMaxRobotCount()) if len(available) < minCount { return RobotRoomConfig{}, xerr.New(xerr.Conflict, "available robot count is not enough") } rng := rand.New(rand.NewSource(now.UnixNano())) selectedCount := randomIntRange(rng, minCount, maxCount) if selectedCount > len(available) { selectedCount = len(available) } selected := selectRobotUsers(rng, ownerID, available, selectedCount) rule, err := normalizeRobotRoomGiftRule(req.GetGiftRule()) if err != nil { return RobotRoomConfig{}, err } roomID := fmt.Sprintf("robot_%d_%d", now.UnixMilli(), rng.Intn(900000)+100000) if !roomid.ValidStringID(roomID) { return RobotRoomConfig{}, xerr.New(xerr.Internal, "generated robot room id is invalid") } title := strings.TrimSpace(req.GetRoomName()) if title == "" { title = fmt.Sprintf("Robot Room %d", ownerID) } coverURL := strings.TrimSpace(req.GetRoomAvatar()) if coverURL == "" { coverURL = defaultRoomAvatar } return RobotRoomConfig{ AppCode: appcode.FromContext(ctx), RoomID: roomID, RoomShortID: fmt.Sprintf("%d", ownerID), Title: title, CoverURL: coverURL, VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()), Status: robotRoomStatusActive, OwnerRobotUserID: ownerID, RobotUserIDs: selected, GiftRule: rule, CreatedByAdminID: req.GetAdminId(), CreatedAtMS: now.UnixMilli(), UpdatedAtMS: now.UnixMilli(), }, nil } func (s *Service) bootstrapRobotRoom(ctx context.Context, config RobotRoomConfig) error { _, err := s.CreateRoom(ctx, &roomv1.CreateRoomRequest{ Meta: &roomv1.RequestMeta{ RequestId: fmt.Sprintf("admin-robot-room-create-%s", config.RoomID), CommandId: fmt.Sprintf("admin-robot-room:%s:create", config.RoomID), ActorUserId: config.OwnerRobotUserID, RoomId: config.RoomID, AppCode: config.AppCode, SentAtMs: s.clock.Now().UnixMilli(), }, SeatCount: int32(max(10, len(config.RobotUserIDs))), Mode: robotRoomMode, VisibleRegionId: config.VisibleRegionID, RoomName: config.Title, RoomAvatar: config.CoverURL, RoomShortId: config.RoomShortID, RobotRoom: true, RobotUserIds: config.RobotUserIDs, }) if err != nil { return err } for _, userID := range config.RobotUserIDs { if userID == config.OwnerRobotUserID { continue } if _, err := s.JoinRoom(ctx, &roomv1.JoinRoomRequest{ Meta: robotRoomCommandMeta(config, userID, fmt.Sprintf("join:%d", userID)), Role: "audience", }); err != nil { return err } } for index, userID := range config.RobotUserIDs { if _, err := s.RobotVirtualMicUp(ctx, RobotMicUpInput{ Meta: robotRoomCommandMeta(config, config.OwnerRobotUserID, fmt.Sprintf("mic:%d", userID)), TargetUserID: userID, SeatNo: int32(index + 1), }); err != nil { return err } } return nil } // RunRobotRoomRuntimeManager 恢复 active 机器人房间的进程内送礼循环;配置事实仍以 MySQL 为准。 func (s *Service) RunRobotRoomRuntimeManager(ctx context.Context, interval time.Duration) { if interval <= 0 { interval = 10 * time.Second } ticker := time.NewTicker(interval) defer ticker.Stop() s.startActiveRobotRooms(ctx) for { select { case <-ctx.Done(): s.stopAllRobotRoomRuntimes() return case <-ticker.C: s.startActiveRobotRooms(ctx) } } } func (s *Service) startActiveRobotRooms(ctx context.Context) { configs, err := s.repository.ListActiveRobotRooms(ctx) if err != nil { logx.Warn(ctx, "robot_room_runtime_scan_failed", slog.String("error", err.Error())) return } for _, config := range configs { s.startRobotRoomRuntime(ctx, config) } } func (s *Service) startRobotRoomRuntime(parent context.Context, config RobotRoomConfig) { if config.Status != robotRoomStatusActive || len(config.RobotUserIDs) < 2 { return } s.robotRuntimeMu.Lock() if s.robotRuntimes == nil { s.robotRuntimes = make(map[string]context.CancelFunc) } if _, exists := s.robotRuntimes[config.RoomID]; exists { s.robotRuntimeMu.Unlock() return } ctx, cancel := context.WithCancel(appcode.WithContext(parent, config.AppCode)) s.robotRuntimes[config.RoomID] = cancel s.robotRuntimeMu.Unlock() go s.runRobotRoomRuntime(ctx, config) } func (s *Service) stopRobotRoomRuntime(roomID string) { s.robotRuntimeMu.Lock() cancel := s.robotRuntimes[roomID] delete(s.robotRuntimes, roomID) s.robotRuntimeMu.Unlock() if cancel != nil { cancel() } } func (s *Service) stopAllRobotRoomRuntimes() { s.robotRuntimeMu.Lock() cancels := make([]context.CancelFunc, 0, len(s.robotRuntimes)) for roomID, cancel := range s.robotRuntimes { cancels = append(cancels, cancel) delete(s.robotRuntimes, roomID) } s.robotRuntimeMu.Unlock() for _, cancel := range cancels { cancel() } } func (s *Service) runRobotRoomRuntime(ctx context.Context, config RobotRoomConfig) { for _, senderID := range config.RobotUserIDs { senderID := senderID go s.runRobotNormalGiftLoop(ctx, config, senderID) go s.runRobotLuckyGiftLoop(ctx, config, senderID) } <-ctx.Done() } func (s *Service) runRobotNormalGiftLoop(ctx context.Context, config RobotRoomConfig, senderID int64) { interval := time.Duration(config.GiftRule.NormalGiftIntervalMS) * time.Millisecond if interval <= 0 || len(config.GiftRule.GiftIDs) == 0 { return } rng := rand.New(rand.NewSource(time.Now().UnixNano() + senderID)) timer := time.NewTimer(randomJitter(interval, rng)) defer timer.Stop() for { select { case <-ctx.Done(): return case <-timer.C: targetID := randomRobotTarget(rng, config.RobotUserIDs, senderID) giftID := randomString(rng, config.GiftRule.GiftIDs) if targetID > 0 && giftID != "" { s.sendRobotGiftBestEffort(ctx, config, senderID, targetID, giftID, "", "normal") } timer.Reset(interval) } } } func (s *Service) runRobotLuckyGiftLoop(ctx context.Context, config RobotRoomConfig, senderID int64) { if len(config.GiftRule.LuckyGiftIDs) == 0 || config.GiftRule.LuckyComboMax <= 0 { return } rng := rand.New(rand.NewSource(time.Now().UnixNano() + senderID*17)) for { combo := randomInt64Range(rng, config.GiftRule.LuckyComboMin, config.GiftRule.LuckyComboMax) for i := int64(0); i < combo; i++ { giftID := randomString(rng, config.GiftRule.LuckyGiftIDs) for _, targetID := range config.RobotUserIDs { if targetID == senderID { continue } if err := ctx.Err(); err != nil { return } s.sendRobotGiftBestEffort(ctx, config, senderID, targetID, giftID, robotRoomLuckyPoolID, "lucky") } } pause := time.Duration(randomInt64Range(rng, config.GiftRule.LuckyPauseMinMS, config.GiftRule.LuckyPauseMaxMS)) * time.Millisecond if pause <= 0 { pause = 5 * time.Second } timer := time.NewTimer(pause) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } } } func (s *Service) sendRobotGiftBestEffort(ctx context.Context, config RobotRoomConfig, senderID int64, targetID int64, giftID string, poolID string, kind string) { _, err := s.RobotSendGift(ctx, RobotSendGiftInput{ Meta: robotRoomCommandMeta(config, senderID, fmt.Sprintf("gift:%s:%d:%d", kind, targetID, time.Now().UTC().UnixNano())), TargetUserID: targetID, GiftID: giftID, GiftCount: 1, PoolID: poolID, RobotUserIDs: config.RobotUserIDs, SyntheticRewardCoins: 0, SyntheticMultiplierPPM: 0, }) if err != nil { logx.Warn(ctx, "robot_room_send_gift_failed", slog.String("room_id", config.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 robotRoomCommandMeta(config RobotRoomConfig, actorUserID int64, suffix string) *roomv1.RequestMeta { commandID := fmt.Sprintf("robot-room:%s:%s", config.RoomID, strings.TrimSpace(suffix)) if len(commandID) > 128 { commandID = commandID[:128] } return &roomv1.RequestMeta{ RequestId: commandID, CommandId: commandID, ActorUserId: actorUserID, RoomId: config.RoomID, AppCode: config.AppCode, SentAtMs: time.Now().UTC().UnixMilli(), } } func normalizeRobotRoomGiftRule(input *roomv1.AdminRobotRoomGiftRule) (RobotRoomGiftRule, error) { if input == nil { return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "gift_rule is required") } rule := RobotRoomGiftRule{ GiftIDs: normalizeStringSet(input.GetGiftIds()), LuckyGiftIDs: normalizeStringSet(input.GetLuckyGiftIds()), NormalGiftIntervalMS: input.GetNormalGiftIntervalMs(), LuckyComboMin: input.GetLuckyComboMin(), LuckyComboMax: input.GetLuckyComboMax(), LuckyPauseMinMS: input.GetLuckyPauseMinMs(), LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(), } if len(rule.GiftIDs) == 0 { return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "gift_ids is required") } if len(rule.LuckyGiftIDs) == 0 { return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "lucky_gift_ids is required") } if rule.NormalGiftIntervalMS <= 0 { return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "normal_gift_interval_ms is required") } if rule.LuckyComboMin <= 0 || rule.LuckyComboMax < rule.LuckyComboMin { return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "lucky combo range is invalid") } if rule.LuckyPauseMinMS <= 0 || rule.LuckyPauseMaxMS < rule.LuckyPauseMinMS { return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "lucky pause range is invalid") } return rule, nil } func robotRoomConfigToProto(config RobotRoomConfig) *roomv1.AdminRobotRoom { return &roomv1.AdminRobotRoom{ AppCode: config.AppCode, RoomId: config.RoomID, RoomShortId: config.RoomShortID, Title: config.Title, CoverUrl: config.CoverURL, VisibleRegionId: config.VisibleRegionID, Status: config.Status, OwnerRobotUserId: config.OwnerRobotUserID, RobotUserIds: append([]int64(nil), config.RobotUserIDs...), GiftRule: &roomv1.AdminRobotRoomGiftRule{ GiftIds: append([]string(nil), config.GiftRule.GiftIDs...), LuckyGiftIds: append([]string(nil), config.GiftRule.LuckyGiftIDs...), NormalGiftIntervalMs: config.GiftRule.NormalGiftIntervalMS, LuckyComboMin: config.GiftRule.LuckyComboMin, LuckyComboMax: config.GiftRule.LuckyComboMax, LuckyPauseMinMs: config.GiftRule.LuckyPauseMinMS, LuckyPauseMaxMs: config.GiftRule.LuckyPauseMaxMS, }, CreatedByAdminId: config.CreatedByAdminID, CreatedAtMs: config.CreatedAtMS, UpdatedAtMs: config.UpdatedAtMS, } } func normalizeRobotCountRange(minCount int32, maxCount int32) (int, int) { minValue := int(minCount) maxValue := int(maxCount) if minValue <= 0 { minValue = 6 } if maxValue <= 0 { maxValue = 8 } if maxValue < minValue { maxValue = minValue } return minValue, maxValue } func selectRobotUsers(rng *rand.Rand, ownerID int64, available []int64, count int) []int64 { others := make([]int64, 0, len(available)) for _, userID := range available { if userID != ownerID { others = append(others, userID) } } rng.Shuffle(len(others), func(i, j int) { others[i], others[j] = others[j], others[i] }) selected := []int64{ownerID} for _, userID := range others { if len(selected) >= count { break } selected = append(selected, userID) } slices.Sort(selected) return selected } func normalizeRobotRoomStatus(status string) string { switch strings.TrimSpace(status) { case robotRoomStatusActive: return robotRoomStatusActive case robotRoomStatusStopped: return robotRoomStatusStopped default: return "" } } func normalizeStringSet(values []string) []string { seen := make(map[string]bool, len(values)) out := make([]string, 0, len(values)) for _, value := range values { value = strings.TrimSpace(value) if value == "" || seen[value] { continue } seen[value] = true out = append(out, value) } slices.Sort(out) return out } func int64SetKeys(values map[int64]bool) []int64 { out := make([]int64, 0, len(values)) for value := range values { out = append(out, value) } slices.Sort(out) return out } func randomString(rng *rand.Rand, values []string) string { if len(values) == 0 { return "" } return values[rng.Intn(len(values))] } func randomRobotTarget(rng *rand.Rand, robots []int64, senderID int64) int64 { targets := make([]int64, 0, len(robots)) for _, userID := range robots { if userID != senderID { targets = append(targets, userID) } } if len(targets) == 0 { return 0 } return targets[rng.Intn(len(targets))] } func randomIntRange(rng *rand.Rand, minValue int, maxValue int) int { if maxValue <= minValue { return minValue } return minValue + rng.Intn(maxValue-minValue+1) } func randomInt64Range(rng *rand.Rand, minValue int64, maxValue int64) int64 { if maxValue <= minValue { return minValue } return minValue + rng.Int63n(maxValue-minValue+1) } func randomJitter(interval time.Duration, rng *rand.Rand) time.Duration { if interval <= time.Second { return interval } return time.Duration(rng.Int63n(int64(interval))) + time.Second }