416 lines
14 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"
"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 必须保持 0Flutter 会把 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
}
}