2026-06-25 11:21:16 +08:00

1174 lines
36 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"
"slices"
"strings"
"sync"
"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"
maxRobotLuckyComboPerCycle = int64(3)
defaultRobotStayMinMS = int64(3 * 60 * 1000)
defaultRobotStayMaxMS = int64(10 * 60 * 1000)
defaultRobotReplaceMinMS = int64(0)
defaultRobotReplaceMaxMS = int64(60 * 1000)
defaultMaxGiftSenders = int64(1)
defaultRobotRoomSeatCount = int32(10)
)
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)
}
robotPool := selectRobotUsers(rng, ownerID, available, len(available))
rule, err := normalizeRobotRoomGiftRule(req.GetGiftRule())
if err != nil {
return RobotRoomConfig{}, err
}
seatCount := normalizeRobotRoomSeatCount(req.GetSeatCount())
if seatCount <= 0 {
return RobotRoomConfig{}, xerr.New(xerr.InvalidArgument, "robot room seat_count is invalid")
}
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()),
OwnerCountryCode: normalizeRoomCountryCode(req.GetOwnerCountryCode()),
Status: robotRoomStatusActive,
OwnerRobotUserID: ownerID,
RobotUserIDs: robotPool,
ActiveRobotCount: int32(selectedCount),
SeatCount: seatCount,
GiftRule: rule,
CreatedByAdminID: req.GetAdminId(),
CreatedAtMS: now.UnixMilli(),
UpdatedAtMS: now.UnixMilli(),
}, nil
}
func (s *Service) bootstrapRobotRoom(ctx context.Context, config RobotRoomConfig) error {
activeIDs := robotRoomInitialActiveIDs(config)
_, 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: config.SeatCount,
Mode: robotRoomMode,
VisibleRegionId: config.VisibleRegionID,
OwnerCountryCode: config.OwnerCountryCode,
RoomName: config.Title,
RoomAvatar: config.CoverURL,
RoomShortId: config.RoomShortID,
RobotRoom: true,
RobotUserIds: config.RobotUserIDs,
})
if err != nil {
return err
}
for _, userID := range activeIDs {
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 activeIDs {
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
}
logx.Info(ctx, "robot_room_runtime_manager_started", slog.Int64("interval_ms", interval.Milliseconds()))
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
}
logx.Info(ctx, "robot_room_runtime_scan_completed", slog.Int("active_room_count", len(configs)))
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 {
logx.Info(parent, "robot_room_runtime_start_skipped",
slog.String("room_id", config.RoomID),
slog.String("status", config.Status),
slog.Int("robot_count", len(config.RobotUserIDs)),
)
return
}
if err := parent.Err(); err != nil {
// 管理端创建/启停请求的 ctx 会随 HTTP/gRPC 请求结束而取消;已取消的 ctx 不能写入
// runtime map否则后台恢复扫描会误以为该房间仍在运行。
logx.Warn(parent, "robot_room_runtime_parent_cancelled", slog.String("room_id", config.RoomID), slog.String("error", err.Error()))
return
}
s.robotRuntimeMu.Lock()
if s.robotRuntimes == nil {
s.robotRuntimes = make(map[string]robotRoomRuntime)
}
if _, exists := s.robotRuntimes[config.RoomID]; exists {
s.robotRuntimeMu.Unlock()
logx.Info(parent, "robot_room_runtime_start_skipped",
slog.String("room_id", config.RoomID),
slog.String("status", config.Status),
slog.Int("robot_count", len(config.RobotUserIDs)),
slog.String("reason", "already_running"),
)
return
}
s.robotRuntimeMu.Unlock()
if err := s.ensureRobotRoomParticipants(parent, config); err != nil {
logx.Warn(parent, "robot_room_runtime_prepare_failed", slog.String("room_id", config.RoomID), slog.String("error", err.Error()))
return
}
s.robotRuntimeMu.Lock()
if _, exists := s.robotRuntimes[config.RoomID]; exists {
s.robotRuntimeMu.Unlock()
return
}
ctx, cancel := context.WithCancel(appcode.WithContext(parent, config.AppCode))
token := fmt.Sprintf("%s:%d", config.RoomID, time.Now().UTC().UnixNano())
s.robotRuntimes[config.RoomID] = robotRoomRuntime{cancel: cancel, token: token}
s.robotRuntimeMu.Unlock()
logx.Info(ctx, "robot_room_runtime_started",
slog.String("room_id", config.RoomID),
slog.Int64("owner_robot_user_id", config.OwnerRobotUserID),
slog.Int("robot_count", len(config.RobotUserIDs)),
slog.Int("normal_gift_count", len(config.GiftRule.GiftIDs)),
slog.Int("lucky_gift_count", len(config.GiftRule.LuckyGiftIDs)),
)
go func() {
defer s.clearRobotRoomRuntime(config.RoomID, token)
s.runRobotRoomRuntime(ctx, config)
}()
}
func (s *Service) stopRobotRoomRuntime(roomID string) {
s.robotRuntimeMu.Lock()
runtime := s.robotRuntimes[roomID]
delete(s.robotRuntimes, roomID)
s.robotRuntimeMu.Unlock()
if runtime.cancel != nil {
runtime.cancel()
}
}
func (s *Service) stopAllRobotRoomRuntimes() {
s.robotRuntimeMu.Lock()
cancels := make([]context.CancelFunc, 0, len(s.robotRuntimes))
for roomID, runtime := range s.robotRuntimes {
if runtime.cancel != nil {
cancels = append(cancels, runtime.cancel)
}
delete(s.robotRuntimes, roomID)
}
s.robotRuntimeMu.Unlock()
for _, cancel := range cancels {
cancel()
}
}
func (s *Service) clearRobotRoomRuntime(roomID string, token string) {
s.robotRuntimeMu.Lock()
defer s.robotRuntimeMu.Unlock()
runtime, exists := s.robotRuntimes[roomID]
if !exists || runtime.token != token {
return
}
delete(s.robotRuntimes, roomID)
}
func (s *Service) ensureRobotRoomParticipants(ctx context.Context, config RobotRoomConfig) error {
roomCtx := appcode.WithContext(ctx, config.AppCode)
snapshot, err := s.currentSnapshot(roomCtx, config.RoomID)
if err != nil {
return err
}
online := make(map[int64]bool, len(snapshot.GetOnlineUsers()))
for _, user := range snapshot.GetOnlineUsers() {
online[user.GetUserId()] = true
}
activeTarget := robotRoomActiveTarget(config)
activeRobots := onlineRobotIDs(config.RobotUserIDs, online)
missing := activeTarget - len(activeRobots)
if missing > 0 {
for _, userID := range robotRoomFillCandidates(config, online, missing) {
// active 机器人房从持久化配置恢复时,旧进程可能已经把机器人 stale 成 left
// 这里只补齐后台配置的活跃人数,不再把整个候选池一次性塞回房间。
if err := s.joinRobotRoomUser(roomCtx, config, userID, 0, fmt.Sprintf("runtime-join:%d:%d", userID, time.Now().UTC().UnixNano())); err != nil {
return err
}
online[userID] = true
activeRobots = append(activeRobots, userID)
}
}
snapshot, err = s.currentSnapshot(roomCtx, config.RoomID)
if err != nil {
return err
}
seated := make(map[int64]bool, len(config.RobotUserIDs))
occupiedSeats := make(map[int32]bool, len(snapshot.GetMicSeats()))
for _, seat := range snapshot.GetMicSeats() {
userID := seat.GetUserId()
if userID > 0 {
seated[userID] = true
occupiedSeats[seat.GetSeatNo()] = true
}
}
for _, userID := range activeRobots {
if userID <= 0 || seated[userID] {
continue
}
seatNo := preferredRobotSeatNo(config, userID)
if occupiedSeats[seatNo] {
seatNo = firstFreeRobotSeatNo(snapshot.GetMicSeats(), activeTarget)
if seatNo <= 0 {
logx.Warn(roomCtx, "robot_room_runtime_mic_restore_skipped", slog.String("room_id", config.RoomID), slog.Int64("robot_user_id", userID), slog.String("reason", "no_free_seat"))
continue
}
}
// 虚拟麦位只服务机器人房展示若并发恢复导致机器人已上麦Conflict 可视为达成目标。
if _, err := s.RobotVirtualMicUp(roomCtx, RobotMicUpInput{
Meta: robotRoomCommandMeta(config, config.OwnerRobotUserID, fmt.Sprintf("runtime-mic:%d:%d", userID, time.Now().UTC().UnixNano())),
TargetUserID: userID,
SeatNo: seatNo,
}); err != nil {
if xerr.CodeOf(err) == xerr.Conflict && strings.Contains(xerr.MessageOf(err), "already on seat") {
continue
}
return err
}
occupiedSeats[seatNo] = true
}
return nil
}
func (s *Service) runRobotRoomRuntime(ctx context.Context, config RobotRoomConfig) {
activity := newRobotRoomActivity(config.RobotUserIDs)
if snapshot, err := s.currentSnapshot(appcode.WithContext(ctx, config.AppCode), config.RoomID); err == nil {
for _, user := range snapshot.GetOnlineUsers() {
if robotRoomHasUser(config.RobotUserIDs, user.GetUserId()) {
activity.markActive(user.GetUserId())
}
}
}
giftSlots := make(chan struct{}, robotRoomMaxGiftSenders(config))
for _, senderID := range config.RobotUserIDs {
senderID := senderID
go s.runRobotPresenceLoop(ctx, config, activity, senderID)
go s.runRobotNormalGiftLoop(ctx, config, activity, giftSlots, senderID)
go s.runRobotLuckyGiftLoop(ctx, config, activity, giftSlots, senderID)
}
<-ctx.Done()
}
func (s *Service) runRobotPresenceLoop(ctx context.Context, config RobotRoomConfig, activity *robotRoomActivity, userID int64) {
if userID <= 0 {
return
}
rng := rand.New(rand.NewSource(time.Now().UnixNano() + userID*31))
for {
if !activity.isActive(userID) {
if !waitRobotRuntimeDelay(ctx, time.Duration(200+rng.Intn(600))*time.Millisecond) {
return
}
continue
}
stay := time.Duration(randomInt64Range(rng, config.GiftRule.RobotStayMinMS, config.GiftRule.RobotStayMaxMS)) * time.Millisecond
if stay <= 0 {
stay = time.Duration(defaultRobotStayMinMS) * time.Millisecond
}
if !waitRobotRuntimeDelay(ctx, stay) {
return
}
if !activity.markInactive(userID) {
continue
}
seatNo := s.robotRoomSeatNo(ctx, config, userID)
if err := s.leaveRobotRoomUser(ctx, config, userID); err != nil {
logx.Warn(ctx, "robot_room_leave_failed", slog.String("room_id", config.RoomID), slog.Int64("robot_user_id", userID), slog.String("error", err.Error()))
activity.markActive(userID)
continue
}
delay := time.Duration(randomInt64Range(rng, config.GiftRule.RobotReplaceMinMS, config.GiftRule.RobotReplaceMaxMS)) * time.Millisecond
if !waitRobotRuntimeDelay(ctx, delay) {
return
}
replacementID := activity.pickReplacement(rng, userID)
if replacementID <= 0 {
replacementID = userID
}
if err := s.joinRobotRoomUser(ctx, config, replacementID, seatNo, fmt.Sprintf("replace:%d:%d", replacementID, time.Now().UTC().UnixNano())); err != nil {
logx.Warn(ctx, "robot_room_replace_join_failed", slog.String("room_id", config.RoomID), slog.Int64("leaving_user_id", userID), slog.Int64("replacement_user_id", replacementID), slog.String("error", err.Error()))
if restoreErr := s.joinRobotRoomUser(ctx, config, userID, seatNo, fmt.Sprintf("restore:%d:%d", userID, time.Now().UTC().UnixNano())); restoreErr != nil {
logx.Warn(ctx, "robot_room_replace_restore_failed", slog.String("room_id", config.RoomID), slog.Int64("robot_user_id", userID), slog.String("error", restoreErr.Error()))
continue
}
activity.markActive(userID)
continue
}
activity.markActive(replacementID)
logx.Info(ctx, "robot_room_replaced_user",
slog.String("room_id", config.RoomID),
slog.Int64("left_robot_user_id", userID),
slog.Int64("joined_robot_user_id", replacementID),
slog.Int64("stay_ms", stay.Milliseconds()),
slog.Int64("replace_delay_ms", delay.Milliseconds()),
)
}
}
func (s *Service) runRobotNormalGiftLoop(ctx context.Context, config RobotRoomConfig, activity *robotRoomActivity, giftSlots chan struct{}, 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:
if !activity.isActive(senderID) {
timer.Reset(interval)
continue
}
targetID := randomRobotTarget(rng, activity.activeIDs(), senderID)
giftID := randomString(rng, config.GiftRule.GiftIDs)
if targetID > 0 && giftID != "" {
s.sendRobotGiftBestEffort(ctx, config, activity, giftSlots, senderID, targetID, giftID, "", "normal")
}
timer.Reset(interval)
}
}
}
func (s *Service) runRobotLuckyGiftLoop(ctx context.Context, config RobotRoomConfig, activity *robotRoomActivity, giftSlots chan struct{}, senderID int64) {
if len(config.GiftRule.LuckyGiftIDs) == 0 || config.GiftRule.LuckyComboMax <= 0 {
return
}
comboMin := clampRobotLuckyCombo(config.GiftRule.LuckyComboMin)
comboMax := clampRobotLuckyCombo(config.GiftRule.LuckyComboMax)
if comboMin <= 0 || comboMax < comboMin {
return
}
rng := rand.New(rand.NewSource(time.Now().UnixNano() + senderID*17))
for {
if !activity.isActive(senderID) {
if !waitRobotRuntimeDelay(ctx, time.Duration(300+rng.Intn(700))*time.Millisecond) {
return
}
continue
}
combo := randomInt64Range(rng, comboMin, comboMax)
for i := int64(0); i < combo; i++ {
giftID := randomString(rng, config.GiftRule.LuckyGiftIDs)
targetID := randomRobotTarget(rng, activity.activeIDs(), senderID)
if targetID > 0 && giftID != "" {
s.sendRobotGiftBestEffort(ctx, config, activity, giftSlots, senderID, targetID, giftID, robotRoomLuckyPoolID, "lucky")
}
if !waitRobotGiftPace(ctx, rng) {
return
}
}
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, activity *robotRoomActivity, giftSlots chan struct{}, senderID int64, targetID int64, giftID string, poolID string, kind string) {
if !activity.isActive(senderID) || !activity.isActive(targetID) {
return
}
select {
case giftSlots <- struct{}{}:
defer func() { <-giftSlots }()
default:
return
}
_, 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 (s *Service) leaveRobotRoomUser(ctx context.Context, config RobotRoomConfig, userID int64) error {
_, err := s.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{
Meta: robotRoomCommandMeta(config, userID, fmt.Sprintf("leave:%d:%d", userID, time.Now().UTC().UnixNano())),
})
return err
}
func (s *Service) joinRobotRoomUser(ctx context.Context, config RobotRoomConfig, userID int64, seatNo int32, suffix string) error {
role := "audience"
if userID == config.OwnerRobotUserID {
role = "owner"
}
if _, err := s.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: robotRoomCommandMeta(config, userID, suffix),
Role: role,
ActorIsRobot: true,
}); err != nil {
return err
}
if seatNo <= 0 {
seatNo = preferredRobotSeatNo(config, userID)
}
if seatNo <= 0 {
seatNo = 1
}
if _, err := s.RobotVirtualMicUp(ctx, RobotMicUpInput{
Meta: robotRoomCommandMeta(config, config.OwnerRobotUserID, fmt.Sprintf("mic:%d:%d", userID, time.Now().UTC().UnixNano())),
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) robotRoomSeatNo(ctx context.Context, config RobotRoomConfig, userID int64) int32 {
snapshot, err := s.currentSnapshot(appcode.WithContext(ctx, config.AppCode), config.RoomID)
if err != nil {
return preferredRobotSeatNo(config, userID)
}
for _, seat := range snapshot.GetMicSeats() {
if seat.GetUserId() == userID {
return seat.GetSeatNo()
}
}
return firstFreeRobotSeatNo(snapshot.GetMicSeats(), robotRoomActiveTarget(config))
}
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: clampRobotLuckyCombo(input.GetLuckyComboMin()),
LuckyComboMax: clampRobotLuckyCombo(input.GetLuckyComboMax()),
LuckyPauseMinMS: input.GetLuckyPauseMinMs(),
LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(),
RobotStayMinMS: input.GetRobotStayMinMs(),
RobotStayMaxMS: input.GetRobotStayMaxMs(),
RobotReplaceMinMS: input.GetRobotReplaceMinMs(),
RobotReplaceMaxMS: input.GetRobotReplaceMaxMs(),
MaxGiftSenders: input.GetMaxGiftSenders(),
}
rule = withRobotRoomGiftRuleDefaults(rule)
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")
}
if rule.RobotStayMinMS <= 0 || rule.RobotStayMaxMS < rule.RobotStayMinMS {
return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "robot stay range is invalid")
}
if rule.RobotReplaceMinMS < 0 || rule.RobotReplaceMaxMS < rule.RobotReplaceMinMS {
return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "robot replace range is invalid")
}
if rule.MaxGiftSenders <= 0 {
return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "max_gift_senders is required")
}
return rule, nil
}
func withRobotRoomGiftRuleDefaults(rule RobotRoomGiftRule) RobotRoomGiftRule {
if rule.RobotStayMinMS <= 0 && rule.RobotStayMaxMS <= 0 {
rule.RobotStayMinMS = defaultRobotStayMinMS
rule.RobotStayMaxMS = defaultRobotStayMaxMS
}
if rule.RobotReplaceMinMS == 0 && rule.RobotReplaceMaxMS == 0 {
rule.RobotReplaceMinMS = defaultRobotReplaceMinMS
rule.RobotReplaceMaxMS = defaultRobotReplaceMaxMS
}
if rule.MaxGiftSenders <= 0 {
rule.MaxGiftSenders = defaultMaxGiftSenders
}
return rule
}
// WithRobotRoomGiftRuleStorageDefaults 让 repository 恢复历史行时复用运行时默认值;
// 默认值只补空字段,不放宽 normalizeRobotRoomGiftRule 对显式非法范围的校验。
func WithRobotRoomGiftRuleStorageDefaults(rule RobotRoomGiftRule) RobotRoomGiftRule {
return withRobotRoomGiftRuleDefaults(rule)
}
func clampRobotLuckyCombo(value int64) int64 {
if value <= 0 {
return value
}
if value > maxRobotLuckyComboPerCycle {
return maxRobotLuckyComboPerCycle
}
return value
}
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...),
ActiveRobotCount: config.ActiveRobotCount,
SeatCount: config.SeatCount,
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,
RobotStayMinMs: config.GiftRule.RobotStayMinMS,
RobotStayMaxMs: config.GiftRule.RobotStayMaxMS,
RobotReplaceMinMs: config.GiftRule.RobotReplaceMinMS,
RobotReplaceMaxMs: config.GiftRule.RobotReplaceMaxMS,
MaxGiftSenders: config.GiftRule.MaxGiftSenders,
},
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 normalizeRobotRoomSeatCount(seatCount int32) int32 {
switch seatCount {
case 0:
return defaultRobotRoomSeatCount
case 10, 15, 20:
return seatCount
default:
return 0
}
}
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 robotRoomInitialActiveIDs(config RobotRoomConfig) []int64 {
target := robotRoomActiveTarget(config)
if target <= 0 {
return nil
}
if target > len(config.RobotUserIDs) {
target = len(config.RobotUserIDs)
}
active := make([]int64, 0, target)
if config.OwnerRobotUserID > 0 && robotRoomHasUser(config.RobotUserIDs, config.OwnerRobotUserID) {
active = append(active, config.OwnerRobotUserID)
}
for _, userID := range config.RobotUserIDs {
if len(active) >= target {
break
}
if userID <= 0 || userID == config.OwnerRobotUserID {
continue
}
active = append(active, userID)
}
return active
}
func robotRoomActiveTarget(config RobotRoomConfig) int {
target := int(config.ActiveRobotCount)
if target <= 0 {
target = len(config.RobotUserIDs)
}
if target > len(config.RobotUserIDs) {
target = len(config.RobotUserIDs)
}
if target < 0 {
return 0
}
return target
}
func robotRoomMaxGiftSenders(config RobotRoomConfig) int {
maxSenders := int(config.GiftRule.MaxGiftSenders)
if maxSenders <= 0 {
maxSenders = int(defaultMaxGiftSenders)
}
if maxSenders > len(config.RobotUserIDs) {
maxSenders = len(config.RobotUserIDs)
}
if maxSenders <= 0 {
maxSenders = 1
}
return maxSenders
}
func robotRoomHasUser(robots []int64, userID int64) bool {
for _, robotID := range robots {
if robotID == userID {
return true
}
}
return false
}
func onlineRobotIDs(robots []int64, online map[int64]bool) []int64 {
out := make([]int64, 0, len(robots))
for _, userID := range robots {
if online[userID] {
out = append(out, userID)
}
}
return out
}
func robotRoomFillCandidates(config RobotRoomConfig, online map[int64]bool, limit int) []int64 {
if limit <= 0 {
return nil
}
out := make([]int64, 0, limit)
if config.OwnerRobotUserID > 0 && !online[config.OwnerRobotUserID] && robotRoomHasUser(config.RobotUserIDs, config.OwnerRobotUserID) {
out = append(out, config.OwnerRobotUserID)
}
for _, userID := range config.RobotUserIDs {
if len(out) >= limit {
break
}
if userID <= 0 || userID == config.OwnerRobotUserID || online[userID] {
continue
}
out = append(out, userID)
}
return out
}
func preferredRobotSeatNo(config RobotRoomConfig, userID int64) int32 {
for index, robotID := range config.RobotUserIDs {
if robotID == userID {
return int32(index + 1)
}
}
return 0
}
func firstFreeRobotSeatNo(seats []*roomv1.SeatState, activeTarget int) int32 {
occupied := make(map[int32]bool, len(seats))
maxSeatNo := int32(0)
for _, seat := range seats {
if seat.GetSeatNo() > maxSeatNo {
maxSeatNo = seat.GetSeatNo()
}
if seat.GetUserId() > 0 {
occupied[seat.GetSeatNo()] = true
}
}
if maxSeatNo <= 0 {
maxSeatNo = int32(max(10, activeTarget))
}
for seatNo := int32(1); seatNo <= maxSeatNo; seatNo++ {
if !occupied[seatNo] {
return seatNo
}
}
return 0
}
func waitRobotRuntimeDelay(ctx context.Context, delay time.Duration) bool {
if delay <= 0 {
select {
case <-ctx.Done():
return false
default:
return true
}
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}
type robotRoomActivity struct {
mu sync.Mutex
active map[int64]bool
giftSenders map[int64]bool
pool []int64
}
func newRobotRoomActivity(pool []int64) *robotRoomActivity {
return &robotRoomActivity{
active: make(map[int64]bool, len(pool)),
giftSenders: make(map[int64]bool),
pool: append([]int64(nil), pool...),
}
}
func (a *robotRoomActivity) isActive(userID int64) bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.active[userID]
}
func (a *robotRoomActivity) markActive(userID int64) {
if userID <= 0 {
return
}
a.mu.Lock()
a.active[userID] = true
a.mu.Unlock()
}
func (a *robotRoomActivity) markInactive(userID int64) bool {
a.mu.Lock()
defer a.mu.Unlock()
if !a.active[userID] {
return false
}
delete(a.active, userID)
delete(a.giftSenders, userID)
return true
}
func (a *robotRoomActivity) isGiftSender(userID int64) bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.giftSenders[userID]
}
func (a *robotRoomActivity) markGiftSender(userID int64) {
if userID <= 0 {
return
}
a.mu.Lock()
if a.active[userID] {
a.giftSenders[userID] = true
}
a.mu.Unlock()
}
func (a *robotRoomActivity) markGiftSenderWithinLimit(userID int64, maxSenders int) bool {
if userID <= 0 || maxSenders <= 0 {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
if !a.active[userID] || len(a.giftSenders) >= maxSenders {
return false
}
a.giftSenders[userID] = true
return true
}
func (a *robotRoomActivity) giftSenderCount() int {
a.mu.Lock()
defer a.mu.Unlock()
return len(a.giftSenders)
}
func (a *robotRoomActivity) activeIDs() []int64 {
a.mu.Lock()
defer a.mu.Unlock()
out := make([]int64, 0, len(a.active))
for userID := range a.active {
out = append(out, userID)
}
return out
}
func (a *robotRoomActivity) pickReplacement(rng *rand.Rand, departingUserID int64) int64 {
a.mu.Lock()
defer a.mu.Unlock()
candidates := make([]int64, 0, len(a.pool))
fallback := make([]int64, 0, 1)
for _, userID := range a.pool {
if a.active[userID] {
continue
}
if userID == departingUserID {
fallback = append(fallback, userID)
continue
}
candidates = append(candidates, userID)
}
if len(candidates) == 0 {
candidates = fallback
}
if len(candidates) == 0 {
return 0
}
return candidates[rng.Intn(len(candidates))]
}
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
}
func waitRobotGiftPace(ctx context.Context, rng *rand.Rand) bool {
delay := time.Duration(300+rng.Intn(500)) * time.Millisecond
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return false
case <-timer.C:
return true
}
}