2026-06-18 22:41:52 +08:00

965 lines
37 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 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
}
}
// 限定房主 ID 只影响本轮候选房 SQL不额外读取 Room Cell 或 presence为空时保持原来的全房间扫描语义。
rooms, err := s.repository.ListHumanRobotCandidateRooms(ctx, config.AppCode, countryCode, config.CandidateRoomMaxOnline, config.RoomTargetMaxOnline, 50, config.AllowedOwnerIDs)
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",
}); 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()})
}
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 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,
})
}
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
}