增加飘屏
This commit is contained in:
parent
ed3e7096e3
commit
1d581ff493
1
Makefile
1
Makefile
@ -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 版本时先暴露静态风险。
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
512
api/proto/events/luckygift/v1/events.pb.go
Normal file
512
api/proto/events/luckygift/v1/events.pb.go
Normal 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 milliseconds;MQ 实际发送时间不改变业务发生时间。
|
||||||
|
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
|
||||||
|
}
|
||||||
54
api/proto/events/luckygift/v1/events.proto
Normal file
54
api/proto/events/luckygift/v1/events.proto
Normal 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 milliseconds;MQ 实际发送时间不改变业务发生时间。
|
||||||
|
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;
|
||||||
|
}
|
||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
@ -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
@ -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 状态的读模型查询。
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
351
docs/新App上线配置检查清单.md
Normal file
351
docs/新App上线配置检查清单.md
Normal 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` | 开关、一个当前版本和 1–7 天完整奖励 |
|
||||||
|
| 每日任务 | `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,但 VIP1–VIP10 全部 disabled | VIP1–VIP8 active | 只启用已完成价格、权益和资源配置的 VIP1–VIP9 |
|
||||||
|
| 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 旧副本代替 |
|
||||||
|
| 首充奖励 | 开关 0,3 个档位全部 disabled | 开关 1,3 个 active | 确认奖励后启用并走真实充值事实 |
|
||||||
|
| 累充奖励 | 开关 0,8 个档位全部 disabled | 开关 1,8 个 active | 确认周期和奖励后启用 |
|
||||||
|
| 七日签到 | 开关 0,无生效版本,无奖励 | 开关 1,有生效版本和 7 天奖励 | 创建 Fami 版本和 7 天奖励后启用 |
|
||||||
|
| CP 周榜 | 开关 0,无奖励 | 开关 1,3 个奖励 | 确认关系类型和奖励后启用 |
|
||||||
|
| 周星 | 当前 UTC 时间无 active 周期 | Lalu 有 1 个 active 周期 | 创建 Fami 周期、指定礼物和 Top 奖励 |
|
||||||
|
| 自研游戏运行数据 | 机器人 0,档位奖池 0 | dice 机器人 80,dice/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 个 active;Fami 少 `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 价格/权益/资源后启用 VIP1–VIP9。
|
||||||
|
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` 及区域 24–30 的 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
143
pkg/luckygiftmq/messages.go
Normal 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
|
||||||
|
}
|
||||||
123
pkg/luckygiftmq/messages_test.go
Normal file
123
pkg/luckygiftmq/messages_test.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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 只接受已序列化 JSON;event_id/desc/ext 让客户端和日志都能快速识别消息类型。
|
// 腾讯 REST 只接受已序列化 JSON;event_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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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, `
|
||||||
|
|||||||
@ -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_code;gateway 会按回调 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"
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 历史事件的小批量定期删除。
|
||||||
// 只清理 delivered:pending/retryable/delivering 仍在投递生命周期内,failed 留给人工补偿。
|
// 只清理 delivered:pending/retryable/delivering 仍在投递生命周期内,failed 留给人工补偿。
|
||||||
// 抽奖事实和审计都在 lucky_draw_records,outbox 没有下游读方,默认开启。
|
// 抽奖事实和审计都在 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
|
||||||
|
}
|
||||||
|
|||||||
@ -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 := `
|
||||||
|
|||||||
@ -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_ms;owner MQ 重投必须保持同一事实正文不漂移。
|
||||||
|
RewardGrantedAtMS int64
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 走 MQ;room-service 同进程启动 IM bridge consumer 补偿房间群消息。
|
# Docker 和 testbox 走 MQ;room-service 同进程启动 IM bridge consumer 补偿房间群消息。
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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":
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 是房间归属 owner;gateway 不应信任 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
|
||||||
|
}
|
||||||
@ -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 唤醒。
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 语义。
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 判定。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user