fix(room): make robot gifts display-only
This commit is contained in:
parent
e3bf386acf
commit
de9c1e9eef
@ -49,6 +49,9 @@
|
|||||||
| `real_room_robot_total_gift_coin` | 真人房机器人总礼物 | 上面三类机器人礼物金币合计。 |
|
| `real_room_robot_total_gift_coin` | 真人房机器人总礼物 | 上面三类机器人礼物金币合计。 |
|
||||||
| `real_room_robot_gift_room_count` | 真人房机器人投放房间数 | 有真人房机器人礼物的房间去重数。 |
|
| `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`。 |
|
| `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_turnover` | lucky 礼物流水 | `RoomGiftSent.pool_id` 非空的送礼金币。 |
|
||||||
| `lucky_gift_payout` | lucky 礼物产出 | `WalletLuckyGiftRewardCredited.amount`,UTC 查询会优先用 activity 的 lucky pool 日表覆盖旧统计行。 |
|
| `lucky_gift_payout` | lucky 礼物产出 | `WalletLuckyGiftRewardCredited.amount`,UTC 查询会优先用 activity 的 lucky pool 日表覆盖旧统计行。 |
|
||||||
| `lucky_gift_payers` | lucky 礼物付费用户 | 发送 lucky 礼物的用户按日去重。 |
|
| `lucky_gift_payers` | lucky 礼物付费用户 | 发送 lucky 礼物的用户按日去重。 |
|
||||||
|
|||||||
@ -37,11 +37,9 @@
|
|||||||
|
|
||||||
### wallet-service
|
### wallet-service
|
||||||
|
|
||||||
- 机器人礼物扣费入口:`DebitRobotGift`。
|
- 只通过 `ListGiftConfigs` 提供 active、区域可用的礼物展示快照。
|
||||||
- 使用专用资产 `ROBOT_COIN`。
|
- `RobotSendGift` 不调用 `DebitRobotGift`,不创建机器人钱包账户、交易、分录或 wallet outbox。
|
||||||
- 自动补足机器人专用金币后扣减。
|
- 礼物目录在 room-service 按 App/区域短缓存;缓存只保存配置,不保存任何送礼事实。
|
||||||
- 不发出真实 `WalletGiftDebited` 事件。
|
|
||||||
- 不写主播周期钻石和真实礼物墙。
|
|
||||||
|
|
||||||
### activity/statistics
|
### activity/statistics
|
||||||
|
|
||||||
@ -101,7 +99,7 @@ room-service gRPC:
|
|||||||
|
|
||||||
wallet-service gRPC:
|
wallet-service gRPC:
|
||||||
|
|
||||||
- `DebitRobotGift`
|
- `ListGiftConfigs`(只读)
|
||||||
|
|
||||||
## 创建流程
|
## 创建流程
|
||||||
|
|
||||||
@ -155,24 +153,22 @@ room-service 启动时会扫描所有 active 机器人房间并恢复运行时
|
|||||||
|
|
||||||
room-service 在机器人送礼时强校验:
|
room-service 在机器人送礼时强校验:
|
||||||
|
|
||||||
- 当前房间必须是机器人房间。
|
- 专属机器人房必须带 `robot_room=true`;真人房机器人使用运行时机器人池校验,不把真人加入允许集合。
|
||||||
- sender 必须在该机器人房间 `robot_user_ids_json` 内。
|
- sender 必须在该机器人房间 `robot_user_ids_json` 内。
|
||||||
- target 必须在同一机器人房间 `robot_user_ids_json` 内。
|
- target 必须在同一机器人房间 `robot_user_ids_json` 内。
|
||||||
- sender 不能给真人送礼。
|
- sender 不能给真人送礼。
|
||||||
- 当前机器人礼物只允许单目标调用。
|
- 当前机器人礼物只允许单目标调用。
|
||||||
|
|
||||||
## 钱包隔离
|
## 纯展示隔离
|
||||||
|
|
||||||
机器人礼物使用 `DebitRobotGift`:
|
`RobotSendGift` 不进入通用 `sendGift/mutateRoom`,只执行:
|
||||||
|
|
||||||
- 资产类型固定为 `ROBOT_COIN`。
|
1. 获得或续租 Redis owner lease,并只读当前 Room Cell(缺失时从 MySQL 只读恢复),校验 sender、target 和机器人池。
|
||||||
- 如果机器人 `ROBOT_COIN` 不足,同一事务内自动补足。
|
2. 从 wallet 礼物目录读取并短缓存名称、图标、动画、类型和展示价值。
|
||||||
- 立即扣减本次礼物价格。
|
3. 在内存构造 `RoomGiftSent`;幸运礼物额外构造 `RoomRobotLuckyGiftDrawn`。
|
||||||
- 交易类型为 `robot_gift_debit`。
|
4. 经过 2 秒展示采样后,best-effort 直发腾讯云 IM。
|
||||||
- 不写真实 `gift_debit`。
|
|
||||||
- 不发 `WalletGiftDebited`。
|
这条链路不写 `room_gift_operations`、`room_command_log`、`room_snapshots`、`room_outbox`、房间贡献统计、钱包账本或任何 outbox,也不调用 RocketMQ producer。内存里的 `outbox.Record` 只复用 protobuf 信封结构,不是持久化 outbox。
|
||||||
- 不写 `user_gift_wall`。
|
|
||||||
- 不写 `host_period_diamond_accounts`。
|
|
||||||
|
|
||||||
## 幸运礼物隔离
|
## 幸运礼物隔离
|
||||||
|
|
||||||
@ -189,25 +185,14 @@ room-service 只生成展示事件:
|
|||||||
|
|
||||||
## 排行榜与统计隔离
|
## 排行榜与统计隔离
|
||||||
|
|
||||||
`RoomGiftSent` 增加:
|
机器人展示用 `RoomGiftSent` 保留:
|
||||||
|
|
||||||
- `is_robot_gift`
|
- `is_robot_gift`
|
||||||
- `synthetic_lucky_gift`
|
- `synthetic_lucky_gift`
|
||||||
|
|
||||||
下游服务看到 `is_robot_gift=true` 时跳过:
|
该事件不会进入 room outbox/MQ,所以下游活动、CP、任务、成长和 statistics 根本不会收到机器人送礼事实;`is_robot_gift=true` 只供 IM 展示兼容。
|
||||||
|
|
||||||
- 活动播报统计
|
机器人送礼同时不修改房间热度、礼物榜、麦位 `gift_value`、火箭燃料、周贡献或 Redis 跨房贡献榜。真人房机器人也遵守同一边界;历史 `RealRoomHeat` 参数仅用于区分参与者校验范围,不能授权真实贡献。
|
||||||
- 周星
|
|
||||||
- 房间流水奖励
|
|
||||||
- 每日任务
|
|
||||||
- 成长值
|
|
||||||
- statistics 礼物消费与幸运礼物统计
|
|
||||||
|
|
||||||
room-service 本地仍允许机器人房内:
|
|
||||||
|
|
||||||
- 房间热度增长
|
|
||||||
- 麦位热度增长
|
|
||||||
- 房内展示事件发送
|
|
||||||
|
|
||||||
## 本地接口测试
|
## 本地接口测试
|
||||||
|
|
||||||
@ -232,7 +217,7 @@ go run ./tools/robot-room-api-test
|
|||||||
5. 创建机器人房间。
|
5. 创建机器人房间。
|
||||||
6. 查询 active 列表。
|
6. 查询 active 列表。
|
||||||
7. 停止、启动、再次停止机器人房间。
|
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 机器人房间占用。
|
- 同一机器人账号不能被多个 active 机器人房间占用。
|
||||||
- 停止机器人房间只停止 runtime,不删除房间。
|
- 停止机器人房间只停止 runtime,不删除房间。
|
||||||
- 如果需要彻底释放机器人账号,应增加删除/归档能力后再开放。
|
- 如果需要彻底释放机器人账号,应增加删除/归档能力后再开放。
|
||||||
|
- 腾讯云 IM 是唯一实时展示通道,失败不补写数据库或 MQ,也不重试成业务事实;丢失的动画/公屏不恢复,房间快照只保证真实热度、榜单和麦位值不被机器人展示污染。
|
||||||
|
|||||||
@ -164,7 +164,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
roomPublisher := integration.NewNoopRoomEventPublisher()
|
roomPublisher := integration.NewNoopRoomEventPublisher()
|
||||||
outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
|
outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
|
||||||
var rtcUserRemover integration.RTCUserRemover
|
var rtcUserRemover integration.RTCUserRemover
|
||||||
var tencentPublisher *integration.TencentIMPublisher
|
var tencentPublisher integration.OutboxPublisher
|
||||||
if cfg.TencentIM.Enabled {
|
if cfg.TencentIM.Enabled {
|
||||||
// 腾讯云 IM 替代自研 IM;room-service 只负责服务端 REST 群消息桥接。
|
// 腾讯云 IM 替代自研 IM;room-service 只负责服务端 REST 群消息桥接。
|
||||||
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||||
@ -175,8 +175,9 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
_ = redisClient.Close()
|
_ = redisClient.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
tencentPublisher = integration.NewTencentIMPublisher(tencentClient)
|
concreteTencentPublisher := integration.NewTencentIMPublisher(tencentClient)
|
||||||
roomPublisher = tencentPublisher
|
tencentPublisher = concreteTencentPublisher
|
||||||
|
roomPublisher = concreteTencentPublisher
|
||||||
}
|
}
|
||||||
if cfg.TencentRTC.Enabled {
|
if cfg.TencentRTC.Enabled {
|
||||||
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
|
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
@ -321,7 +321,6 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
|||||||
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
|
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
|
||||||
"coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()),
|
"coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()),
|
||||||
"billing_receipt_id": body.GetBillingReceiptId(),
|
"billing_receipt_id": body.GetBillingReceiptId(),
|
||||||
"target_gift_value": fmt.Sprintf("%d", body.GetTargetGiftValue()),
|
|
||||||
"gift_type_code": body.GetGiftTypeCode(),
|
"gift_type_code": body.GetGiftTypeCode(),
|
||||||
"cp_relation_type": body.GetCpRelationType(),
|
"cp_relation_type": body.GetCpRelationType(),
|
||||||
"gift_name": body.GetGiftName(),
|
"gift_name": body.GetGiftName(),
|
||||||
@ -337,6 +336,14 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
|||||||
"receiver_display_user_id": body.GetReceiverDisplayUserId(),
|
"receiver_display_user_id": body.GetReceiverDisplayUserId(),
|
||||||
"receiver_pretty_display_user_id": body.GetReceiverPrettyDisplayUserId(),
|
"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
|
return base, true, nil
|
||||||
case "RoomGiftBatchSent":
|
case "RoomGiftBatchSent":
|
||||||
// 批量送礼展示事件只面向新客户端;逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。
|
// 批量送礼展示事件只面向新客户端;逐目标 RoomGiftSent 仍由 outbox 给统计、CP 和礼物墙消费。
|
||||||
@ -448,9 +455,13 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
|||||||
}
|
}
|
||||||
base.ActorUserID = body.GetSenderUserId()
|
base.ActorUserID = body.GetSenderUserId()
|
||||||
base.TargetUserID = body.GetTargetUserId()
|
base.TargetUserID = body.GetTargetUserId()
|
||||||
base.GiftValue = body.GetEffectiveRewardCoins()
|
// 中奖金额保留在 effective_reward_coins 供动画展示;顶层 gift_value 必须为 0,
|
||||||
|
// 否则 Flutter 会把纯展示中奖额累计进机器人发送者的成员贡献。
|
||||||
|
base.GiftValue = 0
|
||||||
base.Attributes = map[string]string{
|
base.Attributes = map[string]string{
|
||||||
"event_type": "lucky_gift_drawn",
|
"event_type": "lucky_gift_drawn",
|
||||||
|
// RoomEvent.GiftValue=0 会被 omitempty 省略;显式扁平 gift_value=0,防止 Flutter 回退读取中奖额并累计成员贡献。
|
||||||
|
"gift_value": "0",
|
||||||
"draw_id": body.GetDrawId(),
|
"draw_id": body.GetDrawId(),
|
||||||
"command_id": body.GetCommandId(),
|
"command_id": body.GetCommandId(),
|
||||||
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
|
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
|
||||||
|
|||||||
@ -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" {
|
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)
|
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) {
|
func TestRoomGiftSentBatchDisplayFactSkipsSingleTargetIM(t *testing.T) {
|
||||||
@ -291,16 +315,28 @@ func TestRoomRobotLuckyGiftDrawnPublishesLuckyGiftIMEvent(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RoomRobotLuckyGiftDrawn should decode: %v", err)
|
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)
|
t.Fatalf("robot lucky gift IM event mismatch: publish=%t event=%+v", publish, event)
|
||||||
}
|
}
|
||||||
if event.Attributes["draw_id"] != "robot_lucky_draw_1" ||
|
if event.Attributes["draw_id"] != "robot_lucky_draw_1" ||
|
||||||
event.Attributes["gift_id"] != "gift_lucky_1" ||
|
event.Attributes["gift_id"] != "gift_lucky_1" ||
|
||||||
event.Attributes["pool_id"] != "robot_lucky_display" ||
|
event.Attributes["pool_id"] != "robot_lucky_display" ||
|
||||||
|
event.Attributes["gift_value"] != "0" ||
|
||||||
event.Attributes["effective_reward_coins"] != "500" ||
|
event.Attributes["effective_reward_coins"] != "500" ||
|
||||||
event.Attributes["reward_status"] != "granted" {
|
event.Attributes["reward_status"] != "granted" {
|
||||||
t.Fatalf("robot lucky gift IM attributes mismatch: %+v", event.Attributes)
|
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) {
|
func TestRoomLuckyGiftDrawnPublishesGrantedOwnerFactAsLuckyGiftIMEvent(t *testing.T) {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/room-service/internal/room/outbox"
|
"hyapp/services/room-service/internal/room/outbox"
|
||||||
"hyapp/services/room-service/internal/room/rank"
|
"hyapp/services/room-service/internal/room/rank"
|
||||||
"hyapp/services/room-service/internal/room/state"
|
"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) {
|
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())
|
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||||
flow := newGiftFlow(s, ctx, req, options)
|
flow := newGiftFlow(s, ctx, req, options)
|
||||||
if recovery.CommandID != "" {
|
if recovery.CommandID != "" {
|
||||||
|
|||||||
@ -87,16 +87,17 @@ func newGiftFlow(s *Service, ctx context.Context, req *roomv1.SendGiftRequest, o
|
|||||||
SenderRegisteredAtMS: req.GetSenderRegisteredAtMs(),
|
SenderRegisteredAtMS: req.GetSenderRegisteredAtMs(),
|
||||||
TargetIsHost: req.GetTargetIsHost(),
|
TargetIsHost: req.GetTargetIsHost(),
|
||||||
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
|
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
|
||||||
TargetHostRegionID: req.GetTargetHostRegionId(),
|
TargetHostRegionID: req.GetTargetHostRegionId(),
|
||||||
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
|
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
|
||||||
TargetHostScopes: giftTargetHostScopesFromProto(req.GetTargetHostScopes()),
|
TargetHostScopes: giftTargetHostScopesFromProto(req.GetTargetHostScopes()),
|
||||||
SenderDisplayProfile: giftDisplayProfileFromProto(req.GetSenderDisplayProfile()),
|
SenderDisplayProfile: giftDisplayProfileFromProto(req.GetSenderDisplayProfile()),
|
||||||
TargetDisplayProfiles: giftDisplayProfilesFromProto(req.GetTargetDisplayProfiles()),
|
TargetDisplayProfiles: giftDisplayProfilesFromProto(req.GetTargetDisplayProfiles()),
|
||||||
RobotGift: options.Robot.Enabled && !options.Robot.RealRoomHeat,
|
RobotGift: options.Robot.Enabled && !options.Robot.RealRoomHeat,
|
||||||
RobotWalletGift: options.Robot.Enabled,
|
RobotWalletGift: options.Robot.Enabled,
|
||||||
SyntheticLuckyGift: options.Robot.Enabled && strings.TrimSpace(req.GetPoolId()) != "",
|
SyntheticLuckyGift: options.Robot.Enabled && strings.TrimSpace(req.GetPoolId()) != "",
|
||||||
SyntheticLuckyRewardCoins: firstPositiveInt64(options.Robot.SyntheticRewardCoins, 0),
|
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 == "" {
|
if cmd.TargetType == "" {
|
||||||
cmd.TargetType = "user"
|
cmd.TargetType = "user"
|
||||||
|
|||||||
@ -3,6 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"math/bits"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -278,8 +280,11 @@ func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetB
|
|||||||
if len(targetBillings) == 0 || targetBillings[0].Billing == nil {
|
if len(targetBillings) == 0 || targetBillings[0].Billing == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, targetBillings[0].Billing.GetCoinSpent()*5)
|
coinSpent := targetBillings[0].Billing.GetCoinSpent()
|
||||||
multiplier := firstPositiveInt64(cmd.SyntheticLuckyMultiplierPPM, 5000000)
|
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())
|
drawID := fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli())
|
||||||
return &roomv1.LuckyGiftDrawResult{
|
return &roomv1.LuckyGiftDrawResult{
|
||||||
Enabled: true,
|
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 {
|
func giftFinalCoinBalanceAfter(billing *walletv1.DebitGiftResponse) int64 {
|
||||||
if billing == nil {
|
if billing == nil {
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@ -2,13 +2,46 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"math"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
roomv1 "hyapp.local/api/proto/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/pkg/xerr"
|
||||||
|
"hyapp/services/room-service/internal/integration"
|
||||||
|
"hyapp/services/room-service/internal/room/outbox"
|
||||||
"hyapp/services/room-service/internal/room/state"
|
"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 {
|
type RobotSendGiftInput struct {
|
||||||
Meta *roomv1.RequestMeta
|
Meta *roomv1.RequestMeta
|
||||||
TargetUserID int64
|
TargetUserID int64
|
||||||
@ -18,7 +51,8 @@ type RobotSendGiftInput struct {
|
|||||||
RobotUserIDs []int64
|
RobotUserIDs []int64
|
||||||
SyntheticRewardCoins int64
|
SyntheticRewardCoins int64
|
||||||
SyntheticMultiplierPPM int64
|
SyntheticMultiplierPPM int64
|
||||||
RealRoomHeat bool
|
// RealRoomHeat 是历史字段名;当前只表示“真人房机器人”校验范围,绝不再产生真实热度或任何持久化贡献。
|
||||||
|
RealRoomHeat bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type robotGiftOptions struct {
|
type robotGiftOptions struct {
|
||||||
@ -26,7 +60,8 @@ type robotGiftOptions struct {
|
|||||||
RobotUserIDs []int64
|
RobotUserIDs []int64
|
||||||
SyntheticRewardCoins int64
|
SyntheticRewardCoins int64
|
||||||
SyntheticMultiplierPPM int64
|
SyntheticMultiplierPPM int64
|
||||||
RealRoomHeat bool
|
// RealRoomHeat 仅供 validateBeforeDebit 区分专属机器人房和真人房机器人池,不能进入状态/账务结算。
|
||||||
|
RealRoomHeat bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (*roomv1.SendGiftResponse, error) {
|
func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (*roomv1.SendGiftResponse, error) {
|
||||||
@ -34,12 +69,13 @@ func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (
|
|||||||
Meta: input.Meta,
|
Meta: input.Meta,
|
||||||
TargetUserId: input.TargetUserID,
|
TargetUserId: input.TargetUserID,
|
||||||
TargetUserIds: []int64{input.TargetUserID},
|
TargetUserIds: []int64{input.TargetUserID},
|
||||||
GiftId: input.GiftID,
|
GiftId: strings.TrimSpace(input.GiftID),
|
||||||
GiftCount: input.GiftCount,
|
GiftCount: input.GiftCount,
|
||||||
TargetType: "user",
|
TargetType: "user",
|
||||||
PoolId: input.PoolID,
|
PoolId: input.PoolID,
|
||||||
}
|
}
|
||||||
return s.sendGift(ctx, req, giftSendOptions{
|
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||||
|
flow := newGiftFlow(s, ctx, req, giftSendOptions{
|
||||||
Robot: robotGiftOptions{
|
Robot: robotGiftOptions{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
RobotUserIDs: input.RobotUserIDs,
|
RobotUserIDs: input.RobotUserIDs,
|
||||||
@ -48,6 +84,300 @@ func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (
|
|||||||
RealRoomHeat: input.RealRoomHeat,
|
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 {
|
func requireRobotGiftParticipants(current *state.RoomState, senderUserID int64, targetUserIDs []int64, runtimeRobotUserIDs []int64, requireRobotRoom bool) error {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"math"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@ -13,6 +14,18 @@ import (
|
|||||||
"hyapp/services/room-service/internal/room/outbox"
|
"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 {
|
type luckyGiftDisplayRepository struct {
|
||||||
Repository
|
Repository
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,11 @@ type scopedOutboxPublisher struct {
|
|||||||
published chan scopedOutboxPublication
|
published chan scopedOutboxPublication
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type blockingRobotDisplayPublisher struct {
|
||||||
|
started chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
func newScopedOutboxPublisher() *scopedOutboxPublisher {
|
func newScopedOutboxPublisher() *scopedOutboxPublisher {
|
||||||
return &scopedOutboxPublisher{published: make(chan scopedOutboxPublication, 2)}
|
return &scopedOutboxPublisher{published: make(chan scopedOutboxPublication, 2)}
|
||||||
}
|
}
|
||||||
@ -32,6 +37,16 @@ func (p *scopedOutboxPublisher) PublishOutboxEvent(ctx context.Context, envelope
|
|||||||
return nil
|
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 {
|
type crossAppOutboxRepository struct {
|
||||||
Repository
|
Repository
|
||||||
claims []string
|
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) {
|
func TestClaimPendingOutboxFindsNonDefaultApp(t *testing.T) {
|
||||||
repository := &crossAppOutboxRepository{}
|
repository := &crossAppOutboxRepository{}
|
||||||
svc := &Service{nodeID: "room-cross-app", repository: repository}
|
svc := &Service{nodeID: "room-cross-app", repository: repository}
|
||||||
|
|||||||
@ -11,8 +11,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// robotDisplayPublishTimeout 是机器人展示直发 IM 的单条上限;超时或失败只记日志,不影响已提交房间状态。
|
// robotDisplayPublishTimeout 是机器人展示直发 IM 的单条上限;超时或失败只记日志,也绝不能补写数据库或 MQ。
|
||||||
robotDisplayPublishTimeout = 3 * time.Second
|
robotDisplayPublishTimeout = 3 * time.Second
|
||||||
|
// robotDisplayMaxConcurrentPublishes 给高频机器人展示设置进程级硬上限,避免腾讯 IM 变慢时无界堆积 goroutine。
|
||||||
|
robotDisplayMaxConcurrentPublishes = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) publishRobotDisplayRecordsBestEffort(ctx context.Context, records []outbox.Record) {
|
func (s *Service) publishRobotDisplayRecordsBestEffort(ctx context.Context, records []outbox.Record) {
|
||||||
@ -20,7 +22,7 @@ func (s *Service) publishRobotDisplayRecordsBestEffort(ctx context.Context, reco
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.robotDisplayPublisher == nil {
|
if s.robotDisplayPublisher == nil {
|
||||||
// 缺少腾讯 IM publisher 时不能阻断房间命令,只把整批展示事件视为 best-effort 投递失败。
|
// 缺少腾讯 IM publisher 时只把整批纯展示事件视为 best-effort 投递失败,不创建持久化补偿事实。
|
||||||
logx.Warn(ctx, "robot_display_im_publish_skipped",
|
logx.Warn(ctx, "robot_display_im_publish_skipped",
|
||||||
slog.String("node_id", s.nodeID),
|
slog.String("node_id", s.nodeID),
|
||||||
slog.Int("record_count", len(records)),
|
slog.Int("record_count", len(records)),
|
||||||
@ -37,8 +39,25 @@ func (s *Service) publishRobotDisplayRecordsBestEffort(ctx context.Context, reco
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
sortOutboxRecords(copied)
|
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) {
|
go func(records []outbox.Record) {
|
||||||
|
defer func() { <-s.robotDisplayPublishSlots }()
|
||||||
for _, record := range records {
|
for _, record := range records {
|
||||||
if record.Envelope == nil {
|
if record.Envelope == nil {
|
||||||
logx.Warn(ctx, "robot_display_im_publish_skipped",
|
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)
|
err := s.robotDisplayPublisher.PublishOutboxEvent(publishCtx, record.Envelope)
|
||||||
cancel()
|
cancel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 机器人展示事件允许极小概率丢失;这里只暴露日志,客户端靠房间快照恢复最终状态。
|
// 机器人展示事件允许极小概率丢失;这里只有日志且不补偿,房间快照只保证真实房态不被这次失败污染。
|
||||||
logx.Warn(ctx, "robot_display_im_publish_failed",
|
logx.Warn(ctx, "robot_display_im_publish_failed",
|
||||||
slog.String("node_id", s.nodeID),
|
slog.String("node_id", s.nodeID),
|
||||||
slog.String("event_id", record.EventID),
|
slog.String("event_id", record.EventID),
|
||||||
|
|||||||
@ -64,7 +64,7 @@ func (s *robotDisplaySampler) filter(records []outbox.Record, window time.Durati
|
|||||||
newestWindow = windowStartMS
|
newestWindow = windowStartMS
|
||||||
}
|
}
|
||||||
if last, exists := s.lastWindow[key]; exists && windowStartMS <= last {
|
if last, exists := s.lastWindow[key]; exists && windowStartMS <= last {
|
||||||
// 展示事件过密时只丢机器人 IM 展示;Room Cell 命令、房间热度、榜单和火箭状态已经在进入这里前完成计算。
|
// 机器人送礼没有 Room Cell 持久状态;展示过密时直接丢当前窗口重复 IM,下一窗口仍可正常展示。
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
s.lastWindow[key] = windowStartMS
|
s.lastWindow[key] = windowStartMS
|
||||||
|
|||||||
@ -33,16 +33,19 @@ func (c *fixedRoomRocketClock) Now() time.Time {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type rocketTestWallet struct {
|
type rocketTestWallet struct {
|
||||||
debits []*walletv1.DebitGiftResponse
|
debits []*walletv1.DebitGiftResponse
|
||||||
grants []*walletv1.GrantResourceGroupRequest
|
grants []*walletv1.GrantResourceGroupRequest
|
||||||
lastDebit *walletv1.DebitGiftRequest
|
lastDebit *walletv1.DebitGiftRequest
|
||||||
debitRequests []*walletv1.DebitGiftRequest
|
debitRequests []*walletv1.DebitGiftRequest
|
||||||
lastBatch *walletv1.BatchDebitGiftRequest
|
lastBatch *walletv1.BatchDebitGiftRequest
|
||||||
balanceResponses []*walletv1.GetBalancesResponse
|
robotDebitRequests []*walletv1.DebitRobotGiftRequest
|
||||||
balanceRequests []*walletv1.GetBalancesRequest
|
robotGiftConfigs []*walletv1.GiftConfig
|
||||||
vipResponses map[int64]*walletv1.GetMyVipResponse
|
robotCatalogRequests []*walletv1.ListGiftConfigsRequest
|
||||||
vipRequests []*walletv1.GetMyVipRequest
|
balanceResponses []*walletv1.GetBalancesResponse
|
||||||
vipError error
|
balanceRequests []*walletv1.GetBalancesRequest
|
||||||
|
vipResponses map[int64]*walletv1.GetMyVipResponse
|
||||||
|
vipRequests []*walletv1.GetMyVipRequest
|
||||||
|
vipError error
|
||||||
}
|
}
|
||||||
|
|
||||||
type recordingRoomDirectIMPublisher struct {
|
type recordingRoomDirectIMPublisher struct {
|
||||||
@ -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) {
|
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 {
|
if len(w.debits) == 0 {
|
||||||
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-robot-default", HeatValue: 1, CoinSpent: 1, ChargeAmount: 1, ChargeAssetType: "ROBOT_COIN"}, nil
|
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
|
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 {
|
func joinReceiptIDs(left string, right string) string {
|
||||||
if left == "" {
|
if left == "" {
|
||||||
return right
|
return right
|
||||||
|
|||||||
@ -95,8 +95,18 @@ type Service struct {
|
|||||||
roomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
|
roomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
|
||||||
// roomRocketProgressCoalescer 把高频燃料变化压成每秒每轮一条 outbox,降低 room_outbox/MQ/IM 写放大。
|
// roomRocketProgressCoalescer 把高频燃料变化压成每秒每轮一条 outbox,降低 room_outbox/MQ/IM 写放大。
|
||||||
roomRocketProgressCoalescer *roomRocketProgressCoalescer
|
roomRocketProgressCoalescer *roomRocketProgressCoalescer
|
||||||
// robotDisplaySampler 把同房间同展示类型在短窗口内收敛成一条直发 IM,房间命令和状态仍完整执行。
|
// robotDisplaySampler 把同房间同展示类型在短窗口内收敛成一条直发 IM;机器人送礼本身不进入房间持久化命令链路。
|
||||||
robotDisplaySampler *robotDisplaySampler
|
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 只保存房间维度贡献值 zset,不承载 Room Cell 核心状态。
|
||||||
roomGiftLeaderboard RoomGiftLeaderboardStore
|
roomGiftLeaderboard RoomGiftLeaderboardStore
|
||||||
// roomListCache 只承载发现页展示顺序;MySQL 仍然是房间列表和置顶事实 owner。
|
// roomListCache 只承载发现页展示顺序;MySQL 仍然是房间列表和置顶事实 owner。
|
||||||
@ -278,6 +288,8 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
|||||||
roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler,
|
roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler,
|
||||||
roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(cfg.RoomDisplayPublisher),
|
roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(cfg.RoomDisplayPublisher),
|
||||||
robotDisplaySampler: newRobotDisplaySampler(),
|
robotDisplaySampler: newRobotDisplaySampler(),
|
||||||
|
robotGiftCatalog: make(map[robotGiftCatalogKey]robotGiftCatalogEntry),
|
||||||
|
robotGiftCatalogLoads: make(map[robotGiftCatalogKey]*robotGiftCatalogLoad),
|
||||||
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
|
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
|
||||||
roomListCache: cfg.RoomListCache,
|
roomListCache: cfg.RoomListCache,
|
||||||
luckyGiftSendLocker: cfg.LuckyGiftSendLocker,
|
luckyGiftSendLocker: cfg.LuckyGiftSendLocker,
|
||||||
|
|||||||
@ -10,6 +10,8 @@ import (
|
|||||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
"hyapp/services/room-service/internal/integration"
|
"hyapp/services/room-service/internal/integration"
|
||||||
roomservice "hyapp/services/room-service/internal/room/service"
|
roomservice "hyapp/services/room-service/internal/room/service"
|
||||||
"hyapp/services/room-service/internal/router"
|
"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()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 0, 0, 0, time.UTC)}
|
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 0, 0, 0, time.UTC)}
|
||||||
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
|
wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
|
||||||
BillingReceiptId: "receipt-robot-display-only",
|
robotGiftConfigForTest("robot-display-gift", 100, "normal"),
|
||||||
CoinSpent: 100,
|
}}
|
||||||
ChargeAmount: 100,
|
|
||||||
HeatValue: 100,
|
|
||||||
ChargeAssetType: "ROBOT_COIN",
|
|
||||||
GiftTypeCode: "robot",
|
|
||||||
}}}
|
|
||||||
robotDisplay := newRecordingRobotDisplayPublisher()
|
robotDisplay := newRecordingRobotDisplayPublisher()
|
||||||
svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-display-boundary", robotDisplay)
|
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)
|
createRocketRoom(t, ctx, svc, roomID, senderID, 9001)
|
||||||
joinRocketRoom(t, ctx, svc, roomID, targetID)
|
joinRocketRoom(t, ctx, svc, roomID, targetID)
|
||||||
beforeMain := outboxEventCounts(t, ctx, repository)
|
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"),
|
Meta: rocketMeta(roomID, senderID, "robot-display-only"),
|
||||||
TargetUserID: targetID,
|
TargetUserID: targetID,
|
||||||
GiftID: "robot-display-gift",
|
GiftID: "robot-display-gift",
|
||||||
GiftCount: 1,
|
GiftCount: 1,
|
||||||
RobotUserIDs: []int64{senderID, targetID},
|
RobotUserIDs: []int64{senderID, targetID},
|
||||||
}); err != nil {
|
RealRoomHeat: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("robot send gift failed: %v", err)
|
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)
|
afterMain := outboxEventCounts(t, ctx, repository)
|
||||||
if delta := afterMain["RoomGiftSent"] - beforeMain["RoomGiftSent"]; delta != 0 {
|
if delta := afterMain["RoomGiftSent"] - beforeMain["RoomGiftSent"]; delta != 0 {
|
||||||
@ -148,17 +162,101 @@ func TestRobotGiftSkipsMainHeatAndRankOutbox(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
|
waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
|
||||||
"RoomGiftSent": 1,
|
"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()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)}
|
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()
|
robotDisplay := newRecordingRobotDisplayPublisher()
|
||||||
svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-display-sampler", robotDisplay)
|
svc := newUserGiftValueTestService(repository, wallet, now, "node-robot-gift-display-sampler", robotDisplay)
|
||||||
|
|
||||||
@ -175,6 +273,7 @@ func TestRobotGiftDownsamplesRobotDisplayWithoutSkippingRoomState(t *testing.T)
|
|||||||
GiftID: "robot-sampler-gift",
|
GiftID: "robot-sampler-gift",
|
||||||
GiftCount: 1,
|
GiftCount: 1,
|
||||||
RobotUserIDs: []int64{senderID, targetID},
|
RobotUserIDs: []int64{senderID, targetID},
|
||||||
|
RealRoomHeat: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first robot send gift failed: %v", err)
|
t.Fatalf("first robot send gift failed: %v", err)
|
||||||
@ -185,19 +284,23 @@ func TestRobotGiftDownsamplesRobotDisplayWithoutSkippingRoomState(t *testing.T)
|
|||||||
GiftID: "robot-sampler-gift",
|
GiftID: "robot-sampler-gift",
|
||||||
GiftCount: 1,
|
GiftCount: 1,
|
||||||
RobotUserIDs: []int64{senderID, targetID},
|
RobotUserIDs: []int64{senderID, targetID},
|
||||||
|
RealRoomHeat: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("second robot send gift failed: %v", err)
|
t.Fatalf("second robot send gift failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if firstResp.GetRoomHeat() != 1 || secondResp.GetRoomHeat() != 2 {
|
if firstResp.GetRoomHeat() != 0 || secondResp.GetRoomHeat() != 0 {
|
||||||
t.Fatalf("robot gift sampler must not skip room heat updates: first=%d second=%d", firstResp.GetRoomHeat(), secondResp.GetRoomHeat())
|
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 {
|
if len(secondResp.GetGiftRank()) != 0 {
|
||||||
t.Fatalf("robot gift sampler must not skip room rank updates: %+v", secondResp.GetGiftRank())
|
t.Fatalf("robot display must keep room rank unchanged: %+v", secondResp.GetGiftRank())
|
||||||
}
|
}
|
||||||
if user := onlineUserByID(secondResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 2 {
|
if user := onlineUserByID(secondResp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 {
|
||||||
t.Fatalf("robot gift sampler must not skip target gift value: %+v", user)
|
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)
|
afterMain := outboxEventCounts(t, ctx, repository)
|
||||||
@ -207,24 +310,17 @@ func TestRobotGiftDownsamplesRobotDisplayWithoutSkippingRoomState(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
|
waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
|
||||||
"RoomGiftSent": 1,
|
"RoomGiftSent": 1,
|
||||||
"RoomHeatChanged": 1,
|
|
||||||
"RoomRankChanged": 1,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRealRoomRobotGiftKeepsRoomContributionAndDirectPublishesRobotDisplay(t *testing.T) {
|
func TestHumanRoomRobotGiftIsDisplayOnlyWithoutContributionOrRocket(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 30, 0, 0, time.UTC)}
|
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 8, 11, 30, 0, 0, time.UTC)}
|
||||||
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
|
wallet := &rocketTestWallet{robotGiftConfigs: []*walletv1.GiftConfig{
|
||||||
BillingReceiptId: "receipt-real-room-robot",
|
robotGiftConfigForTest("real-room-robot-gift", 100, "super_lucky"),
|
||||||
CoinSpent: 100,
|
}}
|
||||||
ChargeAmount: 100,
|
|
||||||
HeatValue: 100,
|
|
||||||
ChargeAssetType: "ROBOT_COIN",
|
|
||||||
GiftTypeCode: "normal",
|
|
||||||
}}}
|
|
||||||
leaderboard := &recordingRoomGiftLeaderboard{}
|
leaderboard := &recordingRoomGiftLeaderboard{}
|
||||||
robotDisplay := newRecordingRobotDisplayPublisher()
|
robotDisplay := newRecordingRobotDisplayPublisher()
|
||||||
svc := roomservice.New(roomservice.Config{
|
svc := roomservice.New(roomservice.Config{
|
||||||
@ -250,6 +346,8 @@ func TestRealRoomRobotGiftKeepsRoomContributionAndDirectPublishesRobotDisplay(t
|
|||||||
t.Fatalf("target mic up failed: %v", err)
|
t.Fatalf("target mic up failed: %v", err)
|
||||||
}
|
}
|
||||||
beforeMain := outboxEventCounts(t, ctx, repository)
|
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{
|
resp, err := svc.RobotSendGift(ctx, roomservice.RobotSendGiftInput{
|
||||||
Meta: rocketMeta(roomID, senderID, "real-room-robot-gift"),
|
Meta: rocketMeta(roomID, senderID, "real-room-robot-gift"),
|
||||||
@ -258,32 +356,38 @@ func TestRealRoomRobotGiftKeepsRoomContributionAndDirectPublishesRobotDisplay(t
|
|||||||
GiftCount: 1,
|
GiftCount: 1,
|
||||||
PoolID: "human_robot_super_lucky_display",
|
PoolID: "human_robot_super_lucky_display",
|
||||||
RobotUserIDs: []int64{senderID, targetID},
|
RobotUserIDs: []int64{senderID, targetID},
|
||||||
SyntheticMultiplierPPM: 5_000_000,
|
SyntheticMultiplierPPM: 20_000_000,
|
||||||
RealRoomHeat: true,
|
RealRoomHeat: true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("real room robot gift failed: %v", err)
|
t.Fatalf("real room robot gift failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if resp.GetRoomHeat() != 100 {
|
if resp.GetRoomHeat() != 0 {
|
||||||
t.Fatalf("real room robot gift must update room heat, got %d", resp.GetRoomHeat())
|
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 {
|
if user := onlineUserByID(resp.GetRoom(), targetID); user == nil || user.GetGiftValue() != 0 {
|
||||||
t.Fatalf("real room robot gift must update target gift value: %+v", user)
|
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 {
|
if seat := seatByNo(resp.GetRoom(), 1); seat == nil || seat.GetGiftValue() != 0 {
|
||||||
t.Fatalf("real room robot gift must update mic gift value: %+v", seat)
|
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 {
|
if len(resp.GetGiftRank()) != 0 {
|
||||||
t.Fatalf("real room robot gift must update room gift rank: %+v", resp.GetGiftRank())
|
t.Fatalf("human-room robot display must not update room gift rank: %+v", resp.GetGiftRank())
|
||||||
}
|
}
|
||||||
if resp.GetRocket() == nil || len(resp.GetRocket().GetPendingLaunches()) != 1 {
|
if rocket := resp.GetRocket(); rocket != nil && (rocket.GetCurrentFuel() != 0 || len(rocket.GetPendingLaunches()) != 0) {
|
||||||
t.Fatalf("real room robot gift must fuel and ignite room rocket: %+v", resp.GetRocket())
|
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 {
|
if len(leaderboard.increments) != 0 {
|
||||||
t.Fatalf("real room robot gift must keep cross-room contribution increment: %+v", leaderboard.increments)
|
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)
|
afterMain := outboxEventCounts(t, ctx, repository)
|
||||||
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} {
|
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged", "RoomRobotLuckyGiftDrawn", "RoomRocketFuelChanged", "RoomRocketIgnited"} {
|
||||||
@ -293,12 +397,11 @@ func TestRealRoomRobotGiftKeepsRoomContributionAndDirectPublishesRobotDisplay(t
|
|||||||
}
|
}
|
||||||
waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
|
waitForRobotDisplayCounts(t, robotDisplay, map[string]int{
|
||||||
"RoomGiftSent": 1,
|
"RoomGiftSent": 1,
|
||||||
"RoomHeatChanged": 1,
|
|
||||||
"RoomRankChanged": 1,
|
|
||||||
"RoomRobotLuckyGiftDrawn": 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) {
|
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 {
|
func outboxEventCounts(t *testing.T, ctx context.Context, repository *mysqltest.Repository) map[string]int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
records, err := repository.ListPendingOutbox(ctx, 200)
|
records, err := repository.ListPendingOutbox(ctx, 200)
|
||||||
@ -406,6 +543,25 @@ func (p *recordingRobotDisplayPublisher) counts() map[string]int {
|
|||||||
return counts
|
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 {
|
func waitForRobotDisplayCounts(t *testing.T, publisher *recordingRobotDisplayPublisher, want map[string]int) map[string]int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user