fix(room): make robot gifts display-only

This commit is contained in:
zhx 2026-07-17 16:46:04 +08:00
parent e3bf386acf
commit de9c1e9eef
17 changed files with 796 additions and 121 deletions

View File

@ -49,6 +49,9 @@
| `real_room_robot_total_gift_coin` | 真人房机器人总礼物 | 上面三类机器人礼物金币合计。 |
| `real_room_robot_gift_room_count` | 真人房机器人投放房间数 | 有真人房机器人礼物的房间去重数。 |
| `real_room_robot_avg_room_gift_coin` | 真人房机器人房均投放 | `real_room_robot_total_gift_coin / real_room_robot_gift_room_count`。 |
> 2026-07-17 起 robot 送礼改为纯腾讯云 IM 展示,不再写 room/wallet 数据库或发布 MQ。`real_room_robot_*` 字段只保留历史兼容;新事件不会再增长,不能继续作为当前机器人投放口径。
| `lucky_gift_turnover` | lucky 礼物流水 | `RoomGiftSent.pool_id` 非空的送礼金币。 |
| `lucky_gift_payout` | lucky 礼物产出 | `WalletLuckyGiftRewardCredited.amount`UTC 查询会优先用 activity 的 lucky pool 日表覆盖旧统计行。 |
| `lucky_gift_payers` | lucky 礼物付费用户 | 发送 lucky 礼物的用户按日去重。 |

View File

@ -37,11 +37,9 @@
### wallet-service
- 机器人礼物扣费入口:`DebitRobotGift`
- 使用专用资产 `ROBOT_COIN`
- 自动补足机器人专用金币后扣减。
- 不发出真实 `WalletGiftDebited` 事件。
- 不写主播周期钻石和真实礼物墙。
- 只通过 `ListGiftConfigs` 提供 active、区域可用的礼物展示快照。
- `RobotSendGift` 不调用 `DebitRobotGift`,不创建机器人钱包账户、交易、分录或 wallet outbox。
- 礼物目录在 room-service 按 App/区域短缓存;缓存只保存配置,不保存任何送礼事实。
### activity/statistics
@ -101,7 +99,7 @@ room-service gRPC
wallet-service gRPC
- `DebitRobotGift`
- `ListGiftConfigs`(只读)
## 创建流程
@ -155,24 +153,22 @@ room-service 启动时会扫描所有 active 机器人房间并恢复运行时
room-service 在机器人送礼时强校验:
- 当前房间必须是机器人房间
- 专属机器人房必须带 `robot_room=true`;真人房机器人使用运行时机器人池校验,不把真人加入允许集合
- sender 必须在该机器人房间 `robot_user_ids_json` 内。
- target 必须在同一机器人房间 `robot_user_ids_json` 内。
- sender 不能给真人送礼。
- 当前机器人礼物只允许单目标调用。
## 钱包隔离
## 纯展示隔离
机器人礼物使用 `DebitRobotGift`
`RobotSendGift` 不进入通用 `sendGift/mutateRoom`,只执行
- 资产类型固定为 `ROBOT_COIN`
- 如果机器人 `ROBOT_COIN` 不足,同一事务内自动补足。
- 立即扣减本次礼物价格。
- 交易类型为 `robot_gift_debit`
- 不写真实 `gift_debit`
- 不发 `WalletGiftDebited`
- 不写 `user_gift_wall`
- 不写 `host_period_diamond_accounts`
1. 获得或续租 Redis owner lease并只读当前 Room Cell缺失时从 MySQL 只读恢复),校验 sender、target 和机器人池。
2. 从 wallet 礼物目录读取并短缓存名称、图标、动画、类型和展示价值。
3. 在内存构造 `RoomGiftSent`;幸运礼物额外构造 `RoomRobotLuckyGiftDrawn`
4. 经过 2 秒展示采样后best-effort 直发腾讯云 IM。
这条链路不写 `room_gift_operations``room_command_log``room_snapshots``room_outbox`、房间贡献统计、钱包账本或任何 outbox也不调用 RocketMQ producer。内存里的 `outbox.Record` 只复用 protobuf 信封结构,不是持久化 outbox。
## 幸运礼物隔离
@ -189,25 +185,14 @@ room-service 只生成展示事件:
## 排行榜与统计隔离
`RoomGiftSent` 增加
机器人展示用 `RoomGiftSent` 保留
- `is_robot_gift`
- `synthetic_lucky_gift`
下游服务看到 `is_robot_gift=true` 时跳过:
该事件不会进入 room outbox/MQ所以下游活动、CP、任务、成长和 statistics 根本不会收到机器人送礼事实;`is_robot_gift=true` 只供 IM 展示兼容。
- 活动播报统计
- 周星
- 房间流水奖励
- 每日任务
- 成长值
- statistics 礼物消费与幸运礼物统计
room-service 本地仍允许机器人房内:
- 房间热度增长
- 麦位热度增长
- 房内展示事件发送
机器人送礼同时不修改房间热度、礼物榜、麦位 `gift_value`、火箭燃料、周贡献或 Redis 跨房贡献榜。真人房机器人也遵守同一边界;历史 `RealRoomHeat` 参数仅用于区分参与者校验范围,不能授权真实贡献。
## 本地接口测试
@ -232,7 +217,7 @@ go run ./tools/robot-room-api-test
5. 创建机器人房间。
6. 查询 active 列表。
7. 停止、启动、再次停止机器人房间。
8. 校验数据库中机器人房间配置和真实 `gift_debit` 隔离
8. 校验 robot 展示前后 `room_gift_operations`、command log、snapshot、room outbox、房间统计行数不变`DebitRobotGift` 调用次数为 0
## 运维注意事项
@ -241,3 +226,4 @@ go run ./tools/robot-room-api-test
- 同一机器人账号不能被多个 active 机器人房间占用。
- 停止机器人房间只停止 runtime不删除房间。
- 如果需要彻底释放机器人账号,应增加删除/归档能力后再开放。
- 腾讯云 IM 是唯一实时展示通道,失败不补写数据库或 MQ也不重试成业务事实丢失的动画/公屏不恢复,房间快照只保证真实热度、榜单和麦位值不被机器人展示污染。

View File

@ -164,7 +164,7 @@ func New(cfg config.Config) (*App, error) {
roomPublisher := integration.NewNoopRoomEventPublisher()
outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
var rtcUserRemover integration.RTCUserRemover
var tencentPublisher *integration.TencentIMPublisher
var tencentPublisher integration.OutboxPublisher
if cfg.TencentIM.Enabled {
// 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
@ -175,8 +175,9 @@ func New(cfg config.Config) (*App, error) {
_ = redisClient.Close()
return nil, err
}
tencentPublisher = integration.NewTencentIMPublisher(tencentClient)
roomPublisher = tencentPublisher
concreteTencentPublisher := integration.NewTencentIMPublisher(tencentClient)
tencentPublisher = concreteTencentPublisher
roomPublisher = concreteTencentPublisher
}
if cfg.TencentRTC.Enabled {
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())

View File

@ -0,0 +1,18 @@
package integration
import (
"context"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
// WalletGiftCatalogClient 是机器人展示链路读取礼物快照的可选能力。
// RobotSendGift 只能调用这个只读目录接口,不能再借 DebitRobotGift 产生钱包账户、交易、分录或 outbox。
type WalletGiftCatalogClient interface {
ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error)
}
// ListGiftConfigs 只读取 wallet owner 的礼物目录;机器人展示使用该快照,绝不调用账务扣费接口。
func (c *grpcWalletClient) ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
return c.client.ListGiftConfigs(ctx, req)
}

View File

@ -321,7 +321,6 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()),
"billing_receipt_id": body.GetBillingReceiptId(),
"target_gift_value": fmt.Sprintf("%d", body.GetTargetGiftValue()),
"gift_type_code": body.GetGiftTypeCode(),
"cp_relation_type": body.GetCpRelationType(),
"gift_name": body.GetGiftName(),
@ -337,6 +336,14 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"receiver_display_user_id": body.GetReceiverDisplayUserId(),
"receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(),
}
if body.GetIsRobotGift() {
// 机器人礼物只用于动画和公屏展示。Flutter 会把顶层 gift_value 累加到成员贡献,
// 也会用 target_gift_value 覆盖麦位值;两者都必须从机器人协议中移除,避免假贡献和乱序回退。
base.GiftValue = 0
base.Attributes["is_robot_gift"] = "true"
} else {
base.Attributes["target_gift_value"] = fmt.Sprintf("%d", body.GetTargetGiftValue())
}
return base, true, nil
case "RoomGiftBatchSent":
// 批量送礼展示事件只面向新客户端;逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。
@ -448,9 +455,13 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId()
base.GiftValue = body.GetEffectiveRewardCoins()
// 中奖金额保留在 effective_reward_coins 供动画展示;顶层 gift_value 必须为 0
// 否则 Flutter 会把纯展示中奖额累计进机器人发送者的成员贡献。
base.GiftValue = 0
base.Attributes = map[string]string{
"event_type": "lucky_gift_drawn",
// RoomEvent.GiftValue=0 会被 omitempty 省略;显式扁平 gift_value=0防止 Flutter 回退读取中奖额并累计成员贡献。
"gift_value": "0",
"draw_id": body.GetDrawId(),
"command_id": body.GetCommandId(),
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),

View File

@ -137,6 +137,30 @@ func TestRoomGiftSentCarriesTargetGiftValueInIMAttributes(t *testing.T) {
if event.Attributes["receiver_nickname"] != "Robot Receiver" || event.Attributes["receiver_avatar"] != "https://cdn.example/receiver.png" || event.Attributes["receiver_display_user_id"] != "100002" {
t.Fatalf("gift IM event receiver display fields mismatch: %+v", event.Attributes)
}
robotRecord, err := outbox.Build("room-robot-gift-value", "RoomGiftSent", 8, time.Now(), &roomeventsv1.RoomGiftSent{
SenderUserId: 2001,
TargetUserId: 2002,
GiftId: "gift_robot_rose",
GiftCount: 1,
GiftValue: 999,
TargetGiftValue: 888,
CoinSpent: 100,
IsRobotGift: true,
})
if err != nil {
t.Fatalf("Build robot RoomGiftSent envelope failed: %v", err)
}
robotEvent, publish, err := roomEventFromEnvelope(robotRecord.Envelope)
if err != nil || !publish {
t.Fatalf("robot RoomGiftSent should publish: publish=%t err=%v", publish, err)
}
if robotEvent.GiftValue != 0 || robotEvent.Attributes["is_robot_gift"] != "true" {
t.Fatalf("robot gift IM must expose zero business contribution: %+v", robotEvent)
}
if _, exists := robotEvent.Attributes["target_gift_value"]; exists {
t.Fatalf("robot gift IM must not overwrite authoritative target value: %+v", robotEvent.Attributes)
}
}
func TestRoomGiftSentBatchDisplayFactSkipsSingleTargetIM(t *testing.T) {
@ -291,16 +315,28 @@ func TestRoomRobotLuckyGiftDrawnPublishesLuckyGiftIMEvent(t *testing.T) {
if err != nil {
t.Fatalf("RoomRobotLuckyGiftDrawn should decode: %v", err)
}
if !publish || event.EventType != "lucky_gift_drawn" || event.ActorUserID != 1001 || event.TargetUserID != 1002 {
if !publish || event.EventType != "lucky_gift_drawn" || event.ActorUserID != 1001 || event.TargetUserID != 1002 || event.GiftValue != 0 {
t.Fatalf("robot lucky gift IM event mismatch: publish=%t event=%+v", publish, event)
}
if event.Attributes["draw_id"] != "robot_lucky_draw_1" ||
event.Attributes["gift_id"] != "gift_lucky_1" ||
event.Attributes["pool_id"] != "robot_lucky_display" ||
event.Attributes["gift_value"] != "0" ||
event.Attributes["effective_reward_coins"] != "500" ||
event.Attributes["reward_status"] != "granted" {
t.Fatalf("robot lucky gift IM attributes mismatch: %+v", event.Attributes)
}
payload, err := json.Marshal(event)
if err != nil {
t.Fatalf("marshal robot lucky gift IM event failed: %v", err)
}
var flattened map[string]any
if err := json.Unmarshal(payload, &flattened); err != nil {
t.Fatalf("decode flattened robot lucky gift IM event failed: %v", err)
}
if flattened["gift_value"] != "0" || flattened["effective_reward_coins"] != "500" {
t.Fatalf("robot lucky gift JSON must keep reward display but zero contribution: %s", payload)
}
}
func TestRoomLuckyGiftDrawnPublishesGrantedOwnerFactAsLuckyGiftIMEvent(t *testing.T) {

View File

@ -4,6 +4,7 @@ import (
"context"
"time"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/outbox"
"hyapp/services/room-service/internal/room/rank"
"hyapp/services/room-service/internal/room/state"
@ -41,6 +42,11 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, opt
}
func (s *Service) sendGiftWithRecovery(ctx context.Context, req *roomv1.SendGiftRequest, options giftSendOptions, recovery GiftOperation) (*roomv1.SendGiftResponse, error) {
if options.Robot.Enabled && recovery.CommandID == "" {
// 新机器人礼物只能走 RobotSendGift 的纯展示通道。这里 fail-close防止后续调用方误把机器人重新接回
// room_gift_operations、Room Cell 持久化、钱包账本或 MQ已有历史 saga 恢复不依赖 options.Robot。
return nil, xerr.New(xerr.InvalidArgument, "robot gift must use display-only path")
}
ctx = contextFromMeta(ctx, req.GetMeta())
flow := newGiftFlow(s, ctx, req, options)
if recovery.CommandID != "" {

View File

@ -96,7 +96,8 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
RobotWalletGift: options.Robot.Enabled,
SyntheticLuckyGift: options.Robot.Enabled && strings.TrimSpace(req.GetPoolId()) != "",
SyntheticLuckyRewardCoins: firstPositiveInt64(options.Robot.SyntheticRewardCoins, 0),
SyntheticLuckyMultiplierPPM: firstPositiveInt64(options.Robot.SyntheticMultiplierPPM, 1000000),
// 0 留给 syntheticLuckyGiftResult 统一选择 5x 默认值;这里提前写成 1x 会让倍数和展示中奖额互相矛盾。
SyntheticLuckyMultiplierPPM: firstPositiveInt64(options.Robot.SyntheticMultiplierPPM, 0),
}
if cmd.TargetType == "" {
cmd.TargetType = "user"

View File

@ -3,6 +3,8 @@ package service
import (
"context"
"fmt"
"math"
"math/bits"
"strings"
"time"
@ -278,8 +280,11 @@ func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetB
if len(targetBillings) == 0 || targetBillings[0].Billing == nil {
return nil
}
rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, targetBillings[0].Billing.GetCoinSpent()*5)
coinSpent := targetBillings[0].Billing.GetCoinSpent()
multiplier := firstPositiveInt64(cmd.SyntheticLuckyMultiplierPPM, 5000000)
// 未显式指定中奖额时必须按同一 multiplier 推导,避免 20x 文案却展示 5x 金额;计算饱和防止异常配置溢出。
defaultRewardCoins := scaledRobotRewardCoins(coinSpent, multiplier)
rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, defaultRewardCoins)
drawID := fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli())
return &roomv1.LuckyGiftDrawResult{
Enabled: true,
@ -301,6 +306,22 @@ func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetB
}
}
func scaledRobotRewardCoins(coinSpent int64, multiplierPPM int64) int64 {
if coinSpent <= 0 || multiplierPPM <= 0 {
return 0
}
// bits.Mul64 + Div64 完成无符号 128 位乘除;高 64 位已不小于除数时,商超出 uint64直接饱和。
high, low := bits.Mul64(uint64(coinSpent), uint64(multiplierPPM))
if high >= 1_000_000 {
return math.MaxInt64
}
reward, _ := bits.Div64(high, low, 1_000_000)
if reward > math.MaxInt64 {
return math.MaxInt64
}
return int64(reward)
}
func giftFinalCoinBalanceAfter(billing *walletv1.DebitGiftResponse) int64 {
if billing == nil {
return 0

View File

@ -2,13 +2,46 @@ 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
@ -18,6 +51,7 @@ type RobotSendGiftInput struct {
RobotUserIDs []int64
SyntheticRewardCoins int64
SyntheticMultiplierPPM int64
// RealRoomHeat 是历史字段名;当前只表示“真人房机器人”校验范围,绝不再产生真实热度或任何持久化贡献。
RealRoomHeat bool
}
@ -26,6 +60,7 @@ type robotGiftOptions struct {
RobotUserIDs []int64
SyntheticRewardCoins int64
SyntheticMultiplierPPM int64
// RealRoomHeat 仅供 validateBeforeDebit 区分专属机器人房和真人房机器人池,不能进入状态/账务结算。
RealRoomHeat bool
}
@ -34,12 +69,13 @@ func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (
Meta: input.Meta,
TargetUserId: input.TargetUserID,
TargetUserIds: []int64{input.TargetUserID},
GiftId: input.GiftID,
GiftId: strings.TrimSpace(input.GiftID),
GiftCount: input.GiftCount,
TargetType: "user",
PoolId: input.PoolID,
}
return s.sendGift(ctx, req, giftSendOptions{
ctx = contextFromMeta(ctx, req.GetMeta())
flow := newGiftFlow(s, ctx, req, giftSendOptions{
Robot: robotGiftOptions{
Enabled: true,
RobotUserIDs: input.RobotUserIDs,
@ -48,6 +84,300 @@ func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (
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 {

View File

@ -2,6 +2,7 @@ package service
import (
"context"
"math"
"sync"
"testing"
@ -13,6 +14,18 @@ import (
"hyapp/services/room-service/internal/room/outbox"
)
func TestScaledRobotRewardCoinsUsesMultiplierAndSaturates(t *testing.T) {
if got := scaledRobotRewardCoins(100, 20_000_000); got != 2_000 {
t.Fatalf("20x robot reward = %d, want 2000", got)
}
if got := scaledRobotRewardCoins(math.MaxInt64, 20_000_000); got != math.MaxInt64 {
t.Fatalf("overflowing robot reward = %d, want saturation", got)
}
if got := scaledRobotRewardCoins(100, 0); got != 0 {
t.Fatalf("zero multiplier robot reward = %d, want 0", got)
}
}
type luckyGiftDisplayRepository struct {
Repository

View File

@ -23,6 +23,11 @@ type scopedOutboxPublisher struct {
published chan scopedOutboxPublication
}
type blockingRobotDisplayPublisher struct {
started chan struct{}
release chan struct{}
}
func newScopedOutboxPublisher() *scopedOutboxPublisher {
return &scopedOutboxPublisher{published: make(chan scopedOutboxPublication, 2)}
}
@ -32,6 +37,16 @@ func (p *scopedOutboxPublisher) PublishOutboxEvent(ctx context.Context, envelope
return nil
}
func (p *blockingRobotDisplayPublisher) PublishOutboxEvent(ctx context.Context, _ *roomeventsv1.EventEnvelope) error {
p.started <- struct{}{}
select {
case <-p.release:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
type crossAppOutboxRepository struct {
Repository
claims []string
@ -109,6 +124,40 @@ func TestRobotDisplaySamplerUsesCommandAppForContextFreeRecords(t *testing.T) {
}
}
func TestRobotDisplayPublisherDropsWhenConcurrencySlotsAreFull(t *testing.T) {
publisher := &blockingRobotDisplayPublisher{
started: make(chan struct{}, robotDisplayMaxConcurrentPublishes+1),
release: make(chan struct{}),
}
t.Cleanup(func() { close(publisher.release) })
svc := &Service{nodeID: "robot-display-cap", robotDisplayPublisher: publisher}
record, err := outbox.Build("robot-display-cap-room", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{})
if err != nil {
t.Fatalf("build capped robot display record: %v", err)
}
for index := 0; index < robotDisplayMaxConcurrentPublishes+1; index++ {
svc.publishRobotDisplayRecordsBestEffort(context.Background(), []outbox.Record{record})
}
if got := len(svc.robotDisplayPublishSlots); got != robotDisplayMaxConcurrentPublishes {
t.Fatalf("robot display active publishers = %d, want hard cap %d", got, robotDisplayMaxConcurrentPublishes)
}
deadline := time.NewTimer(time.Second)
defer deadline.Stop()
for started := 0; started < robotDisplayMaxConcurrentPublishes; started++ {
select {
case <-publisher.started:
case <-deadline.C:
t.Fatalf("only %d capped robot publishers started", started)
}
}
select {
case <-publisher.started:
t.Fatal("robot display publisher exceeded its concurrency cap")
default:
}
}
func TestClaimPendingOutboxFindsNonDefaultApp(t *testing.T) {
repository := &crossAppOutboxRepository{}
svc := &Service{nodeID: "room-cross-app", repository: repository}

View File

@ -11,8 +11,10 @@ import (
)
const (
// robotDisplayPublishTimeout 是机器人展示直发 IM 的单条上限;超时或失败只记日志,不影响已提交房间状态
// robotDisplayPublishTimeout 是机器人展示直发 IM 的单条上限;超时或失败只记日志,也绝不能补写数据库或 MQ
robotDisplayPublishTimeout = 3 * time.Second
// robotDisplayMaxConcurrentPublishes 给高频机器人展示设置进程级硬上限,避免腾讯 IM 变慢时无界堆积 goroutine。
robotDisplayMaxConcurrentPublishes = 32
)
func (s *Service) publishRobotDisplayRecordsBestEffort(ctx context.Context, records []outbox.Record) {
@ -20,7 +22,7 @@ func (s *Service) publishRobotDisplayRecordsBestEffort(ctx context.Context, reco
return
}
if s.robotDisplayPublisher == nil {
// 缺少腾讯 IM publisher 时不能阻断房间命令,只把整批展示事件视为 best-effort 投递失败。
// 缺少腾讯 IM publisher 时只把整批展示事件视为 best-effort 投递失败,不创建持久化补偿事实
logx.Warn(ctx, "robot_display_im_publish_skipped",
slog.String("node_id", s.nodeID),
slog.Int("record_count", len(records)),
@ -37,8 +39,25 @@ func (s *Service) publishRobotDisplayRecordsBestEffort(ctx context.Context, reco
}
}
sortOutboxRecords(copied)
s.robotDisplayPublishOnce.Do(func() {
s.robotDisplayPublishSlots = make(chan struct{}, robotDisplayMaxConcurrentPublishes)
})
select {
case s.robotDisplayPublishSlots <- struct{}{}:
case <-ctx.Done():
return
default:
// 机器人消息没有业务事实语义;发布槽位耗尽时只丢当前展示,不能排队到 DB、MQ 或无界内存。
logx.Warn(ctx, "robot_display_im_publish_skipped",
slog.String("node_id", s.nodeID),
slog.Int("record_count", len(copied)),
slog.String("reason", "publisher_busy"),
)
return
}
go func(records []outbox.Record) {
defer func() { <-s.robotDisplayPublishSlots }()
for _, record := range records {
if record.Envelope == nil {
logx.Warn(ctx, "robot_display_im_publish_skipped",
@ -54,7 +73,7 @@ func (s *Service) publishRobotDisplayRecordsBestEffort(ctx context.Context, reco
err := s.robotDisplayPublisher.PublishOutboxEvent(publishCtx, record.Envelope)
cancel()
if err != nil {
// 机器人展示事件允许极小概率丢失;这里只暴露日志,客户端靠房间快照恢复最终状态
// 机器人展示事件允许极小概率丢失;这里只有日志且不补偿,房间快照只保证真实房态不被这次失败污染
logx.Warn(ctx, "robot_display_im_publish_failed",
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),

View File

@ -64,7 +64,7 @@ func (s *robotDisplaySampler) filter(records []outbox.Record, window time.Durati
newestWindow = windowStartMS
}
if last, exists := s.lastWindow[key]; exists && windowStartMS <= last {
// 展示事件过密时只丢机器人 IM 展示Room Cell 命令、房间热度、榜单和火箭状态已经在进入这里前完成计算
// 机器人送礼没有 Room Cell 持久状态;展示过密时直接丢当前窗口重复 IM下一窗口仍可正常展示
continue
}
s.lastWindow[key] = windowStartMS

View File

@ -38,6 +38,9 @@ type rocketTestWallet struct {
lastDebit *walletv1.DebitGiftRequest
debitRequests []*walletv1.DebitGiftRequest
lastBatch *walletv1.BatchDebitGiftRequest
robotDebitRequests []*walletv1.DebitRobotGiftRequest
robotGiftConfigs []*walletv1.GiftConfig
robotCatalogRequests []*walletv1.ListGiftConfigsRequest
balanceResponses []*walletv1.GetBalancesResponse
balanceRequests []*walletv1.GetBalancesRequest
vipResponses map[int64]*walletv1.GetMyVipResponse
@ -354,6 +357,7 @@ func (w *rocketTestWallet) BatchDebitGift(_ context.Context, req *walletv1.Batch
}
func (w *rocketTestWallet) DebitRobotGift(_ context.Context, req *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) {
w.robotDebitRequests = append(w.robotDebitRequests, proto.Clone(req).(*walletv1.DebitRobotGiftRequest))
if len(w.debits) == 0 {
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-robot-default", HeatValue: 1, CoinSpent: 1, ChargeAmount: 1, ChargeAssetType: "ROBOT_COIN"}, nil
}
@ -362,6 +366,15 @@ func (w *rocketTestWallet) DebitRobotGift(_ context.Context, req *walletv1.Debit
return next, nil
}
func (w *rocketTestWallet) ListGiftConfigs(_ context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) {
w.robotCatalogRequests = append(w.robotCatalogRequests, proto.Clone(req).(*walletv1.ListGiftConfigsRequest))
items := make([]*walletv1.GiftConfig, 0, len(w.robotGiftConfigs))
for _, gift := range w.robotGiftConfigs {
items = append(items, proto.Clone(gift).(*walletv1.GiftConfig))
}
return &walletv1.ListGiftConfigsResponse{Gifts: items, Total: int64(len(items))}, nil
}
func joinReceiptIDs(left string, right string) string {
if left == "" {
return right

View File

@ -95,8 +95,18 @@ type Service struct {
roomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
// roomRocketProgressCoalescer 把高频燃料变化压成每秒每轮一条 outbox降低 room_outbox/MQ/IM 写放大。
roomRocketProgressCoalescer *roomRocketProgressCoalescer
// robotDisplaySampler 把同房间同展示类型在短窗口内收敛成一条直发 IM,房间命令和状态仍完整执行
// robotDisplaySampler 把同房间同展示类型在短窗口内收敛成一条直发 IM;机器人送礼本身不进入房间持久化命令链路
robotDisplaySampler *robotDisplaySampler
// robotDisplayPublishOnce 让手工构造 Service 的内部测试和生产 New 都能安全初始化有界发布槽位。
robotDisplayPublishOnce sync.Once
// robotDisplayPublishSlots 限制腾讯 IM 慢请求占用的 goroutine槽位满时只丢展示绝不回写 DB/MQ。
robotDisplayPublishSlots chan struct{}
// robotGiftCatalogMu 保护机器人展示使用的只读礼物目录缓存;缓存只减少 wallet 查询,不保存送礼事实。
robotGiftCatalogMu sync.Mutex
// robotGiftCatalog 按 App 和房间区域缓存 active 礼物展示快照,绝不能承载余额、流水或幂等语义。
robotGiftCatalog map[robotGiftCatalogKey]robotGiftCatalogEntry
// robotGiftCatalogLoads 只保存进行中的 App/区域目录加载;等待者共享结果且可响应自身 ctx 取消。
robotGiftCatalogLoads map[robotGiftCatalogKey]*robotGiftCatalogLoad
// roomGiftLeaderboard 只保存房间维度贡献值 zset不承载 Room Cell 核心状态。
roomGiftLeaderboard RoomGiftLeaderboardStore
// roomListCache 只承载发现页展示顺序MySQL 仍然是房间列表和置顶事实 owner。
@ -278,6 +288,8 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler,
roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(cfg.RoomDisplayPublisher),
robotDisplaySampler: newRobotDisplaySampler(),
robotGiftCatalog: make(map[robotGiftCatalogKey]robotGiftCatalogEntry),
robotGiftCatalogLoads: make(map[robotGiftCatalogKey]*robotGiftCatalogLoad),
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
roomListCache: cfg.RoomListCache,
luckyGiftSendLocker: cfg.LuckyGiftSendLocker,

View File

@ -10,6 +10,8 @@ import (
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/xerr"
"hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
@ -104,18 +106,13 @@ func TestRoomUserGiftValueFollowsMicChangeAndResetsAfterLeave(t *testing.T) {
}
}
func TestRobotGiftSkipsMainHeatAndRankOutbox(t *testing.T) {
func TestRobotGiftIsDisplayOnlyWithoutRoomPersistenceOrWalletDebit(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
BillingReceiptId: "receipt-robot-display-only",
CoinSpent: 100,
ChargeAmount: 100,
HeatValue: 100,
ChargeAssetType: "ROBOT_COIN",
GiftTypeCode: "robot",
}}}
wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
robotGiftConfigForTest("robot-display-gift", 100, "normal"),
}}
robotDisplay := newRecordingRobotDisplayPublisher()
svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-display-boundary", robotDisplay)
@ -125,16 +122,33 @@ func TestRobotGiftSkipsMainHeatAndRankOutbox(t *testing.T) {
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID)
beforeMain := outboxEventCounts(t, ctx, repository)
trackedTables := []string{"room_gift_operations", "room_command_log", "room_snapshots", "room_outbox", "room_list_entries"}
beforeRows := roomTableRows(t, repository, roomID, trackedTables)
if _, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
resp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
Meta: rocketMeta(roomID, senderID, "robot-display-only"),
TargetUserID: targetID,
GiftID: "robot-display-gift",
GiftCount: 1,
RobotUserIDs: []int64{senderID, targetID},
}); err != nil {
RealRoomHeat: true,
})
if err != nil {
t.Fatalf("robot send gift failed: %v", err)
}
if resp.GetRoomHeat() != 0 || len(resp.GetGiftRank()) != 0 {
t.Fatalf("robot display must not change room heat or rank: %+v", resp)
}
if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 {
t.Fatalf("robot display must not change target gift value: %+v", user)
}
if len(wallet.robotDebitRequests) != 0 || len(wallet.debitRequests) != 0 || wallet.lastBatch != nil {
t.Fatalf("robot display must not call any wallet debit: robot=%d single=%d batch=%v", len(wallet.robotDebitRequests), len(wallet.debitRequests), wallet.lastBatch)
}
if len(wallet.robotCatalogRequests) != 1 || !wallet.robotCatalogRequests[0].GetActiveOnly() || !wallet.robotCatalogRequests[0].GetFilterRegion() {
t.Fatalf("robot display must only read the active regional gift catalog once: %+v", wallet.robotCatalogRequests)
}
assertRoomTableRowsUnchanged(t, repository, roomID, beforeRows)
afterMain := outboxEventCounts(t, ctx, repository)
if delta := afterMain["RoomGiftSent"] - beforeMain["RoomGiftSent"]; delta != 0 {
@ -149,16 +163,100 @@ func TestRobotGiftSkipsMainHeatAndRankOutbox(t *testing.T) {
waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
"RoomGiftSent": 1,
"RoomHeatChanged": 1,
"RoomRankChanged": 1,
})
giftEvents := robotDisplay.roomGiftSentEvents(t)
if len(giftEvents) != 1 {
t.Fatalf("robot display must publish exactly one gift event: %+v", giftEvents)
}
if event := giftEvents[0]; event.GetGiftValue() != 0 || event.GetTargetGiftValue() != 0 || event.GetCoinSpent() != 100 {
t.Fatalf("robot display must preserve visual price but expose zero business contribution: %+v", event)
}
if event := giftEvents[0]; event.GetGiftIconUrl() == "" || event.GetGiftAnimationUrl() == "" {
t.Fatalf("robot display must keep catalog visual assets: %+v", event)
}
if counts := robotDisplay.counts(); counts["RoomHeatChanged"] != 0 || counts["RoomRankChanged"] != 0 {
t.Fatalf("robot display must not publish fake room state changes: %+v", counts)
}
}
func TestRobotGiftDownsamplesRobotDisplayWithoutSkippingRoomState(t *testing.T) {
func TestRobotGiftMissingRoomReturnsNotFoundWithoutCatalogOrDebit(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 15, 0, 0, time.UTC)}
wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
robotGiftConfigForTest("missing-room-gift", 100, "normal"),
}}
display := newRecordingRobotDisplayPublisher()
svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-missing-room", display)
_, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
Meta: rocketMeta("missing-robot-room", 65001, "robot-missing-room"),
TargetUserID: 65002,
GiftID: "missing-room-gift",
GiftCount: 1,
RobotUserIDs: []int64{65001, 65002},
})
if xerr.CodeOf(err) != xerr.NotFound {
t.Fatalf("missing robot room must return not found, got %v", err)
}
if len(wallet.robotCatalogRequests) != 0 || len(wallet.robotDebitRequests) != 0 || len(display.envelopes) != 0 {
t.Fatalf("missing room must stop before catalog, debit and IM: catalog=%d debits=%d events=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests), len(display.envelopes))
}
}
func TestRobotGiftLostOwnershipStopsBeforeCatalogDebitAndIM(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 20, 0, 0, time.UTC)}
wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
robotGiftConfigForTest("lost-owner-gift", 100, "normal"),
}}
display := newRecordingRobotDisplayPublisher()
directory := router.NewMemoryDirectory()
svc := roomservice.New(roomservice.Config{
NodeID: "node-robot-old-owner",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 100,
Clock: now,
RobotDisplayPublisher: display,
}, directory, repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "robot-room-lost-owner"
senderID := int64(65101)
targetID := int64(65102)
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID)
key := roomservice.RuntimeRoomKey(appcode.Default, roomID)
directory.ForceExpire(key)
if lease, err := directory.EnsureOwner(ctx, key, "node-robot-new-owner", now.Now(), 10*time.Second); err != nil || lease.NodeID != "node-robot-new-owner" {
t.Fatalf("move robot room ownership failed: lease=%+v err=%v", lease, err)
}
beforeRows := roomTableRows(t, repository, roomID, []string{"room_gift_operations", "room_command_log", "room_snapshots", "room_outbox", "room_list_entries"})
_, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
Meta: rocketMeta(roomID, senderID, "robot-lost-owner"),
TargetUserID: targetID,
GiftID: "lost-owner-gift",
GiftCount: 1,
RobotUserIDs: []int64{senderID, targetID},
})
if xerr.CodeOf(err) != xerr.Conflict {
t.Fatalf("lost robot owner must return conflict, got %v", err)
}
if len(wallet.robotCatalogRequests) != 0 || len(wallet.robotDebitRequests) != 0 || len(display.envelopes) != 0 {
t.Fatalf("lost owner must stop before catalog, debit and IM: catalog=%d debits=%d events=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests), len(display.envelopes))
}
assertRoomTableRowsUnchanged(t, repository, roomID, beforeRows)
}
func TestRobotGiftDownsamplesDisplayAndKeepsRoomStateUnchanged(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{}
wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
robotGiftConfigForTest("robot-sampler-gift", 1, "normal"),
}}
robotDisplay := newRecordingRobotDisplayPublisher()
svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-display-sampler", robotDisplay)
@ -175,6 +273,7 @@ func TestRobotGiftDownsamplesRobotDisplayWithoutSkippingRoomState(t *testing.T)
GiftID: "robot-sampler-gift",
GiftCount: 1,
RobotUserIDs: []int64{senderID, targetID},
RealRoomHeat: true,
})
if err != nil {
t.Fatalf("first robot send gift failed: %v", err)
@ -185,19 +284,23 @@ func TestRobotGiftDownsamplesRobotDisplayWithoutSkippingRoomState(t *testing.T)
GiftID: "robot-sampler-gift",
GiftCount: 1,
RobotUserIDs: []int64{senderID, targetID},
RealRoomHeat: true,
})
if err != nil {
t.Fatalf("second robot send gift failed: %v", err)
}
if firstResp.GetRoomHeat() != 1 || secondResp.GetRoomHeat() != 2 {
t.Fatalf("robot gift sampler must not skip room heat updates: first=%d second=%d", firstResp.GetRoomHeat(), secondResp.GetRoomHeat())
if firstResp.GetRoomHeat() != 0 || secondResp.GetRoomHeat() != 0 {
t.Fatalf("robot display must keep room heat unchanged: first=%d second=%d", firstResp.GetRoomHeat(), secondResp.GetRoomHeat())
}
if len(secondResp.GetGiftRank()) == 0 || secondResp.GetGiftRank()[0].GetUserId() != senderID || secondResp.GetGiftRank()[0].GetGiftValue() != 2 {
t.Fatalf("robot gift sampler must not skip room rank updates: %+v", secondResp.GetGiftRank())
if len(secondResp.GetGiftRank()) != 0 {
t.Fatalf("robot display must keep room rank unchanged: %+v", secondResp.GetGiftRank())
}
if user := onlineUserByID(secondResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 2 {
t.Fatalf("robot gift sampler must not skip target gift value: %+v", user)
if user := onlineUserByID(secondResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 {
t.Fatalf("robot display must keep target gift value unchanged: %+v", user)
}
if len(wallet.robotDebitRequests) != 0 || len(wallet.robotCatalogRequests) != 1 {
t.Fatalf("two sampled displays must use one cached catalog read and zero debits: catalog=%d debits=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests))
}
afterMain := outboxEventCounts(t, ctx, repository)
@ -208,23 +311,16 @@ func TestRobotGiftDownsamplesRobotDisplayWithoutSkippingRoomState(t *testing.T)
}
waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
"RoomGiftSent": 1,
"RoomHeatChanged": 1,
"RoomRankChanged": 1,
})
}
func TestRealRoomRobotGiftKeepsRoomContributionAndDirectPublishesRobotDisplay(t *testing.T) {
func TestHumanRoomRobotGiftIsDisplayOnlyWithoutContributionOrRocket(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 30, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
BillingReceiptId: "receipt-real-room-robot",
CoinSpent: 100,
ChargeAmount: 100,
HeatValue: 100,
ChargeAssetType: "ROBOT_COIN",
GiftTypeCode: "normal",
}}}
wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
robotGiftConfigForTest("real-room-robot-gift", 100, "super_lucky"),
}}
leaderboard := &recordingRoomGiftLeaderboard{}
robotDisplay := newRecordingRobotDisplayPublisher()
svc := roomservice.New(roomservice.Config{
@ -250,6 +346,8 @@ func TestRealRoomRobotGiftKeepsRoomContributionAndDirectPublishesRobotDisplay(t
t.Fatalf("target mic up failed: %v", err)
}
beforeMain := outboxEventCounts(t, ctx, repository)
trackedTables := []string{"room_gift_operations", "room_command_log", "room_snapshots", "room_outbox", "room_list_entries", "room_user_gift_stats", "room_user_contribution_period_stats"}
beforeRows := roomTableRows(t, repository, roomID, trackedTables)
resp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
Meta: rocketMeta(roomID, senderID, "real-room-robot-gift"),
@ -258,32 +356,38 @@ func TestRealRoomRobotGiftKeepsRoomContributionAndDirectPublishesRobotDisplay(t
GiftCount: 1,
PoolID: "human_robot_super_lucky_display",
RobotUserIDs: []int64{senderID, targetID},
SyntheticMultiplierPPM: 5_000_000,
SyntheticMultiplierPPM: 20_000_000,
RealRoomHeat: true,
})
if err != nil {
t.Fatalf("real room robot gift failed: %v", err)
}
if resp.GetRoomHeat() != 100 {
t.Fatalf("real room robot gift must update room heat, got %d", resp.GetRoomHeat())
if resp.GetRoomHeat() != 0 {
t.Fatalf("human-room robot display must not update room heat, got %d", resp.GetRoomHeat())
}
if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 100 {
t.Fatalf("real room robot gift must update target gift value: %+v", user)
if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 {
t.Fatalf("human-room robot display must not update target gift value: %+v", user)
}
if seat := seatByNo(resp.GetRoom(), 1); seat == nil || seat.GetGiftValue() != 100 {
t.Fatalf("real room robot gift must update mic gift value: %+v", seat)
if seat := seatByNo(resp.GetRoom(), 1); seat == nil || seat.GetGiftValue() != 0 {
t.Fatalf("human-room robot display must not update mic gift value: %+v", seat)
}
if len(resp.GetGiftRank()) == 0 || resp.GetGiftRank()[0].GetUserId() != senderID || resp.GetGiftRank()[0].GetGiftValue() != 100 {
t.Fatalf("real room robot gift must update room gift rank: %+v", resp.GetGiftRank())
if len(resp.GetGiftRank()) != 0 {
t.Fatalf("human-room robot display must not update room gift rank: %+v", resp.GetGiftRank())
}
if resp.GetRocket() == nil || len(resp.GetRocket().GetPendingLaunches()) != 1 {
t.Fatalf("real room robot gift must fuel and ignite room rocket: %+v", resp.GetRocket())
if rocket := resp.GetRocket(); rocket != nil && (rocket.GetCurrentFuel() != 0 || len(rocket.GetPendingLaunches()) != 0) {
t.Fatalf("human-room robot display must not fuel or ignite room rocket: %+v", rocket)
}
if len(leaderboard.increments) != 1 || leaderboard.increments[0].RoomID != roomID || leaderboard.increments[0].CoinSpent != 100 {
t.Fatalf("real room robot gift must keep cross-room contribution increment: %+v", leaderboard.increments)
if len(leaderboard.increments) != 0 {
t.Fatalf("human-room robot display must not update cross-room contribution: %+v", leaderboard.increments)
}
flushRocketProgress(t, ctx, svc, now)
if lucky := resp.GetLuckyGift(); lucky == nil || lucky.GetMultiplierPpm() != 20_000_000 || lucky.GetEffectiveRewardCoins() != 2_000 {
t.Fatalf("human-room robot lucky display must derive reward from configured multiplier: %+v", lucky)
}
if len(wallet.robotDebitRequests) != 0 || len(wallet.robotCatalogRequests) != 1 {
t.Fatalf("human-room robot display must use one catalog read and zero debits: catalog=%d debits=%d", len(wallet.robotCatalogRequests), len(wallet.robotDebitRequests))
}
assertRoomTableRowsUnchanged(t, repository, roomID, beforeRows)
afterMain := outboxEventCounts(t, ctx, repository)
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} {
@ -293,12 +397,11 @@ func TestRealRoomRobotGiftKeepsRoomContributionAndDirectPublishesRobotDisplay(t
}
waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
"RoomGiftSent": 1,
"RoomHeatChanged": 1,
"RoomRankChanged": 1,
"RoomRobotLuckyGiftDrawn": 1,
"RoomRocketFuelChanged": 1,
"RoomRocketIgnited": 1,
})
if counts := robotDisplay.counts(); counts["RoomHeatChanged"] != 0 || counts["RoomRankChanged"] != 0 || counts["RoomRocketFuelChanged"] != 0 || counts["RoomRocketIgnited"] != 0 {
t.Fatalf("human-room robot display must not publish fake room state changes: %+v", counts)
}
}
func TestRoomUserGiftValueRecoversFromCommandLog(t *testing.T) {
@ -362,6 +465,40 @@ func TestRoomUserGiftValueRecoversFromCommandLog(t *testing.T) {
}
}
func robotGiftConfigForTest(giftID string, coinPrice int64, giftType string) *walletv1.GiftConfig {
return &walletv1.GiftConfig{
AppCode: appcode.Default,
GiftId: giftID,
Status: "active",
Name: giftID,
PriceVersion: "robot-display-v1",
CoinPrice: coinPrice,
HeatValue: coinPrice,
GiftTypeCode: giftType,
EffectTypes: []string{"center_flight"},
RegionIds: []int64{0},
Resource: &walletv1.Resource{ResourceType: "gift", Status: "active", PreviewUrl: "https://cdn.example/" + giftID + ".png", AnimationUrl: "https://cdn.example/" + giftID + ".svga"},
}
}
func roomTableRows(t *testing.T, repository *mysqltest.Repository, roomID string, tables []string) map[string]int64 {
t.Helper()
rows := make(map[string]int64, len(tables))
for _, table := range tables {
rows[table] = repository.CountRowsByRoom(table, roomID)
}
return rows
}
func assertRoomTableRowsUnchanged(t *testing.T, repository *mysqltest.Repository, roomID string, before map[string]int64) {
t.Helper()
for table, want := range before {
if got := repository.CountRowsByRoom(table, roomID); got != want {
t.Fatalf("robot display must not write %s: before=%d after=%d", table, want, got)
}
}
}
func outboxEventCounts(t *testing.T, ctx context.Context, repository *mysqltest.Repository) map[string]int {
t.Helper()
records, err := repository.ListPendingOutbox(ctx, 200)
@ -406,6 +543,25 @@ func (p *recordingRobotDisplayPublisher) counts() map[string]int {
return counts
}
func (p *recordingRobotDisplayPublisher) roomGiftSentEvents(t *testing.T) []*roomeventsv1.RoomGiftSent {
t.Helper()
p.mu.Lock()
defer p.mu.Unlock()
events := make([]*roomeventsv1.RoomGiftSent, 0, len(p.envelopes))
for _, envelope := range p.envelopes {
if envelope.GetEventType() != "RoomGiftSent" {
continue
}
var event roomeventsv1.RoomGiftSent
if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
t.Fatalf("decode robot RoomGiftSent failed: %v", err)
}
events = append(events, &event)
}
return events
}
func waitForRobotDisplayCounts(t *testing.T, publisher *recordingRobotDisplayPublisher, want map[string]int) map[string]int {
t.Helper()