增加飘屏

This commit is contained in:
zhx 2026-07-13 21:28:41 +08:00
parent ed3e7096e3
commit 1d581ff493
71 changed files with 5752 additions and 1845 deletions

View File

@ -59,6 +59,7 @@ proto:
proto/game/v1/game.proto \ proto/game/v1/game.proto \
proto/robot/v1/robot.proto \ proto/robot/v1/robot.proto \
proto/wallet/v1/wallet.proto \ proto/wallet/v1/wallet.proto \
proto/events/luckygift/v1/events.proto \
proto/events/room/v1/events.proto proto/events/room/v1/events.proto
# `vet` 使用当前 Go 工具链检查 workspace 内所有后端模块,升级 Go 版本时先暴露静态风险。 # `vet` 使用当前 Go 工具链检查 workspace 内所有后端模块,升级 Go 版本时先暴露静态风险。

View File

@ -180,7 +180,7 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
规则: 规则:
- `room_outbox` 只由 `room-service` 读取和发布;`wallet_outbox` 只由 `wallet-service` 读取和发布;`user_outbox` 只由 `user-service` 读取和发布;`game_outbox` 只由 `game-service` 读取和发布。 - `room_outbox` 只由 `room-service` 读取和发布;`wallet_outbox` 只由 `wallet-service` 读取和发布;`user_outbox` 只由 `user-service` 读取和发布;`game_outbox` 只由 `game-service` 读取和发布`lucky_gift_outbox` 只由 `lucky-gift-service` 读取和发布
- `statistics-service``activity-service``notice-service``cron-service` 等非 owner service 不能配置 owner 数据库 DSN 或扫描其他服务 outbox 表。 - `statistics-service``activity-service``notice-service``cron-service` 等非 owner service 不能配置 owner 数据库 DSN 或扫描其他服务 outbox 表。
- 每个业务消费者使用独立 consumer group。即使消费同一个 topic不同业务也不能共用 group否则会互相抢消息。 - 每个业务消费者使用独立 consumer group。即使消费同一个 topic不同业务也不能共用 group否则会互相抢消息。
- 每个消费者在自己的库内维护幂等事实,例如按 `app_code + event_id` 或业务唯一键建唯一约束RocketMQ 的消费位点只解决投递进度,不替代业务幂等。 - 每个消费者在自己的库内维护幂等事实,例如按 `app_code + event_id` 或业务唯一键建唯一约束RocketMQ 的消费位点只解决投递进度,不替代业务幂等。
@ -201,10 +201,14 @@ docs/flutter对接/Flutter App 通知与钱包余额对接.md
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-activity-red-packet-wallet-outbox` | activity-service 消费 `WalletRedPacketCreated` 并生成区域飘屏 outbox | | `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-activity-red-packet-wallet-outbox` | activity-service 消费 `WalletRedPacketCreated` 并生成区域飘屏 outbox |
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-notice-wallet-outbox` | notice-service 消费余额、账务等私有通知事实 | | `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-notice-wallet-outbox` | notice-service 消费余额、账务等私有通知事实 |
| `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-statistics-wallet-outbox` | statistics-service 消费 `WalletRechargeRecorded` 并更新充值聚合 | | `hyapp_wallet_outbox` | `hyapp-wallet-outbox-producer` | `hyapp-statistics-wallet-outbox` | statistics-service 消费 `WalletRechargeRecorded` 并更新充值聚合 |
| `hyapp_lucky_gift_outbox` | `hyapp-lucky-gift-outbox-producer` | `hyapp-room-lucky-gift-outbox` | room-service 消费已到账的 `LuckyGiftDrawn`,以本地 `room_outbox` 幂等投递房间顶部飘屏 |
| `hyapp_lucky_gift_outbox` | `hyapp-lucky-gift-outbox-producer` | `hyapp-activity-lucky-gift-outbox` | activity-service 消费已到账的 `LuckyGiftDrawn`,以本地 `im_broadcast_outbox` 幂等投递区域大赢家飘屏 |
| `hyapp_game_outbox` | `hyapp-game-outbox-producer` | `hyapp-statistics-game-outbox` | statistics-service 消费 `GameOrderSettled` 并更新游戏聚合 | | `hyapp_game_outbox` | `hyapp-game-outbox-producer` | `hyapp-statistics-game-outbox` | statistics-service 消费 `GameOrderSettled` 并更新游戏聚合 |
| `hyapp_room_rocket_launch` | `hyapp-room-rocket-launch-producer` | `hyapp-room-rocket-launch` | 火箭点火后到点唤醒 room-service 发射命令 | | `hyapp_room_rocket_launch` | `hyapp-room-rocket-launch-producer` | `hyapp-room-rocket-launch` | 火箭点火后到点唤醒 room-service 发射命令 |
RocketMQ 不拥有业务状态。`room_outbox``wallet_outbox``user_outbox``game_outbox` 仍是各 owner service MySQL 内的可靠事实源MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。火箭延迟消息只负责唤醒,到点后 room-service 会重新校验 `rocket_id`、等级、`launch_at_ms`、状态和 UTC 重置边界。 RocketMQ 不拥有业务状态。`room_outbox``wallet_outbox``user_outbox``game_outbox``lucky_gift_outbox` 仍是各 owner service MySQL 内的可靠事实源MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。火箭延迟消息只负责唤醒,到点后 room-service 会重新校验 `rocket_id`、等级、`launch_at_ms`、状态和 UTC 重置边界。
`hyapp_lucky_gift_outbox` 的两个消费者保持实时副作用所需的 latest offset。首次上线必须先创建 topic/group并先部署、确认 room/activity 两个 group 已在线订阅,再开启 lucky-gift producer不能用 `ConsumeFromFirst` 扫历史大奖生成过期飘屏。
## Storage Model ## Storage Model

View File

@ -0,0 +1,512 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.35.1
// protoc v7.35.0
// source: proto/events/luckygift/v1/events.proto
package luckygifteventsv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// EventEnvelope 是 lucky-gift-service 发布 owner outbox 事实的稳定信封。
// occurred_at_ms 固定为抽奖事实创建时的 Unix epoch millisecondsMQ 实际发送时间不改变业务发生时间。
type EventEnvelope struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
EventType string `protobuf:"bytes,2,opt,name=event_type,json=eventType,proto3" json:"event_type,omitempty"`
AppCode string `protobuf:"bytes,3,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
// draw_id 是本次送礼命令的聚合中奖标识;批量子抽明细仍只保存在 lucky owner 数据库,不能放大 MQ 负载。
DrawId string `protobuf:"bytes,4,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
OccurredAtMs int64 `protobuf:"varint,5,opt,name=occurred_at_ms,json=occurredAtMs,proto3" json:"occurred_at_ms,omitempty"`
Body []byte `protobuf:"bytes,6,opt,name=body,proto3" json:"body,omitempty"`
}
func (x *EventEnvelope) Reset() {
*x = EventEnvelope{}
mi := &file_proto_events_luckygift_v1_events_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EventEnvelope) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EventEnvelope) ProtoMessage() {}
func (x *EventEnvelope) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_luckygift_v1_events_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use EventEnvelope.ProtoReflect.Descriptor instead.
func (*EventEnvelope) Descriptor() ([]byte, []int) {
return file_proto_events_luckygift_v1_events_proto_rawDescGZIP(), []int{0}
}
func (x *EventEnvelope) GetEventId() string {
if x != nil {
return x.EventId
}
return ""
}
func (x *EventEnvelope) GetEventType() string {
if x != nil {
return x.EventType
}
return ""
}
func (x *EventEnvelope) GetAppCode() string {
if x != nil {
return x.AppCode
}
return ""
}
func (x *EventEnvelope) GetDrawId() string {
if x != nil {
return x.DrawId
}
return ""
}
func (x *EventEnvelope) GetOccurredAtMs() int64 {
if x != nil {
return x.OccurredAtMs
}
return 0
}
func (x *EventEnvelope) GetBody() []byte {
if x != nil {
return x.Body
}
return nil
}
// LuckyGiftDrawn 表达钱包返奖已经 granted 的幸运礼物中奖事实。
// lucky-gift-service 必须先完成 wallet-service 入账和本地 granted 收敛,再发布本事件;
// room-service 与 activity-service 只从该事实派生房内 IM 和区域飘屏,不能反向改变抽奖或账务状态。
type LuckyGiftDrawn struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
RoomId string `protobuf:"bytes,3,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
PoolId string `protobuf:"bytes,4,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
GiftId string `protobuf:"bytes,5,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
GiftCount int32 `protobuf:"varint,6,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
SenderUserId int64 `protobuf:"varint,7,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"`
TargetUserId int64 `protobuf:"varint,8,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
// 展示资料是送礼入口固化的快照;消费者不应为了飘屏高频同步反查 user-service。
SenderName string `protobuf:"bytes,9,opt,name=sender_name,json=senderName,proto3" json:"sender_name,omitempty"`
SenderAvatar string `protobuf:"bytes,10,opt,name=sender_avatar,json=senderAvatar,proto3" json:"sender_avatar,omitempty"`
SenderDisplayUserId string `protobuf:"bytes,11,opt,name=sender_display_user_id,json=senderDisplayUserId,proto3" json:"sender_display_user_id,omitempty"`
SenderPrettyDisplayUserId string `protobuf:"bytes,12,opt,name=sender_pretty_display_user_id,json=senderPrettyDisplayUserId,proto3" json:"sender_pretty_display_user_id,omitempty"`
// visible_region_id 是房间可见区域,区域播报不能用客户端 IP 或服务所在地推断。
VisibleRegionId int64 `protobuf:"varint,13,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
CountryId int64 `protobuf:"varint,14,opt,name=country_id,json=countryId,proto3" json:"country_id,omitempty"`
CoinSpent int64 `protobuf:"varint,15,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"`
RuleVersion int64 `protobuf:"varint,16,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"`
ExperiencePool string `protobuf:"bytes,17,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"`
SelectedTierId string `protobuf:"bytes,18,opt,name=selected_tier_id,json=selectedTierId,proto3" json:"selected_tier_id,omitempty"`
MultiplierPpm int64 `protobuf:"varint,19,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
BaseRewardCoins int64 `protobuf:"varint,20,opt,name=base_reward_coins,json=baseRewardCoins,proto3" json:"base_reward_coins,omitempty"`
EffectiveRewardCoins int64 `protobuf:"varint,21,opt,name=effective_reward_coins,json=effectiveRewardCoins,proto3" json:"effective_reward_coins,omitempty"`
StageFeedback bool `protobuf:"varint,22,opt,name=stage_feedback,json=stageFeedback,proto3" json:"stage_feedback,omitempty"`
HighMultiplier bool `protobuf:"varint,23,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"`
// reward_status 在本事件中只能是 granted保留字段是为了下游 JSON 与现有 Flutter 协议同名映射。
RewardStatus string `protobuf:"bytes,24,opt,name=reward_status,json=rewardStatus,proto3" json:"reward_status,omitempty"`
WalletTransactionId string `protobuf:"bytes,25,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"`
// 两个时间均为 UTC Unix epoch milliseconds抽奖发生时间与钱包返奖确认时间不能混用。
DrawCreatedAtMs int64 `protobuf:"varint,26,opt,name=draw_created_at_ms,json=drawCreatedAtMs,proto3" json:"draw_created_at_ms,omitempty"`
RewardGrantedAtMs int64 `protobuf:"varint,27,opt,name=reward_granted_at_ms,json=rewardGrantedAtMs,proto3" json:"reward_granted_at_ms,omitempty"`
}
func (x *LuckyGiftDrawn) Reset() {
*x = LuckyGiftDrawn{}
mi := &file_proto_events_luckygift_v1_events_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *LuckyGiftDrawn) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*LuckyGiftDrawn) ProtoMessage() {}
func (x *LuckyGiftDrawn) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_luckygift_v1_events_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use LuckyGiftDrawn.ProtoReflect.Descriptor instead.
func (*LuckyGiftDrawn) Descriptor() ([]byte, []int) {
return file_proto_events_luckygift_v1_events_proto_rawDescGZIP(), []int{1}
}
func (x *LuckyGiftDrawn) GetDrawId() string {
if x != nil {
return x.DrawId
}
return ""
}
func (x *LuckyGiftDrawn) GetCommandId() string {
if x != nil {
return x.CommandId
}
return ""
}
func (x *LuckyGiftDrawn) GetRoomId() string {
if x != nil {
return x.RoomId
}
return ""
}
func (x *LuckyGiftDrawn) GetPoolId() string {
if x != nil {
return x.PoolId
}
return ""
}
func (x *LuckyGiftDrawn) GetGiftId() string {
if x != nil {
return x.GiftId
}
return ""
}
func (x *LuckyGiftDrawn) GetGiftCount() int32 {
if x != nil {
return x.GiftCount
}
return 0
}
func (x *LuckyGiftDrawn) GetSenderUserId() int64 {
if x != nil {
return x.SenderUserId
}
return 0
}
func (x *LuckyGiftDrawn) GetTargetUserId() int64 {
if x != nil {
return x.TargetUserId
}
return 0
}
func (x *LuckyGiftDrawn) GetSenderName() string {
if x != nil {
return x.SenderName
}
return ""
}
func (x *LuckyGiftDrawn) GetSenderAvatar() string {
if x != nil {
return x.SenderAvatar
}
return ""
}
func (x *LuckyGiftDrawn) GetSenderDisplayUserId() string {
if x != nil {
return x.SenderDisplayUserId
}
return ""
}
func (x *LuckyGiftDrawn) GetSenderPrettyDisplayUserId() string {
if x != nil {
return x.SenderPrettyDisplayUserId
}
return ""
}
func (x *LuckyGiftDrawn) GetVisibleRegionId() int64 {
if x != nil {
return x.VisibleRegionId
}
return 0
}
func (x *LuckyGiftDrawn) GetCountryId() int64 {
if x != nil {
return x.CountryId
}
return 0
}
func (x *LuckyGiftDrawn) GetCoinSpent() int64 {
if x != nil {
return x.CoinSpent
}
return 0
}
func (x *LuckyGiftDrawn) GetRuleVersion() int64 {
if x != nil {
return x.RuleVersion
}
return 0
}
func (x *LuckyGiftDrawn) GetExperiencePool() string {
if x != nil {
return x.ExperiencePool
}
return ""
}
func (x *LuckyGiftDrawn) GetSelectedTierId() string {
if x != nil {
return x.SelectedTierId
}
return ""
}
func (x *LuckyGiftDrawn) GetMultiplierPpm() int64 {
if x != nil {
return x.MultiplierPpm
}
return 0
}
func (x *LuckyGiftDrawn) GetBaseRewardCoins() int64 {
if x != nil {
return x.BaseRewardCoins
}
return 0
}
func (x *LuckyGiftDrawn) GetEffectiveRewardCoins() int64 {
if x != nil {
return x.EffectiveRewardCoins
}
return 0
}
func (x *LuckyGiftDrawn) GetStageFeedback() bool {
if x != nil {
return x.StageFeedback
}
return false
}
func (x *LuckyGiftDrawn) GetHighMultiplier() bool {
if x != nil {
return x.HighMultiplier
}
return false
}
func (x *LuckyGiftDrawn) GetRewardStatus() string {
if x != nil {
return x.RewardStatus
}
return ""
}
func (x *LuckyGiftDrawn) GetWalletTransactionId() string {
if x != nil {
return x.WalletTransactionId
}
return ""
}
func (x *LuckyGiftDrawn) GetDrawCreatedAtMs() int64 {
if x != nil {
return x.DrawCreatedAtMs
}
return 0
}
func (x *LuckyGiftDrawn) GetRewardGrantedAtMs() int64 {
if x != nil {
return x.RewardGrantedAtMs
}
return 0
}
var File_proto_events_luckygift_v1_events_proto protoreflect.FileDescriptor
var file_proto_events_luckygift_v1_events_proto_rawDesc = []byte{
0x0a, 0x26, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x6c,
0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74, 0x2f, 0x76, 0x31, 0x2f, 0x65, 0x76, 0x65, 0x6e,
0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x19, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69, 0x66, 0x74,
0x2e, 0x76, 0x31, 0x22, 0xb7, 0x01, 0x0a, 0x0d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x45, 0x6e, 0x76,
0x65, 0x6c, 0x6f, 0x70, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x49, 0x64,
0x12, 0x1d, 0x0a, 0x0a, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12,
0x19, 0x0a, 0x08, 0x61, 0x70, 0x70, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
0x09, 0x52, 0x07, 0x61, 0x70, 0x70, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72,
0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x72, 0x61,
0x77, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x6f, 0x63, 0x63, 0x75, 0x72, 0x72, 0x65, 0x64, 0x5f,
0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x63, 0x63,
0x75, 0x72, 0x72, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x62, 0x6f, 0x64,
0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x62, 0x6f, 0x64, 0x79, 0x22, 0xab, 0x08,
0x0a, 0x0e, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x6e,
0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
0x09, 0x52, 0x06, 0x64, 0x72, 0x61, 0x77, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d,
0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63,
0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d,
0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49,
0x64, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01,
0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69,
0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66,
0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e,
0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75,
0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64,
0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1f,
0x0a, 0x0b, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x09, 0x20,
0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12,
0x23, 0x0a, 0x0d, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72,
0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x41, 0x76,
0x61, 0x74, 0x61, 0x72, 0x12, 0x33, 0x0a, 0x16, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x64,
0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0b,
0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70,
0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x65, 0x6e,
0x64, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c,
0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09,
0x52, 0x19, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69,
0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76,
0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64,
0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52,
0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x72, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x72, 0x79, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x69, 0x6e, 0x5f, 0x73,
0x70, 0x65, 0x6e, 0x74, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x69, 0x6e,
0x53, 0x70, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x75, 0x6c, 0x65, 0x5f, 0x76, 0x65,
0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x10, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x72, 0x75, 0x6c,
0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x0a, 0x0f, 0x65, 0x78, 0x70, 0x65,
0x72, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6f, 0x6c, 0x18, 0x11, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0e, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x50, 0x6f, 0x6f,
0x6c, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x69,
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x65, 0x6c,
0x65, 0x63, 0x74, 0x65, 0x64, 0x54, 0x69, 0x65, 0x72, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x6d,
0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x5f, 0x70, 0x70, 0x6d, 0x18, 0x13, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x50,
0x70, 0x6d, 0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72,
0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x14, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62,
0x61, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x34,
0x0a, 0x16, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61,
0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x15, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14,
0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43,
0x6f, 0x69, 0x6e, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x66, 0x65,
0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x16, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x74,
0x61, 0x67, 0x65, 0x46, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x27, 0x0a, 0x0f, 0x68,
0x69, 0x67, 0x68, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x18, 0x17,
0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x68, 0x69, 0x67, 0x68, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x70,
0x6c, 0x69, 0x65, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x73,
0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x18, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x77,
0x61, 0x72, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x77, 0x61, 0x6c,
0x6c, 0x65, 0x74, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
0x69, 0x64, 0x18, 0x19, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
0x54, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x2b, 0x0a,
0x12, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
0x5f, 0x6d, 0x73, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x64, 0x72, 0x61, 0x77, 0x43,
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x2f, 0x0a, 0x14, 0x72, 0x65,
0x77, 0x61, 0x72, 0x64, 0x5f, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f,
0x6d, 0x73, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64,
0x47, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x42, 0x3d, 0x5a, 0x3b, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70,
0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x6c, 0x75, 0x63, 0x6b,
0x79, 0x67, 0x69, 0x66, 0x74, 0x2f, 0x76, 0x31, 0x3b, 0x6c, 0x75, 0x63, 0x6b, 0x79, 0x67, 0x69,
0x66, 0x74, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x33,
}
var (
file_proto_events_luckygift_v1_events_proto_rawDescOnce sync.Once
file_proto_events_luckygift_v1_events_proto_rawDescData = file_proto_events_luckygift_v1_events_proto_rawDesc
)
func file_proto_events_luckygift_v1_events_proto_rawDescGZIP() []byte {
file_proto_events_luckygift_v1_events_proto_rawDescOnce.Do(func() {
file_proto_events_luckygift_v1_events_proto_rawDescData = protoimpl.X.CompressGZIP(file_proto_events_luckygift_v1_events_proto_rawDescData)
})
return file_proto_events_luckygift_v1_events_proto_rawDescData
}
var file_proto_events_luckygift_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_proto_events_luckygift_v1_events_proto_goTypes = []any{
(*EventEnvelope)(nil), // 0: hyapp.events.luckygift.v1.EventEnvelope
(*LuckyGiftDrawn)(nil), // 1: hyapp.events.luckygift.v1.LuckyGiftDrawn
}
var file_proto_events_luckygift_v1_events_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type
0, // [0:0] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_proto_events_luckygift_v1_events_proto_init() }
func file_proto_events_luckygift_v1_events_proto_init() {
if File_proto_events_luckygift_v1_events_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_events_luckygift_v1_events_proto_rawDesc,
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_proto_events_luckygift_v1_events_proto_goTypes,
DependencyIndexes: file_proto_events_luckygift_v1_events_proto_depIdxs,
MessageInfos: file_proto_events_luckygift_v1_events_proto_msgTypes,
}.Build()
File_proto_events_luckygift_v1_events_proto = out.File
file_proto_events_luckygift_v1_events_proto_rawDesc = nil
file_proto_events_luckygift_v1_events_proto_goTypes = nil
file_proto_events_luckygift_v1_events_proto_depIdxs = nil
}

View File

@ -0,0 +1,54 @@
syntax = "proto3";
package hyapp.events.luckygift.v1;
option go_package = "hyapp.local/api/proto/events/luckygift/v1;luckygifteventsv1";
// EventEnvelope lucky-gift-service owner outbox
// occurred_at_ms Unix epoch millisecondsMQ
message EventEnvelope {
string event_id = 1;
string event_type = 2;
string app_code = 3;
// draw_id lucky owner MQ
string draw_id = 4;
int64 occurred_at_ms = 5;
bytes body = 6;
}
// LuckyGiftDrawn granted
// lucky-gift-service wallet-service granted
// room-service activity-service IM
message LuckyGiftDrawn {
string draw_id = 1;
string command_id = 2;
string room_id = 3;
string pool_id = 4;
string gift_id = 5;
int32 gift_count = 6;
int64 sender_user_id = 7;
int64 target_user_id = 8;
// user-service
string sender_name = 9;
string sender_avatar = 10;
string sender_display_user_id = 11;
string sender_pretty_display_user_id = 12;
// visible_region_id IP
int64 visible_region_id = 13;
int64 country_id = 14;
int64 coin_spent = 15;
int64 rule_version = 16;
string experience_pool = 17;
string selected_tier_id = 18;
int64 multiplier_ppm = 19;
int64 base_reward_coins = 20;
int64 effective_reward_coins = 21;
bool stage_feedback = 22;
bool high_multiplier = 23;
// reward_status granted JSON Flutter
string reward_status = 24;
string wallet_transaction_id = 25;
// UTC Unix epoch milliseconds
int64 draw_created_at_ms = 26;
int64 reward_granted_at_ms = 27;
}

View File

@ -2111,6 +2111,254 @@ func (x *RoomGiftBatchSent) GetVisibleRegionId() int64 {
return 0 return 0
} }
// RoomLuckyGiftDrawn 是 room-service 从 lucky-gift-service owner 事实派生的真人中奖展示事件。
// 它只承载腾讯云 IM 房间表现,不是 Room Cell 核心状态,不能推进 room_version、榜单或房间快照。
type RoomLuckyGiftDrawn struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
PoolId string `protobuf:"bytes,3,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
GiftId string `protobuf:"bytes,4,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
GiftCount int32 `protobuf:"varint,5,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
SenderUserId int64 `protobuf:"varint,6,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"`
TargetUserId int64 `protobuf:"varint,7,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
SenderName string `protobuf:"bytes,8,opt,name=sender_name,json=senderName,proto3" json:"sender_name,omitempty"`
SenderAvatar string `protobuf:"bytes,9,opt,name=sender_avatar,json=senderAvatar,proto3" json:"sender_avatar,omitempty"`
SenderDisplayUserId string `protobuf:"bytes,10,opt,name=sender_display_user_id,json=senderDisplayUserId,proto3" json:"sender_display_user_id,omitempty"`
SenderPrettyDisplayUserId string `protobuf:"bytes,11,opt,name=sender_pretty_display_user_id,json=senderPrettyDisplayUserId,proto3" json:"sender_pretty_display_user_id,omitempty"`
VisibleRegionId int64 `protobuf:"varint,12,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
CountryId int64 `protobuf:"varint,13,opt,name=country_id,json=countryId,proto3" json:"country_id,omitempty"`
CoinSpent int64 `protobuf:"varint,14,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"`
RuleVersion int64 `protobuf:"varint,15,opt,name=rule_version,json=ruleVersion,proto3" json:"rule_version,omitempty"`
ExperiencePool string `protobuf:"bytes,16,opt,name=experience_pool,json=experiencePool,proto3" json:"experience_pool,omitempty"`
SelectedTierId string `protobuf:"bytes,17,opt,name=selected_tier_id,json=selectedTierId,proto3" json:"selected_tier_id,omitempty"`
MultiplierPpm int64 `protobuf:"varint,18,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
BaseRewardCoins int64 `protobuf:"varint,19,opt,name=base_reward_coins,json=baseRewardCoins,proto3" json:"base_reward_coins,omitempty"`
EffectiveRewardCoins int64 `protobuf:"varint,20,opt,name=effective_reward_coins,json=effectiveRewardCoins,proto3" json:"effective_reward_coins,omitempty"`
StageFeedback bool `protobuf:"varint,21,opt,name=stage_feedback,json=stageFeedback,proto3" json:"stage_feedback,omitempty"`
HighMultiplier bool `protobuf:"varint,22,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"`
RewardStatus string `protobuf:"bytes,23,opt,name=reward_status,json=rewardStatus,proto3" json:"reward_status,omitempty"`
WalletTransactionId string `protobuf:"bytes,24,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"`
// 时间沿用 lucky owner 的 UTC Unix epoch milliseconds不以 room-service 消费时间覆盖。
DrawCreatedAtMs int64 `protobuf:"varint,25,opt,name=draw_created_at_ms,json=drawCreatedAtMs,proto3" json:"draw_created_at_ms,omitempty"`
RewardGrantedAtMs int64 `protobuf:"varint,26,opt,name=reward_granted_at_ms,json=rewardGrantedAtMs,proto3" json:"reward_granted_at_ms,omitempty"`
}
func (x *RoomLuckyGiftDrawn) Reset() {
*x = RoomLuckyGiftDrawn{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomLuckyGiftDrawn) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RoomLuckyGiftDrawn) ProtoMessage() {}
func (x *RoomLuckyGiftDrawn) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RoomLuckyGiftDrawn.ProtoReflect.Descriptor instead.
func (*RoomLuckyGiftDrawn) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{20}
}
func (x *RoomLuckyGiftDrawn) GetDrawId() string {
if x != nil {
return x.DrawId
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetCommandId() string {
if x != nil {
return x.CommandId
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetPoolId() string {
if x != nil {
return x.PoolId
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetGiftId() string {
if x != nil {
return x.GiftId
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetGiftCount() int32 {
if x != nil {
return x.GiftCount
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetSenderUserId() int64 {
if x != nil {
return x.SenderUserId
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetTargetUserId() int64 {
if x != nil {
return x.TargetUserId
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetSenderName() string {
if x != nil {
return x.SenderName
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetSenderAvatar() string {
if x != nil {
return x.SenderAvatar
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetSenderDisplayUserId() string {
if x != nil {
return x.SenderDisplayUserId
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetSenderPrettyDisplayUserId() string {
if x != nil {
return x.SenderPrettyDisplayUserId
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetVisibleRegionId() int64 {
if x != nil {
return x.VisibleRegionId
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetCountryId() int64 {
if x != nil {
return x.CountryId
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetCoinSpent() int64 {
if x != nil {
return x.CoinSpent
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetRuleVersion() int64 {
if x != nil {
return x.RuleVersion
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetExperiencePool() string {
if x != nil {
return x.ExperiencePool
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetSelectedTierId() string {
if x != nil {
return x.SelectedTierId
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetMultiplierPpm() int64 {
if x != nil {
return x.MultiplierPpm
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetBaseRewardCoins() int64 {
if x != nil {
return x.BaseRewardCoins
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetEffectiveRewardCoins() int64 {
if x != nil {
return x.EffectiveRewardCoins
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetStageFeedback() bool {
if x != nil {
return x.StageFeedback
}
return false
}
func (x *RoomLuckyGiftDrawn) GetHighMultiplier() bool {
if x != nil {
return x.HighMultiplier
}
return false
}
func (x *RoomLuckyGiftDrawn) GetRewardStatus() string {
if x != nil {
return x.RewardStatus
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetWalletTransactionId() string {
if x != nil {
return x.WalletTransactionId
}
return ""
}
func (x *RoomLuckyGiftDrawn) GetDrawCreatedAtMs() int64 {
if x != nil {
return x.DrawCreatedAtMs
}
return 0
}
func (x *RoomLuckyGiftDrawn) GetRewardGrantedAtMs() int64 {
if x != nil {
return x.RewardGrantedAtMs
}
return 0
}
// RoomRobotLuckyGiftDrawn 是机器人房间专用幸运礼物展示事件。 // RoomRobotLuckyGiftDrawn 是机器人房间专用幸运礼物展示事件。
// 它复用客户端 lucky_gift_drawn 表现协议,但不代表真实奖池抽奖和钱包返奖事实。 // 它复用客户端 lucky_gift_drawn 表现协议,但不代表真实奖池抽奖和钱包返奖事实。
type RoomRobotLuckyGiftDrawn struct { type RoomRobotLuckyGiftDrawn struct {
@ -2136,7 +2384,7 @@ type RoomRobotLuckyGiftDrawn struct {
func (x *RoomRobotLuckyGiftDrawn) Reset() { func (x *RoomRobotLuckyGiftDrawn) Reset() {
*x = RoomRobotLuckyGiftDrawn{} *x = RoomRobotLuckyGiftDrawn{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[20] mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -2148,7 +2396,7 @@ func (x *RoomRobotLuckyGiftDrawn) String() string {
func (*RoomRobotLuckyGiftDrawn) ProtoMessage() {} func (*RoomRobotLuckyGiftDrawn) ProtoMessage() {}
func (x *RoomRobotLuckyGiftDrawn) ProtoReflect() protoreflect.Message { func (x *RoomRobotLuckyGiftDrawn) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[20] mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -2161,7 +2409,7 @@ func (x *RoomRobotLuckyGiftDrawn) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRobotLuckyGiftDrawn.ProtoReflect.Descriptor instead. // Deprecated: Use RoomRobotLuckyGiftDrawn.ProtoReflect.Descriptor instead.
func (*RoomRobotLuckyGiftDrawn) Descriptor() ([]byte, []int) { func (*RoomRobotLuckyGiftDrawn) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{20} return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{21}
} }
func (x *RoomRobotLuckyGiftDrawn) GetDrawId() string { func (x *RoomRobotLuckyGiftDrawn) GetDrawId() string {
@ -2274,7 +2522,7 @@ type RoomHeatChanged struct {
func (x *RoomHeatChanged) Reset() { func (x *RoomHeatChanged) Reset() {
*x = RoomHeatChanged{} *x = RoomHeatChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[21] mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -2286,7 +2534,7 @@ func (x *RoomHeatChanged) String() string {
func (*RoomHeatChanged) ProtoMessage() {} func (*RoomHeatChanged) ProtoMessage() {}
func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message { func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[21] mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -2299,7 +2547,7 @@ func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomHeatChanged.ProtoReflect.Descriptor instead. // Deprecated: Use RoomHeatChanged.ProtoReflect.Descriptor instead.
func (*RoomHeatChanged) Descriptor() ([]byte, []int) { func (*RoomHeatChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{21} return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{22}
} }
func (x *RoomHeatChanged) GetDelta() int64 { func (x *RoomHeatChanged) GetDelta() int64 {
@ -2329,7 +2577,7 @@ type RoomRankChanged struct {
func (x *RoomRankChanged) Reset() { func (x *RoomRankChanged) Reset() {
*x = RoomRankChanged{} *x = RoomRankChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[22] mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -2341,7 +2589,7 @@ func (x *RoomRankChanged) String() string {
func (*RoomRankChanged) ProtoMessage() {} func (*RoomRankChanged) ProtoMessage() {}
func (x *RoomRankChanged) ProtoReflect() protoreflect.Message { func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[22] mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -2354,7 +2602,7 @@ func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRankChanged.ProtoReflect.Descriptor instead. // Deprecated: Use RoomRankChanged.ProtoReflect.Descriptor instead.
func (*RoomRankChanged) Descriptor() ([]byte, []int) { func (*RoomRankChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{22} return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{23}
} }
func (x *RoomRankChanged) GetUserId() int64 { func (x *RoomRankChanged) GetUserId() int64 {
@ -2395,7 +2643,7 @@ type RoomRocketRewardGrant struct {
func (x *RoomRocketRewardGrant) Reset() { func (x *RoomRocketRewardGrant) Reset() {
*x = RoomRocketRewardGrant{} *x = RoomRocketRewardGrant{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[23] mi := &file_proto_events_room_v1_events_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -2407,7 +2655,7 @@ func (x *RoomRocketRewardGrant) String() string {
func (*RoomRocketRewardGrant) ProtoMessage() {} func (*RoomRocketRewardGrant) ProtoMessage() {}
func (x *RoomRocketRewardGrant) ProtoReflect() protoreflect.Message { func (x *RoomRocketRewardGrant) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[23] mi := &file_proto_events_room_v1_events_proto_msgTypes[24]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -2420,7 +2668,7 @@ func (x *RoomRocketRewardGrant) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketRewardGrant.ProtoReflect.Descriptor instead. // Deprecated: Use RoomRocketRewardGrant.ProtoReflect.Descriptor instead.
func (*RoomRocketRewardGrant) Descriptor() ([]byte, []int) { func (*RoomRocketRewardGrant) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{23} return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{24}
} }
func (x *RoomRocketRewardGrant) GetRewardRole() string { func (x *RoomRocketRewardGrant) GetRewardRole() string {
@ -2503,7 +2751,7 @@ type RoomRocketFuelChanged struct {
func (x *RoomRocketFuelChanged) Reset() { func (x *RoomRocketFuelChanged) Reset() {
*x = RoomRocketFuelChanged{} *x = RoomRocketFuelChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[24] mi := &file_proto_events_room_v1_events_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -2515,7 +2763,7 @@ func (x *RoomRocketFuelChanged) String() string {
func (*RoomRocketFuelChanged) ProtoMessage() {} func (*RoomRocketFuelChanged) ProtoMessage() {}
func (x *RoomRocketFuelChanged) ProtoReflect() protoreflect.Message { func (x *RoomRocketFuelChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[24] mi := &file_proto_events_room_v1_events_proto_msgTypes[25]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -2528,7 +2776,7 @@ func (x *RoomRocketFuelChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketFuelChanged.ProtoReflect.Descriptor instead. // Deprecated: Use RoomRocketFuelChanged.ProtoReflect.Descriptor instead.
func (*RoomRocketFuelChanged) Descriptor() ([]byte, []int) { func (*RoomRocketFuelChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{24} return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{25}
} }
func (x *RoomRocketFuelChanged) GetRocketId() string { func (x *RoomRocketFuelChanged) GetRocketId() string {
@ -2653,7 +2901,7 @@ type RoomRocketIgnited struct {
func (x *RoomRocketIgnited) Reset() { func (x *RoomRocketIgnited) Reset() {
*x = RoomRocketIgnited{} *x = RoomRocketIgnited{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[25] mi := &file_proto_events_room_v1_events_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -2665,7 +2913,7 @@ func (x *RoomRocketIgnited) String() string {
func (*RoomRocketIgnited) ProtoMessage() {} func (*RoomRocketIgnited) ProtoMessage() {}
func (x *RoomRocketIgnited) ProtoReflect() protoreflect.Message { func (x *RoomRocketIgnited) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[25] mi := &file_proto_events_room_v1_events_proto_msgTypes[26]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -2678,7 +2926,7 @@ func (x *RoomRocketIgnited) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketIgnited.ProtoReflect.Descriptor instead. // Deprecated: Use RoomRocketIgnited.ProtoReflect.Descriptor instead.
func (*RoomRocketIgnited) Descriptor() ([]byte, []int) { func (*RoomRocketIgnited) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{25} return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{26}
} }
func (x *RoomRocketIgnited) GetRocketId() string { func (x *RoomRocketIgnited) GetRocketId() string {
@ -2798,7 +3046,7 @@ type RoomRocketLaunched struct {
func (x *RoomRocketLaunched) Reset() { func (x *RoomRocketLaunched) Reset() {
*x = RoomRocketLaunched{} *x = RoomRocketLaunched{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[26] mi := &file_proto_events_room_v1_events_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -2810,7 +3058,7 @@ func (x *RoomRocketLaunched) String() string {
func (*RoomRocketLaunched) ProtoMessage() {} func (*RoomRocketLaunched) ProtoMessage() {}
func (x *RoomRocketLaunched) ProtoReflect() protoreflect.Message { func (x *RoomRocketLaunched) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[26] mi := &file_proto_events_room_v1_events_proto_msgTypes[27]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -2823,7 +3071,7 @@ func (x *RoomRocketLaunched) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketLaunched.ProtoReflect.Descriptor instead. // Deprecated: Use RoomRocketLaunched.ProtoReflect.Descriptor instead.
func (*RoomRocketLaunched) Descriptor() ([]byte, []int) { func (*RoomRocketLaunched) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{26} return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{27}
} }
func (x *RoomRocketLaunched) GetRocketId() string { func (x *RoomRocketLaunched) GetRocketId() string {
@ -2902,7 +3150,7 @@ type RoomRocketRewardGranted struct {
func (x *RoomRocketRewardGranted) Reset() { func (x *RoomRocketRewardGranted) Reset() {
*x = RoomRocketRewardGranted{} *x = RoomRocketRewardGranted{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[27] mi := &file_proto_events_room_v1_events_proto_msgTypes[28]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -2914,7 +3162,7 @@ func (x *RoomRocketRewardGranted) String() string {
func (*RoomRocketRewardGranted) ProtoMessage() {} func (*RoomRocketRewardGranted) ProtoMessage() {}
func (x *RoomRocketRewardGranted) ProtoReflect() protoreflect.Message { func (x *RoomRocketRewardGranted) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[27] mi := &file_proto_events_room_v1_events_proto_msgTypes[28]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -2927,7 +3175,7 @@ func (x *RoomRocketRewardGranted) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketRewardGranted.ProtoReflect.Descriptor instead. // Deprecated: Use RoomRocketRewardGranted.ProtoReflect.Descriptor instead.
func (*RoomRocketRewardGranted) Descriptor() ([]byte, []int) { func (*RoomRocketRewardGranted) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{27} return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{28}
} }
func (x *RoomRocketRewardGranted) GetRocketId() string { func (x *RoomRocketRewardGranted) GetRocketId() string {
@ -3371,166 +3619,231 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65,
0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x16, 0x20, 0x01, 0x28, 0x03, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x16, 0x20, 0x01, 0x28, 0x03,
0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49,
0x64, 0x22, 0x80, 0x04, 0x0a, 0x17, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x62, 0x6f, 0x74, 0x4c, 0x64, 0x22, 0x96, 0x08, 0x0a, 0x12, 0x52, 0x6f, 0x6f, 0x6d, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47,
0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x6e, 0x12, 0x17, 0x0a, 0x69, 0x66, 0x74, 0x44, 0x72, 0x61, 0x77, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x61, 0x77,
0x07, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x72, 0x61, 0x77, 0x49,
0x64, 0x72, 0x61, 0x77, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18,
0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64,
0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66,
0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74,
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e,
0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72,
0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65,
0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65,
0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52,
0x6c, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1f, 0x0a,
0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x0b, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01,
0x5f, 0x70, 0x70, 0x6d, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x23,
0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x50, 0x70, 0x6d, 0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61, 0x73, 0x0a, 0x0d, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x76, 0x61, 0x74, 0x61, 0x72, 0x18,
0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x09, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x41, 0x76, 0x61,
0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62, 0x61, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x74, 0x61, 0x72, 0x12, 0x33, 0x0a, 0x16, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x64, 0x69,
0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x34, 0x0a, 0x16, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20,
0x76, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x01, 0x28, 0x09, 0x52, 0x13, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x44, 0x69, 0x73, 0x70, 0x6c,
0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x1d, 0x73, 0x65, 0x6e, 0x64,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x63, 0x65, 0x72, 0x5f, 0x70, 0x72, 0x65, 0x74, 0x74, 0x79, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61,
0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52,
0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x19, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x50, 0x72, 0x65, 0x74, 0x74, 0x79, 0x44, 0x69, 0x73,
0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69,
0x6e, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69,
0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x69,
0x73, 0x5f, 0x72, 0x6f, 0x62, 0x6f, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69,
0x73, 0x52, 0x6f, 0x62, 0x6f, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65,
0x74, 0x69, 0x63, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x79, 0x6e, 0x74, 0x68,
0x65, 0x74, 0x69, 0x63, 0x22, 0x4a, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74,
0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61,
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a,
0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74,
0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e,
0x67, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01,
0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05,
0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f,
0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65,
0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75,
0x65, 0x22, 0x94, 0x02, 0x0a, 0x15, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x72,
0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
0x52, 0x0a, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x17, 0x0a, 0x07,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f,
0x69, 0x74, 0x65, 0x6d, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72,
0x65, 0x77, 0x61, 0x72, 0x64, 0x49, 0x74, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x72,
0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64,
0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c,
0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64,
0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x63,
0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x69, 0x63,
0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x69,
0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x49, 0x64,
0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09,
0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x22, 0xeb, 0x03, 0x0a, 0x15, 0x52, 0x6f, 0x6f,
0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x46, 0x75, 0x65, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x67,
0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12,
0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05,
0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x65, 0x64, 0x5f, 0x66,
0x75, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x61, 0x64, 0x64, 0x65, 0x64,
0x46, 0x75, 0x65, 0x6c, 0x12, 0x30, 0x0a, 0x14, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76,
0x65, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01,
0x28, 0x03, 0x52, 0x12, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x64, 0x64,
0x65, 0x64, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x6f, 0x76, 0x65, 0x72, 0x66, 0x6c,
0x6f, 0x77, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f,
0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63,
0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x25,
0x0a, 0x0e, 0x66, 0x75, 0x65, 0x6c, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64,
0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x66, 0x75, 0x65, 0x6c, 0x54, 0x68, 0x72, 0x65,
0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1e, 0x0a,
0x0b, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01,
0x28, 0x03, 0x52, 0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x24, 0x0a,
0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65,
0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0b,
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05,
0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63,
0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52,
0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69,
0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x0c, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65,
0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0x82, 0x04, 0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x75, 0x6e,
0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x74, 0x72, 0x79, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x69, 0x6e, 0x5f, 0x73, 0x70,
0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x69, 0x6e, 0x53,
0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x70, 0x65, 0x6e, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x75, 0x6c, 0x65, 0x5f, 0x76, 0x65, 0x72,
0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x72, 0x75, 0x6c, 0x65,
0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x46, 0x75, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x0a, 0x0f, 0x65, 0x78, 0x70, 0x65, 0x72,
0x65, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x66, 0x75, 0x65, 0x6c, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x73, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x5f, 0x70, 0x6f, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09,
0x68, 0x6f, 0x6c, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x66, 0x75, 0x65, 0x6c, 0x52, 0x0e, 0x65, 0x78, 0x70, 0x65, 0x72, 0x69, 0x65, 0x6e, 0x63, 0x65, 0x50, 0x6f, 0x6f, 0x6c,
0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x69, 0x67, 0x6e, 0x12, 0x28, 0x0a, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x74, 0x69, 0x65,
0x69, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x65, 0x6c, 0x65,
0x52, 0x0b, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x20, 0x0a, 0x63, 0x74, 0x65, 0x64, 0x54, 0x69, 0x65, 0x72, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x6d, 0x75,
0x0c, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x5f, 0x70, 0x70, 0x6d, 0x18, 0x12, 0x20, 0x01,
0x01, 0x28, 0x03, 0x52, 0x0a, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x50, 0x70,
0x27, 0x0a, 0x0f, 0x62, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x63, 0x6f, 0x6d, 0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64,
0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x62, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x13, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62, 0x61,
0x61, 0x73, 0x74, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x34, 0x0a,
0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x16, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72,
0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x14, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x65,
0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f,
0x69, 0x6e, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x74, 0x61, 0x67, 0x65, 0x5f, 0x66, 0x65, 0x65,
0x64, 0x62, 0x61, 0x63, 0x6b, 0x18, 0x15, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x73, 0x74, 0x61,
0x67, 0x65, 0x46, 0x65, 0x65, 0x64, 0x62, 0x61, 0x63, 0x6b, 0x12, 0x27, 0x0a, 0x0f, 0x68, 0x69,
0x67, 0x68, 0x5f, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x18, 0x16, 0x20,
0x01, 0x28, 0x08, 0x52, 0x0e, 0x68, 0x69, 0x67, 0x68, 0x4d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c,
0x69, 0x65, 0x72, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x73, 0x74,
0x61, 0x74, 0x75, 0x73, 0x18, 0x17, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x77, 0x61,
0x72, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x77, 0x61, 0x6c, 0x6c,
0x65, 0x74, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69,
0x64, 0x18, 0x18, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x54,
0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x2b, 0x0a, 0x12,
0x64, 0x72, 0x61, 0x77, 0x5f, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f,
0x6d, 0x73, 0x18, 0x19, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x64, 0x72, 0x61, 0x77, 0x43, 0x72,
0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x2f, 0x0a, 0x14, 0x72, 0x65, 0x77,
0x61, 0x72, 0x64, 0x5f, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d,
0x73, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47,
0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x22, 0x80, 0x04, 0x0a, 0x17, 0x52,
0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x62, 0x6f, 0x74, 0x4c, 0x75, 0x63, 0x6b, 0x79, 0x47, 0x69, 0x66,
0x74, 0x44, 0x72, 0x61, 0x77, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x72, 0x61, 0x77, 0x5f, 0x69,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x72, 0x61, 0x77, 0x49, 0x64, 0x12,
0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x24,
0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73,
0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75,
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61,
0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69,
0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66,
0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e,
0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75,
0x6e, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x70, 0x6f, 0x6f, 0x6c, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20,
0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x6f, 0x6f, 0x6c, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x6d,
0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x5f, 0x70, 0x70, 0x6d, 0x18, 0x08, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x75, 0x6c, 0x74, 0x69, 0x70, 0x6c, 0x69, 0x65, 0x72, 0x50,
0x70, 0x6d, 0x12, 0x2a, 0x0a, 0x11, 0x62, 0x61, 0x73, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61, 0x72,
0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x62,
0x61, 0x73, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43, 0x6f, 0x69, 0x6e, 0x73, 0x12, 0x34,
0x0a, 0x16, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x72, 0x65, 0x77, 0x61,
0x72, 0x64, 0x5f, 0x63, 0x6f, 0x69, 0x6e, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14,
0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x43,
0x6f, 0x69, 0x6e, 0x73, 0x12, 0x22, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f,
0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x72, 0x65,
0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69,
0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69,
0x6f, 0x6e, 0x49, 0x64, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x70, 0x31, 0x5f, 0x75, 0x73, 0x65, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x73, 0x5f, 0x72, 0x6f, 0x62, 0x6f, 0x74,
0x72, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x74, 0x6f, 0x70, 0x31, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x69, 0x73, 0x52, 0x6f, 0x62, 0x6f, 0x74, 0x12,
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x1c, 0x0a, 0x09, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65, 0x74, 0x69, 0x63, 0x18, 0x0e, 0x20, 0x01,
0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x28, 0x08, 0x52, 0x09, 0x73, 0x79, 0x6e, 0x74, 0x68, 0x65, 0x74, 0x69, 0x63, 0x22, 0x4a, 0x0a,
0x0d, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64,
0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52,
0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e,
0x0d, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x73, 0x68, 0x6f, 0x72, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x6f, 0x6f, 0x6d, 0x53, 0x68, 0x6f, 0x72, 0x74, 0x49, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f, 0x6f,
0x64, 0x12, 0x28, 0x0a, 0x10, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x76, 0x65, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07,
0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x6f, 0x63, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75,
0x6b, 0x65, 0x74, 0x43, 0x6f, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02,
0x65, 0x73, 0x65, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x67,
0x52, 0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x22, 0xe6, 0x02, 0x0a, 0x12, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52,
0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x4c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x94, 0x02, 0x0a, 0x15, 0x52,
0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47,
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x72, 0x61, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x72,
0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6f, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x77, 0x61, 0x72,
0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x6c, 0x65, 0x64, 0x52, 0x6f, 0x6c, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x76, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x6e, 0x65, 0x78, 0x74, 0x4c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24,
0x65, 0x76, 0x65, 0x6c, 0x12, 0x24, 0x0a, 0x0e, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x65, 0x64, 0x0a, 0x0e, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x69, 0x64,
0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x49, 0x74,
0x75, 0x6e, 0x63, 0x68, 0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x65, 0x65, 0x6d, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65,
0x73, 0x65, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x0f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64,
0x70, 0x31, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65,
0x52, 0x0a, 0x74, 0x6f, 0x70, 0x31, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e,
0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x69, 0x63, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18,
0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x55, 0x73, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x69, 0x63, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x19,
0x65, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x10, 0x69, 0x6e, 0x5f, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x0a, 0x08, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0d, 0x52, 0x07, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61,
0x69, 0x6e, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x45, 0x0a, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75,
0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x73, 0x22, 0xeb, 0x03, 0x0a, 0x15, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x72, 0x6f, 0x46, 0x75, 0x65, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72,
0x6f, 0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x72, 0x65, 0x77, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65,
0x61, 0x72, 0x64, 0x73, 0x22, 0x93, 0x01, 0x0a, 0x17, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d,
0x6b, 0x65, 0x74, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x0a, 0x0a, 0x61, 0x64, 0x64, 0x65, 0x64, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01,
0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x28, 0x03, 0x52, 0x09, 0x61, 0x64, 0x64, 0x65, 0x64, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x30, 0x0a,
0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x14, 0x65, 0x66, 0x66, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64,
0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x65, 0x66, 0x66,
0x76, 0x65, 0x6c, 0x12, 0x45, 0x0a, 0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x18, 0x03, 0x65, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x64, 0x64, 0x65, 0x64, 0x46, 0x75, 0x65, 0x6c, 0x12,
0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x65, 0x76, 0x65, 0x23, 0x0a, 0x0d, 0x6f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77, 0x5f, 0x66, 0x75, 0x65, 0x6c,
0x6e, 0x74, 0x73, 0x2e, 0x72, 0x6f, 0x6f, 0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6f, 0x76, 0x65, 0x72, 0x66, 0x6c, 0x6f, 0x77,
0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f,
0x74, 0x52, 0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x42, 0x33, 0x5a, 0x31, 0x68, 0x79, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72,
0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x65, 0x6e, 0x74, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x66, 0x75, 0x65, 0x6c, 0x5f,
0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d, 0x2f, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52,
0x76, 0x31, 0x3b, 0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62, 0x0d, 0x66, 0x75, 0x65, 0x6c, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x16,
0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06,
0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f,
0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x72, 0x65, 0x73,
0x65, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c,
0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07,
0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67,
0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f,
0x75, 0x6e, 0x74, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43,
0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f,
0x69, 0x64, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e,
0x64, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72,
0x65, 0x67, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f,
0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22,
0x82, 0x04, 0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x67,
0x6e, 0x69, 0x74, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f,
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74,
0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28,
0x05, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72,
0x65, 0x6e, 0x74, 0x5f, 0x66, 0x75, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x46, 0x75, 0x65, 0x6c, 0x12, 0x25, 0x0a, 0x0e, 0x66,
0x75, 0x65, 0x6c, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x04, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0d, 0x66, 0x75, 0x65, 0x6c, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f,
0x6c, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
0x5f, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x69, 0x67, 0x6e, 0x69, 0x74,
0x65, 0x64, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68,
0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x6c, 0x61,
0x75, 0x6e, 0x63, 0x68, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x62, 0x72, 0x6f, 0x61,
0x64, 0x63, 0x61, 0x73, 0x74, 0x5f, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28,
0x09, 0x52, 0x0e, 0x62, 0x72, 0x6f, 0x61, 0x64, 0x63, 0x61, 0x73, 0x74, 0x53, 0x63, 0x6f, 0x70,
0x65, 0x12, 0x2a, 0x0a, 0x11, 0x76, 0x69, 0x73, 0x69, 0x62, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x67,
0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x76, 0x69,
0x73, 0x69, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x20, 0x0a,
0x0c, 0x74, 0x6f, 0x70, 0x31, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x09, 0x20,
0x01, 0x28, 0x03, 0x52, 0x0a, 0x74, 0x6f, 0x70, 0x31, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12,
0x26, 0x0a, 0x0f, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
0x69, 0x64, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65,
0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61,
0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d,
0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x73,
0x68, 0x6f, 0x72, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72,
0x6f, 0x6f, 0x6d, 0x53, 0x68, 0x6f, 0x72, 0x74, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x10, 0x72, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x5f, 0x63, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x0d,
0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x43, 0x6f, 0x76, 0x65,
0x72, 0x55, 0x72, 0x6c, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x61, 0x74,
0x5f, 0x6d, 0x73, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x72, 0x65, 0x73, 0x65, 0x74,
0x41, 0x74, 0x4d, 0x73, 0x22, 0xe6, 0x02, 0x0a, 0x12, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x4c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72,
0x6f, 0x63, 0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
0x72, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65,
0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x1d,
0x0a, 0x0a, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01,
0x28, 0x05, 0x52, 0x09, 0x6e, 0x65, 0x78, 0x74, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x24, 0x0a,
0x0e, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x6c, 0x61, 0x75, 0x6e, 0x63, 0x68, 0x65, 0x64, 0x41,
0x74, 0x4d, 0x73, 0x12, 0x1e, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x65, 0x74, 0x5f, 0x61, 0x74, 0x5f,
0x6d, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x72, 0x65, 0x73, 0x65, 0x74, 0x41,
0x74, 0x4d, 0x73, 0x12, 0x20, 0x0a, 0x0c, 0x74, 0x6f, 0x70, 0x31, 0x5f, 0x75, 0x73, 0x65, 0x72,
0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x74, 0x6f, 0x70, 0x31, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72,
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d,
0x69, 0x67, 0x6e, 0x69, 0x74, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a,
0x10, 0x69, 0x6e, 0x5f, 0x72, 0x6f, 0x6f, 0x6d, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0d, 0x69, 0x6e, 0x52, 0x6f, 0x6f, 0x6d, 0x55,
0x73, 0x65, 0x72, 0x49, 0x64, 0x73, 0x12, 0x45, 0x0a, 0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64,
0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x72, 0x6f, 0x6f, 0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x77, 0x61, 0x72, 0x64, 0x47,
0x72, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x22, 0x93, 0x01,
0x0a, 0x17, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x52, 0x65, 0x77, 0x61,
0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x72, 0x6f, 0x63,
0x6b, 0x65, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x6f,
0x63, 0x6b, 0x65, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18,
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x45, 0x0a, 0x07,
0x72, 0x65, 0x77, 0x61, 0x72, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2b, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2e, 0x72, 0x6f, 0x6f,
0x6d, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x6f, 0x63, 0x6b, 0x65, 0x74, 0x52,
0x65, 0x77, 0x61, 0x72, 0x64, 0x47, 0x72, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x72, 0x65, 0x77, 0x61,
0x72, 0x64, 0x73, 0x42, 0x33, 0x5a, 0x31, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63,
0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65,
0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d, 0x2f, 0x76, 0x31, 0x3b, 0x72, 0x6f, 0x6f, 0x6d,
0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
} }
var ( var (
@ -3545,7 +3858,7 @@ func file_proto_events_room_v1_events_proto_rawDescGZIP() []byte {
return file_proto_events_room_v1_events_proto_rawDescData return file_proto_events_room_v1_events_proto_rawDescData
} }
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 28) var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 29)
var file_proto_events_room_v1_events_proto_goTypes = []any{ var file_proto_events_room_v1_events_proto_goTypes = []any{
(*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope (*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope
(*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated (*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated
@ -3567,21 +3880,22 @@ var file_proto_events_room_v1_events_proto_goTypes = []any{
(*RoomGiftBatchLuckyResult)(nil), // 17: hyapp.events.room.v1.RoomGiftBatchLuckyResult (*RoomGiftBatchLuckyResult)(nil), // 17: hyapp.events.room.v1.RoomGiftBatchLuckyResult
(*RoomGiftBatchTarget)(nil), // 18: hyapp.events.room.v1.RoomGiftBatchTarget (*RoomGiftBatchTarget)(nil), // 18: hyapp.events.room.v1.RoomGiftBatchTarget
(*RoomGiftBatchSent)(nil), // 19: hyapp.events.room.v1.RoomGiftBatchSent (*RoomGiftBatchSent)(nil), // 19: hyapp.events.room.v1.RoomGiftBatchSent
(*RoomRobotLuckyGiftDrawn)(nil), // 20: hyapp.events.room.v1.RoomRobotLuckyGiftDrawn (*RoomLuckyGiftDrawn)(nil), // 20: hyapp.events.room.v1.RoomLuckyGiftDrawn
(*RoomHeatChanged)(nil), // 21: hyapp.events.room.v1.RoomHeatChanged (*RoomRobotLuckyGiftDrawn)(nil), // 21: hyapp.events.room.v1.RoomRobotLuckyGiftDrawn
(*RoomRankChanged)(nil), // 22: hyapp.events.room.v1.RoomRankChanged (*RoomHeatChanged)(nil), // 22: hyapp.events.room.v1.RoomHeatChanged
(*RoomRocketRewardGrant)(nil), // 23: hyapp.events.room.v1.RoomRocketRewardGrant (*RoomRankChanged)(nil), // 23: hyapp.events.room.v1.RoomRankChanged
(*RoomRocketFuelChanged)(nil), // 24: hyapp.events.room.v1.RoomRocketFuelChanged (*RoomRocketRewardGrant)(nil), // 24: hyapp.events.room.v1.RoomRocketRewardGrant
(*RoomRocketIgnited)(nil), // 25: hyapp.events.room.v1.RoomRocketIgnited (*RoomRocketFuelChanged)(nil), // 25: hyapp.events.room.v1.RoomRocketFuelChanged
(*RoomRocketLaunched)(nil), // 26: hyapp.events.room.v1.RoomRocketLaunched (*RoomRocketIgnited)(nil), // 26: hyapp.events.room.v1.RoomRocketIgnited
(*RoomRocketRewardGranted)(nil), // 27: hyapp.events.room.v1.RoomRocketRewardGranted (*RoomRocketLaunched)(nil), // 27: hyapp.events.room.v1.RoomRocketLaunched
(*RoomRocketRewardGranted)(nil), // 28: hyapp.events.room.v1.RoomRocketRewardGranted
} }
var file_proto_events_room_v1_events_proto_depIdxs = []int32{ var file_proto_events_room_v1_events_proto_depIdxs = []int32{
4, // 0: hyapp.events.room.v1.RoomUserJoined.entry_vehicle:type_name -> hyapp.events.room.v1.RoomEntryVehicleSnapshot 4, // 0: hyapp.events.room.v1.RoomUserJoined.entry_vehicle:type_name -> hyapp.events.room.v1.RoomEntryVehicleSnapshot
17, // 1: hyapp.events.room.v1.RoomGiftBatchTarget.lucky_gift:type_name -> hyapp.events.room.v1.RoomGiftBatchLuckyResult 17, // 1: hyapp.events.room.v1.RoomGiftBatchTarget.lucky_gift:type_name -> hyapp.events.room.v1.RoomGiftBatchLuckyResult
18, // 2: hyapp.events.room.v1.RoomGiftBatchSent.targets:type_name -> hyapp.events.room.v1.RoomGiftBatchTarget 18, // 2: hyapp.events.room.v1.RoomGiftBatchSent.targets:type_name -> hyapp.events.room.v1.RoomGiftBatchTarget
23, // 3: hyapp.events.room.v1.RoomRocketLaunched.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant 24, // 3: hyapp.events.room.v1.RoomRocketLaunched.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
23, // 4: hyapp.events.room.v1.RoomRocketRewardGranted.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant 24, // 4: hyapp.events.room.v1.RoomRocketRewardGranted.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
5, // [5:5] is the sub-list for method output_type 5, // [5:5] is the sub-list for method output_type
5, // [5:5] is the sub-list for method input_type 5, // [5:5] is the sub-list for method input_type
5, // [5:5] is the sub-list for extension type_name 5, // [5:5] is the sub-list for extension type_name
@ -3600,7 +3914,7 @@ func file_proto_events_room_v1_events_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_events_room_v1_events_proto_rawDesc, RawDescriptor: file_proto_events_room_v1_events_proto_rawDesc,
NumEnums: 0, NumEnums: 0,
NumMessages: 28, NumMessages: 29,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },

View File

@ -267,6 +267,38 @@ message RoomGiftBatchSent {
int64 visible_region_id = 22; int64 visible_region_id = 22;
} }
// RoomLuckyGiftDrawn room-service lucky-gift-service owner
// IM Room Cell room_version
message RoomLuckyGiftDrawn {
string draw_id = 1;
string command_id = 2;
string pool_id = 3;
string gift_id = 4;
int32 gift_count = 5;
int64 sender_user_id = 6;
int64 target_user_id = 7;
string sender_name = 8;
string sender_avatar = 9;
string sender_display_user_id = 10;
string sender_pretty_display_user_id = 11;
int64 visible_region_id = 12;
int64 country_id = 13;
int64 coin_spent = 14;
int64 rule_version = 15;
string experience_pool = 16;
string selected_tier_id = 17;
int64 multiplier_ppm = 18;
int64 base_reward_coins = 19;
int64 effective_reward_coins = 20;
bool stage_feedback = 21;
bool high_multiplier = 22;
string reward_status = 23;
string wallet_transaction_id = 24;
// 沿 lucky owner UTC Unix epoch milliseconds room-service
int64 draw_created_at_ms = 25;
int64 reward_granted_at_ms = 26;
}
// RoomRobotLuckyGiftDrawn // RoomRobotLuckyGiftDrawn
// lucky_gift_drawn // lucky_gift_drawn
message RoomRobotLuckyGiftDrawn { message RoomRobotLuckyGiftDrawn {

File diff suppressed because it is too large Load Diff

View File

@ -31,6 +31,11 @@ message LuckyGiftMeta {
int32 gift_count = 12; int32 gift_count = 12;
int64 visible_region_id = 13; int64 visible_region_id = 13;
int64 country_id = 14; int64 country_id = 14;
// sender_* room-service IM
string sender_name = 15;
string sender_avatar = 16;
string sender_display_user_id = 17;
string sender_pretty_display_user_id = 18;
} }
message LuckyGiftRuleTier { message LuckyGiftRuleTier {

File diff suppressed because it is too large Load Diff

View File

@ -1215,6 +1215,17 @@ message VerifyRoomPresenceResponse {
int64 room_version = 3; int64 room_version = 3;
} }
// ResolveRoomAppCodeRequest room_id
// app_code URL Fami Lalu
message ResolveRoomAppCodeRequest {
string request_id = 1;
string room_id = 2;
}
message ResolveRoomAppCodeResponse {
string app_code = 1;
}
// ListRoomsRequest // ListRoomsRequest
// visible_region_id gateway viewer filter_region_id // visible_region_id gateway viewer filter_region_id
// gateway filter_region_id // gateway filter_region_id
@ -1523,6 +1534,7 @@ service RoomCommandService {
service RoomGuardService { service RoomGuardService {
rpc CheckSpeakPermission(CheckSpeakPermissionRequest) returns (CheckSpeakPermissionResponse); rpc CheckSpeakPermission(CheckSpeakPermissionRequest) returns (CheckSpeakPermissionResponse);
rpc VerifyRoomPresence(VerifyRoomPresenceRequest) returns (VerifyRoomPresenceResponse); rpc VerifyRoomPresence(VerifyRoomPresenceRequest) returns (VerifyRoomPresenceResponse);
rpc ResolveRoomAppCode(ResolveRoomAppCodeRequest) returns (ResolveRoomAppCodeResponse);
} }
// RoomQueryService Room Cell // RoomQueryService Room Cell

View File

@ -1457,6 +1457,7 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
const ( const (
RoomGuardService_CheckSpeakPermission_FullMethodName = "/hyapp.room.v1.RoomGuardService/CheckSpeakPermission" RoomGuardService_CheckSpeakPermission_FullMethodName = "/hyapp.room.v1.RoomGuardService/CheckSpeakPermission"
RoomGuardService_VerifyRoomPresence_FullMethodName = "/hyapp.room.v1.RoomGuardService/VerifyRoomPresence" RoomGuardService_VerifyRoomPresence_FullMethodName = "/hyapp.room.v1.RoomGuardService/VerifyRoomPresence"
RoomGuardService_ResolveRoomAppCode_FullMethodName = "/hyapp.room.v1.RoomGuardService/ResolveRoomAppCode"
) )
// RoomGuardServiceClient is the client API for RoomGuardService service. // RoomGuardServiceClient is the client API for RoomGuardService service.
@ -1467,6 +1468,7 @@ const (
type RoomGuardServiceClient interface { type RoomGuardServiceClient interface {
CheckSpeakPermission(ctx context.Context, in *CheckSpeakPermissionRequest, opts ...grpc.CallOption) (*CheckSpeakPermissionResponse, error) CheckSpeakPermission(ctx context.Context, in *CheckSpeakPermissionRequest, opts ...grpc.CallOption) (*CheckSpeakPermissionResponse, error)
VerifyRoomPresence(ctx context.Context, in *VerifyRoomPresenceRequest, opts ...grpc.CallOption) (*VerifyRoomPresenceResponse, error) VerifyRoomPresence(ctx context.Context, in *VerifyRoomPresenceRequest, opts ...grpc.CallOption) (*VerifyRoomPresenceResponse, error)
ResolveRoomAppCode(ctx context.Context, in *ResolveRoomAppCodeRequest, opts ...grpc.CallOption) (*ResolveRoomAppCodeResponse, error)
} }
type roomGuardServiceClient struct { type roomGuardServiceClient struct {
@ -1497,6 +1499,16 @@ func (c *roomGuardServiceClient) VerifyRoomPresence(ctx context.Context, in *Ver
return out, nil return out, nil
} }
func (c *roomGuardServiceClient) ResolveRoomAppCode(ctx context.Context, in *ResolveRoomAppCodeRequest, opts ...grpc.CallOption) (*ResolveRoomAppCodeResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResolveRoomAppCodeResponse)
err := c.cc.Invoke(ctx, RoomGuardService_ResolveRoomAppCode_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// RoomGuardServiceServer is the server API for RoomGuardService service. // RoomGuardServiceServer is the server API for RoomGuardService service.
// All implementations must embed UnimplementedRoomGuardServiceServer // All implementations must embed UnimplementedRoomGuardServiceServer
// for forward compatibility. // for forward compatibility.
@ -1505,6 +1517,7 @@ func (c *roomGuardServiceClient) VerifyRoomPresence(ctx context.Context, in *Ver
type RoomGuardServiceServer interface { type RoomGuardServiceServer interface {
CheckSpeakPermission(context.Context, *CheckSpeakPermissionRequest) (*CheckSpeakPermissionResponse, error) CheckSpeakPermission(context.Context, *CheckSpeakPermissionRequest) (*CheckSpeakPermissionResponse, error)
VerifyRoomPresence(context.Context, *VerifyRoomPresenceRequest) (*VerifyRoomPresenceResponse, error) VerifyRoomPresence(context.Context, *VerifyRoomPresenceRequest) (*VerifyRoomPresenceResponse, error)
ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error)
mustEmbedUnimplementedRoomGuardServiceServer() mustEmbedUnimplementedRoomGuardServiceServer()
} }
@ -1521,6 +1534,9 @@ func (UnimplementedRoomGuardServiceServer) CheckSpeakPermission(context.Context,
func (UnimplementedRoomGuardServiceServer) VerifyRoomPresence(context.Context, *VerifyRoomPresenceRequest) (*VerifyRoomPresenceResponse, error) { func (UnimplementedRoomGuardServiceServer) VerifyRoomPresence(context.Context, *VerifyRoomPresenceRequest) (*VerifyRoomPresenceResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method VerifyRoomPresence not implemented") return nil, status.Errorf(codes.Unimplemented, "method VerifyRoomPresence not implemented")
} }
func (UnimplementedRoomGuardServiceServer) ResolveRoomAppCode(context.Context, *ResolveRoomAppCodeRequest) (*ResolveRoomAppCodeResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ResolveRoomAppCode not implemented")
}
func (UnimplementedRoomGuardServiceServer) mustEmbedUnimplementedRoomGuardServiceServer() {} func (UnimplementedRoomGuardServiceServer) mustEmbedUnimplementedRoomGuardServiceServer() {}
func (UnimplementedRoomGuardServiceServer) testEmbeddedByValue() {} func (UnimplementedRoomGuardServiceServer) testEmbeddedByValue() {}
@ -1578,6 +1594,24 @@ func _RoomGuardService_VerifyRoomPresence_Handler(srv interface{}, ctx context.C
return interceptor(ctx, in, info, handler) return interceptor(ctx, in, info, handler)
} }
func _RoomGuardService_ResolveRoomAppCode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResolveRoomAppCodeRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomGuardServiceServer).ResolveRoomAppCode(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomGuardService_ResolveRoomAppCode_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomGuardServiceServer).ResolveRoomAppCode(ctx, req.(*ResolveRoomAppCodeRequest))
}
return interceptor(ctx, in, info, handler)
}
// RoomGuardService_ServiceDesc is the grpc.ServiceDesc for RoomGuardService service. // RoomGuardService_ServiceDesc is the grpc.ServiceDesc for RoomGuardService service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@ -1593,6 +1627,10 @@ var RoomGuardService_ServiceDesc = grpc.ServiceDesc{
MethodName: "VerifyRoomPresence", MethodName: "VerifyRoomPresence",
Handler: _RoomGuardService_VerifyRoomPresence_Handler, Handler: _RoomGuardService_VerifyRoomPresence_Handler,
}, },
{
MethodName: "ResolveRoomAppCode",
Handler: _RoomGuardService_ResolveRoomAppCode_Handler,
},
}, },
Streams: []grpc.StreamDesc{}, Streams: []grpc.StreamDesc{},
Metadata: "proto/room/v1/room.proto", Metadata: "proto/room/v1/room.proto",

View File

@ -80,10 +80,10 @@ services:
condition: service_healthy condition: service_healthy
wallet-service: wallet-service:
condition: service_healthy condition: service_healthy
lucky-gift-service:
condition: service_healthy
rocketmq-broker: rocketmq-broker:
condition: service_started condition: service_started
lucky-gift-service:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13101/healthz/ready >/dev/null"] test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13101/healthz/ready >/dev/null"]
interval: 5s interval: 5s
@ -186,6 +186,8 @@ services:
condition: service_healthy condition: service_healthy
wallet-service: wallet-service:
condition: service_healthy condition: service_healthy
rocketmq-broker:
condition: service_started
healthcheck: healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13113/healthz/ready >/dev/null"] test: ["CMD-SHELL", "wget -q -O - http://127.0.0.1:13113/healthz/ready >/dev/null"]
interval: 5s interval: 5s

View File

@ -43,6 +43,7 @@
- owner service 继续负责自己的 outbox claim 和 MQ 发布: - owner service 继续负责自己的 outbox claim 和 MQ 发布:
- `wallet-service` 发布 `hyapp_wallet_outbox` - `wallet-service` 发布 `hyapp_wallet_outbox`
- `room-service` 发布 `hyapp_room_outbox` - `room-service` 发布 `hyapp_room_outbox`
- `lucky-gift-service` 在钱包返奖和本地 granted 收敛后发布 `hyapp_lucky_gift_outbox`
- `activity-service` 发布 activity / broadcast 相关 MQ - `activity-service` 发布 activity / broadcast 相关 MQ
- `game-service` 发布 game 相关 MQ - `game-service` 发布 game 相关 MQ
- `user-service` 发布 `hyapp_user_outbox` - `user-service` 发布 `hyapp_user_outbox`

View File

@ -0,0 +1,351 @@
# 新 App 上线配置检查清单
本文档定义 HyApp 新 App 从租户注册到正式运营的配置边界、业务 owner、开启条件和验收标准。它不是“把旧 App 数据全量复制一遍”的脚本说明;每项数据必须按 owner service 和业务键管理,不能跨租户复用原始自增 ID。
## 1. 使用方式
每次新 App 上线前,先建立一份当次上线记录,填写:
| 字段 | 说明 |
| --- | --- |
| `app_code` | 后端租户编码,全链路使用同一个小写值 |
| App 名称 | 用户可见品牌名 |
| Android package | Android 包名,必须和 Firebase、Google Play 一致 |
| iOS bundle ID | iOS bundle identifier必须和 Firebase、Apple 后台一致 |
| 对照 App | 只用于确定初始产品口径,不代表可以直接复制原始 ID |
| 计划开服时间 | UTC epoch milliseconds用于活动、版本和内容生效时间 |
| 支持国家/区域 | 国家、区域、内容和价格配置的权威范围 |
| 负责人 | 后端、客户端、运营、支付、IM/RTC 各自验收人 |
状态只使用以下四种:
- `NOT_STARTED`:尚未配置。
- `CONFIGURED`:已写入,但未经过真实请求验证。
- `VERIFIED`:已用新 `app_code` 走通生产链路并保留证据。
- `BLOCKED`:存在会阻断上线的问题,必须写明 owner 和解决动作。
## 2. 上线硬性原则
1. 新 App 数据必须使用新 `app_code`;不能依赖代码中的 `lalu` 默认值维持运行。
2. 复制配置必须按 `resource_code``group_code``region_code``product_code` 等业务键重建引用,不能复制旧 App 的自增 ID。
3. 礼物、活动、VIP、游戏等只有主配置表不算完成所有价格、区域、资源、奖励和 worker 必须一起验证。
4. 敏感值只能写入线上环境变量或已忽略的运行时 env不能写入可提交 YAML、SQL、文档或代码。
5. 全部时间使用 UTC范围统一使用 `[start_ms, end_ms)`
6. 共享 RocketMQ topic 不代表多 App 已经就绪;必须确认消息 payload、consumer context、幂等表和 outbox claim 都保留 `app_code`
7. 只有完成“配置写入 → 服务加载 → 真实 API/IM/RTC/MQ 请求 → 日志与数据回查”才能标记 `VERIFIED`
## 3. App 注册与租户基础
Owner`user-service``gateway-service`
| 配置项 | 数据/配置入口 | 开启条件 | 验收标准 |
| --- | --- | --- | --- |
| App 注册 | `hyapp_user.apps` | `status=active``app_code` 和包名唯一 | 按 `X-App-Code` 和 package 都能解析到同一 App |
| Android/iOS 包信息 | `apps` 及客户端 flavor | 与应用商店、Firebase 完全一致 | 真实安装包登录成功 |
| Firebase 项目 | `user-service.third_party.firebase.projects.<app_code>` | 项目 ID、凭据和签名指纹都属于新 App | Google/Firebase token 在新 App 成功,不被其他 App 接受 |
| JWT/请求元数据 | gateway → gRPC `RequestMeta.app_code` | 所有需要租户隔离的 RPC 透传 | 下游日志和数据行均为新 `app_code` |
| 登录风控 | `auth_risk_configs``login_risk_country_blocks` | 明确复制或新建策略 | 允许国家可登录,拦截国家返回预期错误 |
## 4. 国家、区域与数据可见性
Owner`user-service`
| 配置项 | 主表 | 开启条件 | 验收标准 |
| --- | --- | --- | --- |
| 国家 | `countries` | 运营国家 `enabled=1` | `/api/v1/countries` 只返回新 App 允许的国家 |
| 区域 | `regions` | `status=active` | 后台和 App 能查到预期区域 |
| 国家归属 | `region_countries` | 每个启用国家有且只有一个有效业务区域 | 新注册用户的 `region_id` 正确 |
| 存量用户重建 | `user_region_rebuild_tasks` | 只在映射变更或补历史数据时执行 | task 完成且 `UserRegionChanged` outbox 已投递 |
不能根据服务器所在地、IP 或容器时区推断业务国家/区域。
## 5. 管理后台与 App 内容入口
Owner`server/admin`
| 配置项 | 主表 | 开启条件 | 验收标准 |
| --- | --- | --- | --- |
| App 通用配置 | `admin_app_configs` | 新 App 必须有自己的 `group + key` | H5 入口和功能跳转不读取其他 App 值 |
| Banner | `admin_app_banners` | 至少一个符合首页/区域/国家口径的 active 内容 | `/api/v1/app/banners` 在目标国家返回预期内容 |
| 弹窗 | `admin_app_popups` | 按产品需求设为 active并配置生效时间 | `/api/v1/app/popups` 可见且跳转正确 |
| 开屏 | `admin_app_splash_screens` | 按 platform/国家/区域生效 | `/api/v1/app/splash-screens` 返回预期素材 |
| 版本控制 | `admin_app_versions` | Android/iOS 当前生产版本均有记录 | `/api/v1/app/version` 返回正确版本、build 和强更策略 |
| Explore Tab | `admin_app_explore_tabs` | 显式启用并配置预期顺序 | App 探索入口与后台一致 |
内容 API 返回 `200``items=[]` 只证明接口可用,不代表运营配置已完成。
## 6. 腾讯云 IM、RTC 与播报
Owner`gateway-service``activity-service``room-service`;客户端长连属于腾讯云 IM。
| 配置项 | 配置/链路 | 开启条件 | 验收标准 |
| --- | --- | --- | --- |
| IM UserSig | `gateway-service.tencent_im` | SDKAppID、secret、admin identifier 与线上 IM 应用一致 | 新 App 真实用户能登录 IM |
| IM callback | gateway callback | callback 能从合法 GroupID/请求中确定 `app_code` | Fami/Lalu 回调不会互相落库 |
| RTC UserSig | `gateway-service.tencent_rtc` | SDKAppID、secret、room ID 类型与客户端一致 | 真实设备进房、上麦、发流成功 |
| RTC callback | `tencent_rtc.callback_url` | 不能把 `app_code` 写死为其他 App | 回调日志与房间租户一致 |
| 全局/区域播报群 | `activity-service.broadcast` | 为新 App 和所有 active region 建立群 | 群 ID 含新 `app_code`,客户端能加入 |
| 播报 outbox | `im_broadcast_outbox` | worker 必须按记录 `app_code` claim、发送和 mark | 新 App 消息从 pending 到 delivered无跨 App 更新 |
至少实测一条全局播报、一条区域播报和一条房间系统消息。
## 7. 房间配置
Owner`room-service`。Room Cell 仍然是房间核心状态 owner新 App 上线不得把房间状态复制到其他服务。
| 配置项 | 主表 | 开启条件 | 验收标准 |
| --- | --- | --- | --- |
| 麦位 | `room_seat_configs` | 有新 App 配置,否则明确接受代码默认值 | 创房后麦位数量与客户端一致 |
| 火箭 | `room_rocket_configs` | `enabled=1` 且奖励、油箱、频控已设定 | 完成加油、发射、奖励和 MQ 验证 |
| 宝箱 | `room_treasure_configs` | 产品需要时显式开启 | 开关与 App 入口一致 |
| 人机房 | `room_human_robot_configs` | 只有配好机器人账号和策略后才能开启 | 机器人加入/离开不污染真人 presence |
| 区域置顶 | `room_region_pins` | 按新 App 运营需求单独配置 | 房间变更区域后重新建立置顶 |
## 8. 礼物、资源与装扮
Owner`wallet-service`
礼物上线是一组不可拆分的配置:
| 配置项 | 主表 | 开启条件 |
| --- | --- | --- |
| 资源定义 | `resources` | 依赖资源必须 `status=active`,素材 URL 可访问 |
| 资源组 | `resource_groups``resource_group_items` | 所有活动/VIP/成就引用必须映射到新 App 资源 |
| 资源商店 | `resource_shop_items` | 价格、有效期和上架状态已审核 |
| 礼物类型 | `gift_type_configs` | 礼物分类已按产品口径启用 |
| 礼物配置 | `gift_configs` | `status=active`,生效时间正确 |
| 礼物价格 | `wallet_gift_prices` | 每个 active 礼物都有有效价格 |
| 礼物区域 | `gift_config_regions` | 地区限制和新 App 的 region 映射一致 |
| 钻石分成 | `gift_diamond_ratio_configs` | 送礼所在区域有唯一有效分成规则 |
验收必须使用新 App 真实账号执行:礼物列表 → 余额校验 → 扣款 → Room Cell 表现 → `RoomGiftSent` outbox → 统计/活动/通知下游。
## 9. 充值、支付与兑换
Owner`wallet-service`
| 配置项 | 主表/运行配置 | 开启条件 | 验收标准 |
| --- | --- | --- | --- |
| 充值商品 | `wallet_recharge_products` | 新 App 商品 active币种、金额、金币数正确 | App 返回商品和后台一致 |
| 商品区域 | `wallet_recharge_product_regions` | 用新 App `product_id``region_id` 重建 | 每个运营区域都有预期商品 |
| Google Play | `google_play` 及包名/服务账号 | 新 package 和验单凭据正确 | 测试购买、验单、发币、consume 均成功 |
| 第三方支付 | `third_party_payment_channels/methods` | 渠道、国家、币种和回调签名已配置 | 下单和回调的 `app_code` 一致 |
| USDT/币安等外部充值 | service YAML/env 的 per-App 映射 | 必须有新 App 账户/地址,不能回落到 Lalu | 创单、归集、确认数和入账正确 |
| 钻石兑换 | `wallet_diamond_exchange_rules` | active 规则已按运营口径配置 | 边界值和余额流水一致 |
| 红包 | `red_packet_configs` 及档位表 | 开关、金额、份数、过期 worker 均支持新 App | 发、抢、退、播报完整闭环 |
## 10. VIP
Owner`wallet-service`;行为唯一依据是 `vip_program_configs`
| 配置项 | 主表 | 开启条件 | 验收标准 |
| --- | --- | --- | --- |
| VIP 方案 | `vip_program_configs` | `program_type`、等级数、购买/升降级、发放模式明确 | API 返回的方案与后台一致 |
| VIP 等级 | `vip_levels` | 只有完成价格、时长和资源组配置的等级才能 active | `/api/v1/vip/packages` 返回预期等级 |
| VIP 权益 | `vip_level_benefits` | 每个 active 等级的权益和资源引用有效 | 购买/发卡后权益生效 |
| 日奖励 | cron `vip_daily_coin_rebate` | cron `app_codes` 含新 App金额和 UTC 日切口径已确认 | 当日 run、领取和钱包流水一致 |
| 进房/上线播报 | activity IM broadcast | 用户开关、VIP 级别和广播群完整 | 真实设备收到新 App 播报 |
等级处于 `disabled` 时,套餐接口返回空数组是正确保护行为,但该 App 不能因此被认为“VIP 已上线”。
## 11. 活动与奖励
Owner`activity-service`
| 能力 | 主表/配置 | 上线必须同时确认 |
| --- | --- | --- |
| 注册奖励 | `registration_reward_configs` | 开关、奖励类型、金额/资源组、每日限制 |
| 首充奖励 | `first_recharge_reward_configs/tiers` | 主开关和至少一个 active 档位 |
| 累充奖励 | `cumulative_recharge_reward_configs/tiers` | 主开关、周期口径和 active 档位 |
| 邀请奖励 | `invite_activity_reward_configs/tiers` | 邀请关系、有效邀请和充值事件消费就绪 |
| 七日签到 | `seven_day_checkin_configs/versions/rewards` | 开关、一个当前版本和 17 天完整奖励 |
| 每日任务 | `task_definitions/versions` | active 任务、周期、指标和奖励口径一致 |
| 成就 | `achievement_definitions/conditions` | active 成就引用的徽章资源属于新 App |
| 成长等级 | `growth_level_tracks/rules/tiers` | 财富/魅力/游戏轨道及展示资源完整 |
| CP 周榜 | `cp_weekly_rank_configs/rewards` | 开关、排名数、关系类型和奖励齐全 |
| 周星 | `weekly_star_cycles/gifts/rewards` | 当前 UTC 时间命中 active 周期,礼物和 Top 奖励完整 |
| 房间流水奖励 | `room_turnover_reward_configs/tiers` | 开关、周期、档位和钱包发放链路完整 |
| 转盘 | `wheel_rule_versions/prize_tiers` | 当前规则版本 active奖品和 RTP 口径已审核 |
主开关、奖励档位和奖励资源缺任何一项,该活动都不得标记上线。
## 12. 幸运礼物
Owner`lucky-gift-service``activity-service` 中的旧副本不能作为正式运行依据。
| 配置项 | 主表/链路 | 验收标准 |
| --- | --- | --- |
| 规则版本 | `lucky_gift_rule_versions` | 每个运营奖池有 active 规则 |
| 阶段档位 | `lucky_gift_stage_tiers` | 规则对应的 stage/tier 完整且 enabled |
| 独立奖池 | `lucky_pools` | 按 `app_code + pool_id` 初始化;只复制规则时不能复制对照 App 的余额、累计入池/出池或用户运行态 |
| 礼物映射 | 礼物配置与 pool 映射 | 只有新 App active 礼物才能进入抽奖 |
| owner outbox | `lucky_gift_outbox` | 抽奖事实持久化并发布到独立 MQ topic |
| 区域飘屏 | activity 独立 consumer group | 不得复用其他 consumer group且保留 `app_code` |
真实验收要覆盖普通奖、大奖、幂等重试、钱包入账、outbox 投递和区域飘屏。
## 13. 游戏
Owner`game-service`
| 配置项 | 主表 | 开启条件 |
| --- | --- | --- |
| 游戏平台 | `game_platforms` | 平台 active启动和回调参数完整 |
| 游戏目录 | `game_catalog` | 新 App 明确选择要开放的游戏,不强制与对照 App 数量相同 |
| 展示规则 | `game_display_rules` | 每个 active 游戏有可见规则 |
| 自研游戏配置 | `game_self_game_configs`、风控/新手策略 | 游戏开关、RTP、风控参数已审核 |
| 机器人 | `game_self_game_robots` | 自研对战需要补位时,必须有新 App 真实机器人用户 |
| 档位奖池 | `game_self_game_stake_pools` | 每个可玩档位有 active 奖池和初始水位 |
| 回调归属 | gateway JWT → `RequestMeta.app_code` | 厂商回调不能默认记入 Lalu |
第三方游戏少于对照 App 不一定是错误;必须由产品/运营给出明确游戏清单。但自研游戏已对用户开放时,机器人和奖池为空属于上线阻断。
## 14. cron、MQ、outbox 和通知
| 配置项 | Owner | 开启条件 | 验收标准 |
| --- | --- | --- | --- |
| cron 任务 | `cron-service` | 所有应在新 App 运行的 task `app_codes` 含新 App | `cron_task_runs``task_name + app_code` 生成成功记录 |
| owner outbox publisher | room/wallet/user/game/lucky-gift | 只有 owner 服务扫描自己的 outbox | 新 App pending 可持续转 delivered无长时间积压 |
| RocketMQ consumer group | 每个下游服务 | 不得共享 owner 的 outbox 状态或其他业务消费组 | group 存在、无异常 lag、重投幂等 |
| 统计消费 | `statistics-service` | wallet/room/user/game 事实消费器已启用 | 新 App 聚合表产生 UTC 日数据 |
| 私有通知 | `notice-service` | RocketMQ 消费器为主链路 | 钱包/CP/房间通知均使用新 App 租户 |
| 系统/活动 inbox | `activity-service` message owner | 后台通知通过 owner API/fanout job 创建 | 不直写 inbox/fanout 表,能从 App 查看并已读 |
`notice-service` 的旧轮询 worker 关闭、RocketMQ consumer 开启是正常迁移状态,不能只看一个 `enabled=false` 就判定新 App 未开通。
## 15. 统计与数据大屏
Owner`statistics-service`;后台查询入口在 `server/admin`
1. 确认 `WalletRechargeRecorded``GameOrderSettled``RoomGiftSent``RoomUserJoined``UserRegistered` 事件都带新 `app_code`
2. 确认消费幂等表已记录新 App 事件。
3. 确认充值、游戏、礼物、活跃、注册和留存聚合表在 UTC 边界正确生成。
4. 确认 admin 大屏选择新 App 时只查聚合表,不回查 owner 明细库。
5. 使用可控测试账号产生一组注册、进房、充值、游戏、送礼事实,核对次日聚合。
## 16. 客户端与发布
| 项目 | 必须验证 |
| --- | --- |
| flavor/品牌 | App 名称、图标、包名、bundle ID、深链、主题和隐私页均为新 App |
| API host | 生产包指向生产 API不得混入测试 host |
| Firebase | flavor 使用自己的 `google-services.json`/iOS 配置 |
| IM/RTC | SDKAppID、UserSig 及回调链路完整 |
| 支付 | package/product ID/签名与商店后台一致 |
| 版本 API | 首个生产 build 已写入 `admin_app_versions` |
| 埋点 | 心跳、页面、社交漏斗和游戏事件使用新 `app_code` |
必须在真实 Android/iOS 设备上执行最终验收,不能只用 curl 代替 IM、RTC、支付和客户端跳转验证。
## 17. 正式上线验收清单
### 17.1 配置一致性
- [ ] 新 App 在所有 owner 库的 `app_code` 一致。
- [ ] 所有跨表引用已按业务键重建,不存在指向对照 App ID 的记录。
- [ ] 全部敏感配置只存在运行时 env/密钥管理系统。
- [ ] 两台或多台副本的 YAML、镜像 tag 和配置 hash 一致。
### 17.2 业务冒烟
- [ ] 注册、登录、刷新 token、用户资料、国家/区域归属正常。
- [ ] IM 登录、单聊、房间群、全局/区域播报正常。
- [ ] RTC 进房、上麦、发流、回调和离开正常。
- [ ] 创房、进房、麦位、踢人、锁房、房间恢复正常。
- [ ] 礼物列表、送礼扣款、房间表现、收礼统计正常。
- [ ] 充值商品、真实测试购买、回调验单、钱包入账正常。
- [ ] VIP/活动/签到/任务/成就/周星等已宣布上线的能力全部真实验证。
- [ ] 第三方游戏启动、回调归属和余额一致;自研游戏机器人/奖池可用。
### 17.3 异步与数据
- [ ] owner outbox 无新 App 长时间 pending/failed 积压。
- [ ] RocketMQ consumer group 存在且无异常 lag。
- [ ] cron 所需 task 已产生新 App 成功运行记录。
- [ ] 通知、inbox、播报和 push 不串 App。
- [ ] 统计聚合表有新 App 真实事实UTC 边界正确。
### 17.4 发布结论
只有以下条件全部满足才能标记“可正式运营”:
1. P0 阻断项为 0。
2. 所有宣布上线的功能已完成真实设备和生产链路验证。
3. 运营明确接受未上线的可选功能,且 App 客户端不展示无配置入口。
4. 发布镜像、数据迁移、MQ、YAML/env、客户端版本和回滚方案均有记录。
## 18. Fami 生产差异快照2026-07-13
本节是对生产 YAML、生产 MySQL、线上镜像和公网 API 的只读快照,用于保留 Fami 从试运营转正式运营前的基线。后续修复后应追加新日期的验收结果,不覆盖本快照。
### 18.1 P0 阻断项
| 项目 | Fami 当前状态 | Lalu 对照 | 必须动作 |
| --- | --- | --- | --- |
| 礼物 | `gift_configs=0``wallet_gift_prices=0` | 95 个 active 礼物、103 个 active 价格 | 按业务键创建 Fami 礼物和价格,验证送礼闭环 |
| 资源 | 535 条全部 `deleted`,有效资源/资源组/商城均为 0 | 501 active 资源、56 active 资源组 | 重建 Fami 资源体系和全部引用 |
| VIP | 方案 active但 VIP1VIP10 全部 disabled | VIP1VIP8 active | 只启用已完成价格、权益和资源配置的 VIP1VIP9 |
| RTC callback | 两台 gateway 回调 URL 都写死 `app_code=lalu` | Lalu 符合当前值 | 调整回调归属,分别验证 Fami/Lalu RTC 事件 |
| IM 播报 worker | 线上 `activity-service:20260712-main-7d011ce` 仍以默认 Lalu context claim/reconcile | Lalu 已投递 71,564 条 | 发布多 App claim/建群逻辑,实测 Fami 全局和区域播报 |
| 幸运礼物 owner | `lucky-gift-service` 规则版本和档位均为 0 | 15 个规则版本、255 个档位 | 在 owner 库配置,不使用 activity 旧副本代替 |
| 首充奖励 | 开关 03 个档位全部 disabled | 开关 13 个 active | 确认奖励后启用并走真实充值事实 |
| 累充奖励 | 开关 08 个档位全部 disabled | 开关 18 个 active | 确认周期和奖励后启用 |
| 七日签到 | 开关 0无生效版本无奖励 | 开关 1有生效版本和 7 天奖励 | 创建 Fami 版本和 7 天奖励后启用 |
| CP 周榜 | 开关 0无奖励 | 开关 13 个奖励 | 确认关系类型和奖励后启用 |
| 周星 | 当前 UTC 时间无 active 周期 | Lalu 有 1 个 active 周期 | 创建 Fami 周期、指定礼物和 Top 奖励 |
| 自研游戏运行数据 | 机器人 0档位奖池 0 | dice 机器人 80dice/rock 奖池 8 | 如 Fami 对用户开放自研游戏,先初始化机器人和奖池 |
### 18.2 运营内容缺口
| 项目 | Fami 当前状态 | Lalu 对照 |
| --- | --- | --- |
| 首页 Banner | 公网 API `items=0` | 相同请求返回 10 条 |
| 弹窗 | 公网 API `items=0` | 1 条 |
| 开屏 | 公网 API `items=0` | 1 条 |
| 版本控制 | `version=""``build_number=0` | Android `1.0.9/109` |
| H5 入口 | 只有 1 条 app config缺 23 个 `h5-links` | 24 条 app config |
| 第三方游戏目录 | 34 个 active | 37 个 activeFami 少 `Rocket``TeenPattiPro``LavaSlot` |
| 成长/成就展示资源 | 6 个头像框、25 个徽章档位和 32 个成就引用资源,但 Fami 无 active 资源 | Lalu 有 active 资源 |
第三方游戏的三个差异需产品确认,不能在没有授权和产品结论时自动复制。
### 18.3 条件项
- 币安账户映射当前只有 Lalu。
- TRC20 USDT 收款地址当前只有 Lalu。
- Fami 已有 16 个 active 充值商品和对齐的第三方支付方式;上述两项只在产品要求 Fami 开放对应渠道时才是阻断。
### 18.4 已就绪项
- Fami App 注册状态为 active。
- Firebase 项目已配置。
- 16 个 cron 任务均已启用且 `app_codes` 包含 `lalu/fami/huwaa`
- Fami 和 Lalu 均有 141 个 enabled 国家,启用国家无缺口。
- Firebase、登录风控、CP 关系配置已建立。
- 红包、房间火箭、邀请奖励、注册金币奖励、房间流水奖励已开启。
- 财富/魅力/游戏成长等级的规则数量已与 Lalu 对齐,但展示资源仍受资源体系缺失阻断。
- room、wallet、user、game、statistics 的共享 MQ/outbox 主开关已开启。
### 18.5 Fami 建议收敛顺序
1. 重建 Fami 礼物、价格、资源、资源组和商城,修复成长/成就展示引用。
2. 修复 RTC callback 的 App 归属,发布 activity 多 App 播报 claim/建群逻辑。
3. 在 `lucky-gift-service` owner 库配置 Fami 规则和档位,完成独立 MQ consumer 验证。
4. 完成 VIP 价格/权益/资源后启用 VIP1VIP9。
5. 按产品范围启用首充、累充、七日签到、CP 周榜和周星。
6. 补齐 Banner、弹窗、开屏、版本和 H5 入口。
7. 确认 Fami 游戏清单;如开放自研游戏,初始化机器人和档位奖池。
8. 执行第 17 节的真实设备、业务、MQ、数据与统计验收确认 P0 为 0 后再标记可正式运营。
### 18.6 首批修复记录2026-07-13
本次只收敛 RTC/IM 业务租户隔离和幸运礼物初始化,不代表第 18.1 节其他 P0 已解除。
| 项目 | 修复结果 | 验证证据 | 状态 |
| --- | --- | --- | --- |
| RTC callback | 共享腾讯 RTC 应用不再从 URL 固定读取 `app_code=lalu`gateway 验签后按 `room_id` 调 room-service 解析真实房间租户,再提交对应 Room Cell | 两台 gateway 的 callback URL 均已改为无查询参数地址;`rooms.room_id` 无重复并已增加唯一索引gateway/room 双节点运行镜像 `20260713-fami-app-scope-1` 且 healthy | `CONFIGURED`;仍需 Fami/Lalu 各一次真实设备上麦、退流回调后升为 `VERIFIED` |
| IM 播报 worker | 同一腾讯 IM SDKAppID 下,群 ID、outbox claim、发送、mark 和 reconcile 均按记录 `app_code` 隔离;`broadcast.app_codes` 显式配置 `lalu/fami/huwaa` | 腾讯 IM 云端已存在 `hy_fami_bc_g_v2` 及区域 2430 的 7 个 `hy_fami_bc_r_v2_*`activity 双节点运行镜像 `20260713-fami-app-scope-1` 且无 reconcile 错误 | `VERIFIED`(建群与运行态);仍需客户端真实收一条区域/全局播报完成产品验收 |
| 幸运礼物规则 | 从 Lalu 复制所有规则版本和阶段档位到 Fami只变更租户键 | Fami `lucky` 为 6 个规则版本/106 个档位;`super_lucky` 为 9 个规则版本/149 个档位enabled 数量与 Lalu 一致 | `VERIFIED`(配置数据) |
| 幸运礼物奖池 | 未复制 Lalu 奖池状态Fami 的 `lucky``super_lucky` 各自独立初始化 | 两个池均为 `balance=100000``reserve_floor=21000``total_in=0``total_out=0`Fami RTP 窗口、用户状态、抽奖记录和 outbox 均为 0 | `VERIFIED`(初始化数据);仍需真实普通奖/大奖/钱包/outbox 冒烟 |
发布前已执行 `make proto``make test``docker compose config`;发布后 6 个实例均 healthy、restart count 为 0相关 CLB 后端权重均恢复为 100。

143
pkg/luckygiftmq/messages.go Normal file
View File

@ -0,0 +1,143 @@
// Package luckygiftmq defines the RocketMQ wire contract owned by lucky-gift-service.
package luckygiftmq
import (
"encoding/json"
"errors"
"fmt"
"strings"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
)
const (
MessageTypeLuckyGiftOutboxEvent = "lucky_gift_outbox_event"
EventTypeLuckyGiftDrawn = "LuckyGiftDrawn"
TagLuckyGiftDrawn = EventTypeLuckyGiftDrawn
// LuckyGiftDisplayMinMultiplierPPM keeps room and regional top-banner policy on the same exact 10x boundary.
// The owner still publishes every granted positive reward; consumers apply this presentation threshold independently.
LuckyGiftDisplayMinMultiplierPPM int64 = 10_000_000
)
// LuckyGiftOutboxMessage is the inspectable JSON wrapper around one protobuf owner envelope.
// Metadata is duplicated outside envelope only for RocketMQ diagnostics and routing; the protobuf envelope remains authoritative.
type LuckyGiftOutboxMessage struct {
MessageType string `json:"message_type"`
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventType string `json:"event_type"`
DrawID string `json:"draw_id"`
OccurredAtMS int64 `json:"occurred_at_ms"`
Envelope []byte `json:"envelope"`
}
// EventTypeTag validates an owner-controlled event type before it becomes a broker-side typed Tag.
// Only registered fact names are accepted so malformed outbox data cannot publish to an unconsumed routing key.
func EventTypeTag(eventType string) (string, error) {
switch tag := strings.TrimSpace(eventType); tag {
case EventTypeLuckyGiftDrawn:
return tag, nil
case "":
return "", errors.New("lucky gift event_type tag is required")
default:
return "", fmt.Errorf("unsupported lucky gift event_type tag %q", eventType)
}
}
// EncodeLuckyGiftOutboxMessage serializes the protobuf envelope without converting int64 business identifiers through JSON numbers.
func EncodeLuckyGiftOutboxMessage(envelope *luckygifteventsv1.EventEnvelope) ([]byte, error) {
if err := validateLuckyGiftEnvelope(envelope); err != nil {
return nil, err
}
envelopeBytes, err := proto.Marshal(envelope)
if err != nil {
return nil, err
}
return json.Marshal(LuckyGiftOutboxMessage{
MessageType: MessageTypeLuckyGiftOutboxEvent,
AppCode: envelope.GetAppCode(),
EventID: envelope.GetEventId(),
EventType: envelope.GetEventType(),
DrawID: envelope.GetDrawId(),
OccurredAtMS: envelope.GetOccurredAtMs(),
Envelope: envelopeBytes,
})
}
// DecodeLuckyGiftOutboxMessage restores and validates the owner envelope.
// Wrapper/envelope equality is checked explicitly so operators never diagnose one set of visible metadata while consumers process another.
func DecodeLuckyGiftOutboxMessage(body []byte) (*luckygifteventsv1.EventEnvelope, LuckyGiftOutboxMessage, error) {
var message LuckyGiftOutboxMessage
if err := json.Unmarshal(body, &message); err != nil {
return nil, LuckyGiftOutboxMessage{}, err
}
if message.MessageType != MessageTypeLuckyGiftOutboxEvent {
return nil, LuckyGiftOutboxMessage{}, errors.New("unexpected lucky gift outbox message_type")
}
if len(message.Envelope) == 0 {
return nil, LuckyGiftOutboxMessage{}, errors.New("lucky gift outbox envelope is required")
}
var envelope luckygifteventsv1.EventEnvelope
if err := proto.Unmarshal(message.Envelope, &envelope); err != nil {
return nil, LuckyGiftOutboxMessage{}, err
}
if err := validateLuckyGiftEnvelope(&envelope); err != nil {
return nil, LuckyGiftOutboxMessage{}, err
}
if message.AppCode != envelope.GetAppCode() ||
message.EventID != envelope.GetEventId() ||
message.EventType != envelope.GetEventType() ||
message.DrawID != envelope.GetDrawId() ||
message.OccurredAtMS != envelope.GetOccurredAtMs() {
return nil, LuckyGiftOutboxMessage{}, errors.New("lucky gift outbox wrapper does not match envelope")
}
return &envelope, message, nil
}
func validateLuckyGiftEnvelope(envelope *luckygifteventsv1.EventEnvelope) error {
if envelope == nil {
return errors.New("lucky gift outbox envelope is required")
}
if strings.TrimSpace(envelope.GetAppCode()) == "" ||
strings.TrimSpace(envelope.GetEventId()) == "" ||
strings.TrimSpace(envelope.GetDrawId()) == "" ||
envelope.GetOccurredAtMs() <= 0 ||
len(envelope.GetBody()) == 0 {
return errors.New("lucky gift outbox envelope is incomplete")
}
// draw_id 是一次中奖事实的天然幂等键;固定 event_id 规则后,所有消费者都能用同一个键收敛重投,
// 也避免某个 producer 临时生成随机 ID 导致 room/activity 各自重复派生外部飘屏。
if envelope.GetEventId() != "lucky_gift_drawn:"+envelope.GetDrawId() {
return errors.New("lucky gift outbox event_id does not match draw_id")
}
if _, err := EventTypeTag(envelope.GetEventType()); err != nil {
return err
}
// The current topic carries only granted winning facts. Validate the protobuf body here so poison data fails before broker publish
// and is retried from the owner outbox instead of being acknowledged and rejected independently by every consumer.
var drawn luckygifteventsv1.LuckyGiftDrawn
if err := proto.Unmarshal(envelope.GetBody(), &drawn); err != nil {
return fmt.Errorf("decode lucky gift drawn body: %w", err)
}
if strings.TrimSpace(drawn.GetDrawId()) == "" ||
drawn.GetDrawId() != envelope.GetDrawId() ||
strings.TrimSpace(drawn.GetCommandId()) == "" ||
strings.TrimSpace(drawn.GetRoomId()) == "" ||
strings.TrimSpace(drawn.GetGiftId()) == "" ||
drawn.GetGiftCount() <= 0 ||
drawn.GetSenderUserId() <= 0 ||
drawn.GetTargetUserId() <= 0 ||
drawn.GetCoinSpent() <= 0 ||
drawn.GetMultiplierPpm() <= 0 ||
drawn.GetEffectiveRewardCoins() <= 0 ||
strings.ToLower(strings.TrimSpace(drawn.GetRewardStatus())) != "granted" ||
drawn.GetDrawCreatedAtMs() <= 0 ||
drawn.GetDrawCreatedAtMs() != envelope.GetOccurredAtMs() ||
drawn.GetRewardGrantedAtMs() <= 0 {
return errors.New("lucky gift drawn body is incomplete")
}
return nil
}

View File

@ -0,0 +1,123 @@
package luckygiftmq
import (
"encoding/json"
"testing"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
)
func TestLuckyGiftOutboxMessageRoundTrip(t *testing.T) {
envelope := validLuckyGiftEnvelope(t)
body, err := EncodeLuckyGiftOutboxMessage(envelope)
if err != nil {
t.Fatalf("EncodeLuckyGiftOutboxMessage failed: %v", err)
}
decoded, message, err := DecodeLuckyGiftOutboxMessage(body)
if err != nil {
t.Fatalf("DecodeLuckyGiftOutboxMessage failed: %v", err)
}
if !proto.Equal(decoded, envelope) {
t.Fatalf("decoded envelope mismatch: got=%v want=%v", decoded, envelope)
}
if message.MessageType != MessageTypeLuckyGiftOutboxEvent || message.EventType != EventTypeLuckyGiftDrawn || message.DrawID != "draw-1" {
t.Fatalf("decoded wrapper metadata mismatch: %+v", message)
}
}
func TestLuckyGiftOutboxMessageRejectsWrapperEnvelopeDrift(t *testing.T) {
envelope := validLuckyGiftEnvelope(t)
body, err := EncodeLuckyGiftOutboxMessage(envelope)
if err != nil {
t.Fatalf("EncodeLuckyGiftOutboxMessage failed: %v", err)
}
var message LuckyGiftOutboxMessage
if err := json.Unmarshal(body, &message); err != nil {
t.Fatalf("decode wrapper: %v", err)
}
message.DrawID = "draw-other"
tampered, err := json.Marshal(message)
if err != nil {
t.Fatalf("encode tampered wrapper: %v", err)
}
if _, _, err := DecodeLuckyGiftOutboxMessage(tampered); err == nil {
t.Fatal("wrapper/envelope drift must be rejected")
}
}
func TestLuckyGiftOutboxMessageRejectsUnsettledFact(t *testing.T) {
envelope := validLuckyGiftEnvelope(t)
var drawn luckygifteventsv1.LuckyGiftDrawn
if err := proto.Unmarshal(envelope.GetBody(), &drawn); err != nil {
t.Fatalf("decode lucky body: %v", err)
}
drawn.RewardStatus = "pending"
body, err := proto.Marshal(&drawn)
if err != nil {
t.Fatalf("encode pending lucky body: %v", err)
}
envelope.Body = body
if _, err := EncodeLuckyGiftOutboxMessage(envelope); err == nil {
t.Fatal("pending reward must not be published as LuckyGiftDrawn")
}
}
func TestLuckyGiftEventTypeTagAndDisplayBoundary(t *testing.T) {
tag, err := EventTypeTag(EventTypeLuckyGiftDrawn)
if err != nil || tag != TagLuckyGiftDrawn {
t.Fatalf("EventTypeTag result: tag=%q err=%v", tag, err)
}
if _, err := EventTypeTag("LuckyGiftUnknown"); err == nil {
t.Fatal("unsupported lucky gift event type must be rejected")
}
if LuckyGiftDisplayMinMultiplierPPM != 10_000_000 {
t.Fatalf("display threshold=%d, want exact 10x ppm", LuckyGiftDisplayMinMultiplierPPM)
}
}
func validLuckyGiftEnvelope(t *testing.T) *luckygifteventsv1.EventEnvelope {
t.Helper()
const drawCreatedAtMS int64 = 1_783_940_398_274
drawn := &luckygifteventsv1.LuckyGiftDrawn{
DrawId: "draw-1",
CommandId: "command-1",
RoomId: "room-1",
PoolId: "default",
GiftId: "43",
GiftCount: 1,
SenderUserId: 123456,
TargetUserId: 654321,
SenderName: "Lucky User",
SenderAvatar: "https://cdn.example/avatar.png",
SenderDisplayUserId: "123456",
SenderPrettyDisplayUserId: "888888",
VisibleRegionId: 1,
CountryId: 86,
CoinSpent: 100,
RuleVersion: 7,
ExperiencePool: "normal",
SelectedTierId: "normal_50x",
MultiplierPpm: 50_000_000,
BaseRewardCoins: 5_000,
EffectiveRewardCoins: 5_000,
HighMultiplier: true,
RewardStatus: "granted",
WalletTransactionId: "wallet-tx-1",
DrawCreatedAtMs: drawCreatedAtMS,
RewardGrantedAtMs: drawCreatedAtMS + 20,
}
body, err := proto.Marshal(drawn)
if err != nil {
t.Fatalf("marshal lucky body: %v", err)
}
return &luckygifteventsv1.EventEnvelope{
EventId: "lucky_gift_drawn:draw-1",
EventType: EventTypeLuckyGiftDrawn,
AppCode: "lalu",
DrawId: "draw-1",
OccurredAtMs: drawCreatedAtMS,
Body: body,
}
}

View File

@ -44,6 +44,7 @@ TOPICS=(
"hyapp_wallet_outbox" "hyapp_wallet_outbox"
"hyapp_wallet_realtime_outbox" "hyapp_wallet_realtime_outbox"
"hyapp_room_outbox" "hyapp_room_outbox"
"hyapp_lucky_gift_outbox"
"hyapp_room_rocket_launch" "hyapp_room_rocket_launch"
"hyapp_user_outbox" "hyapp_user_outbox"
"hyapp_message_action_outbox" "hyapp_message_action_outbox"
@ -102,8 +103,8 @@ fi
# Topic creation is idempotent, but each updateTopic call shells into the # Topic creation is idempotent, but each updateTopic call shells into the
# broker container and blocks local startup. Query once and only create missing # broker container and blocks local startup. Query once and only create missing
# topics; a warm `make run` then pays one cheap route read instead of seven # topics; a warm `make run` then pays one cheap route read instead of repeating
# management writes. # management writes for the full fixed topic set.
topic_list="$(docker compose exec -T rocketmq-broker "${MQADMIN}" topicList -n "${NAMESRV}" 2>/dev/null || true)" topic_list="$(docker compose exec -T rocketmq-broker "${MQADMIN}" topicList -n "${NAMESRV}" 2>/dev/null || true)"
missing_topics=() missing_topics=()
for topic in "${TOPICS[@]}"; do for topic in "${TOPICS[@]}"; do

View File

@ -50,6 +50,7 @@ tencent_im:
request_timeout: "5s" request_timeout: "5s"
broadcast: broadcast:
enabled: true enabled: true
app_codes: ["lalu", "fami", "huwaa"]
super_gift_min_value: 100000 super_gift_min_value: 100000
worker_poll_interval: "1s" worker_poll_interval: "1s"
worker_batch_size: 100 worker_batch_size: 100
@ -70,6 +71,11 @@ rocketmq:
topic: "hyapp_room_outbox" topic: "hyapp_room_outbox"
consumer_group: "hyapp-activity-room-outbox" consumer_group: "hyapp-activity-room-outbox"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
lucky_gift_outbox:
enabled: true
topic: "hyapp_lucky_gift_outbox"
consumer_group: "hyapp-activity-lucky-gift-outbox"
consumer_max_reconsume_times: 16
wallet_outbox: wallet_outbox:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"

View File

@ -50,6 +50,8 @@ tencent_im:
request_timeout: "5s" request_timeout: "5s"
broadcast: broadcast:
enabled: true enabled: true
# 共享腾讯 IM SDKAppID 时必须显式列出全部业务租户,确保空数据 App 也能预建播报群。
app_codes: ["lalu", "fami", "huwaa"]
super_gift_min_value: 100000 super_gift_min_value: 100000
worker_poll_interval: "1s" worker_poll_interval: "1s"
worker_batch_size: 100 worker_batch_size: 100
@ -70,6 +72,11 @@ rocketmq:
topic: "hyapp_room_outbox" topic: "hyapp_room_outbox"
consumer_group: "hyapp-activity-room-outbox" consumer_group: "hyapp-activity-room-outbox"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
lucky_gift_outbox:
enabled: true
topic: "hyapp_lucky_gift_outbox"
consumer_group: "hyapp-activity-lucky-gift-outbox"
consumer_max_reconsume_times: 16
wallet_outbox: wallet_outbox:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"

View File

@ -50,6 +50,8 @@ tencent_im:
request_timeout: "5s" request_timeout: "5s"
broadcast: broadcast:
enabled: true enabled: true
# 同一套腾讯 IM 云应用承载多个业务 App群 ID 和 outbox 仍按 app_code 隔离。
app_codes: ["lalu", "fami", "huwaa"]
super_gift_min_value: 100000 super_gift_min_value: 100000
worker_poll_interval: "1s" worker_poll_interval: "1s"
worker_batch_size: 100 worker_batch_size: 100
@ -70,6 +72,11 @@ rocketmq:
topic: "hyapp_room_outbox" topic: "hyapp_room_outbox"
consumer_group: "hyapp-activity-room-outbox" consumer_group: "hyapp-activity-room-outbox"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
lucky_gift_outbox:
enabled: true
topic: "hyapp_lucky_gift_outbox"
consumer_group: "hyapp-activity-lucky-gift-outbox"
consumer_max_reconsume_times: 16
wallet_outbox: wallet_outbox:
enabled: true enabled: true
topic: "hyapp_wallet_outbox" topic: "hyapp_wallet_outbox"

View File

@ -8,6 +8,7 @@ import (
"time" "time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/luckygiftmq"
"hyapp/pkg/rocketmqx" "hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq" "hyapp/pkg/roommq"
servicemq "hyapp/pkg/servicekit/mq" servicemq "hyapp/pkg/servicekit/mq"
@ -27,6 +28,14 @@ func buildMQConsumers(cfg config.Config, services *serviceBundle) ([]*rocketmqx.
} }
mqConsumers = append(mqConsumers, consumer) mqConsumers = append(mqConsumers, consumer)
} }
if cfg.RocketMQ.LuckyGiftOutbox.Enabled {
consumer, err := newLuckyGiftOutboxConsumer(cfg, services)
if err != nil {
shutdownConsumers(mqConsumers)
return nil, err
}
mqConsumers = append(mqConsumers, consumer)
}
if cfg.RocketMQ.UserOutbox.Enabled && !services.broadcastPublisherAvailable { if cfg.RocketMQ.UserOutbox.Enabled && !services.broadcastPublisherAvailable {
if !cfg.TaskEventWorker.Enabled { if !cfg.TaskEventWorker.Enabled {
shutdownConsumers(mqConsumers) shutdownConsumers(mqConsumers)
@ -180,6 +189,30 @@ func activityRoomEventTypes() []string {
} }
} }
func newLuckyGiftOutboxConsumer(cfg config.Config, services *serviceBundle) (*rocketmqx.Consumer, error) {
consumer, err := rocketmqx.NewConsumer(luckyGiftOutboxConsumerConfig(cfg.RocketMQ))
if err != nil {
return nil, err
}
// 新 owner topic 从首版就使用 exact typed Tag不订阅宽泛 legacy Tag避免后续审计事实进入区域 IM 投影。
if err := consumer.Subscribe(cfg.RocketMQ.LuckyGiftOutbox.Topic, luckygiftmq.TagLuckyGiftDrawn, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
envelope, _, err := luckygiftmq.DecodeLuckyGiftOutboxMessage(message.Body)
if err != nil {
return err
}
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
if _, err := services.broadcast.HandleLuckyGiftEvent(eventCtx, envelope); err != nil {
// 返回错误给 MQ 保留至少一次语义im_broadcast_outbox 的 event_id 主键负责重投幂等。
return fmt.Errorf("lucky gift outbox broadcast projection: %w", err)
}
return nil
}); err != nil {
_ = consumer.Shutdown()
return nil, err
}
return consumer, nil
}
func newUserOutboxConsumer(cfg config.Config, broadcastSvc *broadcastservice.Service) (*rocketmqx.Consumer, error) { func newUserOutboxConsumer(cfg config.Config, broadcastSvc *broadcastservice.Service) (*rocketmqx.Consumer, error) {
consumer, err := rocketmqx.NewConsumer(userOutboxConsumerConfig(cfg.RocketMQ)) consumer, err := rocketmqx.NewConsumer(userOutboxConsumerConfig(cfg.RocketMQ))
if err != nil { if err != nil {
@ -414,6 +447,12 @@ func roomOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfi
return consumerConfig return consumerConfig
} }
func luckyGiftOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
// 顶部飘屏是实时副作用,新建或误改 group 时不能从 topic 最早位点重放历史大奖;
// 首次上线必须先部署 consumer再开启 lucky-gift producer确保 latest offset 不丢上线窗口事实。
return rocketMQConsumerConfig(cfg, cfg.LuckyGiftOutbox.ConsumerGroup, cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes)
}
func userOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { func userOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
return rocketMQConsumerConfig(cfg, cfg.UserOutbox.ConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes) return rocketMQConsumerConfig(cfg, cfg.UserOutbox.ConsumerGroup, cfg.UserOutbox.ConsumerMaxReconsumeTimes)
} }

View File

@ -21,6 +21,22 @@ func TestRoomOutboxConsumerConfigSerializesCompositeProjection(t *testing.T) {
} }
} }
func TestLuckyGiftOutboxConsumerUsesIndependentRealtimeGroup(t *testing.T) {
cfg := config.Default().RocketMQ
cfg.LuckyGiftOutbox.ConsumerGroup = "activity-lucky-gift-test"
consumerConfig := luckyGiftOutboxConsumerConfig(cfg)
if consumerConfig.GroupName != cfg.LuckyGiftOutbox.ConsumerGroup {
t.Fatalf("lucky gift outbox group = %q, want %q", consumerConfig.GroupName, cfg.LuckyGiftOutbox.ConsumerGroup)
}
if consumerConfig.GroupName == cfg.RoomOutbox.ConsumerGroup || consumerConfig.GroupName == cfg.WalletOutbox.RedPacketBroadcastConsumerGroup {
t.Fatalf("lucky gift outbox must not share another projection offset: %+v", consumerConfig)
}
if consumerConfig.ConsumeFromFirst {
t.Fatal("lucky gift top-banner consumer must not replay historical wins for a new group")
}
}
func TestActivityRoomSelectorKeepsLegacyAndOnlyRelevantTypedEvents(t *testing.T) { func TestActivityRoomSelectorKeepsLegacyAndOnlyRelevantTypedEvents(t *testing.T) {
expression, err := roommq.LegacyCompatibleTagExpression(activityRoomEventTypes()...) expression, err := roommq.LegacyCompatibleTagExpression(activityRoomEventTypes()...)
if err != nil { if err != nil {

View File

@ -83,6 +83,7 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
broadcastSvc := broadcastservice.New(broadcastservice.Config{ broadcastSvc := broadcastservice.New(broadcastservice.Config{
NodeID: cfg.NodeID, NodeID: cfg.NodeID,
AppCodes: cfg.Broadcast.AppCodes,
SuperGiftMinValue: cfg.Broadcast.SuperGiftMinValue, SuperGiftMinValue: cfg.Broadcast.SuperGiftMinValue,
WorkerBatchSize: cfg.Broadcast.WorkerBatchSize, WorkerBatchSize: cfg.Broadcast.WorkerBatchSize,
WorkerLockTTL: cfg.Broadcast.WorkerLockTTL, WorkerLockTTL: cfg.Broadcast.WorkerLockTTL,

View File

@ -36,11 +36,12 @@ func (s *GRPCUserProfileSource) GetSenderProfile(ctx context.Context, userID int
return broadcastservice.SenderProfile{UserID: userID}, nil return broadcastservice.SenderProfile{UserID: userID}, nil
} }
return broadcastservice.SenderProfile{ return broadcastservice.SenderProfile{
UserID: user.GetUserId(), UserID: user.GetUserId(),
Account: user.GetDisplayUserId(), Account: user.GetDisplayUserId(),
Nickname: user.GetUsername(), PrettyDisplayUserID: user.GetPrettyDisplayUserId(),
Avatar: user.GetAvatar(), Nickname: user.GetUsername(),
AvatarFrameURL: "", Avatar: user.GetAvatar(),
RegionCode: user.GetRegionCode(), AvatarFrameURL: "",
RegionCode: user.GetRegionCode(),
}, nil }, nil
} }

View File

@ -6,6 +6,7 @@ import (
"strings" "strings"
"time" "time"
"hyapp/pkg/appcode"
"hyapp/pkg/configx" "hyapp/pkg/configx"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
@ -151,7 +152,10 @@ func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig {
// BroadcastConfig stores activity-service broadcast worker policy. // BroadcastConfig stores activity-service broadcast worker policy.
type BroadcastConfig struct { type BroadcastConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
// AppCodes 是共享腾讯 IM 应用内需要主动维护播报群的业务租户清单。
// outbox 发现只能覆盖已经产生过消息的 App新 App 上线前必须显式列入,才能提前建好全局/区域群。
AppCodes []string `yaml:"app_codes"`
SuperGiftMinValue int64 `yaml:"super_gift_min_value"` SuperGiftMinValue int64 `yaml:"super_gift_min_value"`
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"` WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
WorkerBatchSize int `yaml:"worker_batch_size"` WorkerBatchSize int `yaml:"worker_batch_size"`
@ -174,6 +178,7 @@ type RocketMQConfig struct {
Namespace string `yaml:"namespace"` Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"` VIPChannel bool `yaml:"vip_channel"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"` WalletOutbox WalletOutboxMQConfig `yaml:"wallet_outbox"`
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"` UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
MessageActionOutbox MessageActionOutboxMQConfig `yaml:"message_action_outbox"` MessageActionOutbox MessageActionOutboxMQConfig `yaml:"message_action_outbox"`
@ -187,6 +192,15 @@ type RoomOutboxMQConfig struct {
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
} }
// LuckyGiftOutboxMQConfig 控制 activity 对幸运礼物 owner fact 的独立消费位点。
// 它不能复用 room/wallet consumer group否则 RocketMQ 的负载均衡会让区域飘屏事实被其他业务确认掉。
type LuckyGiftOutboxMQConfig struct {
Enabled bool `yaml:"enabled"`
Topic string `yaml:"topic"`
ConsumerGroup string `yaml:"consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
}
// WalletOutboxMQConfig 控制 activity 对 wallet_outbox topic 的独立消费组。 // WalletOutboxMQConfig 控制 activity 对 wallet_outbox topic 的独立消费组。
type WalletOutboxMQConfig struct { type WalletOutboxMQConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
@ -270,6 +284,7 @@ func Default() Config {
}, },
Broadcast: BroadcastConfig{ Broadcast: BroadcastConfig{
Enabled: false, Enabled: false,
AppCodes: []string{"lalu", "fami", "huwaa"},
SuperGiftMinValue: 100000, SuperGiftMinValue: 100000,
WorkerPollInterval: time.Second, WorkerPollInterval: time.Second,
WorkerBatchSize: 100, WorkerBatchSize: 100,
@ -304,6 +319,12 @@ func defaultRocketMQConfig() RocketMQConfig {
ConsumerGroup: "hyapp-activity-room-outbox", ConsumerGroup: "hyapp-activity-room-outbox",
ConsumerMaxReconsumeTimes: 16, ConsumerMaxReconsumeTimes: 16,
}, },
LuckyGiftOutbox: LuckyGiftOutboxMQConfig{
Enabled: false,
Topic: "hyapp_lucky_gift_outbox",
ConsumerGroup: "hyapp-activity-lucky-gift-outbox",
ConsumerMaxReconsumeTimes: 16,
},
WalletOutbox: WalletOutboxMQConfig{ WalletOutbox: WalletOutboxMQConfig{
Enabled: false, Enabled: false,
Topic: "hyapp_wallet_outbox", Topic: "hyapp_wallet_outbox",
@ -393,6 +414,10 @@ func Load(path string) (Config, error) {
if cfg.Broadcast.SuperGiftMinValue <= 0 { if cfg.Broadcast.SuperGiftMinValue <= 0 {
cfg.Broadcast.SuperGiftMinValue = 100000 cfg.Broadcast.SuperGiftMinValue = 100000
} }
cfg.Broadcast.AppCodes = normalizeAppCodes(cfg.Broadcast.AppCodes)
if len(cfg.Broadcast.AppCodes) == 0 {
cfg.Broadcast.AppCodes = append([]string(nil), Default().Broadcast.AppCodes...)
}
if cfg.Broadcast.WorkerPollInterval <= 0 { if cfg.Broadcast.WorkerPollInterval <= 0 {
cfg.Broadcast.WorkerPollInterval = time.Second cfg.Broadcast.WorkerPollInterval = time.Second
} }
@ -427,6 +452,11 @@ func Load(path string) (Config, error) {
return Config{}, err return Config{}, err
} }
cfg.RocketMQ = rocketMQ cfg.RocketMQ = rocketMQ
if cfg.RocketMQ.LuckyGiftOutbox.Enabled && (!cfg.Broadcast.Enabled || !cfg.TencentIM.Enabled) {
// 消费成功只代表区域 outbox 已持久化;没有 worker 或腾讯 IM publisher 时记录会永久堆积,
// 因此不能让 lucky consumer 以“已开启”配置启动成只进不出的半链路。
return Config{}, errors.New("rocketmq.lucky_gift_outbox.enabled requires broadcast.enabled and tencent_im.enabled")
}
if (cfg.FirstRechargeRewardWorker.Enabled || cfg.CumulativeRechargeRewardWorker.Enabled || cfg.InviteActivityRewardWorker.Enabled || cfg.RedPacketBroadcastWorker.Enabled || cfg.UserLeaderboardWorker.Enabled) && !cfg.RocketMQ.WalletOutbox.Enabled { if (cfg.FirstRechargeRewardWorker.Enabled || cfg.CumulativeRechargeRewardWorker.Enabled || cfg.InviteActivityRewardWorker.Enabled || cfg.RedPacketBroadcastWorker.Enabled || cfg.UserLeaderboardWorker.Enabled) && !cfg.RocketMQ.WalletOutbox.Enabled {
return Config{}, errors.New("wallet outbox workers require rocketmq.wallet_outbox.enabled") return Config{}, errors.New("wallet outbox workers require rocketmq.wallet_outbox.enabled")
} }
@ -442,6 +472,23 @@ func Load(path string) (Config, error) {
return cfg, nil return cfg, nil
} }
func normalizeAppCodes(input []string) []string {
seen := make(map[string]struct{}, len(input))
out := make([]string, 0, len(input))
for _, value := range input {
if strings.TrimSpace(value) == "" {
continue
}
value = appcode.Normalize(value)
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) { func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
defaults := defaultRocketMQConfig() defaults := defaultRocketMQConfig()
cfg.NameServers = normalizeStringSlice(cfg.NameServers) cfg.NameServers = normalizeStringSlice(cfg.NameServers)
@ -459,6 +506,15 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 { if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
} }
if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" {
cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic
}
if cfg.LuckyGiftOutbox.ConsumerGroup = strings.TrimSpace(cfg.LuckyGiftOutbox.ConsumerGroup); cfg.LuckyGiftOutbox.ConsumerGroup == "" {
cfg.LuckyGiftOutbox.ConsumerGroup = defaults.LuckyGiftOutbox.ConsumerGroup
}
if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes
}
if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" { if cfg.WalletOutbox.Topic = strings.TrimSpace(cfg.WalletOutbox.Topic); cfg.WalletOutbox.Topic == "" {
cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic cfg.WalletOutbox.Topic = defaults.WalletOutbox.Topic
} }
@ -517,7 +573,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
if cfg.MessageActionOutbox.Retry < 0 { if cfg.MessageActionOutbox.Retry < 0 {
cfg.MessageActionOutbox.Retry = defaults.MessageActionOutbox.Retry cfg.MessageActionOutbox.Retry = defaults.MessageActionOutbox.Retry
} }
if cfg.RoomOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled || cfg.MessageActionOutbox.Enabled { if cfg.RoomOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled || cfg.WalletOutbox.Enabled || cfg.UserOutbox.Enabled || cfg.MessageActionOutbox.Enabled {
cfg.Enabled = true cfg.Enabled = true
} }
if cfg.Enabled { if cfg.Enabled {

View File

@ -1,6 +1,10 @@
package config package config
import "testing" import (
"os"
"path/filepath"
"testing"
)
func TestLoadLocalEnablesOutboxMQConsumers(t *testing.T) { func TestLoadLocalEnablesOutboxMQConsumers(t *testing.T) {
cfg, err := Load("../../configs/config.yaml") cfg, err := Load("../../configs/config.yaml")
@ -8,9 +12,15 @@ func TestLoadLocalEnablesOutboxMQConsumers(t *testing.T) {
t.Fatalf("Load local config failed: %v", err) t.Fatalf("Load local config failed: %v", err)
} }
// activity 的充值、红包、礼物和区域播报都依赖 MQ fanout本地默认必须和测试/线上一致。 // activity 的充值、红包、礼物和区域播报都依赖 MQ fanout本地默认必须和测试/线上一致。
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.WalletOutbox.Enabled || !cfg.RocketMQ.UserOutbox.Enabled { if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.LuckyGiftOutbox.Enabled || !cfg.RocketMQ.WalletOutbox.Enabled || !cfg.RocketMQ.UserOutbox.Enabled {
t.Fatalf("local config must enable all activity outbox MQ consumers: %+v", cfg.RocketMQ) t.Fatalf("local config must enable all activity outbox MQ consumers: %+v", cfg.RocketMQ)
} }
if len(cfg.Broadcast.AppCodes) != 3 || cfg.Broadcast.AppCodes[1] != "fami" {
t.Fatalf("local broadcast app list must include fami before it has outbox data: %+v", cfg.Broadcast.AppCodes)
}
if cfg.RocketMQ.LuckyGiftOutbox.Topic != "hyapp_lucky_gift_outbox" || cfg.RocketMQ.LuckyGiftOutbox.ConsumerGroup != "hyapp-activity-lucky-gift-outbox" {
t.Fatalf("local lucky gift consumer config mismatch: %+v", cfg.RocketMQ.LuckyGiftOutbox)
}
if !cfg.FirstRechargeRewardWorker.Enabled || !cfg.CumulativeRechargeRewardWorker.Enabled || !cfg.InviteActivityRewardWorker.Enabled || !cfg.RedPacketBroadcastWorker.Enabled || !cfg.UserLeaderboardWorker.Enabled { if !cfg.FirstRechargeRewardWorker.Enabled || !cfg.CumulativeRechargeRewardWorker.Enabled || !cfg.InviteActivityRewardWorker.Enabled || !cfg.RedPacketBroadcastWorker.Enabled || !cfg.UserLeaderboardWorker.Enabled {
t.Fatalf("local config must enable wallet outbox workers: first=%+v cumulative=%+v invite=%+v red_packet=%+v user_leaderboard=%+v", cfg.FirstRechargeRewardWorker, cfg.CumulativeRechargeRewardWorker, cfg.InviteActivityRewardWorker, cfg.RedPacketBroadcastWorker, cfg.UserLeaderboardWorker) t.Fatalf("local config must enable wallet outbox workers: first=%+v cumulative=%+v invite=%+v red_packet=%+v user_leaderboard=%+v", cfg.FirstRechargeRewardWorker, cfg.CumulativeRechargeRewardWorker, cfg.InviteActivityRewardWorker, cfg.RedPacketBroadcastWorker, cfg.UserLeaderboardWorker)
} }
@ -30,6 +40,12 @@ func TestLoadTencentExampleEnablesRoomOutboxMQ(t *testing.T) {
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Topic == "" || cfg.RocketMQ.RoomOutbox.ConsumerGroup == "" { if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.Topic == "" || cfg.RocketMQ.RoomOutbox.ConsumerGroup == "" {
t.Fatalf("tencent example must configure room outbox MQ consumer: %+v", cfg.RocketMQ) t.Fatalf("tencent example must configure room outbox MQ consumer: %+v", cfg.RocketMQ)
} }
if len(cfg.Broadcast.AppCodes) != 3 || cfg.Broadcast.AppCodes[1] != "fami" {
t.Fatalf("tencent broadcast app list must include fami: %+v", cfg.Broadcast.AppCodes)
}
if !cfg.RocketMQ.LuckyGiftOutbox.Enabled || cfg.RocketMQ.LuckyGiftOutbox.Topic != "hyapp_lucky_gift_outbox" || cfg.RocketMQ.LuckyGiftOutbox.ConsumerGroup == "" {
t.Fatalf("tencent example must configure lucky gift outbox MQ consumer: %+v", cfg.RocketMQ.LuckyGiftOutbox)
}
if !cfg.RocketMQ.UserOutbox.Enabled || cfg.RocketMQ.UserOutbox.Topic != "hyapp_user_outbox" || cfg.RocketMQ.UserOutbox.ConsumerGroup == "" { if !cfg.RocketMQ.UserOutbox.Enabled || cfg.RocketMQ.UserOutbox.Topic != "hyapp_user_outbox" || cfg.RocketMQ.UserOutbox.ConsumerGroup == "" {
t.Fatalf("tencent example must configure user outbox MQ consumer: %+v", cfg.RocketMQ.UserOutbox) t.Fatalf("tencent example must configure user outbox MQ consumer: %+v", cfg.RocketMQ.UserOutbox)
} }
@ -43,3 +59,22 @@ func TestLoadTencentExampleEnablesRoomOutboxMQ(t *testing.T) {
t.Fatalf("tencent example must configure user leaderboard worker: worker=%+v wallet=%+v", cfg.UserLeaderboardWorker, cfg.RocketMQ.WalletOutbox) t.Fatalf("tencent example must configure user leaderboard worker: worker=%+v wallet=%+v", cfg.UserLeaderboardWorker, cfg.RocketMQ.WalletOutbox)
} }
} }
func TestLoadRejectsLuckyGiftConsumerWithoutBroadcastDelivery(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(`
tencent_im:
enabled: false
broadcast:
enabled: false
rocketmq:
name_servers: ["127.0.0.1:9876"]
lucky_gift_outbox:
enabled: true
`), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := Load(path); err == nil {
t.Fatal("lucky gift consumer without broadcast delivery must be rejected")
}
}

View File

@ -83,6 +83,15 @@ type ConsumeRoomEventResult struct {
BroadcastCreated bool BroadcastCreated bool
} }
// ConsumeLuckyGiftEventResult 描述 lucky-gift owner fact 是否生成区域播报;
// EventID 保留来源事实BroadcastEventID 是 activity-service 自己的幂等投影键。
type ConsumeLuckyGiftEventResult struct {
EventID string
Status string
BroadcastEventID string
BroadcastCreated bool
}
// RedPacketWalletEvent 是 wallet-service 已提交的红包资金事实。 // RedPacketWalletEvent 是 wallet-service 已提交的红包资金事实。
type RedPacketWalletEvent struct { type RedPacketWalletEvent struct {
AppCode string AppCode string

View File

@ -0,0 +1,195 @@
package broadcast
import (
"context"
"encoding/json"
"fmt"
"strings"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/luckygiftmq"
"hyapp/pkg/xerr"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
)
// HandleLuckyGiftEvent 把 lucky-gift-service 在钱包返奖成功后发布的 owner fact 投影成区域 IM outbox。
// MQ 事实只负责描述已发生的中奖activity-service 独立拥有区域展示策略和腾讯 IM 投递状态,不能反写 owner outbox。
func (s *Service) HandleLuckyGiftEvent(ctx context.Context, envelope *luckygifteventsv1.EventEnvelope) (broadcastdomain.ConsumeLuckyGiftEventResult, error) {
if envelope == nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, xerr.New(xerr.InvalidArgument, "lucky gift event envelope is required")
}
result := broadcastdomain.ConsumeLuckyGiftEventResult{
EventID: envelope.GetEventId(),
Status: broadcastdomain.StatusSkipped,
}
if envelope.GetEventType() != luckygiftmq.EventTypeLuckyGiftDrawn {
// 独立 topic 未来可能增加审计类事实;未知类型必须确认跳过,不能误生成客户端无法识别的播报。
return result, nil
}
var event luckygifteventsv1.LuckyGiftDrawn
if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, fmt.Errorf("decode lucky gift drawn body: %w", err)
}
if err := validateLuckyGiftDrawnEnvelope(envelope, &event); err != nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, err
}
// 区域顶部飘屏只接受已经实际到账的 10 倍及以上奖励。pending/failed 事实、0 奖励和无区域事实
// 都属于合法跳过,避免展示先于钱包到账或把区域未知事件扩大成全局消息。
if !shouldPublishLuckyGiftBigWin(&event) {
return result, nil
}
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
sender, err := s.luckyGiftSenderProfile(eventCtx, &event)
if err != nil {
// 资料查询是完整 IM 快照的一部分;暂时失败必须交还 MQ 重试,不能先确认后永久退化成错误昵称。
return broadcastdomain.ConsumeLuckyGiftEventResult{}, fmt.Errorf("resolve lucky gift sender profile: %w", err)
}
broadcastEventID := "lucky_gift_big_win:" + event.GetDrawId()
payloadJSON, err := luckyGiftBigWinPayload(envelope, &event, broadcastEventID, sender, s.now().UTC().UnixMilli())
if err != nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, err
}
published, err := s.PublishRegionBroadcast(eventCtx, PublishInput{
EventID: broadcastEventID,
BroadcastType: broadcastdomain.TypeLuckyGiftBigWin,
RegionID: event.GetVisibleRegionId(),
PayloadJSON: payloadJSON,
})
if err != nil {
return broadcastdomain.ConsumeLuckyGiftEventResult{}, err
}
// im_broadcast_outbox 的 (app_code,event_id) 是本投影的幂等事实MQ 重投只会得到 Created=false
// 不会生成第二条区域消息,也不需要读取 lucky-gift-service 的 owner outbox 状态。
return broadcastdomain.ConsumeLuckyGiftEventResult{
EventID: envelope.GetEventId(),
Status: published.Status,
BroadcastEventID: published.EventID,
BroadcastCreated: published.Created,
}, nil
}
func validateLuckyGiftDrawnEnvelope(envelope *luckygifteventsv1.EventEnvelope, event *luckygifteventsv1.LuckyGiftDrawn) error {
if strings.TrimSpace(envelope.GetAppCode()) == "" || strings.TrimSpace(envelope.GetEventId()) == "" {
return xerr.New(xerr.InvalidArgument, "lucky gift event identity is incomplete")
}
drawID := strings.TrimSpace(event.GetDrawId())
if drawID == "" || strings.TrimSpace(event.GetRoomId()) == "" || event.GetSenderUserId() <= 0 {
return xerr.New(xerr.InvalidArgument, "lucky gift drawn fact is incomplete")
}
if envelopeDrawID := strings.TrimSpace(envelope.GetDrawId()); envelopeDrawID != "" && envelopeDrawID != drawID {
return xerr.New(xerr.InvalidArgument, "lucky gift envelope draw_id does not match body")
}
return nil
}
func shouldPublishLuckyGiftBigWin(event *luckygifteventsv1.LuckyGiftDrawn) bool {
if event == nil || !strings.EqualFold(strings.TrimSpace(event.GetRewardStatus()), "granted") {
return false
}
return event.GetEffectiveRewardCoins() > 0 &&
event.GetVisibleRegionId() > 0 &&
event.GetMultiplierPpm() >= luckygiftmq.LuckyGiftDisplayMinMultiplierPPM
}
func (s *Service) luckyGiftSenderProfile(ctx context.Context, event *luckygifteventsv1.LuckyGiftDrawn) (SenderProfile, error) {
profile := SenderProfile{
UserID: event.GetSenderUserId(),
Account: strings.TrimSpace(event.GetSenderDisplayUserId()),
PrettyDisplayUserID: strings.TrimSpace(event.GetSenderPrettyDisplayUserId()),
Nickname: strings.TrimSpace(event.GetSenderName()),
Avatar: strings.TrimSpace(event.GetSenderAvatar()),
}
// 送礼主链路已经把展示快照写进 owner fact字段齐全时禁止回查当前资料避免用户改名后历史事实漂移。
if profile.Nickname != "" && profile.Account != "" {
return profile, nil
}
resolved, err := s.broadcastUserProfile(ctx, profile.UserID)
if err != nil {
return SenderProfile{}, err
}
if profile.Account == "" {
profile.Account = strings.TrimSpace(resolved.Account)
}
if profile.PrettyDisplayUserID == "" {
profile.PrettyDisplayUserID = strings.TrimSpace(resolved.PrettyDisplayUserID)
}
if profile.Nickname == "" {
profile.Nickname = firstLuckyGiftDisplayValue(resolved.Nickname, profile.PrettyDisplayUserID, profile.Account)
}
if profile.Avatar == "" {
profile.Avatar = strings.TrimSpace(resolved.Avatar)
}
return profile, nil
}
func firstLuckyGiftDisplayValue(values ...string) string {
for _, value := range values {
if normalized := strings.TrimSpace(value); normalized != "" {
return normalized
}
}
return ""
}
func luckyGiftBigWinPayload(envelope *luckygifteventsv1.EventEnvelope, event *luckygifteventsv1.LuckyGiftDrawn, eventID string, sender SenderProfile, sentAtMS int64) (string, error) {
createdAtMS := event.GetDrawCreatedAtMs()
if createdAtMS <= 0 {
createdAtMS = envelope.GetOccurredAtMs()
}
// 字段保持拆服前 Flutter 已消费的扁平协议,并补充 granted/pretty ID 快照;不携带 gift_name
// 客户端继续用 gift_id 命中本地多语言礼物配置,避免服务端文案覆盖客户端翻译。
payload := map[string]any{
"event_type": "lucky_gift_drawn",
"event_id": eventID,
"broadcast_type": broadcastdomain.TypeLuckyGiftBigWin,
"scope": broadcastdomain.ScopeRegion,
"app_code": envelope.GetAppCode(),
"region_id": event.GetVisibleRegionId(),
"visible_region_id": event.GetVisibleRegionId(),
"draw_id": event.GetDrawId(),
"command_id": event.GetCommandId(),
"pool_id": event.GetPoolId(),
"room_id": event.GetRoomId(),
"gift_id": event.GetGiftId(),
"gift_count": event.GetGiftCount(),
"user_id": event.GetSenderUserId(),
"sender_user_id": event.GetSenderUserId(),
"target_user_id": event.GetTargetUserId(),
"sender_name": sender.Nickname,
"sender_avatar": sender.Avatar,
"sender_display_user_id": sender.Account,
"sender_pretty_display_user_id": sender.PrettyDisplayUserID,
"coin_spent": event.GetCoinSpent(),
"rule_version": event.GetRuleVersion(),
"experience_pool": event.GetExperiencePool(),
"selected_tier_id": event.GetSelectedTierId(),
"multiplier_ppm": event.GetMultiplierPpm(),
"base_reward_coins": event.GetBaseRewardCoins(),
"effective_reward_coins": event.GetEffectiveRewardCoins(),
"reward_status": "granted",
"wallet_transaction_id": event.GetWalletTransactionId(),
"stage_feedback": event.GetStageFeedback(),
"high_multiplier": event.GetHighMultiplier(),
"created_at_ms": createdAtMS,
"draw_created_at_ms": createdAtMS,
"reward_granted_at_ms": event.GetRewardGrantedAtMs(),
"sent_at_ms": sentAtMS,
"action": map[string]any{
"type": "enter_room",
"room_id": event.GetRoomId(),
},
"sender": map[string]any{
"user_id": sender.UserID,
"display_user_id": sender.Account,
"pretty_display_user_id": sender.PrettyDisplayUserID,
"nickname": sender.Nickname,
"avatar": sender.Avatar,
},
}
encoded, err := json.Marshal(payload)
return string(encoded), err
}

View File

@ -8,7 +8,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"sort"
"strings" "strings"
"sync/atomic"
"time" "time"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -58,18 +60,21 @@ type SenderProfileSource interface {
// SenderProfile 是播报 IM payload 的发送者展示字段。 // SenderProfile 是播报 IM payload 的发送者展示字段。
// activity-service 不持久化该资料,只在消费事实时组装一次播报消息。 // activity-service 不持久化该资料,只在消费事实时组装一次播报消息。
type SenderProfile struct { type SenderProfile struct {
UserID int64 UserID int64
Account string Account string
Nickname string PrettyDisplayUserID string
Avatar string Nickname string
AvatarFrameURL string Avatar string
RegionCode string AvatarFrameURL string
RegionCode string
} }
// Config 保存播报策略和 worker 参数。 // Config 保存播报策略和 worker 参数。
// SuperGiftMinValue 是产品策略阈值Worker* 是 outbox 投递吞吐和故障恢复参数。 // SuperGiftMinValue 是产品策略阈值Worker* 是 outbox 投递吞吐和故障恢复参数。
type Config struct { type Config struct {
NodeID string NodeID string
// AppCodes 是共享腾讯 IM 应用内需要维护的业务租户;群名和 outbox 仍按 app_code 隔离。
AppCodes []string
SuperGiftMinValue int64 SuperGiftMinValue int64
WorkerBatchSize int WorkerBatchSize int
WorkerLockTTL time.Duration WorkerLockTTL time.Duration
@ -101,6 +106,8 @@ type Service struct {
regionSource RegionSource regionSource RegionSource
senderProfileSource SenderProfileSource senderProfileSource SenderProfileSource
now func() time.Time now func() time.Time
// outboxClaimCursor 轮转跨 App claim 起点,避免默认 lalu 的持续积压饿死其他租户播报。
outboxClaimCursor atomic.Uint64
} }
// New 创建播报服务。 // New 创建播报服务。
@ -294,14 +301,15 @@ func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOp
} }
options = normalizeWorkerOptions(options, s.cfg) options = normalizeWorkerOptions(options, s.cfg)
nowMS := s.now().UTC().UnixMilli() nowMS := s.now().UTC().UnixMilli()
records, err := s.repository.ClaimPendingBroadcastOutbox(ctx, options.WorkerID, nowMS, nowMS+options.LockTTL.Milliseconds(), options.BatchSize, options.MaxRetry) records, err := s.claimPendingBroadcasts(ctx, options, nowMS)
if err != nil { if err != nil {
return broadcastdomain.ProcessResult{}, err return broadcastdomain.ProcessResult{}, err
} }
result := broadcastdomain.ProcessResult{ClaimedCount: len(records), HasMore: len(records) >= options.BatchSize} result := broadcastdomain.ProcessResult{ClaimedCount: len(records), HasMore: len(records) >= options.BatchSize}
for _, record := range records { for _, record := range records {
recordCtx := appcode.WithContext(ctx, record.AppCode)
// 腾讯 REST 只接受已序列化 JSONevent_id/desc/ext 让客户端和日志都能快速识别消息类型。 // 腾讯 REST 只接受已序列化 JSONevent_id/desc/ext 让客户端和日志都能快速识别消息类型。
err := s.publisher.PublishGroupCustomMessage(ctx, tencentim.CustomGroupMessage{ err := s.publisher.PublishGroupCustomMessage(recordCtx, tencentim.CustomGroupMessage{
GroupID: record.GroupID, GroupID: record.GroupID,
EventID: record.EventID, EventID: record.EventID,
Desc: record.BroadcastType, Desc: record.BroadcastType,
@ -312,7 +320,7 @@ func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOp
result.FailureCount++ result.FailureCount++
// 单条投递失败必须落日志:区域群缺失/满员这类腾讯侧错误此前只写 last_error 列, // 单条投递失败必须落日志:区域群缺失/满员这类腾讯侧错误此前只写 last_error 列,
// 重试烧完后记录无声沉底为 failed线上排障只能翻表。attempt 达到上限时标记 final 便于告警。 // 重试烧完后记录无声沉底为 failed线上排障只能翻表。attempt 达到上限时标记 final 便于告警。
logx.Error(ctx, "broadcast_outbox_deliver_failed", err, logx.Error(recordCtx, "broadcast_outbox_deliver_failed", err,
slog.String("event_id", record.EventID), slog.String("event_id", record.EventID),
slog.String("group_id", record.GroupID), slog.String("group_id", record.GroupID),
slog.String("scope", record.Scope), slog.String("scope", record.Scope),
@ -321,18 +329,84 @@ func (s *Service) ProcessPendingBroadcasts(ctx context.Context, options WorkerOp
slog.Int("max_retry", options.MaxRetry), slog.Int("max_retry", options.MaxRetry),
slog.Bool("final", record.AttemptCount+1 >= options.MaxRetry)) slog.Bool("final", record.AttemptCount+1 >= options.MaxRetry))
// Mark 失败只记录为 best-effort当前记录锁会过期下一轮 worker 仍可重新接管。 // Mark 失败只记录为 best-effort当前记录锁会过期下一轮 worker 仍可重新接管。
_ = s.repository.MarkBroadcastOutboxFailed(ctx, record.EventID, options.WorkerID, s.now().UTC().UnixMilli(), s.now().UTC().Add(options.PollInterval).UnixMilli(), options.MaxRetry, trimError(err.Error())) _ = s.repository.MarkBroadcastOutboxFailed(recordCtx, record.EventID, options.WorkerID, s.now().UTC().UnixMilli(), s.now().UTC().Add(options.PollInterval).UnixMilli(), options.MaxRetry, trimError(err.Error()))
continue continue
} }
if err := s.repository.MarkBroadcastOutboxDelivered(ctx, record.EventID, s.now().UTC().UnixMilli()); err != nil { if err := s.repository.MarkBroadcastOutboxDelivered(recordCtx, record.EventID, s.now().UTC().UnixMilli()); err != nil {
return result, err return result, err
} }
result.SuccessCount++ result.SuccessCount++
s.dualSendLegacyBroadcast(ctx, record) s.dualSendLegacyBroadcast(recordCtx, record)
} }
return result, nil return result, nil
} }
type broadcastOutboxAppCodeLister interface {
ListBroadcastOutboxAppCodes(ctx context.Context) ([]string, error)
}
func (s *Service) claimPendingBroadcasts(ctx context.Context, options WorkerOptions, nowMS int64) ([]broadcastdomain.OutboxRecord, error) {
appCodes, err := s.broadcastOutboxAppCodes(ctx)
if err != nil || len(appCodes) == 0 {
return nil, err
}
start := int(s.outboxClaimCursor.Add(1)-1) % len(appCodes)
ordered := make([]string, 0, len(appCodes))
for offset := 0; offset < len(appCodes); offset++ {
ordered = append(ordered, appCodes[(start+offset)%len(appCodes)])
}
// 第一轮给每个 App 公平配额;若有 App 无积压,第二轮再把剩余额度让给有积压的 App兼顾隔离和吞吐。
records := make([]broadcastdomain.OutboxRecord, 0, options.BatchSize)
quota := (options.BatchSize + len(ordered) - 1) / len(ordered)
for pass := 0; pass < 2 && len(records) < options.BatchSize; pass++ {
for _, appCode := range ordered {
remaining := options.BatchSize - len(records)
if remaining <= 0 {
break
}
limit := remaining
if pass == 0 && limit > quota {
limit = quota
}
claimed, err := s.repository.ClaimPendingBroadcastOutbox(appcode.WithContext(ctx, appCode), options.WorkerID, nowMS, nowMS+options.LockTTL.Milliseconds(), limit, options.MaxRetry)
if err != nil {
return nil, err
}
records = append(records, claimed...)
}
}
return records, nil
}
func (s *Service) broadcastOutboxAppCodes(ctx context.Context) ([]string, error) {
// 配置清单保证零 outbox 的新 App 也会建群;数据库发现继续兜底历史或灰度 App避免漏投已有记录。
appCodes := append([]string(nil), s.cfg.AppCodes...)
appCodes = append(appCodes, appcode.FromContext(ctx))
if lister, ok := s.repository.(broadcastOutboxAppCodeLister); ok {
discovered, err := lister.ListBroadcastOutboxAppCodes(ctx)
if err != nil {
return nil, err
}
appCodes = append(appCodes, discovered...)
}
seen := make(map[string]struct{}, len(appCodes))
normalized := make([]string, 0, len(appCodes))
for _, appCode := range appCodes {
if strings.TrimSpace(appCode) == "" {
continue
}
appCode = appcode.Normalize(appCode)
if _, exists := seen[appCode]; exists {
continue
}
seen[appCode] = struct{}{}
normalized = append(normalized, appCode)
}
sort.Strings(normalized)
return normalized, nil
}
// dualSendLegacyBroadcast 在播报群从 v1(ChatRoom) 切到 v2(AVChatRoom) 的过渡期内, // dualSendLegacyBroadcast 在播报群从 v1(ChatRoom) 切到 v2(AVChatRoom) 的过渡期内,
// 把已成功投递到 v2 群的全局/区域播报补发一份到 v1 旧群:老客户端在重新拉取 join 列表前 // 把已成功投递到 v2 群的全局/区域播报补发一份到 v1 旧群:老客户端在重新拉取 join 列表前
// 仍停留在旧群里。补发尽力而为,失败不影响主投递的成功状态。 // 仍停留在旧群里。补发尽力而为,失败不影响主投递的成功状态。
@ -623,6 +697,20 @@ func normalizeConfig(cfg Config) Config {
if cfg.WorkerPollInterval <= 0 { if cfg.WorkerPollInterval <= 0 {
cfg.WorkerPollInterval = time.Second cfg.WorkerPollInterval = time.Second
} }
seenApps := make(map[string]struct{}, len(cfg.AppCodes))
normalizedApps := make([]string, 0, len(cfg.AppCodes))
for _, value := range cfg.AppCodes {
if strings.TrimSpace(value) == "" {
continue
}
value = appcode.Normalize(value)
if _, exists := seenApps[value]; exists {
continue
}
seenApps[value] = struct{}{}
normalizedApps = append(normalizedApps, value)
}
cfg.AppCodes = normalizedApps
return cfg return cfg
} }

View File

@ -8,12 +8,77 @@ import (
"time" "time"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/imgroup"
"hyapp/pkg/luckygiftmq"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast" broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast"
) )
type crossAppBroadcastRepository struct {
Repository
claimed map[string]bool
deliveredApps []string
}
func (r *crossAppBroadcastRepository) ListBroadcastOutboxAppCodes(context.Context) ([]string, error) {
return []string{"huwaa", "fami"}, nil
}
func (r *crossAppBroadcastRepository) ClaimPendingBroadcastOutbox(ctx context.Context, _ string, _ int64, _ int64, _ int, _ int) ([]broadcastdomain.OutboxRecord, error) {
appCode := appcode.FromContext(ctx)
if r.claimed == nil {
r.claimed = map[string]bool{}
}
if r.claimed[appCode] {
return nil, nil
}
r.claimed[appCode] = true
return []broadcastdomain.OutboxRecord{{
AppCode: appCode,
EventID: "cross-app:" + appCode,
Scope: broadcastdomain.ScopeRoom,
GroupID: "room-" + appCode,
BroadcastType: broadcastdomain.TypeLuckyGiftBigWin,
PayloadJSON: `{}`,
}}, nil
}
func (r *crossAppBroadcastRepository) MarkBroadcastOutboxDelivered(ctx context.Context, _ string, _ int64) error {
r.deliveredApps = append(r.deliveredApps, appcode.FromContext(ctx))
return nil
}
func (r *crossAppBroadcastRepository) MarkBroadcastOutboxFailed(context.Context, string, string, int64, int64, int, string) error {
return nil
}
func TestProcessPendingBroadcastsUsesEachRecordAppContext(t *testing.T) {
repository := &crossAppBroadcastRepository{}
service := New(Config{NodeID: "cross-app-broadcast"}, repository, &fakePublisher{}, nil)
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() })
result, err := service.ProcessPendingBroadcasts(context.Background(), WorkerOptions{WorkerID: "worker", BatchSize: 2, LockTTL: time.Second, MaxRetry: 8})
if err != nil {
t.Fatalf("process cross-app broadcasts: %v", err)
}
if result.SuccessCount != 2 || len(repository.deliveredApps) != 2 {
t.Fatalf("cross-app broadcasts were not delivered: result=%+v apps=%v", result, repository.deliveredApps)
}
want := map[string]bool{"fami": true, "huwaa": true}
for _, appCode := range repository.deliveredApps {
if !want[appCode] {
t.Fatalf("unexpected delivered app context %q", appCode)
}
delete(want, appCode)
}
if len(want) != 0 {
t.Fatalf("missing delivered app contexts: %v", want)
}
}
func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) { func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
repository := newFakeRepository() repository := newFakeRepository()
service := New(Config{NodeID: "node-a", SuperGiftMinValue: 100}, repository, nil, nil) service := New(Config{NodeID: "node-a", SuperGiftMinValue: 100}, repository, nil, nil)
@ -68,6 +133,129 @@ func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) {
} }
} }
func TestHandleLuckyGiftEventCreatesTenTimesRegionBroadcastIdempotently(t *testing.T) {
repository := newFakeRepository()
service := New(Config{NodeID: "node-a"}, repository, nil, nil)
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_001_000) })
// owner fact 已带送礼时展示快照;即使当前 user-service 不可用,也不应回查并阻断这条事实。
service.SetSenderProfileSource(fakeSenderProfileSource{err: errors.New("user service unavailable")})
envelope := mustLuckyGiftEnvelope(t, &luckygifteventsv1.LuckyGiftDrawn{
DrawId: "lucky_draw_10x",
CommandId: "cmd-lucky-10x",
RoomId: "room-210",
PoolId: "super_lucky",
GiftId: "43",
GiftCount: 1,
SenderUserId: 42,
TargetUserId: 99,
SenderName: "Real Sender",
SenderAvatar: "https://cdn.example/sender.png",
SenderDisplayUserId: "160042",
SenderPrettyDisplayUserId: "888042",
VisibleRegionId: 210,
CountryId: 15,
CoinSpent: 100,
RuleVersion: 7,
ExperiencePool: "advanced",
SelectedTierId: "10x",
MultiplierPpm: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM,
BaseRewardCoins: 1000,
EffectiveRewardCoins: 1000,
HighMultiplier: true,
RewardStatus: "granted",
WalletTransactionId: "wallet-tx-10x",
DrawCreatedAtMs: 1_700_000_000_000,
RewardGrantedAtMs: 1_700_000_000_500,
})
result, err := service.HandleLuckyGiftEvent(context.Background(), envelope)
if err != nil {
t.Fatalf("HandleLuckyGiftEvent failed: %v", err)
}
if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "lucky_gift_big_win:lucky_draw_10x" {
t.Fatalf("unexpected lucky gift consume result: %+v", result)
}
record := repository.records[result.BroadcastEventID]
if record.GroupID != "hy_lalu_bc_r_v2_210" || record.BroadcastType != broadcastdomain.TypeLuckyGiftBigWin {
t.Fatalf("unexpected lucky gift broadcast record: %+v", record)
}
var payload map[string]any
if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
t.Fatalf("lucky gift payload is not json: %v", err)
}
if payload["event_type"] != "lucky_gift_drawn" || payload["reward_status"] != "granted" || payload["sender_name"] != "Real Sender" || payload["sender_pretty_display_user_id"] != "888042" {
t.Fatalf("lucky gift payload identity/profile mismatch: %+v", payload)
}
if payload["room_id"] != "room-210" || payload["draw_id"] != "lucky_draw_10x" || payload["multiplier_ppm"] != float64(luckygiftmq.LuckyGiftDisplayMinMultiplierPPM) || payload["effective_reward_coins"] != float64(1000) || payload["sent_at_ms"] != float64(1_700_000_001_000) {
t.Fatalf("lucky gift payload reward mismatch: %+v", payload)
}
if _, exists := payload["gift_name"]; exists {
t.Fatalf("region payload must let Flutter resolve localized gift name: %+v", payload)
}
action, _ := payload["action"].(map[string]any)
if action["type"] != "enter_room" || action["room_id"] != "room-210" {
t.Fatalf("lucky gift action mismatch: %+v", action)
}
sender, _ := payload["sender"].(map[string]any)
if sender["nickname"] != "Real Sender" || sender["display_user_id"] != "160042" || sender["pretty_display_user_id"] != "888042" {
t.Fatalf("nested lucky gift sender mismatch: %+v", sender)
}
again, err := service.HandleLuckyGiftEvent(context.Background(), envelope)
if err != nil {
t.Fatalf("duplicate HandleLuckyGiftEvent failed: %v", err)
}
if again.BroadcastCreated || len(repository.records) != 1 {
t.Fatalf("duplicate lucky owner fact must reuse one outbox: result=%+v records=%d", again, len(repository.records))
}
}
func TestHandleLuckyGiftEventSkipsNonBigWinFacts(t *testing.T) {
tests := []struct {
name string
mutate func(*luckygifteventsv1.LuckyGiftDrawn)
}{
{name: "below ten times", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) {
event.MultiplierPpm = luckygiftmq.LuckyGiftDisplayMinMultiplierPPM - 1
}},
{name: "pending reward", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.RewardStatus = "pending" }},
{name: "zero reward", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.EffectiveRewardCoins = 0 }},
{name: "unknown region", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.VisibleRegionId = 0 }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
repository := newFakeRepository()
service := New(Config{NodeID: "node-a"}, repository, nil, nil)
event := validLuckyGiftDrawn()
test.mutate(event)
result, err := service.HandleLuckyGiftEvent(context.Background(), mustLuckyGiftEnvelope(t, event))
if err != nil {
t.Fatalf("HandleLuckyGiftEvent failed: %v", err)
}
if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" || len(repository.records) != 0 {
t.Fatalf("non-big-win fact must be skipped: result=%+v records=%+v", result, repository.records)
}
})
}
}
func TestHandleLuckyGiftEventReturnsProfileFailureForMQRetry(t *testing.T) {
repository := newFakeRepository()
service := New(Config{NodeID: "node-a"}, repository, nil, nil)
service.SetSenderProfileSource(fakeSenderProfileSource{err: errors.New("user service timeout")})
event := validLuckyGiftDrawn()
// 旧 pending outbox 可能没有展示快照activity 必须回查资料,失败时不能确认 MQ 或写不完整播报。
event.SenderName = ""
event.SenderDisplayUserId = ""
if _, err := service.HandleLuckyGiftEvent(context.Background(), mustLuckyGiftEnvelope(t, event)); err == nil {
t.Fatal("missing snapshot with profile failure should return an error")
}
if len(repository.records) != 0 {
t.Fatalf("profile failure must not persist incomplete broadcast: %+v", repository.records)
}
}
func TestHandleRoomGiftSentCreatesRegionBroadcastForTaggedGiftBelowThreshold(t *testing.T) { func TestHandleRoomGiftSentCreatesRegionBroadcastForTaggedGiftBelowThreshold(t *testing.T) {
repository := newFakeRepository() repository := newFakeRepository()
service := New(Config{NodeID: "node-a", SuperGiftMinValue: 1000}, repository, nil, nil) service := New(Config{NodeID: "node-a", SuperGiftMinValue: 1000}, repository, nil, nil)
@ -459,6 +647,33 @@ func TestEnsureBroadcastGroupsUsesAVChatRoomAndSurvivesGlobalFailure(t *testing.
} }
} }
func TestReconcileBroadcastGroupsIncludesConfiguredAppWithoutOutbox(t *testing.T) {
publisher := &fakePublisher{}
service := New(Config{NodeID: "multi-app-reconcile", AppCodes: []string{"lalu", "fami"}}, newFakeRepository(), publisher, fakeRegionSource{regionIDs: []int64{1001}})
service.reconcileBroadcastGroups(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker"})
wantGroups := map[string]bool{
"hy_lalu_bc_g_v2": true,
"hy_lalu_bc_r_v2_1001": true,
"hy_fami_bc_g_v2": true,
"hy_fami_bc_r_v2_1001": true,
}
for index, spec := range publisher.ensuredGroups {
if !wantGroups[spec.GroupID] {
t.Fatalf("unexpected configured app group: %+v", spec)
}
delete(wantGroups, spec.GroupID)
parsed := imgroup.Parse(spec.GroupID)
if parsed.AppCode != publisher.ensuredAppCodes[index] {
t.Fatalf("group %s reconciled with wrong app context %s", spec.GroupID, publisher.ensuredAppCodes[index])
}
}
if len(wantGroups) != 0 {
t.Fatalf("configured app groups were not reconciled: %+v", wantGroups)
}
}
func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope { func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
t.Helper() t.Helper()
body, err := proto.Marshal(gift) body, err := proto.Marshal(gift)
@ -509,6 +724,47 @@ func redPacketPayloadData(t *testing.T, payloadJSON string) map[string]any {
return payload.Data return payload.Data
} }
func validLuckyGiftDrawn() *luckygifteventsv1.LuckyGiftDrawn {
return &luckygifteventsv1.LuckyGiftDrawn{
DrawId: "lucky_draw_valid",
CommandId: "cmd-lucky-valid",
RoomId: "room-210",
PoolId: "super_lucky",
GiftId: "43",
GiftCount: 1,
SenderUserId: 42,
TargetUserId: 99,
SenderName: "Real Sender",
SenderAvatar: "https://cdn.example/sender.png",
SenderDisplayUserId: "160042",
VisibleRegionId: 210,
CoinSpent: 100,
MultiplierPpm: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM,
BaseRewardCoins: 1000,
EffectiveRewardCoins: 1000,
RewardStatus: "granted",
WalletTransactionId: "wallet-tx-valid",
DrawCreatedAtMs: 1_700_000_000_000,
RewardGrantedAtMs: 1_700_000_000_500,
}
}
func mustLuckyGiftEnvelope(t *testing.T, event *luckygifteventsv1.LuckyGiftDrawn) *luckygifteventsv1.EventEnvelope {
t.Helper()
body, err := proto.Marshal(event)
if err != nil {
t.Fatalf("marshal lucky gift drawn: %v", err)
}
return &luckygifteventsv1.EventEnvelope{
EventId: "lucky_gift_drawn:" + event.GetDrawId(),
EventType: luckygiftmq.EventTypeLuckyGiftDrawn,
AppCode: "lalu",
DrawId: event.GetDrawId(),
OccurredAtMs: event.GetDrawCreatedAtMs(),
Body: body,
}
}
func containsCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) bool { func containsCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) bool {
return findCustomMessageEvent(messages, eventID) != nil return findCustomMessageEvent(messages, eventID) != nil
} }
@ -587,14 +843,16 @@ func (r *fakeRepository) MarkBroadcastOutboxFailed(_ context.Context, eventID st
type fakePublisher struct { type fakePublisher struct {
messages []tencentim.CustomGroupMessage messages []tencentim.CustomGroupMessage
ensuredGroups []tencentim.GroupSpec ensuredGroups []tencentim.GroupSpec
ensuredAppCodes []string
ensureErrors map[string]error ensureErrors map[string]error
deletedGroupIDs []string deletedGroupIDs []string
deletedUserID int64 deletedUserID int64
deleteError error deleteError error
} }
func (p *fakePublisher) EnsureGroup(_ context.Context, spec tencentim.GroupSpec) error { func (p *fakePublisher) EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error {
p.ensuredGroups = append(p.ensuredGroups, spec) p.ensuredGroups = append(p.ensuredGroups, spec)
p.ensuredAppCodes = append(p.ensuredAppCodes, appcode.FromContext(ctx))
return p.ensureErrors[spec.GroupID] return p.ensureErrors[spec.GroupID]
} }

View File

@ -5,6 +5,7 @@ import (
"log/slog" "log/slog"
"time" "time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx" "hyapp/pkg/logx"
) )
@ -40,7 +41,29 @@ func (s *Service) RunWorker(ctx context.Context, options WorkerOptions) {
} }
func (s *Service) reconcileBroadcastGroups(ctx context.Context, options WorkerOptions) { func (s *Service) reconcileBroadcastGroups(ctx context.Context, options WorkerOptions) {
if _, err := s.EnsureBroadcastGroups(ctx); err != nil && ctx.Err() == nil { appCodes, err := s.broadcastOutboxAppCodes(ctx)
logx.Error(ctx, "broadcast_group_reconcile_failed", err, slog.String("worker_id", options.WorkerID)) if err != nil {
if ctx.Err() == nil {
logx.Error(ctx, "broadcast_group_reconcile_apps_failed", err, slog.String("worker_id", options.WorkerID))
}
return
}
// 没有历史 outbox 的当前 App 仍需要全局/区域群;其他 App 从实际 outbox 租户键发现,避免后台 worker 永远只维护默认 lalu。
currentApp := appcode.FromContext(ctx)
foundCurrent := false
for _, appCode := range appCodes {
if appCode == currentApp {
foundCurrent = true
break
}
}
if !foundCurrent {
appCodes = append(appCodes, currentApp)
}
for _, appCode := range appCodes {
appCtx := appcode.WithContext(ctx, appCode)
if _, err := s.EnsureBroadcastGroups(appCtx); err != nil && ctx.Err() == nil {
logx.Error(appCtx, "broadcast_group_reconcile_failed", err, slog.String("worker_id", options.WorkerID), slog.String("app_code", appCode))
}
} }
} }

View File

@ -54,6 +54,25 @@ func (r *Repository) SaveBroadcastOutbox(ctx context.Context, record broadcastdo
return false, existing, nil return false, existing, nil
} }
// ListBroadcastOutboxAppCodes 枚举实际存在的租户键,常驻 worker 再按 App 使用现有 claim 前缀索引。
// 主键以 app_code 开头DISTINCT 只枚举少量租户,不需要为了跨 App 投递移除 app_code 条件扫描热表。
func (r *Repository) ListBroadcastOutboxAppCodes(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT app_code FROM im_broadcast_outbox ORDER BY app_code`)
if err != nil {
return nil, err
}
defer rows.Close()
appCodes := make([]string, 0, 4)
for rows.Next() {
var appCode string
if err := rows.Scan(&appCode); err != nil {
return nil, err
}
appCodes = append(appCodes, appCode)
}
return appCodes, rows.Err()
}
// GetBroadcastOutbox 按幂等键读取一条播报 outbox测试和后台排障都会用到。 // GetBroadcastOutbox 按幂等键读取一条播报 outbox测试和后台排障都会用到。
func (r *Repository) GetBroadcastOutbox(ctx context.Context, eventID string) (broadcastdomain.OutboxRecord, bool, error) { func (r *Repository) GetBroadcastOutbox(ctx context.Context, eventID string) (broadcastdomain.OutboxRecord, bool, error) {
row := r.db.QueryRowContext(ctx, ` row := r.db.QueryRowContext(ctx, `

View File

@ -161,7 +161,8 @@ tencent_rtc:
# 语音房场景固定使用 VoiceChatRoom所有客户端必须保持一致。 # 语音房场景固定使用 VoiceChatRoom所有客户端必须保持一致。
app_scene: "voice_chat_room" app_scene: "voice_chat_room"
# 腾讯 RTC 控制台配置的事件回调地址,用于房间退出和音频发布状态同步。 # 腾讯 RTC 控制台配置的事件回调地址,用于房间退出和音频发布状态同步。
callback_url: "https://api.example.com/api/v1/tencent-rtc/callback?app_code=default" # 多 App 共用腾讯 RTC 应用时不要拼 app_codegateway 会按回调 room_id 向 room-service 解析真实租户。
callback_url: "https://api.example.com/api/v1/tencent-rtc/callback"
# 腾讯 RTC 回调签名 key必须和控制台配置保持一致不要复用 UserSig SecretKey。 # 腾讯 RTC 回调签名 key必须和控制台配置保持一致不要复用 UserSig SecretKey。
callback_sign_key: "TENCENT_RTC_CALLBACK_SIGN_KEY" callback_sign_key: "TENCENT_RTC_CALLBACK_SIGN_KEY"

View File

@ -40,6 +40,7 @@ type RoomClient interface {
type RoomGuardClient interface { type RoomGuardClient interface {
CheckSpeakPermission(ctx context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error) CheckSpeakPermission(ctx context.Context, req *roomv1.CheckSpeakPermissionRequest) (*roomv1.CheckSpeakPermissionResponse, error)
VerifyRoomPresence(ctx context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error) VerifyRoomPresence(ctx context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error)
ResolveRoomAppCode(ctx context.Context, req *roomv1.ResolveRoomAppCodeRequest) (*roomv1.ResolveRoomAppCodeResponse, error)
} }
// RoomQueryClient 抽象 gateway 对 room-service 读模型查询的依赖。 // RoomQueryClient 抽象 gateway 对 room-service 读模型查询的依赖。
@ -195,6 +196,10 @@ func (c *grpcRoomGuardClient) VerifyRoomPresence(ctx context.Context, req *roomv
return c.client.VerifyRoomPresence(ctx, req) return c.client.VerifyRoomPresence(ctx, req)
} }
func (c *grpcRoomGuardClient) ResolveRoomAppCode(ctx context.Context, req *roomv1.ResolveRoomAppCodeRequest) (*roomv1.ResolveRoomAppCodeResponse, error) {
return c.client.ResolveRoomAppCode(ctx, req)
}
func (c *grpcRoomQueryClient) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) { func (c *grpcRoomQueryClient) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
return c.client.ListRooms(ctx, req) return c.client.ListRooms(ctx, req)
} }

View File

@ -88,8 +88,8 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
roomID, ok := parseTencentRTCRoomID(body.EventInfo.RoomID) roomID, ok := parseTencentRTCRoomID(body.EventInfo.RoomID)
userID, userOK := parseTencentIMUserID(body.EventInfo.UserID) userID, userOK := parseTencentIMUserID(body.EventInfo.UserID)
eventTimeMS := tencentRTCEventTimeMS(body) eventTimeMS := tencentRTCEventTimeMS(body)
logCtx := logx.With(request.Context(), slog.String("request_id", httpkit.RequestIDFromContext(request.Context())), slog.String("app_code", tencentRTCCallbackAppCode(request))) logCtx := logx.With(request.Context(), slog.String("request_id", httpkit.RequestIDFromContext(request.Context())))
if !ok || !userOK || eventTimeMS <= 0 || h.roomClient == nil { if !ok || !userOK || eventTimeMS <= 0 || h.roomClient == nil || h.roomGuardClient == nil {
logx.Warn(logCtx, "gateway_rtc_callback_ignored", logx.Warn(logCtx, "gateway_rtc_callback_ignored",
slog.String("provider", "tencent"), slog.String("provider", "tencent"),
slog.Int("event_group", body.EventGroupID), slog.Int("event_group", body.EventGroupID),
@ -102,13 +102,37 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
return return
} }
// 腾讯回调只携带 SDKAppID 和 room_id线上当前多个 App 共用腾讯应用URL app_code 无法代表真实房间租户。
// 房间归属必须回到 room-service 的持久事实解析,避免 Fami 回调被固定 lalu 参数写入错误 Room Cell。
appResp, err := h.roomGuardClient.ResolveRoomAppCode(request.Context(), &roomv1.ResolveRoomAppCodeRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
RoomId: roomID,
})
if err != nil {
if tencentRTCCallbackCanAckBusinessError(err) {
logx.Warn(logCtx, "gateway_rtc_callback_room_app_ignored", slog.String("room_id", roomID), slog.String("reason", string(xerr.ReasonFromGRPC(err))))
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
return
}
logx.Error(logCtx, "gateway_rtc_callback_room_app_error", err, slog.String("room_id", roomID))
writeTencentRTCCallback(writer, http.StatusInternalServerError, 1, "room service error")
return
}
resolvedAppCode := appcode.Normalize(appResp.GetAppCode())
if strings.TrimSpace(appResp.GetAppCode()) == "" {
logx.Warn(logCtx, "gateway_rtc_callback_room_app_ignored", slog.String("room_id", roomID), slog.String("reason", "app_code_empty"))
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
return
}
logCtx = logx.With(logCtx, slog.String("app_code", resolvedAppCode))
resp, err := h.roomClient.ApplyRTCEvent(request.Context(), &roomv1.ApplyRTCEventRequest{ resp, err := h.roomClient.ApplyRTCEvent(request.Context(), &roomv1.ApplyRTCEventRequest{
Meta: &roomv1.RequestMeta{ Meta: &roomv1.RequestMeta{
RequestId: httpkit.RequestIDFromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()),
CommandId: tencentRTCCallbackCommandID(body, roomID, userID, eventTimeMS), CommandId: tencentRTCCallbackCommandID(body, roomID, userID, eventTimeMS),
ActorUserId: userID, ActorUserId: userID,
RoomId: roomID, RoomId: roomID,
AppCode: tencentRTCCallbackAppCode(request), AppCode: resolvedAppCode,
GatewayNodeId: "gateway-local", GatewayNodeId: "gateway-local",
SessionId: fmt.Sprintf("tencent-rtc-%d", body.EventType), SessionId: fmt.Sprintf("tencent-rtc-%d", body.EventType),
SentAtMs: time.Now().UnixMilli(), SentAtMs: time.Now().UnixMilli(),
@ -242,14 +266,6 @@ func tencentRTCCallbackDigest(body tencentRTCCallbackBody, roomID string, userID
return hex.EncodeToString(sum[:16]) return hex.EncodeToString(sum[:16])
} }
func tencentRTCCallbackAppCode(request *http.Request) string {
if raw := strings.TrimSpace(request.URL.Query().Get("app_code")); raw != "" {
return appcode.Normalize(raw)
}
return appcode.FromContext(request.Context())
}
func tencentRTCCallbackCanAckBusinessError(err error) bool { func tencentRTCCallbackCanAckBusinessError(err error) bool {
reason := xerr.ReasonFromGRPC(err) reason := xerr.ReasonFromGRPC(err)
return reason == xerr.NotFound || reason == xerr.InvalidArgument || reason == xerr.RoomClosed || reason == xerr.PermissionDenied return reason == xerr.NotFound || reason == xerr.InvalidArgument || reason == xerr.RoomClosed || reason == xerr.PermissionDenied

View File

@ -2799,8 +2799,10 @@ func valueOfString(value *string) string {
type fakeRoomGuardClient struct { type fakeRoomGuardClient struct {
speakResp *roomv1.CheckSpeakPermissionResponse speakResp *roomv1.CheckSpeakPermissionResponse
presenceResp *roomv1.VerifyRoomPresenceResponse presenceResp *roomv1.VerifyRoomPresenceResponse
roomAppResp *roomv1.ResolveRoomAppCodeResponse
lastSpeak *roomv1.CheckSpeakPermissionRequest lastSpeak *roomv1.CheckSpeakPermissionRequest
lastPresence *roomv1.VerifyRoomPresenceRequest lastPresence *roomv1.VerifyRoomPresenceRequest
lastRoomApp *roomv1.ResolveRoomAppCodeRequest
err error err error
} }
@ -2828,6 +2830,17 @@ func (f *fakeRoomGuardClient) VerifyRoomPresence(_ context.Context, req *roomv1.
return &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}, nil return &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 1}, nil
} }
func (f *fakeRoomGuardClient) ResolveRoomAppCode(_ context.Context, req *roomv1.ResolveRoomAppCodeRequest) (*roomv1.ResolveRoomAppCodeResponse, error) {
f.lastRoomApp = req
if f.err != nil {
return nil, f.err
}
if f.roomAppResp != nil {
return f.roomAppResp, nil
}
return &roomv1.ResolveRoomAppCodeResponse{AppCode: "lalu"}, nil
}
type fakeObjectUploader struct { type fakeObjectUploader struct {
lastKey string lastKey string
lastContent []byte lastContent []byte
@ -11629,7 +11642,8 @@ func TestTencentRTCTokenFailsClosedForConfigAndUpstreamErrors(t *testing.T) {
func TestTencentRTCCallbackVerifiesSignatureAndAppliesEvent(t *testing.T) { func TestTencentRTCCallbackVerifiesSignatureAndAppliesEvent(t *testing.T) {
roomClient := &fakeRoomClient{} roomClient := &fakeRoomClient{}
handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{}) guard := &fakeRoomGuardClient{roomAppResp: &roomv1.ResolveRoomAppCodeResponse{AppCode: "fami"}}
handler := NewHandlerWithConfig(roomClient, guard, nil, nil, TencentIMConfig{})
handler.SetTencentRTC(TencentRTCConfig{ handler.SetTencentRTC(TencentRTCConfig{
SDKAppID: 1400000000, SDKAppID: 1400000000,
CallbackSignKey: "callback-key", CallbackSignKey: "callback-key",
@ -11652,9 +11666,12 @@ func TestTencentRTCCallbackVerifiesSignatureAndAppliesEvent(t *testing.T) {
t.Fatalf("RTC event fields mismatch: %+v", roomClient.lastRTCEvent) t.Fatalf("RTC event fields mismatch: %+v", roomClient.lastRTCEvent)
} }
meta := roomClient.lastRTCEvent.GetMeta() meta := roomClient.lastRTCEvent.GetMeta()
if meta.GetRoomId() != "room_1001" || meta.GetActorUserId() != 42 || meta.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-rtc-callback") || meta.GetAppCode() != "lalu" { if meta.GetRoomId() != "room_1001" || meta.GetActorUserId() != 42 || meta.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-rtc-callback") || meta.GetAppCode() != "fami" {
t.Fatalf("RTC event meta mismatch: %+v", meta) t.Fatalf("RTC event meta mismatch: %+v", meta)
} }
if guard.lastRoomApp == nil || guard.lastRoomApp.GetRoomId() != "room_1001" {
t.Fatalf("RTC callback must resolve app from room owner: %+v", guard.lastRoomApp)
}
if !strings.HasPrefix(meta.GetCommandId(), "cmd_") || !strings.HasPrefix(roomClient.lastRTCEvent.GetExternalEventId(), "rtc_evt_") { if !strings.HasPrefix(meta.GetCommandId(), "cmd_") || !strings.HasPrefix(roomClient.lastRTCEvent.GetExternalEventId(), "rtc_evt_") {
t.Fatalf("RTC callback must derive stable ids: meta=%+v event=%+v", meta, roomClient.lastRTCEvent) t.Fatalf("RTC callback must derive stable ids: meta=%+v event=%+v", meta, roomClient.lastRTCEvent)
} }

View File

@ -12,6 +12,21 @@ health_http_addr: ":13113"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_lucky_gift?parseTime=true&charset=utf8mb4&loc=UTC" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_lucky_gift?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_auto_migrate: true mysql_auto_migrate: true
wallet_service_addr: "wallet-service:13004" wallet_service_addr: "wallet-service:13004"
rocketmq:
enabled: true
name_servers: ["rocketmq-namesrv:9876"]
name_server_domain: ""
access_key: ""
secret_key: ""
security_token: ""
namespace: ""
vip_channel: false
lucky_gift_outbox:
enabled: true
topic: "hyapp_lucky_gift_outbox"
producer_group: "hyapp-lucky-gift-outbox-producer"
send_timeout: "5s"
retry: 2
lucky_gift_worker: lucky_gift_worker:
enabled: true enabled: true
worker_poll_interval: "1s" worker_poll_interval: "1s"

View File

@ -20,6 +20,23 @@ mysql_auto_migrate: false
# 中奖入账仍由 wallet-service 负责lucky-gift-service 只持有抽奖事实和待补偿 outbox。 # 中奖入账仍由 wallet-service 负责lucky-gift-service 只持有抽奖事实和待补偿 outbox。
wallet_service_addr: "wallet-service.internal:13004" wallet_service_addr: "wallet-service.internal:13004"
# 中奖事实只从 lucky_gift_outbox 发布;真实接入凭据必须由部署环境注入,不能写入可提交配置。
rocketmq:
enabled: true
name_servers: []
name_server_domain: "http://MQ_INST_REPLACE.mq-internet-access.mq-internet.tencenttdmq.com:80"
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
security_token: ""
namespace: "MQ_INST_REPLACE"
vip_channel: false
lucky_gift_outbox:
enabled: true
topic: "hyapp_lucky_gift_outbox"
producer_group: "hyapp-lucky-gift-outbox-producer"
send_timeout: "5s"
retry: 2
lucky_gift_worker: lucky_gift_worker:
enabled: true enabled: true
worker_poll_interval: "1s" worker_poll_interval: "1s"

View File

@ -12,6 +12,21 @@ health_http_addr: ":13113"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_lucky_gift?parseTime=true&charset=utf8mb4&loc=UTC" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_lucky_gift?parseTime=true&charset=utf8mb4&loc=UTC"
mysql_auto_migrate: true mysql_auto_migrate: true
wallet_service_addr: "127.0.0.1:13004" wallet_service_addr: "127.0.0.1:13004"
rocketmq:
enabled: true
name_servers: ["127.0.0.1:19876"]
name_server_domain: ""
access_key: ""
secret_key: ""
security_token: ""
namespace: ""
vip_channel: false
lucky_gift_outbox:
enabled: true
topic: "hyapp_lucky_gift_outbox"
producer_group: "hyapp-lucky-gift-outbox-producer"
send_timeout: "5s"
retry: 2
lucky_gift_worker: lucky_gift_worker:
enabled: true enabled: true
worker_poll_interval: "1s" worker_poll_interval: "1s"

View File

@ -14,10 +14,13 @@ import (
"hyapp/pkg/grpchealth" "hyapp/pkg/grpchealth"
"hyapp/pkg/healthhttp" "hyapp/pkg/healthhttp"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
serviceapp "hyapp/pkg/servicekit/app" serviceapp "hyapp/pkg/servicekit/app"
servicegrpc "hyapp/pkg/servicekit/grpcserver" servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health" servicehealth "hyapp/pkg/servicekit/health"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/services/lucky-gift-service/internal/config" "hyapp/services/lucky-gift-service/internal/config"
"hyapp/services/lucky-gift-service/internal/integration"
luckygiftservice "hyapp/services/lucky-gift-service/internal/service/luckygift" luckygiftservice "hyapp/services/lucky-gift-service/internal/service/luckygift"
mysqlstorage "hyapp/services/lucky-gift-service/internal/storage/mysql" mysqlstorage "hyapp/services/lucky-gift-service/internal/storage/mysql"
grpcserver "hyapp/services/lucky-gift-service/internal/transport/grpc" grpcserver "hyapp/services/lucky-gift-service/internal/transport/grpc"
@ -26,16 +29,17 @@ import (
// App 装配 lucky-gift-service 的 owner 进程。 // App 装配 lucky-gift-service 的 owner 进程。
// 该进程是幸运礼物规则、抽奖、外部 App 接入和 outbox 的唯一写入者;其他服务只持有 gRPC client。 // 该进程是幸运礼物规则、抽奖、外部 App 接入和 outbox 的唯一写入者;其他服务只持有 gRPC client。
type App struct { type App struct {
cfg config.Config cfg config.Config
server *grpc.Server server *grpc.Server
listener net.Listener listener net.Listener
health *grpchealth.ServingChecker health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server healthHTTP *healthhttp.Server
repository *mysqlstorage.Repository repository *mysqlstorage.Repository
walletConn *grpc.ClientConn walletConn *grpc.ClientConn
service *luckygiftservice.Service eventProducer *rocketmqx.Producer
workers *serviceapp.BackgroundGroup service *luckygiftservice.Service
closeOnce sync.Once workers *serviceapp.BackgroundGroup
closeOnce sync.Once
} }
func New(cfg config.Config) (*App, error) { func New(cfg config.Config) (*App, error) {
@ -58,15 +62,30 @@ func New(cfg config.Config) (*App, error) {
_ = repository.Close() _ = repository.Close()
return nil, err return nil, err
} }
var eventProducer *rocketmqx.Producer
var eventPublisher luckygiftservice.EventPublisher
if cfg.RocketMQ.LuckyGiftOutbox.Enabled {
eventProducer, err = rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ))
if err != nil {
_ = walletConn.Close()
_ = repository.Close()
return nil, err
}
eventPublisher = integration.NewRocketMQEventPublisher(eventProducer, cfg.RocketMQ.LuckyGiftOutbox.Topic)
}
listener, err := net.Listen("tcp", cfg.GRPCAddr) listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil { if err != nil {
servicemq.ShutdownProducers([]*rocketmqx.Producer{eventProducer})
_ = walletConn.Close() _ = walletConn.Close()
_ = repository.Close() _ = repository.Close()
return nil, err return nil, err
} }
server := servicegrpc.New("lucky-gift-service") server := servicegrpc.New("lucky-gift-service")
svc := luckygiftservice.New(repository, luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn))) svc := luckygiftservice.New(repository,
luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn)),
luckygiftservice.WithEventPublisher(eventPublisher),
)
luckygiftv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(svc)) luckygiftv1.RegisterLuckyGiftServiceServer(server, grpcserver.NewLuckyGiftServer(svc))
luckygiftv1.RegisterAdminLuckyGiftServiceServer(server, grpcserver.NewAdminLuckyGiftServer(svc)) luckygiftv1.RegisterAdminLuckyGiftServiceServer(server, grpcserver.NewAdminLuckyGiftServer(svc))
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{ health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
@ -80,21 +99,23 @@ func New(cfg config.Config) (*App, error) {
}) })
if err != nil { if err != nil {
_ = listener.Close() _ = listener.Close()
servicemq.ShutdownProducers([]*rocketmqx.Producer{eventProducer})
_ = walletConn.Close() _ = walletConn.Close()
_ = repository.Close() _ = repository.Close()
return nil, err return nil, err
} }
return &App{ return &App{
cfg: cfg, cfg: cfg,
server: server, server: server,
listener: listener, listener: listener,
health: health, health: health,
healthHTTP: healthHTTP, healthHTTP: healthHTTP,
repository: repository, repository: repository,
walletConn: walletConn, walletConn: walletConn,
service: svc, eventProducer: eventProducer,
workers: serviceapp.NewBackground(context.Background()), service: svc,
workers: serviceapp.NewBackground(context.Background()),
}, nil }, nil
} }
@ -106,6 +127,10 @@ func (a *App) Run() error {
} }
}() }()
} }
if err := servicemq.StartProducers([]*rocketmqx.Producer{a.eventProducer}); err != nil {
a.health.MarkStopped()
return err
}
a.health.MarkServing() a.health.MarkServing()
if a.cfg.LuckyGiftWorker.Enabled && a.service != nil { if a.cfg.LuckyGiftWorker.Enabled && a.service != nil {
options := luckyGiftWorkerOptions(a.cfg.NodeID, a.cfg.LuckyGiftWorker) options := luckyGiftWorkerOptions(a.cfg.NodeID, a.cfg.LuckyGiftWorker)
@ -128,6 +153,7 @@ func (a *App) Run() error {
a.workers.StopAndWait() a.workers.StopAndWait()
} }
a.health.MarkStopped() a.health.MarkStopped()
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.eventProducer})
if errors.Is(err, grpc.ErrServerStopped) { if errors.Is(err, grpc.ErrServerStopped) {
return nil return nil
} }
@ -151,12 +177,30 @@ func (a *App) Close() {
if a.walletConn != nil { if a.walletConn != nil {
_ = a.walletConn.Close() _ = a.walletConn.Close()
} }
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.eventProducer})
if a.repository != nil { if a.repository != nil {
_ = a.repository.Close() _ = a.repository.Close()
} }
}) })
} }
func rocketMQProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig {
return rocketmqx.ProducerConfig{
EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers,
NameServerDomain: cfg.NameServerDomain,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
SecurityToken: cfg.SecurityToken,
Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel,
},
GroupName: cfg.LuckyGiftOutbox.ProducerGroup,
SendTimeout: cfg.LuckyGiftOutbox.SendTimeout,
Retry: cfg.LuckyGiftOutbox.Retry,
}
}
func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luckygiftservice.WorkerOptions { func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luckygiftservice.WorkerOptions {
return luckygiftservice.WorkerOptions{ return luckygiftservice.WorkerOptions{
WorkerID: nodeID, WorkerID: nodeID,

View File

@ -21,11 +21,36 @@ type Config struct {
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
WalletServiceAddr string `yaml:"wallet_service_addr"` WalletServiceAddr string `yaml:"wallet_service_addr"`
LuckyGiftWorker LuckyGiftWorkerConfig `yaml:"lucky_gift_worker"` LuckyGiftWorker LuckyGiftWorkerConfig `yaml:"lucky_gift_worker"`
Log logx.Config `yaml:"log"` // RocketMQ 只发布 lucky-gift-service 自有 outbox 事实room/activity 不能直读幸运礼物数据库。
RocketMQ RocketMQConfig `yaml:"rocketmq"`
Log logx.Config `yaml:"log"`
}
// RocketMQConfig 保存幸运礼物 owner topic 的连接身份。
type RocketMQConfig struct {
Enabled bool `yaml:"enabled"`
NameServers []string `yaml:"name_servers"`
NameServerDomain string `yaml:"name_server_domain"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
SecurityToken string `yaml:"security_token"`
Namespace string `yaml:"namespace"`
VIPChannel bool `yaml:"vip_channel"`
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
}
// LuckyGiftOutboxMQConfig 控制 granted 中奖事实发布;地址和凭据来自运行环境,业务开关在可运行配置中默认开启。
type LuckyGiftOutboxMQConfig struct {
Enabled bool `yaml:"enabled"`
Topic string `yaml:"topic"`
ProducerGroup string `yaml:"producer_group"`
SendTimeout time.Duration `yaml:"send_timeout"`
Retry int `yaml:"retry"`
} }
// LuckyGiftWorkerConfig 控制 draw outbox 补偿和统计快照刷新。 // LuckyGiftWorkerConfig 控制 draw outbox 补偿和统计快照刷新。
// 外部 App 抽奖不走 HyApp wallet但仍写 outbox 给审计/统计;内部房间抽奖中奖后由 worker 兜底补发钱包和 IM。 // 外部 App 抽奖不走 HyApp wallet但仍写 outbox 给审计/统计;内部房间抽奖中奖后由 worker 兜底返奖,
// 并在 granted 收敛后把 owner 事实可靠发布给 room/activity 的独立消费者。
type LuckyGiftWorkerConfig struct { type LuckyGiftWorkerConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
WorkerPollInterval time.Duration `yaml:"worker_poll_interval"` WorkerPollInterval time.Duration `yaml:"worker_poll_interval"`
@ -42,7 +67,7 @@ type LuckyGiftWorkerConfig struct {
// OutboxRetentionConfig 控制 lucky_gift_outbox 中 delivered 历史事件的小批量定期删除。 // OutboxRetentionConfig 控制 lucky_gift_outbox 中 delivered 历史事件的小批量定期删除。
// 只清理 deliveredpending/retryable/delivering 仍在投递生命周期内failed 留给人工补偿。 // 只清理 deliveredpending/retryable/delivering 仍在投递生命周期内failed 留给人工补偿。
// 抽奖事实和审计都在 lucky_draw_recordsoutbox 没有下游读方,默认开启 // 抽奖事实和审计都在 lucky_draw_records;下游只读 RocketMQ owner 事实,不直读本表,因此 delivered 行可定期清理
type OutboxRetentionConfig struct { type OutboxRetentionConfig struct {
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
// MaxAge 是 delivered 事件按 updated_at_ms 计算的保留时长,早于 now-MaxAge 的才会删除。 // MaxAge 是 delivered 事件按 updated_at_ms 计算的保留时长,早于 now-MaxAge 的才会删除。
@ -81,6 +106,7 @@ func Default() Config {
BatchSize: 500, BatchSize: 500,
}, },
}, },
RocketMQ: defaultRocketMQConfig(),
Log: logx.Config{ Log: logx.Config{
Level: "info", Level: "info",
Format: "json", Format: "json",
@ -89,6 +115,19 @@ func Default() Config {
} }
} }
func defaultRocketMQConfig() RocketMQConfig {
return RocketMQConfig{
Enabled: false,
LuckyGiftOutbox: LuckyGiftOutboxMQConfig{
Enabled: false,
Topic: "hyapp_lucky_gift_outbox",
ProducerGroup: "hyapp-lucky-gift-outbox-producer",
SendTimeout: 5 * time.Second,
Retry: 2,
},
}
}
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
cfg := Default() cfg := Default()
if strings.TrimSpace(path) == "" { if strings.TrimSpace(path) == "" {
@ -159,5 +198,54 @@ func Load(path string) (Config, error) {
// 保留窗口低于一天基本可以断定是把单位写错(比如 30 写成 30ns/30s宁可拒绝启动。 // 保留窗口低于一天基本可以断定是把单位写错(比如 30 写成 30ns/30s宁可拒绝启动。
return Config{}, fmt.Errorf("lucky_gift_worker retention max_age %s is below the 24h safety floor", cfg.LuckyGiftWorker.Retention.MaxAge) return Config{}, fmt.Errorf("lucky_gift_worker retention max_age %s is below the 24h safety floor", cfg.LuckyGiftWorker.Retention.MaxAge)
} }
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
if err != nil {
return Config{}, err
}
cfg.RocketMQ = rocketMQ
return cfg, nil return cfg, nil
} }
func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
defaults := defaultRocketMQConfig()
cfg.NameServers = normalizeStringSlice(cfg.NameServers)
cfg.NameServerDomain = strings.TrimSpace(cfg.NameServerDomain)
cfg.AccessKey = strings.TrimSpace(cfg.AccessKey)
cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
cfg.SecurityToken = strings.TrimSpace(cfg.SecurityToken)
cfg.Namespace = strings.TrimSpace(cfg.Namespace)
if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" {
cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic
}
if cfg.LuckyGiftOutbox.ProducerGroup = strings.TrimSpace(cfg.LuckyGiftOutbox.ProducerGroup); cfg.LuckyGiftOutbox.ProducerGroup == "" {
cfg.LuckyGiftOutbox.ProducerGroup = defaults.LuckyGiftOutbox.ProducerGroup
}
if cfg.LuckyGiftOutbox.SendTimeout <= 0 {
cfg.LuckyGiftOutbox.SendTimeout = defaults.LuckyGiftOutbox.SendTimeout
}
if cfg.LuckyGiftOutbox.Retry < 0 {
cfg.LuckyGiftOutbox.Retry = defaults.LuckyGiftOutbox.Retry
}
if cfg.LuckyGiftOutbox.Enabled {
cfg.Enabled = true
}
if cfg.Enabled {
if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" {
return RocketMQConfig{}, fmt.Errorf("rocketmq name_servers or name_server_domain is required")
}
if (cfg.AccessKey == "") != (cfg.SecretKey == "") {
return RocketMQConfig{}, fmt.Errorf("rocketmq access_key and secret_key must be configured together")
}
}
return cfg, nil
}
func normalizeStringSlice(input []string) []string {
out := make([]string, 0, len(input))
for _, value := range input {
if value = strings.TrimSpace(value); value != "" {
out = append(out, value)
}
}
return out
}

View File

@ -28,6 +28,47 @@ func TestLoadDefaultsEnableOutboxRetention(t *testing.T) {
} }
} }
func TestLoadRunnableConfigsPublishLuckyGiftOwnerOutbox(t *testing.T) {
for _, path := range []string{"../../configs/config.yaml", "../../configs/config.docker.yaml", "../../configs/config.tencent.example.yaml"} {
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load(%s) failed: %v", path, err)
}
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.LuckyGiftOutbox.Enabled {
t.Fatalf("%s must enable lucky owner outbox: %+v", path, cfg.RocketMQ)
}
if cfg.RocketMQ.LuckyGiftOutbox.Topic != "hyapp_lucky_gift_outbox" || cfg.RocketMQ.LuckyGiftOutbox.ProducerGroup == "" {
t.Fatalf("%s has incomplete lucky owner routing: %+v", path, cfg.RocketMQ.LuckyGiftOutbox)
}
}
}
func TestLoadLocalUsesComposeRocketMQHostPort(t *testing.T) {
cfg, err := Load("../../configs/config.yaml")
if err != nil {
t.Fatalf("load local config: %v", err)
}
if len(cfg.RocketMQ.NameServers) != 1 || cfg.RocketMQ.NameServers[0] != "127.0.0.1:19876" {
t.Fatalf("local config must use compose host-mapped nameserver port: %+v", cfg.RocketMQ.NameServers)
}
}
func TestLoadRejectsEnabledRocketMQWithoutEndpoint(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
content := `
rocketmq:
enabled: true
lucky_gift_outbox:
enabled: true
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "name_servers") {
t.Fatalf("Load should reject missing RocketMQ endpoint, got err=%v", err)
}
}
func TestLoadRejectsRetentionMaxAgeBelowSafetyFloor(t *testing.T) { func TestLoadRejectsRetentionMaxAgeBelowSafetyFloor(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml") path := filepath.Join(t.TempDir(), "config.yaml")
content := ` content := `

View File

@ -117,6 +117,11 @@ type DrawCommand struct {
PaidAtMS int64 PaidAtMS int64
VisibleRegionID int64 VisibleRegionID int64
CountryID int64 CountryID int64
// Sender* 是扣费时刻的公开展示快照owner outbox 必须携带快照,避免下游为一条飘屏同步反查 user-service。
SenderName string
SenderAvatar string
SenderDisplayUserID string
SenderPrettyDisplayUserID string
} }
type CheckResult struct { type CheckResult struct {
@ -264,4 +269,6 @@ type DrawOutbox struct {
type DrawRewardState struct { type DrawRewardState struct {
AllGranted bool AllGranted bool
WalletTransactionID string WalletTransactionID string
// RewardGrantedAtMS 取 granted draw 的稳定 updated_at_msowner MQ 重投必须保持同一事实正文不漂移。
RewardGrantedAtMS int64
} }

View File

@ -0,0 +1,55 @@
package integration
import (
"context"
"errors"
"strings"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
"hyapp/pkg/luckygiftmq"
"hyapp/pkg/rocketmqx"
)
// RocketMQEventPublisher 把已经完成钱包入账的幸运礼物事实发布到 owner topic。
// 它不读取 outbox 表claim、重试和 delivered 状态仍由 lucky-gift-service 的 settlement worker 独占。
type RocketMQEventPublisher struct {
producer *rocketmqx.Producer
topic string
}
func NewRocketMQEventPublisher(producer *rocketmqx.Producer, topic string) *RocketMQEventPublisher {
return &RocketMQEventPublisher{producer: producer, topic: strings.TrimSpace(topic)}
}
// PublishLuckyGiftEvent 只有在 Broker ACK 后返回成功worker 随后才能把 owner outbox 标为 delivered。
func (p *RocketMQEventPublisher) PublishLuckyGiftEvent(ctx context.Context, envelope *luckygifteventsv1.EventEnvelope) error {
if p == nil || p.producer == nil {
return errors.New("lucky gift RocketMQ producer is not configured")
}
message, err := luckyGiftRocketMessage(p.topic, envelope)
if err != nil {
return err
}
return p.producer.SendSync(ctx, message)
}
func luckyGiftRocketMessage(topic string, envelope *luckygifteventsv1.EventEnvelope) (rocketmqx.Message, error) {
topic = strings.TrimSpace(topic)
if topic == "" {
return rocketmqx.Message{}, errors.New("lucky gift outbox topic is required")
}
body, err := luckygiftmq.EncodeLuckyGiftOutboxMessage(envelope)
if err != nil {
return rocketmqx.Message{}, err
}
tag, err := luckygiftmq.EventTypeTag(envelope.GetEventType())
if err != nil {
return rocketmqx.Message{}, err
}
return rocketmqx.Message{
Topic: topic,
Tag: tag,
Keys: []string{envelope.GetEventId(), envelope.GetDrawId()},
Body: body,
}, nil
}

View File

@ -0,0 +1,56 @@
package integration
import (
"testing"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
"hyapp/pkg/luckygiftmq"
)
func TestLuckyGiftRocketMessageUsesOwnerEventIdentity(t *testing.T) {
fact, err := proto.Marshal(&luckygifteventsv1.LuckyGiftDrawn{
DrawId: "draw-10x",
CommandId: "cmd-10x",
RoomId: "room-1",
GiftId: "43",
GiftCount: 1,
SenderUserId: 123456,
TargetUserId: 654321,
CoinSpent: 100,
MultiplierPpm: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM,
EffectiveRewardCoins: 1000,
RewardStatus: "granted",
DrawCreatedAtMs: 1783940398274,
RewardGrantedAtMs: 1783940398374,
})
if err != nil {
t.Fatalf("marshal lucky fact: %v", err)
}
envelope := &luckygifteventsv1.EventEnvelope{
EventId: "lucky_gift_drawn:draw-10x",
EventType: luckygiftmq.EventTypeLuckyGiftDrawn,
AppCode: "lalu",
DrawId: "draw-10x",
OccurredAtMs: 1783940398274,
Body: fact,
}
message, err := luckyGiftRocketMessage("hyapp_lucky_gift_outbox", envelope)
if err != nil {
t.Fatalf("build RocketMQ message: %v", err)
}
if message.Topic != "hyapp_lucky_gift_outbox" || message.Tag != luckygiftmq.EventTypeLuckyGiftDrawn {
t.Fatalf("unexpected routing: topic=%q tag=%q", message.Topic, message.Tag)
}
if len(message.Keys) != 2 || message.Keys[0] != envelope.GetEventId() || message.Keys[1] != envelope.GetDrawId() {
t.Fatalf("unexpected keys: %v", message.Keys)
}
decoded, _, err := luckygiftmq.DecodeLuckyGiftOutboxMessage(message.Body)
if err != nil {
t.Fatalf("decode RocketMQ body: %v", err)
}
if decoded.GetEventId() != envelope.GetEventId() || decoded.GetOccurredAtMs() != envelope.GetOccurredAtMs() {
t.Fatalf("decoded envelope mismatch: %+v", decoded)
}
}

View File

@ -0,0 +1,174 @@
package luckygift
import (
"context"
"errors"
"testing"
"time"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
"hyapp/pkg/luckygiftmq"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
type captureLuckyGiftEventPublisher struct {
envelope *luckygifteventsv1.EventEnvelope
err error
}
func (p *captureLuckyGiftEventPublisher) PublishLuckyGiftEvent(_ context.Context, envelope *luckygifteventsv1.EventEnvelope) error {
p.envelope = proto.Clone(envelope).(*luckygifteventsv1.EventEnvelope)
return p.err
}
// settlementEventRepository embeds the full port only to keep this test focused on the methods used by the granted branch.
// Any unexpected repository call still panics through the nil embedded interface and exposes a widened code path immediately.
type settlementEventRepository struct {
Repository
rewardState domain.DrawRewardState
deliveredOutbox string
retryableOutbox string
failedOutbox string
failedDrawUpdate bool
}
func (r *settlementEventRepository) GetLuckyGiftDrawRewardState(context.Context, string, []string) (domain.DrawRewardState, error) {
return r.rewardState, nil
}
func (r *settlementEventRepository) MarkLuckyGiftOutboxDelivered(_ context.Context, event domain.DrawOutbox, _ int64) error {
r.deliveredOutbox = event.OutboxID
return nil
}
func (r *settlementEventRepository) MarkLuckyGiftOutboxRetryable(_ context.Context, event domain.DrawOutbox, _ int, _ int64, _ string, _ int64) error {
r.retryableOutbox = event.OutboxID
return nil
}
func (r *settlementEventRepository) MarkLuckyGiftOutboxFailed(_ context.Context, event domain.DrawOutbox, _ int, _ string, _ int64) error {
r.failedOutbox = event.OutboxID
return nil
}
func (r *settlementEventRepository) MarkLuckyGiftDrawsFailed(context.Context, string, []string, string, int64) error {
r.failedDrawUpdate = true
return nil
}
func TestPublishLuckyGiftDrawnFactCarriesGrantedSnapshot(t *testing.T) {
publisher := &captureLuckyGiftEventPublisher{}
service := New(nil, WithEventPublisher(publisher))
service.SetClock(func() time.Time { return time.UnixMilli(1783940398374).UTC() })
payload := testLuckyGiftDrawnPayload()
if err := service.publishLuckyGiftDrawnFact(context.Background(), payload, luckyGiftRewardReceipt{WalletTransactionID: "wallet-tx-1", RewardGrantedAtMS: 1783940398320}); err != nil {
t.Fatalf("publish lucky gift fact: %v", err)
}
if publisher.envelope == nil || publisher.envelope.GetEventType() != luckygiftmq.EventTypeLuckyGiftDrawn || publisher.envelope.GetOccurredAtMs() != payload.CreatedAtMS {
t.Fatalf("unexpected owner envelope: %+v", publisher.envelope)
}
var fact luckygifteventsv1.LuckyGiftDrawn
if err := proto.Unmarshal(publisher.envelope.GetBody(), &fact); err != nil {
t.Fatalf("decode owner fact: %v", err)
}
if fact.GetRewardStatus() != domain.StatusGranted || fact.GetWalletTransactionId() != "wallet-tx-1" || fact.GetMultiplierPpm() != luckygiftmq.LuckyGiftDisplayMinMultiplierPPM || fact.GetSenderName() != "Lucky Sender" || fact.GetRewardGrantedAtMs() != 1783940398320 {
t.Fatalf("unexpected owner fact: %+v", &fact)
}
}
func TestPublishLuckyGiftDrawnFactIsStableAcrossBrokerRetries(t *testing.T) {
publisher := &captureLuckyGiftEventPublisher{}
service := New(nil, WithEventPublisher(publisher))
payload := testLuckyGiftDrawnPayload()
receipt := luckyGiftRewardReceipt{WalletTransactionID: "wallet-tx-stable", RewardGrantedAtMS: 1783940398320}
service.SetClock(func() time.Time { return time.UnixMilli(1783940400000).UTC() })
if err := service.publishLuckyGiftDrawnFact(context.Background(), payload, receipt); err != nil {
t.Fatalf("first publish: %v", err)
}
first := proto.Clone(publisher.envelope).(*luckygifteventsv1.EventEnvelope)
service.SetClock(func() time.Time { return time.UnixMilli(1783940500000).UTC() })
if err := service.publishLuckyGiftDrawnFact(context.Background(), payload, receipt); err != nil {
t.Fatalf("retry publish: %v", err)
}
if !proto.Equal(first, publisher.envelope) {
t.Fatalf("same event_id changed across retry:\nfirst=%+v\nretry=%+v", first, publisher.envelope)
}
}
func TestGrantedDrawKeepsSettlementRetryableUntilBrokerAcceptsFact(t *testing.T) {
repository := &settlementEventRepository{rewardState: domain.DrawRewardState{AllGranted: true, WalletTransactionID: "wallet-tx-existing", RewardGrantedAtMS: 1783940398320}}
publisher := &captureLuckyGiftEventPublisher{err: errors.New("broker unavailable")}
service := New(repository, WithEventPublisher(publisher))
service.SetClock(func() time.Time { return time.UnixMilli(1783940398374).UTC() })
event := domain.DrawOutbox{AppCode: "lalu", OutboxID: "lucky_reward_draw-10x", EventType: domain.EventTypeLuckyGiftRewardSettlement}
err := service.processRewardSettlementOutbox(context.Background(), event, testLuckyGiftDrawnPayload(), WorkerOptions{MaxRetry: 8})
if err != nil {
t.Fatalf("retry transition should be persisted without returning a second error: %v", err)
}
if repository.retryableOutbox != event.OutboxID || repository.deliveredOutbox != "" || repository.failedOutbox != "" {
t.Fatalf("unexpected outbox transition: retryable=%q delivered=%q failed=%q", repository.retryableOutbox, repository.deliveredOutbox, repository.failedOutbox)
}
if repository.failedDrawUpdate {
t.Fatal("broker failure must never overwrite an already granted draw")
}
if publisher.envelope == nil {
t.Fatal("granted retry must still publish the owner fact")
}
}
func TestGrantedDrawBrokerRetryExhaustionNeverMarksCreditedDrawFailed(t *testing.T) {
repository := &settlementEventRepository{rewardState: domain.DrawRewardState{AllGranted: true, WalletTransactionID: "wallet-tx-existing", RewardGrantedAtMS: 1783940398320}}
publisher := &captureLuckyGiftEventPublisher{err: errors.New("broker outage exceeded realtime retry window")}
service := New(repository, WithEventPublisher(publisher))
service.SetClock(func() time.Time { return time.UnixMilli(1783940398374).UTC() })
event := domain.DrawOutbox{
AppCode: "lalu",
OutboxID: "lucky_reward_draw-10x",
EventType: domain.EventTypeLuckyGiftRewardSettlement,
PayloadJSON: `{"draw_id":"draw-10x"}`,
RetryCount: 7,
}
if err := service.processRewardSettlementOutbox(context.Background(), event, testLuckyGiftDrawnPayload(), WorkerOptions{MaxRetry: 8}); err != nil {
t.Fatalf("terminal outbox transition: %v", err)
}
if repository.failedOutbox != event.OutboxID || repository.retryableOutbox != "" || repository.deliveredOutbox != "" {
t.Fatalf("unexpected terminal transition: failed=%q retryable=%q delivered=%q", repository.failedOutbox, repository.retryableOutbox, repository.deliveredOutbox)
}
if repository.failedDrawUpdate {
t.Fatal("credited draw must remain granted when realtime fact exhausts retries")
}
}
func testLuckyGiftDrawnPayload() luckyGiftDrawnPayload {
return luckyGiftDrawnPayload{
EventType: "lucky_gift_drawn",
EventID: "lucky_gift_drawn:draw-10x",
AppCode: "lalu",
DrawID: "draw-10x",
DrawIDs: []string{"draw-10x"},
CommandID: "cmd-10x",
PoolID: "super_lucky",
RoomID: "room-1",
GiftID: "43",
GiftCount: 1,
UserID: 123456,
SenderUserID: 123456,
TargetUserID: 654321,
SenderName: "Lucky Sender",
SenderAvatar: "https://cdn.example/sender.png",
SenderDisplayUserID: "123456",
VisibleRegionID: 1,
CountryID: 86,
CoinSpent: 100,
MultiplierPPM: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM,
BaseRewardCoins: 1000,
EffectiveRewardCoins: 1000,
CreatedAtMS: 1783940398274,
}
}

View File

@ -14,11 +14,14 @@ import (
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/giftlimits" "hyapp/pkg/giftlimits"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/luckygiftmq"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift" domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
) )
@ -59,11 +62,18 @@ type UserPublisher interface {
PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error PublishUserCustomMessage(ctx context.Context, message tencentim.CustomUserMessage) error
} }
// EventPublisher 把钱包已确认入账的中奖事实发布到 lucky-gift-service 专属 topic。
// 下游 room/activity 各自使用独立 consumer group 和本地幂等 outbox发布失败只能重试展示事实不能回滚钱包。
type EventPublisher interface {
PublishLuckyGiftEvent(ctx context.Context, envelope *luckygifteventsv1.EventEnvelope) error
}
// Service 承载幸运礼物查询、抽奖命令和后台规则配置。 // Service 承载幸运礼物查询、抽奖命令和后台规则配置。
type Service struct { type Service struct {
repository Repository repository Repository
wallet WalletClient wallet WalletClient
userPub UserPublisher userPub UserPublisher
eventPub EventPublisher
now func() time.Time now func() time.Time
} }
@ -81,6 +91,12 @@ func WithUserPublisher(publisher UserPublisher) Option {
} }
} }
func WithEventPublisher(publisher EventPublisher) Option {
return func(s *Service) {
s.eventPub = publisher
}
}
func New(repository Repository, options ...Option) *Service { func New(repository Repository, options ...Option) *Service {
service := &Service{repository: repository, now: time.Now} service := &Service{repository: repository, now: time.Now}
for _, option := range options { for _, option := range options {
@ -217,6 +233,10 @@ func (s *Service) normalizeDrawCommand(cmd domain.DrawCommand) (domain.DrawComma
cmd.DeviceID = strings.TrimSpace(cmd.DeviceID) cmd.DeviceID = strings.TrimSpace(cmd.DeviceID)
cmd.RoomID = strings.TrimSpace(cmd.RoomID) cmd.RoomID = strings.TrimSpace(cmd.RoomID)
cmd.AnchorID = strings.TrimSpace(cmd.AnchorID) cmd.AnchorID = strings.TrimSpace(cmd.AnchorID)
cmd.SenderName = strings.TrimSpace(cmd.SenderName)
cmd.SenderAvatar = strings.TrimSpace(cmd.SenderAvatar)
cmd.SenderDisplayUserID = strings.TrimSpace(cmd.SenderDisplayUserID)
cmd.SenderPrettyDisplayUserID = strings.TrimSpace(cmd.SenderPrettyDisplayUserID)
if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 { if !giftlimits.ValidCommandID(cmd.CommandID) || cmd.UserID <= 0 || cmd.DeviceID == "" || cmd.RoomID == "" || cmd.AnchorID == "" || cmd.GiftID == "" || cmd.CoinSpent <= 0 {
return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete") return domain.DrawCommand{}, xerr.New(xerr.InvalidArgument, "lucky gift draw command is incomplete")
} }
@ -341,6 +361,10 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
// payload 不可解析说明抽奖事实旁路已经损坏,重试也无法恢复,直接走失败收敛。 // payload 不可解析说明抽奖事实旁路已经损坏,重试也无法恢复,直接走失败收敛。
return s.markOutboxFailed(ctx, event, options, err, false) return s.markOutboxFailed(ctx, event, options, err, false)
} }
if strings.TrimSpace(payload.AppCode) == "" {
// 早期 settlement payload 可能只依赖 outbox 列保存 app_code发布跨服务事实时必须把 owner 列补回信封。
payload.AppCode = event.AppCode
}
eventCtx := appcode.WithContext(ctx, event.AppCode) eventCtx := appcode.WithContext(ctx, event.AppCode)
switch event.EventType { switch event.EventType {
case domain.EventTypeLuckyGiftRewardSettlement, domain.EventTypeLuckyGiftDrawn, "": case domain.EventTypeLuckyGiftRewardSettlement, domain.EventTypeLuckyGiftDrawn, "":
@ -368,7 +392,14 @@ func (s *Service) processRewardSettlementOutbox(ctx context.Context, event domai
) )
} }
} }
// 幸运礼物不再产生跨用户展示副作用outbox 只负责可靠返奖和中奖用户余额通知,避免展示行为改变结算状态。 if credited && payload.EffectiveRewardCoins > 0 {
// owner outbox 在钱包入账和 draw=granted 之后才发布中奖事实Broker 未确认时保持 retryable
// 但 alreadyCredited=true 禁止重试耗尽后把已经到账的抽奖覆盖成 failed。
if err := s.publishLuckyGiftDrawnFact(ctx, payload, receipt); err != nil {
return s.markOutboxFailed(ctx, event, options, err, true)
}
}
// 只有 Broker 接受中奖事实后才交付 settlement outbox消费者按 event_id 幂等,因此 publish 成功、ACK 落库失败只会安全重放。
return s.repository.MarkLuckyGiftOutboxDelivered(ctx, event, s.now().UTC().UnixMilli()) return s.repository.MarkLuckyGiftOutboxDelivered(ctx, event, s.now().UTC().UnixMilli())
} }
@ -385,6 +416,8 @@ func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOu
alreadyGranted = state.AllGranted alreadyGranted = state.AllGranted
if alreadyGranted { if alreadyGranted {
walletTransactionID = state.WalletTransactionID walletTransactionID = state.WalletTransactionID
receipt.WalletTransactionID = state.WalletTransactionID
receipt.RewardGrantedAtMS = state.RewardGrantedAtMS
credited = true credited = true
} else { } else {
var err error var err error
@ -397,15 +430,17 @@ func (s *Service) settleLuckyGiftReward(ctx context.Context, event domain.DrawOu
} }
} }
if !credited { if !credited {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil { if _, err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil {
return false, false, receipt, err return false, false, receipt, err
} }
return false, false, receipt, nil return false, false, receipt, nil
} }
if !alreadyGranted { if !alreadyGranted {
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID); err != nil { grantedAtMS, err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, event.AppCode, payload.DrawIDs, walletTransactionID)
if err != nil {
return credited, alreadyGranted, receipt, err return credited, alreadyGranted, receipt, err
} }
receipt.RewardGrantedAtMS = grantedAtMS
} }
return credited, alreadyGranted, receipt, nil return credited, alreadyGranted, receipt, nil
} }
@ -415,6 +450,8 @@ type luckyGiftRewardReceipt struct {
CoinBalanceAfter int64 CoinBalanceAfter int64
CoinFrozenAfter int64 CoinFrozenAfter int64
BalanceVersion int64 BalanceVersion int64
// RewardGrantedAtMS 来自 lucky draw 持久状态;同一 event_id 的 Broker 重投必须复用这个时间。
RewardGrantedAtMS int64
} }
func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult { func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
@ -422,29 +459,33 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
return result return result
} }
payload := luckyGiftDrawnPayload{ payload := luckyGiftDrawnPayload{
AppCode: appcode.FromContext(ctx), AppCode: appcode.FromContext(ctx),
DrawID: result.DrawID, DrawID: result.DrawID,
DrawIDs: luckyDrawResultIDs(result), DrawIDs: luckyDrawResultIDs(result),
CommandID: cmd.CommandID, CommandID: cmd.CommandID,
PoolID: result.PoolID, PoolID: result.PoolID,
RoomID: cmd.RoomID, RoomID: cmd.RoomID,
GiftID: cmd.GiftID, GiftID: cmd.GiftID,
GiftCount: cmd.GiftCount, GiftCount: cmd.GiftCount,
UserID: cmd.UserID, UserID: cmd.UserID,
SenderUserID: cmd.UserID, SenderUserID: cmd.UserID,
TargetUserID: cmd.TargetUserID, TargetUserID: cmd.TargetUserID,
VisibleRegionID: cmd.VisibleRegionID, SenderName: cmd.SenderName,
CountryID: cmd.CountryID, SenderAvatar: cmd.SenderAvatar,
CoinSpent: cmd.CoinSpent, SenderDisplayUserID: cmd.SenderDisplayUserID,
RuleVersion: result.RuleVersion, SenderPrettyDisplayUserID: cmd.SenderPrettyDisplayUserID,
ExperiencePool: result.ExperiencePool, VisibleRegionID: cmd.VisibleRegionID,
SelectedTierID: result.SelectedTierID, CountryID: cmd.CountryID,
MultiplierPPM: result.MultiplierPPM, CoinSpent: cmd.CoinSpent,
BaseRewardCoins: result.BaseRewardCoins, RuleVersion: result.RuleVersion,
EffectiveRewardCoins: result.EffectiveRewardCoins, ExperiencePool: result.ExperiencePool,
StageFeedback: result.StageFeedback, SelectedTierID: result.SelectedTierID,
HighMultiplier: result.HighMultiplier, MultiplierPPM: result.MultiplierPPM,
CreatedAtMS: result.CreatedAtMS, BaseRewardCoins: result.BaseRewardCoins,
EffectiveRewardCoins: result.EffectiveRewardCoins,
StageFeedback: result.StageFeedback,
HighMultiplier: result.HighMultiplier,
CreatedAtMS: result.CreatedAtMS,
} }
receipt, err := s.creditLuckyGiftReward(ctx, payload.AppCode, payload) receipt, err := s.creditLuckyGiftReward(ctx, payload.AppCode, payload)
if err != nil { if err != nil {
@ -457,7 +498,8 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
} }
result.RewardStatus = domain.StatusGranted result.RewardStatus = domain.StatusGranted
result.WalletTransactionID = receipt.WalletTransactionID result.WalletTransactionID = receipt.WalletTransactionID
if err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID); err != nil { grantedAtMS, err := s.markLuckyGiftDrawsGrantedAfterWallet(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID)
if err != nil {
logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err, logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err,
slog.String("draw_id", result.DrawID), slog.String("draw_id", result.DrawID),
slog.String("command_id", result.CommandID), slog.String("command_id", result.CommandID),
@ -465,6 +507,7 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
) )
return result return result
} }
receipt.RewardGrantedAtMS = grantedAtMS
// HTTP/gRPC 抽奖响应不再返回返奖后余额;成功落库后的余额变化通过钱包私有 IM 发送Flutter 用 balance_after 覆盖本地余额。 // HTTP/gRPC 抽奖响应不再返回返奖后余额;成功落库后的余额变化通过钱包私有 IM 发送Flutter 用 balance_after 覆盖本地余额。
if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, 3*time.Second); err != nil { if err := s.publishLuckyGiftWalletBalanceChanged(ctx, payload, receipt, 3*time.Second); err != nil {
logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err, logx.Error(ctx, "lucky_gift_wallet_balance_notice_failed", err,
@ -476,23 +519,85 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
return result return result
} }
func (s *Service) markLuckyGiftDrawsGrantedAfterWallet(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string) error { func (s *Service) publishLuckyGiftDrawnFact(ctx context.Context, payload luckyGiftDrawnPayload, receipt luckyGiftRewardReceipt) error {
if s.eventPub == nil {
// 无 MQ 的显式本地隔离模式仍允许验证抽奖/钱包主链路;可提交的 Docker 和线上配置必须启用 owner topic。
return nil
}
drawCreatedAtMS := payload.CreatedAtMS
if drawCreatedAtMS <= 0 {
return fmt.Errorf("lucky gift draw created_at_ms is missing")
}
rewardGrantedAtMS := receipt.RewardGrantedAtMS
if rewardGrantedAtMS <= 0 {
// 拆服前已 granted 的历史行理论上都有 updated_at_ms若遇到人工旧数据缺时间只能退化到稳定的抽奖时间
// 不能在每次 MQ retry 时重新取 now 造成相同 event_id 对应不同事实正文。
rewardGrantedAtMS = drawCreatedAtMS
}
body, err := proto.Marshal(&luckygifteventsv1.LuckyGiftDrawn{
DrawId: payload.DrawID,
CommandId: payload.CommandID,
RoomId: payload.RoomID,
PoolId: payload.PoolID,
GiftId: payload.GiftID,
GiftCount: payload.GiftCount,
SenderUserId: payload.SenderUserID,
TargetUserId: payload.TargetUserID,
SenderName: payload.SenderName,
SenderAvatar: payload.SenderAvatar,
SenderDisplayUserId: payload.SenderDisplayUserID,
SenderPrettyDisplayUserId: payload.SenderPrettyDisplayUserID,
VisibleRegionId: payload.VisibleRegionID,
CountryId: payload.CountryID,
CoinSpent: payload.CoinSpent,
RuleVersion: payload.RuleVersion,
ExperiencePool: payload.ExperiencePool,
SelectedTierId: payload.SelectedTierID,
MultiplierPpm: payload.MultiplierPPM,
BaseRewardCoins: payload.BaseRewardCoins,
EffectiveRewardCoins: payload.EffectiveRewardCoins,
StageFeedback: payload.StageFeedback,
HighMultiplier: payload.HighMultiplier,
RewardStatus: domain.StatusGranted,
WalletTransactionId: receipt.WalletTransactionID,
DrawCreatedAtMs: drawCreatedAtMS,
RewardGrantedAtMs: rewardGrantedAtMS,
})
if err != nil {
return err
}
eventID := strings.TrimSpace(payload.EventID)
if eventID == "" {
eventID = "lucky_gift_drawn:" + payload.DrawID
}
return s.eventPub.PublishLuckyGiftEvent(ctx, &luckygifteventsv1.EventEnvelope{
EventId: eventID,
EventType: luckygiftmq.EventTypeLuckyGiftDrawn,
AppCode: payload.AppCode,
DrawId: payload.DrawID,
OccurredAtMs: drawCreatedAtMS,
Body: body,
})
}
func (s *Service) markLuckyGiftDrawsGrantedAfterWallet(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string) (int64, error) {
baseCtx := context.WithoutCancel(ctx) baseCtx := context.WithoutCancel(ctx)
var lastErr error var lastErr error
for attempt := 0; attempt < 3; attempt++ { for attempt := 0; attempt < 3; attempt++ {
markCtx, cancel := context.WithTimeout(appcode.WithContext(baseCtx, appCode), 5*time.Second) markCtx, cancel := context.WithTimeout(appcode.WithContext(baseCtx, appCode), 5*time.Second)
err := s.repository.MarkLuckyGiftDrawsGranted(markCtx, appCode, drawIDs, walletTransactionID, s.now().UTC().UnixMilli()) grantedAtMS := s.now().UTC().UnixMilli()
err := s.repository.MarkLuckyGiftDrawsGranted(markCtx, appCode, drawIDs, walletTransactionID, grantedAtMS)
cancel() cancel()
if err == nil { if err == nil {
return nil return grantedAtMS, nil
} }
lastErr = err lastErr = err
if !isRetryableLuckyGrantMarkError(err) { if !isRetryableLuckyGrantMarkError(err) {
return err return 0, err
} }
time.Sleep(time.Duration(attempt+1) * 50 * time.Millisecond) time.Sleep(time.Duration(attempt+1) * 50 * time.Millisecond)
} }
return lastErr return 0, lastErr
} }
func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, payload luckyGiftDrawnPayload) (luckyGiftRewardReceipt, error) { func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, payload luckyGiftDrawnPayload) (luckyGiftRewardReceipt, error) {
@ -697,30 +802,31 @@ type luckyGiftDrawnPayload struct {
AppCode string `json:"app_code"` AppCode string `json:"app_code"`
DrawID string `json:"draw_id"` DrawID string `json:"draw_id"`
// DrawIDs 是聚合发放的明细集合;老 outbox 没有该字段时 decode 会回填为单个 DrawID。 // DrawIDs 是聚合发放的明细集合;老 outbox 没有该字段时 decode 会回填为单个 DrawID。
DrawIDs []string `json:"draw_ids,omitempty"` DrawIDs []string `json:"draw_ids,omitempty"`
CommandID string `json:"command_id"` CommandID string `json:"command_id"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
GiftID string `json:"gift_id"` GiftID string `json:"gift_id"`
GiftCount int32 `json:"gift_count"` GiftCount int32 `json:"gift_count"`
UserID int64 `json:"user_id"` UserID int64 `json:"user_id"`
SenderUserID int64 `json:"sender_user_id"` SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"` TargetUserID int64 `json:"target_user_id"`
SenderName string `json:"sender_name,omitempty"` SenderName string `json:"sender_name,omitempty"`
SenderAvatar string `json:"sender_avatar,omitempty"` SenderAvatar string `json:"sender_avatar,omitempty"`
SenderDisplayUserID string `json:"sender_display_user_id,omitempty"` SenderDisplayUserID string `json:"sender_display_user_id,omitempty"`
VisibleRegionID int64 `json:"visible_region_id"` SenderPrettyDisplayUserID string `json:"sender_pretty_display_user_id,omitempty"`
CountryID int64 `json:"country_id"` VisibleRegionID int64 `json:"visible_region_id"`
CoinSpent int64 `json:"coin_spent"` CountryID int64 `json:"country_id"`
RuleVersion int64 `json:"rule_version"` CoinSpent int64 `json:"coin_spent"`
ExperiencePool string `json:"experience_pool"` RuleVersion int64 `json:"rule_version"`
SelectedTierID string `json:"selected_tier_id"` ExperiencePool string `json:"experience_pool"`
MultiplierPPM int64 `json:"multiplier_ppm"` SelectedTierID string `json:"selected_tier_id"`
BaseRewardCoins int64 `json:"base_reward_coins"` MultiplierPPM int64 `json:"multiplier_ppm"`
EffectiveRewardCoins int64 `json:"effective_reward_coins"` BaseRewardCoins int64 `json:"base_reward_coins"`
StageFeedback bool `json:"stage_feedback"` EffectiveRewardCoins int64 `json:"effective_reward_coins"`
HighMultiplier bool `json:"high_multiplier"` StageFeedback bool `json:"stage_feedback"`
CreatedAtMS int64 `json:"created_at_ms"` HighMultiplier bool `json:"high_multiplier"`
CreatedAtMS int64 `json:"created_at_ms"`
} }
func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error) { func decodeDrawOutboxPayload(payloadJSON string) (luckyGiftDrawnPayload, error) {

View File

@ -993,31 +993,35 @@ func luckyDrawRecordSnapshots(input luckyDrawRecordInput) (string, string, strin
func (r *Repository) insertLuckyAggregateRewardSettlementOutbox(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, state luckyBatchDrawState, nowMS int64) error { func (r *Repository) insertLuckyAggregateRewardSettlementOutbox(ctx context.Context, tx *sql.Tx, appCode string, cmd domain.DrawCommand, state luckyBatchDrawState, nowMS int64) error {
aggregate := state.AggregateResult aggregate := state.AggregateResult
payload, _ := json.Marshal(map[string]any{ payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn", "event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:" + aggregate.DrawID, "event_id": "lucky_gift_drawn:" + aggregate.DrawID,
"app_code": appCode, "app_code": appCode,
"draw_id": aggregate.DrawID, "draw_id": aggregate.DrawID,
"draw_ids": state.DrawIDs, "draw_ids": state.DrawIDs,
"command_id": cmd.CommandID, "command_id": cmd.CommandID,
"pool_id": aggregate.PoolID, "pool_id": aggregate.PoolID,
"user_id": cmd.UserID, "user_id": cmd.UserID,
"sender_user_id": cmd.UserID, "sender_user_id": cmd.UserID,
"target_user_id": cmd.TargetUserID, "target_user_id": cmd.TargetUserID,
"visible_region_id": cmd.VisibleRegionID, "sender_name": cmd.SenderName,
"country_id": cmd.CountryID, "sender_avatar": cmd.SenderAvatar,
"room_id": cmd.RoomID, "sender_display_user_id": cmd.SenderDisplayUserID,
"gift_id": cmd.GiftID, "sender_pretty_display_user_id": cmd.SenderPrettyDisplayUserID,
"gift_count": cmd.GiftCount, "visible_region_id": cmd.VisibleRegionID,
"coin_spent": cmd.CoinSpent, "country_id": cmd.CountryID,
"rule_version": aggregate.RuleVersion, "room_id": cmd.RoomID,
"experience_pool": aggregate.ExperiencePool, "gift_id": cmd.GiftID,
"selected_tier_id": aggregate.SelectedTierID, "gift_count": cmd.GiftCount,
"multiplier_ppm": aggregate.MultiplierPPM, "coin_spent": cmd.CoinSpent,
"base_reward_coins": aggregate.BaseRewardCoins, "rule_version": aggregate.RuleVersion,
"effective_reward_coins": aggregate.EffectiveRewardCoins, "experience_pool": aggregate.ExperiencePool,
"stage_feedback": aggregate.StageFeedback, "selected_tier_id": aggregate.SelectedTierID,
"high_multiplier": aggregate.HighMultiplier, "multiplier_ppm": aggregate.MultiplierPPM,
"created_at_ms": nowMS, "base_reward_coins": aggregate.BaseRewardCoins,
"effective_reward_coins": aggregate.EffectiveRewardCoins,
"stage_feedback": aggregate.StageFeedback,
"high_multiplier": aggregate.HighMultiplier,
"created_at_ms": nowMS,
}) })
return r.insertLuckyRewardSettlementOutbox(ctx, tx, appCode, aggregate.DrawID, payload, nowMS) return r.insertLuckyRewardSettlementOutbox(ctx, tx, appCode, aggregate.DrawID, payload, nowMS)
} }
@ -1324,32 +1328,37 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
return err return err
} }
if luckyDrawNeedsRewardSettlementOutbox(input.Candidate) { if luckyDrawNeedsRewardSettlementOutbox(input.Candidate) {
// outbox 只记录钱包返奖结算;展示层副作用不参与可靠补偿链路。 // 同一 owner outbox 先补偿钱包返奖,再在 granted 收敛后发布中奖事实room/activity 的展示副作用
// 由各自本地 outbox 独立补偿,不能在抽奖事务里直接调用腾讯云 IM。
payload, _ := json.Marshal(map[string]any{ payload, _ := json.Marshal(map[string]any{
"event_type": "lucky_gift_drawn", "event_type": "lucky_gift_drawn",
"event_id": "lucky_gift_drawn:" + input.DrawID, "event_id": "lucky_gift_drawn:" + input.DrawID,
"app_code": input.AppCode, "app_code": input.AppCode,
"draw_id": input.DrawID, "draw_id": input.DrawID,
"command_id": input.Command.CommandID, "command_id": input.Command.CommandID,
"pool_id": input.Config.PoolID, "pool_id": input.Config.PoolID,
"user_id": input.Command.UserID, "user_id": input.Command.UserID,
"sender_user_id": input.Command.UserID, "sender_user_id": input.Command.UserID,
"target_user_id": input.Command.TargetUserID, "target_user_id": input.Command.TargetUserID,
"visible_region_id": input.Command.VisibleRegionID, "sender_name": input.Command.SenderName,
"country_id": input.Command.CountryID, "sender_avatar": input.Command.SenderAvatar,
"room_id": input.Command.RoomID, "sender_display_user_id": input.Command.SenderDisplayUserID,
"gift_id": input.Command.GiftID, "sender_pretty_display_user_id": input.Command.SenderPrettyDisplayUserID,
"gift_count": input.Command.GiftCount, "visible_region_id": input.Command.VisibleRegionID,
"coin_spent": input.Command.CoinSpent, "country_id": input.Command.CountryID,
"rule_version": input.Config.RuleVersion, "room_id": input.Command.RoomID,
"experience_pool": input.ExperiencePool, "gift_id": input.Command.GiftID,
"selected_tier_id": input.Candidate.TierID, "gift_count": input.Command.GiftCount,
"multiplier_ppm": input.Candidate.MultiplierPPM, "coin_spent": input.Command.CoinSpent,
"base_reward_coins": input.Candidate.BaseReward, "rule_version": input.Config.RuleVersion,
"effective_reward_coins": input.Candidate.effectiveReward(), "experience_pool": input.ExperiencePool,
"stage_feedback": input.StageFeedback, "selected_tier_id": input.Candidate.TierID,
"high_multiplier": input.Candidate.HighMultiplier, "multiplier_ppm": input.Candidate.MultiplierPPM,
"created_at_ms": input.NowMS, "base_reward_coins": input.Candidate.BaseReward,
"effective_reward_coins": input.Candidate.effectiveReward(),
"stage_feedback": input.StageFeedback,
"high_multiplier": input.Candidate.HighMultiplier,
"created_at_ms": input.NowMS,
}) })
if err := r.insertLuckyRewardSettlementOutbox(ctx, tx, input.AppCode, input.DrawID, payload, input.NowMS); err != nil { if err := r.insertLuckyRewardSettlementOutbox(ctx, tx, input.AppCode, input.DrawID, payload, input.NowMS); err != nil {
return err return err
@ -1659,7 +1668,7 @@ func (r *Repository) GetLuckyGiftDrawRewardState(ctx context.Context, appCode st
args = append(args, drawID) args = append(args, drawID)
} }
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT reward_status, COALESCE(reward_transaction_id, ''), COUNT(*) SELECT reward_status, COALESCE(reward_transaction_id, ''), MAX(updated_at_ms), COUNT(*)
FROM lucky_draw_records FROM lucky_draw_records
WHERE app_code = ? AND draw_id IN (`+luckySQLPlaceholders(len(drawIDs))+`) WHERE app_code = ? AND draw_id IN (`+luckySQLPlaceholders(len(drawIDs))+`)
GROUP BY reward_status, reward_transaction_id`, args...) GROUP BY reward_status, reward_transaction_id`, args...)
@ -1670,11 +1679,13 @@ func (r *Repository) GetLuckyGiftDrawRewardState(ctx context.Context, appCode st
var total int64 var total int64
var granted int64 var granted int64
walletTransactionID := "" walletTransactionID := ""
rewardGrantedAtMS := int64(0)
for rows.Next() { for rows.Next() {
var status string var status string
var txID string var txID string
var updatedAtMS int64
var count int64 var count int64
if err := rows.Scan(&status, &txID, &count); err != nil { if err := rows.Scan(&status, &txID, &updatedAtMS, &count); err != nil {
return domain.DrawRewardState{}, err return domain.DrawRewardState{}, err
} }
total += count total += count
@ -1683,6 +1694,9 @@ func (r *Repository) GetLuckyGiftDrawRewardState(ctx context.Context, appCode st
if walletTransactionID == "" { if walletTransactionID == "" {
walletTransactionID = txID walletTransactionID = txID
} }
if updatedAtMS > rewardGrantedAtMS {
rewardGrantedAtMS = updatedAtMS
}
} }
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@ -1691,6 +1705,7 @@ func (r *Repository) GetLuckyGiftDrawRewardState(ctx context.Context, appCode st
return domain.DrawRewardState{ return domain.DrawRewardState{
AllGranted: total == int64(len(drawIDs)) && granted == total, AllGranted: total == int64(len(drawIDs)) && granted == total,
WalletTransactionID: walletTransactionID, WalletTransactionID: walletTransactionID,
RewardGrantedAtMS: rewardGrantedAtMS,
}, nil }, nil
} }

View File

@ -105,19 +105,23 @@ func (s *LuckyGiftServer) ExecuteExternalGiftDraw(ctx context.Context, req *luck
func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawCommand { func luckyDrawCommandFromProto(meta *luckygiftv1.LuckyGiftMeta) domain.DrawCommand {
return domain.DrawCommand{ return domain.DrawCommand{
CommandID: meta.GetCommandId(), CommandID: meta.GetCommandId(),
PoolID: meta.GetPoolId(), PoolID: meta.GetPoolId(),
UserID: meta.GetUserId(), UserID: meta.GetUserId(),
TargetUserID: meta.GetTargetUserId(), TargetUserID: meta.GetTargetUserId(),
DeviceID: meta.GetDeviceId(), DeviceID: meta.GetDeviceId(),
RoomID: meta.GetRoomId(), RoomID: meta.GetRoomId(),
AnchorID: meta.GetAnchorId(), AnchorID: meta.GetAnchorId(),
GiftID: meta.GetGiftId(), GiftID: meta.GetGiftId(),
GiftCount: meta.GetGiftCount(), GiftCount: meta.GetGiftCount(),
CoinSpent: meta.GetCoinSpent(), CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(), PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(), VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(), CountryID: meta.GetCountryId(),
SenderName: meta.GetSenderName(),
SenderAvatar: meta.GetSenderAvatar(),
SenderDisplayUserID: meta.GetSenderDisplayUserId(),
SenderPrettyDisplayUserID: meta.GetSenderPrettyDisplayUserId(),
} }
} }

View File

@ -81,6 +81,12 @@ rocketmq:
topic: "hyapp_user_outbox" topic: "hyapp_user_outbox"
consumer_group: "hyapp-room-user-region-sync" consumer_group: "hyapp-room-user-region-sync"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
# 房间中奖 IM 由 lucky-gift owner 事实可靠派生;独立 group 保证不和其他下游共享消费位点。
lucky_gift_outbox:
enabled: true
topic: "hyapp_lucky_gift_outbox"
consumer_group: "hyapp-room-lucky-gift-outbox"
consumer_max_reconsume_times: 16
outbox_worker: outbox_worker:
# Docker 和 testbox 走 MQroom-service 同进程启动 IM bridge consumer 补偿房间群消息。 # Docker 和 testbox 走 MQroom-service 同进程启动 IM bridge consumer 补偿房间群消息。
enabled: true enabled: true

View File

@ -91,6 +91,12 @@ rocketmq:
topic: "hyapp_user_outbox" topic: "hyapp_user_outbox"
consumer_group: "hyapp-room-user-region-sync" consumer_group: "hyapp-room-user-region-sync"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
# 线上必须使用 room-service 自己的消费组owner outbox 只通过 MQ 进入房间展示链路。
lucky_gift_outbox:
enabled: true
topic: "hyapp_lucky_gift_outbox"
consumer_group: "hyapp-room-lucky-gift-outbox"
consumer_max_reconsume_times: 16
outbox_worker: outbox_worker:
# mq 模式下 outbox worker 只把事实投 MQ并顺带安排火箭延迟发射唤醒。 # mq 模式下 outbox worker 只把事实投 MQ并顺带安排火箭延迟发射唤醒。
enabled: true enabled: true

View File

@ -84,6 +84,12 @@ rocketmq:
topic: "hyapp_user_outbox" topic: "hyapp_user_outbox"
consumer_group: "hyapp-room-user-region-sync" consumer_group: "hyapp-room-user-region-sync"
consumer_max_reconsume_times: 16 consumer_max_reconsume_times: 16
# 幸运礼物 owner 事实由独立消费组派生为房间展示 outbox不能复用 activity 消费位点或直连幸运礼物库。
lucky_gift_outbox:
enabled: true
topic: "hyapp_lucky_gift_outbox"
consumer_group: "hyapp-room-lucky-gift-outbox"
consumer_max_reconsume_times: 16
outbox_worker: outbox_worker:
# room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;本地也走 MQ避免和测试/线上链路漂移。 # room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;本地也走 MQ避免和测试/线上链路漂移。
enabled: true enabled: true

View File

@ -25,6 +25,7 @@ CREATE TABLE IF NOT EXISTS rooms (
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms', created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms', updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, room_id), PRIMARY KEY (app_code, room_id),
UNIQUE KEY uk_rooms_room_id (room_id),
UNIQUE KEY uk_rooms_short_id (app_code, room_short_id), UNIQUE KEY uk_rooms_short_id (app_code, room_short_id),
UNIQUE KEY uk_rooms_owner_user (app_code, owner_user_id), UNIQUE KEY uk_rooms_owner_user (app_code, owner_user_id),
KEY idx_rooms_region_status (app_code, visible_region_id, status) KEY idx_rooms_region_status (app_code, visible_region_id, status)

View File

@ -15,6 +15,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/healthhttp" "hyapp/pkg/healthhttp"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/luckygiftmq"
"hyapp/pkg/rocketmqx" "hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq" "hyapp/pkg/roommq"
serviceapp "hyapp/pkg/servicekit/app" serviceapp "hyapp/pkg/servicekit/app"
@ -65,7 +66,7 @@ type App struct {
healthHTTP *healthhttp.Server healthHTTP *healthhttp.Server
// mqProducers 在 outbox worker 启动前打开,承载 fanout 和延迟发射唤醒。 // mqProducers 在 outbox worker 启动前打开,承载 fanout 和延迟发射唤醒。
mqProducers []*rocketmqx.Producer mqProducers []*rocketmqx.Producer
// mqConsumers 在 gRPC 服务启动前订阅,承接 room_outbox fanout 和 delayed open // mqConsumers 在 gRPC 服务启动前订阅,承接火箭唤醒、用户区域同步和幸运礼物 owner 事实
mqConsumers []*rocketmqx.Consumer mqConsumers []*rocketmqx.Consumer
// workers 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。 // workers 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。
workers *serviceapp.BackgroundGroup workers *serviceapp.BackgroundGroup
@ -190,7 +191,7 @@ func New(cfg config.Config) (*App, error) {
} }
mqProducers := make([]*rocketmqx.Producer, 0, 2) mqProducers := make([]*rocketmqx.Producer, 0, 2)
mqConsumers := make([]*rocketmqx.Consumer, 0, 3) mqConsumers := make([]*rocketmqx.Consumer, 0, 4)
var rocketLaunchScheduler integration.RoomRocketLaunchScheduler var rocketLaunchScheduler integration.RoomRocketLaunchScheduler
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual { if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
if activityConn != nil { if activityConn != nil {
@ -295,6 +296,31 @@ func New(cfg config.Config) (*App, error) {
} }
mqConsumers = append(mqConsumers, userConsumer) mqConsumers = append(mqConsumers, userConsumer)
} }
if cfg.RocketMQ.LuckyGiftOutbox.Enabled {
luckyGiftConsumer, err := rocketmqx.NewConsumer(luckyGiftRocketMQConsumerConfig(cfg.RocketMQ))
if err != nil {
_ = repository.Close()
closeClientConn(activityConn)
_ = walletConn.Close()
_ = redisClient.Close()
return nil, err
}
if err := luckyGiftConsumer.Subscribe(cfg.RocketMQ.LuckyGiftOutbox.Topic, luckygiftmq.TagLuckyGiftDrawn, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
envelope, _, err := luckygiftmq.DecodeLuckyGiftOutboxMessage(message.Body)
if err != nil {
return err
}
// 消费回调只在本地 room_outbox 持久化成功后确认;腾讯 IM 失败由本服务 outbox worker 独立补偿。
return svc.HandleLuckyGiftOutboxEvent(ctx, envelope)
}); err != nil {
_ = repository.Close()
closeClientConn(activityConn)
_ = walletConn.Close()
_ = redisClient.Close()
return nil, err
}
mqConsumers = append(mqConsumers, luckyGiftConsumer)
}
server := servicegrpc.New("room-service") server := servicegrpc.New("room-service")
roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout)) roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout))
@ -594,6 +620,12 @@ func rocketMQConsumerConfig(cfg config.RocketMQConfig, groupName string, maxReco
} }
} }
func luckyGiftRocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
// 房间飘屏是实时外部副作用,不能在新建/误改 group 时从最早位点重放历史大奖。
// 保持 SDK 的 latest 默认值,因此首次上线必须先让 room/activity consumer 建组并稳定订阅,再开启 lucky-gift producer。
return rocketMQConsumerConfig(cfg, cfg.LuckyGiftOutbox.ConsumerGroup, cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes, 0)
}
func rocketMQEndpointConfig(cfg config.RocketMQConfig) rocketmqx.EndpointConfig { func rocketMQEndpointConfig(cfg config.RocketMQConfig) rocketmqx.EndpointConfig {
return rocketmqx.EndpointConfig{ return rocketmqx.EndpointConfig{
NameServers: cfg.NameServers, NameServers: cfg.NameServers,

View File

@ -152,10 +152,11 @@ type RocketMQConfig struct {
Retry int `yaml:"retry"` Retry int `yaml:"retry"`
// RoomEventTagMode 是 room topic producer 的两阶段发布开关。legacy 保持旧 // RoomEventTagMode 是 room topic producer 的两阶段发布开关。legacy 保持旧
// consumer 可见event_type 只能在全部 consumer 完成兼容订阅滚动后启用。 // consumer 可见event_type 只能在全部 consumer 完成兼容订阅滚动后启用。
RoomEventTagMode roommq.EventTagMode `yaml:"room_event_tag_mode"` RoomEventTagMode roommq.EventTagMode `yaml:"room_event_tag_mode"`
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"` RocketLaunch RocketLaunchMQConfig `yaml:"rocket_launch"`
UserOutbox UserOutboxMQConfig `yaml:"user_outbox"` UserOutbox UserOutboxMQConfig `yaml:"user_outbox"`
LuckyGiftOutbox LuckyGiftOutboxMQConfig `yaml:"lucky_gift_outbox"`
} }
// RoomOutboxMQConfig 控制 room_outbox fanout topic。 // RoomOutboxMQConfig 控制 room_outbox fanout topic。
@ -190,6 +191,15 @@ type UserOutboxMQConfig struct {
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
} }
// LuckyGiftOutboxMQConfig 控制 room-service 消费 lucky-gift-service 已提交的中奖事实。
// 该 consumer 只派生房间展示 outbox不读取幸运礼物库也不修改 Room Cell 状态。
type LuckyGiftOutboxMQConfig struct {
Enabled bool `yaml:"enabled"`
Topic string `yaml:"topic"`
ConsumerGroup string `yaml:"consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
}
// TencentIMConfig 描述 room-service 调用腾讯云 IM REST API 的配置。 // TencentIMConfig 描述 room-service 调用腾讯云 IM REST API 的配置。
type TencentIMConfig struct { type TencentIMConfig struct {
// Enabled 控制是否启用腾讯云 IM REST 桥接;关闭时只保留 Room Cell 内部提交。 // Enabled 控制是否启用腾讯云 IM REST 桥接;关闭时只保留 Room Cell 内部提交。
@ -371,6 +381,12 @@ func defaultRocketMQConfig() RocketMQConfig {
ConsumerGroup: "hyapp-room-user-region-sync", ConsumerGroup: "hyapp-room-user-region-sync",
ConsumerMaxReconsumeTimes: 16, ConsumerMaxReconsumeTimes: 16,
}, },
LuckyGiftOutbox: LuckyGiftOutboxMQConfig{
Enabled: false,
Topic: "hyapp_lucky_gift_outbox",
ConsumerGroup: "hyapp-room-lucky-gift-outbox",
ConsumerMaxReconsumeTimes: 16,
},
} }
} }
@ -465,6 +481,10 @@ func Normalize(cfg Config) (Config, error) {
return Config{}, err return Config{}, err
} }
cfg.RocketMQ = rocketMQ cfg.RocketMQ = rocketMQ
if cfg.RocketMQ.LuckyGiftOutbox.Enabled && !cfg.TencentIM.Enabled {
// 开启 owner 事实消费却没有 IM publisher 会让派生 outbox 永久 retry启动时拒绝比运行后静默积压更安全。
return Config{}, fmt.Errorf("rocketmq.lucky_gift_outbox.enabled requires tencent_im.enabled")
}
return cfg, nil return cfg, nil
} }
@ -609,7 +629,16 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 { if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
} }
if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled { if cfg.LuckyGiftOutbox.Topic = strings.TrimSpace(cfg.LuckyGiftOutbox.Topic); cfg.LuckyGiftOutbox.Topic == "" {
cfg.LuckyGiftOutbox.Topic = defaults.LuckyGiftOutbox.Topic
}
if cfg.LuckyGiftOutbox.ConsumerGroup = strings.TrimSpace(cfg.LuckyGiftOutbox.ConsumerGroup); cfg.LuckyGiftOutbox.ConsumerGroup == "" {
cfg.LuckyGiftOutbox.ConsumerGroup = defaults.LuckyGiftOutbox.ConsumerGroup
}
if cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.LuckyGiftOutbox.ConsumerMaxReconsumeTimes = defaults.LuckyGiftOutbox.ConsumerMaxReconsumeTimes
}
if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled || cfg.LuckyGiftOutbox.Enabled {
cfg.Enabled = true cfg.Enabled = true
} }
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual { if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {

View File

@ -49,9 +49,12 @@ func TestLoad(t *testing.T) {
t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker) t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker)
} }
// 本地默认仍走 room_outbox MQ 和火箭延迟唤醒;房间 UI IM 已改为 Room Cell 提交后直发,不再启 MQ IM bridge。 // 本地默认仍走 room_outbox MQ 和火箭延迟唤醒;房间 UI IM 已改为 Room Cell 提交后直发,不再启 MQ IM bridge。
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.UserOutbox.Enabled { if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.UserOutbox.Enabled || !cfg.RocketMQ.LuckyGiftOutbox.Enabled {
t.Fatalf("local config must enable room MQ fanout and disable MQ IM bridge: %+v", cfg.RocketMQ) t.Fatalf("local config must enable room MQ fanout and disable MQ IM bridge: %+v", cfg.RocketMQ)
} }
if cfg.RocketMQ.LuckyGiftOutbox.Topic != "hyapp_lucky_gift_outbox" || cfg.RocketMQ.LuckyGiftOutbox.ConsumerGroup != "hyapp-room-lucky-gift-outbox" {
t.Fatalf("local config must isolate lucky gift display consumption: %+v", cfg.RocketMQ.LuckyGiftOutbox)
}
if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy { if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy {
t.Fatalf("Phase A config must publish legacy room tags: %q", cfg.RocketMQ.RoomEventTagMode) t.Fatalf("Phase A config must publish legacy room tags: %q", cfg.RocketMQ.RoomEventTagMode)
} }
@ -123,7 +126,7 @@ func TestLoadTencentExample(t *testing.T) {
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PollInterval != 200*time.Millisecond || cfg.OutboxWorker.BatchSize != 50 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 { if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PollInterval != 200*time.Millisecond || cfg.OutboxWorker.BatchSize != 50 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 {
t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker) t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker)
} }
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled { if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.LuckyGiftOutbox.Enabled {
t.Fatalf("tencent example must route room outbox and rocket launch through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ) t.Fatalf("tencent example must route room outbox and rocket launch through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ)
} }
if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy { if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy {
@ -146,6 +149,9 @@ func TestLoadDockerConfigDoesNotStartRoomIMBridgeWhenOutboxUsesMQ(t *testing.T)
if !cfg.RocketMQ.RoomOutbox.Enabled { if !cfg.RocketMQ.RoomOutbox.Enabled {
t.Fatalf("docker config must enable room outbox MQ") t.Fatalf("docker config must enable room outbox MQ")
} }
if !cfg.RocketMQ.LuckyGiftOutbox.Enabled || cfg.RocketMQ.LuckyGiftOutbox.ConsumerGroup != "hyapp-room-lucky-gift-outbox" {
t.Fatalf("docker config must enable an isolated lucky gift consumer: %+v", cfg.RocketMQ.LuckyGiftOutbox)
}
if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy { if cfg.RocketMQ.RoomEventTagMode != roommq.EventTagModeLegacy {
t.Fatalf("docker config must remain in Phase A: %q", cfg.RocketMQ.RoomEventTagMode) t.Fatalf("docker config must remain in Phase A: %q", cfg.RocketMQ.RoomEventTagMode)
} }
@ -187,6 +193,16 @@ func TestNormalizeRejectsMQPublishModeWithoutRoomOutboxMQ(t *testing.T) {
} }
} }
func TestNormalizeRejectsLuckyGiftConsumerWithoutTencentIM(t *testing.T) {
cfg := Default()
cfg.RocketMQ.LuckyGiftOutbox.Enabled = true
cfg.RocketMQ.NameServers = []string{"127.0.0.1:9876"}
if _, err := Normalize(cfg); err == nil || !strings.Contains(err.Error(), "tencent_im.enabled") {
t.Fatalf("Normalize should reject lucky gift consumption without room IM publisher: %v", err)
}
}
func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) { func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
cfg := Default() cfg := Default()
cfg.OutboxWorker.PollInterval = 0 cfg.OutboxWorker.PollInterval = 0

View File

@ -401,6 +401,45 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"gift_value": fmt.Sprintf("%d", body.GetGiftValue()), "gift_value": fmt.Sprintf("%d", body.GetGiftValue()),
} }
return base, true, nil return base, true, nil
case "RoomLuckyGiftDrawn":
// 真人中奖由 lucky-gift owner 事实派生room-service 只把稳定快照翻译为现有 Flutter lucky_gift_drawn 协议。
var body roomeventsv1.RoomLuckyGiftDrawn
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId()
base.GiftValue = body.GetEffectiveRewardCoins()
base.Attributes = map[string]string{
"event_type": "lucky_gift_drawn",
"draw_id": body.GetDrawId(),
"command_id": body.GetCommandId(),
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
"target_user_id": fmt.Sprintf("%d", body.GetTargetUserId()),
"sender_name": body.GetSenderName(),
"sender_avatar": body.GetSenderAvatar(),
"sender_display_user_id": body.GetSenderDisplayUserId(),
"sender_pretty_display_user_id": body.GetSenderPrettyDisplayUserId(),
"gift_id": body.GetGiftId(),
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"pool_id": body.GetPoolId(),
"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
"country_id": fmt.Sprintf("%d", body.GetCountryId()),
"coin_spent": fmt.Sprintf("%d", body.GetCoinSpent()),
"rule_version": fmt.Sprintf("%d", body.GetRuleVersion()),
"experience_pool": body.GetExperiencePool(),
"selected_tier_id": body.GetSelectedTierId(),
"multiplier_ppm": fmt.Sprintf("%d", body.GetMultiplierPpm()),
"base_reward_coins": fmt.Sprintf("%d", body.GetBaseRewardCoins()),
"effective_reward_coins": fmt.Sprintf("%d", body.GetEffectiveRewardCoins()),
"stage_feedback": fmt.Sprintf("%t", body.GetStageFeedback()),
"high_multiplier": fmt.Sprintf("%t", body.GetHighMultiplier()),
"reward_status": body.GetRewardStatus(),
"wallet_transaction_id": body.GetWalletTransactionId(),
"created_at_ms": fmt.Sprintf("%d", body.GetDrawCreatedAtMs()),
"reward_granted_at_ms": fmt.Sprintf("%d", body.GetRewardGrantedAtMs()),
}
return base, true, nil
case "RoomRobotLuckyGiftDrawn": case "RoomRobotLuckyGiftDrawn":
// 机器人幸运礼物只发房间展示协议,不进入 activity-service 真实抽奖和奖池链路。 // 机器人幸运礼物只发房间展示协议,不进入 activity-service 真实抽奖和奖池链路。
var body roomeventsv1.RoomRobotLuckyGiftDrawn var body roomeventsv1.RoomRobotLuckyGiftDrawn
@ -598,6 +637,8 @@ func eventTypeForClient(eventType string) string {
return "room_rank_changed" return "room_rank_changed"
case "RoomRobotLuckyGiftDrawn": case "RoomRobotLuckyGiftDrawn":
return "lucky_gift_drawn" return "lucky_gift_drawn"
case "RoomLuckyGiftDrawn":
return "lucky_gift_drawn"
case "RoomRocketFuelChanged": case "RoomRocketFuelChanged":
return "room_rocket_fuel_changed" return "room_rocket_fuel_changed"
case "RoomRocketIgnited": case "RoomRocketIgnited":

View File

@ -303,6 +303,57 @@ func TestRoomRobotLuckyGiftDrawnPublishesLuckyGiftIMEvent(t *testing.T) {
} }
} }
func TestRoomLuckyGiftDrawnPublishesGrantedOwnerFactAsLuckyGiftIMEvent(t *testing.T) {
record, err := outbox.Build("room-lucky-owner", "RoomLuckyGiftDrawn", 0, time.Now(), &roomeventsv1.RoomLuckyGiftDrawn{
DrawId: "lucky_draw_1",
CommandId: "cmd_lucky_1",
PoolId: "advanced",
GiftId: "43",
GiftCount: 1,
SenderUserId: 1001,
TargetUserId: 1002,
SenderName: "Lucky Sender",
SenderAvatar: "https://cdn.example.test/sender.png",
SenderDisplayUserId: "123456",
SenderPrettyDisplayUserId: "VIP123456",
VisibleRegionId: 1,
CountryId: 86,
CoinSpent: 100,
RuleVersion: 7,
ExperiencePool: "normal",
SelectedTierId: "50x",
MultiplierPpm: 50_000_000,
BaseRewardCoins: 5000,
EffectiveRewardCoins: 5000,
HighMultiplier: true,
RewardStatus: "granted",
WalletTransactionId: "wallet-lucky-1",
DrawCreatedAtMs: 1_783_940_398_274,
RewardGrantedAtMs: 1_783_940_398_300,
})
if err != nil {
t.Fatalf("Build RoomLuckyGiftDrawn envelope failed: %v", err)
}
record.EventID = "lucky_gift_drawn:lucky_draw_1"
record.Envelope.EventId = record.EventID
event, publish, err := roomEventFromEnvelope(record.Envelope)
if err != nil {
t.Fatalf("RoomLuckyGiftDrawn should decode: %v", err)
}
if !publish || event.EventType != "lucky_gift_drawn" || event.EventID != record.EventID || event.ActorUserID != 1001 || event.TargetUserID != 1002 || event.GiftValue != 5000 {
t.Fatalf("owner lucky gift IM event mismatch: publish=%t event=%+v", publish, event)
}
if event.Attributes["sender_name"] != "Lucky Sender" ||
event.Attributes["sender_pretty_display_user_id"] != "VIP123456" ||
event.Attributes["multiplier_ppm"] != "50000000" ||
event.Attributes["reward_status"] != "granted" ||
event.Attributes["created_at_ms"] != "1783940398274" ||
event.Attributes["wallet_transaction_id"] != "wallet-lucky-1" {
t.Fatalf("owner lucky gift IM attributes mismatch: %+v", event.Attributes)
}
}
func TestRoomMicChangedCarriesTargetGiftValueInIMAttributes(t *testing.T) { func TestRoomMicChangedCarriesTargetGiftValueInIMAttributes(t *testing.T) {
record, err := outbox.Build("room-mic-value", "RoomMicChanged", 9, time.Now(), &roomeventsv1.RoomMicChanged{ record, err := outbox.Build("room-mic-value", "RoomMicChanged", 9, time.Now(), &roomeventsv1.RoomMicChanged{
ActorUserId: 1001, ActorUserId: 1001,

View File

@ -92,6 +92,11 @@ func luckyGiftMetaForTarget(ctx context.Context, meta *roomv1.RequestMeta, cmd c
PoolId: cmd.PoolID, PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID, VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID, CountryId: cmd.SenderCountryID,
// 展示资料来自 gateway 本次送礼入口的用户快照,随抽奖事实持久化后,下游不需要在异步消费时反查当前资料。
SenderName: giftDisplayName(cmd.SenderDisplayProfile),
SenderAvatar: strings.TrimSpace(cmd.SenderDisplayProfile.Avatar),
SenderDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.DisplayUserID),
SenderPrettyDisplayUserId: strings.TrimSpace(cmd.SenderDisplayProfile.PrettyDisplayUserID),
} }
} }

View File

@ -0,0 +1,113 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/luckygiftmq"
"hyapp/services/room-service/internal/room/outbox"
)
const roomLuckyGiftDrawnEventType = "RoomLuckyGiftDrawn"
// HandleLuckyGiftOutboxEvent 把 lucky-gift-service 已完成返奖的 owner 事实持久化为房间展示 outbox。
// 中奖展示不改变房间状态,因此不能伪造 Room Cell 命令、推进 room_version 或写 command log
// room_outbox 的稳定 event_id 同时承担本服务消费幂等和腾讯云 IM 失败补偿。
func (s *Service) HandleLuckyGiftOutboxEvent(ctx context.Context, envelope *luckygifteventsv1.EventEnvelope) error {
if s == nil || s.repository == nil {
return errors.New("room repository is not configured")
}
if envelope == nil || envelope.GetEventType() != luckygiftmq.EventTypeLuckyGiftDrawn {
return errors.New("unexpected lucky gift outbox event")
}
var drawn luckygifteventsv1.LuckyGiftDrawn
if err := proto.Unmarshal(envelope.GetBody(), &drawn); err != nil {
return fmt.Errorf("decode lucky gift drawn body: %w", err)
}
if err := validateLuckyGiftDrawnEnvelope(envelope, &drawn); err != nil {
return err
}
// owner 发布全部正向返奖事实;房间金色飘屏只消费达到统一 10x 门槛的 granted 中奖。
// 低倍事实没有房间副作用,重复投递仍保持同样的 no-op 结果。
if !strings.EqualFold(strings.TrimSpace(drawn.GetRewardStatus()), "granted") ||
drawn.GetEffectiveRewardCoins() <= 0 ||
drawn.GetMultiplierPpm() < luckygiftmq.LuckyGiftDisplayMinMultiplierPPM {
return nil
}
body := &roomeventsv1.RoomLuckyGiftDrawn{
DrawId: drawn.GetDrawId(),
CommandId: drawn.GetCommandId(),
PoolId: drawn.GetPoolId(),
GiftId: drawn.GetGiftId(),
GiftCount: drawn.GetGiftCount(),
SenderUserId: drawn.GetSenderUserId(),
TargetUserId: drawn.GetTargetUserId(),
SenderName: drawn.GetSenderName(),
SenderAvatar: drawn.GetSenderAvatar(),
SenderDisplayUserId: drawn.GetSenderDisplayUserId(),
SenderPrettyDisplayUserId: drawn.GetSenderPrettyDisplayUserId(),
VisibleRegionId: drawn.GetVisibleRegionId(),
CountryId: drawn.GetCountryId(),
CoinSpent: drawn.GetCoinSpent(),
RuleVersion: drawn.GetRuleVersion(),
ExperiencePool: drawn.GetExperiencePool(),
SelectedTierId: drawn.GetSelectedTierId(),
MultiplierPpm: drawn.GetMultiplierPpm(),
BaseRewardCoins: drawn.GetBaseRewardCoins(),
EffectiveRewardCoins: drawn.GetEffectiveRewardCoins(),
StageFeedback: drawn.GetStageFeedback(),
HighMultiplier: drawn.GetHighMultiplier(),
RewardStatus: "granted",
WalletTransactionId: drawn.GetWalletTransactionId(),
DrawCreatedAtMs: drawn.GetDrawCreatedAtMs(),
RewardGrantedAtMs: drawn.GetRewardGrantedAtMs(),
}
record, err := outbox.Build(drawn.GetRoomId(), roomLuckyGiftDrawnEventType, 0, time.UnixMilli(envelope.GetOccurredAtMs()).UTC(), body)
if err != nil {
return err
}
// owner event_id 是跨服务唯一事实 ID覆盖 room 本地随机 ID 后MQ 重投只能命中同一条 room_outbox。
record.AppCode = strings.TrimSpace(envelope.GetAppCode())
record.EventID = strings.TrimSpace(envelope.GetEventId())
record.Envelope.AppCode = record.AppCode
record.Envelope.EventId = record.EventID
ctx = appcode.WithContext(ctx, record.AppCode)
if err := s.repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil {
return err
}
// 唤醒必须发生在 SaveOutbox 提交之后;重复事实最多产生一个多余唤醒,不会产生第二条 IM outbox。
s.notifyOutboxCommitted([]outbox.Record{record})
return nil
}
func validateLuckyGiftDrawnEnvelope(envelope *luckygifteventsv1.EventEnvelope, drawn *luckygifteventsv1.LuckyGiftDrawn) error {
if envelope == nil || drawn == nil {
return errors.New("lucky gift drawn fact is required")
}
appCode := strings.TrimSpace(envelope.GetAppCode())
drawID := strings.TrimSpace(drawn.GetDrawId())
eventID := strings.TrimSpace(envelope.GetEventId())
if appCode == "" || drawID == "" || drawID != strings.TrimSpace(envelope.GetDrawId()) ||
strings.TrimSpace(drawn.GetRoomId()) == "" || strings.TrimSpace(drawn.GetCommandId()) == "" ||
strings.TrimSpace(drawn.GetGiftId()) == "" || drawn.GetGiftCount() <= 0 ||
drawn.GetSenderUserId() <= 0 || drawn.GetTargetUserId() <= 0 ||
envelope.GetOccurredAtMs() <= 0 || drawn.GetDrawCreatedAtMs() <= 0 || drawn.GetRewardGrantedAtMs() <= 0 {
return errors.New("lucky gift drawn fact is incomplete")
}
if eventID != "lucky_gift_drawn:"+drawID {
// 同一 draw 只能映射到一个房间 IM 幂等键;不能接受 producer 临时生成的随机 event_id。
return errors.New("lucky gift drawn event_id does not match draw_id")
}
return nil
}

View File

@ -0,0 +1,147 @@
package service
import (
"context"
"sync"
"testing"
"google.golang.org/protobuf/proto"
luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/luckygiftmq"
"hyapp/services/room-service/internal/room/outbox"
)
type luckyGiftDisplayRepository struct {
Repository
mu sync.Mutex
records map[string]outbox.Record
}
func (r *luckyGiftDisplayRepository) SaveOutbox(_ context.Context, records []outbox.Record) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.records == nil {
r.records = make(map[string]outbox.Record)
}
for _, record := range records {
key := record.AppCode + "\x00" + record.EventID
if _, exists := r.records[key]; !exists {
r.records[key] = record
}
}
return nil
}
func (r *luckyGiftDisplayRepository) snapshot() []outbox.Record {
r.mu.Lock()
defer r.mu.Unlock()
result := make([]outbox.Record, 0, len(r.records))
for _, record := range r.records {
result = append(result, record)
}
return result
}
func TestHandleLuckyGiftOutboxEventPersistsOneDerivedRoomDisplayOutbox(t *testing.T) {
repository := &luckyGiftDisplayRepository{}
svc := &Service{repository: repository, outboxWake: make(chan struct{}, 4)}
envelope := luckyGiftDisplayEnvelope(t, luckygiftmq.LuckyGiftDisplayMinMultiplierPPM)
ctx := appcode.WithContext(context.Background(), "lalu")
if err := svc.HandleLuckyGiftOutboxEvent(ctx, envelope); err != nil {
t.Fatalf("HandleLuckyGiftOutboxEvent failed: %v", err)
}
if err := svc.HandleLuckyGiftOutboxEvent(ctx, envelope); err != nil {
t.Fatalf("replayed HandleLuckyGiftOutboxEvent failed: %v", err)
}
records := repository.snapshot()
if len(records) != 1 {
t.Fatalf("owner event replay must keep one room outbox row, got %d", len(records))
}
record := records[0]
if record.AppCode != "lalu" || record.EventID != "lucky_gift_drawn:lucky_draw_room_1" || record.EventType != roomLuckyGiftDrawnEventType || record.RoomID != "room-lucky-display" || record.Envelope.GetRoomVersion() != 0 {
t.Fatalf("derived room outbox identity mismatch: %+v envelope=%+v", record, record.Envelope)
}
var body roomeventsv1.RoomLuckyGiftDrawn
if err := proto.Unmarshal(record.Envelope.GetBody(), &body); err != nil {
t.Fatalf("decode derived RoomLuckyGiftDrawn failed: %v", err)
}
if body.GetDrawId() != "lucky_draw_room_1" || body.GetSenderName() != "Sender" || body.GetMultiplierPpm() != luckygiftmq.LuckyGiftDisplayMinMultiplierPPM || body.GetRewardStatus() != "granted" {
t.Fatalf("derived RoomLuckyGiftDrawn mismatch: %+v", &body)
}
}
func TestHandleLuckyGiftOutboxEventSkipsRewardBelowDisplayThreshold(t *testing.T) {
repository := &luckyGiftDisplayRepository{}
svc := &Service{repository: repository, outboxWake: make(chan struct{}, 1)}
envelope := luckyGiftDisplayEnvelope(t, luckygiftmq.LuckyGiftDisplayMinMultiplierPPM-1)
if err := svc.HandleLuckyGiftOutboxEvent(context.Background(), envelope); err != nil {
t.Fatalf("below-threshold owner fact should be acknowledged: %v", err)
}
if records := repository.snapshot(); len(records) != 0 {
t.Fatalf("below-threshold owner fact must have no room display side effect: %+v", records)
}
}
func TestHandleLuckyGiftOutboxEventRejectsUnstableEventID(t *testing.T) {
repository := &luckyGiftDisplayRepository{}
svc := &Service{repository: repository, outboxWake: make(chan struct{}, 1)}
envelope := luckyGiftDisplayEnvelope(t, luckygiftmq.LuckyGiftDisplayMinMultiplierPPM)
envelope.EventId = "evt_random"
if err := svc.HandleLuckyGiftOutboxEvent(context.Background(), envelope); err == nil {
t.Fatal("owner fact with random event_id must be rejected")
}
if records := repository.snapshot(); len(records) != 0 {
t.Fatalf("invalid owner fact must not persist room display outbox: %+v", records)
}
}
func luckyGiftDisplayEnvelope(t *testing.T, multiplierPPM int64) *luckygifteventsv1.EventEnvelope {
t.Helper()
drawn := &luckygifteventsv1.LuckyGiftDrawn{
DrawId: "lucky_draw_room_1",
CommandId: "cmd_lucky_room_1",
RoomId: "room-lucky-display",
PoolId: "advanced",
GiftId: "43",
GiftCount: 1,
SenderUserId: 123456,
TargetUserId: 654321,
SenderName: "Sender",
SenderAvatar: "https://cdn.example.test/sender.png",
SenderDisplayUserId: "123456",
SenderPrettyDisplayUserId: "VIP123456",
VisibleRegionId: 1,
CountryId: 86,
CoinSpent: 100,
RuleVersion: 3,
ExperiencePool: "normal",
SelectedTierId: "10x",
MultiplierPpm: multiplierPPM,
BaseRewardCoins: 1000,
EffectiveRewardCoins: 1000,
HighMultiplier: true,
RewardStatus: "granted",
WalletTransactionId: "wallet-lucky-room-1",
DrawCreatedAtMs: 1_783_940_398_274,
RewardGrantedAtMs: 1_783_940_398_300,
}
body, err := proto.Marshal(drawn)
if err != nil {
t.Fatalf("marshal LuckyGiftDrawn failed: %v", err)
}
return &luckygifteventsv1.EventEnvelope{
EventId: "lucky_gift_drawn:lucky_draw_room_1",
EventType: luckygiftmq.EventTypeLuckyGiftDrawn,
AppCode: "lalu",
DrawId: drawn.GetDrawId(),
OccurredAtMs: drawn.GetDrawCreatedAtMs(),
Body: body,
}
}

View File

@ -152,6 +152,13 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
GiftId: "rose", GiftId: "rose",
GiftCount: 1, GiftCount: 1,
PoolId: "super_lucky", PoolId: "super_lucky",
SenderDisplayProfile: &roomv1.SendGiftDisplayProfile{
UserId: ownerID,
Username: "lucky sender",
Avatar: "https://cdn.example.com/sender.png",
DisplayUserId: "123456",
PrettyDisplayUserId: "HY-123456",
},
}) })
if err != nil { if err != nil {
t.Fatalf("send lucky gift failed: %v", err) t.Fatalf("send lucky gift failed: %v", err)
@ -173,7 +180,11 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
drawMeta.GetCoinSpent() != 100 || drawMeta.GetCoinSpent() != 100 ||
drawMeta.GetDeviceId() != "device-session-1" || drawMeta.GetDeviceId() != "device-session-1" ||
drawMeta.GetAnchorId() != "101" || drawMeta.GetAnchorId() != "101" ||
drawMeta.GetVisibleRegionId() != 9001 { drawMeta.GetVisibleRegionId() != 9001 ||
drawMeta.GetSenderName() != "lucky sender" ||
drawMeta.GetSenderAvatar() != "https://cdn.example.com/sender.png" ||
drawMeta.GetSenderDisplayUserId() != "123456" ||
drawMeta.GetSenderPrettyDisplayUserId() != "HY-123456" {
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta) t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
} }
} }

View File

@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"sort" "sort"
@ -198,6 +199,10 @@ func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorker
if options.RetryStrategy != outboxRetryExponentialBackoff { if options.RetryStrategy != outboxRetryExponentialBackoff {
return fmt.Errorf("unsupported outbox retry_strategy %q", options.RetryStrategy) return fmt.Errorf("unsupported outbox retry_strategy %q", options.RetryStrategy)
} }
appCodes, err := s.roomOutboxAppCodes(ctx)
if err != nil || len(appCodes) == 0 {
return err
}
for processed := 0; processed < options.BatchSize; processed++ { for processed := 0; processed < options.BatchSize; processed++ {
select { select {
@ -213,7 +218,7 @@ func (s *Service) processPendingOutbox(ctx context.Context, options OutboxWorker
// batch_size*publish_timeout吞吐由配置的 worker concurrency 横向提供。 // batch_size*publish_timeout吞吐由配置的 worker concurrency 横向提供。
claimOptions := options claimOptions := options
claimOptions.BatchSize = 1 claimOptions.BatchSize = 1
records, err := s.claimPendingOutbox(ctx, claimOptions) records, err := s.claimPendingOutboxForApps(ctx, claimOptions, appCodes)
if err != nil { if err != nil {
return err return err
} }
@ -317,9 +322,61 @@ func outboxEventPriority(eventType string) int {
} }
func (s *Service) claimPendingOutbox(ctx context.Context, options OutboxWorkerOptions) ([]outbox.Record, error) { func (s *Service) claimPendingOutbox(ctx context.Context, options OutboxWorkerOptions) ([]outbox.Record, error) {
appCodes, err := s.roomOutboxAppCodes(ctx)
if err != nil {
return nil, err
}
return s.claimPendingOutboxForApps(ctx, options, appCodes)
}
func (s *Service) claimPendingOutboxForApps(ctx context.Context, options OutboxWorkerOptions, appCodes []string) ([]outbox.Record, error) {
if len(appCodes) == 0 {
return nil, nil
}
lockUntilMS := time.Now().UTC().Add(outboxSingleRecordLease(options.PublishTimeout)).UnixMilli() lockUntilMS := time.Now().UTC().Add(outboxSingleRecordLease(options.PublishTimeout)).UnixMilli()
workerID := firstNonEmpty(strings.TrimSpace(options.WorkerID), s.nodeID) workerID := firstNonEmpty(strings.TrimSpace(options.WorkerID), s.nodeID)
return s.repository.ClaimPendingOutbox(ctx, workerID, options.BatchSize, lockUntilMS)
// 主 worker 每次只 claim 一条;轮转 App 起点可保留单条 lease 语义,同时避免某个高流量 App 独占所有轮次。
start := int(s.outboxClaimCursor.Add(1)-1) % len(appCodes)
for offset := 0; offset < len(appCodes); offset++ {
appCode := appCodes[(start+offset)%len(appCodes)]
records, err := s.repository.ClaimPendingOutbox(appcode.WithContext(ctx, appCode), workerID, options.BatchSize, lockUntilMS)
if err != nil {
return nil, err
}
if len(records) > 0 {
return records, nil
}
}
return nil, nil
}
type roomOutboxAppCodeLister interface {
ListRoomOutboxAppCodes(ctx context.Context) ([]string, error)
}
func (s *Service) roomOutboxAppCodes(ctx context.Context) ([]string, error) {
// memory/fake repository 没有跨 App 枚举能力时沿用调用上下文,保持隔离测试和旧实现兼容。
lister, ok := s.repository.(roomOutboxAppCodeLister)
if !ok {
return []string{appcode.FromContext(ctx)}, nil
}
appCodes, err := lister.ListRoomOutboxAppCodes(ctx)
if err != nil {
return nil, err
}
seen := make(map[string]struct{}, len(appCodes))
normalized := make([]string, 0, len(appCodes))
for _, appCode := range appCodes {
appCode = appcode.Normalize(appCode)
if _, exists := seen[appCode]; exists {
continue
}
seen[appCode] = struct{}{}
normalized = append(normalized, appCode)
}
sort.Strings(normalized)
return normalized, nil
} }
func outboxSingleRecordLease(publishTimeout time.Duration) time.Duration { func outboxSingleRecordLease(publishTimeout time.Duration) time.Duration {
@ -345,6 +402,17 @@ func (s *Service) markOutboxDead(ctx context.Context, eventID string, lastErr st
} }
func (s *Service) publishOutboxRecord(ctx context.Context, record outbox.Record) error { func (s *Service) publishOutboxRecord(ctx context.Context, record outbox.Record) error {
if record.EventType == roomLuckyGiftDrawnEventType {
// 真人中奖展示是 lucky-gift owner 事实在 room-service 的本地派生 outbox只投腾讯云房间群。
// 若继续走通用 outboxPublisher会把派生事件重新发到 hyapp_room_outbox造成事实 owner 混淆和循环 fanout。
if record.Envelope == nil || record.Envelope.GetEventType() != roomLuckyGiftDrawnEventType {
return errors.New("room lucky gift display envelope is invalid")
}
if s.roomDisplayPublisher == nil {
return errors.New("room display publisher is not configured")
}
return s.roomDisplayPublisher.PublishOutboxEvent(ctx, record.Envelope)
}
// Ignited 在 outbox worker 内安排延迟发射,保证 MQ 调度失败能通过 room_outbox retry客户端 IM 展示已在 Room Cell 提交后直发。 // Ignited 在 outbox worker 内安排延迟发射,保证 MQ 调度失败能通过 room_outbox retry客户端 IM 展示已在 Room Cell 提交后直发。
if err := s.scheduleRoomRocketLaunchFromEnvelope(ctx, record.Envelope); err != nil { if err := s.scheduleRoomRocketLaunchFromEnvelope(ctx, record.Envelope); err != nil {
return err return err

View File

@ -1,12 +1,42 @@
package service package service
import ( import (
"context"
"testing" "testing"
"time" "time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/outbox"
) )
type countingOutboxPublisher struct {
events []*roomeventsv1.EventEnvelope
}
type crossAppOutboxRepository struct {
Repository
claims []string
}
func (r *crossAppOutboxRepository) ListRoomOutboxAppCodes(context.Context) ([]string, error) {
return []string{"fami", "lalu"}, nil
}
func (r *crossAppOutboxRepository) ClaimPendingOutbox(ctx context.Context, _ string, _ int, _ int64) ([]outbox.Record, error) {
appCode := appcode.FromContext(ctx)
r.claims = append(r.claims, appCode)
if appCode != "fami" {
return nil, nil
}
return []outbox.Record{{AppCode: "fami", EventID: "lucky_gift_drawn:fami-draw"}}, nil
}
func (p *countingOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error {
p.events = append(p.events, envelope)
return nil
}
func TestOutboxSingleRecordLeaseCoversPublishAndStateWrite(t *testing.T) { func TestOutboxSingleRecordLeaseCoversPublishAndStateWrite(t *testing.T) {
if got := outboxSingleRecordLease(15 * time.Second); got != 45*time.Second { if got := outboxSingleRecordLease(15 * time.Second); got != 45*time.Second {
t.Fatalf("single-record lease = %s, want 45s", got) t.Fatalf("single-record lease = %s, want 45s", got)
@ -16,6 +46,21 @@ func TestOutboxSingleRecordLeaseCoversPublishAndStateWrite(t *testing.T) {
} }
} }
func TestClaimPendingOutboxFindsNonDefaultApp(t *testing.T) {
repository := &crossAppOutboxRepository{}
svc := &Service{nodeID: "room-cross-app", repository: repository}
records, err := svc.claimPendingOutbox(context.Background(), OutboxWorkerOptions{BatchSize: 1, PublishTimeout: time.Second})
if err != nil {
t.Fatalf("claim cross-app outbox: %v", err)
}
if len(records) != 1 || records[0].AppCode != "fami" {
t.Fatalf("non-default app outbox was not claimed: %+v", records)
}
if len(repository.claims) == 0 || repository.claims[len(repository.claims)-1] != "fami" {
t.Fatalf("claim contexts did not reach fami: %v", repository.claims)
}
}
func TestSortOutboxRecordsPrioritizesGiftDisplay(t *testing.T) { func TestSortOutboxRecordsPrioritizesGiftDisplay(t *testing.T) {
// Direct IM/robot display 仍使用批内排序;低优先级事件故意更早,验证礼物展示优先。 // Direct IM/robot display 仍使用批内排序;低优先级事件故意更早,验证礼物展示优先。
records := []outbox.Record{ records := []outbox.Record{
@ -34,3 +79,42 @@ func TestSortOutboxRecordsPrioritizesGiftDisplay(t *testing.T) {
} }
} }
} }
func TestPublishOutboxRecordRoutesLuckyGiftDisplayOnlyToRoomIM(t *testing.T) {
display := &countingOutboxPublisher{}
fanout := &countingOutboxPublisher{}
svc := &Service{roomDisplayPublisher: display, outboxPublisher: fanout}
record := outbox.Record{
EventID: "lucky_gift_drawn:draw-1",
EventType: roomLuckyGiftDrawnEventType,
RoomID: "room-1",
Envelope: &roomeventsv1.EventEnvelope{
EventId: "lucky_gift_drawn:draw-1",
EventType: roomLuckyGiftDrawnEventType,
RoomId: "room-1",
},
}
if err := svc.publishOutboxRecord(context.Background(), record); err != nil {
t.Fatalf("publish lucky display outbox failed: %v", err)
}
if len(display.events) != 1 {
t.Fatalf("lucky display must reach room IM publisher once, got %d", len(display.events))
}
if len(fanout.events) != 0 {
t.Fatalf("derived lucky display must not return to room MQ fanout: %d", len(fanout.events))
}
}
func TestPublishOutboxRecordKeepsLuckyGiftPendingWithoutRoomIMPublisher(t *testing.T) {
svc := &Service{outboxPublisher: &countingOutboxPublisher{}}
err := svc.publishOutboxRecord(context.Background(), outbox.Record{
EventID: "lucky_gift_drawn:draw-2",
EventType: roomLuckyGiftDrawnEventType,
RoomID: "room-2",
Envelope: &roomeventsv1.EventEnvelope{EventId: "lucky_gift_drawn:draw-2", EventType: roomLuckyGiftDrawnEventType, RoomId: "room-2"},
})
if err == nil {
t.Fatal("lucky display without room IM publisher must stay retryable")
}
}

View File

@ -808,6 +808,9 @@ type RoomMetaStore interface {
SaveRoomMeta(ctx context.Context, meta RoomMeta) error SaveRoomMeta(ctx context.Context, meta RoomMeta) error
// GetRoomMeta 读取房间基础元数据,恢复无内存 Cell 的房间依赖它。 // GetRoomMeta 读取房间基础元数据,恢复无内存 Cell 的房间依赖它。
GetRoomMeta(ctx context.Context, roomID string) (RoomMeta, bool, error) GetRoomMeta(ctx context.Context, roomID string) (RoomMeta, bool, error)
// GetRoomMetaByGlobalID 只供已验签的 RTC 等外部回调解析房间租户。
// room_id 由全局 ID 生成器保证跨 App 唯一;普通业务查询仍必须使用 app_code + room_id。
GetRoomMetaByGlobalID(ctx context.Context, roomID string) (RoomMeta, bool, error)
// GetRoomMetaByShortID 用客户端展示短房号解析内部 room_id命令链路必须先 canonicalize 后再取 lease 和写 command log。 // GetRoomMetaByShortID 用客户端展示短房号解析内部 room_id命令链路必须先 canonicalize 后再取 lease 和写 command log。
GetRoomMetaByShortID(ctx context.Context, roomShortID string) (RoomMeta, bool, error) GetRoomMetaByShortID(ctx context.Context, roomShortID string) (RoomMeta, bool, error)
// GetRoomMetaByOwner 查询 owner 已创建的房间;产品规则要求一个用户只能创建一个房间。 // GetRoomMetaByOwner 查询 owner 已创建的房间;产品规则要求一个用户只能创建一个房间。
@ -854,7 +857,7 @@ type RoomConfigStore interface {
// OutboxStore 管理 room_outbox 的补偿投递生命周期。 // OutboxStore 管理 room_outbox 的补偿投递生命周期。
type OutboxStore interface { type OutboxStore interface {
// SaveOutbox 写入房间外事件,必须和成功命令保持同一提交语义 // SaveOutbox 写入房间外事件。Room Cell 事件必须和成功命令同事务提交;外部 owner 派生展示则以 source event_id 幂等单独提交
SaveOutbox(ctx context.Context, records []outbox.Record) error SaveOutbox(ctx context.Context, records []outbox.Record) error
// ListPendingOutbox 扫描待补偿投递事件。 // ListPendingOutbox 扫描待补偿投递事件。
ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error)

View File

@ -0,0 +1,27 @@
package service
import (
"context"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
)
// ResolveRoomAppCode 让已验签的腾讯 RTC 回调从房间事实解析租户。
// room-service 是房间归属 ownergateway 不应信任 URL 中可伪造、也容易写死的 app_code。
func (s *Service) ResolveRoomAppCode(ctx context.Context, req *roomv1.ResolveRoomAppCodeRequest) (*roomv1.ResolveRoomAppCodeResponse, error) {
roomID := strings.TrimSpace(req.GetRoomId())
if !roomid.ValidStringID(roomID) {
return nil, xerr.New(xerr.InvalidArgument, "room_id is invalid")
}
meta, exists, err := s.repository.GetRoomMetaByGlobalID(ctx, roomID)
if err != nil {
return nil, err
}
if !exists || strings.TrimSpace(meta.AppCode) == "" {
return nil, xerr.New(xerr.NotFound, "room not found")
}
return &roomv1.ResolveRoomAppCodeResponse{AppCode: meta.AppCode}, nil
}

View File

@ -37,7 +37,7 @@ type Config struct {
RoomRocketLaunchScheduler integration.RoomRocketLaunchScheduler RoomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
// RobotDisplayPublisher 专门 best-effort 投递机器人房间展示事件;失败只打日志,不进入 MySQL outbox。 // RobotDisplayPublisher 专门 best-effort 投递机器人房间展示事件;失败只打日志,不进入 MySQL outbox。
RobotDisplayPublisher integration.OutboxPublisher RobotDisplayPublisher integration.OutboxPublisher
// RoomDisplayPublisher 在 Room Cell 提交后直接投递腾讯 IM 展示事件;失败不回滚已提交房间命令 // RoomDisplayPublisher 在 Room Cell 提交后直投低延迟展示,也由幸运礼物派生 outbox 补偿投递房间 IM
RoomDisplayPublisher integration.OutboxPublisher RoomDisplayPublisher integration.OutboxPublisher
// HumanRobotPoolProvider 自动读取全站机器人账号和用户国家,用于真人房间机器人运行时按国家分组。 // HumanRobotPoolProvider 自动读取全站机器人账号和用户国家,用于真人房间机器人运行时按国家分组。
HumanRobotPoolProvider integration.HumanRoomRobotPoolProvider HumanRobotPoolProvider integration.HumanRoomRobotPoolProvider
@ -83,10 +83,12 @@ type Service struct {
outboxPublisher integration.OutboxPublisher outboxPublisher integration.OutboxPublisher
// robotDisplayPublisher 只服务机器人房间展示事件,和主 room_outbox 持久事实隔离。 // robotDisplayPublisher 只服务机器人房间展示事件,和主 room_outbox 持久事实隔离。
robotDisplayPublisher integration.OutboxPublisher robotDisplayPublisher integration.OutboxPublisher
// roomDisplayPublisher 只承载客户端可由快照修复的直接 IM 展示事件,不作为业务事实源 // roomDisplayPublisher 只承载腾讯 IM 展示,不作为业务事实源;真人幸运飘屏由本地 room_outbox 兜住失败重试
roomDisplayPublisher integration.OutboxPublisher roomDisplayPublisher integration.OutboxPublisher
// outboxWake 提交成功后唤醒主 outbox worker它只是低延迟提示事实源仍然是 MySQL room_outbox。 // outboxWake 提交成功后唤醒主 outbox worker它只是低延迟提示事实源仍然是 MySQL room_outbox。
outboxWake chan struct{} outboxWake chan struct{}
// outboxClaimCursor 让跨 App claim 轮转起点,避免默认 lalu 长期有积压时饿死 fami/huwaa 等租户。
outboxClaimCursor atomic.Uint64
// humanRobotPoolProvider 运行时自动获取全站机器人池,避免后台手工维护国家机器人池。 // humanRobotPoolProvider 运行时自动获取全站机器人池,避免后台手工维护国家机器人池。
humanRobotPoolProvider integration.HumanRoomRobotPoolProvider humanRobotPoolProvider integration.HumanRoomRobotPoolProvider
// roomRocketLaunchScheduler 在 ignited outbox 投递成功后安排 launch_at_ms 唤醒。 // roomRocketLaunchScheduler 在 ignited outbox 投递成功后安排 launch_at_ms 唤醒。

View File

@ -13,7 +13,7 @@ import (
"hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/outbox"
) )
// SaveOutbox 追加待投递 room_outbox 事件;机器人展示事件已经改为提交后 best-effort 直发 IM不再进入 MySQL // SaveOutbox 追加待投递 room_outbox 事件;同一 app_code/event_id 重投保持 no-op既覆盖 Room Cell 事实,也覆盖外部 owner 派生展示
func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error { func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) error {
if len(records) == 0 { if len(records) == 0 {
// 没有事件时保持无副作用,方便领域层统一调用。 // 没有事件时保持无副作用,方便领域层统一调用。
@ -64,6 +64,25 @@ func (r *Repository) SaveOutbox(ctx context.Context, records []outbox.Record) er
return tx.Commit() return tx.Commit()
} }
// ListRoomOutboxAppCodes 枚举本表实际存在的租户键,供常驻 worker 逐 App 使用现有前缀索引 claim。
// app_code 是主键首列DISTINCT 可走有序索引;相比移除 app_code 条件全表 claim不会让热 outbox 扫描退化。
func (r *Repository) ListRoomOutboxAppCodes(ctx context.Context) ([]string, error) {
rows, err := r.db.QueryContext(ctx, `SELECT DISTINCT app_code FROM room_outbox ORDER BY app_code`)
if err != nil {
return nil, err
}
defer rows.Close()
appCodes := make([]string, 0, 4)
for rows.Next() {
var appCode string
if err := rows.Scan(&appCode); err != nil {
return nil, err
}
appCodes = append(appCodes, appCode)
}
return appCodes, rows.Err()
}
// ListPendingOutbox 返回待投递事件。 // ListPendingOutbox 返回待投递事件。
func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) { func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error) {
if limit <= 0 { if limit <= 0 {

View File

@ -60,6 +60,27 @@ func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservic
return meta, true, nil return meta, true, nil
} }
// GetRoomMetaByGlobalID 从全局 room_id 解析房间所属 App。
// 该查询只允许可信内部回调使用uk_rooms_room_id 同时保证查询命中索引并阻止跨 App 歧义。
func (r *Repository) GetRoomMetaByGlobalID(ctx context.Context, roomID string) (roomservice.RoomMeta, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id
FROM rooms
WHERE room_id = ?
LIMIT 1`,
strings.TrimSpace(roomID),
)
var meta roomservice.RoomMeta
if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RoomMeta{}, false, nil
}
return roomservice.RoomMeta{}, false, err
}
return meta, true, nil
}
// GetRoomMetaByShortID 通过展示短房号解析内部房间 ID。 // GetRoomMetaByShortID 通过展示短房号解析内部房间 ID。
func (r *Repository) GetRoomMetaByShortID(ctx context.Context, roomShortID string) (roomservice.RoomMeta, bool, error) { func (r *Repository) GetRoomMetaByShortID(ctx context.Context, roomShortID string) (roomservice.RoomMeta, bool, error) {
// rooms.uk_rooms_short_id 保证同 app 下短房号唯一;这里只做解析,不改变 GetRoomMeta 的精确 room_id 语义。 // rooms.uk_rooms_short_id 保证同 app 下短房号唯一;这里只做解析,不改变 GetRoomMeta 的精确 room_id 语义。

View File

@ -23,3 +23,23 @@ func TestGetRoomMetaByShortIDReturnsCanonicalRoomID(t *testing.T) {
t.Fatalf("short id must return canonical room meta, got %+v", meta) t.Fatalf("short id must return canonical room meta, got %+v", meta)
} }
} }
func TestGetRoomMetaByGlobalIDResolvesOwningApp(t *testing.T) {
ctx := context.Background()
repo := newCommandLogTestRepository(t, ctx)
if _, err := repo.db.ExecContext(ctx, `
INSERT INTO rooms (
app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status,
visible_region_id, created_at_ms, updated_at_ms
) VALUES ('fami', 'room-fami-rtc', 'fami-rtc', 9001, 8, 'voice', 'active', 1, 1000, 1000)`); err != nil {
t.Fatalf("insert fami room: %v", err)
}
meta, exists, err := repo.GetRoomMetaByGlobalID(appcode.WithContext(ctx, "lalu"), "room-fami-rtc")
if err != nil || !exists {
t.Fatalf("resolve global room app: exists=%t err=%v", exists, err)
}
if meta.AppCode != "fami" || meta.RoomID != "room-fami-rtc" {
t.Fatalf("global room lookup must ignore caller default app and return owner: %+v", meta)
}
}

View File

@ -292,6 +292,11 @@ func (s *Server) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventReq
return mapServiceResult(s.svc.ApplyRTCEvent(ctx, req)) return mapServiceResult(s.svc.ApplyRTCEvent(ctx, req))
} }
// ResolveRoomAppCode 仅解析已验签回调的房间租户,不读取或修改 Room Cell 状态。
func (s *Server) ResolveRoomAppCode(ctx context.Context, req *roomv1.ResolveRoomAppCodeRequest) (*roomv1.ResolveRoomAppCodeResponse, error) {
return mapServiceResult(s.svc.ResolveRoomAppCode(ctx, req))
}
// SetMicSeatLock 代理到领域服务。 // SetMicSeatLock 代理到领域服务。
func (s *Server) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) { func (s *Server) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
// 锁麦权限和目标麦位状态由 Room Cell 判定。 // 锁麦权限和目标麦位状态由 Room Cell 判定。