416 lines
14 KiB
Go
416 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"math"
|
||
"strings"
|
||
"time"
|
||
|
||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||
roomv1 "hyapp.local/api/proto/room/v1"
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/giftlimits"
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/room-service/internal/integration"
|
||
"hyapp/services/room-service/internal/room/outbox"
|
||
"hyapp/services/room-service/internal/room/state"
|
||
)
|
||
|
||
const (
|
||
// 机器人送礼量远高于礼物配置变更频率;短缓存把每次展示的 wallet/MySQL 只读查询收敛为每 App/区域每分钟一次。
|
||
robotGiftCatalogTTL = time.Minute
|
||
// wallet 故障只短暂负缓存,既让同批机器人共享失败,也能在服务恢复后迅速重试。
|
||
robotGiftCatalogErrorTTL = time.Second
|
||
robotGiftCatalogQueryTimeout = 3 * time.Second
|
||
robotGiftCatalogPageSize = int32(500)
|
||
robotGiftCatalogMaxPages = int32(20)
|
||
)
|
||
|
||
type robotGiftCatalogKey struct {
|
||
appCode string
|
||
regionID int64
|
||
}
|
||
|
||
type robotGiftCatalogEntry struct {
|
||
expiresAtMS int64
|
||
gifts map[string]*walletv1.GiftConfig
|
||
loadErr error
|
||
}
|
||
|
||
type robotGiftCatalogLoad struct {
|
||
done chan struct{}
|
||
}
|
||
|
||
type RobotSendGiftInput struct {
|
||
Meta *roomv1.RequestMeta
|
||
TargetUserID int64
|
||
GiftID string
|
||
GiftCount int32
|
||
PoolID string
|
||
RobotUserIDs []int64
|
||
SyntheticRewardCoins int64
|
||
SyntheticMultiplierPPM int64
|
||
// RealRoomHeat 是历史字段名;当前只表示“真人房机器人”校验范围,绝不再产生真实热度或任何持久化贡献。
|
||
RealRoomHeat bool
|
||
}
|
||
|
||
type robotGiftOptions struct {
|
||
Enabled bool
|
||
RobotUserIDs []int64
|
||
SyntheticRewardCoins int64
|
||
SyntheticMultiplierPPM int64
|
||
// RealRoomHeat 仅供 validateBeforeDebit 区分专属机器人房和真人房机器人池,不能进入状态/账务结算。
|
||
RealRoomHeat bool
|
||
}
|
||
|
||
func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (*roomv1.SendGiftResponse, error) {
|
||
req := &roomv1.SendGiftRequest{
|
||
Meta: input.Meta,
|
||
TargetUserId: input.TargetUserID,
|
||
TargetUserIds: []int64{input.TargetUserID},
|
||
GiftId: strings.TrimSpace(input.GiftID),
|
||
GiftCount: input.GiftCount,
|
||
TargetType: "user",
|
||
PoolId: input.PoolID,
|
||
}
|
||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||
flow := newGiftFlow(s, ctx, req, giftSendOptions{
|
||
Robot: robotGiftOptions{
|
||
Enabled: true,
|
||
RobotUserIDs: input.RobotUserIDs,
|
||
SyntheticRewardCoins: input.SyntheticRewardCoins,
|
||
SyntheticMultiplierPPM: input.SyntheticMultiplierPPM,
|
||
RealRoomHeat: input.RealRoomHeat,
|
||
},
|
||
})
|
||
// RobotGift 永远是纯展示语义。RealRoomHeat 只兼容真人房机器人参与者校验,不能再授权热度、榜单、火箭或统计落库。
|
||
flow.cmd.RobotGift = true
|
||
flow.cmd.RobotWalletGift = true
|
||
|
||
if flow.cmd.RoomID() == "" || flow.cmd.ActorUserID() <= 0 || !giftlimits.ValidCommandID(flow.cmd.ID()) {
|
||
return nil, xerr.New(xerr.InvalidArgument, "command meta is incomplete")
|
||
}
|
||
if s.isDraining() {
|
||
return nil, xerr.New(xerr.Unavailable, "room service is draining")
|
||
}
|
||
|
||
// 只读取当前 Room Cell 做 presence 与机器人池校验;ensureCell 仅续租 Redis owner lease,
|
||
// 不进入 mutateRoom,因此不会写 room_gift_operations、command log、snapshot、账本或房间统计表。
|
||
snapshot, leaseToken, err := s.robotGiftOwnerSnapshot(ctx, flow.cmd.RoomID())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
current := state.FromProto(snapshot)
|
||
if err := flow.validateBeforeDebit(current); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// 礼物目录调用是只读快照查询,替代历史 DebitRobotGift;返回值仅用于 IM 图标、动画、类型和展示价值。
|
||
giftConfig, err := s.robotGiftConfig(ctx, flow.cmd.GiftID, snapshot.GetVisibleRegionId())
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
billing, err := robotGiftDisplayBilling(giftConfig, flow.cmd.GiftCount)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
targetBillings := []giftTargetBilling{{
|
||
TargetUserID: flow.cmd.TargetUserID,
|
||
CommandID: flow.cmd.ID(),
|
||
Billing: billing,
|
||
}}
|
||
targetGiftValues := map[int64]int64{}
|
||
if target := current.OnlineUsers[flow.cmd.TargetUserID]; target != nil {
|
||
// 纯展示礼物不能改变麦位/在线用户累计值;事件携带当前值,客户端不会把机器人价值误并入真实状态。
|
||
targetGiftValues[flow.cmd.TargetUserID] = target.GiftValue
|
||
}
|
||
|
||
now := s.clock.Now()
|
||
records, err := buildRoomGiftSentRecords(flow.cmd.RoomID(), snapshot.GetVersion(), now, flow.cmd, RoomMeta{
|
||
RoomID: flow.cmd.RoomID(),
|
||
VisibleRegionID: snapshot.GetVisibleRegionId(),
|
||
}, targetBillings, targetGiftValues)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var luckyGift *roomv1.LuckyGiftDrawResult
|
||
var luckyGifts []*roomv1.LuckyGiftDrawResult
|
||
if flow.cmd.SyntheticLuckyGift {
|
||
luckyGift = syntheticLuckyGiftResult(flow.cmd, targetBillings, now)
|
||
if luckyGift != nil {
|
||
luckyGifts = []*roomv1.LuckyGiftDrawResult{luckyGift}
|
||
drawRecord, buildErr := outbox.Build(flow.cmd.RoomID(), "RoomRobotLuckyGiftDrawn", snapshot.GetVersion(), now, &roomeventsv1.RoomRobotLuckyGiftDrawn{
|
||
DrawId: luckyGift.GetDrawId(),
|
||
CommandId: flow.cmd.ID(),
|
||
SenderUserId: flow.cmd.ActorUserID(),
|
||
TargetUserId: flow.cmd.TargetUserID,
|
||
GiftId: flow.cmd.GiftID,
|
||
GiftCount: flow.cmd.GiftCount,
|
||
PoolId: flow.cmd.PoolID,
|
||
MultiplierPpm: luckyGift.GetMultiplierPpm(),
|
||
BaseRewardCoins: luckyGift.GetBaseRewardCoins(),
|
||
EffectiveRewardCoins: luckyGift.GetEffectiveRewardCoins(),
|
||
CreatedAtMs: luckyGift.GetCreatedAtMs(),
|
||
VisibleRegionId: snapshot.GetVisibleRegionId(),
|
||
IsRobot: true,
|
||
Synthetic: true,
|
||
})
|
||
if buildErr != nil {
|
||
return nil, buildErr
|
||
}
|
||
records = append(records, drawRecord)
|
||
}
|
||
}
|
||
|
||
// outbox.Record 在这里仅是复用 protobuf 信封的内存载体;不调用 repository/outboxPublisher,
|
||
// 只经过采样器后直接交给生产环境的 TencentIMPublisher,因此不会生成任何 RocketMQ 消息。
|
||
records = s.sampleRobotDisplayRecords(ctx, records)
|
||
if len(records) > 0 {
|
||
// wallet 目录查询最多持续 3 秒;发布前用同一 token 再做 fencing,防止查询期间房间已被另一节点接管。
|
||
if err := s.verifyRobotGiftOwner(ctx, flow.cmd.RoomID(), leaseToken); err != nil {
|
||
return nil, err
|
||
}
|
||
}
|
||
s.publishRobotDisplayRecordsBestEffort(ctx, records)
|
||
|
||
return &roomv1.SendGiftResponse{
|
||
Result: commandResult(true, snapshot.GetVersion(), now),
|
||
RoomHeat: snapshot.GetHeat(),
|
||
GiftRank: snapshot.GetGiftRank(),
|
||
Room: snapshot,
|
||
Rocket: snapshot.GetRocket(),
|
||
LuckyGift: luckyGift,
|
||
LuckyGifts: luckyGifts,
|
||
}, nil
|
||
}
|
||
|
||
func (s *Service) robotGiftOwnerSnapshot(ctx context.Context, roomID string) (*roomv1.RoomSnapshot, string, error) {
|
||
if s.directory == nil {
|
||
// 极简内部测试允许没有 lease 目录;生产 HealthCheck 会拒绝这种装配。
|
||
snapshot, err := s.currentSnapshot(ctx, roomID)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
if snapshot == nil {
|
||
return nil, "", xerr.New(xerr.NotFound, "room not found")
|
||
}
|
||
return snapshot, "", nil
|
||
}
|
||
roomCell, lease, err := s.ensureCell(ctx, roomID)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
current, _, err := roomCell.Snapshot(ctx)
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
return snapshotWithApp(ctx, current.ToProto()), lease.LeaseToken, nil
|
||
}
|
||
|
||
func (s *Service) verifyRobotGiftOwner(ctx context.Context, roomID string, leaseToken string) error {
|
||
if s.directory == nil {
|
||
return nil
|
||
}
|
||
owned, err := s.directory.VerifyOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, leaseToken, s.clock.Now())
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !owned {
|
||
return xerr.New(xerr.Conflict, "room owner lease changed before robot display")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) robotGiftConfig(ctx context.Context, giftID string, regionID int64) (*walletv1.GiftConfig, error) {
|
||
key := robotGiftCatalogKey{appCode: appcode.FromContext(ctx), regionID: regionID}
|
||
for {
|
||
nowMS := s.clock.Now().UnixMilli()
|
||
s.robotGiftCatalogMu.Lock()
|
||
if cached, exists := s.robotGiftCatalog[key]; exists && cached.expiresAtMS > nowMS {
|
||
s.robotGiftCatalogMu.Unlock()
|
||
if cached.loadErr != nil {
|
||
return nil, cached.loadErr
|
||
}
|
||
return robotGiftConfigOrNotFound(cached.gifts[giftID])
|
||
}
|
||
if loading := s.robotGiftCatalogLoads[key]; loading != nil {
|
||
done := loading.done
|
||
s.robotGiftCatalogMu.Unlock()
|
||
select {
|
||
case <-done:
|
||
// loader 已把成功或短负缓存结果写入 catalog;循环一次统一读取。
|
||
continue
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
}
|
||
}
|
||
if s.robotGiftCatalogLoads == nil {
|
||
s.robotGiftCatalogLoads = make(map[robotGiftCatalogKey]*robotGiftCatalogLoad)
|
||
}
|
||
loading := &robotGiftCatalogLoad{done: make(chan struct{})}
|
||
s.robotGiftCatalogLoads[key] = loading
|
||
s.robotGiftCatalogMu.Unlock()
|
||
// loader 脱离首个 caller 的取消信号但仍受 3 秒 RPC timeout;所有调用者都按自己的 ctx 等待同一 done。
|
||
go s.completeRobotGiftCatalogLoad(context.WithoutCancel(ctx), key, loading)
|
||
select {
|
||
case <-loading.done:
|
||
continue
|
||
case <-ctx.Done():
|
||
return nil, ctx.Err()
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) completeRobotGiftCatalogLoad(ctx context.Context, key robotGiftCatalogKey, loading *robotGiftCatalogLoad) {
|
||
// 网络查询在全局缓存锁外执行;同 key 等待 done,不同 key 可并行请求 wallet。
|
||
gifts, loadErr := s.loadRobotGiftCatalog(ctx, key)
|
||
now := s.clock.Now()
|
||
expiresAt := now.Add(robotGiftCatalogTTL)
|
||
if loadErr != nil {
|
||
expiresAt = now.Add(robotGiftCatalogErrorTTL)
|
||
}
|
||
s.robotGiftCatalogMu.Lock()
|
||
if s.robotGiftCatalog == nil {
|
||
s.robotGiftCatalog = make(map[robotGiftCatalogKey]robotGiftCatalogEntry)
|
||
}
|
||
s.robotGiftCatalog[key] = robotGiftCatalogEntry{expiresAtMS: expiresAt.UnixMilli(), gifts: gifts, loadErr: loadErr}
|
||
delete(s.robotGiftCatalogLoads, key)
|
||
close(loading.done)
|
||
s.pruneRobotGiftCatalogLocked(now.UnixMilli())
|
||
s.robotGiftCatalogMu.Unlock()
|
||
}
|
||
|
||
func (s *Service) pruneRobotGiftCatalogLocked(nowMS int64) {
|
||
if len(s.robotGiftCatalog) <= 1024 {
|
||
return
|
||
}
|
||
for key, entry := range s.robotGiftCatalog {
|
||
if entry.expiresAtMS <= nowMS && s.robotGiftCatalogLoads[key] == nil {
|
||
delete(s.robotGiftCatalog, key)
|
||
}
|
||
}
|
||
}
|
||
|
||
func robotGiftConfigOrNotFound(gift *walletv1.GiftConfig) (*walletv1.GiftConfig, error) {
|
||
if gift == nil {
|
||
return nil, xerr.New(xerr.NotFound, "robot gift config is not active in room region")
|
||
}
|
||
return gift, nil
|
||
}
|
||
|
||
func (s *Service) loadRobotGiftCatalog(ctx context.Context, key robotGiftCatalogKey) (map[string]*walletv1.GiftConfig, error) {
|
||
client, ok := s.wallet.(integration.WalletGiftCatalogClient)
|
||
if !ok || client == nil {
|
||
return nil, xerr.New(xerr.Unavailable, "wallet gift catalog is not configured")
|
||
}
|
||
|
||
queryCtx, cancel := context.WithTimeout(ctx, robotGiftCatalogQueryTimeout)
|
||
defer cancel()
|
||
gifts := make(map[string]*walletv1.GiftConfig)
|
||
var loaded int64
|
||
for page := int32(1); page <= robotGiftCatalogMaxPages; page++ {
|
||
resp, err := client.ListGiftConfigs(queryCtx, &walletv1.ListGiftConfigsRequest{
|
||
AppCode: key.appCode,
|
||
Page: page,
|
||
PageSize: robotGiftCatalogPageSize,
|
||
ActiveOnly: true,
|
||
RegionId: key.regionID,
|
||
FilterRegion: true,
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for _, gift := range resp.GetGifts() {
|
||
if id := strings.TrimSpace(gift.GetGiftId()); id != "" {
|
||
gifts[id] = gift
|
||
}
|
||
}
|
||
loaded += int64(len(resp.GetGifts()))
|
||
if loaded >= resp.GetTotal() || len(resp.GetGifts()) < int(robotGiftCatalogPageSize) {
|
||
return gifts, nil
|
||
}
|
||
}
|
||
return nil, xerr.New(xerr.Unavailable, "wallet gift catalog exceeds robot display limit")
|
||
}
|
||
|
||
func robotGiftDisplayBilling(gift *walletv1.GiftConfig, giftCount int32) (*walletv1.DebitGiftResponse, error) {
|
||
if gift == nil || giftCount <= 0 {
|
||
return nil, xerr.New(xerr.InvalidArgument, "robot gift display config is invalid")
|
||
}
|
||
coinSpent, err := checkedRobotGiftDisplayValue(gift.GetCoinPrice(), giftCount)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
resource := gift.GetResource()
|
||
iconURL := ""
|
||
animationURL := ""
|
||
name := strings.TrimSpace(gift.GetName())
|
||
if resource != nil {
|
||
iconURL = strings.TrimSpace(resource.GetPreviewUrl())
|
||
if iconURL == "" {
|
||
iconURL = strings.TrimSpace(resource.GetAssetUrl())
|
||
}
|
||
animationURL = strings.TrimSpace(resource.GetAnimationUrl())
|
||
if name == "" {
|
||
name = strings.TrimSpace(resource.GetName())
|
||
}
|
||
}
|
||
// 该返回对象只是复用 RoomGiftSent 构造器的展示快照,不包含 receipt/transaction/balance,也没有任何账务含义。
|
||
// HeatValue 必须保持 0:Flutter 会把 gift_value 增加到成员和麦位的本地值,纯展示事件不能制造短暂的假贡献。
|
||
return &walletv1.DebitGiftResponse{
|
||
CoinSpent: coinSpent,
|
||
ChargeAmount: coinSpent,
|
||
HeatValue: 0,
|
||
GiftTypeCode: gift.GetGiftTypeCode(),
|
||
CpRelationType: gift.GetCpRelationType(),
|
||
GiftName: name,
|
||
GiftIconUrl: iconURL,
|
||
GiftAnimationUrl: animationURL,
|
||
GiftEffectTypes: append([]string(nil), gift.GetEffectTypes()...),
|
||
PriceVersion: gift.GetPriceVersion(),
|
||
}, nil
|
||
}
|
||
|
||
func checkedRobotGiftDisplayValue(unitValue int64, giftCount int32) (int64, error) {
|
||
if unitValue < 0 || giftCount <= 0 || unitValue > math.MaxInt64/int64(giftCount) {
|
||
return 0, xerr.New(xerr.InvalidArgument, "robot gift display value is invalid")
|
||
}
|
||
return unitValue * int64(giftCount), nil
|
||
}
|
||
|
||
func requireRobotGiftParticipants(current *state.RoomState, senderUserID int64, targetUserIDs []int64, runtimeRobotUserIDs []int64, requireRobotRoom bool) error {
|
||
if current == nil {
|
||
return xerr.New(xerr.PermissionDenied, "robot gift requires room state")
|
||
}
|
||
if requireRobotRoom && (current == nil || current.RoomExt[roomExtRobotRoomKey] != "true") {
|
||
return xerr.New(xerr.PermissionDenied, "robot gift requires robot room")
|
||
}
|
||
allowed := parseInt64CSV(current.RoomExt[roomExtRobotUserIDsKey])
|
||
for _, userID := range runtimeRobotUserIDs {
|
||
if userID > 0 {
|
||
allowed[userID] = true
|
||
}
|
||
}
|
||
if !allowed[senderUserID] {
|
||
return xerr.New(xerr.PermissionDenied, "robot sender is not allowed")
|
||
}
|
||
for _, targetUserID := range targetUserIDs {
|
||
if !allowed[targetUserID] {
|
||
return xerr.New(xerr.PermissionDenied, "robot gift target is not allowed")
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func normalizeGiftTargetType(raw string) string {
|
||
raw = strings.TrimSpace(raw)
|
||
switch raw {
|
||
case "", "user", "all_mic", "all_room", "couple":
|
||
return raw
|
||
default:
|
||
return raw
|
||
}
|
||
}
|