2026-06-15 23:53:30 +08:00

567 lines
18 KiB
Go

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
}