房间语音房宝箱
This commit is contained in:
parent
7499582c45
commit
36da9df0c7
31
README.md
31
README.md
@ -9,12 +9,12 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
||||
任何开发,不需要历史兼容,目前只是在开发阶段
|
||||
|
||||
- `gateway-service` 是客户端 HTTP JSON 入口,负责鉴权、协议转换、签发腾讯云 IM UserSig,以及调用内部 gRPC 服务。
|
||||
- `room-service` 是房间业务状态 owner,负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、snapshot、command log、outbox、Redis lease,以及腾讯云 IM 群组/系统消息桥接。
|
||||
- `room-service` 是房间业务状态 owner,负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、语音房宝箱、snapshot、command log、outbox、Redis lease、RocketMQ room outbox relay,以及腾讯云 IM 群组/系统消息桥接。
|
||||
- `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL;`room-service` 的 `SendGift` 同步调用它。
|
||||
- `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。
|
||||
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路按独立消费位点接入。
|
||||
- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer;可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。
|
||||
- `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。
|
||||
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件和 `room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理。
|
||||
- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件和 `room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理,MQ 模式下通过独立 consumer group 解耦。
|
||||
- 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`。
|
||||
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`。
|
||||
|
||||
@ -23,7 +23,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
||||
- 客户端命令面:`gateway-service` 暴露 HTTP JSON。
|
||||
- 客户端实时面:腾讯云 IM SDK,客户端用 gateway 签发的 UserSig 登录。
|
||||
- 服务内部调用:gRPC + protobuf,契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。
|
||||
- 房间外事件:protobuf outbox,当前先落 MySQL。
|
||||
- 房间外事件:protobuf outbox 先落 MySQL;生产可由 RocketMQ fanout 到 activity、notice、IM bridge 和后续 push/inbox,消费端按 `event_id` 幂等。
|
||||
|
||||
## Time Model
|
||||
|
||||
@ -106,7 +106,7 @@ Authorization: Bearer <access_token>
|
||||
- `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`;`room-service` 由该 actor 收敛 owner,外部 CreateRoom 请求体不接收 `owner_user_id`。
|
||||
- `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。
|
||||
- `room-service` 在 `CreateRoom` 成功后写 `room_outbox`,由 outbox worker 异步创建或补偿同名腾讯云 IM 群组。
|
||||
- `room-service` 在上下麦、禁言、踢人、送礼等命令提交后只写房间状态、command log 和 outbox;腾讯云 IM 房间系统自定义消息由 outbox worker 异步投递。
|
||||
- `room-service` 在上下麦、禁言、踢人、送礼、宝箱进度和开箱等命令提交后只写房间状态、command log 和 outbox;腾讯云 IM 房间系统自定义消息由 outbox worker direct 投递,或由 IM bridge consumer 从 RocketMQ 消费后投递。
|
||||
- `notice-service` 消费钱包余额变更 outbox 和房间踢人 outbox,向单个用户发送 `wallet_notice`、`room_notice` 私有自定义消息;owner service 不直接调用腾讯云 IM 单聊。
|
||||
- `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence` 和 `CheckSpeakPermission`。
|
||||
|
||||
@ -130,6 +130,21 @@ docs/notice-service架构.md
|
||||
docs/flutter对接/Flutter App 通知与钱包余额对接.md
|
||||
```
|
||||
|
||||
## RocketMQ
|
||||
|
||||
本地默认不依赖 RocketMQ,`room-service` 使用 `outbox_worker.publish_mode=direct` 直连腾讯云 IM publisher 和 activity gRPC publisher。线上推荐启用腾讯云 RocketMQ,并把 `room-service` 配成 `publish_mode=mq`,让 room outbox 只发布到消息总线,下游服务各自 consumer group 消费。
|
||||
|
||||
当前 topic 和 consumer group:
|
||||
|
||||
| Topic | Producer | Consumer Group | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费 `RoomGiftSent`、`RoomTreasureCountdownStarted` 等房间事实 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费 `RoomUserKicked` 等私有通知事实 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
|
||||
| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 room-service 开箱命令 |
|
||||
|
||||
RocketMQ 不拥有房间状态。`room_outbox` 仍是 MySQL 内的可靠事实源;MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。宝箱延迟消息只负责唤醒,到点后 room-service 会重新校验 `box_id`、等级、`open_at_ms`、状态和 UTC 重置边界。
|
||||
|
||||
## Storage Model
|
||||
|
||||
MySQL 是本地和线上运行的持久化底座:
|
||||
@ -197,7 +212,7 @@ services/notice-service/ async user notice delivery workers
|
||||
- 腾讯云 IM 回调入口已经落在 gateway;线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup` 和 `Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。
|
||||
- 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id`;`X-Request-ID` 不需要也不应该由前端传入。
|
||||
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`。
|
||||
- `notice-service` 本地默认不启用腾讯云 IM worker;线上启用 `wallet_notice_worker.enabled=true` 或 `room_notice_worker.enabled=true` 时必须同时启用 `tencent_im.enabled=true`。
|
||||
- `activity-service` 已承接每日任务和消息/活动基础能力;`room_outbox` 到 activity/audit 的实际消费链路仍需按独立消费位点补齐。
|
||||
- `room-service` App 已启动 `room_outbox` 补偿 worker;该状态只表示腾讯云 IM 系统消息补偿结果,activity/audit 后续必须使用独立消费位点。
|
||||
- `notice-service` 本地默认不启用腾讯云 IM worker;线上启用钱包通知、房间通知 MySQL worker 或 RocketMQ room outbox consumer 时,必须同时启用 `tencent_im.enabled=true`。
|
||||
- `activity-service` 已承接每日任务、消息/活动基础能力和 room outbox consumer;本地可走 direct gRPC,线上推荐走 RocketMQ `hyapp_room_outbox`。
|
||||
- `room-service` App 已启动 `room_outbox` 补偿 worker;`direct` 模式表示直连 publisher 完成,`mq` 模式表示事件已发布到 RocketMQ,下游成功由各服务消费位点保证。
|
||||
- JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.11
|
||||
// protoc v5.28.3
|
||||
// protoc (unknown)
|
||||
// source: proto/events/room/v1/events.proto
|
||||
|
||||
package roomeventsv1
|
||||
@ -1175,6 +1175,550 @@ func (x *RoomRankChanged) GetGiftValue() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
type RoomTreasureRewardGrant struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
RewardRole string `protobuf:"bytes,1,opt,name=reward_role,json=rewardRole,proto3" json:"reward_role,omitempty"`
|
||||
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
RewardItemId string `protobuf:"bytes,3,opt,name=reward_item_id,json=rewardItemId,proto3" json:"reward_item_id,omitempty"`
|
||||
ResourceGroupId int64 `protobuf:"varint,4,opt,name=resource_group_id,json=resourceGroupId,proto3" json:"resource_group_id,omitempty"`
|
||||
DisplayName string `protobuf:"bytes,5,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
|
||||
IconUrl string `protobuf:"bytes,6,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"`
|
||||
GrantId string `protobuf:"bytes,7,opt,name=grant_id,json=grantId,proto3" json:"grant_id,omitempty"`
|
||||
Status string `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) Reset() {
|
||||
*x = RoomTreasureRewardGrant{}
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[17]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RoomTreasureRewardGrant) ProtoMessage() {}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[17]
|
||||
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 RoomTreasureRewardGrant.ProtoReflect.Descriptor instead.
|
||||
func (*RoomTreasureRewardGrant) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{17}
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) GetRewardRole() string {
|
||||
if x != nil {
|
||||
return x.RewardRole
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) GetUserId() int64 {
|
||||
if x != nil {
|
||||
return x.UserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) GetRewardItemId() string {
|
||||
if x != nil {
|
||||
return x.RewardItemId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) GetResourceGroupId() int64 {
|
||||
if x != nil {
|
||||
return x.ResourceGroupId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) GetDisplayName() string {
|
||||
if x != nil {
|
||||
return x.DisplayName
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) GetIconUrl() string {
|
||||
if x != nil {
|
||||
return x.IconUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) GetGrantId() string {
|
||||
if x != nil {
|
||||
return x.GrantId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGrant) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RoomTreasureProgressChanged 表达送礼扣费成功后宝箱有效能量发生变化。
|
||||
type RoomTreasureProgressChanged struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
BoxId string `protobuf:"bytes,1,opt,name=box_id,json=boxId,proto3" json:"box_id,omitempty"`
|
||||
Level int32 `protobuf:"varint,2,opt,name=level,proto3" json:"level,omitempty"`
|
||||
AddedEnergy int64 `protobuf:"varint,3,opt,name=added_energy,json=addedEnergy,proto3" json:"added_energy,omitempty"`
|
||||
EffectiveAddedEnergy int64 `protobuf:"varint,4,opt,name=effective_added_energy,json=effectiveAddedEnergy,proto3" json:"effective_added_energy,omitempty"`
|
||||
OverflowEnergy int64 `protobuf:"varint,5,opt,name=overflow_energy,json=overflowEnergy,proto3" json:"overflow_energy,omitempty"`
|
||||
CurrentProgress int64 `protobuf:"varint,6,opt,name=current_progress,json=currentProgress,proto3" json:"current_progress,omitempty"`
|
||||
EnergyThreshold int64 `protobuf:"varint,7,opt,name=energy_threshold,json=energyThreshold,proto3" json:"energy_threshold,omitempty"`
|
||||
Status string `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"`
|
||||
ResetAtMs int64 `protobuf:"varint,9,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,omitempty"`
|
||||
SenderUserId int64 `protobuf:"varint,10,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"`
|
||||
GiftId string `protobuf:"bytes,11,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
|
||||
GiftCount int32 `protobuf:"varint,12,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
|
||||
CommandId string `protobuf:"bytes,13,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||
VisibleRegionId int64 `protobuf:"varint,14,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) Reset() {
|
||||
*x = RoomTreasureProgressChanged{}
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RoomTreasureProgressChanged) ProtoMessage() {}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
|
||||
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 RoomTreasureProgressChanged.ProtoReflect.Descriptor instead.
|
||||
func (*RoomTreasureProgressChanged) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{18}
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetBoxId() string {
|
||||
if x != nil {
|
||||
return x.BoxId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetLevel() int32 {
|
||||
if x != nil {
|
||||
return x.Level
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetAddedEnergy() int64 {
|
||||
if x != nil {
|
||||
return x.AddedEnergy
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetEffectiveAddedEnergy() int64 {
|
||||
if x != nil {
|
||||
return x.EffectiveAddedEnergy
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetOverflowEnergy() int64 {
|
||||
if x != nil {
|
||||
return x.OverflowEnergy
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetCurrentProgress() int64 {
|
||||
if x != nil {
|
||||
return x.CurrentProgress
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetEnergyThreshold() int64 {
|
||||
if x != nil {
|
||||
return x.EnergyThreshold
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetStatus() string {
|
||||
if x != nil {
|
||||
return x.Status
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetResetAtMs() int64 {
|
||||
if x != nil {
|
||||
return x.ResetAtMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetSenderUserId() int64 {
|
||||
if x != nil {
|
||||
return x.SenderUserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetGiftId() string {
|
||||
if x != nil {
|
||||
return x.GiftId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetGiftCount() int32 {
|
||||
if x != nil {
|
||||
return x.GiftCount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetCommandId() string {
|
||||
if x != nil {
|
||||
return x.CommandId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureProgressChanged) GetVisibleRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.VisibleRegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RoomTreasureCountdownStarted 表达宝箱满能量后进入倒计时,并作为区域/全局播报的事实源。
|
||||
type RoomTreasureCountdownStarted struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
BoxId string `protobuf:"bytes,1,opt,name=box_id,json=boxId,proto3" json:"box_id,omitempty"`
|
||||
Level int32 `protobuf:"varint,2,opt,name=level,proto3" json:"level,omitempty"`
|
||||
CurrentProgress int64 `protobuf:"varint,3,opt,name=current_progress,json=currentProgress,proto3" json:"current_progress,omitempty"`
|
||||
EnergyThreshold int64 `protobuf:"varint,4,opt,name=energy_threshold,json=energyThreshold,proto3" json:"energy_threshold,omitempty"`
|
||||
CountdownStartedAtMs int64 `protobuf:"varint,5,opt,name=countdown_started_at_ms,json=countdownStartedAtMs,proto3" json:"countdown_started_at_ms,omitempty"`
|
||||
OpenAtMs int64 `protobuf:"varint,6,opt,name=open_at_ms,json=openAtMs,proto3" json:"open_at_ms,omitempty"`
|
||||
BroadcastScope string `protobuf:"bytes,7,opt,name=broadcast_scope,json=broadcastScope,proto3" json:"broadcast_scope,omitempty"`
|
||||
VisibleRegionId int64 `protobuf:"varint,8,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
|
||||
Top1UserId int64 `protobuf:"varint,9,opt,name=top1_user_id,json=top1UserId,proto3" json:"top1_user_id,omitempty"`
|
||||
IgniterUserId int64 `protobuf:"varint,10,opt,name=igniter_user_id,json=igniterUserId,proto3" json:"igniter_user_id,omitempty"`
|
||||
CommandId string `protobuf:"bytes,11,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) Reset() {
|
||||
*x = RoomTreasureCountdownStarted{}
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RoomTreasureCountdownStarted) ProtoMessage() {}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
|
||||
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 RoomTreasureCountdownStarted.ProtoReflect.Descriptor instead.
|
||||
func (*RoomTreasureCountdownStarted) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{19}
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetBoxId() string {
|
||||
if x != nil {
|
||||
return x.BoxId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetLevel() int32 {
|
||||
if x != nil {
|
||||
return x.Level
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetCurrentProgress() int64 {
|
||||
if x != nil {
|
||||
return x.CurrentProgress
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetEnergyThreshold() int64 {
|
||||
if x != nil {
|
||||
return x.EnergyThreshold
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetCountdownStartedAtMs() int64 {
|
||||
if x != nil {
|
||||
return x.CountdownStartedAtMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetOpenAtMs() int64 {
|
||||
if x != nil {
|
||||
return x.OpenAtMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetBroadcastScope() string {
|
||||
if x != nil {
|
||||
return x.BroadcastScope
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetVisibleRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.VisibleRegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetTop1UserId() int64 {
|
||||
if x != nil {
|
||||
return x.Top1UserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetIgniterUserId() int64 {
|
||||
if x != nil {
|
||||
return x.IgniterUserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureCountdownStarted) GetCommandId() string {
|
||||
if x != nil {
|
||||
return x.CommandId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RoomTreasureOpened 表达宝箱已经打开,在线用户名单按开箱瞬间 Room Cell presence 结算。
|
||||
type RoomTreasureOpened struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
BoxId string `protobuf:"bytes,1,opt,name=box_id,json=boxId,proto3" json:"box_id,omitempty"`
|
||||
Level int32 `protobuf:"varint,2,opt,name=level,proto3" json:"level,omitempty"`
|
||||
NextLevel int32 `protobuf:"varint,3,opt,name=next_level,json=nextLevel,proto3" json:"next_level,omitempty"`
|
||||
OpenedAtMs int64 `protobuf:"varint,4,opt,name=opened_at_ms,json=openedAtMs,proto3" json:"opened_at_ms,omitempty"`
|
||||
ResetAtMs int64 `protobuf:"varint,5,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,omitempty"`
|
||||
Top1UserId int64 `protobuf:"varint,6,opt,name=top1_user_id,json=top1UserId,proto3" json:"top1_user_id,omitempty"`
|
||||
IgniterUserId int64 `protobuf:"varint,7,opt,name=igniter_user_id,json=igniterUserId,proto3" json:"igniter_user_id,omitempty"`
|
||||
InRoomUserIds []int64 `protobuf:"varint,8,rep,packed,name=in_room_user_ids,json=inRoomUserIds,proto3" json:"in_room_user_ids,omitempty"`
|
||||
Rewards []*RoomTreasureRewardGrant `protobuf:"bytes,9,rep,name=rewards,proto3" json:"rewards,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) Reset() {
|
||||
*x = RoomTreasureOpened{}
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RoomTreasureOpened) ProtoMessage() {}
|
||||
|
||||
func (x *RoomTreasureOpened) 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 RoomTreasureOpened.ProtoReflect.Descriptor instead.
|
||||
func (*RoomTreasureOpened) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{20}
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) GetBoxId() string {
|
||||
if x != nil {
|
||||
return x.BoxId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) GetLevel() int32 {
|
||||
if x != nil {
|
||||
return x.Level
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) GetNextLevel() int32 {
|
||||
if x != nil {
|
||||
return x.NextLevel
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) GetOpenedAtMs() int64 {
|
||||
if x != nil {
|
||||
return x.OpenedAtMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) GetResetAtMs() int64 {
|
||||
if x != nil {
|
||||
return x.ResetAtMs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) GetTop1UserId() int64 {
|
||||
if x != nil {
|
||||
return x.Top1UserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) GetIgniterUserId() int64 {
|
||||
if x != nil {
|
||||
return x.IgniterUserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) GetInRoomUserIds() []int64 {
|
||||
if x != nil {
|
||||
return x.InRoomUserIds
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *RoomTreasureOpened) GetRewards() []*RoomTreasureRewardGrant {
|
||||
if x != nil {
|
||||
return x.Rewards
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RoomTreasureRewardGranted 是客户端展示发奖弹窗和私有通知的最小事实。
|
||||
type RoomTreasureRewardGranted struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
BoxId string `protobuf:"bytes,1,opt,name=box_id,json=boxId,proto3" json:"box_id,omitempty"`
|
||||
Level int32 `protobuf:"varint,2,opt,name=level,proto3" json:"level,omitempty"`
|
||||
Rewards []*RoomTreasureRewardGrant `protobuf:"bytes,3,rep,name=rewards,proto3" json:"rewards,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGranted) Reset() {
|
||||
*x = RoomTreasureRewardGranted{}
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGranted) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RoomTreasureRewardGranted) ProtoMessage() {}
|
||||
|
||||
func (x *RoomTreasureRewardGranted) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
|
||||
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 RoomTreasureRewardGranted.ProtoReflect.Descriptor instead.
|
||||
func (*RoomTreasureRewardGranted) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{21}
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGranted) GetBoxId() string {
|
||||
if x != nil {
|
||||
return x.BoxId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGranted) GetLevel() int32 {
|
||||
if x != nil {
|
||||
return x.Level
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomTreasureRewardGranted) GetRewards() []*RoomTreasureRewardGrant {
|
||||
if x != nil {
|
||||
return x.Rewards
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_proto_events_room_v1_events_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_events_room_v1_events_proto_rawDesc = "" +
|
||||
@ -1273,7 +1817,68 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
|
||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x14\n" +
|
||||
"\x05score\x18\x02 \x01(\x03R\x05score\x12\x1d\n" +
|
||||
"\n" +
|
||||
"gift_value\x18\x03 \x01(\x03R\tgiftValueB3Z1hyapp.local/api/proto/events/room/v1;roomeventsv1b\x06proto3"
|
||||
"gift_value\x18\x03 \x01(\x03R\tgiftValue\"\x96\x02\n" +
|
||||
"\x17RoomTreasureRewardGrant\x12\x1f\n" +
|
||||
"\vreward_role\x18\x01 \x01(\tR\n" +
|
||||
"rewardRole\x12\x17\n" +
|
||||
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12$\n" +
|
||||
"\x0ereward_item_id\x18\x03 \x01(\tR\frewardItemId\x12*\n" +
|
||||
"\x11resource_group_id\x18\x04 \x01(\x03R\x0fresourceGroupId\x12!\n" +
|
||||
"\fdisplay_name\x18\x05 \x01(\tR\vdisplayName\x12\x19\n" +
|
||||
"\bicon_url\x18\x06 \x01(\tR\aiconUrl\x12\x19\n" +
|
||||
"\bgrant_id\x18\a \x01(\tR\agrantId\x12\x16\n" +
|
||||
"\x06status\x18\b \x01(\tR\x06status\"\x83\x04\n" +
|
||||
"\x1bRoomTreasureProgressChanged\x12\x15\n" +
|
||||
"\x06box_id\x18\x01 \x01(\tR\x05boxId\x12\x14\n" +
|
||||
"\x05level\x18\x02 \x01(\x05R\x05level\x12!\n" +
|
||||
"\fadded_energy\x18\x03 \x01(\x03R\vaddedEnergy\x124\n" +
|
||||
"\x16effective_added_energy\x18\x04 \x01(\x03R\x14effectiveAddedEnergy\x12'\n" +
|
||||
"\x0foverflow_energy\x18\x05 \x01(\x03R\x0eoverflowEnergy\x12)\n" +
|
||||
"\x10current_progress\x18\x06 \x01(\x03R\x0fcurrentProgress\x12)\n" +
|
||||
"\x10energy_threshold\x18\a \x01(\x03R\x0fenergyThreshold\x12\x16\n" +
|
||||
"\x06status\x18\b \x01(\tR\x06status\x12\x1e\n" +
|
||||
"\vreset_at_ms\x18\t \x01(\x03R\tresetAtMs\x12$\n" +
|
||||
"\x0esender_user_id\x18\n" +
|
||||
" \x01(\x03R\fsenderUserId\x12\x17\n" +
|
||||
"\agift_id\x18\v \x01(\tR\x06giftId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"gift_count\x18\f \x01(\x05R\tgiftCount\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\r \x01(\tR\tcommandId\x12*\n" +
|
||||
"\x11visible_region_id\x18\x0e \x01(\x03R\x0fvisibleRegionId\"\xb4\x03\n" +
|
||||
"\x1cRoomTreasureCountdownStarted\x12\x15\n" +
|
||||
"\x06box_id\x18\x01 \x01(\tR\x05boxId\x12\x14\n" +
|
||||
"\x05level\x18\x02 \x01(\x05R\x05level\x12)\n" +
|
||||
"\x10current_progress\x18\x03 \x01(\x03R\x0fcurrentProgress\x12)\n" +
|
||||
"\x10energy_threshold\x18\x04 \x01(\x03R\x0fenergyThreshold\x125\n" +
|
||||
"\x17countdown_started_at_ms\x18\x05 \x01(\x03R\x14countdownStartedAtMs\x12\x1c\n" +
|
||||
"\n" +
|
||||
"open_at_ms\x18\x06 \x01(\x03R\bopenAtMs\x12'\n" +
|
||||
"\x0fbroadcast_scope\x18\a \x01(\tR\x0ebroadcastScope\x12*\n" +
|
||||
"\x11visible_region_id\x18\b \x01(\x03R\x0fvisibleRegionId\x12 \n" +
|
||||
"\ftop1_user_id\x18\t \x01(\x03R\n" +
|
||||
"top1UserId\x12&\n" +
|
||||
"\x0figniter_user_id\x18\n" +
|
||||
" \x01(\x03R\rigniterUserId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\v \x01(\tR\tcommandId\"\xde\x02\n" +
|
||||
"\x12RoomTreasureOpened\x12\x15\n" +
|
||||
"\x06box_id\x18\x01 \x01(\tR\x05boxId\x12\x14\n" +
|
||||
"\x05level\x18\x02 \x01(\x05R\x05level\x12\x1d\n" +
|
||||
"\n" +
|
||||
"next_level\x18\x03 \x01(\x05R\tnextLevel\x12 \n" +
|
||||
"\fopened_at_ms\x18\x04 \x01(\x03R\n" +
|
||||
"openedAtMs\x12\x1e\n" +
|
||||
"\vreset_at_ms\x18\x05 \x01(\x03R\tresetAtMs\x12 \n" +
|
||||
"\ftop1_user_id\x18\x06 \x01(\x03R\n" +
|
||||
"top1UserId\x12&\n" +
|
||||
"\x0figniter_user_id\x18\a \x01(\x03R\rigniterUserId\x12'\n" +
|
||||
"\x10in_room_user_ids\x18\b \x03(\x03R\rinRoomUserIds\x12G\n" +
|
||||
"\arewards\x18\t \x03(\v2-.hyapp.events.room.v1.RoomTreasureRewardGrantR\arewards\"\x91\x01\n" +
|
||||
"\x19RoomTreasureRewardGranted\x12\x15\n" +
|
||||
"\x06box_id\x18\x01 \x01(\tR\x05boxId\x12\x14\n" +
|
||||
"\x05level\x18\x02 \x01(\x05R\x05level\x12G\n" +
|
||||
"\arewards\x18\x03 \x03(\v2-.hyapp.events.room.v1.RoomTreasureRewardGrantR\arewardsB3Z1hyapp.local/api/proto/events/room/v1;roomeventsv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_proto_events_room_v1_events_proto_rawDescOnce sync.Once
|
||||
@ -1287,32 +1892,39 @@ func file_proto_events_room_v1_events_proto_rawDescGZIP() []byte {
|
||||
return file_proto_events_room_v1_events_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
|
||||
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 22)
|
||||
var file_proto_events_room_v1_events_proto_goTypes = []any{
|
||||
(*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope
|
||||
(*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated
|
||||
(*RoomProfileUpdated)(nil), // 2: hyapp.events.room.v1.RoomProfileUpdated
|
||||
(*RoomUserJoined)(nil), // 3: hyapp.events.room.v1.RoomUserJoined
|
||||
(*RoomUserLeft)(nil), // 4: hyapp.events.room.v1.RoomUserLeft
|
||||
(*RoomClosed)(nil), // 5: hyapp.events.room.v1.RoomClosed
|
||||
(*RoomMicChanged)(nil), // 6: hyapp.events.room.v1.RoomMicChanged
|
||||
(*RoomMicSeatLocked)(nil), // 7: hyapp.events.room.v1.RoomMicSeatLocked
|
||||
(*RoomChatEnabledChanged)(nil), // 8: hyapp.events.room.v1.RoomChatEnabledChanged
|
||||
(*RoomPasswordChanged)(nil), // 9: hyapp.events.room.v1.RoomPasswordChanged
|
||||
(*RoomAdminChanged)(nil), // 10: hyapp.events.room.v1.RoomAdminChanged
|
||||
(*RoomUserMuted)(nil), // 11: hyapp.events.room.v1.RoomUserMuted
|
||||
(*RoomUserKicked)(nil), // 12: hyapp.events.room.v1.RoomUserKicked
|
||||
(*RoomUserUnbanned)(nil), // 13: hyapp.events.room.v1.RoomUserUnbanned
|
||||
(*RoomGiftSent)(nil), // 14: hyapp.events.room.v1.RoomGiftSent
|
||||
(*RoomHeatChanged)(nil), // 15: hyapp.events.room.v1.RoomHeatChanged
|
||||
(*RoomRankChanged)(nil), // 16: hyapp.events.room.v1.RoomRankChanged
|
||||
(*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope
|
||||
(*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated
|
||||
(*RoomProfileUpdated)(nil), // 2: hyapp.events.room.v1.RoomProfileUpdated
|
||||
(*RoomUserJoined)(nil), // 3: hyapp.events.room.v1.RoomUserJoined
|
||||
(*RoomUserLeft)(nil), // 4: hyapp.events.room.v1.RoomUserLeft
|
||||
(*RoomClosed)(nil), // 5: hyapp.events.room.v1.RoomClosed
|
||||
(*RoomMicChanged)(nil), // 6: hyapp.events.room.v1.RoomMicChanged
|
||||
(*RoomMicSeatLocked)(nil), // 7: hyapp.events.room.v1.RoomMicSeatLocked
|
||||
(*RoomChatEnabledChanged)(nil), // 8: hyapp.events.room.v1.RoomChatEnabledChanged
|
||||
(*RoomPasswordChanged)(nil), // 9: hyapp.events.room.v1.RoomPasswordChanged
|
||||
(*RoomAdminChanged)(nil), // 10: hyapp.events.room.v1.RoomAdminChanged
|
||||
(*RoomUserMuted)(nil), // 11: hyapp.events.room.v1.RoomUserMuted
|
||||
(*RoomUserKicked)(nil), // 12: hyapp.events.room.v1.RoomUserKicked
|
||||
(*RoomUserUnbanned)(nil), // 13: hyapp.events.room.v1.RoomUserUnbanned
|
||||
(*RoomGiftSent)(nil), // 14: hyapp.events.room.v1.RoomGiftSent
|
||||
(*RoomHeatChanged)(nil), // 15: hyapp.events.room.v1.RoomHeatChanged
|
||||
(*RoomRankChanged)(nil), // 16: hyapp.events.room.v1.RoomRankChanged
|
||||
(*RoomTreasureRewardGrant)(nil), // 17: hyapp.events.room.v1.RoomTreasureRewardGrant
|
||||
(*RoomTreasureProgressChanged)(nil), // 18: hyapp.events.room.v1.RoomTreasureProgressChanged
|
||||
(*RoomTreasureCountdownStarted)(nil), // 19: hyapp.events.room.v1.RoomTreasureCountdownStarted
|
||||
(*RoomTreasureOpened)(nil), // 20: hyapp.events.room.v1.RoomTreasureOpened
|
||||
(*RoomTreasureRewardGranted)(nil), // 21: hyapp.events.room.v1.RoomTreasureRewardGranted
|
||||
}
|
||||
var file_proto_events_room_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
|
||||
17, // 0: hyapp.events.room.v1.RoomTreasureOpened.rewards:type_name -> hyapp.events.room.v1.RoomTreasureRewardGrant
|
||||
17, // 1: hyapp.events.room.v1.RoomTreasureRewardGranted.rewards:type_name -> hyapp.events.room.v1.RoomTreasureRewardGrant
|
||||
2, // [2:2] is the sub-list for method output_type
|
||||
2, // [2:2] is the sub-list for method input_type
|
||||
2, // [2:2] is the sub-list for extension type_name
|
||||
2, // [2:2] is the sub-list for extension extendee
|
||||
0, // [0:2] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_proto_events_room_v1_events_proto_init() }
|
||||
@ -1326,7 +1938,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_events_room_v1_events_proto_rawDesc), len(file_proto_events_room_v1_events_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 17,
|
||||
NumMessages: 22,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@ -141,3 +141,67 @@ message RoomRankChanged {
|
||||
int64 score = 2;
|
||||
int64 gift_value = 3;
|
||||
}
|
||||
|
||||
message RoomTreasureRewardGrant {
|
||||
string reward_role = 1;
|
||||
int64 user_id = 2;
|
||||
string reward_item_id = 3;
|
||||
int64 resource_group_id = 4;
|
||||
string display_name = 5;
|
||||
string icon_url = 6;
|
||||
string grant_id = 7;
|
||||
string status = 8;
|
||||
}
|
||||
|
||||
// RoomTreasureProgressChanged 表达送礼扣费成功后宝箱有效能量发生变化。
|
||||
message RoomTreasureProgressChanged {
|
||||
string box_id = 1;
|
||||
int32 level = 2;
|
||||
int64 added_energy = 3;
|
||||
int64 effective_added_energy = 4;
|
||||
int64 overflow_energy = 5;
|
||||
int64 current_progress = 6;
|
||||
int64 energy_threshold = 7;
|
||||
string status = 8;
|
||||
int64 reset_at_ms = 9;
|
||||
int64 sender_user_id = 10;
|
||||
string gift_id = 11;
|
||||
int32 gift_count = 12;
|
||||
string command_id = 13;
|
||||
int64 visible_region_id = 14;
|
||||
}
|
||||
|
||||
// RoomTreasureCountdownStarted 表达宝箱满能量后进入倒计时,并作为区域/全局播报的事实源。
|
||||
message RoomTreasureCountdownStarted {
|
||||
string box_id = 1;
|
||||
int32 level = 2;
|
||||
int64 current_progress = 3;
|
||||
int64 energy_threshold = 4;
|
||||
int64 countdown_started_at_ms = 5;
|
||||
int64 open_at_ms = 6;
|
||||
string broadcast_scope = 7;
|
||||
int64 visible_region_id = 8;
|
||||
int64 top1_user_id = 9;
|
||||
int64 igniter_user_id = 10;
|
||||
string command_id = 11;
|
||||
}
|
||||
|
||||
// RoomTreasureOpened 表达宝箱已经打开,在线用户名单按开箱瞬间 Room Cell presence 结算。
|
||||
message RoomTreasureOpened {
|
||||
string box_id = 1;
|
||||
int32 level = 2;
|
||||
int32 next_level = 3;
|
||||
int64 opened_at_ms = 4;
|
||||
int64 reset_at_ms = 5;
|
||||
int64 top1_user_id = 6;
|
||||
int64 igniter_user_id = 7;
|
||||
repeated int64 in_room_user_ids = 8;
|
||||
repeated RoomTreasureRewardGrant rewards = 9;
|
||||
}
|
||||
|
||||
// RoomTreasureRewardGranted 是客户端展示发奖弹窗和私有通知的最小事实。
|
||||
message RoomTreasureRewardGranted {
|
||||
string box_id = 1;
|
||||
int32 level = 2;
|
||||
repeated RoomTreasureRewardGrant rewards = 3;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -63,6 +63,69 @@ message RankItem {
|
||||
int64 updated_at_ms = 4;
|
||||
}
|
||||
|
||||
// RoomTreasureRewardItem 是后台配置给客户端展示的宝箱奖励候选项。
|
||||
message RoomTreasureRewardItem {
|
||||
string reward_item_id = 1;
|
||||
int64 resource_group_id = 2;
|
||||
int64 weight = 3;
|
||||
string display_name = 4;
|
||||
string icon_url = 5;
|
||||
}
|
||||
|
||||
// RoomTreasureLevel 暴露单个等级宝箱的物料、阈值和三类奖励池。
|
||||
message RoomTreasureLevel {
|
||||
int32 level = 1;
|
||||
int64 energy_threshold = 2;
|
||||
string cover_url = 3;
|
||||
string animation_url = 4;
|
||||
string opening_animation_url = 5;
|
||||
string opened_image_url = 6;
|
||||
repeated RoomTreasureRewardItem in_room_rewards = 7;
|
||||
repeated RoomTreasureRewardItem top1_rewards = 8;
|
||||
repeated RoomTreasureRewardItem igniter_rewards = 9;
|
||||
}
|
||||
|
||||
// RoomTreasureRewardGrant 是一次宝箱打开后给某个用户结算出的具体奖励。
|
||||
message RoomTreasureRewardGrant {
|
||||
string reward_role = 1;
|
||||
int64 user_id = 2;
|
||||
string reward_item_id = 3;
|
||||
int64 resource_group_id = 4;
|
||||
string display_name = 5;
|
||||
string icon_url = 6;
|
||||
string grant_id = 7;
|
||||
string status = 8;
|
||||
}
|
||||
|
||||
// RoomTreasureState 是 Room Cell 持有并随快照恢复的当前宝箱状态。
|
||||
message RoomTreasureState {
|
||||
int32 current_level = 1;
|
||||
int64 current_progress = 2;
|
||||
int64 energy_threshold = 3;
|
||||
string status = 4;
|
||||
int64 countdown_started_at_ms = 5;
|
||||
int64 open_at_ms = 6;
|
||||
int64 opened_at_ms = 7;
|
||||
int64 reset_at_ms = 8;
|
||||
int64 top1_user_id = 9;
|
||||
int64 igniter_user_id = 10;
|
||||
string box_id = 11;
|
||||
int64 config_version = 12;
|
||||
repeated RoomTreasureRewardGrant last_rewards = 13;
|
||||
}
|
||||
|
||||
// RoomTreasureInfo 是 App 初始化宝箱 UI 的完整读模型。
|
||||
message RoomTreasureInfo {
|
||||
bool enabled = 1;
|
||||
repeated RoomTreasureLevel levels = 2;
|
||||
RoomTreasureState state = 3;
|
||||
int64 server_time_ms = 4;
|
||||
string broadcast_scope = 5;
|
||||
int64 open_delay_ms = 6;
|
||||
int64 broadcast_delay_ms = 7;
|
||||
string reward_stack_policy = 8;
|
||||
}
|
||||
|
||||
// RoomSnapshot 把 room-service 对外需要暴露的房间投影集中返回。
|
||||
message RoomSnapshot {
|
||||
string room_id = 1;
|
||||
@ -84,6 +147,8 @@ message RoomSnapshot {
|
||||
int64 visible_region_id = 18;
|
||||
// locked 只表达房间是否需要入房密码;具体密码哈希不对外映射到 HTTP JSON。
|
||||
bool locked = 19;
|
||||
// treasure 是语音房宝箱当前状态;配置物料通过 GetRoomTreasure 单独读取,避免快照过大。
|
||||
RoomTreasureState treasure = 20;
|
||||
}
|
||||
|
||||
// CreateRoomRequest 创建一个新的房间执行单元。
|
||||
@ -409,6 +474,7 @@ message SendGiftResponse {
|
||||
int64 room_heat = 3;
|
||||
repeated RankItem gift_rank = 4;
|
||||
RoomSnapshot room = 5;
|
||||
RoomTreasureState treasure = 6;
|
||||
}
|
||||
|
||||
// CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。
|
||||
@ -549,6 +615,19 @@ message GetRoomSnapshotResponse {
|
||||
bool is_followed = 3;
|
||||
}
|
||||
|
||||
// GetRoomTreasureRequest 主动读取当前房间宝箱配置物料和进度状态。
|
||||
// 该查询必须由 gateway 写入鉴权后的 viewer_user_id,客户端不能提交 viewer_user_id。
|
||||
message GetRoomTreasureRequest {
|
||||
RequestMeta meta = 1;
|
||||
string room_id = 2;
|
||||
int64 viewer_user_id = 3;
|
||||
}
|
||||
|
||||
message GetRoomTreasureResponse {
|
||||
RoomTreasureInfo treasure = 1;
|
||||
int64 server_time_ms = 2;
|
||||
}
|
||||
|
||||
// ListRoomOnlineUsersRequest 分页查询房间在线用户,不让客户端拉完整 RoomSnapshot 做列表分页。
|
||||
message ListRoomOnlineUsersRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -634,5 +713,6 @@ service RoomQueryService {
|
||||
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
|
||||
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
|
||||
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);
|
||||
rpc GetRoomTreasure(GetRoomTreasureRequest) returns (GetRoomTreasureResponse);
|
||||
rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v5.28.3
|
||||
// - protoc (unknown)
|
||||
// source: proto/room/v1/room.proto
|
||||
|
||||
package roomv1
|
||||
@ -1110,6 +1110,7 @@ const (
|
||||
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
|
||||
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
|
||||
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
|
||||
RoomQueryService_GetRoomTreasure_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomTreasure"
|
||||
RoomQueryService_ListRoomOnlineUsers_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomOnlineUsers"
|
||||
)
|
||||
|
||||
@ -1124,6 +1125,7 @@ type RoomQueryServiceClient interface {
|
||||
GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error)
|
||||
GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error)
|
||||
GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error)
|
||||
GetRoomTreasure(ctx context.Context, in *GetRoomTreasureRequest, opts ...grpc.CallOption) (*GetRoomTreasureResponse, error)
|
||||
ListRoomOnlineUsers(ctx context.Context, in *ListRoomOnlineUsersRequest, opts ...grpc.CallOption) (*ListRoomOnlineUsersResponse, error)
|
||||
}
|
||||
|
||||
@ -1185,6 +1187,16 @@ func (c *roomQueryServiceClient) GetRoomSnapshot(ctx context.Context, in *GetRoo
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomQueryServiceClient) GetRoomTreasure(ctx context.Context, in *GetRoomTreasureRequest, opts ...grpc.CallOption) (*GetRoomTreasureResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetRoomTreasureResponse)
|
||||
err := c.cc.Invoke(ctx, RoomQueryService_GetRoomTreasure_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomQueryServiceClient) ListRoomOnlineUsers(ctx context.Context, in *ListRoomOnlineUsersRequest, opts ...grpc.CallOption) (*ListRoomOnlineUsersResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRoomOnlineUsersResponse)
|
||||
@ -1206,6 +1218,7 @@ type RoomQueryServiceServer interface {
|
||||
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
|
||||
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
|
||||
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
|
||||
GetRoomTreasure(context.Context, *GetRoomTreasureRequest) (*GetRoomTreasureResponse, error)
|
||||
ListRoomOnlineUsers(context.Context, *ListRoomOnlineUsersRequest) (*ListRoomOnlineUsersResponse, error)
|
||||
mustEmbedUnimplementedRoomQueryServiceServer()
|
||||
}
|
||||
@ -1232,6 +1245,9 @@ func (UnimplementedRoomQueryServiceServer) GetCurrentRoom(context.Context, *GetC
|
||||
func (UnimplementedRoomQueryServiceServer) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetRoomSnapshot not implemented")
|
||||
}
|
||||
func (UnimplementedRoomQueryServiceServer) GetRoomTreasure(context.Context, *GetRoomTreasureRequest) (*GetRoomTreasureResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetRoomTreasure not implemented")
|
||||
}
|
||||
func (UnimplementedRoomQueryServiceServer) ListRoomOnlineUsers(context.Context, *ListRoomOnlineUsersRequest) (*ListRoomOnlineUsersResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListRoomOnlineUsers not implemented")
|
||||
}
|
||||
@ -1346,6 +1362,24 @@ func _RoomQueryService_GetRoomSnapshot_Handler(srv interface{}, ctx context.Cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomQueryService_GetRoomTreasure_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetRoomTreasureRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomQueryServiceServer).GetRoomTreasure(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomQueryService_GetRoomTreasure_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomQueryServiceServer).GetRoomTreasure(ctx, req.(*GetRoomTreasureRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomQueryService_ListRoomOnlineUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRoomOnlineUsersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -1391,6 +1425,10 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetRoomSnapshot",
|
||||
Handler: _RoomQueryService_GetRoomSnapshot_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetRoomTreasure",
|
||||
Handler: _RoomQueryService_GetRoomTreasure_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListRoomOnlineUsers",
|
||||
Handler: _RoomQueryService_ListRoomOnlineUsers_Handler,
|
||||
|
||||
@ -145,6 +145,7 @@ type DebitGiftResponse struct {
|
||||
BalanceAfter int64 `protobuf:"varint,7,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"`
|
||||
ChargeAssetType string `protobuf:"bytes,8,opt,name=charge_asset_type,json=chargeAssetType,proto3" json:"charge_asset_type,omitempty"`
|
||||
ChargeAmount int64 `protobuf:"varint,9,opt,name=charge_amount,json=chargeAmount,proto3" json:"charge_amount,omitempty"`
|
||||
GiftTypeCode string `protobuf:"bytes,10,opt,name=gift_type_code,json=giftTypeCode,proto3" json:"gift_type_code,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -242,6 +243,13 @@ func (x *DebitGiftResponse) GetChargeAmount() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) GetGiftTypeCode() string {
|
||||
if x != nil {
|
||||
return x.GiftTypeCode
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// AssetBalance 是用户某类资产的余额投影。
|
||||
type AssetBalance struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -8858,7 +8866,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"gift_count\x18\x06 \x01(\x05R\tgiftCount\x12#\n" +
|
||||
"\rprice_version\x18\a \x01(\tR\fpriceVersion\x12\x19\n" +
|
||||
"\bapp_code\x18\b \x01(\tR\aappCode\x12\x1b\n" +
|
||||
"\tregion_id\x18\t \x01(\x03R\bregionId\"\xeb\x02\n" +
|
||||
"\tregion_id\x18\t \x01(\x03R\bregionId\"\x91\x03\n" +
|
||||
"\x11DebitGiftResponse\x12,\n" +
|
||||
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
|
||||
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" +
|
||||
@ -8870,7 +8878,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\rprice_version\x18\x06 \x01(\tR\fpriceVersion\x12#\n" +
|
||||
"\rbalance_after\x18\a \x01(\x03R\fbalanceAfter\x12*\n" +
|
||||
"\x11charge_asset_type\x18\b \x01(\tR\x0fchargeAssetType\x12#\n" +
|
||||
"\rcharge_amount\x18\t \x01(\x03R\fchargeAmount\"\xb2\x01\n" +
|
||||
"\rcharge_amount\x18\t \x01(\x03R\fchargeAmount\x12$\n" +
|
||||
"\x0egift_type_code\x18\n" +
|
||||
" \x01(\tR\fgiftTypeCode\"\xb2\x01\n" +
|
||||
"\fAssetBalance\x12\x1d\n" +
|
||||
"\n" +
|
||||
"asset_type\x18\x01 \x01(\tR\tassetType\x12)\n" +
|
||||
|
||||
@ -31,6 +31,7 @@ message DebitGiftResponse {
|
||||
int64 balance_after = 7;
|
||||
string charge_asset_type = 8;
|
||||
int64 charge_amount = 9;
|
||||
string gift_type_code = 10;
|
||||
}
|
||||
|
||||
// AssetBalance 是用户某类资产的余额投影。
|
||||
|
||||
@ -133,6 +133,22 @@ python3 deploy/tencent-tat/validate_rendered_configs.py \
|
||||
- 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存;Redis 不能作为业务恢复事实来源。
|
||||
- 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。
|
||||
|
||||
当前 RocketMQ 资源约定:
|
||||
|
||||
| Topic | Producer Group | Consumer Group | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费房间事实,处理贵重礼物播报、宝箱播报、成长等活动逻辑 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关房间事实 |
|
||||
| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
|
||||
| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | room-service 消费宝箱延迟开箱唤醒消息 |
|
||||
|
||||
线上推荐配置:
|
||||
|
||||
- `services/room-service/configs/config.tencent.example.yaml` 保持 `outbox_worker.publish_mode: "mq"`。
|
||||
- `rocketmq.room_outbox.enabled: true` 后,`room_outbox.status=delivered` 只表示事件已进入 RocketMQ;activity、notice、IM bridge 的成功状态由各自消费位点负责。
|
||||
- `rocketmq.treasure_open.enabled: true` 后,宝箱倒计时使用毫秒级 `open_at_ms` 投递延迟消息;消息到达后 room-service 仍要重新校验 Room Cell 状态,不能把 MQ 当成宝箱状态来源。
|
||||
- 本地 Docker 默认关闭 MQ,使用 direct publisher 维持无云依赖的可运行形态。
|
||||
|
||||
## 真实数据验证
|
||||
|
||||
本地真实 MySQL/Redis 验证:
|
||||
|
||||
@ -18,8 +18,8 @@ docs/房间Outbox补偿开发.md
|
||||
| Capability | Current Owner | Current Rule |
|
||||
| --- | --- | --- |
|
||||
| IM UserSig | `gateway-service` | `/api/v1/im/usersig` 只签发 `sdk_app_id/user_id/user_sig/expire_at_ms` |
|
||||
| 房间群建群 | `room-service` | `CreateRoom` 调腾讯 IM REST `create_group`,`GroupID = room_id` |
|
||||
| 房间系统消息 | `room-service` | Room Cell 命令成功后向 `GroupID = room_id` 发 `room_*` 自定义消息 |
|
||||
| 房间群建群 | `room-service` | `CreateRoom` 写 `RoomCreated` outbox,worker direct 投递或 IM bridge consumer 从 MQ 消费后调用腾讯 IM REST `create_group`,`GroupID = room_id` |
|
||||
| 房间系统消息 | `room-service` | Room Cell 命令成功后写 `room_outbox`,worker direct 投递或 IM bridge consumer 从 MQ 消费后向 `GroupID = room_id` 发 `room_*` 自定义消息 |
|
||||
| IM 入群/发言回调 | `gateway-service` | 当前把所有 `GroupId` 都当作 `room_id`,再回查 `room-service` guard |
|
||||
| 系统/活动 inbox | `activity-service` | backend inbox/fanout,非实时 IM 群 |
|
||||
|
||||
@ -38,6 +38,7 @@ docs/房间Outbox补偿开发.md
|
||||
| 播报发送能力 | `activity-service` | 服务端向全局/区域播报群发送自定义消息 |
|
||||
| 旧区域群成员移除 | `activity-service` | 用户区域变化后,对旧区域群执行 `delete_group_member(user_id)` |
|
||||
| 贵重礼物播报触发 | `activity-service` consuming room outbox | 基于 `RoomGiftSent` 事实判断阈值并发区域播报 |
|
||||
| 宝箱倒计时播报触发 | `activity-service` consuming room outbox | 基于 `RoomTreasureCountdownStarted` 事实按后台配置发区域或全局播报 |
|
||||
|
||||
本阶段不做:
|
||||
|
||||
@ -106,7 +107,7 @@ func Parse(groupID string) ParsedGroup
|
||||
| 红包资金事实 | `wallet-service` 或独立 red-packet domain | IM 只携带入口和展示字段 |
|
||||
| 客户端 IM 长连接 | Tencent IM SDK | 本仓库不自建 WebSocket |
|
||||
|
||||
`room-service` 只需要继续产出 `RoomGiftSent` outbox 事实。贵重礼物是否需要区域播报,由 `activity-service` 消费 outbox 后判断,这样不会把区域播报策略塞进 Room Cell 主链路。
|
||||
`room-service` 只需要继续产出 `RoomGiftSent`、`RoomTreasureCountdownStarted` 等 outbox 事实。贵重礼物和宝箱倒计时是否需要区域/全局播报,由 `activity-service` 通过 direct gRPC 或 RocketMQ `hyapp_room_outbox` consumer group 消费后判断,这样不会把区域播报策略塞进 Room Cell 主链路。
|
||||
|
||||
## Client Flow
|
||||
|
||||
@ -362,6 +363,7 @@ sequenceDiagram
|
||||
participant G as gateway-service
|
||||
participant R as room-service
|
||||
participant W as wallet-service
|
||||
participant MQ as RocketMQ
|
||||
participant A as activity-service
|
||||
participant IM as Tencent IM
|
||||
|
||||
@ -372,11 +374,14 @@ sequenceDiagram
|
||||
R->>R: Room Cell heat/rank + RoomGiftSent outbox
|
||||
R-->>G: SendGiftResponse
|
||||
G-->>C: room result
|
||||
A->>R: consume room outbox / event stream
|
||||
R->>MQ: publish hyapp_room_outbox when MQ mode is enabled
|
||||
MQ->>A: consume RoomGiftSent by hyapp-activity-room-outbox
|
||||
A->>A: threshold decision
|
||||
A->>IM: send_group_msg(region broadcast group)
|
||||
```
|
||||
|
||||
本地未启用 MQ 时,`room-service` outbox worker 可以 direct gRPC 投递 activity-service;线上推荐使用 RocketMQ,activity-service 不需要回查 room-service 主链路。
|
||||
|
||||
阈值策略属于播报配置,不属于 Room Cell:
|
||||
|
||||
| Config | Meaning |
|
||||
@ -399,6 +404,54 @@ sequenceDiagram
|
||||
|
||||
如果现有事件字段不足,应优先扩展 protobuf event,而不是让 activity-service 反查多个服务拼凑事实。
|
||||
|
||||
## Room Treasure Broadcast
|
||||
|
||||
宝箱满能量进入倒计时时,Room Cell 写 `RoomTreasureCountdownStarted` outbox。activity-service 消费该事实后按后台配置决定发区域播报还是全局播报;开箱到点、奖励结算和宝箱状态推进仍由 room-service 负责。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as gateway-service
|
||||
participant R as room-service
|
||||
participant W as wallet-service
|
||||
participant MQ as RocketMQ
|
||||
participant A as activity-service
|
||||
participant IM as Tencent IM
|
||||
|
||||
C->>G: POST /api/v1/rooms/gift/send
|
||||
G->>R: SendGift
|
||||
R->>W: DebitGift
|
||||
W-->>R: receipt + heat/gift point
|
||||
R->>R: add effective treasure energy
|
||||
R->>R: status=countdown when threshold reached
|
||||
R-->>G: SendGiftResponse
|
||||
R->>MQ: RoomTreasureCountdownStarted
|
||||
MQ->>A: consume by hyapp-activity-room-outbox
|
||||
A->>IM: send_group_msg(region/global broadcast group)
|
||||
```
|
||||
|
||||
播报 payload 的核心字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "room_treasure:lalu_room_xxx:box_1",
|
||||
"broadcast_type": "room_treasure",
|
||||
"scope": "region",
|
||||
"app_code": "lalu",
|
||||
"region_id": 1001,
|
||||
"room_id": "lalu_room_xxx",
|
||||
"box_id": "box_1",
|
||||
"level": 3,
|
||||
"open_at_ms": 1770000000000,
|
||||
"action": {
|
||||
"type": "enter_room",
|
||||
"room_id": "lalu_room_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
倒计时期间继续送礼不会累加到下一宝箱,溢出能量也不会跨级;这些规则由 room-service 在 `RoomTreasureProgressChanged` 和 `RoomTreasureCountdownStarted` 事实中表达,activity-service 不能自行推导宝箱进度。
|
||||
|
||||
## Red Packet Broadcast
|
||||
|
||||
红包资金事实必须由 wallet/red-packet 领域持久化:
|
||||
@ -443,7 +496,8 @@ sequenceDiagram
|
||||
5. `activity-service` 增加 broadcast group reconciler,启动时确保全局和 active 区域群。
|
||||
6. `activity-service` 增加 `PublishRegionBroadcast/PublishGlobalBroadcast` 和 broadcast outbox。
|
||||
7. 扩展 `RoomGiftSent` 事件字段,activity-service 消费后按阈值发 `super_gift` 区域播报。
|
||||
8. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。
|
||||
8. 消费 `RoomTreasureCountdownStarted`,按后台配置发 `room_treasure` 区域或全局播报。
|
||||
9. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。
|
||||
|
||||
## Test Expectations
|
||||
|
||||
@ -459,4 +513,5 @@ sequenceDiagram
|
||||
| group reconciler | 已存在群视为成功,失败可重试 |
|
||||
| broadcast outbox | 同一 `event_id` 幂等,失败后 retry |
|
||||
| super gift | 未达阈值不播报,达阈值发送到用户房间所在区域群 |
|
||||
| room treasure | 宝箱满能量后只按 `RoomTreasureCountdownStarted` 播报一次;区域/全局 scope 来自后台配置 |
|
||||
| red packet | IM payload 不影响领取接口的后端金额校验 |
|
||||
|
||||
@ -10,7 +10,8 @@
|
||||
- `notice-service` 抢占该事件,写自己的 `notice_delivery_events` 投递位点。
|
||||
- `notice-service` 通过腾讯云 IM C2C `TIMCustomElem` 给用户发送 `wallet_notice`。
|
||||
- 客户端收到通知后按 `asset_type + balance_version` 更新本地余额;乱序或重复事件直接丢弃。
|
||||
- `room-service` 写 `room_outbox.RoomUserKicked` 后,`notice-service` 只给被踢用户发送 `room_notice` C2C 私有通知;房间群系统消息和 IM 群成员移除仍由 room-service 的房间 IM bridge 负责。
|
||||
- `room-service` 写 `room_outbox.RoomUserKicked` 后,`notice-service` 可通过 MySQL 扫描或 RocketMQ `hyapp_room_outbox` fanout 消费该事实,只给被踢用户发送 `room_notice` C2C 私有通知;房间群系统消息和 IM 群成员移除仍由 room-service 的房间 IM bridge 负责。
|
||||
- RocketMQ 模式下 `notice-service` 不反写 `room_outbox.status`,只写 `notice_delivery_events`;room outbox 的发布状态属于 room-service,下游通知投递状态属于 notice-service。
|
||||
|
||||
## 服务边界
|
||||
|
||||
@ -26,7 +27,7 @@
|
||||
|
||||
房间 IM 被拆成两种通道:
|
||||
|
||||
- 房间群通道:`RoomCreated` 建群、`RoomUserKicked` 移除群成员、`room_user_kicked` 群系统消息由 room-service outbox worker 投递。它依赖房间群生命周期和群成员控制,不进入 notice-service。
|
||||
- 房间群通道:`RoomCreated` 建群、`RoomUserKicked` 移除群成员、`room_user_kicked` 群系统消息由 room-service outbox worker direct 投递,或由 room-service 的 `hyapp-room-im-bridge` consumer group 从 `hyapp_room_outbox` 消费后投递。它依赖房间群生命周期和群成员控制,不进入 notice-service。
|
||||
- 用户私有通道:被踢本人可能已经先被移出 IM 群,不能保证收到群消息;因此 notice-service 监听同一条 `RoomUserKicked` 事实,发送 C2C `room_notice` 给 `target_user_id`。
|
||||
|
||||
## 当前目录结构
|
||||
@ -91,6 +92,7 @@ sequenceDiagram
|
||||
participant Gateway as gateway-service
|
||||
participant Room as room-service
|
||||
participant RoomDB as hyapp_room
|
||||
participant RocketMQ as RocketMQ
|
||||
participant Notice as notice-service
|
||||
participant NoticeDB as hyapp_notice
|
||||
participant TencentIM as Tencent IM
|
||||
@ -101,15 +103,24 @@ sequenceDiagram
|
||||
Room->>Room: remove presence, release mic, write ban
|
||||
Room->>RoomDB: command log + snapshot + room_outbox.RoomUserKicked
|
||||
Room-->>Gateway: success
|
||||
Room-->>TencentIM: room outbox worker deletes group member and sends group event
|
||||
Notice->>RoomDB: claim RoomUserKicked
|
||||
Room-->>TencentIM: direct/IM bridge deletes group member and sends group event
|
||||
Room->>RocketMQ: publish hyapp_room_outbox when MQ enabled
|
||||
Notice->>RocketMQ: consume RoomUserKicked by hyapp-notice-room-outbox
|
||||
Notice->>RoomDB: or claim RoomUserKicked by MySQL polling
|
||||
Notice->>NoticeDB: notice_delivery_events=delivering
|
||||
Notice->>TencentIM: C2C TIMCustomElem(room_notice)
|
||||
TencentIM-->>Target: private kicked notice
|
||||
Notice->>NoticeDB: delivered / retryable / failed
|
||||
```
|
||||
|
||||
房间群消息和 C2C 私有通知使用同一个 `event_id`,客户端按 `event_id` 去重即可。notice-service 不修改 `room_outbox.status`,它只用 `notice_delivery_events` 记录自己的 C2C 投递状态,避免和 room-service 群消息 worker 互相覆盖。
|
||||
房间群消息和 C2C 私有通知使用同一个 `event_id`,客户端按 `event_id` 去重即可。notice-service 不修改 `room_outbox.status`,它只用 `notice_delivery_events` 记录自己的 C2C 投递状态,避免和 room-service outbox worker 或 IM bridge consumer 互相覆盖。
|
||||
|
||||
MQ 和 MySQL 扫描是同一事实的两种入口:
|
||||
|
||||
| 入口 | 使用场景 | 幂等边界 |
|
||||
| --- | --- | --- |
|
||||
| MySQL `room_notice_worker` | 本地或未接 MQ 的环境,直接从 `hyapp_room.room_outbox` claim `RoomUserKicked` | `notice_delivery_events(source_name, app_code, source_event_id, channel)` |
|
||||
| RocketMQ `hyapp_room_outbox` | 线上推荐,notice 使用独立 consumer group `hyapp-notice-room-outbox` 解耦消费 | 同一张 `notice_delivery_events`,重复 MQ 消息不会重复投 C2C |
|
||||
|
||||
## Payload 约定
|
||||
|
||||
@ -172,6 +183,13 @@ wallet_notice_worker:
|
||||
|
||||
room_notice_worker:
|
||||
enabled: false
|
||||
|
||||
rocketmq:
|
||||
enabled: false
|
||||
room_outbox:
|
||||
enabled: false
|
||||
topic: "hyapp_room_outbox"
|
||||
consumer_group: "hyapp-notice-room-outbox"
|
||||
```
|
||||
|
||||
线上启用私有通知必须同时开启腾讯云 IM,并配置对应 owner outbox 所在库名:
|
||||
@ -199,18 +217,33 @@ room_notice_worker:
|
||||
batch_size: 100
|
||||
lock_ttl: 30s
|
||||
max_retry_count: 10
|
||||
|
||||
rocketmq:
|
||||
enabled: true
|
||||
name_servers:
|
||||
- "TENCENT_ROCKETMQ_NAMESERVER:9876"
|
||||
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
|
||||
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
|
||||
room_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_room_outbox"
|
||||
consumer_group: "hyapp-notice-room-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
```
|
||||
|
||||
`notice-service` 使用 `13009` 作为 gRPC health 端口,`13109` 作为 HTTP health 端口。
|
||||
|
||||
启用 MQ 消费后,可以按部署阶段选择是否保留 MySQL 扫描 worker。两者同时开启时不会重复发通知,因为 `notice_delivery_events` 的主键会挡住同一 `event_id + channel` 的重复投递;长期线上推荐以 MQ consumer 作为实时入口,MySQL 扫描只作为补偿或独立验证工具。
|
||||
|
||||
## 后续扩展顺序
|
||||
|
||||
1. 钱包余额通知:已按 `walletnotice` 模块落地。
|
||||
2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。
|
||||
3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outbox;notice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。
|
||||
4. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox,复用 `notice_delivery_events` 或新增同语义位点表。
|
||||
5. 站内 inbox:新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。
|
||||
6. Push:新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。
|
||||
4. 语音房宝箱私有通知:如产品需要私聊提醒,可在 `roomnotice` 中消费 `RoomTreasureRewardGranted` 或由活动/系统消息模块转发;奖励入账事实仍由 room-service 调 wallet 完成,notice 只投通知。
|
||||
5. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox,复用 `notice_delivery_events` 或新增同语义位点表。
|
||||
6. 站内 inbox:新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。
|
||||
7. Push:新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。
|
||||
|
||||
## 真实库验证
|
||||
|
||||
|
||||
@ -17,7 +17,8 @@
|
||||
| --- | --- | --- | --- |
|
||||
| `room-service` | stale presence cleanup | `services/room-service/internal/room/service/presence.go` | 留在 `room-service` |
|
||||
| `room-service` | mic publish timeout cleanup | `services/room-service/internal/room/service/mic_publish_timeout.go` | 留在 `room-service` |
|
||||
| `room-service` | room outbox delivery to Tencent IM | `services/room-service/internal/room/service/outbox_worker.go` | 留在 `room-service` 或后续拆 `room-worker`,不进通用 cron |
|
||||
| `room-service` | room outbox relay to direct publishers / RocketMQ | `services/room-service/internal/room/service/outbox_worker.go` | 留在 `room-service` 或后续拆 `room-worker`,不进通用 cron |
|
||||
| `room-service` | room treasure delayed open wakeup | `services/room-service/internal/room/service/room_treasure.go`、`services/room-service/internal/room/service/room_treasure_mq.go` | 留在 `room-service`;RocketMQ 只唤醒,到点后仍走 Room Cell 命令链路 |
|
||||
| `user-service` | region rebuild task | `UserCronService.ProcessRegionRebuildBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 |
|
||||
| `user-service` | login IP risk job | `UserCronService.ProcessLoginIPRiskBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 |
|
||||
| `user-service` | invite recharge wallet outbox consumer | `services/user-service/internal/service/invite/service.go` | 先不迁,需补 durable cursor 或事件总线 |
|
||||
@ -93,7 +94,8 @@ graph LR
|
||||
| --- | --- |
|
||||
| Room Cell stale presence cleanup | 依赖本节点已装载 Room Cell 和 Redis lease,必须由 `room-service` owner 执行命令链路 |
|
||||
| Mic publish timeout cleanup | 必须对当前 Room Cell 状态做二次校验并执行 `MicDown`,不能让 cron 直接清麦 |
|
||||
| room outbox delivery | 这是 room-service 的事件投递补偿,不是通用定时任务;需要和 Tencent IM bridge、outbox 状态语义绑定 |
|
||||
| room outbox relay | 这是 room-service 的事件投递补偿,不是通用定时任务;需要和 direct publisher、RocketMQ 发布、outbox 状态语义绑定 |
|
||||
| room treasure open timer | 宝箱状态属于 Room Cell;延迟消息只负责唤醒,开箱必须由 room-service 重新校验 `box_id/open_at_ms/UTC reset` 后执行命令 |
|
||||
| wallet ledger transaction | 金币、钻石、积分、余额的加减和冻结必须在 `wallet-service` 事务内完成 |
|
||||
| wallet outbox publish | 属于 wallet-service outbox relay 或 MQ 投递,不应该由通用 cron 直接扫表改状态 |
|
||||
| invite recharge outbox consumer | 当前使用 in-memory cursor 扫 wallet outbox,迁移前必须先做 durable offset 或正式事件总线 |
|
||||
@ -265,8 +267,11 @@ cron-service 可以直接开启多个实例;调度层通过 `cron_task_leases`
|
||||
- room mic outbox consumer。
|
||||
- wallet outbox relay。
|
||||
- activity room outbox consumer。
|
||||
- notice room outbox consumer。
|
||||
- room IM bridge MQ consumer。
|
||||
- room treasure delayed open consumer。
|
||||
|
||||
这些需要的是事件消费基础设施:MQ、Redis Stream、或 MySQL durable offset。cron-service 不能用“每隔几秒从头扫 outbox”的方式替代事件消费者。
|
||||
这些需要的是事件消费基础设施:RocketMQ、Redis Stream、或 MySQL durable offset。cron-service 不能用“每隔几秒从头扫 outbox”的方式替代事件消费者。即使某些消费者内部有低频扫描补偿,也必须留在业务 owner service 中,因为它们需要理解 outbox 状态、消费幂等和领域状态校验。
|
||||
|
||||
### Future Scheduled Business Jobs
|
||||
|
||||
|
||||
@ -1,32 +1,32 @@
|
||||
# Room Outbox Worker
|
||||
|
||||
本文档记录当前 `room-service` outbox 的实际语义。房间写命令只负责提交 Room Cell 状态、command log、snapshot 和 outbox;腾讯云 IM、activity-service 等外部投递全部由 outbox worker 异步执行。
|
||||
本文档定义当前 `room-service` outbox 的实际语义。房间写命令只负责提交 Room Cell 状态、command log、snapshot 和 `room_outbox`;腾讯云 IM、activity-service、notice-service、后续 push/inbox 等外部副作用都通过 outbox worker 或 RocketMQ 下游 consumer 异步完成。
|
||||
|
||||
## 边界
|
||||
## 核心结论
|
||||
|
||||
| 项目 | 当前规则 |
|
||||
| --- | --- |
|
||||
| 主链路 | 不同步调用腾讯云 IM,不同步投递 activity-service |
|
||||
| 持久化 | 命令变更、command log、room_outbox 在同一提交链路内完成 |
|
||||
| worker | 读取 `room_outbox`,投递到配置的 outbox publisher fanout |
|
||||
| 腾讯云 IM | `RoomCreated` 由 worker 确保群组存在,其他房间事件由 worker 发送群系统消息 |
|
||||
| activity-service | 由 room outbox worker 异步投递,失败进入 room_outbox 重试 |
|
||||
| 幂等 | 房间命令靠 `command_id`,outbox 投递靠 `event_id` |
|
||||
| 状态 owner | Room Cell 和 MySQL snapshot/command log 是房间状态权威来源 |
|
||||
| 持久事实 | `room_outbox` 是房间外事件的持久事实源,和房间命令提交链路一起落 MySQL |
|
||||
| 发布模式 | `outbox_worker.publish_mode` 支持 `direct`、`mq`、`dual` |
|
||||
| MQ 职责 | RocketMQ 只做事件分发和宝箱延迟唤醒,不保存房间核心状态 |
|
||||
| 下游语义 | 下游服务按 `event_id` 幂等消费,失败由自己的位点、重试和死信处理 |
|
||||
| 主链路 | HTTP/gRPC 请求不等待 IM REST、activity、notice、push 或 inbox 投递完成 |
|
||||
|
||||
不做的事:
|
||||
|
||||
| 不做 | 原因 |
|
||||
| --- | --- |
|
||||
| 在 HTTP/gRPC 请求里等待 IM REST | 外部慢调用会拖住房间串行命令队列 |
|
||||
| 在 Room Cell 中保存投递状态 | 投递状态属于 outbox,不属于房间权威状态 |
|
||||
| 直接用 Redis 做 outbox | MySQL 是恢复和补偿的持久来源 |
|
||||
| 精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once,消费端必须按 `event_id` 去重 |
|
||||
| 在 Room Cell 中保存投递状态 | 投递状态属于 outbox 或下游消费位点,不属于房间权威状态 |
|
||||
| 用 Redis 替代 outbox | Redis 只保存热状态和短 TTL lease,不能作为恢复事实来源 |
|
||||
| 用 RocketMQ 替代 `room_outbox` | 房间状态提交成功但 MQ 发布失败时,必须能从 MySQL 补偿发布 |
|
||||
| 追求精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once,消费者必须按 `event_id` 去重 |
|
||||
|
||||
## 写命令语义
|
||||
## 写命令链路
|
||||
|
||||
```text
|
||||
gateway -> room-service command
|
||||
-> idempotency check
|
||||
-> command_id idempotency check
|
||||
-> ensure Redis lease and Room Cell
|
||||
-> mutate cloned RoomState
|
||||
-> optional wallet debit before room gift state is committed
|
||||
@ -37,11 +37,25 @@ gateway -> room-service command
|
||||
|
||||
outbox worker
|
||||
-> claim pending/retryable rows
|
||||
-> publish to composite outbox publisher
|
||||
-> if RoomTreasureCountdownStarted, schedule treasure open delayed wakeup when configured
|
||||
-> publish by mode:
|
||||
direct: Tencent IM publisher + activity gRPC publisher
|
||||
mq: RocketMQ room_outbox publisher
|
||||
dual: direct publishers + RocketMQ publisher
|
||||
-> delivered / retryable / failed
|
||||
```
|
||||
|
||||
主链路成功只表示房间状态已经落库;不表示腾讯云 IM 或 activity-service 已经完成投递。客户端实时表现应以房间响应、房间 snapshot 和 IM 系统消息共同补偿。
|
||||
主请求成功只表示房间状态已经提交;不表示腾讯云 IM、activity-service、notice-service 或任何后续通知已经投递完成。客户端实时表现应以请求响应、房间 snapshot 和腾讯云 IM 系统消息共同补偿。
|
||||
|
||||
## 发布模式
|
||||
|
||||
| `publish_mode` | 行为 | 适用场景 |
|
||||
| --- | --- | --- |
|
||||
| `direct` | worker 直接调用腾讯云 IM publisher 和 activity gRPC publisher;所有 direct publisher 成功后才标记 `delivered` | 本地开发、未接 MQ 的隔离测试 |
|
||||
| `mq` | worker 只把 `room_outbox_event` 发布到 `hyapp_room_outbox`;RocketMQ 接收成功后标记 `delivered` | 线上推荐模式,activity、notice、IM bridge 各自 consumer group 解耦消费 |
|
||||
| `dual` | 同时 direct 投递和 MQ 发布,全部成功后标记 `delivered` | 迁移或灰度期;要求所有消费端都能按 `event_id` 去重 |
|
||||
|
||||
在 `mq` 模式下,`room_outbox.status=delivered` 的含义是“事件已经可靠进入 RocketMQ”,不是“所有下游都已处理成功”。下游的成功、重试和死信由各自服务的消费位点负责,不能反写 `room_outbox.status`。
|
||||
|
||||
## 数据状态
|
||||
|
||||
@ -51,21 +65,54 @@ outbox worker
|
||||
| --- | --- |
|
||||
| `pending` | 已落库,尚未被 worker 处理 |
|
||||
| `delivering` | 已被 worker 抢占,`lock_until_ms` 前其他 worker 不抢 |
|
||||
| `delivered` | 所有配置的 outbox publisher 已处理成功 |
|
||||
| `retryable` | 上次投递失败,等待 `next_retry_at_ms` 后重试 |
|
||||
| `failed` | 达到最大重试次数,进入死信,需要人工处理或后台工具重放 |
|
||||
| `delivered` | 当前发布模式要求的 publisher 已处理成功;MQ 模式下表示 RocketMQ 已接收 |
|
||||
| `retryable` | 上次发布失败,等待 `next_retry_at_ms` 后重试 |
|
||||
| `failed` | 达到最大重试次数,进入死信,需要后台工具查看或重放 |
|
||||
|
||||
旧数据里可能存在历史 `published` 状态。当前 worker 不再写这个状态,新链路只写上表状态。
|
||||
|
||||
## RocketMQ Topics
|
||||
|
||||
| Topic | Producer | Consumer Group | 语义 |
|
||||
| --- | --- | --- | --- |
|
||||
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-activity-room-outbox` | activity-service 消费房间事实,做活动、播报、系统消息等后续处理 |
|
||||
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关事实,例如 `RoomUserKicked` |
|
||||
| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 |
|
||||
| `hyapp_room_treasure_open` | `room-service` treasure scheduler | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 Room Cell 开箱命令 |
|
||||
|
||||
`hyapp_room_treasure_open` 不是业务状态源。消息只携带 `app_code、room_id、box_id、level、open_at_ms、command_id` 等唤醒参数;真正能否开箱仍由 room-service 加载 Room Cell 后校验当前宝箱状态、等级、`box_id`、`open_at_ms` 和 UTC 重置边界。
|
||||
|
||||
## 配置
|
||||
|
||||
`services/room-service/configs/*.yaml` 当前配置:
|
||||
`services/room-service/configs/*.yaml` 需要同时维护本地、Docker 和腾讯云示例配置:
|
||||
|
||||
```yaml
|
||||
snapshot_every_n: 10
|
||||
rocketmq:
|
||||
enabled: false
|
||||
name_servers: []
|
||||
name_server_domain: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
namespace: ""
|
||||
send_timeout: "3s"
|
||||
retry: 2
|
||||
room_outbox:
|
||||
enabled: false
|
||||
topic: "hyapp_room_outbox"
|
||||
producer_group: "hyapp-room-outbox-producer"
|
||||
tencent_im_consumer_enabled: false
|
||||
tencent_im_consumer_group: "hyapp-room-im-bridge"
|
||||
consumer_max_reconsume_times: 16
|
||||
treasure_open:
|
||||
enabled: false
|
||||
topic: "hyapp_room_treasure_open"
|
||||
producer_group: "hyapp-room-treasure-open-producer"
|
||||
consumer_group: "hyapp-room-treasure-open"
|
||||
consumer_max_reconsume_times: 16
|
||||
|
||||
outbox_worker:
|
||||
enabled: true
|
||||
publish_mode: "direct"
|
||||
poll_interval: "1s"
|
||||
batch_size: 100
|
||||
publish_timeout: "3s"
|
||||
@ -79,18 +126,33 @@ outbox_worker:
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `snapshot_every_n` | 开发和生产默认 `10`;命令日志仍保证恢复,不能依赖每条命令快照 |
|
||||
| `rocketmq.enabled` | 控制 RocketMQ client 是否启用;本地默认关闭,线上示例开启 |
|
||||
| `rocketmq.room_outbox.enabled` | 控制 room outbox 是否可发布到 MQ;`publish_mode=mq/dual` 时必须开启 |
|
||||
| `rocketmq.room_outbox.tencent_im_consumer_enabled` | 控制 room-service 是否作为 IM bridge consumer 消费 `hyapp_room_outbox` |
|
||||
| `rocketmq.treasure_open.enabled` | 控制宝箱倒计时是否发布和消费延迟唤醒消息 |
|
||||
| `outbox_worker.publish_mode` | 本地默认 `direct`,线上建议 `mq`,迁移期可用 `dual` |
|
||||
| `poll_interval` | worker 空轮询间隔,必须大于 0,避免 busy loop |
|
||||
| `batch_size` | 每轮最大抢占条数,必须大于 0,避免全表扫描 |
|
||||
| `publish_timeout` | 单条 outbox 投递 deadline,避免外部系统拖死 worker |
|
||||
| `retry_strategy` | 当前为 `exponential_backoff` |
|
||||
| `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久污染日志 |
|
||||
| `initial_backoff` | 第一次失败后的最小退避 |
|
||||
| `max_backoff` | 指数退避上限 |
|
||||
| `publish_timeout` | 单条 outbox 发布 deadline,避免外部系统拖死 worker |
|
||||
| `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久刷日志 |
|
||||
|
||||
## 投递对象
|
||||
|
||||
| Publisher / Consumer | 行为 |
|
||||
| --- | --- |
|
||||
| RocketMQ room outbox publisher | 把 protobuf outbox 包装成统一 `room_outbox_event`,发布到 `hyapp_room_outbox` |
|
||||
| Tencent IM direct publisher | `RoomCreated` 确保群存在;房间系统事件发送腾讯云 IM 群自定义消息 |
|
||||
| Activity direct publisher | 本地或未接 MQ 时直接调用 activity-service room event 消费入口 |
|
||||
| Treasure open scheduler | 在 `RoomTreasureCountdownStarted` 后发布到点延迟消息,唤醒 room-service 开箱 |
|
||||
| Activity MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,按 `event_id` 幂等推进活动/播报逻辑 |
|
||||
| Notice MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,写 `notice_delivery_events` 后投 C2C 私有通知 |
|
||||
| Room IM bridge MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,投递房间群系统消息和群成员控制 |
|
||||
|
||||
同一个事件允许被多个 consumer group 消费。任何 consumer 失败都不能阻塞 Room Cell 主链路,也不能影响其它 consumer group 的进度。
|
||||
|
||||
## 重试和死信
|
||||
|
||||
失败处理:
|
||||
发布失败处理:
|
||||
|
||||
```text
|
||||
publish failed
|
||||
@ -101,16 +163,7 @@ publish failed
|
||||
|
||||
`failed` 不再自动重试。后台应提供人工查看和重放工具,重放时必须保留原 `event_id` 或生成明确的新修复事件,避免消费端无法去重。
|
||||
|
||||
## 投递对象
|
||||
|
||||
当前 `CompositeOutboxPublisher` fanout 到多个 publisher:
|
||||
|
||||
| Publisher | 行为 |
|
||||
| --- | --- |
|
||||
| Tencent IM | `RoomCreated` 确保群存在;房间系统事件发送腾讯云 IM 群自定义消息 |
|
||||
| Activity | 将 room outbox 事件投递给 `activity-service` 的消费入口 |
|
||||
|
||||
fanout 要求所有 publisher 成功后才标记 `delivered`。任一失败都会进入重试,因此消费端必须幂等处理重复事件。
|
||||
RocketMQ consumer 失败时不修改 `room_outbox`。consumer 应依赖 RocketMQ 重投、自己的幂等表和死信处理;对于已经执行业务副作用但 ACK 失败的窗口,下一次消费必须按 `event_id` 安全去重。
|
||||
|
||||
## 阶段耗时日志
|
||||
|
||||
@ -133,12 +186,13 @@ fanout 要求所有 publisher 成功后才标记 `delivered`。任一失败都
|
||||
|
||||
## 验收
|
||||
|
||||
必须满足:
|
||||
|
||||
| 场景 | 验收 |
|
||||
| --- | --- |
|
||||
| IM 不可用 | 房间写命令仍成功,outbox 进入 `retryable` 或最终 `failed` |
|
||||
| activity 不可用 | 房间写命令仍成功,outbox 进入重试 |
|
||||
| IM 不可用,`direct/dual` | 房间写命令仍成功,outbox 进入 `retryable` 或最终 `failed` |
|
||||
| activity 不可用,`direct/dual` | 房间写命令仍成功,outbox 进入重试 |
|
||||
| MQ 不可用,`mq/dual` | 房间写命令仍成功,outbox 进入重试;MQ 恢复后补发 |
|
||||
| activity consumer 不可用,`mq` | `room_outbox` 不回退;RocketMQ 和 activity 消费位点负责积压与重试 |
|
||||
| poison event | 达到 `max_retry_count` 后进入 `failed`,不再每秒刷日志 |
|
||||
| 正常投递 | 同房间事件最终全部 `delivered` |
|
||||
| 宝箱倒计时 | `RoomTreasureCountdownStarted` 发布延迟唤醒;到点后 room-service 重新校验状态再开箱 |
|
||||
| 正常投递 | 同一 `event_id` 可被多个 consumer group 独立消费且重复投递不产生重复副作用 |
|
||||
| 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` |
|
||||
|
||||
@ -81,7 +81,7 @@
|
||||
| 礼物配置 | 礼物 ID、价格、热度、表现可配置 |
|
||||
| SendGift | 扣费成功后进入 Room Cell |
|
||||
| 房间表现 | 热度、本地榜、礼物消息同步变化 |
|
||||
| outbox | activity/audit 后续可消费完整事件 |
|
||||
| outbox | activity、notice、IM bridge 可通过 direct 或 RocketMQ 消费完整房间事件 |
|
||||
|
||||
验收:用户送普通礼物后金币减少、房间热度增加、榜单变化、消息广播成功。
|
||||
|
||||
|
||||
@ -135,14 +135,15 @@
|
||||
| 本地礼物榜 | `DONE` | `LocalRank` 和 `gift_rank` 快照 | 全局榜、跨房榜、榜单重置未做 |
|
||||
| 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效;SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 |
|
||||
| 背包/道具 | `PARTIAL` | `user_resource_entitlements`、`user_resource_equipment` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 |
|
||||
| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox,由 worker 异步投 IM/activity | 客户端去重和展示策略未验收 |
|
||||
| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox,由 worker direct 投 IM/activity,或发布到 RocketMQ 后由各 consumer group 消费 | 客户端去重和展示策略未验收 |
|
||||
| 语音房宝箱 | `DONE` | `room_treasure_configs`、`GetRoomTreasure`、`RoomTreasureProgressChanged/CountdownStarted/Opened/RewardGranted`,开箱支持本地扫描和 RocketMQ 延迟唤醒 | App UI、后台配置运营验收和真实 MQ 联调 |
|
||||
|
||||
## Activity And Lucky Gift
|
||||
|
||||
| Feature | Status | Current Evidence | Remaining Work |
|
||||
| --- | --- | --- | --- |
|
||||
| activity-service 底座 | `PARTIAL` | gRPC Ping/GetActivityStatus、MySQL activity/outbox 表存在 | 具体活动规则和奖励结算未实现 |
|
||||
| room outbox 消费边界 | `DONE` | room outbox worker 异步投递 activity-service,失败跟随 room_outbox 重试/死信 | activity 内部消费幂等表和后台排障页需补 |
|
||||
| room outbox 消费边界 | `DONE` | 本地 direct 投递 activity-service;线上可发布 `hyapp_room_outbox`,activity 使用 `hyapp-activity-room-outbox` 独立消费位点 | 后台排障页和消费积压告警需补 |
|
||||
| 幸运礼物开发文档 | `DONE` | `docs/幸运礼物Activity服务开发.md` | 按文档实现 proto、pool、risk、draw |
|
||||
| 活动配置 | `TODO` | 只有 activity status projection | 活动规则、时间、奖池、风控配置未实现 |
|
||||
| LuckyGift Check | `TODO` | 文档有设计,proto/code 未实现 | `CheckLuckyGift` RPC 和规则校验 |
|
||||
@ -235,7 +236,7 @@
|
||||
|
||||
| Task | Status | Acceptance |
|
||||
| --- | --- | --- |
|
||||
| room outbox -> activity consumer | `TODO` | activity-service 幂等消费 RoomGiftSent |
|
||||
| LuckyGift event handler | `TODO` | activity-service 在现有 RoomGiftSent 消费入口上增加幸运礼物 Check/Draw |
|
||||
| LuckyGift proto 和 service | `TODO` | CheckLuckyGift/ExecuteLuckyGiftDraw 可用 |
|
||||
| 三层奖池和风控 | `TODO` | 平台、房间、礼物奖池不会透支 |
|
||||
| 奖励入账 | `TODO` | wallet 支持 CreditLuckyReward,失败进入 activity outbox |
|
||||
|
||||
@ -1,138 +1,142 @@
|
||||
# 语音房宝箱功能架构
|
||||
|
||||
本文定义语音房宝箱的产品规则、服务边界、App 接口、IM 事件、后台配置、奖励结算和验收边界。宝箱是房间内送礼驱动的互动玩法:用户在语音房送礼积攒能量,当前等级宝箱能量满后进入倒计时,倒计时结束时按房间快照抽奖并发放奖励。所有周期、重置和统计口径统一使用 UTC。
|
||||
本文定义当前语音房宝箱的产品规则、服务边界、App 接口、后台配置、MQ 唤醒、room outbox 事件和验收边界。宝箱是房间内送礼驱动的互动玩法:用户在语音房送礼积攒能量,当前等级满能量后进入倒计时,倒计时结束时由 `room-service` 按 Room Cell 快照结算并调用 `wallet-service` 发放资源组奖励。所有周期、重置和统计口径统一使用 UTC。
|
||||
|
||||
## Goals
|
||||
## 当前实现结论
|
||||
|
||||
| 目标 | 规则 |
|
||||
| 能力 | 当前事实 |
|
||||
| --- | --- |
|
||||
| 礼物积攒能量 | 只有 `SendGift` 扣费成功并落 Room Cell 后才增加宝箱能量 |
|
||||
| 7 级宝箱 | 每个 UTC 自然日从 1 级开始,最多开启 7 个等级 |
|
||||
| 满能量倒计时开启 | 能量达到当前等级阈值后锁定本轮,按后台 `open_delay_ms` 设置 `open_at_ms` |
|
||||
| 满能量播报 | 满能量后立即或按后台延迟发起播报,播报范围支持 `none/region/global` |
|
||||
| 开箱抽奖 | `open_at_ms` 到达后快照房间在线用户、点火人和贡献第一,生成奖励结算事实 |
|
||||
| 自动发奖 | 奖励由 activity-service 幂等抽奖并调用 wallet-service 发放资源组,不需要 App 手动领取 |
|
||||
| UTC 重置 | 进度、贡献和已开启等级按 UTC 日期重置,不使用服务器本地时区或客户端时区 |
|
||||
| App 初始化 | `GET /api/v1/rooms/{room_id}/treasure` 返回 7 级物料、当前等级、当前进度、重置时间、奖励展示配置 |
|
||||
| 送礼累积 | `SendGift` 同步调用 `wallet-service.DebitGift` 成功后,Room Cell 内更新热度、礼物榜和宝箱进度 |
|
||||
| 满能量 | 当前等级进度封顶为阈值,锁定点火人、贡献第一、`box_id`、`open_at_ms` 和 UTC `reset_at_ms` |
|
||||
| 溢出和倒计时送礼 | 当前等级溢出能量作废;倒计时期间送礼不累加到下一级,也不改变点火人或贡献第一 |
|
||||
| 到期开箱 | `room-service` 通过 RocketMQ 延迟消息到 `open_at_ms` 唤醒;本地已加载 Cell 扫描 worker 仍作为兜底 |
|
||||
| 奖励结算 | `room-service` 在开箱命令内按配置加权抽取奖励,并调用 `wallet-service.GrantResourceGroup` 幂等发放 |
|
||||
| 离房/下线 | `top1` 和 `igniter` 在倒计时开始时锁定;开箱前离房或下线仍结算对应角色奖励 |
|
||||
| 在房奖励 | 只发给开箱命令执行时 Room Cell `OnlineUsers` 中的用户 |
|
||||
| 播报 | `activity-service` 消费 `RoomTreasureCountdownStarted`,按 `broadcast_scope` 发区域或全局播报 |
|
||||
| 事件分发 | `room_outbox` 先落 MySQL,再由 outbox worker 发布 RocketMQ;activity、notice、IM bridge 各自 consumer group 消费 |
|
||||
|
||||
## Non-Goals
|
||||
|
||||
| 不做 | 原因 |
|
||||
| --- | --- |
|
||||
| 客户端提交能量、奖励或中奖结果 | 能量和奖励必须来自服务端扣费、配置和随机结果 |
|
||||
| 在 room-service 保存钱包余额或发奖账本 | 钱包资产和资源发放仍由 wallet-service 拥有 |
|
||||
| 由 activity-service 判断谁在房 | 房间 presence 和开箱快照必须由 Room Cell 产出 |
|
||||
| 只用 Redis 保存宝箱进度 | MySQL snapshot/command log/宝箱表才是恢复来源 |
|
||||
| 宝箱发奖失败时提前展示已到账 | 客户端只能展示 wallet-service 已成功发放的奖励 |
|
||||
| 客户端提交能量、奖励或中奖结果 | 能量和奖励必须来自服务端扣费、配置和确定性抽奖 |
|
||||
| 只用 Redis 保存宝箱进度 | MySQL snapshot、command log 和 `room_outbox` 才是恢复来源 |
|
||||
| activity-service 判断谁在房 | 房间 presence 和开箱快照必须由 Room Cell 产出 |
|
||||
| activity-service 发放宝箱奖励 | 当前发奖在 `room-service` 开箱命令内调用 `wallet-service`,避免跨服务二次改写房间结算事实 |
|
||||
| cron-service 到点开箱 | 到点唤醒属于房间事件消费和 Room Cell 命令触发,不是通用 cron 任务 |
|
||||
| 倒计时期间继续排队能量 | 产品规则明确无效,不累加到下一个宝箱 |
|
||||
|
||||
## Missing Interfaces
|
||||
|
||||
你的描述已经覆盖了核心 App 查询和 3 类 IM,但还缺这些接口和后台能力。
|
||||
|
||||
| 类型 | 接口 | 必要性 |
|
||||
| --- | --- | --- |
|
||||
| App 查询 | `GET /api/v1/rooms/{room_id}/treasure-chest` | 房间页主动同步宝箱配置、当前进度、倒计时、重置时间和最近结算 |
|
||||
| App 查询 | `GET /api/v1/rooms/{room_id}/treasure-chest/rewards?round_id=...&scope=mine\|public` | 用户重连或错过 IM 后查询自己奖励、公开奖励摘要和发奖状态 |
|
||||
| App 查询 | `JoinRoom` / `RoomDetail` 可内嵌 `treasure_chest_summary` | 避免进房首屏先空白,再等单独接口返回 |
|
||||
| App 命令响应 | `SendGiftResponse` 增加 `treasure_chest_delta` | 送礼人成功送礼后立即更新能量和倒计时,不只依赖异步 IM |
|
||||
| Admin 配置 | `GET/PUT /api/v1/admin/room-treasure/config` | 配置开关、7 级阈值、物料、倒计时、播报范围、奖励池和生效版本 |
|
||||
| Admin 审计 | `GET /api/v1/admin/room-treasure/rounds` | 排查某房某天宝箱进度、点火人、贡献第一和状态 |
|
||||
| Admin 审计 | `GET /api/v1/admin/room-treasure/reward-records` | 查询用户中奖、发奖、失败和重试记录 |
|
||||
| Admin 补偿 | `POST /api/v1/admin/room-treasure/rounds/{round_id}/retry-settlement` | 对抽奖或 wallet 发放失败的轮次做幂等补偿 |
|
||||
| Internal room | `ProcessDueTreasureChests` | cron 或 room-service worker 触发到期开箱,不能依赖单机内存 timer |
|
||||
| Internal room | `ApplyTreasureChestSettlement` | activity-service 发奖完成后回写结算摘要,由 room-service 发房间发奖 IM |
|
||||
| Internal activity | `ExecuteTreasureChestSettlement` 或消费 `RoomTreasureChestOpened` outbox | 按 Room Cell 快照做抽奖、写奖励记录、调用 wallet 发奖 |
|
||||
| Internal wallet | 复用 `GrantResourceGroup`,后续补 `BatchGrantResourceGroup` | 每个在房用户都可能获奖,首版可逐个幂等发放,大房间需要批量优化 |
|
||||
|
||||
如果坚持“所有奖励完全自动到账”,不需要 App `claim` 接口;如果产品改成手动领取,必须新增 `POST /api/v1/rooms/{room_id}/treasure-chest/rewards/{reward_id}/claim`,并重新定义过期和未领取回收规则。
|
||||
|
||||
## Ownership
|
||||
## 服务边界
|
||||
|
||||
| 模块 | 拥有 | 不拥有 |
|
||||
| --- | --- | --- |
|
||||
| `room-service` | 宝箱进度、等级、状态机、点火人、贡献榜、开箱在线用户快照、房间宝箱 IM | 奖励实际入账、用户完整资料、全局/区域播报群成员 |
|
||||
| `activity-service` | 宝箱奖励抽奖、奖励记录、发奖重试、全局/区域播报 outbox | Room Cell presence、房间核心状态 |
|
||||
| `wallet-service` | 资源组发放、金币/钻石入账、钱包 outbox、幂等账本 | 宝箱进度和中奖资格 |
|
||||
| `gateway-service` | App HTTP envelope、鉴权、聚合 room/activity/wallet 读模型 | 能量、抽奖和发奖事实 |
|
||||
| `server/admin` | 后台权限、表单校验、审计、调用 owner service 管理 RPC | 直接写 room/activity/wallet 业务表 |
|
||||
| `room-service` | 宝箱进度、等级、状态机、点火人、贡献第一、开箱在线用户快照、抽奖结果、房间宝箱 IM outbox、RocketMQ 延迟开箱唤醒消费 | 用户完整资料、全局/区域播报群成员、钱包余额账本 |
|
||||
| `wallet-service` | 送礼扣费、资源组发放、钱包账务、发放幂等 | 宝箱进度、中奖资格、房间 presence |
|
||||
| `activity-service` | 满能量后区域/全局播报、成长值等房间事件派生消费 | Room Cell 状态、宝箱奖励结算 |
|
||||
| `notice-service` | 通过 room outbox/MQ 消费需要私信的房间事实,写自己的投递位点和死信 | 房间群系统消息、群成员控制、房间状态 |
|
||||
| `gateway-service` | App HTTP envelope、鉴权、协议转换、调用 room gRPC | 能量、抽奖和发奖事实 |
|
||||
| `server/admin` | 后台菜单、配置表单、审计、调用 owner service 管理接口 | 直接写 room/wallet/activity 业务表 |
|
||||
|
||||
宝箱进度必须经过 Room Cell 命令链路。activity-service 可以消费 room outbox 做奖励结算和播报,但不能自己判断房间内用户、不能直接改宝箱进度。
|
||||
宝箱进度和开箱必须经过 Room Cell 命令链路。MQ 只承担“到点唤醒”和“已提交事实分发”,不能成为宝箱状态 owner。
|
||||
|
||||
## State Machine
|
||||
## 状态机
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> disabled
|
||||
disabled --> charging: config enabled
|
||||
charging --> countdown: energy >= threshold
|
||||
countdown --> opening: now >= open_at_ms
|
||||
opening --> settling: RoomTreasureChestOpened outbox committed
|
||||
settling --> settled: rewards terminal
|
||||
settled --> charging: next level exists
|
||||
settled --> exhausted: level 7 settled
|
||||
exhausted --> charging: next UTC day
|
||||
charging --> charging: UTC daily reset
|
||||
[*] --> idle
|
||||
idle --> idle: SendGift progress<threshold
|
||||
idle --> countdown: SendGift progress>=threshold
|
||||
countdown --> opened: open_at_ms reached
|
||||
opened --> idle: next level
|
||||
opened --> exhausted: level 7 opened
|
||||
idle --> idle: UTC day reset
|
||||
countdown --> idle: UTC day reset before open
|
||||
exhausted --> idle: next UTC day
|
||||
```
|
||||
|
||||
| 状态 | 语义 |
|
||||
| --- | --- |
|
||||
| `disabled` | 后台关闭,不累计能量,不发宝箱 IM |
|
||||
| `charging` | 当前等级可累积能量 |
|
||||
| `countdown` | 当前等级已满,`open_at_ms` 已确定,本轮进度、点火人和配置版本锁定 |
|
||||
| `opening` | 到期开箱命令正在 Room Cell 内提交,快照参与用户 |
|
||||
| `settling` | activity-service 正在抽奖和调用 wallet 发奖 |
|
||||
| `settled` | 本轮奖励达到终态,房间已发结算 IM |
|
||||
| `idle` | 当前等级可累积能量 |
|
||||
| `countdown` | 当前等级已满,`open_at_ms` 已确定,本轮进度、点火人、贡献第一和配置版本锁定 |
|
||||
| `opened` | 本轮已开箱,奖励已在命令内完成抽取和资源组发放请求 |
|
||||
| `exhausted` | 当天 7 级都已开启,等下一个 UTC 自然日 |
|
||||
|
||||
`round_id` 建议稳定生成:
|
||||
当前实现没有单独持久化 `round_id` 表。`box_id` 是单轮宝箱幂等和奖励选择的核心 ID,开箱命令 ID 固定为:
|
||||
|
||||
```text
|
||||
treasure:<room_id>:<cycle_day>:<level>:<sequence>
|
||||
cmd_room_treasure_open_<box_id>
|
||||
```
|
||||
|
||||
`cycle_day` 固定为 UTC `YYYY-MM-DD`。同一房间同一 UTC 日同一等级只允许一个 active round。
|
||||
奖励发放命令 ID 固定由 `box_id + role + user_id + reward_item_id` 派生,重复开箱或 MQ 重投不会重复发放同一条奖励。
|
||||
|
||||
## Energy Rules
|
||||
## 能量规则
|
||||
|
||||
| 规则 | 决策 |
|
||||
| --- | --- |
|
||||
| 能量来源 | 默认使用 `DebitGiftResponse.gift_point_added`;后台可配置 gift_id 级别倍率或覆盖值 |
|
||||
| 生效条件 | wallet 扣费成功、room-service 已提交 `RoomGiftSent`、礼物能量值大于 0 |
|
||||
| 能量来源 | 默认使用 `DebitGiftResponse.gift_point_added`;后台可配置 `gift_id` 或 `gift_type_code` 的倍率、覆盖值和排除规则 |
|
||||
| 生效条件 | wallet 扣费成功、Room Cell 提交 `SendGift`、礼物能量值大于 0 |
|
||||
| 幂等 | 同一 `SendGift.command_id` 只能增加一次宝箱能量 |
|
||||
| 点火人 | 第一笔让当前等级 `progress >= threshold` 的送礼用户 |
|
||||
| 贡献第一 | 当前 round 内累计能量最高用户;同分按首次达到该分值时间,再按 user_id 升序 |
|
||||
| 贡献第一 | 当前等级倒计时前累计有效能量最高用户 |
|
||||
| 溢出能量 | 单笔礼物超过当前等级剩余阈值时,只计入补满当前等级所需能量,多余能量作废 |
|
||||
| 倒计时期间送礼 | 当前等级已经满能量,继续送礼不再增加宝箱能量,也不会排队到下一等级 |
|
||||
| 7 级后送礼 | `exhausted` 后当天不再累计宝箱能量,礼物仍正常扣费、热度和榜单照常 |
|
||||
| 倒计时送礼 | 当前等级已经满能量,继续送礼不再增加宝箱能量,也不会排队到下一等级 |
|
||||
| 7 级后送礼 | 当天不再累计宝箱能量,礼物仍正常扣费、热度和榜单照常更新 |
|
||||
|
||||
溢出和倒计时期间的无效能量只用于审计,不计入当前 round 贡献、不计入下一等级进度、不改变点火人或贡献第一。送礼本身仍正常扣费、热度和房间礼物榜照常更新。
|
||||
无效能量只进入 `RoomTreasureProgressChanged` 的审计字段,不计入当前等级贡献、不计入下一等级进度、不改变点火人或贡献第一。
|
||||
|
||||
## Opening Rules
|
||||
## 开箱与 MQ 唤醒
|
||||
|
||||
| 场景 | 处理 |
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as App
|
||||
participant R as room-service
|
||||
participant W as wallet-service
|
||||
participant DB as hyapp_room
|
||||
participant MQ as RocketMQ
|
||||
participant A as activity-service
|
||||
participant IM as Tencent IM
|
||||
|
||||
C->>R: SendGift(command_id)
|
||||
R->>W: DebitGift(command_id)
|
||||
W-->>R: gift_point_added, heat_value
|
||||
R->>R: Room Cell updates treasure
|
||||
R->>DB: command_log + snapshot + room_outbox
|
||||
R-->>C: SendGiftResponse.treasure
|
||||
R->>DB: outbox worker claims RoomTreasureCountdownStarted
|
||||
R->>MQ: delayed RoomTreasureOpenDue(open_at_ms)
|
||||
R->>MQ: room_outbox event
|
||||
MQ-->>A: RoomTreasureCountdownStarted
|
||||
A-->>IM: region/global broadcast
|
||||
MQ-->>R: RoomTreasureOpenDue at open_at_ms
|
||||
R->>R: OpenRoomTreasure command
|
||||
R->>W: GrantResourceGroup per reward
|
||||
R->>DB: command_log + snapshot + opened/reward outbox
|
||||
```
|
||||
|
||||
关键规则:
|
||||
|
||||
- 延迟消息只唤醒,不直接修改状态。
|
||||
- MQ 提前投递时,`room-service` 先检查 `open_at_ms`,未到点返回可重试错误,不写 no-op command log,避免污染开箱 `command_id`。
|
||||
- MQ 重复投递、outbox 重试或扫描 worker 同时触发时,`command_id` 和 wallet grant command 保证幂等。
|
||||
- 如果 `reset_at_ms` 已过,UTC 日界优先,昨天倒计时宝箱不再结算。
|
||||
- 本地 `room_treasure_open_scan_interval` 只扫描本节点已加载且仍持有 lease 的 Cell,是兜底,不解决未加载房间;未加载房间依赖 MQ delayed wakeup。
|
||||
|
||||
## 奖励规则
|
||||
|
||||
| 奖励角色 | 资格 |
|
||||
| --- | --- |
|
||||
| 满能量 | Room Cell 把 round 切到 `countdown`,写 `RoomTreasureChestCountdownStarted` outbox |
|
||||
| 播报 | activity-service 消费 countdown 事件,按配置发 `region/global` 播报;失败只重试播报,不回滚宝箱 |
|
||||
| 到期开箱 | room-service worker 或 cron-service 触发 `ProcessDueTreasureChests`,由 Room Cell 提交开箱命令 |
|
||||
| 开箱快照 | 以开箱命令执行时的 `OnlineUsers` 为准生成在房奖励参与人 |
|
||||
| 房间关闭 | 如果已有 `countdown` round,CloseRoom 必须先提交开箱快照或取消策略;推荐先快照结算,再关闭房间 |
|
||||
| 房间无人 | 在房奖励参与人为空;点火人和贡献第一仍按 round 贡献事实抽奖 |
|
||||
| `in_room` | 开箱命令执行时 Room Cell `OnlineUsers` 内的每个用户 |
|
||||
| `top1` | 倒计时开始时锁定的贡献第一用户 |
|
||||
| `igniter` | 倒计时开始时锁定的点火人 |
|
||||
|
||||
点火人和贡献第一不要求开箱时仍在房或在线。只要用户在本 round 已经形成点火人或贡献第一事实,即使开箱前离房、断线或下线,也必须保留对应角色奖励;在房奖励仍只发给开箱快照里的 `OnlineUsers`。
|
||||
|
||||
## Reward Rules
|
||||
|
||||
| 奖励槽位 | 资格 | 默认是否可与其他槽位叠加 |
|
||||
| --- | --- | --- |
|
||||
| `in_room` | 开箱时 Room Cell `OnlineUsers` 内的每个用户 | 可叠加 |
|
||||
| `top1` | 当前 round 贡献第一用户 | 可叠加 |
|
||||
| `igniter` | 当前 round 点火人 | 可叠加 |
|
||||
|
||||
奖励配置按等级拆分,每个槽位是一个加权奖励池。奖励项推荐引用 `resource_group_id`,由 wallet-service 按资源组快照发放金币、钻石或资源权益。
|
||||
奖励配置按等级拆分,每个角色是一个加权奖励池。奖励项引用 `resource_group_id`,由 `wallet-service` 按资源组发放金币、钻石或资源权益。
|
||||
|
||||
```json
|
||||
{
|
||||
"level": 3,
|
||||
"slot": "top1",
|
||||
"reward_role": "top1",
|
||||
"items": [
|
||||
{
|
||||
"reward_item_id": "lv3_top1_a",
|
||||
@ -145,230 +149,138 @@ treasure:<room_id>:<cycle_day>:<level>:<sequence>
|
||||
}
|
||||
```
|
||||
|
||||
发奖幂等键:
|
||||
`reward_stack_policy`:
|
||||
|
||||
```text
|
||||
treasure_reward:<round_id>:<slot>:<user_id>
|
||||
```
|
||||
| 策略 | 语义 |
|
||||
| --- | --- |
|
||||
| `allow_stack` | 同一用户可同时获得在房、top1、igniter 多个角色奖励 |
|
||||
| `priority_only` | 同一用户只获得优先级最高的一份,优先级为 `igniter -> top1 -> in_room` |
|
||||
|
||||
activity-service 对每个奖励记录先写 `treasure_chest_reward_records`,再调用 wallet-service。wallet 成功后才能把该记录标记为 `granted`。失败进入 `retryable/failed`,后台补偿必须复用同一幂等键。
|
||||
## App 接口
|
||||
|
||||
## App Interfaces
|
||||
|
||||
### Get Treasure Chest
|
||||
### 获取宝箱信息
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms/{room_id}/treasure-chest
|
||||
GET /api/v1/rooms/{room_id}/treasure
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Response `data`:
|
||||
Response `data` 核心字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"enabled": true,
|
||||
"server_time_ms": 1779120000000,
|
||||
"reset_at_ms": 1779148800000,
|
||||
"config_version": 12,
|
||||
"current": {
|
||||
"cycle_day": "2026-05-19",
|
||||
"round_id": "treasure:lalu_room_1:2026-05-19:3:1",
|
||||
"level": 3,
|
||||
"status": "charging",
|
||||
"progress": 3600,
|
||||
"threshold": 10000,
|
||||
"progress_ppm": 360000,
|
||||
"opened_level_count": 2,
|
||||
"broadcast_scope": "region",
|
||||
"open_delay_ms": 30000,
|
||||
"broadcast_delay_ms": 0,
|
||||
"reward_stack_policy": "allow_stack",
|
||||
"state": {
|
||||
"current_level": 3,
|
||||
"current_progress": 3600,
|
||||
"energy_threshold": 10000,
|
||||
"status": "idle",
|
||||
"open_at_ms": 0,
|
||||
"top1_user_id": "10001",
|
||||
"igniter_user_id": "0",
|
||||
"top1_user_id": "10001"
|
||||
"box_id": "box_xxx",
|
||||
"last_rewards": []
|
||||
},
|
||||
"levels": [
|
||||
{
|
||||
"level": 1,
|
||||
"threshold": 3000,
|
||||
"energy_threshold": 3000,
|
||||
"cover_url": "https://cdn.example/chest/lv1/cover.png",
|
||||
"animation_url": "https://cdn.example/chest/lv1/idle.svga",
|
||||
"opening_animation_url": "https://cdn.example/chest/lv1/opening.svga",
|
||||
"opened_image_url": "https://cdn.example/chest/lv1/opened.png",
|
||||
"rewards": {
|
||||
"in_room": [],
|
||||
"top1": [],
|
||||
"igniter": []
|
||||
}
|
||||
"in_room_rewards": [],
|
||||
"top1_rewards": [],
|
||||
"igniter_rewards": []
|
||||
}
|
||||
],
|
||||
"latest_round": {
|
||||
"round_id": "treasure:lalu_room_1:2026-05-19:2:1",
|
||||
"level": 2,
|
||||
"status": "settled",
|
||||
"opened_at_ms": 1779116400000,
|
||||
"settled_at_ms": 1779116401200,
|
||||
"my_rewards": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `levels` 固定返回 7 个等级的物料和奖励展示配置。
|
||||
- `levels` 固定返回 7 个等级。
|
||||
- `reset_at_ms` 是下一个 UTC 零点,不是用户本地零点。
|
||||
- `progress_ppm = floor(progress * 1000000 / threshold)`,客户端不自行除浮点。
|
||||
- 倒计时期间送礼和单笔溢出的无效能量不返回为下一等级进度;客户端只展示服务端返回的当前等级进度。
|
||||
- 客户端只展示服务端返回的当前等级进度;不要自行把溢出能量展示到下一等级。
|
||||
- 当前没有单独的 App 领奖接口,奖励自动发放,最近一次开箱奖励通过 `state.last_rewards` 展示。
|
||||
|
||||
### Get Treasure Rewards
|
||||
### SendGift 响应
|
||||
|
||||
```http
|
||||
GET /api/v1/rooms/{room_id}/treasure-chest/rewards?round_id=treasure:...&scope=mine
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
`SendGiftResponse` 已携带 `treasure` 状态,送礼人成功送礼后可立即更新宝箱 UI,不必等待 IM。
|
||||
|
||||
Response `data`:
|
||||
## 房间 IM 事件
|
||||
|
||||
```json
|
||||
{
|
||||
"round_id": "treasure:lalu_room_1:2026-05-19:3:1",
|
||||
"level": 3,
|
||||
"status": "settled",
|
||||
"reward_status": "granted",
|
||||
"items": [
|
||||
{
|
||||
"reward_record_id": "treasure_reward:treasure:lalu_room_1:2026-05-19:3:1:in_room:10001",
|
||||
"slot": "in_room",
|
||||
"user_id": "10001",
|
||||
"resource_group_id": 12001,
|
||||
"display_name": "Room Reward",
|
||||
"icon_url": "https://cdn.example/reward.png",
|
||||
"grant_status": "granted",
|
||||
"granted_at_ms": 1779120012000
|
||||
}
|
||||
],
|
||||
"next_page_token": ""
|
||||
}
|
||||
```
|
||||
房间内宝箱 IM 走腾讯云房间群 `TIMCustomElem`,由 room outbox 事件转换。当前事件类型:
|
||||
|
||||
`scope=public` 只返回公开摘要,例如点火人、贡献第一和最多 N 条可展示奖励;不要把所有用户的私派奖励大列表塞进 IM payload。
|
||||
|
||||
## IM Contracts
|
||||
|
||||
房间内宝箱 IM 仍走腾讯云房间群 `TIMCustomElem`,公共字段保持稳定:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "room_treasure:...",
|
||||
"event_type": "room_treasure_progress_changed",
|
||||
"room_id": "lalu_room_1",
|
||||
"room_version": 102,
|
||||
"round_id": "treasure:lalu_room_1:2026-05-19:3:1",
|
||||
"level": 3,
|
||||
"cycle_day": "2026-05-19",
|
||||
"server_time_ms": 1779120000000
|
||||
}
|
||||
```
|
||||
| Event | 客户端 `event_type` | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `RoomTreasureProgressChanged` | `room_treasure_progress_changed` | 宝箱进度增加 |
|
||||
| `RoomTreasureCountdownStarted` | `room_treasure_countdown_started` | 满能量进入倒计时 |
|
||||
| `RoomTreasureOpened` | `room_treasure_opened` | 宝箱打开并给出公开奖励摘要 |
|
||||
| `RoomTreasureRewardGranted` | `room_treasure_reward_granted` | 发奖结果事件,包含奖励列表 |
|
||||
|
||||
### Progress Changed
|
||||
|
||||
`event_type=room_treasure_progress_changed`
|
||||
|
||||
```json
|
||||
{
|
||||
"sender_user_id": "10001",
|
||||
"event_type": "room_treasure_progress_changed",
|
||||
"box_id": "box_xxx",
|
||||
"level": 3,
|
||||
"added_energy": 500,
|
||||
"effective_added_energy": 500,
|
||||
"overflow_energy": 0,
|
||||
"current_progress": 3600,
|
||||
"energy_threshold": 10000,
|
||||
"status": "idle",
|
||||
"reset_at_ms": 1779148800000,
|
||||
"gift_id": "rose",
|
||||
"gift_count": 10,
|
||||
"energy_added": 500,
|
||||
"invalid_energy": 0,
|
||||
"progress_before": 3100,
|
||||
"progress_after": 3600,
|
||||
"threshold": 10000,
|
||||
"status": "charging",
|
||||
"top1_user_id": "10001",
|
||||
"reset_at_ms": 1779148800000
|
||||
"gift_count": 10
|
||||
}
|
||||
```
|
||||
|
||||
### Countdown Started
|
||||
|
||||
`event_type=room_treasure_countdown_started`
|
||||
|
||||
```json
|
||||
{
|
||||
"progress_after": 10000,
|
||||
"threshold": 10000,
|
||||
"igniter_user_id": "10002",
|
||||
"top1_user_id": "10001",
|
||||
"full_at_ms": 1779120000000,
|
||||
"open_at_ms": 1779120030000,
|
||||
"broadcast_scope": "region"
|
||||
}
|
||||
```
|
||||
|
||||
该事件进入 room outbox 后,activity-service 按配置生成全局或区域播报:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "treasure_broadcast:treasure:lalu_room_1:2026-05-19:3:1",
|
||||
"broadcast_type": "room_treasure_ready",
|
||||
"scope": "region",
|
||||
"region_id": 1001,
|
||||
"room_id": "lalu_room_1",
|
||||
"round_id": "treasure:lalu_room_1:2026-05-19:3:1",
|
||||
"event_type": "room_treasure_countdown_started",
|
||||
"box_id": "box_xxx",
|
||||
"level": 3,
|
||||
"current_progress": 10000,
|
||||
"energy_threshold": 10000,
|
||||
"open_at_ms": 1779120030000,
|
||||
"action": {
|
||||
"type": "enter_room",
|
||||
"room_id": "lalu_room_1"
|
||||
}
|
||||
"broadcast_scope": "region",
|
||||
"visible_region_id": 1001,
|
||||
"top1_user_id": "10001",
|
||||
"igniter_user_id": "10002"
|
||||
}
|
||||
```
|
||||
|
||||
### Chest Opened
|
||||
|
||||
`event_type=room_treasure_opened`
|
||||
### Opened / Reward Granted
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "room_treasure_opened",
|
||||
"box_id": "box_xxx",
|
||||
"level": 3,
|
||||
"next_level": 4,
|
||||
"opened_at_ms": 1779120030000,
|
||||
"participant_count": 86,
|
||||
"igniter_user_id": "10002",
|
||||
"top1_user_id": "10001",
|
||||
"settlement_status": "pending"
|
||||
"igniter_user_id": "10002",
|
||||
"rewards_json": "[...]"
|
||||
}
|
||||
```
|
||||
|
||||
### Rewards Settled
|
||||
群 IM 承载公开摘要。后续如要做个人中奖私信或消息 Tab,应由 `notice-service` 或 inbox consumer 消费 `RoomTreasureRewardGranted`,不要让 `room-service` 同步投递私信。
|
||||
|
||||
`event_type=room_treasure_rewards_settled`
|
||||
## 后台配置
|
||||
|
||||
```json
|
||||
{
|
||||
"settled_at_ms": 1779120031200,
|
||||
"reward_status": "granted",
|
||||
"participant_reward_count": 86,
|
||||
"top1_user_id": "10001",
|
||||
"igniter_user_id": "10002",
|
||||
"public_rewards": [
|
||||
{
|
||||
"slot": "top1",
|
||||
"user_id": "10001",
|
||||
"display_name": "Top1 Reward",
|
||||
"icon_url": "https://cdn.example/reward.png"
|
||||
},
|
||||
{
|
||||
"slot": "igniter",
|
||||
"user_id": "10002",
|
||||
"display_name": "Igniter Reward",
|
||||
"icon_url": "https://cdn.example/reward.png"
|
||||
}
|
||||
],
|
||||
"full_result_required": true
|
||||
}
|
||||
```
|
||||
|
||||
群 IM 只承载公开摘要。用户自己的完整发奖结果通过 `GET /treasure-chest/rewards?scope=mine` 查询;后续接 notice-service 时,可以再补私有单聊或 inbox 通知。
|
||||
|
||||
## Admin Config
|
||||
|
||||
后台配置必须带 `config_version`,保存后只影响新 round;已经进入 `countdown/opening/settling` 的 round 使用锁定的配置快照。
|
||||
后台配置带 `config_version`。倒计时开始后,当前宝箱的 `box_id`、等级、进度、`open_at_ms`、点火人和贡献第一不再因配置修改而变化;当前实现开箱时读取最新启用配置的奖励池,因此运营在倒计时窗口内修改奖励配置要按发布窗口处理。
|
||||
|
||||
| 字段 | 含义 |
|
||||
| --- | --- |
|
||||
@ -377,214 +289,90 @@ Response `data`:
|
||||
| `open_delay_ms` | 满能量到开箱的倒计时 |
|
||||
| `broadcast_enabled` | 是否满能量后播报 |
|
||||
| `broadcast_scope` | `none/region/global` |
|
||||
| `broadcast_delay_ms` | 满能量后多久发播报,默认 0 |
|
||||
| `broadcast_delay_ms` | 满能量后多久发播报,当前代码写入配置和读模型,实际倒计时事件立即进入 room outbox |
|
||||
| `reward_stack_policy` | `allow_stack` 或 `priority_only` |
|
||||
| `levels` | 7 个等级配置,包含阈值、物料 URL 和奖励池 |
|
||||
| `levels` | 7 个等级配置,包含阈值、物料 URL 和三类奖励池 |
|
||||
| `gift_energy_rules` | gift_id 或 gift_type_code 的能量倍率、覆盖值和排除规则 |
|
||||
| `effective_from_ms` | 配置开始生效时间,UTC epoch ms |
|
||||
| `updated_by_admin_id` | 审计字段 |
|
||||
|
||||
每个等级必须配置:
|
||||
|
||||
| 字段 | 规则 |
|
||||
| --- | --- |
|
||||
| `level` | 1 到 7,不能缺级 |
|
||||
| `threshold` | 大于 0,建议单调递增 |
|
||||
| `cover_url` | 静态封面 |
|
||||
| `animation_url` | 未开启待机动效 |
|
||||
| `opening_animation_url` | 开启倒计时或开箱动效 |
|
||||
| `opened_image_url` | 开启后静态图 |
|
||||
| `in_room_rewards` | 在房用户奖励池 |
|
||||
| `top1_rewards` | 贡献第一奖励池 |
|
||||
| `igniter_rewards` | 点火人奖励池 |
|
||||
|
||||
奖励池保存时应校验:
|
||||
|
||||
- `resource_group_id` 必须存在且 active。
|
||||
- 同一槽位权重总和必须大于 0。
|
||||
- 同一奖励池权重总和必须大于 0。
|
||||
- 展示字段可以保存快照,但发放仍以 wallet-service 资源组事实为准。
|
||||
- 配置不能直接填写金币数量绕过资源组,除非新增明确的钱包奖励配置类型。
|
||||
|
||||
## Internal Events
|
||||
## room_outbox 与 RocketMQ
|
||||
|
||||
建议在 `api/proto/events/room/v1/events.proto` 增加:
|
||||
`room_outbox` 是 MySQL 内的可靠事实源,RocketMQ 是发布后的事件总线。不能用 MQ 替代 outbox,因为房间状态提交成功但 MQ 发布失败时必须能从 MySQL 补偿。
|
||||
|
||||
| Event | 生产方 | 消费方 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `RoomTreasureChestProgressChanged` | room-service | Tencent IM/activity audit | 房间内进度 IM、审计 |
|
||||
| `RoomTreasureChestCountdownStarted` | room-service | Tencent IM/activity broadcast | 房间倒计时 IM、区域/全局播报 |
|
||||
| `RoomTreasureChestOpened` | room-service | activity-service | 奖励抽奖和发放输入,包含参与人快照和奖励配置快照 |
|
||||
| `RoomTreasureChestSettlementApplied` | room-service | Tencent IM/audit | 发奖完成后的房间 IM |
|
||||
当前 topic:
|
||||
|
||||
`RoomTreasureChestOpened` 必须包含:
|
||||
| Topic | Producer | Consumer |
|
||||
| --- | --- | --- |
|
||||
| `hyapp_room_outbox` | `room-service` outbox worker | `activity-service`、`notice-service`、`room-service` IM bridge、后续 push/inbox |
|
||||
| `hyapp_room_treasure_open` | `room-service` treasure open scheduler | `room-service` treasure open consumer |
|
||||
|
||||
```text
|
||||
round_id
|
||||
cycle_day
|
||||
level
|
||||
config_version
|
||||
room_id
|
||||
visible_region_id
|
||||
opened_at_ms
|
||||
participant_user_ids
|
||||
igniter_user_id
|
||||
top1_user_id
|
||||
contribution_top
|
||||
reward_config_snapshot_json
|
||||
推荐线上配置:
|
||||
|
||||
```yaml
|
||||
outbox_worker:
|
||||
enabled: true
|
||||
publish_mode: "mq"
|
||||
|
||||
rocketmq:
|
||||
enabled: true
|
||||
room_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_room_outbox"
|
||||
treasure_open:
|
||||
enabled: true
|
||||
topic: "hyapp_room_treasure_open"
|
||||
```
|
||||
|
||||
activity-service 不反查 Room Cell 判断参与人,避免开奖时与房间状态发生竞态。
|
||||
`publish_mode`:
|
||||
|
||||
## Data Model Sketch
|
||||
|
||||
room-service 持久化:
|
||||
|
||||
```sql
|
||||
room_treasure_rounds(
|
||||
app_code,
|
||||
room_id,
|
||||
cycle_day,
|
||||
round_id,
|
||||
level,
|
||||
status,
|
||||
progress,
|
||||
threshold,
|
||||
invalid_energy,
|
||||
config_version,
|
||||
igniter_user_id,
|
||||
top1_user_id,
|
||||
participant_count,
|
||||
full_at_ms,
|
||||
open_at_ms,
|
||||
opened_at_ms,
|
||||
settled_at_ms,
|
||||
reset_at_ms,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
);
|
||||
|
||||
room_treasure_contributions(
|
||||
app_code,
|
||||
round_id,
|
||||
user_id,
|
||||
energy,
|
||||
first_contributed_at_ms,
|
||||
last_contributed_at_ms
|
||||
);
|
||||
```
|
||||
|
||||
activity-service 持久化:
|
||||
|
||||
```sql
|
||||
treasure_chest_settlements(
|
||||
app_code,
|
||||
settlement_id,
|
||||
round_id,
|
||||
room_id,
|
||||
level,
|
||||
status,
|
||||
reward_config_snapshot_json,
|
||||
participant_count,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
);
|
||||
|
||||
treasure_chest_reward_records(
|
||||
app_code,
|
||||
reward_record_id,
|
||||
settlement_id,
|
||||
round_id,
|
||||
slot,
|
||||
user_id,
|
||||
reward_item_id,
|
||||
resource_group_id,
|
||||
grant_command_id,
|
||||
grant_status,
|
||||
grant_id,
|
||||
retry_count,
|
||||
last_error,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
);
|
||||
```
|
||||
|
||||
Room Cell snapshot 也要包含当前宝箱轻量状态,保证节点恢复后不依赖 Redis 重建进度。表结构用于查询、due scan 和后台审计;command log 仍是写命令恢复的关键事实。
|
||||
| 模式 | 行为 |
|
||||
| --- | --- |
|
||||
| `direct` | room outbox worker 直接投 activity gRPC 和 Tencent IM,适合本地默认 |
|
||||
| `mq` | room outbox worker 只投 RocketMQ,activity/notice/IM bridge 各自消费,推荐线上 |
|
||||
| `dual` | direct + MQ 双写,只用于迁移验证,所有消费者必须幂等 |
|
||||
|
||||
## Daily Reset
|
||||
|
||||
| 规则 | 决策 |
|
||||
| --- | --- |
|
||||
| 日期来源 | `time.Now().UTC()`,`cycle_day=YYYY-MM-DD` |
|
||||
| 日期来源 | `time.Now().UTC()` |
|
||||
| 重置时间 | 每天 UTC `00:00:00.000` |
|
||||
| 时间区间 | `[day_start_ms, next_day_start_ms)` |
|
||||
| charging round | 到达新 UTC 日时清空进度、贡献和 queued energy,创建新 day level 1 round |
|
||||
| countdown/opening/settling round | 已满能量的 round 继续开箱和发奖,使用原 `cycle_day` |
|
||||
| idle | 到达新 UTC 日时回到 level 1,清空进度和贡献 |
|
||||
| countdown | 如果 `open_at_ms` 跨过 `reset_at_ms`,UTC 日界优先,旧宝箱不再结算 |
|
||||
| exhausted | 下一个 UTC 日恢复 level 1 |
|
||||
|
||||
必须补 UTC 边界测试:`day_start_ms`、`next_day_start_ms - 1`、`next_day_start_ms`。不要重新引入 `time.Local`、`task_timezone` 或客户端时区。
|
||||
必须覆盖 UTC 边界测试:`day_start_ms`、`next_day_start_ms - 1`、`next_day_start_ms`。不要重新引入 `time.Local`、`task_timezone` 或客户端时区。
|
||||
|
||||
## Implementation Flow
|
||||
## 验收清单
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as gateway-service
|
||||
participant R as room-service
|
||||
participant W as wallet-service
|
||||
participant A as activity-service
|
||||
participant IM as Tencent IM
|
||||
|
||||
C->>G: POST /api/v1/rooms/gift/send
|
||||
G->>R: SendGift(command_id)
|
||||
R->>W: DebitGift(command_id)
|
||||
W-->>R: gift_point_added, receipt
|
||||
R->>R: Room Cell updates heat/rank/treasure
|
||||
R-->>C: SendGiftResponse + treasure summary
|
||||
R-->>IM: room_treasure_progress_changed
|
||||
alt energy full
|
||||
R-->>IM: room_treasure_countdown_started
|
||||
R-->>A: room outbox RoomTreasureChestCountdownStarted
|
||||
A-->>IM: region/global room_treasure_ready broadcast
|
||||
end
|
||||
R->>R: ProcessDueTreasureChests at open_at_ms
|
||||
R-->>IM: room_treasure_opened
|
||||
R-->>A: room outbox RoomTreasureChestOpened
|
||||
A->>A: draw rewards idempotently
|
||||
A->>W: GrantResourceGroup per reward
|
||||
W-->>A: grant result
|
||||
A->>R: ApplyTreasureChestSettlement(summary)
|
||||
R-->>IM: room_treasure_rewards_settled
|
||||
```
|
||||
|
||||
`ApplyTreasureChestSettlement` 是 gRPC/protobuf 边界,不允许 activity-service import room-service `internal` 包。它只提交结算摘要和可展示公共结果,不让 activity-service 改写宝箱进度。
|
||||
|
||||
## Failure Handling
|
||||
|
||||
| 失败 | 处理 |
|
||||
| 场景 | 验收 |
|
||||
| --- | --- |
|
||||
| wallet DebitGift 失败 | 不增加能量,不写宝箱进度,不发宝箱 IM |
|
||||
| room outbox 投递 IM 失败 | 房间状态已提交,outbox 重试或死信;App 可用 GET 接口补状态 |
|
||||
| broadcast 失败 | activity broadcast outbox 重试,不影响开箱 |
|
||||
| due open worker 宕机 | MySQL `open_at_ms/status` 可被下一轮 worker/cron 重新扫描 |
|
||||
| activity 抽奖失败 | settlement 保持 `retryable`,不发“已发奖” IM |
|
||||
| wallet 发奖部分失败 | 成功记录保持 granted,失败记录重试;房间 IM 标记 `partial` 或等待全部终态后再发 |
|
||||
| App 错过 IM | `GET /treasure-chest` 和 `GET /treasure-chest/rewards` 补齐 |
|
||||
| 送礼成功 | wallet 扣费成功,Room Cell 进度增加,`SendGiftResponse.treasure` 返回最新状态 |
|
||||
| 单笔溢出 | 进度封顶当前阈值,溢出能量作废,不进入下一等级 |
|
||||
| 倒计时送礼 | 礼物正常扣费和加热度,但宝箱进度、点火人、贡献第一不变化 |
|
||||
| 满能量 | 写 `RoomTreasureCountdownStarted`,outbox worker 安排 RocketMQ delayed open |
|
||||
| 提前延迟消息 | 不写开箱 no-op command log,MQ 后续可重试 |
|
||||
| 到点开箱 | 即使房间未加载,MQ 唤醒后 room-service 恢复 Room Cell 并开箱 |
|
||||
| top1/igniter 离房 | 仍发角色奖励 |
|
||||
| 在房奖励 | 只发开箱瞬间在线用户 |
|
||||
| UTC 重置 | 到达 UTC 零点后进度回到 level 1,跨日倒计时不结算 |
|
||||
| MQ/outbox 重投 | `event_id`、`box_id`、wallet grant command 保证幂等 |
|
||||
|
||||
## Tests
|
||||
## 代码位置
|
||||
|
||||
必须覆盖:
|
||||
|
||||
| 场景 | 验证 |
|
||||
| 模块 | 路径 |
|
||||
| --- | --- |
|
||||
| 送礼幂等 | 同一 `command_id` 重试不重复加能量 |
|
||||
| 阈值边界 | `progress = threshold - 1` 不点火,`progress = threshold` 点火一次 |
|
||||
| 溢出能量 | 单笔礼物超过阈值时只补满当前等级,剩余能量作废且不进入下一等级 |
|
||||
| 倒计时送礼 | countdown/opening/settling 期间送礼不增加宝箱能量,不改变点火人和 top1 |
|
||||
| 倒计时开箱 | `open_at_ms` 前不开,等于或超过时只开一次 |
|
||||
| 在线快照 | 开箱时在线用户获得 in_room 抽奖资格,离房用户不获得 in_room 奖励 |
|
||||
| top1/igniter | 同分 tie-break 固定;离房、断线或下线后仍结算角色奖励 |
|
||||
| UTC 重置 | 覆盖 UTC 日开始、结束前一毫秒、结束边界 |
|
||||
| room close | countdown 后关房不会丢掉已满宝箱奖励 |
|
||||
| 发奖幂等 | reward_record 重试不重复发资源组 |
|
||||
| 播报范围 | `none/region/global` 分别不发、发区域、发全局 |
|
||||
| IM 补偿 | outbox 重试不会产生不同 event_id 的重复客户端消息 |
|
||||
| 宝箱状态机和结算 | `services/room-service/internal/room/service/room_treasure.go` |
|
||||
| MQ delayed open | `services/room-service/internal/room/service/room_treasure_mq.go` |
|
||||
| outbox worker | `services/room-service/internal/room/service/outbox_worker.go` |
|
||||
| RocketMQ 适配 | `pkg/rocketmqx`、`pkg/roommq`、`services/room-service/internal/integration/rocketmq_outbox.go` |
|
||||
| App HTTP | `services/gateway-service/internal/transport/http/roomapi` |
|
||||
| 后台配置 | `server/admin/internal/modules/roomtreasure` |
|
||||
|
||||
17
go.mod
17
go.mod
@ -3,6 +3,7 @@ module hyapp
|
||||
go 1.26.3
|
||||
|
||||
require (
|
||||
github.com/apache/rocketmq-client-go/v2 v2.1.2
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
@ -22,13 +23,29 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/emirpasic/gods v1.12.0 // indirect
|
||||
github.com/golang/mock v1.3.1 // indirect
|
||||
github.com/google/go-querystring v1.0.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect
|
||||
github.com/mitchellh/mapstructure v1.4.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mozillazg/go-httpheader v0.2.1 // indirect
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
|
||||
github.com/pkg/errors v0.8.1 // indirect
|
||||
github.com/sirupsen/logrus v1.4.0 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
github.com/tidwall/gjson v1.13.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/net v0.29.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/term v0.28.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect
|
||||
stathat.com/c/consistent v1.0.0 // indirect
|
||||
)
|
||||
|
||||
156
go.sum
156
go.sum
@ -1,12 +1,20 @@
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I=
|
||||
github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM=
|
||||
github.com/apache/rocketmq-client-go/v2 v2.1.2 h1:yt73olKe5N6894Dbm+ojRf/JPiP0cxfDNNffKwhpJVg=
|
||||
github.com/apache/rocketmq-client-go/v2 v2.1.2/go.mod h1:6I6vgxHR3hzrvn+6n/4mrhS+UTulzK/X9LB2Vk1U5gE=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@ -14,54 +22,200 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg=
|
||||
github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s=
|
||||
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
|
||||
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
|
||||
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/sirupsen/logrus v1.4.0 h1:yKenngtzGh+cUSSh6GWbxW2abRqhYUSR/t/6+2QqNvE=
|
||||
github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.66 h1:O4O6EsozBoDjxWbltr3iULgkI7WPj/BFNlYTXDuE64E=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.66/go.mod h1:8+hG+mQMuRP/OIS9d83syAvXvrMj9HhkND6Q1fLghw0=
|
||||
github.com/tidwall/gjson v1.13.0 h1:3TFY9yxOQShrvmjdM76K+jc66zJeT6D3/VFFYCGQf7M=
|
||||
github.com/tidwall/gjson v1.13.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
|
||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
|
||||
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
stathat.com/c/consistent v1.0.0 h1:ezyc51EGcRPJUxfHGSgJjWzJdj3NiMU9pNfLNGiXV0c=
|
||||
stathat.com/c/consistent v1.0.0/go.mod h1:QkzMWzcbB+yQBL2AttO6sgsQS/JSTapcDISJalmCDS0=
|
||||
|
||||
315
pkg/rocketmqx/rocketmqx.go
Normal file
315
pkg/rocketmqx/rocketmqx.go
Normal file
@ -0,0 +1,315 @@
|
||||
// Package rocketmqx hides the Apache RocketMQ client behind the small surface
|
||||
// this codebase needs: synchronous publish, delayed publish by millisecond
|
||||
// timestamp, and clustering push consume.
|
||||
package rocketmqx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
rocketmq "github.com/apache/rocketmq-client-go/v2"
|
||||
"github.com/apache/rocketmq-client-go/v2/consumer"
|
||||
"github.com/apache/rocketmq-client-go/v2/primitive"
|
||||
"github.com/apache/rocketmq-client-go/v2/producer"
|
||||
)
|
||||
|
||||
const (
|
||||
// Tencent Cloud RocketMQ accepts this property as an absolute Unix epoch
|
||||
// millisecond delivery timestamp. Open-source RocketMQ delay levels remain
|
||||
// available through the native client, but the treasure open timer needs an
|
||||
// exact backend-configured open_at_ms.
|
||||
PropertyStartDeliverTime = "__STARTDELIVERTIME"
|
||||
)
|
||||
|
||||
// EndpointConfig is the shared RocketMQ connection identity.
|
||||
type EndpointConfig struct {
|
||||
NameServers []string
|
||||
NameServerDomain string
|
||||
AccessKey string
|
||||
SecretKey string
|
||||
SecurityToken string
|
||||
Namespace string
|
||||
VIPChannel bool
|
||||
}
|
||||
|
||||
// ProducerConfig configures one producer group.
|
||||
type ProducerConfig struct {
|
||||
EndpointConfig
|
||||
GroupName string
|
||||
SendTimeout time.Duration
|
||||
Retry int
|
||||
}
|
||||
|
||||
// ConsumerConfig configures one clustering push consumer group.
|
||||
type ConsumerConfig struct {
|
||||
EndpointConfig
|
||||
GroupName string
|
||||
MaxReconsumeTimes int32
|
||||
ConsumeRetryDelay time.Duration
|
||||
ConsumePullBatch int32
|
||||
ConsumePullTimeout time.Duration
|
||||
}
|
||||
|
||||
// Message is the stable publish shape used by services.
|
||||
type Message struct {
|
||||
Topic string
|
||||
Tag string
|
||||
Keys []string
|
||||
Body []byte
|
||||
Properties map[string]string
|
||||
DeliveryAtMS int64
|
||||
}
|
||||
|
||||
// ConsumedMessage is the stable consume shape used by service handlers.
|
||||
type ConsumedMessage struct {
|
||||
Topic string
|
||||
Tag string
|
||||
Keys string
|
||||
MsgID string
|
||||
Body []byte
|
||||
Properties map[string]string
|
||||
ReconsumeTimes int32
|
||||
}
|
||||
|
||||
// Handler returns nil after the message has been durably processed. A non-nil
|
||||
// error asks RocketMQ to retry the message according to the consumer group
|
||||
// retry policy.
|
||||
type Handler func(context.Context, ConsumedMessage) error
|
||||
|
||||
// Producer wraps a started or startable RocketMQ producer.
|
||||
type Producer struct {
|
||||
client rocketmq.Producer
|
||||
}
|
||||
|
||||
// NewProducer creates a RocketMQ producer. Call Start before SendSync.
|
||||
func NewProducer(cfg ProducerConfig) (*Producer, error) {
|
||||
if err := validateEndpoint(cfg.EndpointConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group := strings.TrimSpace(cfg.GroupName)
|
||||
if group == "" {
|
||||
return nil, errors.New("rocketmq producer group_name is required")
|
||||
}
|
||||
options := []producer.Option{producer.WithGroupName(group)}
|
||||
options = append(options, producerEndpointOptions(cfg.EndpointConfig)...)
|
||||
if cfg.SendTimeout > 0 {
|
||||
options = append(options, producer.WithSendMsgTimeout(cfg.SendTimeout))
|
||||
}
|
||||
if cfg.Retry > 0 {
|
||||
options = append(options, producer.WithRetry(cfg.Retry))
|
||||
}
|
||||
client, err := rocketmq.NewProducer(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Producer{client: client}, nil
|
||||
}
|
||||
|
||||
// Start registers the producer group and opens broker connections.
|
||||
func (p *Producer) Start() error {
|
||||
if p == nil || p.client == nil {
|
||||
return errors.New("rocketmq producer is not configured")
|
||||
}
|
||||
return p.client.Start()
|
||||
}
|
||||
|
||||
// Shutdown releases the underlying RocketMQ producer.
|
||||
func (p *Producer) Shutdown() error {
|
||||
if p == nil || p.client == nil {
|
||||
return nil
|
||||
}
|
||||
return p.client.Shutdown()
|
||||
}
|
||||
|
||||
// SendSync publishes one message and only returns nil when the broker accepts it.
|
||||
func (p *Producer) SendSync(ctx context.Context, message Message) error {
|
||||
if p == nil || p.client == nil {
|
||||
return errors.New("rocketmq producer is not configured")
|
||||
}
|
||||
topic := strings.TrimSpace(message.Topic)
|
||||
if topic == "" {
|
||||
return errors.New("rocketmq topic is required")
|
||||
}
|
||||
msg := primitive.NewMessage(topic, message.Body)
|
||||
if tag := strings.TrimSpace(message.Tag); tag != "" {
|
||||
msg.WithTag(tag)
|
||||
}
|
||||
keys := make([]string, 0, len(message.Keys))
|
||||
for _, key := range message.Keys {
|
||||
if key = strings.TrimSpace(key); key != "" {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
msg.WithKeys(keys)
|
||||
}
|
||||
for key, value := range message.Properties {
|
||||
msg.WithProperty(strings.TrimSpace(key), strings.TrimSpace(value))
|
||||
}
|
||||
if message.DeliveryAtMS > 0 {
|
||||
msg.WithProperty(PropertyStartDeliverTime, fmt.Sprintf("%d", message.DeliveryAtMS))
|
||||
}
|
||||
result, err := p.client.SendSync(ctx, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result == nil || result.Status != primitive.SendOK {
|
||||
if result == nil {
|
||||
return errors.New("rocketmq send failed: empty result")
|
||||
}
|
||||
return fmt.Errorf("rocketmq send failed: status=%d msg_id=%s", result.Status, result.MsgID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consumer wraps a clustering push consumer.
|
||||
type Consumer struct {
|
||||
client rocketmq.PushConsumer
|
||||
}
|
||||
|
||||
// NewConsumer creates a RocketMQ push consumer. Subscribe before Start.
|
||||
func NewConsumer(cfg ConsumerConfig) (*Consumer, error) {
|
||||
if err := validateEndpoint(cfg.EndpointConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group := strings.TrimSpace(cfg.GroupName)
|
||||
if group == "" {
|
||||
return nil, errors.New("rocketmq consumer group_name is required")
|
||||
}
|
||||
options := []consumer.Option{consumer.WithGroupName(group)}
|
||||
options = append(options, consumerEndpointOptions(cfg.EndpointConfig)...)
|
||||
if cfg.MaxReconsumeTimes > 0 {
|
||||
options = append(options, consumer.WithMaxReconsumeTimes(cfg.MaxReconsumeTimes))
|
||||
}
|
||||
if cfg.ConsumeRetryDelay > 0 {
|
||||
options = append(options, consumer.WithSuspendCurrentQueueTimeMillis(cfg.ConsumeRetryDelay))
|
||||
}
|
||||
if cfg.ConsumePullBatch > 0 {
|
||||
options = append(options, consumer.WithPullBatchSize(cfg.ConsumePullBatch))
|
||||
}
|
||||
if cfg.ConsumePullTimeout > 0 {
|
||||
options = append(options, consumer.WithConsumeTimeout(cfg.ConsumePullTimeout))
|
||||
}
|
||||
client, err := rocketmq.NewPushConsumer(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Consumer{client: client}, nil
|
||||
}
|
||||
|
||||
// Subscribe binds a topic/tag selector to a handler. Empty tag subscribes all.
|
||||
func (c *Consumer) Subscribe(topic string, tag string, handler Handler) error {
|
||||
if c == nil || c.client == nil {
|
||||
return errors.New("rocketmq consumer is not configured")
|
||||
}
|
||||
topic = strings.TrimSpace(topic)
|
||||
if topic == "" {
|
||||
return errors.New("rocketmq topic is required")
|
||||
}
|
||||
if handler == nil {
|
||||
return errors.New("rocketmq handler is required")
|
||||
}
|
||||
selector := consumer.MessageSelector{}
|
||||
if tag = strings.TrimSpace(tag); tag != "" {
|
||||
selector = consumer.MessageSelector{Type: consumer.TAG, Expression: tag}
|
||||
}
|
||||
return c.client.Subscribe(topic, selector, func(ctx context.Context, messages ...*primitive.MessageExt) (consumer.ConsumeResult, error) {
|
||||
for _, message := range messages {
|
||||
if message == nil {
|
||||
continue
|
||||
}
|
||||
err := handler(ctx, ConsumedMessage{
|
||||
Topic: message.Topic,
|
||||
Tag: message.GetTags(),
|
||||
Keys: message.GetKeys(),
|
||||
MsgID: message.MsgId,
|
||||
Body: message.Body,
|
||||
Properties: message.GetProperties(),
|
||||
ReconsumeTimes: message.ReconsumeTimes,
|
||||
})
|
||||
if err != nil {
|
||||
return consumer.ConsumeRetryLater, err
|
||||
}
|
||||
}
|
||||
return consumer.ConsumeSuccess, nil
|
||||
})
|
||||
}
|
||||
|
||||
// Start begins pulling messages.
|
||||
func (c *Consumer) Start() error {
|
||||
if c == nil || c.client == nil {
|
||||
return errors.New("rocketmq consumer is not configured")
|
||||
}
|
||||
return c.client.Start()
|
||||
}
|
||||
|
||||
// Shutdown releases the underlying consumer.
|
||||
func (c *Consumer) Shutdown() error {
|
||||
if c == nil || c.client == nil {
|
||||
return nil
|
||||
}
|
||||
return c.client.Shutdown()
|
||||
}
|
||||
|
||||
func validateEndpoint(cfg EndpointConfig) error {
|
||||
nameservers := normalizeStringSlice(cfg.NameServers)
|
||||
if len(nameservers) == 0 && strings.TrimSpace(cfg.NameServerDomain) == "" {
|
||||
return errors.New("rocketmq name_servers or name_server_domain is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func producerEndpointOptions(cfg EndpointConfig) []producer.Option {
|
||||
options := make([]producer.Option, 0, 5)
|
||||
if nameservers := normalizeStringSlice(cfg.NameServers); len(nameservers) > 0 {
|
||||
options = append(options, producer.WithNsResolver(primitive.NewPassthroughResolver(nameservers)))
|
||||
} else if domain := strings.TrimSpace(cfg.NameServerDomain); domain != "" {
|
||||
options = append(options, producer.WithNameServerDomain(domain))
|
||||
}
|
||||
if namespace := strings.TrimSpace(cfg.Namespace); namespace != "" {
|
||||
options = append(options, producer.WithNamespace(namespace))
|
||||
}
|
||||
options = append(options, producer.WithVIPChannel(cfg.VIPChannel))
|
||||
if strings.TrimSpace(cfg.AccessKey) != "" || strings.TrimSpace(cfg.SecretKey) != "" {
|
||||
options = append(options, producer.WithCredentials(credentials(cfg)))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func consumerEndpointOptions(cfg EndpointConfig) []consumer.Option {
|
||||
options := make([]consumer.Option, 0, 5)
|
||||
if nameservers := normalizeStringSlice(cfg.NameServers); len(nameservers) > 0 {
|
||||
options = append(options, consumer.WithNsResolver(primitive.NewPassthroughResolver(nameservers)))
|
||||
} else if domain := strings.TrimSpace(cfg.NameServerDomain); domain != "" {
|
||||
options = append(options, consumer.WithNameServerDomain(domain))
|
||||
}
|
||||
if namespace := strings.TrimSpace(cfg.Namespace); namespace != "" {
|
||||
options = append(options, consumer.WithNamespace(namespace))
|
||||
}
|
||||
options = append(options, consumer.WithVIPChannel(cfg.VIPChannel))
|
||||
if strings.TrimSpace(cfg.AccessKey) != "" || strings.TrimSpace(cfg.SecretKey) != "" {
|
||||
options = append(options, consumer.WithCredentials(credentials(cfg)))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func credentials(cfg EndpointConfig) primitive.Credentials {
|
||||
return primitive.Credentials{
|
||||
AccessKey: strings.TrimSpace(cfg.AccessKey),
|
||||
SecretKey: strings.TrimSpace(cfg.SecretKey),
|
||||
SecurityToken: strings.TrimSpace(cfg.SecurityToken),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
106
pkg/roommq/messages.go
Normal file
106
pkg/roommq/messages.go
Normal file
@ -0,0 +1,106 @@
|
||||
// Package roommq defines RocketMQ payloads owned by room-service.
|
||||
package roommq
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
MessageTypeRoomOutboxEvent = "room_outbox_event"
|
||||
MessageTypeRoomTreasureOpenDue = "room_treasure_open_due"
|
||||
|
||||
TagRoomOutboxEvent = "room_outbox_event"
|
||||
TagRoomTreasureOpenDue = "room_treasure_open_due"
|
||||
)
|
||||
|
||||
// RoomOutboxMessage is the MQ representation of one durable room_outbox fact.
|
||||
type RoomOutboxMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
AppCode string `json:"app_code"`
|
||||
EventID string `json:"event_id"`
|
||||
EventType string `json:"event_type"`
|
||||
RoomID string `json:"room_id"`
|
||||
RoomVersion int64 `json:"room_version"`
|
||||
OccurredAtMS int64 `json:"occurred_at_ms"`
|
||||
Envelope []byte `json:"envelope"`
|
||||
}
|
||||
|
||||
// RoomTreasureOpenDueMessage wakes room-service at the configured open_at_ms.
|
||||
type RoomTreasureOpenDueMessage struct {
|
||||
MessageType string `json:"message_type"`
|
||||
AppCode string `json:"app_code"`
|
||||
RoomID string `json:"room_id"`
|
||||
BoxID string `json:"box_id"`
|
||||
Level int32 `json:"level"`
|
||||
OpenAtMS int64 `json:"open_at_ms"`
|
||||
ResetAtMS int64 `json:"reset_at_ms"`
|
||||
CommandID string `json:"command_id"`
|
||||
}
|
||||
|
||||
// EncodeRoomOutboxMessage serializes the protobuf envelope into an MQ JSON body.
|
||||
func EncodeRoomOutboxMessage(envelope *roomeventsv1.EventEnvelope) ([]byte, error) {
|
||||
if envelope == nil {
|
||||
return nil, errors.New("room outbox envelope is required")
|
||||
}
|
||||
envelopeBytes, err := proto.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(RoomOutboxMessage{
|
||||
MessageType: MessageTypeRoomOutboxEvent,
|
||||
AppCode: envelope.GetAppCode(),
|
||||
EventID: envelope.GetEventId(),
|
||||
EventType: envelope.GetEventType(),
|
||||
RoomID: envelope.GetRoomId(),
|
||||
RoomVersion: envelope.GetRoomVersion(),
|
||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||
Envelope: envelopeBytes,
|
||||
})
|
||||
}
|
||||
|
||||
// DecodeRoomOutboxMessage validates the MQ body and restores the protobuf fact.
|
||||
func DecodeRoomOutboxMessage(body []byte) (*roomeventsv1.EventEnvelope, RoomOutboxMessage, error) {
|
||||
var message RoomOutboxMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
return nil, RoomOutboxMessage{}, err
|
||||
}
|
||||
if message.MessageType != MessageTypeRoomOutboxEvent {
|
||||
return nil, RoomOutboxMessage{}, errors.New("unexpected room outbox message_type")
|
||||
}
|
||||
var envelope roomeventsv1.EventEnvelope
|
||||
if err := proto.Unmarshal(message.Envelope, &envelope); err != nil {
|
||||
return nil, RoomOutboxMessage{}, err
|
||||
}
|
||||
if envelope.GetEventId() == "" || envelope.GetEventType() == "" || envelope.GetRoomId() == "" {
|
||||
return nil, RoomOutboxMessage{}, errors.New("room outbox envelope is incomplete")
|
||||
}
|
||||
return &envelope, message, nil
|
||||
}
|
||||
|
||||
// EncodeRoomTreasureOpenDueMessage serializes an open wakeup command.
|
||||
func EncodeRoomTreasureOpenDueMessage(message RoomTreasureOpenDueMessage) ([]byte, error) {
|
||||
message.MessageType = MessageTypeRoomTreasureOpenDue
|
||||
if message.AppCode == "" || message.RoomID == "" || message.BoxID == "" || message.Level <= 0 || message.OpenAtMS <= 0 {
|
||||
return nil, errors.New("room treasure open due message is incomplete")
|
||||
}
|
||||
return json.Marshal(message)
|
||||
}
|
||||
|
||||
// DecodeRoomTreasureOpenDueMessage validates an open wakeup body.
|
||||
func DecodeRoomTreasureOpenDueMessage(body []byte) (RoomTreasureOpenDueMessage, error) {
|
||||
var message RoomTreasureOpenDueMessage
|
||||
if err := json.Unmarshal(body, &message); err != nil {
|
||||
return RoomTreasureOpenDueMessage{}, err
|
||||
}
|
||||
if message.MessageType != MessageTypeRoomTreasureOpenDue {
|
||||
return RoomTreasureOpenDueMessage{}, errors.New("unexpected room treasure message_type")
|
||||
}
|
||||
if message.AppCode == "" || message.RoomID == "" || message.BoxID == "" || message.Level <= 0 || message.OpenAtMS <= 0 {
|
||||
return RoomTreasureOpenDueMessage{}, errors.New("room treasure open due message is incomplete")
|
||||
}
|
||||
return message, nil
|
||||
}
|
||||
57
pkg/roommq/messages_test.go
Normal file
57
pkg/roommq/messages_test.go
Normal file
@ -0,0 +1,57 @@
|
||||
package roommq
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
)
|
||||
|
||||
func TestRoomOutboxMessageRoundTrip(t *testing.T) {
|
||||
body, err := proto.Marshal(&roomeventsv1.RoomTreasureCountdownStarted{BoxId: "box-1", Level: 2, OpenAtMs: 12345})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal body: %v", err)
|
||||
}
|
||||
envelope := &roomeventsv1.EventEnvelope{
|
||||
AppCode: "default",
|
||||
EventId: "evt-1",
|
||||
EventType: "RoomTreasureCountdownStarted",
|
||||
RoomId: "room-1",
|
||||
RoomVersion: 9,
|
||||
OccurredAtMs: 12300,
|
||||
Body: body,
|
||||
}
|
||||
encoded, err := EncodeRoomOutboxMessage(envelope)
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
decoded, message, err := DecodeRoomOutboxMessage(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if message.MessageType != MessageTypeRoomOutboxEvent || decoded.GetEventId() != envelope.GetEventId() {
|
||||
t.Fatalf("unexpected decoded message: %#v %#v", message, decoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomTreasureOpenDueRoundTrip(t *testing.T) {
|
||||
encoded, err := EncodeRoomTreasureOpenDueMessage(RoomTreasureOpenDueMessage{
|
||||
AppCode: "default",
|
||||
RoomID: "room-1",
|
||||
BoxID: "box-1",
|
||||
Level: 1,
|
||||
OpenAtMS: 12345,
|
||||
ResetAtMS: 99999,
|
||||
CommandID: "cmd_room_treasure_open_box-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
decoded, err := DecodeRoomTreasureOpenDueMessage(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if decoded.MessageType != MessageTypeRoomTreasureOpenDue || decoded.BoxID != "box-1" || decoded.OpenAtMS != 12345 {
|
||||
t.Fatalf("unexpected decoded message: %#v", decoded)
|
||||
}
|
||||
}
|
||||
@ -47,6 +47,7 @@ import (
|
||||
registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
|
||||
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
||||
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
|
||||
roomtreasuremodule "hyapp-admin-server/internal/modules/roomtreasure"
|
||||
searchmodule "hyapp-admin-server/internal/modules/search"
|
||||
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
|
||||
uploadmodule "hyapp-admin-server/internal/modules/upload"
|
||||
@ -232,6 +233,7 @@ func main() {
|
||||
RegionBlock: regionblockmodule.New(userDB, auditHandler),
|
||||
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
|
||||
RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler),
|
||||
RoomTreasure: roomtreasuremodule.New(roomDB, auditHandler),
|
||||
Search: searchmodule.New(store),
|
||||
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
|
||||
|
||||
49
server/admin/internal/modules/roomtreasure/handler.go
Normal file
49
server/admin/internal/modules/roomtreasure/handler.go
Normal file
@ -0,0 +1,49 @@
|
||||
package roomtreasure
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(roomDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(roomDB), audit: audit}
|
||||
}
|
||||
|
||||
func (h *Handler) GetRoomTreasureConfig(c *gin.Context) {
|
||||
config, err := h.service.GetConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取房间宝箱配置失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, config)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateRoomTreasureConfig(c *gin.Context) {
|
||||
var req updateRoomTreasureConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "房间宝箱配置参数不正确")
|
||||
return
|
||||
}
|
||||
config, err := h.service.UpdateConfig(c.Request.Context(), req, int64(middleware.CurrentUserID(c)))
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidArgument) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.ServerError(c, "更新房间宝箱配置失败")
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "update-room-treasure-config", "room_treasure_configs", config.AppCode, "success", "")
|
||||
response.OK(c, config)
|
||||
}
|
||||
16
server/admin/internal/modules/roomtreasure/routes.go
Normal file
16
server/admin/internal/modules/roomtreasure/routes.go
Normal file
@ -0,0 +1,16 @@
|
||||
package roomtreasure
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
|
||||
protected.GET("/admin/activity/room-treasure/config", middleware.RequirePermission("room-treasure:view"), h.GetRoomTreasureConfig)
|
||||
protected.PUT("/admin/activity/room-treasure/config", middleware.RequirePermission("room-treasure:update"), h.UpdateRoomTreasureConfig)
|
||||
}
|
||||
422
server/admin/internal/modules/roomtreasure/service.go
Normal file
422
server/admin/internal/modules/roomtreasure/service.go
Normal file
@ -0,0 +1,422 @@
|
||||
package roomtreasure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
)
|
||||
|
||||
const (
|
||||
roomTreasureLevelCount = 7
|
||||
|
||||
defaultEnergySource = "gift_point_added"
|
||||
defaultOpenDelayMS = int64(30_000)
|
||||
defaultBroadcastScope = "region"
|
||||
defaultRewardStackPolicy = "allow_stack"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidArgument = errors.New("invalid argument")
|
||||
|
||||
defaultLevelEnergyThresholds = []int64{1_000, 3_000, 6_000, 10_000, 15_000, 21_000, 28_000}
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
type RoomTreasureConfig struct {
|
||||
AppCode string `json:"appCode"`
|
||||
Enabled bool `json:"enabled"`
|
||||
ConfigVersion int64 `json:"configVersion"`
|
||||
EnergySource string `json:"energySource"`
|
||||
OpenDelayMS int64 `json:"openDelayMs"`
|
||||
BroadcastEnabled bool `json:"broadcastEnabled"`
|
||||
BroadcastScope string `json:"broadcastScope"`
|
||||
BroadcastDelayMS int64 `json:"broadcastDelayMs"`
|
||||
RewardStackPolicy string `json:"rewardStackPolicy"`
|
||||
Levels []RoomTreasureLevelConfig `json:"levels"`
|
||||
GiftEnergyRules []GiftEnergyRuleConfig `json:"giftEnergyRules"`
|
||||
UpdatedByAdminID int64 `json:"updatedByAdminId"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type RoomTreasureLevelConfig struct {
|
||||
Level int32 `json:"level"`
|
||||
EnergyThreshold int64 `json:"energyThreshold"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
AnimationURL string `json:"animationUrl"`
|
||||
OpeningAnimationURL string `json:"openingAnimationUrl"`
|
||||
OpenedImageURL string `json:"openedImageUrl"`
|
||||
InRoomRewards []RoomTreasureRewardItem `json:"inRoomRewards"`
|
||||
Top1Rewards []RoomTreasureRewardItem `json:"top1Rewards"`
|
||||
IgniterRewards []RoomTreasureRewardItem `json:"igniterRewards"`
|
||||
}
|
||||
|
||||
type RoomTreasureRewardItem struct {
|
||||
RewardItemID string `json:"rewardItemId"`
|
||||
ResourceGroupID int64 `json:"resourceGroupId"`
|
||||
Weight int64 `json:"weight"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
}
|
||||
|
||||
type GiftEnergyRuleConfig struct {
|
||||
RuleID string `json:"ruleId"`
|
||||
GiftID string `json:"giftId"`
|
||||
GiftTypeCode string `json:"giftTypeCode"`
|
||||
MultiplierPPM int64 `json:"multiplierPpm"`
|
||||
FixedEnergy int64 `json:"fixedEnergy"`
|
||||
Excluded bool `json:"excluded"`
|
||||
}
|
||||
|
||||
type updateRoomTreasureConfigRequest struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
EnergySource string `json:"energySource"`
|
||||
OpenDelayMS int64 `json:"openDelayMs"`
|
||||
BroadcastEnabled bool `json:"broadcastEnabled"`
|
||||
BroadcastScope string `json:"broadcastScope"`
|
||||
BroadcastDelayMS int64 `json:"broadcastDelayMs"`
|
||||
RewardStackPolicy string `json:"rewardStackPolicy"`
|
||||
Levels []RoomTreasureLevelConfig `json:"levels"`
|
||||
GiftEnergyRules []GiftEnergyRuleConfig `json:"giftEnergyRules"`
|
||||
}
|
||||
|
||||
func NewService(db *sql.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
// GetConfig 读取当前 App 的语音房宝箱配置;缺行时返回关闭态默认配置,便于首次进入后台页面。
|
||||
func (s *Service) GetConfig(ctx context.Context) (RoomTreasureConfig, error) {
|
||||
if s.db == nil {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("room mysql is not configured")
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
row := s.db.QueryRowContext(ctx, `
|
||||
SELECT app_code,
|
||||
enabled,
|
||||
config_version,
|
||||
energy_source,
|
||||
open_delay_ms,
|
||||
broadcast_enabled,
|
||||
broadcast_scope,
|
||||
broadcast_delay_ms,
|
||||
reward_stack_policy,
|
||||
levels_json,
|
||||
gift_energy_rules_json,
|
||||
updated_by_admin_id,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
FROM room_treasure_configs
|
||||
WHERE app_code = ?
|
||||
`, appCode)
|
||||
|
||||
var enabled int
|
||||
var broadcastEnabled int
|
||||
var rawLevels string
|
||||
var rawGiftEnergyRules string
|
||||
config := defaultConfig(appCode)
|
||||
if err := row.Scan(
|
||||
&config.AppCode,
|
||||
&enabled,
|
||||
&config.ConfigVersion,
|
||||
&config.EnergySource,
|
||||
&config.OpenDelayMS,
|
||||
&broadcastEnabled,
|
||||
&config.BroadcastScope,
|
||||
&config.BroadcastDelayMS,
|
||||
&config.RewardStackPolicy,
|
||||
&rawLevels,
|
||||
&rawGiftEnergyRules,
|
||||
&config.UpdatedByAdminID,
|
||||
&config.CreatedAtMS,
|
||||
&config.UpdatedAtMS,
|
||||
); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return config, nil
|
||||
}
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
config.Enabled = enabled == 1
|
||||
config.BroadcastEnabled = broadcastEnabled == 1
|
||||
if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rawGiftEnergyRules), &config.GiftEnergyRules); err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
return normalizeConfig(config)
|
||||
}
|
||||
|
||||
// UpdateConfig 保存房间宝箱低频配置;版本号由后台递增,运行时只使用已提交版本。
|
||||
func (s *Service) UpdateConfig(ctx context.Context, req updateRoomTreasureConfigRequest, operatorAdminID int64) (RoomTreasureConfig, error) {
|
||||
if s.db == nil {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("room mysql is not configured")
|
||||
}
|
||||
current, err := s.GetConfig(ctx)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
nowMS := time.Now().UTC().UnixMilli()
|
||||
createdAtMS := current.CreatedAtMS
|
||||
if createdAtMS <= 0 {
|
||||
createdAtMS = nowMS
|
||||
}
|
||||
config, err := normalizeConfig(RoomTreasureConfig{
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
Enabled: req.Enabled,
|
||||
ConfigVersion: current.ConfigVersion + 1,
|
||||
EnergySource: req.EnergySource,
|
||||
OpenDelayMS: req.OpenDelayMS,
|
||||
BroadcastEnabled: req.BroadcastEnabled,
|
||||
BroadcastScope: req.BroadcastScope,
|
||||
BroadcastDelayMS: req.BroadcastDelayMS,
|
||||
RewardStackPolicy: req.RewardStackPolicy,
|
||||
Levels: req.Levels,
|
||||
GiftEnergyRules: req.GiftEnergyRules,
|
||||
UpdatedByAdminID: operatorAdminID,
|
||||
CreatedAtMS: createdAtMS,
|
||||
UpdatedAtMS: nowMS,
|
||||
})
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
rawLevels, err := json.Marshal(config.Levels)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
rawGiftEnergyRules, err := json.Marshal(config.GiftEnergyRules)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO room_treasure_configs (
|
||||
app_code,
|
||||
enabled,
|
||||
config_version,
|
||||
energy_source,
|
||||
open_delay_ms,
|
||||
broadcast_enabled,
|
||||
broadcast_scope,
|
||||
broadcast_delay_ms,
|
||||
reward_stack_policy,
|
||||
levels_json,
|
||||
gift_energy_rules_json,
|
||||
updated_by_admin_id,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
config_version = VALUES(config_version),
|
||||
energy_source = VALUES(energy_source),
|
||||
open_delay_ms = VALUES(open_delay_ms),
|
||||
broadcast_enabled = VALUES(broadcast_enabled),
|
||||
broadcast_scope = VALUES(broadcast_scope),
|
||||
broadcast_delay_ms = VALUES(broadcast_delay_ms),
|
||||
reward_stack_policy = VALUES(reward_stack_policy),
|
||||
levels_json = VALUES(levels_json),
|
||||
gift_energy_rules_json = VALUES(gift_energy_rules_json),
|
||||
updated_by_admin_id = VALUES(updated_by_admin_id),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`,
|
||||
config.AppCode,
|
||||
boolInt(config.Enabled),
|
||||
config.ConfigVersion,
|
||||
config.EnergySource,
|
||||
config.OpenDelayMS,
|
||||
boolInt(config.BroadcastEnabled),
|
||||
config.BroadcastScope,
|
||||
config.BroadcastDelayMS,
|
||||
config.RewardStackPolicy,
|
||||
string(rawLevels),
|
||||
string(rawGiftEnergyRules),
|
||||
config.UpdatedByAdminID,
|
||||
config.CreatedAtMS,
|
||||
config.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func defaultConfig(appCode string) RoomTreasureConfig {
|
||||
levels := make([]RoomTreasureLevelConfig, 0, roomTreasureLevelCount)
|
||||
for index, threshold := range defaultLevelEnergyThresholds {
|
||||
levels = append(levels, RoomTreasureLevelConfig{
|
||||
Level: int32(index + 1),
|
||||
EnergyThreshold: threshold,
|
||||
})
|
||||
}
|
||||
return RoomTreasureConfig{
|
||||
AppCode: appCode,
|
||||
EnergySource: defaultEnergySource,
|
||||
OpenDelayMS: defaultOpenDelayMS,
|
||||
BroadcastEnabled: true,
|
||||
BroadcastScope: defaultBroadcastScope,
|
||||
RewardStackPolicy: defaultRewardStackPolicy,
|
||||
Levels: levels,
|
||||
GiftEnergyRules: []GiftEnergyRuleConfig{},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeConfig(config RoomTreasureConfig) (RoomTreasureConfig, error) {
|
||||
config.AppCode = strings.TrimSpace(config.AppCode)
|
||||
if config.AppCode == "" {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("%w: app_code 不能为空", ErrInvalidArgument)
|
||||
}
|
||||
if config.ConfigVersion < 0 {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("%w: 配置版本不能小于 0", ErrInvalidArgument)
|
||||
}
|
||||
config.EnergySource = defaultString(strings.TrimSpace(config.EnergySource), defaultEnergySource)
|
||||
if !stringIn(config.EnergySource, "gift_point_added", "heat_value") {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("%w: 能量来源不支持", ErrInvalidArgument)
|
||||
}
|
||||
if config.OpenDelayMS < 0 {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("%w: 开箱倒计时不能小于 0", ErrInvalidArgument)
|
||||
}
|
||||
config.BroadcastScope = defaultString(strings.TrimSpace(config.BroadcastScope), defaultBroadcastScope)
|
||||
if !stringIn(config.BroadcastScope, "none", "region", "global") {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("%w: 广播范围不支持", ErrInvalidArgument)
|
||||
}
|
||||
if config.BroadcastDelayMS < 0 {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("%w: 广播延迟不能小于 0", ErrInvalidArgument)
|
||||
}
|
||||
config.RewardStackPolicy = defaultString(strings.TrimSpace(config.RewardStackPolicy), defaultRewardStackPolicy)
|
||||
if !stringIn(config.RewardStackPolicy, "allow_stack", "priority_only") {
|
||||
return RoomTreasureConfig{}, fmt.Errorf("%w: 奖励叠加策略不支持", ErrInvalidArgument)
|
||||
}
|
||||
levels, err := normalizeLevels(config.Levels)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
config.Levels = levels
|
||||
giftEnergyRules, err := normalizeGiftEnergyRules(config.GiftEnergyRules)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
config.GiftEnergyRules = giftEnergyRules
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func normalizeLevels(levels []RoomTreasureLevelConfig) ([]RoomTreasureLevelConfig, error) {
|
||||
if len(levels) == 0 {
|
||||
return defaultConfig("placeholder").Levels, nil
|
||||
}
|
||||
if len(levels) != roomTreasureLevelCount {
|
||||
return nil, fmt.Errorf("%w: 宝箱等级必须配置 7 级", ErrInvalidArgument)
|
||||
}
|
||||
seen := make(map[int32]bool, roomTreasureLevelCount)
|
||||
normalized := make([]RoomTreasureLevelConfig, 0, roomTreasureLevelCount)
|
||||
for _, level := range levels {
|
||||
if level.Level < 1 || level.Level > roomTreasureLevelCount {
|
||||
return nil, fmt.Errorf("%w: 宝箱等级只能是 1 到 7", ErrInvalidArgument)
|
||||
}
|
||||
if seen[level.Level] {
|
||||
return nil, fmt.Errorf("%w: 宝箱等级不能重复", ErrInvalidArgument)
|
||||
}
|
||||
seen[level.Level] = true
|
||||
if level.EnergyThreshold <= 0 {
|
||||
return nil, fmt.Errorf("%w: 第 %d 级能量阈值必须大于 0", ErrInvalidArgument, level.Level)
|
||||
}
|
||||
level.CoverURL = strings.TrimSpace(level.CoverURL)
|
||||
level.AnimationURL = strings.TrimSpace(level.AnimationURL)
|
||||
level.OpeningAnimationURL = strings.TrimSpace(level.OpeningAnimationURL)
|
||||
level.OpenedImageURL = strings.TrimSpace(level.OpenedImageURL)
|
||||
var err error
|
||||
if level.InRoomRewards, err = normalizeRewardPool(level.Level, "in_room", level.InRoomRewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if level.Top1Rewards, err = normalizeRewardPool(level.Level, "top1", level.Top1Rewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if level.IgniterRewards, err = normalizeRewardPool(level.Level, "igniter", level.IgniterRewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized = append(normalized, level)
|
||||
}
|
||||
slices.SortFunc(normalized, func(left, right RoomTreasureLevelConfig) int {
|
||||
return int(left.Level - right.Level)
|
||||
})
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRewardPool(level int32, role string, rewards []RoomTreasureRewardItem) ([]RoomTreasureRewardItem, error) {
|
||||
normalized := make([]RoomTreasureRewardItem, 0, len(rewards))
|
||||
for index, reward := range rewards {
|
||||
reward.RewardItemID = strings.TrimSpace(reward.RewardItemID)
|
||||
reward.DisplayName = strings.TrimSpace(reward.DisplayName)
|
||||
reward.IconURL = strings.TrimSpace(reward.IconURL)
|
||||
if reward.RewardItemID == "" && reward.ResourceGroupID == 0 && reward.Weight == 0 && reward.DisplayName == "" && reward.IconURL == "" {
|
||||
continue
|
||||
}
|
||||
if reward.ResourceGroupID <= 0 {
|
||||
return nil, fmt.Errorf("%w: 第 %d 级奖励资源组不能为空", ErrInvalidArgument, level)
|
||||
}
|
||||
if reward.Weight <= 0 {
|
||||
return nil, fmt.Errorf("%w: 第 %d 级奖励权重必须大于 0", ErrInvalidArgument, level)
|
||||
}
|
||||
if reward.RewardItemID == "" {
|
||||
reward.RewardItemID = fmt.Sprintf("level_%d_%s_%d", level, role, index+1)
|
||||
}
|
||||
normalized = append(normalized, reward)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeGiftEnergyRules(rules []GiftEnergyRuleConfig) ([]GiftEnergyRuleConfig, error) {
|
||||
normalized := make([]GiftEnergyRuleConfig, 0, len(rules))
|
||||
for index, rule := range rules {
|
||||
rule.RuleID = strings.TrimSpace(rule.RuleID)
|
||||
rule.GiftID = strings.TrimSpace(rule.GiftID)
|
||||
rule.GiftTypeCode = strings.TrimSpace(rule.GiftTypeCode)
|
||||
if rule.RuleID == "" && rule.GiftID == "" && rule.GiftTypeCode == "" && rule.MultiplierPPM == 0 && rule.FixedEnergy == 0 && !rule.Excluded {
|
||||
continue
|
||||
}
|
||||
if rule.GiftID == "" && rule.GiftTypeCode == "" {
|
||||
return nil, fmt.Errorf("%w: 礼物能量规则必须选择礼物或礼物类型", ErrInvalidArgument)
|
||||
}
|
||||
if rule.MultiplierPPM < 0 || rule.FixedEnergy < 0 {
|
||||
return nil, fmt.Errorf("%w: 礼物能量规则不能配置负数", ErrInvalidArgument)
|
||||
}
|
||||
if !rule.Excluded && rule.MultiplierPPM == 0 && rule.FixedEnergy == 0 {
|
||||
return nil, fmt.Errorf("%w: 礼物能量规则必须配置倍率或固定能量", ErrInvalidArgument)
|
||||
}
|
||||
if rule.RuleID == "" {
|
||||
rule.RuleID = fmt.Sprintf("gift_energy_rule_%d", index+1)
|
||||
}
|
||||
normalized = append(normalized, rule)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func boolInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func stringIn(value string, candidates ...string) bool {
|
||||
for _, candidate := range candidates {
|
||||
if value == candidate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
79
server/admin/internal/modules/roomtreasure/service_test.go
Normal file
79
server/admin/internal/modules/roomtreasure/service_test.go
Normal file
@ -0,0 +1,79 @@
|
||||
package roomtreasure
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeConfigDefaultsAndSortsLevels(t *testing.T) {
|
||||
config, err := normalizeConfig(RoomTreasureConfig{
|
||||
AppCode: "lalu",
|
||||
OpenDelayMS: defaultOpenDelayMS,
|
||||
Levels: []RoomTreasureLevelConfig{
|
||||
{Level: 2, EnergyThreshold: 300},
|
||||
{Level: 1, EnergyThreshold: 100},
|
||||
{Level: 3, EnergyThreshold: 600},
|
||||
{Level: 4, EnergyThreshold: 1_000},
|
||||
{Level: 5, EnergyThreshold: 1_500},
|
||||
{Level: 6, EnergyThreshold: 2_100},
|
||||
{Level: 7, EnergyThreshold: 2_800},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeConfig failed: %v", err)
|
||||
}
|
||||
if config.EnergySource != defaultEnergySource || config.OpenDelayMS != defaultOpenDelayMS || config.BroadcastScope != defaultBroadcastScope {
|
||||
t.Fatalf("default fields mismatch: %+v", config)
|
||||
}
|
||||
if config.Levels[0].Level != 1 || config.Levels[6].Level != 7 {
|
||||
t.Fatalf("levels should be sorted by level: %+v", config.Levels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfigKeepsOperationalDefaults(t *testing.T) {
|
||||
config, err := normalizeConfig(defaultConfig("lalu"))
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeConfig(defaultConfig) failed: %v", err)
|
||||
}
|
||||
if !config.BroadcastEnabled || config.OpenDelayMS != defaultOpenDelayMS || len(config.Levels) != roomTreasureLevelCount {
|
||||
t.Fatalf("default config mismatch: %+v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigRejectsInvalidLevels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config RoomTreasureConfig
|
||||
}{
|
||||
{name: "missing_level", config: RoomTreasureConfig{AppCode: "lalu", Levels: []RoomTreasureLevelConfig{{Level: 1, EnergyThreshold: 1}}}},
|
||||
{name: "duplicate", config: RoomTreasureConfig{AppCode: "lalu", Levels: []RoomTreasureLevelConfig{
|
||||
{Level: 1, EnergyThreshold: 1}, {Level: 1, EnergyThreshold: 2}, {Level: 3, EnergyThreshold: 3}, {Level: 4, EnergyThreshold: 4}, {Level: 5, EnergyThreshold: 5}, {Level: 6, EnergyThreshold: 6}, {Level: 7, EnergyThreshold: 7},
|
||||
}}},
|
||||
{name: "zero_threshold", config: RoomTreasureConfig{AppCode: "lalu", Levels: []RoomTreasureLevelConfig{
|
||||
{Level: 1, EnergyThreshold: 0}, {Level: 2, EnergyThreshold: 2}, {Level: 3, EnergyThreshold: 3}, {Level: 4, EnergyThreshold: 4}, {Level: 5, EnergyThreshold: 5}, {Level: 6, EnergyThreshold: 6}, {Level: 7, EnergyThreshold: 7},
|
||||
}}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := normalizeConfig(test.config)
|
||||
if !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("normalizeConfig should reject %s with ErrInvalidArgument, got %v", test.name, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigRejectsIncompleteRewardAndEnergyRule(t *testing.T) {
|
||||
config := defaultConfig("lalu")
|
||||
config.Levels[0].InRoomRewards = []RoomTreasureRewardItem{{ResourceGroupID: 0, Weight: 100}}
|
||||
if _, err := normalizeConfig(config); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("reward without resource group should be invalid, got %v", err)
|
||||
}
|
||||
|
||||
config = defaultConfig("lalu")
|
||||
config.GiftEnergyRules = []GiftEnergyRuleConfig{{GiftID: "gift-1"}}
|
||||
if _, err := normalizeConfig(config); !errors.Is(err, ErrInvalidArgument) {
|
||||
t.Fatalf("energy rule without multiplier or fixed energy should be invalid, got %v", err)
|
||||
}
|
||||
}
|
||||
@ -96,6 +96,8 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"},
|
||||
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
|
||||
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"},
|
||||
{Name: "房间宝箱查看", Code: "room-treasure:view", Kind: "menu"},
|
||||
{Name: "房间宝箱更新", Code: "room-treasure:update", Kind: "button"},
|
||||
{Name: "角色查看", Code: "role:view", Kind: "menu"},
|
||||
{Name: "角色创建", Code: "role:create", Kind: "button"},
|
||||
{Name: "角色更新", Code: "role:update", Kind: "button"},
|
||||
@ -126,7 +128,7 @@ var deprecatedPermissionCodes = []string{
|
||||
"service:view", "service:create", "service:update", "service:operate",
|
||||
"notification:view", "notification:read", "notification:read-all", "notification:delete",
|
||||
}
|
||||
var deprecatedMenuCodes = []string{"services", "notifications"}
|
||||
var deprecatedMenuCodes = []string{"services", "notifications", "jobs", "room-treasure"}
|
||||
|
||||
func (s *Store) seedPermissions() error {
|
||||
return s.syncDefaultPermissions(s.db)
|
||||
@ -158,7 +160,6 @@ func (s *Store) seedMenus() error {
|
||||
{Title: "游戏管理", Code: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 71, Visible: true},
|
||||
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 72, Visible: true},
|
||||
{Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, Visible: true},
|
||||
{Title: "任务中心", Code: "jobs", Path: "/jobs", Icon: "task", PermissionCode: "job:view", Sort: 90, Visible: true},
|
||||
}
|
||||
for _, menu := range menus {
|
||||
syncedMenu, err := s.syncDefaultMenu(s.db, menu)
|
||||
@ -471,12 +472,13 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
"achievement:view", "achievement:create", "achievement:update",
|
||||
"seven-day-checkin:view", "seven-day-checkin:update",
|
||||
"room-treasure:view", "room-treasure:update",
|
||||
"log:view",
|
||||
"job:view", "job:cancel", "export:create",
|
||||
"upload:create",
|
||||
}
|
||||
case "auditor":
|
||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "role:view", "permission:view", "job:view"}
|
||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "role:view", "permission:view", "job:view"}
|
||||
case "readonly":
|
||||
return []string{
|
||||
"overview:view",
|
||||
@ -509,6 +511,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"daily-task:view",
|
||||
"achievement:view",
|
||||
"seven-day-checkin:view",
|
||||
"room-treasure:view",
|
||||
"role:view",
|
||||
"permission:view",
|
||||
"menu:view",
|
||||
@ -545,9 +548,10 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
"achievement:view", "achievement:create", "achievement:update",
|
||||
"seven-day-checkin:view", "seven-day-checkin:update",
|
||||
"room-treasure:view", "room-treasure:update",
|
||||
}
|
||||
case "auditor", "readonly":
|
||||
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view"}
|
||||
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -27,6 +27,7 @@ import (
|
||||
"hyapp-admin-server/internal/modules/registrationreward"
|
||||
resourcemodule "hyapp-admin-server/internal/modules/resource"
|
||||
"hyapp-admin-server/internal/modules/roomadmin"
|
||||
"hyapp-admin-server/internal/modules/roomtreasure"
|
||||
"hyapp-admin-server/internal/modules/search"
|
||||
"hyapp-admin-server/internal/modules/sevendaycheckin"
|
||||
"hyapp-admin-server/internal/modules/upload"
|
||||
@ -61,6 +62,7 @@ type Handlers struct {
|
||||
RegionBlock *regionblock.Handler
|
||||
Resource *resourcemodule.Handler
|
||||
RoomAdmin *roomadmin.Handler
|
||||
RoomTreasure *roomtreasure.Handler
|
||||
Search *search.Handler
|
||||
SevenDayCheckIn *sevendaycheckin.Handler
|
||||
Upload *upload.Handler
|
||||
@ -93,6 +95,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
regionblock.RegisterRoutes(protected, h.RegionBlock)
|
||||
gamemanagement.RegisterRoutes(protected, h.Game)
|
||||
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
|
||||
roomtreasure.RegisterRoutes(protected, h.RoomTreasure)
|
||||
dashboard.RegisterRoutes(protected, h.Dashboard)
|
||||
hostorg.RegisterRoutes(protected, h.HostOrg)
|
||||
audit.RegisterRoutes(protected, h.Audit)
|
||||
|
||||
59
server/admin/migrations/018_room_treasure_navigation.sql
Normal file
59
server/admin/migrations/018_room_treasure_navigation.sql
Normal file
@ -0,0 +1,59 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 房间宝箱后台只维护 room-service 的低频配置,不拥有 Room Cell 运行时进度和发奖事实。
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('房间宝箱查看', 'room-treasure:view', 'menu', '', @now_ms, @now_ms),
|
||||
('房间宝箱更新', 'room-treasure:update', 'button', '', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
kind = VALUES(kind),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
|
||||
(NULL, '活动管理', 'activities', '', 'campaign', '', 70, TRUE, @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '房间宝箱', 'room-treasure', '/activities/room-treasure', 'redeem', 'room-treasure:view', 73, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'activities'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code = 'platform-admin'
|
||||
AND admin_permission.code IN ('room-treasure:view', 'room-treasure:update');
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code = 'ops-admin'
|
||||
AND admin_permission.code IN ('room-treasure:view', 'room-treasure:update');
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code IN ('auditor', 'readonly')
|
||||
AND admin_permission.code = 'room-treasure:view';
|
||||
@ -0,0 +1,6 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 这两个入口不再作为后台导航展示;权限和接口保留,避免影响已有内部能力。
|
||||
DELETE FROM admin_menus
|
||||
WHERE code IN ('jobs', 'room-treasure')
|
||||
OR path IN ('/jobs', '/activities/room-treasure');
|
||||
@ -31,6 +31,19 @@ broadcast:
|
||||
worker_lock_ttl: "30s"
|
||||
worker_max_retry: 8
|
||||
ensure_groups_on_startup: true
|
||||
rocketmq:
|
||||
# Docker 本地默认关闭;接入外部 RocketMQ 后由独立 consumer group 消费房间事实。
|
||||
enabled: false
|
||||
name_servers: []
|
||||
name_server_domain: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
namespace: ""
|
||||
room_outbox:
|
||||
enabled: false
|
||||
topic: "hyapp_room_outbox"
|
||||
consumer_group: "hyapp-activity-room-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
batch_size: 100
|
||||
|
||||
@ -31,6 +31,19 @@ broadcast:
|
||||
worker_lock_ttl: "30s"
|
||||
worker_max_retry: 8
|
||||
ensure_groups_on_startup: true
|
||||
rocketmq:
|
||||
enabled: true
|
||||
name_servers:
|
||||
- "TENCENT_ROCKETMQ_NAMESERVER:9876"
|
||||
name_server_domain: ""
|
||||
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
|
||||
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
|
||||
namespace: "hyapp-prod"
|
||||
room_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_room_outbox"
|
||||
consumer_group: "hyapp-activity-room-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
batch_size: 100
|
||||
|
||||
@ -31,6 +31,19 @@ broadcast:
|
||||
worker_lock_ttl: "30s"
|
||||
worker_max_retry: 8
|
||||
ensure_groups_on_startup: true
|
||||
rocketmq:
|
||||
# 本地默认关闭,room-service 直接 gRPC 投递 activity;开启后消费 room_outbox MQ fanout。
|
||||
enabled: false
|
||||
name_servers: []
|
||||
name_server_domain: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
namespace: ""
|
||||
room_outbox:
|
||||
enabled: false
|
||||
topic: "hyapp_room_outbox"
|
||||
consumer_group: "hyapp-activity-room-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
batch_size: 100
|
||||
|
||||
@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
@ -12,10 +13,13 @@ import (
|
||||
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/grpchealth"
|
||||
"hyapp/pkg/grpcshutdown"
|
||||
"hyapp/pkg/healthhttp"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/rocketmqx"
|
||||
"hyapp/pkg/roommq"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/services/activity-service/internal/client"
|
||||
"hyapp/services/activity-service/internal/config"
|
||||
@ -41,6 +45,7 @@ type App struct {
|
||||
mysqlRepo *mysqlstorage.Repository
|
||||
broadcast *broadcastservice.Service
|
||||
broadcastWorkerEnabled bool
|
||||
mqConsumers []*rocketmqx.Consumer
|
||||
userConn *grpc.ClientConn
|
||||
walletConn *grpc.ClientConn
|
||||
workerCtx context.Context
|
||||
@ -134,6 +139,36 @@ func New(cfg config.Config) (*App, error) {
|
||||
broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc, growthSvc)
|
||||
activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
|
||||
activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer)
|
||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 1)
|
||||
if cfg.RocketMQ.RoomOutbox.Enabled {
|
||||
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ))
|
||||
if err != nil {
|
||||
_ = walletConn.Close()
|
||||
_ = userConn.Close()
|
||||
_ = listener.Close()
|
||||
_ = repository.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
|
||||
if _, err := broadcastSvc.HandleRoomEvent(eventCtx, envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = growthSvc.HandleRoomEvent(eventCtx, envelope)
|
||||
return err
|
||||
}); err != nil {
|
||||
_ = walletConn.Close()
|
||||
_ = userConn.Close()
|
||||
_ = listener.Close()
|
||||
_ = repository.Close()
|
||||
return nil, err
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
health := grpchealth.NewServingChecker("activity-service", grpchealth.Dependency{
|
||||
Name: "mysql",
|
||||
Check: repository.Ping,
|
||||
@ -157,6 +192,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
mysqlRepo: repository,
|
||||
broadcast: broadcastSvc,
|
||||
broadcastWorkerEnabled: cfg.Broadcast.Enabled,
|
||||
mqConsumers: mqConsumers,
|
||||
userConn: userConn,
|
||||
walletConn: walletConn,
|
||||
workerCtx: workerCtx,
|
||||
@ -168,6 +204,10 @@ func New(cfg config.Config) (*App, error) {
|
||||
func (a *App) Run() error {
|
||||
a.runHealthHTTP()
|
||||
a.health.MarkServing()
|
||||
if err := a.startMQ(); err != nil {
|
||||
a.health.MarkStopped()
|
||||
return err
|
||||
}
|
||||
if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil {
|
||||
a.workerWG.Go(func() {
|
||||
a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{})
|
||||
@ -178,6 +218,7 @@ func (a *App) Run() error {
|
||||
a.workerStop()
|
||||
}
|
||||
a.workerWG.Wait()
|
||||
a.shutdownMQ()
|
||||
a.health.MarkStopped()
|
||||
if errors.Is(err, grpc.ErrServerStopped) {
|
||||
return nil
|
||||
@ -194,6 +235,7 @@ func (a *App) Close() {
|
||||
a.workerStop()
|
||||
}
|
||||
a.workerWG.Wait()
|
||||
a.shutdownMQ()
|
||||
grpcshutdown.GracefulStop(a.server, 15*time.Second)
|
||||
a.closeHealthHTTP()
|
||||
if a.userConn != nil {
|
||||
@ -228,3 +270,40 @@ func (a *App) closeHealthHTTP() {
|
||||
defer cancel()
|
||||
_ = a.healthHTTP.Close(ctx)
|
||||
}
|
||||
|
||||
func (a *App) startMQ() error {
|
||||
for _, consumer := range a.mqConsumers {
|
||||
if err := consumer.Start(); err != nil {
|
||||
a.shutdownMQ()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) shutdownMQ() {
|
||||
for _, consumer := range a.mqConsumers {
|
||||
if err := consumer.Shutdown(); err != nil {
|
||||
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||||
return rocketmqx.ConsumerConfig{
|
||||
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.RoomOutbox.ConsumerGroup,
|
||||
MaxReconsumeTimes: cfg.RoomOutbox.ConsumerMaxReconsumeTimes,
|
||||
ConsumeRetryDelay: time.Second,
|
||||
ConsumePullBatch: 32,
|
||||
ConsumePullTimeout: 15 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@ -28,6 +29,8 @@ type Config struct {
|
||||
TencentIM TencentIMConfig `yaml:"tencent_im"`
|
||||
// Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。
|
||||
Broadcast BroadcastConfig `yaml:"broadcast"`
|
||||
// RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。
|
||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
Consumer ConsumerConfig `yaml:"consumer"`
|
||||
@ -86,6 +89,27 @@ type BroadcastConfig struct {
|
||||
EnsureGroupsOnStartup bool `yaml:"ensure_groups_on_startup"`
|
||||
}
|
||||
|
||||
// RocketMQConfig 描述 activity-service 消费房间事实的 MQ 连接。
|
||||
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"`
|
||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||
}
|
||||
|
||||
// RoomOutboxMQConfig 控制 activity 对 room_outbox topic 的消费位点。
|
||||
type RoomOutboxMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Topic string `yaml:"topic"`
|
||||
ConsumerGroup string `yaml:"consumer_group"`
|
||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||
}
|
||||
|
||||
// Default 返回本地默认配置。
|
||||
func Default() Config {
|
||||
return Config{
|
||||
@ -114,6 +138,7 @@ func Default() Config {
|
||||
WorkerMaxRetry: 8,
|
||||
EnsureGroupsOnStartup: true,
|
||||
},
|
||||
RocketMQ: defaultRocketMQConfig(),
|
||||
MySQLAutoMigrate: false,
|
||||
Consumer: ConsumerConfig{
|
||||
RoomOutboxPollIntervalMs: 1000,
|
||||
@ -130,6 +155,18 @@ func Default() Config {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRocketMQConfig() RocketMQConfig {
|
||||
return RocketMQConfig{
|
||||
Enabled: false,
|
||||
RoomOutbox: RoomOutboxMQConfig{
|
||||
Enabled: false,
|
||||
Topic: "hyapp_room_outbox",
|
||||
ConsumerGroup: "hyapp-activity-room-outbox",
|
||||
ConsumerMaxReconsumeTimes: 16,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从独立 config.yaml 读取 activity-service 配置。
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
@ -195,6 +232,52 @@ func Load(path string) (Config, error) {
|
||||
if cfg.Broadcast.WorkerMaxRetry <= 0 {
|
||||
cfg.Broadcast.WorkerMaxRetry = 8
|
||||
}
|
||||
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.RocketMQ = rocketMQ
|
||||
|
||||
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.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" {
|
||||
cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic
|
||||
}
|
||||
if cfg.RoomOutbox.ConsumerGroup = strings.TrimSpace(cfg.RoomOutbox.ConsumerGroup); cfg.RoomOutbox.ConsumerGroup == "" {
|
||||
cfg.RoomOutbox.ConsumerGroup = defaults.RoomOutbox.ConsumerGroup
|
||||
}
|
||||
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
|
||||
}
|
||||
if cfg.RoomOutbox.Enabled {
|
||||
cfg.Enabled = true
|
||||
}
|
||||
if cfg.Enabled {
|
||||
if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" {
|
||||
return RocketMQConfig{}, errors.New("rocketmq name_servers or name_server_domain is required")
|
||||
}
|
||||
if (cfg.AccessKey == "") != (cfg.SecretKey == "") {
|
||||
return RocketMQConfig{}, errors.New("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
|
||||
}
|
||||
|
||||
23
services/activity-service/internal/config/config_test.go
Normal file
23
services/activity-service/internal/config/config_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadLocalKeepsRocketMQDisabled(t *testing.T) {
|
||||
cfg, err := Load("../../configs/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Load local config failed: %v", err)
|
||||
}
|
||||
if cfg.RocketMQ.Enabled || cfg.RocketMQ.RoomOutbox.Enabled {
|
||||
t.Fatalf("local config must not require RocketMQ: %+v", cfg.RocketMQ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTencentExampleEnablesRoomOutboxMQ(t *testing.T) {
|
||||
cfg, err := Load("../../configs/config.tencent.example.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Load tencent example failed: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@ const (
|
||||
TypeSuperGift = "super_gift"
|
||||
// TypeRedPacket 是红包入口播报,资金事实必须由 wallet/red-packet 领域持久化。
|
||||
TypeRedPacket = "red_packet"
|
||||
// TypeRoomTreasure 是语音房宝箱满能量后的开箱倒计时播报。
|
||||
TypeRoomTreasure = "room_treasure"
|
||||
|
||||
// StatusPending 表示消息已持久化,尚未被 worker claim。
|
||||
StatusPending = "pending"
|
||||
|
||||
@ -266,8 +266,11 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
|
||||
}
|
||||
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
|
||||
result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
|
||||
if envelope.GetEventType() == "RoomTreasureCountdownStarted" {
|
||||
return s.handleRoomTreasureCountdown(eventCtx, envelope)
|
||||
}
|
||||
if envelope.GetEventType() != "RoomGiftSent" {
|
||||
// 当前只消费礼物事实;未来红包或运营事件应显式增加事件类型分支和测试。
|
||||
// 当前只消费礼物和房间宝箱事实;未来红包或运营事件应显式增加事件类型分支和测试。
|
||||
return result, nil
|
||||
}
|
||||
var gift roomeventsv1.RoomGiftSent
|
||||
@ -301,6 +304,53 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) handleRoomTreasureCountdown(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) {
|
||||
result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
|
||||
var treasure roomeventsv1.RoomTreasureCountdownStarted
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &treasure); err != nil {
|
||||
return broadcastdomain.ConsumeRoomEventResult{}, err
|
||||
}
|
||||
switch treasure.GetBroadcastScope() {
|
||||
case broadcastdomain.ScopeRegion:
|
||||
if treasure.GetVisibleRegionId() <= 0 {
|
||||
return result, nil
|
||||
}
|
||||
case broadcastdomain.ScopeGlobal:
|
||||
default:
|
||||
return result, nil
|
||||
}
|
||||
|
||||
broadcastEventID := roomTreasureBroadcastEventID(envelope, &treasure)
|
||||
payloadJSON, err := roomTreasurePayload(envelope, &treasure, broadcastEventID, s.now().UTC().UnixMilli())
|
||||
if err != nil {
|
||||
return broadcastdomain.ConsumeRoomEventResult{}, err
|
||||
}
|
||||
var published broadcastdomain.PublishResult
|
||||
if treasure.GetBroadcastScope() == broadcastdomain.ScopeGlobal {
|
||||
published, err = s.PublishGlobalBroadcast(ctx, PublishInput{
|
||||
EventID: broadcastEventID,
|
||||
BroadcastType: broadcastdomain.TypeRoomTreasure,
|
||||
PayloadJSON: payloadJSON,
|
||||
})
|
||||
} else {
|
||||
published, err = s.PublishRegionBroadcast(ctx, PublishInput{
|
||||
EventID: broadcastEventID,
|
||||
BroadcastType: broadcastdomain.TypeRoomTreasure,
|
||||
RegionID: treasure.GetVisibleRegionId(),
|
||||
PayloadJSON: payloadJSON,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
return broadcastdomain.ConsumeRoomEventResult{}, err
|
||||
}
|
||||
return broadcastdomain.ConsumeRoomEventResult{
|
||||
EventID: envelope.GetEventId(),
|
||||
Status: published.Status,
|
||||
BroadcastEventID: published.EventID,
|
||||
BroadcastCreated: published.Created,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WorkerOptions 描述一次 worker 批处理请求;cron 和常驻 worker 都走同一套参数归一化。
|
||||
type WorkerOptions struct {
|
||||
WorkerID string
|
||||
@ -423,6 +473,41 @@ func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.R
|
||||
return string(encoded), err
|
||||
}
|
||||
|
||||
func roomTreasureBroadcastEventID(envelope *roomeventsv1.EventEnvelope, treasure *roomeventsv1.RoomTreasureCountdownStarted) string {
|
||||
if strings.TrimSpace(treasure.GetBoxId()) != "" {
|
||||
return fmt.Sprintf("room_treasure_broadcast:%s:%s", envelope.GetRoomId(), treasure.GetBoxId())
|
||||
}
|
||||
return "room_treasure_broadcast:" + envelope.GetEventId()
|
||||
}
|
||||
|
||||
func roomTreasurePayload(envelope *roomeventsv1.EventEnvelope, treasure *roomeventsv1.RoomTreasureCountdownStarted, eventID string, sentAtMS int64) (string, error) {
|
||||
payload := map[string]any{
|
||||
"event_id": eventID,
|
||||
"broadcast_type": broadcastdomain.TypeRoomTreasure,
|
||||
"scope": treasure.GetBroadcastScope(),
|
||||
"app_code": envelope.GetAppCode(),
|
||||
"region_id": treasure.GetVisibleRegionId(),
|
||||
"room_id": envelope.GetRoomId(),
|
||||
"box_id": treasure.GetBoxId(),
|
||||
"level": treasure.GetLevel(),
|
||||
"open_at_ms": treasure.GetOpenAtMs(),
|
||||
"top1_user_id": treasure.GetTop1UserId(),
|
||||
"igniter_user_id": treasure.GetIgniterUserId(),
|
||||
"sent_at_ms": sentAtMS,
|
||||
"room_version": envelope.GetRoomVersion(),
|
||||
"source_event_id": envelope.GetEventId(),
|
||||
"current_progress": treasure.GetCurrentProgress(),
|
||||
"energy_threshold": treasure.GetEnergyThreshold(),
|
||||
"countdown_started_at_ms": treasure.GetCountdownStartedAtMs(),
|
||||
"action": map[string]any{
|
||||
"type": "enter_room",
|
||||
"room_id": envelope.GetRoomId(),
|
||||
},
|
||||
}
|
||||
encoded, err := json.Marshal(payload)
|
||||
return string(encoded), err
|
||||
}
|
||||
|
||||
func trimError(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) > 512 {
|
||||
|
||||
@ -46,6 +46,7 @@ type RoomQueryClient interface {
|
||||
GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error)
|
||||
GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error)
|
||||
GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error)
|
||||
GetRoomTreasure(ctx context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error)
|
||||
ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error)
|
||||
}
|
||||
|
||||
@ -194,6 +195,10 @@ func (c *grpcRoomQueryClient) GetRoomSnapshot(ctx context.Context, req *roomv1.G
|
||||
return c.client.GetRoomSnapshot(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomQueryClient) GetRoomTreasure(ctx context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error) {
|
||||
return c.client.GetRoomTreasure(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomQueryClient) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
|
||||
return c.client.ListRoomOnlineUsers(ctx, req)
|
||||
}
|
||||
|
||||
@ -94,6 +94,7 @@ type RoomHandlers struct {
|
||||
GetRoomDetail http.HandlerFunc
|
||||
ListRoomOnlineUsers http.HandlerFunc
|
||||
GetRoomGiftPanel http.HandlerFunc
|
||||
GetRoomTreasure http.HandlerFunc
|
||||
FollowRoom http.HandlerFunc
|
||||
CreateRoom http.HandlerFunc
|
||||
UpdateRoomProfile http.HandlerFunc
|
||||
@ -285,6 +286,7 @@ func (r routes) registerRoomRoutes() {
|
||||
r.profile("/rooms/{room_id}/detail", http.MethodGet, h.GetRoomDetail)
|
||||
r.profile("/rooms/{room_id}/online-users", http.MethodGet, h.ListRoomOnlineUsers)
|
||||
r.profile("/rooms/{room_id}/gift-panel", http.MethodGet, h.GetRoomGiftPanel)
|
||||
r.profile("/rooms/{room_id}/treasure", http.MethodGet, h.GetRoomTreasure)
|
||||
r.profile("/rooms/{room_id}/follow", "", h.FollowRoom)
|
||||
r.profile("/rooms/create", http.MethodPost, h.CreateRoom)
|
||||
r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile)
|
||||
|
||||
@ -279,18 +279,21 @@ type fakeRoomQueryClient struct {
|
||||
lastMyRoom *roomv1.GetMyRoomRequest
|
||||
lastCurrent *roomv1.GetCurrentRoomRequest
|
||||
lastSnapshot *roomv1.GetRoomSnapshotRequest
|
||||
lastTreasure *roomv1.GetRoomTreasureRequest
|
||||
lastOnline *roomv1.ListRoomOnlineUsersRequest
|
||||
resp *roomv1.ListRoomsResponse
|
||||
feedsResp *roomv1.ListRoomsResponse
|
||||
myRoomResp *roomv1.GetMyRoomResponse
|
||||
currentResp *roomv1.GetCurrentRoomResponse
|
||||
snapshotResp *roomv1.GetRoomSnapshotResponse
|
||||
treasureResp *roomv1.GetRoomTreasureResponse
|
||||
onlineResp *roomv1.ListRoomOnlineUsersResponse
|
||||
err error
|
||||
feedsErr error
|
||||
myRoomErr error
|
||||
currentErr error
|
||||
snapshotErr error
|
||||
treasureErr error
|
||||
onlineErr error
|
||||
}
|
||||
|
||||
@ -698,6 +701,17 @@ func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.Get
|
||||
return &roomv1.GetRoomSnapshotResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomQueryClient) GetRoomTreasure(_ context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error) {
|
||||
f.lastTreasure = req
|
||||
if f.treasureErr != nil {
|
||||
return nil, f.treasureErr
|
||||
}
|
||||
if f.treasureResp != nil {
|
||||
return f.treasureResp, nil
|
||||
}
|
||||
return &roomv1.GetRoomTreasureResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomQueryClient) ListRoomOnlineUsers(_ context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
|
||||
f.lastOnline = req
|
||||
if f.onlineErr != nil {
|
||||
|
||||
@ -52,6 +52,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
|
||||
GetRoomDetail: h.getRoomDetail,
|
||||
ListRoomOnlineUsers: h.listRoomOnlineUsers,
|
||||
GetRoomGiftPanel: h.getRoomGiftPanel,
|
||||
GetRoomTreasure: h.getRoomTreasure,
|
||||
FollowRoom: h.handleRoomFollow,
|
||||
CreateRoom: h.createRoom,
|
||||
UpdateRoomProfile: h.updateRoomProfile,
|
||||
|
||||
@ -514,6 +514,29 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req
|
||||
})
|
||||
}
|
||||
|
||||
// getRoomTreasure 返回房间宝箱物料配置和当前进度。
|
||||
func (h *Handler) getRoomTreasure(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.roomQueryClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
roomID := strings.TrimSpace(request.PathValue("room_id"))
|
||||
if !roomid.ValidStringID(roomID) {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
resp, err := h.roomQueryClient.GetRoomTreasure(request.Context(), &roomv1.GetRoomTreasureRequest{
|
||||
Meta: httpkit.RoomMeta(request, roomID, ""),
|
||||
RoomId: roomID,
|
||||
ViewerUserId: auth.UserIDFromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, roomTreasureDataFromProto(resp))
|
||||
}
|
||||
|
||||
// handleRoomFollow 建立或取消当前用户对房间的关注关系。
|
||||
// 关注关系由 room-service 保存,gateway 只负责鉴权用户和 path room_id 的协议转换。
|
||||
func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Request) {
|
||||
|
||||
@ -115,6 +115,64 @@ type roomGiftPanelData struct {
|
||||
QuantityPresets []int32 `json:"quantity_presets"`
|
||||
}
|
||||
|
||||
type roomTreasureData struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Levels []roomTreasureLevelData `json:"levels"`
|
||||
State roomTreasureStateData `json:"state"`
|
||||
BroadcastScope string `json:"broadcast_scope"`
|
||||
OpenDelayMS int64 `json:"open_delay_ms"`
|
||||
BroadcastDelayMS int64 `json:"broadcast_delay_ms"`
|
||||
RewardStackPolicy string `json:"reward_stack_policy"`
|
||||
ServerTimeMS int64 `json:"server_time_ms"`
|
||||
}
|
||||
|
||||
type roomTreasureLevelData struct {
|
||||
Level int32 `json:"level"`
|
||||
EnergyThreshold int64 `json:"energy_threshold"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
AnimationURL string `json:"animation_url,omitempty"`
|
||||
OpeningAnimationURL string `json:"opening_animation_url,omitempty"`
|
||||
OpenedImageURL string `json:"opened_image_url,omitempty"`
|
||||
InRoomRewards []roomTreasureRewardData `json:"in_room_rewards"`
|
||||
Top1Rewards []roomTreasureRewardData `json:"top1_rewards"`
|
||||
IgniterRewards []roomTreasureRewardData `json:"igniter_rewards"`
|
||||
}
|
||||
|
||||
type roomTreasureRewardData struct {
|
||||
RewardItemID string `json:"reward_item_id"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
Weight int64 `json:"weight,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
}
|
||||
|
||||
type roomTreasureStateData struct {
|
||||
CurrentLevel int32 `json:"current_level"`
|
||||
CurrentProgress int64 `json:"current_progress"`
|
||||
EnergyThreshold int64 `json:"energy_threshold"`
|
||||
Status string `json:"status"`
|
||||
CountdownStartedAtMS int64 `json:"countdown_started_at_ms,omitempty"`
|
||||
OpenAtMS int64 `json:"open_at_ms,omitempty"`
|
||||
OpenedAtMS int64 `json:"opened_at_ms,omitempty"`
|
||||
ResetAtMS int64 `json:"reset_at_ms"`
|
||||
Top1UserID string `json:"top1_user_id,omitempty"`
|
||||
IgniterUserID string `json:"igniter_user_id,omitempty"`
|
||||
BoxID string `json:"box_id,omitempty"`
|
||||
ConfigVersion int64 `json:"config_version,omitempty"`
|
||||
LastRewards []roomTreasureRewardGrantData `json:"last_rewards,omitempty"`
|
||||
}
|
||||
|
||||
type roomTreasureRewardGrantData struct {
|
||||
RewardRole string `json:"reward_role"`
|
||||
UserID string `json:"user_id"`
|
||||
RewardItemID string `json:"reward_item_id"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
GrantID string `json:"grant_id,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type roomGiftRecipientData struct {
|
||||
TargetType string `json:"target_type"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
@ -245,6 +303,93 @@ func myRoomDataFromProto(resp *roomv1.GetMyRoomResponse) myRoomData {
|
||||
return data
|
||||
}
|
||||
|
||||
func roomTreasureDataFromProto(resp *roomv1.GetRoomTreasureResponse) roomTreasureData {
|
||||
if resp == nil || resp.GetTreasure() == nil {
|
||||
return roomTreasureData{}
|
||||
}
|
||||
treasure := resp.GetTreasure()
|
||||
return roomTreasureData{
|
||||
Enabled: treasure.GetEnabled(),
|
||||
Levels: roomTreasureLevelsFromProto(treasure.GetLevels()),
|
||||
State: roomTreasureStateFromProto(treasure.GetState()),
|
||||
BroadcastScope: treasure.GetBroadcastScope(),
|
||||
OpenDelayMS: treasure.GetOpenDelayMs(),
|
||||
BroadcastDelayMS: treasure.GetBroadcastDelayMs(),
|
||||
RewardStackPolicy: treasure.GetRewardStackPolicy(),
|
||||
ServerTimeMS: resp.GetServerTimeMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func roomTreasureLevelsFromProto(levels []*roomv1.RoomTreasureLevel) []roomTreasureLevelData {
|
||||
out := make([]roomTreasureLevelData, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
out = append(out, roomTreasureLevelData{
|
||||
Level: level.GetLevel(),
|
||||
EnergyThreshold: level.GetEnergyThreshold(),
|
||||
CoverURL: level.GetCoverUrl(),
|
||||
AnimationURL: level.GetAnimationUrl(),
|
||||
OpeningAnimationURL: level.GetOpeningAnimationUrl(),
|
||||
OpenedImageURL: level.GetOpenedImageUrl(),
|
||||
InRoomRewards: roomTreasureRewardsFromProto(level.GetInRoomRewards()),
|
||||
Top1Rewards: roomTreasureRewardsFromProto(level.GetTop1Rewards()),
|
||||
IgniterRewards: roomTreasureRewardsFromProto(level.GetIgniterRewards()),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomTreasureRewardsFromProto(rewards []*roomv1.RoomTreasureRewardItem) []roomTreasureRewardData {
|
||||
out := make([]roomTreasureRewardData, 0, len(rewards))
|
||||
for _, reward := range rewards {
|
||||
out = append(out, roomTreasureRewardData{
|
||||
RewardItemID: reward.GetRewardItemId(),
|
||||
ResourceGroupID: reward.GetResourceGroupId(),
|
||||
Weight: reward.GetWeight(),
|
||||
DisplayName: reward.GetDisplayName(),
|
||||
IconURL: reward.GetIconUrl(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomTreasureStateFromProto(state *roomv1.RoomTreasureState) roomTreasureStateData {
|
||||
if state == nil {
|
||||
return roomTreasureStateData{}
|
||||
}
|
||||
return roomTreasureStateData{
|
||||
CurrentLevel: state.GetCurrentLevel(),
|
||||
CurrentProgress: state.GetCurrentProgress(),
|
||||
EnergyThreshold: state.GetEnergyThreshold(),
|
||||
Status: state.GetStatus(),
|
||||
CountdownStartedAtMS: state.GetCountdownStartedAtMs(),
|
||||
OpenAtMS: state.GetOpenAtMs(),
|
||||
OpenedAtMS: state.GetOpenedAtMs(),
|
||||
ResetAtMS: state.GetResetAtMs(),
|
||||
Top1UserID: formatOptionalUserID(state.GetTop1UserId()),
|
||||
IgniterUserID: formatOptionalUserID(state.GetIgniterUserId()),
|
||||
BoxID: state.GetBoxId(),
|
||||
ConfigVersion: state.GetConfigVersion(),
|
||||
LastRewards: roomTreasureRewardGrantsFromProto(state.GetLastRewards()),
|
||||
}
|
||||
}
|
||||
|
||||
func roomTreasureRewardGrantsFromProto(rewards []*roomv1.RoomTreasureRewardGrant) []roomTreasureRewardGrantData {
|
||||
out := make([]roomTreasureRewardGrantData, 0, len(rewards))
|
||||
for _, reward := range rewards {
|
||||
out = append(out, roomTreasureRewardGrantData{
|
||||
RewardRole: reward.GetRewardRole(),
|
||||
UserID: formatOptionalUserID(reward.GetUserId()),
|
||||
RewardItemID: reward.GetRewardItemId(),
|
||||
ResourceGroupID: reward.GetResourceGroupId(),
|
||||
DisplayName: reward.GetDisplayName(),
|
||||
IconURL: reward.GetIconUrl(),
|
||||
GrantID: reward.GetGrantId(),
|
||||
Status: reward.GetStatus(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
|
||||
if room == nil {
|
||||
return roomListItemData{}
|
||||
|
||||
@ -37,6 +37,20 @@ room_notice_worker:
|
||||
initial_backoff: 5s
|
||||
max_backoff: 5m
|
||||
|
||||
rocketmq:
|
||||
# Docker 本地默认关闭;接入外部 RocketMQ 后可消费 room_outbox 私有通知事实。
|
||||
enabled: false
|
||||
name_servers: []
|
||||
name_server_domain: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
namespace: ""
|
||||
room_outbox:
|
||||
enabled: false
|
||||
topic: "hyapp_room_outbox"
|
||||
consumer_group: "hyapp-notice-room-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
|
||||
@ -37,6 +37,20 @@ room_notice_worker:
|
||||
initial_backoff: 5s
|
||||
max_backoff: 5m
|
||||
|
||||
rocketmq:
|
||||
enabled: true
|
||||
name_servers:
|
||||
- "TENCENT_ROCKETMQ_NAMESERVER:9876"
|
||||
name_server_domain: ""
|
||||
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
|
||||
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
|
||||
namespace: "hyapp-prod"
|
||||
room_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_room_outbox"
|
||||
consumer_group: "hyapp-notice-room-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
|
||||
@ -37,6 +37,20 @@ room_notice_worker:
|
||||
initial_backoff: 5s
|
||||
max_backoff: 5m
|
||||
|
||||
rocketmq:
|
||||
# 本地默认关闭;开启后 notice 直接消费 room_outbox MQ fanout,仍写 notice_delivery_events 做幂等。
|
||||
enabled: false
|
||||
name_servers: []
|
||||
name_server_domain: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
namespace: ""
|
||||
room_outbox:
|
||||
enabled: false
|
||||
topic: "hyapp_room_outbox"
|
||||
consumer_group: "hyapp-notice-room-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
|
||||
@ -4,16 +4,20 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/grpchealth"
|
||||
"hyapp/pkg/grpcshutdown"
|
||||
"hyapp/pkg/healthhttp"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/rocketmqx"
|
||||
"hyapp/pkg/roommq"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/services/notice-service/internal/config"
|
||||
"hyapp/services/notice-service/internal/modules/roomnotice"
|
||||
@ -34,6 +38,7 @@ type App struct {
|
||||
roomNoticeService *roomnotice.Service
|
||||
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
|
||||
roomWorkerEnabled bool
|
||||
mqConsumers []*rocketmqx.Consumer
|
||||
workerCtx context.Context
|
||||
workerCancel context.CancelFunc
|
||||
workerWG sync.WaitGroup
|
||||
@ -87,6 +92,11 @@ func New(cfg config.Config) (*App, error) {
|
||||
_ = store.Close()
|
||||
return nil, errors.New("room notice worker requires tencent_im.enabled")
|
||||
}
|
||||
if cfg.RocketMQ.RoomOutbox.Enabled && publisher == nil {
|
||||
_ = listener.Close()
|
||||
_ = store.Close()
|
||||
return nil, errors.New("room outbox mq consumer requires tencent_im.enabled")
|
||||
}
|
||||
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("notice-service")))
|
||||
health := grpchealth.NewServingChecker("notice-service", grpchealth.Dependency{
|
||||
@ -101,6 +111,31 @@ func New(cfg config.Config) (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
walletSvc := walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher)
|
||||
roomSvc := roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher)
|
||||
roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker)
|
||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 1)
|
||||
if cfg.RocketMQ.RoomOutbox.Enabled {
|
||||
consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ))
|
||||
if err != nil {
|
||||
_ = listener.Close()
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = roomSvc.ProcessRoomOutboxEnvelope(appcode.WithContext(ctx, envelope.GetAppCode()), envelope, roomOptions)
|
||||
return err
|
||||
}); err != nil {
|
||||
_ = listener.Close()
|
||||
_ = store.Close()
|
||||
return nil, err
|
||||
}
|
||||
mqConsumers = append(mqConsumers, consumer)
|
||||
}
|
||||
workerCtx, workerCancel := context.WithCancel(context.Background())
|
||||
return &App{
|
||||
server: server,
|
||||
@ -108,12 +143,13 @@ func New(cfg config.Config) (*App, error) {
|
||||
health: health,
|
||||
healthHTTP: healthHTTP,
|
||||
store: store,
|
||||
walletNoticeService: walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher),
|
||||
walletNoticeService: walletSvc,
|
||||
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
|
||||
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
|
||||
roomNoticeService: roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher),
|
||||
roomWorkerOptions: roomNoticeWorkerOptions(cfg.RoomNoticeWorker),
|
||||
roomNoticeService: roomSvc,
|
||||
roomWorkerOptions: roomOptions,
|
||||
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
|
||||
mqConsumers: mqConsumers,
|
||||
workerCtx: workerCtx,
|
||||
workerCancel: workerCancel,
|
||||
}, nil
|
||||
@ -123,11 +159,16 @@ func New(cfg config.Config) (*App, error) {
|
||||
func (a *App) Run() error {
|
||||
a.runHealthHTTP()
|
||||
a.health.MarkServing()
|
||||
if err := a.startMQ(); err != nil {
|
||||
a.health.MarkStopped()
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if a.workerCancel != nil {
|
||||
a.workerCancel()
|
||||
}
|
||||
a.workerWG.Wait()
|
||||
a.shutdownMQ()
|
||||
a.health.MarkStopped()
|
||||
}()
|
||||
if a.walletWorkerEnabled && a.walletNoticeService != nil {
|
||||
@ -155,6 +196,7 @@ func (a *App) Close() {
|
||||
a.workerCancel()
|
||||
}
|
||||
a.workerWG.Wait()
|
||||
a.shutdownMQ()
|
||||
grpcshutdown.GracefulStop(a.server, 15*time.Second)
|
||||
a.closeHealthHTTP()
|
||||
if a.store != nil {
|
||||
@ -206,3 +248,40 @@ func roomNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) roomnotice.Roo
|
||||
MaxBackoff: cfg.MaxBackoff,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) startMQ() error {
|
||||
for _, consumer := range a.mqConsumers {
|
||||
if err := consumer.Start(); err != nil {
|
||||
a.shutdownMQ()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) shutdownMQ() {
|
||||
for _, consumer := range a.mqConsumers {
|
||||
if err := consumer.Shutdown(); err != nil {
|
||||
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {
|
||||
return rocketmqx.ConsumerConfig{
|
||||
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.RoomOutbox.ConsumerGroup,
|
||||
MaxReconsumeTimes: cfg.RoomOutbox.ConsumerMaxReconsumeTimes,
|
||||
ConsumeRetryDelay: time.Second,
|
||||
ConsumePullBatch: 32,
|
||||
ConsumePullTimeout: 15 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@ -30,8 +31,10 @@ type Config struct {
|
||||
WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"`
|
||||
// RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。
|
||||
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
Log logx.Config `yaml:"log"`
|
||||
// RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。
|
||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
Log logx.Config `yaml:"log"`
|
||||
}
|
||||
|
||||
// TencentIMConfig 描述服务端调用腾讯云 IM REST API 的配置。
|
||||
@ -73,6 +76,27 @@ type WalletNoticeWorkerConfig struct {
|
||||
MaxBackoff time.Duration `yaml:"max_backoff"`
|
||||
}
|
||||
|
||||
// RocketMQConfig 描述 notice-service 消费房间事实的 MQ 连接。
|
||||
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"`
|
||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||
}
|
||||
|
||||
// RoomOutboxMQConfig 控制 notice 对 room_outbox topic 的消费位点。
|
||||
type RoomOutboxMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Topic string `yaml:"topic"`
|
||||
ConsumerGroup string `yaml:"consumer_group"`
|
||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||
}
|
||||
|
||||
// Default 返回本地开发默认配置。
|
||||
func Default() Config {
|
||||
return Config{
|
||||
@ -112,6 +136,7 @@ func Default() Config {
|
||||
InitialBackoff: 5 * time.Second,
|
||||
MaxBackoff: 5 * time.Minute,
|
||||
},
|
||||
RocketMQ: defaultRocketMQConfig(),
|
||||
Log: logx.Config{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
@ -120,6 +145,18 @@ func Default() Config {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRocketMQConfig() RocketMQConfig {
|
||||
return RocketMQConfig{
|
||||
Enabled: false,
|
||||
RoomOutbox: RoomOutboxMQConfig{
|
||||
Enabled: false,
|
||||
Topic: "hyapp_room_outbox",
|
||||
ConsumerGroup: "hyapp-notice-room-outbox",
|
||||
ConsumerMaxReconsumeTimes: 16,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从独立 config.yaml 读取 notice-service 配置。
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
@ -159,6 +196,11 @@ func Load(path string) (Config, error) {
|
||||
}
|
||||
cfg.WalletNoticeWorker = normalizeWalletNoticeWorker(cfg.WalletNoticeWorker)
|
||||
cfg.RoomNoticeWorker = normalizeWalletNoticeWorker(cfg.RoomNoticeWorker)
|
||||
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.RocketMQ = rocketMQ
|
||||
if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" {
|
||||
cfg.TencentIM.AdminIdentifier = "administrator"
|
||||
}
|
||||
@ -174,6 +216,47 @@ func Load(path string) (Config, error) {
|
||||
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.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" {
|
||||
cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic
|
||||
}
|
||||
if cfg.RoomOutbox.ConsumerGroup = strings.TrimSpace(cfg.RoomOutbox.ConsumerGroup); cfg.RoomOutbox.ConsumerGroup == "" {
|
||||
cfg.RoomOutbox.ConsumerGroup = defaults.RoomOutbox.ConsumerGroup
|
||||
}
|
||||
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
|
||||
}
|
||||
if cfg.RoomOutbox.Enabled {
|
||||
cfg.Enabled = true
|
||||
}
|
||||
if cfg.Enabled {
|
||||
if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" {
|
||||
return RocketMQConfig{}, errors.New("rocketmq name_servers or name_server_domain is required")
|
||||
}
|
||||
if (cfg.AccessKey == "") != (cfg.SecretKey == "") {
|
||||
return RocketMQConfig{}, errors.New("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
|
||||
}
|
||||
|
||||
func normalizeWalletNoticeWorker(cfg WalletNoticeWorkerConfig) WalletNoticeWorkerConfig {
|
||||
def := Default().WalletNoticeWorker
|
||||
if cfg.PollInterval <= 0 {
|
||||
|
||||
23
services/notice-service/internal/config/config_test.go
Normal file
23
services/notice-service/internal/config/config_test.go
Normal file
@ -0,0 +1,23 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadLocalKeepsRocketMQDisabled(t *testing.T) {
|
||||
cfg, err := Load("../../configs/config.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Load local config failed: %v", err)
|
||||
}
|
||||
if cfg.RocketMQ.Enabled || cfg.RocketMQ.RoomOutbox.Enabled {
|
||||
t.Fatalf("local config must not require RocketMQ: %+v", cfg.RocketMQ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTencentExampleEnablesRoomOutboxMQ(t *testing.T) {
|
||||
cfg, err := Load("../../configs/config.tencent.example.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("Load tencent example failed: %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -172,6 +172,48 @@ func (r *MySQLRepository) claimRoomKickCandidate(ctx context.Context, candidate
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
// ClaimRoomKickEnvelope 把 MQ 收到的 room_outbox 信封认领到 notice_delivery_events。
|
||||
func (r *MySQLRepository) ClaimRoomKickEnvelope(ctx context.Context, workerID string, envelope *roomeventsv1.EventEnvelope, lockTTL time.Duration) (RoomKickEvent, bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return RoomKickEvent{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured")
|
||||
}
|
||||
workerID = strings.TrimSpace(workerID)
|
||||
if workerID == "" {
|
||||
return RoomKickEvent{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required")
|
||||
}
|
||||
if lockTTL <= 0 {
|
||||
lockTTL = 30 * time.Second
|
||||
}
|
||||
createdAtMS := int64(0)
|
||||
if envelope != nil {
|
||||
createdAtMS = envelope.GetOccurredAtMs()
|
||||
}
|
||||
event, err := roomKickEventFromEnvelope(envelope, createdAtMS)
|
||||
if err != nil {
|
||||
return RoomKickEvent{}, false, err
|
||||
}
|
||||
now := time.Now()
|
||||
lockUntilMS := now.Add(lockTTL).UnixMilli()
|
||||
nowMs := now.UnixMilli()
|
||||
ctx = appcode.WithContext(ctx, event.AppCode)
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return RoomKickEvent{}, false, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs)
|
||||
if err != nil || !claimed {
|
||||
return RoomKickEvent{}, false, err
|
||||
}
|
||||
event.RetryCount = retryCount
|
||||
if err := tx.Commit(); err != nil {
|
||||
return RoomKickEvent{}, false, err
|
||||
}
|
||||
return event, true, nil
|
||||
}
|
||||
|
||||
func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, candidate roomKickCandidate) (RoomKickEvent, error) {
|
||||
var (
|
||||
event RoomKickEvent
|
||||
@ -202,6 +244,13 @@ func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, can
|
||||
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
|
||||
return RoomKickEvent{}, err
|
||||
}
|
||||
return roomKickEventFromEnvelope(&envelope, event.CreatedAtMS)
|
||||
}
|
||||
|
||||
func roomKickEventFromEnvelope(envelope *roomeventsv1.EventEnvelope, createdAtMS int64) (RoomKickEvent, error) {
|
||||
if envelope == nil {
|
||||
return RoomKickEvent{}, fmt.Errorf("room event envelope is required")
|
||||
}
|
||||
if envelope.GetEventType() != eventRoomUserKicked {
|
||||
return RoomKickEvent{}, fmt.Errorf("unexpected room event type %q", envelope.GetEventType())
|
||||
}
|
||||
@ -209,10 +258,20 @@ func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, can
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return RoomKickEvent{}, err
|
||||
}
|
||||
event.RoomVersion = envelope.GetRoomVersion()
|
||||
event.OccurredAtMS = envelope.GetOccurredAtMs()
|
||||
event.ActorUserID = body.GetActorUserId()
|
||||
event.TargetUserID = body.GetTargetUserId()
|
||||
event := RoomKickEvent{
|
||||
AppCode: appcode.Normalize(envelope.GetAppCode()),
|
||||
EventID: envelope.GetEventId(),
|
||||
EventType: envelope.GetEventType(),
|
||||
RoomID: envelope.GetRoomId(),
|
||||
RoomVersion: envelope.GetRoomVersion(),
|
||||
ActorUserID: body.GetActorUserId(),
|
||||
TargetUserID: body.GetTargetUserId(),
|
||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||
CreatedAtMS: createdAtMS,
|
||||
}
|
||||
if event.AppCode == "" || event.EventID == "" || event.RoomID == "" {
|
||||
return RoomKickEvent{}, fmt.Errorf("room kick event envelope is incomplete")
|
||||
}
|
||||
if event.TargetUserID <= 0 {
|
||||
return RoomKickEvent{}, fmt.Errorf("room kick event target_user_id is invalid")
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/tencentim"
|
||||
)
|
||||
@ -14,6 +15,7 @@ import (
|
||||
// Repository 是 room notice 模块需要的持久化边界。
|
||||
type Repository interface {
|
||||
ClaimRoomKickEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]RoomKickEvent, error)
|
||||
ClaimRoomKickEnvelope(ctx context.Context, workerID string, envelope *roomeventsv1.EventEnvelope, lockTTL time.Duration) (RoomKickEvent, bool, error)
|
||||
MarkRoomKickDelivered(ctx context.Context, event RoomKickEvent, deliveredPayload []byte, nowMs int64) error
|
||||
MarkRoomKickRetryable(ctx context.Context, event RoomKickEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error
|
||||
MarkRoomKickFailed(ctx context.Context, event RoomKickEvent, retryCount int, lastErr string, nowMs int64) error
|
||||
@ -64,6 +66,27 @@ func (s *Service) ProcessRoomKickNotices(ctx context.Context, options RoomNotice
|
||||
return processed, nil
|
||||
}
|
||||
|
||||
// ProcessRoomOutboxEnvelope handles one room_outbox MQ message. Unknown event
|
||||
// types are acknowledged by returning handled=false; poison messages for known
|
||||
// types still return an error so MQ can retry or dead-letter them.
|
||||
func (s *Service) ProcessRoomOutboxEnvelope(ctx context.Context, envelope *roomeventsv1.EventEnvelope, options RoomNoticeWorkerOptions) (bool, error) {
|
||||
if envelope == nil || envelope.GetEventType() != eventRoomUserKicked {
|
||||
return false, nil
|
||||
}
|
||||
options = normalizeRoomNoticeWorkerOptions(options, s.cfg.NodeID)
|
||||
if s.repository == nil {
|
||||
return true, fmt.Errorf("notice repository is not configured")
|
||||
}
|
||||
if s.publisher == nil {
|
||||
return true, fmt.Errorf("notice publisher is not configured")
|
||||
}
|
||||
event, claimed, err := s.repository.ClaimRoomKickEnvelope(ctx, options.WorkerID, envelope, options.LockTTL)
|
||||
if err != nil || !claimed {
|
||||
return true, err
|
||||
}
|
||||
return true, s.publishRoomKickEvent(ctx, event, options)
|
||||
}
|
||||
|
||||
func (s *Service) publishRoomKickEvent(ctx context.Context, event RoomKickEvent, options RoomNoticeWorkerOptions) error {
|
||||
payload, err := roomKickNoticePayload(event)
|
||||
if err != nil {
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
"hyapp/pkg/tencentim"
|
||||
)
|
||||
|
||||
@ -114,6 +115,13 @@ func (r *fakeRepository) ClaimRoomKickEvents(context.Context, string, int, time.
|
||||
return r.events, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) ClaimRoomKickEnvelope(context.Context, string, *roomeventsv1.EventEnvelope, time.Duration) (RoomKickEvent, bool, error) {
|
||||
if len(r.events) == 0 {
|
||||
return RoomKickEvent{}, false, nil
|
||||
}
|
||||
return r.events[0], true, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) MarkRoomKickDelivered(_ context.Context, event RoomKickEvent, _ []byte, _ int64) error {
|
||||
r.deliveredEventID = event.EventID
|
||||
return nil
|
||||
|
||||
@ -48,9 +48,34 @@ presence_stale_after: "2m"
|
||||
presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
room_treasure_open_scan_interval: "1s"
|
||||
rocketmq:
|
||||
# Docker 本地默认关闭;接入外部 RocketMQ 后可把 publish_mode 改为 mq 或 dual。
|
||||
enabled: false
|
||||
name_servers: []
|
||||
name_server_domain: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
namespace: ""
|
||||
send_timeout: "3s"
|
||||
retry: 2
|
||||
room_outbox:
|
||||
enabled: false
|
||||
topic: "hyapp_room_outbox"
|
||||
producer_group: "hyapp-room-outbox-producer"
|
||||
tencent_im_consumer_enabled: false
|
||||
tencent_im_consumer_group: "hyapp-room-im-bridge"
|
||||
consumer_max_reconsume_times: 16
|
||||
treasure_open:
|
||||
enabled: false
|
||||
topic: "hyapp_room_treasure_open"
|
||||
producer_group: "hyapp-room-treasure-open-producer"
|
||||
consumer_group: "hyapp-room-treasure-open"
|
||||
consumer_max_reconsume_times: 16
|
||||
outbox_worker:
|
||||
# Docker 本地默认启动补偿 worker,验证 IM/activity 异步投递、退避和死信闭环。
|
||||
enabled: true
|
||||
publish_mode: "direct"
|
||||
poll_interval: "1s"
|
||||
batch_size: 100
|
||||
publish_timeout: "3s"
|
||||
|
||||
@ -57,9 +57,35 @@ presence_stale_after: "2m"
|
||||
presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
outbox_worker:
|
||||
# 线上默认启动补偿 worker;腾讯云 IM/activity-service 抖动时不回滚 Room Cell 状态。
|
||||
room_treasure_open_scan_interval: "1s"
|
||||
rocketmq:
|
||||
# 线上建议开启:room_outbox 只投 MQ,activity/notice/IM bridge 各自用 consumer group 解耦消费。
|
||||
enabled: true
|
||||
name_servers:
|
||||
- "TENCENT_ROCKETMQ_NAMESERVER:9876"
|
||||
name_server_domain: ""
|
||||
access_key: "TENCENT_ROCKETMQ_ACCESS_KEY"
|
||||
secret_key: "TENCENT_ROCKETMQ_SECRET_KEY"
|
||||
namespace: "hyapp-prod"
|
||||
send_timeout: "3s"
|
||||
retry: 2
|
||||
room_outbox:
|
||||
enabled: true
|
||||
topic: "hyapp_room_outbox"
|
||||
producer_group: "hyapp-room-outbox-producer"
|
||||
tencent_im_consumer_enabled: true
|
||||
tencent_im_consumer_group: "hyapp-room-im-bridge"
|
||||
consumer_max_reconsume_times: 16
|
||||
treasure_open:
|
||||
enabled: true
|
||||
topic: "hyapp_room_treasure_open"
|
||||
producer_group: "hyapp-room-treasure-open-producer"
|
||||
consumer_group: "hyapp-room-treasure-open"
|
||||
consumer_max_reconsume_times: 16
|
||||
outbox_worker:
|
||||
# mq 模式下 outbox worker 只把事实投 MQ,并顺带安排宝箱延迟开箱唤醒。
|
||||
enabled: true
|
||||
publish_mode: "mq"
|
||||
poll_interval: "1s"
|
||||
batch_size: 100
|
||||
publish_timeout: "3s"
|
||||
|
||||
@ -51,9 +51,34 @@ presence_stale_after: "2m"
|
||||
presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
room_treasure_open_scan_interval: "1s"
|
||||
rocketmq:
|
||||
# 本地默认关闭;开启后 room_outbox 可投 MQ,宝箱倒计时用延迟消息到点唤醒。
|
||||
enabled: false
|
||||
name_servers: []
|
||||
name_server_domain: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
namespace: ""
|
||||
send_timeout: "3s"
|
||||
retry: 2
|
||||
room_outbox:
|
||||
enabled: false
|
||||
topic: "hyapp_room_outbox"
|
||||
producer_group: "hyapp-room-outbox-producer"
|
||||
tencent_im_consumer_enabled: false
|
||||
tencent_im_consumer_group: "hyapp-room-im-bridge"
|
||||
consumer_max_reconsume_times: 16
|
||||
treasure_open:
|
||||
enabled: false
|
||||
topic: "hyapp_room_treasure_open"
|
||||
producer_group: "hyapp-room-treasure-open-producer"
|
||||
consumer_group: "hyapp-room-treasure-open"
|
||||
consumer_max_reconsume_times: 16
|
||||
outbox_worker:
|
||||
# room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;失败退避重试,超过上限转死信。
|
||||
enabled: true
|
||||
publish_mode: "direct"
|
||||
poll_interval: "1s"
|
||||
batch_size: 100
|
||||
publish_timeout: "3s"
|
||||
|
||||
@ -174,6 +174,25 @@ CREATE TABLE IF NOT EXISTS room_seat_configs (
|
||||
PRIMARY KEY (app_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间麦位配置表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_treasure_configs (
|
||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用房间宝箱',
|
||||
config_version BIGINT NOT NULL DEFAULT 1 COMMENT '配置版本,后台每次保存递增',
|
||||
energy_source VARCHAR(32) NOT NULL COMMENT '能量来源,默认按送礼价值累计',
|
||||
open_delay_ms BIGINT NOT NULL COMMENT '能量满后开箱倒计时,UTC 毫秒时长',
|
||||
broadcast_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '倒计时开始后是否广播',
|
||||
broadcast_scope VARCHAR(32) NOT NULL COMMENT '广播范围:none/region/global',
|
||||
broadcast_delay_ms BIGINT NOT NULL DEFAULT 0 COMMENT '广播后到开箱的延迟,UTC 毫秒时长',
|
||||
reward_stack_policy VARCHAR(32) NOT NULL COMMENT '同一用户多角色命中奖励时的叠加策略',
|
||||
levels_json JSON NOT NULL COMMENT '7 级宝箱物料、阈值和奖励池配置',
|
||||
gift_energy_rules_json JSON NOT NULL COMMENT '礼物能量倍率、固定能量和排除规则',
|
||||
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后更新管理员 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code),
|
||||
KEY idx_room_treasure_enabled (app_code, enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房宝箱后台配置表';
|
||||
|
||||
INSERT IGNORE INTO room_seat_configs (
|
||||
app_code,
|
||||
allowed_seat_counts,
|
||||
@ -187,3 +206,43 @@ INSERT IGNORE INTO room_seat_configs (
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
INSERT IGNORE INTO room_treasure_configs (
|
||||
app_code,
|
||||
enabled,
|
||||
config_version,
|
||||
energy_source,
|
||||
open_delay_ms,
|
||||
broadcast_enabled,
|
||||
broadcast_scope,
|
||||
broadcast_delay_ms,
|
||||
reward_stack_policy,
|
||||
levels_json,
|
||||
gift_energy_rules_json,
|
||||
updated_by_admin_id,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
'lalu',
|
||||
0,
|
||||
1,
|
||||
'gift_point_added',
|
||||
30000,
|
||||
1,
|
||||
'region',
|
||||
0,
|
||||
'allow_stack',
|
||||
JSON_ARRAY(
|
||||
JSON_OBJECT('level', 1, 'energyThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 2, 'energyThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 3, 'energyThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 4, 'energyThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 5, 'energyThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 6, 'energyThreshold', 21000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 7, 'energyThreshold', 28000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY())
|
||||
),
|
||||
JSON_ARRAY(),
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
@ -14,10 +14,13 @@ import (
|
||||
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/grpchealth"
|
||||
"hyapp/pkg/grpcshutdown"
|
||||
"hyapp/pkg/healthhttp"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/rocketmqx"
|
||||
"hyapp/pkg/roommq"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/pkg/tencentrtc"
|
||||
"hyapp/services/room-service/internal/config"
|
||||
@ -53,6 +56,10 @@ type App struct {
|
||||
health *healthcheck.State
|
||||
// healthHTTP 给 CLB 和发布脚本提供 HTTP readiness,不承载业务请求。
|
||||
healthHTTP *healthhttp.Server
|
||||
// mqProducers 在 outbox worker 启动前打开,承载 fanout 和延迟开箱唤醒。
|
||||
mqProducers []*rocketmqx.Producer
|
||||
// mqConsumers 在 gRPC 服务启动前订阅,承接 room_outbox fanout 和 delayed open。
|
||||
mqConsumers []*rocketmqx.Consumer
|
||||
// workerCtx 统一控制本节点后台 worker,关闭时必须先停止 worker 再释放 MySQL/Redis。
|
||||
workerCtx context.Context
|
||||
// workerCancel 停止 presence stale 和 outbox 补偿 worker。
|
||||
@ -76,10 +83,13 @@ func New(cfg config.Config) (*App, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
_ = walletConn.Close()
|
||||
return nil, err
|
||||
var activityConn *grpc.ClientConn
|
||||
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
|
||||
activityConn, err = grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
_ = walletConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
startupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
@ -88,7 +98,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
// MySQL 是房间恢复的持久来源,启动阶段必须确认连接池可用。
|
||||
repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN, cfg.MySQLMaxOpenConns, cfg.MySQLMaxIdleConns)
|
||||
if err != nil {
|
||||
_ = activityConn.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
@ -97,7 +107,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
// 本地和测试环境可自动建表,线上应通过显式迁移控制结构变更。
|
||||
if err := repository.Migrate(startupCtx); err != nil {
|
||||
_ = repository.Close()
|
||||
_ = activityConn.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
@ -107,53 +117,145 @@ func New(cfg config.Config) (*App, error) {
|
||||
redisClient, err := router.NewRedisClient(startupCtx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
|
||||
if err != nil {
|
||||
_ = repository.Close()
|
||||
_ = activityConn.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
directory := router.NewRedisDirectory(redisClient)
|
||||
roomPublisher := integration.NewNoopRoomEventPublisher()
|
||||
activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second)
|
||||
outboxPublishers := []integration.OutboxPublisher{activityPublisher}
|
||||
outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
|
||||
var rtcUserRemover integration.RTCUserRemover
|
||||
var tencentPublisher *integration.TencentIMPublisher
|
||||
if cfg.TencentIM.Enabled {
|
||||
// 腾讯云 IM 替代自研 IM;room-service 只负责服务端 REST 群消息桥接。
|
||||
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||
if err != nil {
|
||||
_ = repository.Close()
|
||||
_ = activityConn.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = redisClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
tencentPublisher := integration.NewTencentIMPublisher(tencentClient)
|
||||
tencentPublisher = integration.NewTencentIMPublisher(tencentClient)
|
||||
roomPublisher = tencentPublisher
|
||||
outboxPublishers = append(outboxPublishers, tencentPublisher)
|
||||
}
|
||||
if cfg.TencentRTC.Enabled {
|
||||
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
|
||||
if err != nil {
|
||||
_ = repository.Close()
|
||||
_ = activityConn.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = redisClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
rtcUserRemover = rtcClient
|
||||
}
|
||||
|
||||
mqProducers := make([]*rocketmqx.Producer, 0, 2)
|
||||
mqConsumers := make([]*rocketmqx.Consumer, 0, 2)
|
||||
var treasureOpenScheduler integration.RoomTreasureOpenScheduler
|
||||
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
|
||||
if activityConn != nil {
|
||||
activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second)
|
||||
outboxPublishers = append(outboxPublishers, activityPublisher)
|
||||
}
|
||||
if tencentPublisher != nil {
|
||||
outboxPublishers = append(outboxPublishers, tencentPublisher)
|
||||
}
|
||||
}
|
||||
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeMQ || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
|
||||
outboxProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ProducerGroup))
|
||||
if err != nil {
|
||||
_ = repository.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = redisClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
mqProducers = append(mqProducers, outboxProducer)
|
||||
outboxPublishers = append(outboxPublishers, integration.NewRocketMQOutboxPublisher(outboxProducer, cfg.RocketMQ.RoomOutbox.Topic))
|
||||
}
|
||||
if cfg.RocketMQ.TreasureOpen.Enabled {
|
||||
treasureProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ProducerGroup))
|
||||
if err != nil {
|
||||
_ = repository.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = redisClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
mqProducers = append(mqProducers, treasureProducer)
|
||||
treasureOpenScheduler = integration.NewRocketMQTreasureOpenScheduler(treasureProducer, cfg.RocketMQ.TreasureOpen.Topic)
|
||||
}
|
||||
outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...)
|
||||
|
||||
// 领域服务只依赖接口,App 层负责选择 MySQL/Redis/gRPC 具体实现。
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: cfg.NodeID,
|
||||
LeaseTTL: cfg.LeaseTTL,
|
||||
RankLimit: cfg.RankLimit,
|
||||
SnapshotEveryN: cfg.SnapshotEveryN,
|
||||
PresenceStaleAfter: cfg.PresenceStaleAfter,
|
||||
MicPublishTimeout: cfg.MicPublishTimeout,
|
||||
RTCUserRemover: rtcUserRemover,
|
||||
NodeID: cfg.NodeID,
|
||||
LeaseTTL: cfg.LeaseTTL,
|
||||
RankLimit: cfg.RankLimit,
|
||||
SnapshotEveryN: cfg.SnapshotEveryN,
|
||||
PresenceStaleAfter: cfg.PresenceStaleAfter,
|
||||
MicPublishTimeout: cfg.MicPublishTimeout,
|
||||
RTCUserRemover: rtcUserRemover,
|
||||
RoomTreasureOpenScheduler: treasureOpenScheduler,
|
||||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher)
|
||||
if cfg.RocketMQ.TreasureOpen.Enabled {
|
||||
treasureConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ConsumerGroup, cfg.RocketMQ.TreasureOpen.ConsumerMaxReconsumeTimes))
|
||||
if err != nil {
|
||||
_ = repository.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = redisClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := treasureConsumer.Subscribe(cfg.RocketMQ.TreasureOpen.Topic, roommq.TagRoomTreasureOpenDue, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||
due, err := roommq.DecodeRoomTreasureOpenDueMessage(message.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return svc.HandleRoomTreasureOpenDue(ctx, due)
|
||||
}); err != nil {
|
||||
_ = repository.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = redisClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
mqConsumers = append(mqConsumers, treasureConsumer)
|
||||
}
|
||||
if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled && tencentPublisher == nil {
|
||||
_ = repository.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = redisClient.Close()
|
||||
return nil, errors.New("rocketmq room_outbox tencent_im consumer requires tencent_im.enabled")
|
||||
}
|
||||
if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled && tencentPublisher != nil {
|
||||
imConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.TencentIMConsumerGroup, cfg.RocketMQ.RoomOutbox.ConsumerMaxReconsumeTimes))
|
||||
if err != nil {
|
||||
_ = repository.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = redisClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := imConsumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
|
||||
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tencentPublisher.PublishOutboxEvent(appcode.WithContext(ctx, envelope.GetAppCode()), envelope)
|
||||
}); err != nil {
|
||||
_ = repository.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = redisClient.Close()
|
||||
return nil, err
|
||||
}
|
||||
mqConsumers = append(mqConsumers, imConsumer)
|
||||
}
|
||||
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service")))
|
||||
roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout))
|
||||
@ -165,7 +267,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(healthState))
|
||||
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, healthState)
|
||||
if err != nil {
|
||||
_ = activityConn.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = repository.Close()
|
||||
_ = redisClient.Close()
|
||||
@ -176,7 +278,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
listener, err := net.Listen("tcp", cfg.GRPCAddr)
|
||||
if err != nil {
|
||||
_ = healthHTTP.Close(context.Background())
|
||||
_ = activityConn.Close()
|
||||
closeClientConn(activityConn)
|
||||
_ = walletConn.Close()
|
||||
_ = repository.Close()
|
||||
_ = redisClient.Close()
|
||||
@ -196,6 +298,8 @@ func New(cfg config.Config) (*App, error) {
|
||||
nodeRegistry: directory,
|
||||
health: healthState,
|
||||
healthHTTP: healthHTTP,
|
||||
mqProducers: mqProducers,
|
||||
mqConsumers: mqConsumers,
|
||||
workerCtx: workerCtx,
|
||||
workerCancel: workerCancel,
|
||||
}, nil
|
||||
@ -210,11 +314,16 @@ func (a *App) Run() error {
|
||||
a.health.MarkGRPCStopped()
|
||||
return err
|
||||
}
|
||||
if err := a.startMQ(); err != nil {
|
||||
a.health.MarkGRPCStopped()
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if a.workerCancel != nil {
|
||||
a.workerCancel()
|
||||
}
|
||||
a.workerWG.Wait()
|
||||
a.shutdownMQ()
|
||||
}()
|
||||
a.workerWG.Go(func() {
|
||||
a.runNodeRegistryHeartbeat(a.workerCtx)
|
||||
@ -231,6 +340,12 @@ func (a *App) Run() error {
|
||||
a.service.RunMicPublishTimeoutWorker(a.workerCtx, a.cfg.MicPublishScanInterval)
|
||||
})
|
||||
}
|
||||
if a.cfg.RoomTreasureOpenScanInterval > 0 {
|
||||
// 宝箱开箱仍通过 Room Cell 命令链路提交,worker 只负责触发到点的系统命令。
|
||||
a.workerWG.Go(func() {
|
||||
a.service.RunRoomTreasureOpenWorker(a.workerCtx, a.cfg.RoomTreasureOpenScanInterval)
|
||||
})
|
||||
}
|
||||
if a.cfg.OutboxWorker.Enabled {
|
||||
a.workerWG.Go(func() {
|
||||
a.service.RunOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{
|
||||
@ -268,6 +383,7 @@ func (a *App) Close() {
|
||||
a.workerCancel()
|
||||
}
|
||||
a.workerWG.Wait()
|
||||
a.shutdownMQ()
|
||||
// GracefulStop 让已进入的 gRPC 请求自然结束。
|
||||
grpcshutdown.GracefulStop(a.grpcServer, 15*time.Second)
|
||||
if a.service != nil {
|
||||
@ -365,3 +481,70 @@ func (a *App) closeHealthHTTP() {
|
||||
defer cancel()
|
||||
_ = a.healthHTTP.Close(ctx)
|
||||
}
|
||||
|
||||
func (a *App) startMQ() error {
|
||||
for _, producer := range a.mqProducers {
|
||||
if err := producer.Start(); err != nil {
|
||||
a.shutdownMQ()
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, consumer := range a.mqConsumers {
|
||||
if err := consumer.Start(); err != nil {
|
||||
a.shutdownMQ()
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) shutdownMQ() {
|
||||
for _, consumer := range a.mqConsumers {
|
||||
if err := consumer.Shutdown(); err != nil {
|
||||
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
for _, producer := range a.mqProducers {
|
||||
if err := producer.Shutdown(); err != nil {
|
||||
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig {
|
||||
return rocketmqx.ProducerConfig{
|
||||
EndpointConfig: rocketMQEndpointConfig(cfg),
|
||||
GroupName: groupName,
|
||||
SendTimeout: cfg.SendTimeout,
|
||||
Retry: cfg.Retry,
|
||||
}
|
||||
}
|
||||
|
||||
func rocketMQConsumerConfig(cfg config.RocketMQConfig, groupName string, maxReconsumeTimes int32) rocketmqx.ConsumerConfig {
|
||||
return rocketmqx.ConsumerConfig{
|
||||
EndpointConfig: rocketMQEndpointConfig(cfg),
|
||||
GroupName: groupName,
|
||||
MaxReconsumeTimes: maxReconsumeTimes,
|
||||
ConsumeRetryDelay: time.Second,
|
||||
ConsumePullBatch: 32,
|
||||
ConsumePullTimeout: 15 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func rocketMQEndpointConfig(cfg config.RocketMQConfig) rocketmqx.EndpointConfig {
|
||||
return rocketmqx.EndpointConfig{
|
||||
NameServers: cfg.NameServers,
|
||||
NameServerDomain: cfg.NameServerDomain,
|
||||
AccessKey: cfg.AccessKey,
|
||||
SecretKey: cfg.SecretKey,
|
||||
SecurityToken: cfg.SecurityToken,
|
||||
Namespace: cfg.Namespace,
|
||||
VIPChannel: cfg.VIPChannel,
|
||||
}
|
||||
}
|
||||
|
||||
func closeClientConn(conn *grpc.ClientConn) {
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
@ -14,6 +14,12 @@ import (
|
||||
"hyapp/pkg/tencentrtc"
|
||||
)
|
||||
|
||||
const (
|
||||
OutboxPublishModeDirect = "direct"
|
||||
OutboxPublishModeMQ = "mq"
|
||||
OutboxPublishModeDual = "dual"
|
||||
)
|
||||
|
||||
// Config 描述 room-service 进程启动所需的最小配置。
|
||||
type Config struct {
|
||||
// ServiceName 是日志和部署识别使用的稳定服务名。
|
||||
@ -70,6 +76,10 @@ type Config struct {
|
||||
MicPublishTimeout time.Duration `yaml:"mic_publish_timeout"`
|
||||
// MicPublishScanInterval 是本节点扫描 pending_publish 超时麦位的周期。
|
||||
MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"`
|
||||
// RoomTreasureOpenScanInterval 是本节点扫描到点开箱宝箱的周期。
|
||||
RoomTreasureOpenScanInterval time.Duration `yaml:"room_treasure_open_scan_interval"`
|
||||
// RocketMQ 承载 room_outbox 事件分发和语音房宝箱延迟开箱唤醒。
|
||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||
// OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。
|
||||
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
||||
// Log 控制 stdout 结构化日志格式、等级和 access body 策略。
|
||||
@ -80,6 +90,8 @@ type Config struct {
|
||||
type OutboxWorkerConfig struct {
|
||||
// Enabled 为 false 时不启动补偿 worker,测试或临时止血可用。
|
||||
Enabled bool `yaml:"enabled"`
|
||||
// PublishMode 控制 room_outbox 发往 direct、mq 或 dual,避免 MQ 消费者与直接投递重复发同一条 IM。
|
||||
PublishMode string `yaml:"publish_mode"`
|
||||
// PollInterval 是两轮 pending outbox 扫描之间的固定间隔。
|
||||
PollInterval time.Duration `yaml:"poll_interval"`
|
||||
// BatchSize 是单轮最多处理的 pending outbox 数,防止全表扫描。
|
||||
@ -96,6 +108,41 @@ type OutboxWorkerConfig struct {
|
||||
MaxBackoff time.Duration `yaml:"max_backoff"`
|
||||
}
|
||||
|
||||
// RocketMQConfig 描述 room-service 使用的 RocketMQ 集群和业务 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"`
|
||||
SendTimeout time.Duration `yaml:"send_timeout"`
|
||||
Retry int `yaml:"retry"`
|
||||
RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"`
|
||||
TreasureOpen TreasureOpenMQConfig `yaml:"treasure_open"`
|
||||
}
|
||||
|
||||
// RoomOutboxMQConfig 控制 room_outbox fanout topic。
|
||||
type RoomOutboxMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Topic string `yaml:"topic"`
|
||||
ProducerGroup string `yaml:"producer_group"`
|
||||
TencentIMConsumerEnabled bool `yaml:"tencent_im_consumer_enabled"`
|
||||
TencentIMConsumerGroup string `yaml:"tencent_im_consumer_group"`
|
||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||
}
|
||||
|
||||
// TreasureOpenMQConfig 控制宝箱延迟开箱唤醒 topic。
|
||||
type TreasureOpenMQConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Topic string `yaml:"topic"`
|
||||
ProducerGroup string `yaml:"producer_group"`
|
||||
ConsumerGroup string `yaml:"consumer_group"`
|
||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||
}
|
||||
|
||||
// TencentIMConfig 描述 room-service 调用腾讯云 IM REST API 的配置。
|
||||
type TencentIMConfig struct {
|
||||
// Enabled 控制是否启用腾讯云 IM REST 桥接;关闭时只保留 Room Cell 内部提交。
|
||||
@ -200,17 +247,19 @@ func Default() Config {
|
||||
Endpoint: tencentrtc.DefaultRESTEndpoint,
|
||||
RequestTimeout: 5 * time.Second,
|
||||
},
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
MySQLMaxOpenConns: 20,
|
||||
MySQLMaxIdleConns: 10,
|
||||
MySQLAutoMigrate: true,
|
||||
RedisAddr: "127.0.0.1:13379",
|
||||
RedisDB: 0,
|
||||
PresenceStaleAfter: 2 * time.Minute,
|
||||
PresenceStaleScanInterval: 30 * time.Second,
|
||||
MicPublishTimeout: 15 * time.Second,
|
||||
MicPublishScanInterval: time.Second,
|
||||
OutboxWorker: defaultOutboxWorkerConfig(),
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC",
|
||||
MySQLMaxOpenConns: 20,
|
||||
MySQLMaxIdleConns: 10,
|
||||
MySQLAutoMigrate: true,
|
||||
RedisAddr: "127.0.0.1:13379",
|
||||
RedisDB: 0,
|
||||
PresenceStaleAfter: 2 * time.Minute,
|
||||
PresenceStaleScanInterval: 30 * time.Second,
|
||||
MicPublishTimeout: 15 * time.Second,
|
||||
MicPublishScanInterval: time.Second,
|
||||
RoomTreasureOpenScanInterval: time.Second,
|
||||
RocketMQ: defaultRocketMQConfig(),
|
||||
OutboxWorker: defaultOutboxWorkerConfig(),
|
||||
Log: logx.Config{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
@ -223,6 +272,7 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
|
||||
// 默认启动补偿 worker,使用退避和死信避免外部故障刷屏。
|
||||
return OutboxWorkerConfig{
|
||||
Enabled: true,
|
||||
PublishMode: OutboxPublishModeDirect,
|
||||
PollInterval: time.Second,
|
||||
BatchSize: 100,
|
||||
PublishTimeout: 3 * time.Second,
|
||||
@ -233,6 +283,29 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func defaultRocketMQConfig() RocketMQConfig {
|
||||
return RocketMQConfig{
|
||||
Enabled: false,
|
||||
SendTimeout: 3 * time.Second,
|
||||
Retry: 2,
|
||||
RoomOutbox: RoomOutboxMQConfig{
|
||||
Enabled: false,
|
||||
Topic: "hyapp_room_outbox",
|
||||
ProducerGroup: "hyapp-room-outbox-producer",
|
||||
TencentIMConsumerEnabled: false,
|
||||
TencentIMConsumerGroup: "hyapp-room-im-bridge",
|
||||
ConsumerMaxReconsumeTimes: 16,
|
||||
},
|
||||
TreasureOpen: TreasureOpenMQConfig{
|
||||
Enabled: false,
|
||||
Topic: "hyapp_room_treasure_open",
|
||||
ProducerGroup: "hyapp-room-treasure-open-producer",
|
||||
ConsumerGroup: "hyapp-room-treasure-open",
|
||||
ConsumerMaxReconsumeTimes: 16,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize 补齐动态配置默认值,并拒绝会改变补偿语义的未知策略。
|
||||
func Normalize(cfg Config) (Config, error) {
|
||||
// 配置归一化只处理需要跨环境保持语义的字段,其他默认值由 Default 提供。
|
||||
@ -301,6 +374,11 @@ func Normalize(cfg Config) (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.OutboxWorker = outboxWorker
|
||||
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ, cfg.OutboxWorker.PublishMode)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.RocketMQ = rocketMQ
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@ -350,10 +428,86 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er
|
||||
// 当前只实现指数退避;未知策略直接启动失败,避免误以为退避或死信已生效。
|
||||
return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker retry_strategy %q", cfg.RetryStrategy)
|
||||
}
|
||||
cfg.PublishMode = strings.ToLower(strings.TrimSpace(cfg.PublishMode))
|
||||
if cfg.PublishMode == "" {
|
||||
cfg.PublishMode = defaults.PublishMode
|
||||
}
|
||||
switch cfg.PublishMode {
|
||||
case OutboxPublishModeDirect, OutboxPublishModeMQ, OutboxPublishModeDual:
|
||||
default:
|
||||
return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker publish_mode %q", cfg.PublishMode)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (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.SendTimeout <= 0 {
|
||||
cfg.SendTimeout = defaults.SendTimeout
|
||||
}
|
||||
if cfg.Retry < 0 {
|
||||
cfg.Retry = defaults.Retry
|
||||
}
|
||||
if cfg.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" {
|
||||
cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic
|
||||
}
|
||||
if cfg.RoomOutbox.ProducerGroup = strings.TrimSpace(cfg.RoomOutbox.ProducerGroup); cfg.RoomOutbox.ProducerGroup == "" {
|
||||
cfg.RoomOutbox.ProducerGroup = defaults.RoomOutbox.ProducerGroup
|
||||
}
|
||||
if cfg.RoomOutbox.TencentIMConsumerGroup = strings.TrimSpace(cfg.RoomOutbox.TencentIMConsumerGroup); cfg.RoomOutbox.TencentIMConsumerGroup == "" {
|
||||
cfg.RoomOutbox.TencentIMConsumerGroup = defaults.RoomOutbox.TencentIMConsumerGroup
|
||||
}
|
||||
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
|
||||
}
|
||||
if cfg.TreasureOpen.Topic = strings.TrimSpace(cfg.TreasureOpen.Topic); cfg.TreasureOpen.Topic == "" {
|
||||
cfg.TreasureOpen.Topic = defaults.TreasureOpen.Topic
|
||||
}
|
||||
if cfg.TreasureOpen.ProducerGroup = strings.TrimSpace(cfg.TreasureOpen.ProducerGroup); cfg.TreasureOpen.ProducerGroup == "" {
|
||||
cfg.TreasureOpen.ProducerGroup = defaults.TreasureOpen.ProducerGroup
|
||||
}
|
||||
if cfg.TreasureOpen.ConsumerGroup = strings.TrimSpace(cfg.TreasureOpen.ConsumerGroup); cfg.TreasureOpen.ConsumerGroup == "" {
|
||||
cfg.TreasureOpen.ConsumerGroup = defaults.TreasureOpen.ConsumerGroup
|
||||
}
|
||||
if cfg.TreasureOpen.ConsumerMaxReconsumeTimes <= 0 {
|
||||
cfg.TreasureOpen.ConsumerMaxReconsumeTimes = defaults.TreasureOpen.ConsumerMaxReconsumeTimes
|
||||
}
|
||||
if cfg.RoomOutbox.Enabled || cfg.TreasureOpen.Enabled || cfg.RoomOutbox.TencentIMConsumerEnabled {
|
||||
cfg.Enabled = true
|
||||
}
|
||||
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {
|
||||
if !cfg.RoomOutbox.Enabled {
|
||||
return RocketMQConfig{}, fmt.Errorf("outbox_worker publish_mode %q requires rocketmq.room_outbox.enabled", publishMode)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// Load 从独立 config.yaml 读取 room-service 配置。
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
|
||||
@ -34,9 +34,12 @@ func TestLoad(t *testing.T) {
|
||||
if cfg.WalletServiceAddr != "127.0.0.1:13004" {
|
||||
t.Fatalf("unexpected wallet addr: %s", cfg.WalletServiceAddr)
|
||||
}
|
||||
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.PublishTimeout != 3*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute {
|
||||
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PublishMode != OutboxPublishModeDirect || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.PublishTimeout != 3*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute {
|
||||
t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker)
|
||||
}
|
||||
if cfg.RocketMQ.Enabled || cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.TreasureOpen.Enabled {
|
||||
t.Fatalf("local config must not require RocketMQ: %+v", cfg.RocketMQ)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadTencentExample 验证线上腾讯云 IM 模板能覆盖本地默认配置。
|
||||
@ -67,6 +70,9 @@ func TestLoadTencentExample(t *testing.T) {
|
||||
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 {
|
||||
t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker)
|
||||
}
|
||||
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.TreasureOpen.Enabled {
|
||||
t.Fatalf("tencent example must route room outbox and treasure open through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsUnknownOutboxRetryStrategy(t *testing.T) {
|
||||
@ -78,6 +84,24 @@ func TestNormalizeRejectsUnknownOutboxRetryStrategy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsUnknownOutboxPublishMode(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.OutboxWorker.PublishMode = "random"
|
||||
|
||||
if _, err := Normalize(cfg); err == nil {
|
||||
t.Fatal("Normalize should reject unsupported outbox publish_mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsMQPublishModeWithoutRoomOutboxMQ(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.OutboxWorker.PublishMode = OutboxPublishModeMQ
|
||||
|
||||
if _, err := Normalize(cfg); err == nil {
|
||||
t.Fatal("Normalize should reject mq publish_mode without rocketmq.room_outbox.enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
|
||||
cfg := Default()
|
||||
cfg.OutboxWorker.PollInterval = 0
|
||||
@ -92,7 +116,7 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize failed: %v", err)
|
||||
}
|
||||
if normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute {
|
||||
if normalized.OutboxWorker.PublishMode != OutboxPublishModeDirect || normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute {
|
||||
t.Fatalf("outbox worker defaults not restored: %+v", normalized.OutboxWorker)
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,8 @@ import (
|
||||
type WalletClient interface {
|
||||
// DebitGift 执行送礼扣费;失败时 room-service 不进入 Room Cell 状态提交。
|
||||
DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error)
|
||||
// GrantResourceGroup 发放宝箱奖励资源组;命令 ID 必须由 room-service 按宝箱结算事实生成。
|
||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
}
|
||||
|
||||
// RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。
|
||||
@ -34,3 +36,20 @@ type OutboxPublisher interface {
|
||||
// PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 转为 retryable 状态重试。
|
||||
PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error
|
||||
}
|
||||
|
||||
// RoomTreasureOpenSchedule 是 RoomTreasureCountdownStarted 派生出的延迟开箱唤醒任务。
|
||||
type RoomTreasureOpenSchedule struct {
|
||||
AppCode string
|
||||
RoomID string
|
||||
BoxID string
|
||||
Level int32
|
||||
OpenAtMS int64
|
||||
ResetAtMS int64
|
||||
CommandID string
|
||||
}
|
||||
|
||||
// RoomTreasureOpenScheduler 抽象“到 open_at_ms 唤醒 room-service”的外部延迟通道。
|
||||
type RoomTreasureOpenScheduler interface {
|
||||
// ScheduleRoomTreasureOpen 只负责安排唤醒;真正开箱仍由 room-service 命令链路校验并提交。
|
||||
ScheduleRoomTreasureOpen(ctx context.Context, schedule RoomTreasureOpenSchedule) error
|
||||
}
|
||||
|
||||
@ -26,3 +26,9 @@ func (c *grpcWalletClient) DebitGift(ctx context.Context, req *walletv1.DebitGif
|
||||
// 这里不吞错,账务失败必须原样阻断 SendGift 房间状态变更。
|
||||
return c.client.DebitGift(ctx, req)
|
||||
}
|
||||
|
||||
// GrantResourceGroup 直接转调 wallet-service 资源组发放接口。
|
||||
func (c *grpcWalletClient) GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
|
||||
// 宝箱奖励以 resource group 为后台配置单位,wallet-service 负责展开资产和权益。
|
||||
return c.client.GrantResourceGroup(ctx, req)
|
||||
}
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
"hyapp/pkg/rocketmqx"
|
||||
"hyapp/pkg/roommq"
|
||||
)
|
||||
|
||||
// RocketMQOutboxPublisher publishes durable room_outbox facts to a RocketMQ topic.
|
||||
type RocketMQOutboxPublisher struct {
|
||||
producer *rocketmqx.Producer
|
||||
topic string
|
||||
}
|
||||
|
||||
// NewRocketMQOutboxPublisher creates a room_outbox fanout publisher.
|
||||
func NewRocketMQOutboxPublisher(producer *rocketmqx.Producer, topic string) *RocketMQOutboxPublisher {
|
||||
return &RocketMQOutboxPublisher{producer: producer, topic: topic}
|
||||
}
|
||||
|
||||
// PublishOutboxEvent implements OutboxPublisher.
|
||||
func (p *RocketMQOutboxPublisher) PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
|
||||
body, err := roommq.EncodeRoomOutboxMessage(envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return p.producer.SendSync(ctx, rocketmqx.Message{
|
||||
Topic: p.topic,
|
||||
Tag: roommq.TagRoomOutboxEvent,
|
||||
Keys: []string{envelope.GetEventId(), envelope.GetRoomId()},
|
||||
Body: body,
|
||||
Properties: map[string]string{
|
||||
"message_type": roommq.MessageTypeRoomOutboxEvent,
|
||||
"app_code": envelope.GetAppCode(),
|
||||
"room_id": envelope.GetRoomId(),
|
||||
"event_id": envelope.GetEventId(),
|
||||
"event_type": envelope.GetEventType(),
|
||||
"room_version": strconv.FormatInt(envelope.GetRoomVersion(), 10),
|
||||
"occurred_at_ms": strconv.FormatInt(envelope.GetOccurredAtMs(), 10),
|
||||
"source_database": "hyapp_room",
|
||||
"source_table": "room_outbox",
|
||||
"delivery_version": "1",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// RocketMQTreasureOpenScheduler publishes exact-time treasure wakeup messages.
|
||||
type RocketMQTreasureOpenScheduler struct {
|
||||
producer *rocketmqx.Producer
|
||||
topic string
|
||||
}
|
||||
|
||||
// NewRocketMQTreasureOpenScheduler creates the delayed open scheduler.
|
||||
func NewRocketMQTreasureOpenScheduler(producer *rocketmqx.Producer, topic string) *RocketMQTreasureOpenScheduler {
|
||||
return &RocketMQTreasureOpenScheduler{producer: producer, topic: topic}
|
||||
}
|
||||
|
||||
// ScheduleRoomTreasureOpen implements RoomTreasureOpenScheduler.
|
||||
func (s *RocketMQTreasureOpenScheduler) ScheduleRoomTreasureOpen(ctx context.Context, schedule RoomTreasureOpenSchedule) error {
|
||||
body, err := roommq.EncodeRoomTreasureOpenDueMessage(roommq.RoomTreasureOpenDueMessage{
|
||||
AppCode: schedule.AppCode,
|
||||
RoomID: schedule.RoomID,
|
||||
BoxID: schedule.BoxID,
|
||||
Level: schedule.Level,
|
||||
OpenAtMS: schedule.OpenAtMS,
|
||||
ResetAtMS: schedule.ResetAtMS,
|
||||
CommandID: schedule.CommandID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.producer.SendSync(ctx, rocketmqx.Message{
|
||||
Topic: s.topic,
|
||||
Tag: roommq.TagRoomTreasureOpenDue,
|
||||
Keys: []string{schedule.BoxID, schedule.RoomID},
|
||||
Body: body,
|
||||
DeliveryAtMS: schedule.OpenAtMS,
|
||||
Properties: map[string]string{
|
||||
"message_type": roommq.MessageTypeRoomTreasureOpenDue,
|
||||
"app_code": schedule.AppCode,
|
||||
"room_id": schedule.RoomID,
|
||||
"box_id": schedule.BoxID,
|
||||
"level": strconv.FormatInt(int64(schedule.Level), 10),
|
||||
"open_at_ms": strconv.FormatInt(schedule.OpenAtMS, 10),
|
||||
"reset_at_ms": strconv.FormatInt(schedule.ResetAtMS, 10),
|
||||
},
|
||||
})
|
||||
}
|
||||
@ -2,6 +2,7 @@ package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
@ -257,6 +258,85 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
"billing_receipt_id": body.GetBillingReceiptId(),
|
||||
}
|
||||
return base, true, nil
|
||||
case "RoomTreasureProgressChanged":
|
||||
var body roomeventsv1.RoomTreasureProgressChanged
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetSenderUserId()
|
||||
base.Attributes = map[string]string{
|
||||
"box_id": body.GetBoxId(),
|
||||
"level": fmt.Sprintf("%d", body.GetLevel()),
|
||||
"added_energy": fmt.Sprintf("%d", body.GetAddedEnergy()),
|
||||
"effective_added_energy": fmt.Sprintf("%d", body.GetEffectiveAddedEnergy()),
|
||||
"overflow_energy": fmt.Sprintf("%d", body.GetOverflowEnergy()),
|
||||
"current_progress": fmt.Sprintf("%d", body.GetCurrentProgress()),
|
||||
"energy_threshold": fmt.Sprintf("%d", body.GetEnergyThreshold()),
|
||||
"status": body.GetStatus(),
|
||||
"reset_at_ms": fmt.Sprintf("%d", body.GetResetAtMs()),
|
||||
"gift_id": body.GetGiftId(),
|
||||
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
|
||||
"command_id": body.GetCommandId(),
|
||||
}
|
||||
return base, true, nil
|
||||
case "RoomTreasureCountdownStarted":
|
||||
var body roomeventsv1.RoomTreasureCountdownStarted
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetIgniterUserId()
|
||||
base.TargetUserID = body.GetTop1UserId()
|
||||
base.Attributes = map[string]string{
|
||||
"box_id": body.GetBoxId(),
|
||||
"level": fmt.Sprintf("%d", body.GetLevel()),
|
||||
"current_progress": fmt.Sprintf("%d", body.GetCurrentProgress()),
|
||||
"energy_threshold": fmt.Sprintf("%d", body.GetEnergyThreshold()),
|
||||
"countdown_start_ms": fmt.Sprintf("%d", body.GetCountdownStartedAtMs()),
|
||||
"open_at_ms": fmt.Sprintf("%d", body.GetOpenAtMs()),
|
||||
"broadcast_scope": body.GetBroadcastScope(),
|
||||
"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
|
||||
"top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()),
|
||||
"igniter_user_id": fmt.Sprintf("%d", body.GetIgniterUserId()),
|
||||
"command_id": body.GetCommandId(),
|
||||
}
|
||||
return base, true, nil
|
||||
case "RoomTreasureOpened":
|
||||
var body roomeventsv1.RoomTreasureOpened
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
rewardsJSON, err := json.Marshal(body.GetRewards())
|
||||
if err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetIgniterUserId()
|
||||
base.TargetUserID = body.GetTop1UserId()
|
||||
base.Attributes = map[string]string{
|
||||
"box_id": body.GetBoxId(),
|
||||
"level": fmt.Sprintf("%d", body.GetLevel()),
|
||||
"next_level": fmt.Sprintf("%d", body.GetNextLevel()),
|
||||
"opened_at_ms": fmt.Sprintf("%d", body.GetOpenedAtMs()),
|
||||
"reset_at_ms": fmt.Sprintf("%d", body.GetResetAtMs()),
|
||||
"top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()),
|
||||
"igniter_user_id": fmt.Sprintf("%d", body.GetIgniterUserId()),
|
||||
"rewards_json": string(rewardsJSON),
|
||||
}
|
||||
return base, true, nil
|
||||
case "RoomTreasureRewardGranted":
|
||||
var body roomeventsv1.RoomTreasureRewardGranted
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
rewardsJSON, err := json.Marshal(body.GetRewards())
|
||||
if err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.Attributes = map[string]string{
|
||||
"box_id": body.GetBoxId(),
|
||||
"level": fmt.Sprintf("%d", body.GetLevel()),
|
||||
"rewards_json": string(rewardsJSON),
|
||||
}
|
||||
return base, true, nil
|
||||
default:
|
||||
// RoomCreated/HeatChanged/RankChanged 不单独推客户端,避免一条命令产生多条系统消息。
|
||||
return tencentim.RoomEvent{}, false, nil
|
||||
@ -292,6 +372,14 @@ func eventTypeForClient(eventType string) string {
|
||||
return "room_user_unbanned"
|
||||
case "RoomGiftSent":
|
||||
return "room_gift_sent"
|
||||
case "RoomTreasureProgressChanged":
|
||||
return "room_treasure_progress_changed"
|
||||
case "RoomTreasureCountdownStarted":
|
||||
return "room_treasure_countdown_started"
|
||||
case "RoomTreasureOpened":
|
||||
return "room_treasure_opened"
|
||||
case "RoomTreasureRewardGranted":
|
||||
return "room_treasure_reward_granted"
|
||||
default:
|
||||
return eventType
|
||||
}
|
||||
|
||||
@ -338,11 +338,69 @@ type SendGift struct {
|
||||
HeatValue int64 `json:"heat_value,omitempty"`
|
||||
// PriceVersion 是本次钱包结算使用的服务端礼物价格版本。
|
||||
PriceVersion string `json:"price_version,omitempty"`
|
||||
// GiftTypeCode 是 wallet-service 结算时锁定的礼物类型,用于宝箱礼物类型能量规则。
|
||||
GiftTypeCode string `json:"gift_type_code,omitempty"`
|
||||
// TreasureTouched 表示本次送礼已经计算过宝箱状态,恢复时直接使用下列确定性快照。
|
||||
TreasureTouched bool `json:"treasure_touched,omitempty"`
|
||||
// TreasureAddedEnergy 是按后台规则算出的理论能量,倒计时和溢出规则会让它不完全生效。
|
||||
TreasureAddedEnergy int64 `json:"treasure_added_energy,omitempty"`
|
||||
// TreasureEffectiveEnergy 是实际写入当前等级进度的能量,溢出不会进入下一级。
|
||||
TreasureEffectiveEnergy int64 `json:"treasure_effective_energy,omitempty"`
|
||||
// TreasureOverflowEnergy 是本次礼物在当前等级阈值之外被丢弃的能量。
|
||||
TreasureOverflowEnergy int64 `json:"treasure_overflow_energy,omitempty"`
|
||||
// TreasureLevel/TreasureProgress/TreasureStatus 是送礼后宝箱状态快照。
|
||||
TreasureLevel int32 `json:"treasure_level,omitempty"`
|
||||
TreasureProgress int64 `json:"treasure_progress,omitempty"`
|
||||
TreasureStatus string `json:"treasure_status,omitempty"`
|
||||
// TreasureEnergyThreshold 是当前等级阈值快照。
|
||||
TreasureEnergyThreshold int64 `json:"treasure_energy_threshold,omitempty"`
|
||||
// TreasureCountdownStartedAtMS/TreasureOpenAtMS 只在点火进入倒计时时写入。
|
||||
TreasureCountdownStartedAtMS int64 `json:"treasure_countdown_started_at_ms,omitempty"`
|
||||
TreasureOpenAtMS int64 `json:"treasure_open_at_ms,omitempty"`
|
||||
// TreasureResetAtMS 是该宝箱进度所属 UTC 日的下一次重置时间。
|
||||
TreasureResetAtMS int64 `json:"treasure_reset_at_ms,omitempty"`
|
||||
// TreasureTop1UserID/TreasureIgniterUserID 在满能量瞬间锁定,离房后仍用它们发奖。
|
||||
TreasureTop1UserID int64 `json:"treasure_top1_user_id,omitempty"`
|
||||
TreasureIgniterUserID int64 `json:"treasure_igniter_user_id,omitempty"`
|
||||
// TreasureBoxID 是本轮宝箱幂等 ID,开箱、抽奖和 IM 都围绕它关联。
|
||||
TreasureBoxID string `json:"treasure_box_id,omitempty"`
|
||||
// TreasureConfigVersion 是本轮宝箱采用的后台配置版本。
|
||||
TreasureConfigVersion int64 `json:"treasure_config_version,omitempty"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (SendGift) Type() string { return "send_gift" }
|
||||
|
||||
// TreasureRewardGrant 记录开箱命令中已经结算出的资源组发放结果。
|
||||
type TreasureRewardGrant struct {
|
||||
RewardRole string `json:"reward_role"`
|
||||
UserID int64 `json:"user_id"`
|
||||
RewardItemID string `json:"reward_item_id"`
|
||||
ResourceGroupID int64 `json:"resource_group_id"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
IconURL string `json:"icon_url,omitempty"`
|
||||
GrantID string `json:"grant_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// OpenRoomTreasure 定义系统到点开箱命令。
|
||||
type OpenRoomTreasure struct {
|
||||
Base
|
||||
BoxID string `json:"box_id"`
|
||||
Level int32 `json:"level"`
|
||||
NextLevel int32 `json:"next_level"`
|
||||
NextEnergyThreshold int64 `json:"next_energy_threshold"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
ResetAtMS int64 `json:"reset_at_ms"`
|
||||
Top1UserID int64 `json:"top1_user_id,omitempty"`
|
||||
IgniterUserID int64 `json:"igniter_user_id,omitempty"`
|
||||
InRoomUserIDs []int64 `json:"in_room_user_ids,omitempty"`
|
||||
Rewards []TreasureRewardGrant `json:"rewards,omitempty"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (OpenRoomTreasure) Type() string { return "open_room_treasure" }
|
||||
|
||||
// Serialize 把命令编码成 command log 持久化所需的稳定载荷。
|
||||
func Serialize(cmd Command) ([]byte, error) {
|
||||
// 当前 command log 使用 JSON 载荷,便于排查;命令类型由独立列保存。
|
||||
@ -374,6 +432,31 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
|
||||
delete(values, "gift_point_added")
|
||||
delete(values, "heat_value")
|
||||
delete(values, "price_version")
|
||||
delete(values, "gift_type_code")
|
||||
delete(values, "treasure_touched")
|
||||
delete(values, "treasure_added_energy")
|
||||
delete(values, "treasure_effective_energy")
|
||||
delete(values, "treasure_overflow_energy")
|
||||
delete(values, "treasure_level")
|
||||
delete(values, "treasure_progress")
|
||||
delete(values, "treasure_status")
|
||||
delete(values, "treasure_energy_threshold")
|
||||
delete(values, "treasure_countdown_started_at_ms")
|
||||
delete(values, "treasure_open_at_ms")
|
||||
delete(values, "treasure_reset_at_ms")
|
||||
delete(values, "treasure_top1_user_id")
|
||||
delete(values, "treasure_igniter_user_id")
|
||||
delete(values, "treasure_box_id")
|
||||
delete(values, "treasure_config_version")
|
||||
// OpenRoomTreasure 的以下字段是 worker 在开箱时结算出的结果,不属于触发开箱的幂等语义。
|
||||
delete(values, "next_level")
|
||||
delete(values, "next_energy_threshold")
|
||||
delete(values, "config_version")
|
||||
delete(values, "reset_at_ms")
|
||||
delete(values, "top1_user_id")
|
||||
delete(values, "igniter_user_id")
|
||||
delete(values, "in_room_user_ids")
|
||||
delete(values, "rewards")
|
||||
// SetRoomPassword 的哈希带随机盐;通用 payload 比较先去掉哈希,service 层再用 transient 明文和已存 bcrypt 哈希确认是否同一密码。
|
||||
delete(values, "password_hash")
|
||||
// MicUp 的发流确认 deadline 由 room-service 生成,客户端重试同一 command_id 时不能因此冲突。
|
||||
@ -430,6 +513,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
|
||||
cmd = &UnbanUser{}
|
||||
case SendGift{}.Type():
|
||||
cmd = &SendGift{}
|
||||
case OpenRoomTreasure{}.Type():
|
||||
cmd = &OpenRoomTreasure{}
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown room command type: %s", commandType)
|
||||
}
|
||||
|
||||
@ -20,6 +20,10 @@ func (followTestWallet) DebitGift(context.Context, *walletv1.DebitGiftRequest) (
|
||||
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-follow-test"}, nil
|
||||
}
|
||||
|
||||
func (followTestWallet) GrantResourceGroup(context.Context, *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
|
||||
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-follow-test"}}, nil
|
||||
}
|
||||
|
||||
func TestRoomFollowAffectsSnapshotAndFollowedFeed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -65,6 +65,10 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
if !exists {
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
|
||||
}
|
||||
treasureConfig, err := s.roomTreasureConfig(ctx)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
// 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
|
||||
walletStartedAt := time.Now()
|
||||
@ -92,15 +96,20 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
settledCommand.GiftPointAdded = billing.GetGiftPointAdded()
|
||||
settledCommand.HeatValue = heatValue
|
||||
settledCommand.PriceVersion = billing.GetPriceVersion()
|
||||
commandPayload, err := command.Serialize(settledCommand)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
|
||||
|
||||
// 扣费成功后,Room Cell 同步更新热度和本地礼物榜。
|
||||
current.Heat += heatValue
|
||||
current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now)
|
||||
current.Version++
|
||||
treasureApply, err := s.applyRoomTreasureGift(now, current, treasureConfig, cmd, &settledCommand, billing, roomMeta)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
commandPayload, err := command.Serialize(settledCommand)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
// 送礼事件、热度事件和榜单事件使用同一个房间版本,方便消费者关联同一次命令。
|
||||
giftEvent, err := outbox.Build(current.RoomID, "RoomGiftSent", current.Version, now, &roomeventsv1.RoomGiftSent{
|
||||
@ -135,6 +144,22 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
records := []outbox.Record{giftEvent, heatEvent, rankEvent}
|
||||
if treasureApply.progressEvent != nil {
|
||||
progressEvent, err := outbox.Build(current.RoomID, "RoomTreasureProgressChanged", current.Version, now, treasureApply.progressEvent)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records = append(records, progressEvent)
|
||||
}
|
||||
if treasureApply.countdown != nil {
|
||||
countdownEvent, err := outbox.Build(current.RoomID, "RoomTreasureCountdownStarted", current.Version, now, treasureApply.countdown)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records = append(records, countdownEvent)
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
billingReceiptID: billing.GetBillingReceiptId(),
|
||||
@ -161,7 +186,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
"price_version": settledCommand.PriceVersion,
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{giftEvent, heatEvent, rankEvent}, nil
|
||||
}, records, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -173,6 +198,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
RoomHeat: result.roomHeat,
|
||||
GiftRank: result.giftRank,
|
||||
Room: result.snapshot,
|
||||
Treasure: result.snapshot.GetTreasure(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@ -155,9 +155,9 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
continue
|
||||
}
|
||||
|
||||
publishCtx, cancel := context.WithTimeout(context.Background(), options.PublishTimeout)
|
||||
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
|
||||
// 发布上下文不继承 worker ctx,避免 shutdown 取消导致已开始的单条外部投递无界处于未知状态。
|
||||
err := s.outboxPublisher.PublishOutboxEvent(publishCtx, record.Envelope)
|
||||
err := s.publishOutboxRecord(publishCtx, record)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// 投递失败转为 retryable,并写入指数退避时间,等待下一轮 worker 到期后再抢占。
|
||||
@ -203,6 +203,17 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) publishOutboxRecord(ctx context.Context, record outbox.Record) error {
|
||||
// Countdown 先安排延迟开箱,再对外广播事件;如果 MQ 延迟调度不可用,本条 outbox 保持 retryable。
|
||||
if err := s.scheduleRoomTreasureOpenFromEnvelope(ctx, record.Envelope); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.outboxPublisher == nil {
|
||||
return nil
|
||||
}
|
||||
return s.outboxPublisher.PublishOutboxEvent(ctx, record.Envelope)
|
||||
}
|
||||
|
||||
// outboxBackoff 根据失败次数计算指数退避,且永远不超过 MaxBackoff。
|
||||
func outboxBackoff(retryCount int, options OutboxWorkerOptions) time.Duration {
|
||||
if retryCount <= 1 {
|
||||
|
||||
@ -348,8 +348,64 @@ func replay(current *state.RoomState, cmd command.Command) error {
|
||||
})
|
||||
}
|
||||
|
||||
if typed.TreasureTouched {
|
||||
current.Treasure = treasureStateFromSettledGift(typed)
|
||||
}
|
||||
|
||||
current.Version++
|
||||
case *command.OpenRoomTreasure:
|
||||
if current.Treasure == nil || current.Treasure.BoxID != typed.BoxID {
|
||||
// 开箱命令必须对应当前倒计时宝箱;如果快照已包含更新后的下一轮状态,回放保持幂等。
|
||||
return nil
|
||||
}
|
||||
current.Treasure = treasureStateFromOpenCommand(typed)
|
||||
current.Version++
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func treasureStateFromSettledGift(cmd *command.SendGift) *state.TreasureState {
|
||||
return &state.TreasureState{
|
||||
CurrentLevel: cmd.TreasureLevel,
|
||||
CurrentProgress: cmd.TreasureProgress,
|
||||
EnergyThreshold: cmd.TreasureEnergyThreshold,
|
||||
Status: cmd.TreasureStatus,
|
||||
CountdownStartedAtMS: cmd.TreasureCountdownStartedAtMS,
|
||||
OpenAtMS: cmd.TreasureOpenAtMS,
|
||||
ResetAtMS: cmd.TreasureResetAtMS,
|
||||
Top1UserID: cmd.TreasureTop1UserID,
|
||||
IgniterUserID: cmd.TreasureIgniterUserID,
|
||||
BoxID: cmd.TreasureBoxID,
|
||||
ConfigVersion: cmd.TreasureConfigVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func treasureStateFromOpenCommand(cmd *command.OpenRoomTreasure) *state.TreasureState {
|
||||
return &state.TreasureState{
|
||||
CurrentLevel: cmd.NextLevel,
|
||||
EnergyThreshold: cmd.NextEnergyThreshold,
|
||||
Status: state.TreasureStatusIdle,
|
||||
OpenedAtMS: cmd.SentAtMS,
|
||||
ResetAtMS: cmd.ResetAtMS,
|
||||
ConfigVersion: cmd.ConfigVersion,
|
||||
LastRewards: treasureRewardsFromCommand(cmd.Rewards),
|
||||
}
|
||||
}
|
||||
|
||||
func treasureRewardsFromCommand(input []command.TreasureRewardGrant) []state.TreasureRewardGrant {
|
||||
rewards := make([]state.TreasureRewardGrant, 0, len(input))
|
||||
for _, reward := range input {
|
||||
rewards = append(rewards, state.TreasureRewardGrant{
|
||||
RewardRole: reward.RewardRole,
|
||||
UserID: reward.UserID,
|
||||
RewardItemID: reward.RewardItemID,
|
||||
ResourceGroupID: reward.ResourceGroupID,
|
||||
DisplayName: reward.DisplayName,
|
||||
IconURL: reward.IconURL,
|
||||
GrantID: reward.GrantID,
|
||||
Status: reward.Status,
|
||||
})
|
||||
}
|
||||
return rewards
|
||||
}
|
||||
|
||||
@ -139,6 +139,56 @@ type RoomSeatConfig struct {
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// RoomTreasureConfig 是后台语音房宝箱配置的运行时快照。
|
||||
type RoomTreasureConfig struct {
|
||||
AppCode string
|
||||
Enabled bool
|
||||
ConfigVersion int64
|
||||
EnergySource string
|
||||
OpenDelayMS int64
|
||||
BroadcastEnabled bool
|
||||
BroadcastScope string
|
||||
BroadcastDelayMS int64
|
||||
RewardStackPolicy string
|
||||
Levels []RoomTreasureLevelConfig
|
||||
GiftEnergyRules []GiftEnergyRuleConfig
|
||||
UpdatedByAdminID int64
|
||||
CreatedAtMS int64
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// RoomTreasureLevelConfig 描述单个等级的物料、阈值和三类奖励池。
|
||||
type RoomTreasureLevelConfig struct {
|
||||
Level int32 `json:"level"`
|
||||
EnergyThreshold int64 `json:"energyThreshold"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
AnimationURL string `json:"animationUrl"`
|
||||
OpeningAnimationURL string `json:"openingAnimationUrl"`
|
||||
OpenedImageURL string `json:"openedImageUrl"`
|
||||
InRoomRewards []RoomTreasureRewardItem `json:"inRoomRewards"`
|
||||
Top1Rewards []RoomTreasureRewardItem `json:"top1Rewards"`
|
||||
IgniterRewards []RoomTreasureRewardItem `json:"igniterRewards"`
|
||||
}
|
||||
|
||||
// RoomTreasureRewardItem 是后台配置中的奖励候选项。
|
||||
type RoomTreasureRewardItem struct {
|
||||
RewardItemID string `json:"rewardItemId"`
|
||||
ResourceGroupID int64 `json:"resourceGroupId"`
|
||||
Weight int64 `json:"weight"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
}
|
||||
|
||||
// GiftEnergyRuleConfig 控制特定礼物或礼物类型如何覆盖默认能量计算。
|
||||
type GiftEnergyRuleConfig struct {
|
||||
RuleID string `json:"ruleId"`
|
||||
GiftID string `json:"giftId"`
|
||||
GiftTypeCode string `json:"giftTypeCode"`
|
||||
MultiplierPPM int64 `json:"multiplierPpm"`
|
||||
FixedEnergy int64 `json:"fixedEnergy"`
|
||||
Excluded bool `json:"excluded"`
|
||||
}
|
||||
|
||||
// RoomPresence 是当前用户可恢复房间的轻量读模型。
|
||||
// 它从 RoomSnapshot 投影而来,只用于 App 启动/回前台探测,不替代 Room Cell 权威状态。
|
||||
type RoomPresence struct {
|
||||
@ -331,6 +381,10 @@ type Repository interface {
|
||||
GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, bool, error)
|
||||
// UpsertRoomSeatConfig 写入房间座位数配置,供本地验证和后台配置共享同一张表。
|
||||
UpsertRoomSeatConfig(ctx context.Context, config RoomSeatConfig) error
|
||||
// GetRoomTreasureConfig 读取语音房宝箱低频配置;不存在时 service 层使用关闭态默认配置。
|
||||
GetRoomTreasureConfig(ctx context.Context) (RoomTreasureConfig, bool, error)
|
||||
// UpsertRoomTreasureConfig 写入语音房宝箱配置,主要供集成测试和本地验证复用生产表结构。
|
||||
UpsertRoomTreasureConfig(ctx context.Context, config RoomTreasureConfig) error
|
||||
// ListCommandsAfter 读取快照版本之后的命令,恢复时按版本顺序回放。
|
||||
ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error)
|
||||
// SaveSnapshot 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。
|
||||
|
||||
932
services/room-service/internal/room/service/room_treasure.go
Normal file
932
services/room-service/internal/room/service/room_treasure.go
Normal file
@ -0,0 +1,932 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/roomid"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
"hyapp/services/room-service/internal/room/rank"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
const (
|
||||
roomTreasureLevelCount = 7
|
||||
|
||||
roomTreasureDefaultEnergySource = "gift_point_added"
|
||||
roomTreasureDefaultOpenDelayMS = int64(30_000)
|
||||
roomTreasureDefaultBroadcastScope = "region"
|
||||
roomTreasureDefaultRewardStackPolicy = "allow_stack"
|
||||
|
||||
roomTreasureEnergyGiftPoint = "gift_point_added"
|
||||
roomTreasureEnergyHeatValue = "heat_value"
|
||||
|
||||
roomTreasureBroadcastNone = "none"
|
||||
roomTreasureBroadcastRegion = "region"
|
||||
roomTreasureBroadcastGlobal = "global"
|
||||
|
||||
roomTreasureRewardStackAllow = "allow_stack"
|
||||
roomTreasureRewardStackPriority = "priority_only"
|
||||
|
||||
roomTreasureRewardInRoom = "in_room"
|
||||
roomTreasureRewardTop1 = "top1"
|
||||
roomTreasureRewardIgniter = "igniter"
|
||||
roomTreasureGrantSource = "room_treasure"
|
||||
roomTreasureGrantStatus = "succeeded"
|
||||
)
|
||||
|
||||
var roomTreasureDefaultThresholds = []int64{1_000, 3_000, 6_000, 10_000, 15_000, 21_000, 28_000}
|
||||
|
||||
// GetRoomTreasure 返回房间宝箱 UI 初始化数据;它只读 Room Cell,不刷新 presence。
|
||||
func (s *Service) GetRoomTreasure(ctx context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||
roomID := strings.TrimSpace(req.GetRoomId())
|
||||
viewerUserID := req.GetViewerUserId()
|
||||
if !roomid.ValidStringID(roomID) {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room_id is invalid")
|
||||
}
|
||||
if viewerUserID <= 0 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
|
||||
}
|
||||
|
||||
snapshot, err := s.currentSnapshot(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if snapshot == nil || snapshot.GetRoomId() == "" {
|
||||
return nil, xerr.New(xerr.NotFound, "room not found")
|
||||
}
|
||||
if snapshot.GetStatus() != state.RoomStatusActive {
|
||||
return nil, xerr.New(xerr.RoomClosed, "room closed")
|
||||
}
|
||||
if containsUserID(snapshot.GetBanUserIds(), viewerUserID) || findProtoUser(snapshot, viewerUserID) == nil {
|
||||
// 宝箱状态属于房间内活动信息,只允许仍在业务 presence 内的用户读取。
|
||||
return nil, xerr.New(xerr.PermissionDenied, "viewer is not in room")
|
||||
}
|
||||
|
||||
config, err := s.roomTreasureConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := s.clock.Now().UTC()
|
||||
info := roomTreasureInfoFromConfig(config, snapshot.GetTreasure(), now)
|
||||
return &roomv1.GetRoomTreasureResponse{
|
||||
Treasure: info,
|
||||
ServerTimeMs: now.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) roomTreasureConfig(ctx context.Context) (RoomTreasureConfig, error) {
|
||||
config, exists, err := s.repository.GetRoomTreasureConfig(ctx)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
if !exists {
|
||||
config = defaultRoomTreasureConfig(appcode.FromContext(ctx))
|
||||
}
|
||||
return normalizeRoomTreasureConfig(config)
|
||||
}
|
||||
|
||||
func defaultRoomTreasureConfig(appCode string) RoomTreasureConfig {
|
||||
levels := make([]RoomTreasureLevelConfig, 0, roomTreasureLevelCount)
|
||||
for index, threshold := range roomTreasureDefaultThresholds {
|
||||
levels = append(levels, RoomTreasureLevelConfig{
|
||||
Level: int32(index + 1),
|
||||
EnergyThreshold: threshold,
|
||||
})
|
||||
}
|
||||
return RoomTreasureConfig{
|
||||
AppCode: appcode.Normalize(appCode),
|
||||
EnergySource: roomTreasureDefaultEnergySource,
|
||||
OpenDelayMS: roomTreasureDefaultOpenDelayMS,
|
||||
BroadcastEnabled: true,
|
||||
BroadcastScope: roomTreasureDefaultBroadcastScope,
|
||||
RewardStackPolicy: roomTreasureDefaultRewardStackPolicy,
|
||||
Levels: levels,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRoomTreasureConfig(config RoomTreasureConfig) (RoomTreasureConfig, error) {
|
||||
config.AppCode = appcode.Normalize(config.AppCode)
|
||||
if config.AppCode == "" {
|
||||
config.AppCode = appcode.Default
|
||||
}
|
||||
if config.ConfigVersion < 0 {
|
||||
return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure config_version is invalid")
|
||||
}
|
||||
config.EnergySource = defaultRoomTreasureString(config.EnergySource, roomTreasureDefaultEnergySource)
|
||||
if config.EnergySource != roomTreasureEnergyGiftPoint && config.EnergySource != roomTreasureEnergyHeatValue {
|
||||
return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure energy_source is invalid")
|
||||
}
|
||||
if config.OpenDelayMS < 0 || config.BroadcastDelayMS < 0 {
|
||||
return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure delay is invalid")
|
||||
}
|
||||
config.BroadcastScope = defaultRoomTreasureString(config.BroadcastScope, roomTreasureDefaultBroadcastScope)
|
||||
if config.BroadcastScope != roomTreasureBroadcastNone && config.BroadcastScope != roomTreasureBroadcastRegion && config.BroadcastScope != roomTreasureBroadcastGlobal {
|
||||
return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure broadcast_scope is invalid")
|
||||
}
|
||||
if !config.BroadcastEnabled {
|
||||
config.BroadcastScope = roomTreasureBroadcastNone
|
||||
}
|
||||
config.RewardStackPolicy = defaultRoomTreasureString(config.RewardStackPolicy, roomTreasureDefaultRewardStackPolicy)
|
||||
if config.RewardStackPolicy != roomTreasureRewardStackAllow && config.RewardStackPolicy != roomTreasureRewardStackPriority {
|
||||
return RoomTreasureConfig{}, xerr.New(xerr.InvalidArgument, "room treasure reward_stack_policy is invalid")
|
||||
}
|
||||
|
||||
levels, err := normalizeRoomTreasureLevels(config.Levels)
|
||||
if err != nil {
|
||||
return RoomTreasureConfig{}, err
|
||||
}
|
||||
config.Levels = levels
|
||||
config.GiftEnergyRules = normalizeRoomTreasureEnergyRules(config.GiftEnergyRules)
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func normalizeRoomTreasureLevels(levels []RoomTreasureLevelConfig) ([]RoomTreasureLevelConfig, error) {
|
||||
if len(levels) == 0 {
|
||||
return defaultRoomTreasureConfig(appcode.Default).Levels, nil
|
||||
}
|
||||
if len(levels) != roomTreasureLevelCount {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room treasure levels must be 7")
|
||||
}
|
||||
seen := make(map[int32]bool, roomTreasureLevelCount)
|
||||
normalized := make([]RoomTreasureLevelConfig, 0, roomTreasureLevelCount)
|
||||
for _, level := range levels {
|
||||
if level.Level < 1 || level.Level > roomTreasureLevelCount || seen[level.Level] {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room treasure level is invalid")
|
||||
}
|
||||
if level.EnergyThreshold <= 0 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room treasure energy_threshold is invalid")
|
||||
}
|
||||
seen[level.Level] = true
|
||||
level.CoverURL = strings.TrimSpace(level.CoverURL)
|
||||
level.AnimationURL = strings.TrimSpace(level.AnimationURL)
|
||||
level.OpeningAnimationURL = strings.TrimSpace(level.OpeningAnimationURL)
|
||||
level.OpenedImageURL = strings.TrimSpace(level.OpenedImageURL)
|
||||
level.InRoomRewards = normalizeRoomTreasureRewardPool(level.InRoomRewards)
|
||||
level.Top1Rewards = normalizeRoomTreasureRewardPool(level.Top1Rewards)
|
||||
level.IgniterRewards = normalizeRoomTreasureRewardPool(level.IgniterRewards)
|
||||
normalized = append(normalized, level)
|
||||
}
|
||||
slices.SortFunc(normalized, func(left, right RoomTreasureLevelConfig) int {
|
||||
return int(left.Level - right.Level)
|
||||
})
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRoomTreasureRewardPool(input []RoomTreasureRewardItem) []RoomTreasureRewardItem {
|
||||
out := make([]RoomTreasureRewardItem, 0, len(input))
|
||||
for _, reward := range input {
|
||||
reward.RewardItemID = strings.TrimSpace(reward.RewardItemID)
|
||||
reward.DisplayName = strings.TrimSpace(reward.DisplayName)
|
||||
reward.IconURL = strings.TrimSpace(reward.IconURL)
|
||||
if reward.ResourceGroupID <= 0 || reward.Weight <= 0 {
|
||||
// 后台保存层会拒绝非法奖励;运行时跳过历史脏项,避免一个奖励池配置污染送礼主链路。
|
||||
continue
|
||||
}
|
||||
out = append(out, reward)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeRoomTreasureEnergyRules(input []GiftEnergyRuleConfig) []GiftEnergyRuleConfig {
|
||||
out := make([]GiftEnergyRuleConfig, 0, len(input))
|
||||
for _, rule := range input {
|
||||
rule.RuleID = strings.TrimSpace(rule.RuleID)
|
||||
rule.GiftID = strings.TrimSpace(rule.GiftID)
|
||||
rule.GiftTypeCode = strings.TrimSpace(rule.GiftTypeCode)
|
||||
if rule.GiftID == "" && rule.GiftTypeCode == "" {
|
||||
continue
|
||||
}
|
||||
if rule.MultiplierPPM < 0 || rule.FixedEnergy < 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultRoomTreasureString(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func roomTreasureInfoFromConfig(config RoomTreasureConfig, current *roomv1.RoomTreasureState, now time.Time) *roomv1.RoomTreasureInfo {
|
||||
stateView := roomTreasureProtoStateForView(current, config, now)
|
||||
return &roomv1.RoomTreasureInfo{
|
||||
Enabled: config.Enabled,
|
||||
Levels: roomTreasureLevelsToProto(config.Levels),
|
||||
State: stateView,
|
||||
ServerTimeMs: now.UnixMilli(),
|
||||
BroadcastScope: config.BroadcastScope,
|
||||
OpenDelayMs: config.OpenDelayMS,
|
||||
BroadcastDelayMs: config.BroadcastDelayMS,
|
||||
RewardStackPolicy: config.RewardStackPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
func roomTreasureProtoStateForView(current *roomv1.RoomTreasureState, config RoomTreasureConfig, now time.Time) *roomv1.RoomTreasureState {
|
||||
internal := state.TreasureState{}
|
||||
if current != nil {
|
||||
internal = state.TreasureState{
|
||||
CurrentLevel: current.GetCurrentLevel(),
|
||||
CurrentProgress: current.GetCurrentProgress(),
|
||||
EnergyThreshold: current.GetEnergyThreshold(),
|
||||
Status: current.GetStatus(),
|
||||
CountdownStartedAtMS: current.GetCountdownStartedAtMs(),
|
||||
OpenAtMS: current.GetOpenAtMs(),
|
||||
OpenedAtMS: current.GetOpenedAtMs(),
|
||||
ResetAtMS: current.GetResetAtMs(),
|
||||
Top1UserID: current.GetTop1UserId(),
|
||||
IgniterUserID: current.GetIgniterUserId(),
|
||||
BoxID: current.GetBoxId(),
|
||||
ConfigVersion: current.GetConfigVersion(),
|
||||
LastRewards: stateRewardsFromProto(current.GetLastRewards()),
|
||||
}
|
||||
}
|
||||
normalized := treasureStateForNow(&internal, config, now, false)
|
||||
return treasureStateToRoomProto(normalized)
|
||||
}
|
||||
|
||||
func stateRewardsFromProto(input []*roomv1.RoomTreasureRewardGrant) []state.TreasureRewardGrant {
|
||||
out := make([]state.TreasureRewardGrant, 0, len(input))
|
||||
for _, reward := range input {
|
||||
out = append(out, state.TreasureRewardGrant{
|
||||
RewardRole: reward.GetRewardRole(),
|
||||
UserID: reward.GetUserId(),
|
||||
RewardItemID: reward.GetRewardItemId(),
|
||||
ResourceGroupID: reward.GetResourceGroupId(),
|
||||
DisplayName: reward.GetDisplayName(),
|
||||
IconURL: reward.GetIconUrl(),
|
||||
GrantID: reward.GetGrantId(),
|
||||
Status: reward.GetStatus(),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomTreasureLevelsToProto(levels []RoomTreasureLevelConfig) []*roomv1.RoomTreasureLevel {
|
||||
out := make([]*roomv1.RoomTreasureLevel, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
out = append(out, &roomv1.RoomTreasureLevel{
|
||||
Level: level.Level,
|
||||
EnergyThreshold: level.EnergyThreshold,
|
||||
CoverUrl: level.CoverURL,
|
||||
AnimationUrl: level.AnimationURL,
|
||||
OpeningAnimationUrl: level.OpeningAnimationURL,
|
||||
OpenedImageUrl: level.OpenedImageURL,
|
||||
InRoomRewards: roomTreasureRewardsToProto(level.InRoomRewards),
|
||||
Top1Rewards: roomTreasureRewardsToProto(level.Top1Rewards),
|
||||
IgniterRewards: roomTreasureRewardsToProto(level.IgniterRewards),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func roomTreasureRewardsToProto(input []RoomTreasureRewardItem) []*roomv1.RoomTreasureRewardItem {
|
||||
out := make([]*roomv1.RoomTreasureRewardItem, 0, len(input))
|
||||
for _, reward := range input {
|
||||
out = append(out, &roomv1.RoomTreasureRewardItem{
|
||||
RewardItemId: reward.RewardItemID,
|
||||
ResourceGroupId: reward.ResourceGroupID,
|
||||
Weight: reward.Weight,
|
||||
DisplayName: reward.DisplayName,
|
||||
IconUrl: reward.IconURL,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type treasureGiftApplyResult struct {
|
||||
touched bool
|
||||
progressEvent *roomeventsv1.RoomTreasureProgressChanged
|
||||
countdown *roomeventsv1.RoomTreasureCountdownStarted
|
||||
}
|
||||
|
||||
func (s *Service) applyRoomTreasureGift(now time.Time, current *state.RoomState, cfg RoomTreasureConfig, cmd command.SendGift, settled *command.SendGift, billing *walletv1.DebitGiftResponse, roomMeta RoomMeta) (treasureGiftApplyResult, error) {
|
||||
if !cfg.Enabled {
|
||||
return treasureGiftApplyResult{}, nil
|
||||
}
|
||||
now = now.UTC()
|
||||
nowMS := now.UnixMilli()
|
||||
treasure := treasureStateForNow(current.Treasure, cfg, now, true)
|
||||
result := treasureGiftApplyResult{touched: current.Treasure == nil || treasure.ResetAtMS != current.Treasure.ResetAtMS}
|
||||
|
||||
addedEnergy := roomTreasureEnergy(cfg, cmd.GiftID, billing.GetGiftTypeCode(), billing.GetGiftPointAdded(), billing.GetHeatValue())
|
||||
effectiveEnergy := int64(0)
|
||||
overflowEnergy := int64(0)
|
||||
|
||||
if treasure.Status == state.TreasureStatusCountdown {
|
||||
// 倒计时期间送礼只走普通礼物表现,宝箱能量按产品规则整笔无效。
|
||||
overflowEnergy = addedEnergy
|
||||
} else if addedEnergy > 0 {
|
||||
remaining := treasure.EnergyThreshold - treasure.CurrentProgress
|
||||
if remaining < 0 {
|
||||
remaining = 0
|
||||
}
|
||||
effectiveEnergy = minInt64(addedEnergy, remaining)
|
||||
overflowEnergy = addedEnergy - effectiveEnergy
|
||||
if effectiveEnergy > 0 {
|
||||
treasure.CurrentProgress += effectiveEnergy
|
||||
result.touched = true
|
||||
}
|
||||
if treasure.CurrentProgress >= treasure.EnergyThreshold {
|
||||
treasure.CurrentProgress = treasure.EnergyThreshold
|
||||
treasure.Status = state.TreasureStatusCountdown
|
||||
treasure.CountdownStartedAtMS = nowMS
|
||||
treasure.OpenAtMS = nowMS + cfg.OpenDelayMS
|
||||
treasure.Top1UserID = firstRankUserID(current.GiftRank)
|
||||
treasure.IgniterUserID = cmd.ActorUserID()
|
||||
treasure.ConfigVersion = cfg.ConfigVersion
|
||||
result.countdown = &roomeventsv1.RoomTreasureCountdownStarted{
|
||||
BoxId: treasure.BoxID,
|
||||
Level: treasure.CurrentLevel,
|
||||
CurrentProgress: treasure.CurrentProgress,
|
||||
EnergyThreshold: treasure.EnergyThreshold,
|
||||
CountdownStartedAtMs: treasure.CountdownStartedAtMS,
|
||||
OpenAtMs: treasure.OpenAtMS,
|
||||
BroadcastScope: cfg.BroadcastScope,
|
||||
VisibleRegionId: roomMeta.VisibleRegionID,
|
||||
Top1UserId: treasure.Top1UserID,
|
||||
IgniterUserId: treasure.IgniterUserID,
|
||||
CommandId: cmd.ID(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if result.touched {
|
||||
current.Treasure = treasure
|
||||
}
|
||||
if effectiveEnergy > 0 {
|
||||
result.progressEvent = &roomeventsv1.RoomTreasureProgressChanged{
|
||||
BoxId: treasure.BoxID,
|
||||
Level: treasure.CurrentLevel,
|
||||
AddedEnergy: addedEnergy,
|
||||
EffectiveAddedEnergy: effectiveEnergy,
|
||||
OverflowEnergy: overflowEnergy,
|
||||
CurrentProgress: treasure.CurrentProgress,
|
||||
EnergyThreshold: treasure.EnergyThreshold,
|
||||
Status: treasure.Status,
|
||||
ResetAtMs: treasure.ResetAtMS,
|
||||
SenderUserId: cmd.ActorUserID(),
|
||||
GiftId: cmd.GiftID,
|
||||
GiftCount: cmd.GiftCount,
|
||||
CommandId: cmd.ID(),
|
||||
VisibleRegionId: roomMeta.VisibleRegionID,
|
||||
}
|
||||
}
|
||||
|
||||
if result.touched {
|
||||
settled.GiftTypeCode = billing.GetGiftTypeCode()
|
||||
settled.TreasureTouched = true
|
||||
settled.TreasureAddedEnergy = addedEnergy
|
||||
settled.TreasureEffectiveEnergy = effectiveEnergy
|
||||
settled.TreasureOverflowEnergy = overflowEnergy
|
||||
settled.TreasureLevel = treasure.CurrentLevel
|
||||
settled.TreasureProgress = treasure.CurrentProgress
|
||||
settled.TreasureStatus = treasure.Status
|
||||
settled.TreasureEnergyThreshold = treasure.EnergyThreshold
|
||||
settled.TreasureCountdownStartedAtMS = treasure.CountdownStartedAtMS
|
||||
settled.TreasureOpenAtMS = treasure.OpenAtMS
|
||||
settled.TreasureResetAtMS = treasure.ResetAtMS
|
||||
settled.TreasureTop1UserID = treasure.Top1UserID
|
||||
settled.TreasureIgniterUserID = treasure.IgniterUserID
|
||||
settled.TreasureBoxID = treasure.BoxID
|
||||
settled.TreasureConfigVersion = treasure.ConfigVersion
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func treasureStateForNow(input *state.TreasureState, cfg RoomTreasureConfig, now time.Time, allocateBoxID bool) *state.TreasureState {
|
||||
now = now.UTC()
|
||||
nowMS := now.UnixMilli()
|
||||
resetAtMS := nextUTCResetMS(now)
|
||||
level := int32(1)
|
||||
if input != nil && input.CurrentLevel >= 1 && input.CurrentLevel <= roomTreasureLevelCount {
|
||||
level = input.CurrentLevel
|
||||
}
|
||||
if input == nil || input.ResetAtMS <= 0 || nowMS >= input.ResetAtMS {
|
||||
return newTreasureState(levelForUTCReset(), roomTreasureLevelByNumber(cfg, levelForUTCReset()), resetAtMS, cfg.ConfigVersion, allocateBoxID)
|
||||
}
|
||||
|
||||
treasure := input.Clone()
|
||||
levelConfig := roomTreasureLevelByNumber(cfg, level)
|
||||
treasure.CurrentLevel = level
|
||||
treasure.EnergyThreshold = levelConfig.EnergyThreshold
|
||||
treasure.ResetAtMS = input.ResetAtMS
|
||||
if treasure.Status == "" {
|
||||
treasure.Status = state.TreasureStatusIdle
|
||||
}
|
||||
if treasure.Status != state.TreasureStatusCountdown {
|
||||
treasure.Status = state.TreasureStatusIdle
|
||||
treasure.OpenAtMS = 0
|
||||
treasure.CountdownStartedAtMS = 0
|
||||
}
|
||||
if treasure.CurrentProgress < 0 {
|
||||
treasure.CurrentProgress = 0
|
||||
}
|
||||
if treasure.CurrentProgress > treasure.EnergyThreshold {
|
||||
treasure.CurrentProgress = treasure.EnergyThreshold
|
||||
}
|
||||
if treasure.BoxID == "" && allocateBoxID {
|
||||
treasure.BoxID = idgen.New("room_treasure_box")
|
||||
}
|
||||
return treasure
|
||||
}
|
||||
|
||||
func newTreasureState(level int32, levelConfig RoomTreasureLevelConfig, resetAtMS int64, configVersion int64, allocateBoxID bool) *state.TreasureState {
|
||||
boxID := ""
|
||||
if allocateBoxID {
|
||||
boxID = idgen.New("room_treasure_box")
|
||||
}
|
||||
return &state.TreasureState{
|
||||
CurrentLevel: level,
|
||||
EnergyThreshold: levelConfig.EnergyThreshold,
|
||||
Status: state.TreasureStatusIdle,
|
||||
ResetAtMS: resetAtMS,
|
||||
BoxID: boxID,
|
||||
ConfigVersion: configVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func levelForUTCReset() int32 {
|
||||
return 1
|
||||
}
|
||||
|
||||
func nextUTCResetMS(now time.Time) int64 {
|
||||
utc := now.UTC()
|
||||
next := time.Date(utc.Year(), utc.Month(), utc.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
return next.UnixMilli()
|
||||
}
|
||||
|
||||
func roomTreasureLevelByNumber(cfg RoomTreasureConfig, level int32) RoomTreasureLevelConfig {
|
||||
for _, item := range cfg.Levels {
|
||||
if item.Level == level {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return defaultRoomTreasureConfig(cfg.AppCode).Levels[0]
|
||||
}
|
||||
|
||||
func roomTreasureEnergy(cfg RoomTreasureConfig, giftID string, giftTypeCode string, giftPointAdded int64, heatValue int64) int64 {
|
||||
base := giftPointAdded
|
||||
if cfg.EnergySource == roomTreasureEnergyHeatValue {
|
||||
base = heatValue
|
||||
}
|
||||
if base < 0 {
|
||||
base = 0
|
||||
}
|
||||
|
||||
for _, rule := range cfg.GiftEnergyRules {
|
||||
if rule.GiftID != "" && rule.GiftID == giftID {
|
||||
return roomTreasureEnergyByRule(base, rule)
|
||||
}
|
||||
}
|
||||
for _, rule := range cfg.GiftEnergyRules {
|
||||
if rule.GiftID == "" && rule.GiftTypeCode != "" && rule.GiftTypeCode == giftTypeCode {
|
||||
return roomTreasureEnergyByRule(base, rule)
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func roomTreasureEnergyByRule(base int64, rule GiftEnergyRuleConfig) int64 {
|
||||
if rule.Excluded {
|
||||
return 0
|
||||
}
|
||||
energy := int64(0)
|
||||
if rule.MultiplierPPM > 0 {
|
||||
energy += base * rule.MultiplierPPM / 1_000_000
|
||||
}
|
||||
energy += rule.FixedEnergy
|
||||
if energy < 0 {
|
||||
return 0
|
||||
}
|
||||
return energy
|
||||
}
|
||||
|
||||
func firstRankUserID(items []state.RankItem) int64 {
|
||||
if len(items) == 0 {
|
||||
return 0
|
||||
}
|
||||
return items[0].UserID
|
||||
}
|
||||
|
||||
func minInt64(left int64, right int64) int64 {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func treasureStateToRoomProto(input *state.TreasureState) *roomv1.RoomTreasureState {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
rewards := make([]*roomv1.RoomTreasureRewardGrant, 0, len(input.LastRewards))
|
||||
for _, reward := range input.LastRewards {
|
||||
rewards = append(rewards, &roomv1.RoomTreasureRewardGrant{
|
||||
RewardRole: reward.RewardRole,
|
||||
UserId: reward.UserID,
|
||||
RewardItemId: reward.RewardItemID,
|
||||
ResourceGroupId: reward.ResourceGroupID,
|
||||
DisplayName: reward.DisplayName,
|
||||
IconUrl: reward.IconURL,
|
||||
GrantId: reward.GrantID,
|
||||
Status: reward.Status,
|
||||
})
|
||||
}
|
||||
return &roomv1.RoomTreasureState{
|
||||
CurrentLevel: input.CurrentLevel,
|
||||
CurrentProgress: input.CurrentProgress,
|
||||
EnergyThreshold: input.EnergyThreshold,
|
||||
Status: input.Status,
|
||||
CountdownStartedAtMs: input.CountdownStartedAtMS,
|
||||
OpenAtMs: input.OpenAtMS,
|
||||
OpenedAtMs: input.OpenedAtMS,
|
||||
ResetAtMs: input.ResetAtMS,
|
||||
Top1UserId: input.Top1UserID,
|
||||
IgniterUserId: input.IgniterUserID,
|
||||
BoxId: input.BoxID,
|
||||
ConfigVersion: input.ConfigVersion,
|
||||
LastRewards: rewards,
|
||||
}
|
||||
}
|
||||
|
||||
func treasureStateToEventRewards(input []state.TreasureRewardGrant) []*roomeventsv1.RoomTreasureRewardGrant {
|
||||
out := make([]*roomeventsv1.RoomTreasureRewardGrant, 0, len(input))
|
||||
for _, reward := range input {
|
||||
out = append(out, &roomeventsv1.RoomTreasureRewardGrant{
|
||||
RewardRole: reward.RewardRole,
|
||||
UserId: reward.UserID,
|
||||
RewardItemId: reward.RewardItemID,
|
||||
ResourceGroupId: reward.ResourceGroupID,
|
||||
DisplayName: reward.DisplayName,
|
||||
IconUrl: reward.IconURL,
|
||||
GrantId: reward.GrantID,
|
||||
Status: reward.Status,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RunRoomTreasureOpenWorker 周期性扫描已装载房间,把到点的倒计时宝箱走命令链路打开。
|
||||
func (s *Service) RunRoomTreasureOpenWorker(ctx context.Context, interval time.Duration) {
|
||||
if interval <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = s.SweepRoomTreasureOpenings(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SweepRoomTreasureOpenings 只处理本节点仍持有 lease 的已装载 Room Cell。
|
||||
func (s *Service) SweepRoomTreasureOpenings(ctx context.Context) error {
|
||||
now := s.clock.Now().UTC()
|
||||
nowMS := now.UnixMilli()
|
||||
for _, roomRef := range s.loadedRoomRefs() {
|
||||
roomCtx := appcode.WithContext(ctx, roomRef.AppCode)
|
||||
lease, owned, err := s.loadedRoomLease(roomCtx, roomRef, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
continue
|
||||
}
|
||||
roomCell := s.loadCell(roomCtx, roomRef.RoomID)
|
||||
if roomCell == nil {
|
||||
continue
|
||||
}
|
||||
currentState, _, err := roomCell.Snapshot(roomCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned || currentState == nil || currentState.Treasure == nil {
|
||||
continue
|
||||
}
|
||||
treasure := currentState.Treasure
|
||||
if treasure.ResetAtMS > 0 && nowMS >= treasure.ResetAtMS {
|
||||
// UTC 切日优先级高于延迟开箱;跨日倒计时宝箱不再结算,下一次查询或送礼会按新 UTC 日投影/持久化一级新进度。
|
||||
continue
|
||||
}
|
||||
if treasure.Status != state.TreasureStatusCountdown || treasure.OpenAtMS <= 0 || treasure.OpenAtMS > nowMS {
|
||||
continue
|
||||
}
|
||||
cmd := command.OpenRoomTreasure{
|
||||
Base: command.Base{
|
||||
AppCode: roomRef.AppCode,
|
||||
RequestID: idgen.New("req_room_treasure_open"),
|
||||
CommandID: roomTreasureOpenCommandID(treasure.BoxID),
|
||||
ActorID: roomTreasureSystemActor(currentState),
|
||||
Room: roomRef.RoomID,
|
||||
GatewayNodeID: s.nodeID,
|
||||
SessionID: "room-treasure-open",
|
||||
SentAtMS: nowMS,
|
||||
},
|
||||
BoxID: treasure.BoxID,
|
||||
Level: treasure.CurrentLevel,
|
||||
}
|
||||
if _, err := s.openRoomTreasure(roomCtx, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func roomTreasureOpenCommandID(boxID string) string {
|
||||
if strings.TrimSpace(boxID) == "" {
|
||||
return idgen.New("cmd_room_treasure_open")
|
||||
}
|
||||
return "cmd_room_treasure_open_" + boxID
|
||||
}
|
||||
|
||||
func roomTreasureSystemActor(current *state.RoomState) int64 {
|
||||
if current == nil {
|
||||
return 0
|
||||
}
|
||||
if current.OwnerUserID > 0 {
|
||||
return current.OwnerUserID
|
||||
}
|
||||
if current.Treasure != nil && current.Treasure.IgniterUserID > 0 {
|
||||
return current.Treasure.IgniterUserID
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) openRoomTreasure(ctx context.Context, cmd command.OpenRoomTreasure) (*roomv1.RoomTreasureState, error) {
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
now = now.UTC()
|
||||
nowMS := now.UnixMilli()
|
||||
if current.Treasure == nil || current.Treasure.Status != state.TreasureStatusCountdown {
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
if current.Treasure.BoxID != cmd.BoxID || current.Treasure.CurrentLevel != cmd.Level {
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
if current.Treasure.ResetAtMS > 0 && nowMS >= current.Treasure.ResetAtMS {
|
||||
// 手动或延迟到达的开箱命令也不能越过 UTC 日界结算昨天的宝箱。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
if current.Treasure.OpenAtMS <= 0 || current.Treasure.OpenAtMS > nowMS {
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
cfg, err := s.roomTreasureConfig(ctx)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
levelCfg := roomTreasureLevelByNumber(cfg, current.Treasure.CurrentLevel)
|
||||
inRoomUserIDs := sortedTreasureOnlineUserIDs(current.OnlineUsers)
|
||||
rewards, err := s.settleRoomTreasureRewards(ctx, now, current, cfg, levelCfg, inRoomUserIDs)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
nextLevel := current.Treasure.CurrentLevel + 1
|
||||
if nextLevel > roomTreasureLevelCount {
|
||||
// 七级是当天最高等级;打开七级后继续留在七级,UTC 切日再回到一级。
|
||||
nextLevel = roomTreasureLevelCount
|
||||
}
|
||||
nextLevelCfg := roomTreasureLevelByNumber(cfg, nextLevel)
|
||||
settledCommand := cmd
|
||||
settledCommand.NextLevel = nextLevel
|
||||
settledCommand.NextEnergyThreshold = nextLevelCfg.EnergyThreshold
|
||||
settledCommand.ConfigVersion = cfg.ConfigVersion
|
||||
settledCommand.ResetAtMS = current.Treasure.ResetAtMS
|
||||
settledCommand.Top1UserID = current.Treasure.Top1UserID
|
||||
settledCommand.IgniterUserID = current.Treasure.IgniterUserID
|
||||
settledCommand.InRoomUserIDs = inRoomUserIDs
|
||||
settledCommand.Rewards = treasureRewardsToCommand(rewards)
|
||||
commandPayload, err := command.Serialize(settledCommand)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
openedBoxID := current.Treasure.BoxID
|
||||
openedLevel := current.Treasure.CurrentLevel
|
||||
top1UserID := current.Treasure.Top1UserID
|
||||
igniterUserID := current.Treasure.IgniterUserID
|
||||
resetAtMS := current.Treasure.ResetAtMS
|
||||
current.Treasure = &state.TreasureState{
|
||||
CurrentLevel: nextLevel,
|
||||
EnergyThreshold: nextLevelCfg.EnergyThreshold,
|
||||
Status: state.TreasureStatusIdle,
|
||||
OpenedAtMS: nowMS,
|
||||
ResetAtMS: resetAtMS,
|
||||
ConfigVersion: cfg.ConfigVersion,
|
||||
LastRewards: rewards,
|
||||
}
|
||||
current.Version++
|
||||
|
||||
openedEvent, err := outbox.Build(current.RoomID, "RoomTreasureOpened", current.Version, now, &roomeventsv1.RoomTreasureOpened{
|
||||
BoxId: openedBoxID,
|
||||
Level: openedLevel,
|
||||
NextLevel: nextLevel,
|
||||
OpenedAtMs: nowMS,
|
||||
ResetAtMs: resetAtMS,
|
||||
Top1UserId: top1UserID,
|
||||
IgniterUserId: igniterUserID,
|
||||
InRoomUserIds: inRoomUserIDs,
|
||||
Rewards: treasureStateToEventRewards(rewards),
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records := []outbox.Record{openedEvent}
|
||||
if len(rewards) > 0 {
|
||||
rewardEvent, err := outbox.Build(current.RoomID, "RoomTreasureRewardGranted", current.Version, now, &roomeventsv1.RoomTreasureRewardGranted{
|
||||
BoxId: openedBoxID,
|
||||
Level: openedLevel,
|
||||
Rewards: treasureStateToEventRewards(rewards),
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
records = append(records, rewardEvent)
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
commandPayload: commandPayload,
|
||||
}, records, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.snapshot.GetTreasure(), nil
|
||||
}
|
||||
|
||||
func sortedTreasureOnlineUserIDs(users map[int64]*state.RoomUserState) []int64 {
|
||||
userIDs := make([]int64, 0, len(users))
|
||||
for userID := range users {
|
||||
if userID > 0 {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
slices.Sort(userIDs)
|
||||
return userIDs
|
||||
}
|
||||
|
||||
func (s *Service) settleRoomTreasureRewards(ctx context.Context, now time.Time, current *state.RoomState, cfg RoomTreasureConfig, levelCfg RoomTreasureLevelConfig, inRoomUserIDs []int64) ([]state.TreasureRewardGrant, error) {
|
||||
if current == nil || current.Treasure == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rewards := make([]state.TreasureRewardGrant, 0, len(inRoomUserIDs)+2)
|
||||
claimed := make(map[int64]bool, len(inRoomUserIDs)+2)
|
||||
addReward := func(role string, userID int64, pool []RoomTreasureRewardItem) error {
|
||||
if userID <= 0 {
|
||||
return nil
|
||||
}
|
||||
if cfg.RewardStackPolicy == roomTreasureRewardStackPriority && claimed[userID] {
|
||||
return nil
|
||||
}
|
||||
item, ok := selectRoomTreasureReward(current.Treasure.BoxID, role, userID, pool)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
grantID, err := s.grantRoomTreasureReward(ctx, now, current, current.Treasure.BoxID, role, userID, item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rewards = append(rewards, state.TreasureRewardGrant{
|
||||
RewardRole: role,
|
||||
UserID: userID,
|
||||
RewardItemID: item.RewardItemID,
|
||||
ResourceGroupID: item.ResourceGroupID,
|
||||
DisplayName: item.DisplayName,
|
||||
IconURL: item.IconURL,
|
||||
GrantID: grantID,
|
||||
Status: roomTreasureGrantStatus,
|
||||
})
|
||||
claimed[userID] = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.RewardStackPolicy == roomTreasureRewardStackPriority {
|
||||
// 去重策略下先发特殊身份奖励,再发普通在房奖励,避免 top1/点火人被普通池占位。
|
||||
if err := addReward(roomTreasureRewardIgniter, current.Treasure.IgniterUserID, levelCfg.IgniterRewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := addReward(roomTreasureRewardTop1, current.Treasure.Top1UserID, levelCfg.Top1Rewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, userID := range inRoomUserIDs {
|
||||
if err := addReward(roomTreasureRewardInRoom, userID, levelCfg.InRoomRewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rewards, nil
|
||||
}
|
||||
|
||||
for _, userID := range inRoomUserIDs {
|
||||
if err := addReward(roomTreasureRewardInRoom, userID, levelCfg.InRoomRewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := addReward(roomTreasureRewardTop1, current.Treasure.Top1UserID, levelCfg.Top1Rewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := addReward(roomTreasureRewardIgniter, current.Treasure.IgniterUserID, levelCfg.IgniterRewards); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rewards, nil
|
||||
}
|
||||
|
||||
func (s *Service) grantRoomTreasureReward(ctx context.Context, _ time.Time, current *state.RoomState, boxID string, role string, userID int64, item RoomTreasureRewardItem) (string, error) {
|
||||
if s.wallet == nil {
|
||||
return "", xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||||
}
|
||||
operatorUserID := current.OwnerUserID
|
||||
if operatorUserID <= 0 {
|
||||
operatorUserID = userID
|
||||
}
|
||||
resp, err := s.wallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
|
||||
CommandId: roomTreasureGrantCommandID(boxID, role, userID, item.RewardItemID),
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
TargetUserId: userID,
|
||||
GroupId: item.ResourceGroupID,
|
||||
Reason: fmt.Sprintf("room treasure %s reward", role),
|
||||
OperatorUserId: operatorUserID,
|
||||
GrantSource: roomTreasureGrantSource,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp == nil || resp.GetGrant() == nil {
|
||||
return "", xerr.New(xerr.Unavailable, "wallet grant response is empty")
|
||||
}
|
||||
return resp.GetGrant().GetGrantId(), nil
|
||||
}
|
||||
|
||||
func selectRoomTreasureReward(boxID string, role string, userID int64, pool []RoomTreasureRewardItem) (RoomTreasureRewardItem, bool) {
|
||||
total := int64(0)
|
||||
for _, item := range pool {
|
||||
if item.Weight > 0 && item.ResourceGroupID > 0 {
|
||||
total += item.Weight
|
||||
}
|
||||
}
|
||||
if total <= 0 {
|
||||
return RoomTreasureRewardItem{}, false
|
||||
}
|
||||
hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", boxID, role, userID)))
|
||||
pick := int64(binary.BigEndian.Uint64(hash[:8]) % uint64(total))
|
||||
cursor := int64(0)
|
||||
for _, item := range pool {
|
||||
if item.Weight <= 0 || item.ResourceGroupID <= 0 {
|
||||
continue
|
||||
}
|
||||
cursor += item.Weight
|
||||
if pick < cursor {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return pool[len(pool)-1], true
|
||||
}
|
||||
|
||||
func roomTreasureGrantCommandID(boxID string, role string, userID int64, rewardItemID string) string {
|
||||
hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%s", boxID, role, userID, rewardItemID)))
|
||||
return fmt.Sprintf("cmd_room_treasure_grant_%x", hash[:8])
|
||||
}
|
||||
|
||||
func treasureRewardsToCommand(input []state.TreasureRewardGrant) []command.TreasureRewardGrant {
|
||||
out := make([]command.TreasureRewardGrant, 0, len(input))
|
||||
for _, reward := range input {
|
||||
out = append(out, command.TreasureRewardGrant{
|
||||
RewardRole: reward.RewardRole,
|
||||
UserID: reward.UserID,
|
||||
RewardItemID: reward.RewardItemID,
|
||||
ResourceGroupID: reward.ResourceGroupID,
|
||||
DisplayName: reward.DisplayName,
|
||||
IconURL: reward.IconURL,
|
||||
GrantID: reward.GrantID,
|
||||
Status: reward.Status,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
111
services/room-service/internal/room/service/room_treasure_mq.go
Normal file
111
services/room-service/internal/room/service/room_treasure_mq.go
Normal file
@ -0,0 +1,111 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/roommq"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/integration"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
// scheduleRoomTreasureOpenFromEnvelope derives the delayed wakeup from the
|
||||
// already committed countdown outbox. Scheduling after outbox claim keeps
|
||||
// SendGift free of external MQ side effects before MySQL commit.
|
||||
func (s *Service) scheduleRoomTreasureOpenFromEnvelope(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
|
||||
if s.roomTreasureOpenScheduler == nil || envelope == nil || envelope.GetEventType() != "RoomTreasureCountdownStarted" {
|
||||
return nil
|
||||
}
|
||||
var event roomeventsv1.RoomTreasureCountdownStarted
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
|
||||
return err
|
||||
}
|
||||
if event.GetBoxId() == "" || event.GetLevel() <= 0 || event.GetOpenAtMs() <= 0 {
|
||||
return fmt.Errorf("room treasure countdown event is incomplete: event_id=%s", envelope.GetEventId())
|
||||
}
|
||||
return s.roomTreasureOpenScheduler.ScheduleRoomTreasureOpen(ctx, integration.RoomTreasureOpenSchedule{
|
||||
AppCode: appcode.Normalize(envelope.GetAppCode()),
|
||||
RoomID: envelope.GetRoomId(),
|
||||
BoxID: event.GetBoxId(),
|
||||
Level: event.GetLevel(),
|
||||
OpenAtMS: event.GetOpenAtMs(),
|
||||
CommandID: roomTreasureOpenCommandID(event.GetBoxId()),
|
||||
})
|
||||
}
|
||||
|
||||
// HandleRoomTreasureOpenDue handles a RocketMQ delayed wakeup. It pre-checks
|
||||
// due-ness before writing a command log, because an early-delivered delay
|
||||
// message must not poison the deterministic open command_id as a no-op.
|
||||
func (s *Service) HandleRoomTreasureOpenDue(ctx context.Context, due roommq.RoomTreasureOpenDueMessage) error {
|
||||
ctx = appcode.WithContext(ctx, due.AppCode)
|
||||
if due.RoomID == "" || due.BoxID == "" || due.Level <= 0 || due.OpenAtMS <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "room treasure open due message is incomplete")
|
||||
}
|
||||
|
||||
roomCell, lease, err := s.ensureCell(ctx, due.RoomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
currentState, _, err := roomCell.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owned, err := s.directory.VerifyOwner(ctx, runtimeRoomKeyFromContext(ctx, due.RoomID), s.nodeID, lease.LeaseToken, s.clock.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
return xerr.New(xerr.Conflict, "room treasure open lease moved")
|
||||
}
|
||||
|
||||
now := s.clock.Now().UTC()
|
||||
nowMS := now.UnixMilli()
|
||||
if currentState == nil || currentState.Treasure == nil || currentState.Treasure.Status != state.TreasureStatusCountdown {
|
||||
return nil
|
||||
}
|
||||
treasure := currentState.Treasure
|
||||
if treasure.BoxID != due.BoxID || treasure.CurrentLevel != due.Level || treasure.OpenAtMS != due.OpenAtMS {
|
||||
// Stale delayed messages are expected after retries or if the room reset/opened by another path.
|
||||
return nil
|
||||
}
|
||||
if treasure.ResetAtMS > 0 && nowMS >= treasure.ResetAtMS {
|
||||
// UTC day reset wins over yesterday's delayed wakeup.
|
||||
return nil
|
||||
}
|
||||
if treasure.OpenAtMS > nowMS {
|
||||
return xerr.New(xerr.Unavailable, "room treasure open wakeup arrived before open_at_ms")
|
||||
}
|
||||
|
||||
actorUserID := currentState.OwnerUserID
|
||||
if actorUserID <= 0 {
|
||||
actorUserID = treasure.IgniterUserID
|
||||
}
|
||||
if actorUserID <= 0 {
|
||||
return xerr.New(xerr.InvalidArgument, "room treasure open actor is missing")
|
||||
}
|
||||
commandID := due.CommandID
|
||||
if commandID == "" {
|
||||
commandID = roomTreasureOpenCommandID(due.BoxID)
|
||||
}
|
||||
_, err = s.openRoomTreasure(ctx, command.OpenRoomTreasure{
|
||||
Base: command.Base{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RequestID: idgen.New("req_room_treasure_open_mq"),
|
||||
CommandID: commandID,
|
||||
ActorID: actorUserID,
|
||||
Room: due.RoomID,
|
||||
GatewayNodeID: s.nodeID,
|
||||
SessionID: "room-treasure-open-mq",
|
||||
SentAtMS: nowMS,
|
||||
},
|
||||
BoxID: due.BoxID,
|
||||
Level: due.Level,
|
||||
})
|
||||
return err
|
||||
}
|
||||
@ -0,0 +1,519 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/roommq"
|
||||
"hyapp/services/room-service/internal/integration"
|
||||
roomservice "hyapp/services/room-service/internal/room/service"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
type fixedRoomTreasureClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *fixedRoomTreasureClock) Now() time.Time {
|
||||
return c.now.UTC()
|
||||
}
|
||||
|
||||
type treasureTestWallet struct {
|
||||
debits []*walletv1.DebitGiftResponse
|
||||
grants []*walletv1.GrantResourceGroupRequest
|
||||
}
|
||||
|
||||
func (w *treasureTestWallet) DebitGift(_ context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||||
if len(w.debits) == 0 {
|
||||
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-default", GiftPointAdded: 1, HeatValue: 1}, nil
|
||||
}
|
||||
next := w.debits[0]
|
||||
w.debits = w.debits[1:]
|
||||
return next, nil
|
||||
}
|
||||
|
||||
func (w *treasureTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
|
||||
w.grants = append(w.grants, req)
|
||||
return &walletv1.ResourceGrantResponse{
|
||||
Grant: &walletv1.ResourceGrant{
|
||||
GrantId: fmt.Sprintf("grant-%d-%d", req.GetTargetUserId(), req.GetGroupId()),
|
||||
CommandId: req.GetCommandId(),
|
||||
TargetUserId: req.GetTargetUserId(),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type treasureOpenScheduleRecorder struct {
|
||||
schedules []integration.RoomTreasureOpenSchedule
|
||||
}
|
||||
|
||||
func (r *treasureOpenScheduleRecorder) ScheduleRoomTreasureOpen(_ context.Context, schedule integration.RoomTreasureOpenSchedule) error {
|
||||
r.schedules = append(r.schedules, schedule)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRoomTreasureGiftOverflowAndCountdownInvalidEnergy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
|
||||
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{BillingReceiptId: "receipt-fill", GiftPointAdded: 150, HeatValue: 150, GiftTypeCode: "normal"},
|
||||
{BillingReceiptId: "receipt-countdown", GiftPointAdded: 200, HeatValue: 200, GiftTypeCode: "normal"},
|
||||
}}
|
||||
svc := newTreasureTestService(t, repository, wallet, now)
|
||||
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 1_000))
|
||||
|
||||
roomID := "room-treasure-flow"
|
||||
ownerID := int64(101)
|
||||
viewerID := int64(202)
|
||||
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||
joinTreasureRoom(t, ctx, svc, roomID, viewerID)
|
||||
|
||||
fillResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: treasureMeta(roomID, ownerID, "fill"),
|
||||
TargetType: "user",
|
||||
TargetUserId: viewerID,
|
||||
GiftId: "gift-overflow",
|
||||
GiftCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send fill gift failed: %v", err)
|
||||
}
|
||||
treasure := fillResp.GetTreasure()
|
||||
if treasure.GetCurrentLevel() != 1 || treasure.GetCurrentProgress() != 100 || treasure.GetStatus() != state.TreasureStatusCountdown {
|
||||
t.Fatalf("fill gift must cap level 1 progress and enter countdown: %+v", treasure)
|
||||
}
|
||||
if treasure.GetOpenAtMs() != now.Now().Add(time.Second).UnixMilli() || treasure.GetResetAtMs() != time.Date(2026, 5, 21, 0, 0, 0, 0, time.UTC).UnixMilli() {
|
||||
t.Fatalf("treasure timers must use UTC business time: %+v", treasure)
|
||||
}
|
||||
if treasure.GetTop1UserId() != ownerID || treasure.GetIgniterUserId() != ownerID {
|
||||
t.Fatalf("top1 and igniter must lock at countdown start: %+v", treasure)
|
||||
}
|
||||
|
||||
progressEvents := treasureProgressEvents(t, ctx, repository)
|
||||
if len(progressEvents) != 1 || progressEvents[0].GetAddedEnergy() != 150 || progressEvents[0].GetEffectiveAddedEnergy() != 100 || progressEvents[0].GetOverflowEnergy() != 50 {
|
||||
t.Fatalf("progress event must expose effective energy and discarded overflow: %+v", progressEvents)
|
||||
}
|
||||
if got := countTreasureCountdownEvents(t, ctx, repository); got != 1 {
|
||||
t.Fatalf("fill gift must emit one countdown event, got %d", got)
|
||||
}
|
||||
|
||||
countdownResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: treasureMeta(roomID, viewerID, "countdown"),
|
||||
TargetType: "user",
|
||||
TargetUserId: ownerID,
|
||||
GiftId: "gift-during-countdown",
|
||||
GiftCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send countdown gift failed: %v", err)
|
||||
}
|
||||
if countdownResp.GetTreasure().GetCurrentProgress() != 100 || countdownResp.GetTreasure().GetStatus() != state.TreasureStatusCountdown {
|
||||
t.Fatalf("countdown gift must not change treasure progress: %+v", countdownResp.GetTreasure())
|
||||
}
|
||||
if got := len(treasureProgressEvents(t, ctx, repository)); got != 1 {
|
||||
t.Fatalf("countdown invalid energy must not emit a second progress event, got %d", got)
|
||||
}
|
||||
if got := countTreasureCountdownEvents(t, ctx, repository); got != 1 {
|
||||
t.Fatalf("countdown invalid energy must not emit another countdown event, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomTreasureCountdownOutboxSchedulesDelayedOpen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
|
||||
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{BillingReceiptId: "receipt-fill", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
|
||||
}}
|
||||
scheduler := &treasureOpenScheduleRecorder{}
|
||||
svc := newTreasureTestServiceWithScheduler(t, repository, wallet, now, scheduler)
|
||||
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 1_000))
|
||||
|
||||
roomID := "room-treasure-schedule"
|
||||
ownerID := int64(801)
|
||||
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9008)
|
||||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: treasureMeta(roomID, ownerID, "fill"),
|
||||
TargetType: "user",
|
||||
TargetUserId: ownerID,
|
||||
GiftId: "gift-fill",
|
||||
GiftCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("fill treasure failed: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{PublishTimeout: time.Second, BatchSize: 100}); err != nil {
|
||||
t.Fatalf("process outbox failed: %v", err)
|
||||
}
|
||||
if len(scheduler.schedules) != 1 {
|
||||
t.Fatalf("scheduled opens=%d, want 1: %+v", len(scheduler.schedules), scheduler.schedules)
|
||||
}
|
||||
scheduled := scheduler.schedules[0]
|
||||
if scheduled.RoomID != roomID || scheduled.Level != 1 || scheduled.OpenAtMS != now.Now().Add(time.Second).UnixMilli() || scheduled.CommandID == "" {
|
||||
t.Fatalf("unexpected delayed open schedule: %+v", scheduled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomTreasureOpenDueEarlyMessageDoesNotPoisonCommandID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
|
||||
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{BillingReceiptId: "receipt-ignite", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
|
||||
}}
|
||||
svc := newTreasureTestService(t, repository, wallet, now)
|
||||
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 1_000))
|
||||
|
||||
roomID := "room-treasure-mq-open"
|
||||
ownerID := int64(901)
|
||||
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9009)
|
||||
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: treasureMeta(roomID, ownerID, "ignite"),
|
||||
TargetType: "user",
|
||||
TargetUserId: ownerID,
|
||||
GiftId: "gift-ignite",
|
||||
GiftCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ignite treasure failed: %v", err)
|
||||
}
|
||||
treasure := resp.GetTreasure()
|
||||
due := roommq.RoomTreasureOpenDueMessage{
|
||||
MessageType: roommq.MessageTypeRoomTreasureOpenDue,
|
||||
AppCode: appcode.Default,
|
||||
RoomID: roomID,
|
||||
BoxID: treasure.GetBoxId(),
|
||||
Level: treasure.GetCurrentLevel(),
|
||||
OpenAtMS: treasure.GetOpenAtMs(),
|
||||
CommandID: "cmd_room_treasure_open_" + treasure.GetBoxId(),
|
||||
}
|
||||
|
||||
if err := svc.HandleRoomTreasureOpenDue(ctx, due); err == nil {
|
||||
t.Fatal("early delayed message should ask MQ to retry")
|
||||
}
|
||||
now.now = now.now.Add(time.Second)
|
||||
if err := svc.HandleRoomTreasureOpenDue(ctx, due); err != nil {
|
||||
t.Fatalf("due delayed message should open treasure after retry: %v", err)
|
||||
}
|
||||
info, err := svc.GetRoomTreasure(ctx, &roomv1.GetRoomTreasureRequest{
|
||||
RoomId: roomID,
|
||||
Meta: &roomv1.RequestMeta{
|
||||
AppCode: appcode.Default,
|
||||
RequestId: "req-check",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get treasure failed: %v", err)
|
||||
}
|
||||
if info.GetTreasure().GetState().GetStatus() != state.TreasureStatusIdle || info.GetTreasure().GetState().GetCurrentLevel() != 2 {
|
||||
t.Fatalf("treasure should open once after early retry: %+v", info.GetTreasure().GetState())
|
||||
}
|
||||
if len(wallet.grants) == 0 {
|
||||
t.Fatal("open should grant configured rewards after retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomTreasureOpenGrantsLockedTop1AndIgniterAfterLeave(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC)}
|
||||
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{BillingReceiptId: "receipt-ignite", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
|
||||
}}
|
||||
svc := newTreasureTestService(t, repository, wallet, now)
|
||||
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 1_000))
|
||||
|
||||
roomID := "room-treasure-open"
|
||||
ownerID := int64(301)
|
||||
igniterID := int64(302)
|
||||
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9002)
|
||||
joinTreasureRoom(t, ctx, svc, roomID, igniterID)
|
||||
|
||||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: treasureMeta(roomID, igniterID, "ignite"),
|
||||
TargetType: "user",
|
||||
TargetUserId: ownerID,
|
||||
GiftId: "gift-ignite",
|
||||
GiftCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("ignite treasure failed: %v", err)
|
||||
}
|
||||
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: treasureMeta(roomID, igniterID, "leave")}); err != nil {
|
||||
t.Fatalf("igniter leave room failed: %v", err)
|
||||
}
|
||||
|
||||
now.now = now.now.Add(1100 * time.Millisecond)
|
||||
if err := svc.SweepRoomTreasureOpenings(ctx); err != nil {
|
||||
t.Fatalf("sweep room treasure openings failed: %v", err)
|
||||
}
|
||||
|
||||
if !hasTreasureGrant(wallet.grants, igniterID, 2001) || !hasTreasureGrant(wallet.grants, igniterID, 3001) {
|
||||
t.Fatalf("left top1/igniter must still receive locked rewards: %+v", wallet.grants)
|
||||
}
|
||||
if hasTreasureGrant(wallet.grants, igniterID, 1001) {
|
||||
t.Fatalf("left igniter must not receive in-room reward: %+v", wallet.grants)
|
||||
}
|
||||
if !hasTreasureGrant(wallet.grants, ownerID, 1001) {
|
||||
t.Fatalf("remaining in-room owner must receive in-room reward: %+v", wallet.grants)
|
||||
}
|
||||
|
||||
info, err := svc.GetRoomTreasure(ctx, &roomv1.GetRoomTreasureRequest{
|
||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
|
||||
RoomId: roomID,
|
||||
ViewerUserId: ownerID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get treasure after open failed: %v", err)
|
||||
}
|
||||
stateView := info.GetTreasure().GetState()
|
||||
if stateView.GetCurrentLevel() != 2 || stateView.GetStatus() != state.TreasureStatusIdle {
|
||||
t.Fatalf("opened treasure must advance to next idle level: %+v", stateView)
|
||||
}
|
||||
if len(stateView.GetLastRewards()) != 3 {
|
||||
t.Fatalf("opened treasure must retain in-room, top1 and igniter grants for client display: %+v", stateView.GetLastRewards())
|
||||
}
|
||||
if got := treasureRewardUserIDs(stateView.GetLastRewards()); !slices.Equal(got, []int64{301, 302, 302}) {
|
||||
t.Fatalf("reward users mismatch: got %+v rewards=%+v", got, stateView.GetLastRewards())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomTreasureUTCResetCancelsCrossDayCountdown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomTreasureClock{now: time.Date(2026, 5, 20, 23, 59, 59, 0, time.UTC)}
|
||||
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{
|
||||
{BillingReceiptId: "receipt-cross-day", GiftPointAdded: 100, HeatValue: 100, GiftTypeCode: "normal"},
|
||||
{BillingReceiptId: "receipt-new-day", GiftPointAdded: 10, HeatValue: 10, GiftTypeCode: "normal"},
|
||||
}}
|
||||
svc := newTreasureTestService(t, repository, wallet, now)
|
||||
writeTreasureConfig(t, ctx, repository, treasureConfigForTest(100, 2_000))
|
||||
|
||||
roomID := "room-treasure-reset"
|
||||
ownerID := int64(401)
|
||||
viewerID := int64(402)
|
||||
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9003)
|
||||
joinTreasureRoom(t, ctx, svc, roomID, viewerID)
|
||||
|
||||
fillResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: treasureMeta(roomID, viewerID, "cross-day-fill"),
|
||||
TargetType: "user",
|
||||
TargetUserId: ownerID,
|
||||
GiftId: "gift-cross-day",
|
||||
GiftCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send cross-day gift failed: %v", err)
|
||||
}
|
||||
if fillResp.GetTreasure().GetStatus() != state.TreasureStatusCountdown || fillResp.GetTreasure().GetOpenAtMs() <= fillResp.GetTreasure().GetResetAtMs() {
|
||||
t.Fatalf("fixture must create a countdown whose open time crosses UTC reset: %+v", fillResp.GetTreasure())
|
||||
}
|
||||
|
||||
now.now = time.Date(2026, 5, 21, 0, 0, 2, 0, time.UTC)
|
||||
if err := svc.SweepRoomTreasureOpenings(ctx); err != nil {
|
||||
t.Fatalf("sweep cross-day treasure failed: %v", err)
|
||||
}
|
||||
if len(wallet.grants) != 0 {
|
||||
t.Fatalf("cross-day countdown must be canceled by UTC reset, grants=%+v", wallet.grants)
|
||||
}
|
||||
|
||||
info, err := svc.GetRoomTreasure(ctx, &roomv1.GetRoomTreasureRequest{
|
||||
Meta: &roomv1.RequestMeta{AppCode: appcode.Default, RoomId: roomID},
|
||||
RoomId: roomID,
|
||||
ViewerUserId: ownerID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("get treasure after UTC reset failed: %v", err)
|
||||
}
|
||||
if stateView := info.GetTreasure().GetState(); stateView.GetCurrentLevel() != 1 || stateView.GetCurrentProgress() != 0 || stateView.GetStatus() != state.TreasureStatusIdle {
|
||||
t.Fatalf("query view must reset stale countdown at UTC boundary: %+v", stateView)
|
||||
}
|
||||
|
||||
nextResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: treasureMeta(roomID, ownerID, "new-day-gift"),
|
||||
TargetType: "user",
|
||||
TargetUserId: viewerID,
|
||||
GiftId: "gift-new-day",
|
||||
GiftCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send new-day gift failed: %v", err)
|
||||
}
|
||||
if nextResp.GetTreasure().GetCurrentLevel() != 1 || nextResp.GetTreasure().GetCurrentProgress() != 10 || nextResp.GetTreasure().GetStatus() != state.TreasureStatusIdle {
|
||||
t.Fatalf("first new-day gift must start a fresh level 1 treasure: %+v", nextResp.GetTreasure())
|
||||
}
|
||||
}
|
||||
|
||||
func newTreasureTestService(t *testing.T, repository *mysqltest.Repository, wallet *treasureTestWallet, clock *fixedRoomTreasureClock) *roomservice.Service {
|
||||
t.Helper()
|
||||
return newTreasureTestServiceWithScheduler(t, repository, wallet, clock, nil)
|
||||
}
|
||||
|
||||
func newTreasureTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repository, wallet *treasureTestWallet, clock *fixedRoomTreasureClock, scheduler integration.RoomTreasureOpenScheduler) *roomservice.Service {
|
||||
t.Helper()
|
||||
|
||||
return roomservice.New(roomservice.Config{
|
||||
NodeID: "node-treasure-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
Clock: clock,
|
||||
RoomTreasureOpenScheduler: scheduler,
|
||||
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||
}
|
||||
|
||||
func treasureConfigForTest(threshold int64, openDelayMS int64) roomservice.RoomTreasureConfig {
|
||||
levels := make([]roomservice.RoomTreasureLevelConfig, 0, 7)
|
||||
for level := int32(1); level <= 7; level++ {
|
||||
levels = append(levels, roomservice.RoomTreasureLevelConfig{
|
||||
Level: level,
|
||||
EnergyThreshold: threshold + int64(level-1)*100,
|
||||
CoverURL: fmt.Sprintf("https://example.test/treasure/%d-cover.png", level),
|
||||
AnimationURL: fmt.Sprintf("https://example.test/treasure/%d-idle.webp", level),
|
||||
OpeningAnimationURL: fmt.Sprintf("https://example.test/treasure/%d-opening.webp", level),
|
||||
OpenedImageURL: fmt.Sprintf("https://example.test/treasure/%d-open.png", level),
|
||||
InRoomRewards: []roomservice.RoomTreasureRewardItem{{
|
||||
RewardItemID: fmt.Sprintf("level-%d-in-room", level),
|
||||
ResourceGroupID: 1000 + int64(level),
|
||||
Weight: 1,
|
||||
DisplayName: "In Room",
|
||||
IconURL: "https://example.test/reward/in-room.png",
|
||||
}},
|
||||
Top1Rewards: []roomservice.RoomTreasureRewardItem{{
|
||||
RewardItemID: fmt.Sprintf("level-%d-top1", level),
|
||||
ResourceGroupID: 2000 + int64(level),
|
||||
Weight: 1,
|
||||
DisplayName: "Top 1",
|
||||
IconURL: "https://example.test/reward/top1.png",
|
||||
}},
|
||||
IgniterRewards: []roomservice.RoomTreasureRewardItem{{
|
||||
RewardItemID: fmt.Sprintf("level-%d-igniter", level),
|
||||
ResourceGroupID: 3000 + int64(level),
|
||||
Weight: 1,
|
||||
DisplayName: "Igniter",
|
||||
IconURL: "https://example.test/reward/igniter.png",
|
||||
}},
|
||||
})
|
||||
}
|
||||
return roomservice.RoomTreasureConfig{
|
||||
AppCode: appcode.Default,
|
||||
Enabled: true,
|
||||
ConfigVersion: 1,
|
||||
EnergySource: "gift_point_added",
|
||||
OpenDelayMS: openDelayMS,
|
||||
BroadcastEnabled: true,
|
||||
BroadcastScope: "region",
|
||||
RewardStackPolicy: "allow_stack",
|
||||
Levels: levels,
|
||||
}
|
||||
}
|
||||
|
||||
func writeTreasureConfig(t *testing.T, ctx context.Context, repository *mysqltest.Repository, config roomservice.RoomTreasureConfig) {
|
||||
t.Helper()
|
||||
|
||||
if err := repository.UpsertRoomTreasureConfig(ctx, config); err != nil {
|
||||
t.Fatalf("upsert treasure config failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTreasureRoom(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, ownerID int64, regionID int64) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||
SeatCount: 10,
|
||||
Mode: "voice",
|
||||
VisibleRegionId: regionID,
|
||||
RoomName: roomID,
|
||||
RoomShortId: roomID,
|
||||
}); err != nil {
|
||||
t.Fatalf("create treasure room failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func joinTreasureRoom(t *testing.T, ctx context.Context, svc *roomservice.Service, roomID string, userID int64) {
|
||||
t.Helper()
|
||||
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, userID),
|
||||
Role: "audience",
|
||||
}); err != nil {
|
||||
t.Fatalf("join treasure room failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func treasureMeta(roomID string, actorUserID int64, suffix string) *roomv1.RequestMeta {
|
||||
return &roomv1.RequestMeta{
|
||||
RequestId: "req-room-treasure-" + suffix,
|
||||
CommandId: "cmd-room-treasure-" + suffix,
|
||||
ActorUserId: actorUserID,
|
||||
RoomId: roomID,
|
||||
AppCode: appcode.Default,
|
||||
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func treasureProgressEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomTreasureProgressChanged {
|
||||
t.Helper()
|
||||
|
||||
records, err := repository.ListPendingOutbox(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending outbox failed: %v", err)
|
||||
}
|
||||
events := make([]*roomeventsv1.RoomTreasureProgressChanged, 0)
|
||||
for _, record := range records {
|
||||
if record.EventType != "RoomTreasureProgressChanged" {
|
||||
continue
|
||||
}
|
||||
var event roomeventsv1.RoomTreasureProgressChanged
|
||||
if err := proto.Unmarshal(record.Envelope.GetBody(), &event); err != nil {
|
||||
t.Fatalf("decode treasure progress event failed: %v", err)
|
||||
}
|
||||
events = append(events, &event)
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func countTreasureCountdownEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) int {
|
||||
t.Helper()
|
||||
|
||||
records, err := repository.ListPendingOutbox(ctx, 50)
|
||||
if err != nil {
|
||||
t.Fatalf("list pending outbox failed: %v", err)
|
||||
}
|
||||
count := 0
|
||||
for _, record := range records {
|
||||
if record.EventType == "RoomTreasureCountdownStarted" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func hasTreasureGrant(grants []*walletv1.GrantResourceGroupRequest, userID int64, groupID int64) bool {
|
||||
for _, grant := range grants {
|
||||
if grant.GetTargetUserId() == userID && grant.GetGroupId() == groupID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func treasureRewardUserIDs(rewards []*roomv1.RoomTreasureRewardGrant) []int64 {
|
||||
userIDs := make([]int64, 0, len(rewards))
|
||||
for _, reward := range rewards {
|
||||
userIDs = append(userIDs, reward.GetUserId())
|
||||
}
|
||||
slices.Sort(userIDs)
|
||||
return userIDs
|
||||
}
|
||||
@ -32,6 +32,8 @@ type Config struct {
|
||||
MicPublishTimeout time.Duration
|
||||
// RTCUserRemover 是平台级封禁后把用户从腾讯 RTC 房间移出的外部边界。
|
||||
RTCUserRemover integration.RTCUserRemover
|
||||
// RoomTreasureOpenScheduler 把倒计时开箱唤醒交给外部延迟消息,避免只靠已加载 Cell 扫描。
|
||||
RoomTreasureOpenScheduler integration.RoomTreasureOpenScheduler
|
||||
// Clock 允许测试注入稳定时间,生产默认使用系统时钟。
|
||||
Clock clock.Clock
|
||||
}
|
||||
@ -62,6 +64,8 @@ type Service struct {
|
||||
syncPublisher integration.RoomEventPublisher
|
||||
// outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。
|
||||
outboxPublisher integration.OutboxPublisher
|
||||
// roomTreasureOpenScheduler 在 countdown outbox 投递成功后安排 open_at_ms 唤醒。
|
||||
roomTreasureOpenScheduler integration.RoomTreasureOpenScheduler
|
||||
// rtcUserRemover 在 Room Cell 提交驱逐后同步移除实时音频房连接。
|
||||
rtcUserRemover integration.RTCUserRemover
|
||||
// draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。
|
||||
@ -142,20 +146,21 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
||||
}
|
||||
|
||||
return &Service{
|
||||
nodeID: cfg.NodeID,
|
||||
leaseTTL: cfg.LeaseTTL,
|
||||
rankLimit: rankLimit,
|
||||
snapshotEveryN: snapshotEveryN,
|
||||
presenceStaleAfter: presenceStaleAfter,
|
||||
micPublishTimeout: micPublishTimeout,
|
||||
clock: usedClock,
|
||||
directory: directory,
|
||||
repository: repository,
|
||||
wallet: wallet,
|
||||
syncPublisher: syncPublisher,
|
||||
outboxPublisher: outboxPublisher,
|
||||
rtcUserRemover: cfg.RTCUserRemover,
|
||||
cells: make(map[string]*cell.RoomCell),
|
||||
nodeID: cfg.NodeID,
|
||||
leaseTTL: cfg.LeaseTTL,
|
||||
rankLimit: rankLimit,
|
||||
snapshotEveryN: snapshotEveryN,
|
||||
presenceStaleAfter: presenceStaleAfter,
|
||||
micPublishTimeout: micPublishTimeout,
|
||||
clock: usedClock,
|
||||
directory: directory,
|
||||
repository: repository,
|
||||
wallet: wallet,
|
||||
syncPublisher: syncPublisher,
|
||||
outboxPublisher: outboxPublisher,
|
||||
roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler,
|
||||
rtcUserRemover: cfg.RTCUserRemover,
|
||||
cells: make(map[string]*cell.RoomCell),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -18,6 +18,11 @@ const (
|
||||
// RoomStatusClosed 表示房间已经关闭,guard 和进房都必须拒绝。
|
||||
RoomStatusClosed = "closed"
|
||||
|
||||
// TreasureStatusIdle 表示当前等级宝箱还在积攒能量。
|
||||
TreasureStatusIdle = "idle"
|
||||
// TreasureStatusCountdown 表示当前等级已经满能量,等待后台 worker 到点开箱。
|
||||
TreasureStatusCountdown = "countdown"
|
||||
|
||||
// MicPublishIdle 表示麦位没有正在确认或已确认的 RTC 发流会话。
|
||||
MicPublishIdle = "idle"
|
||||
// MicPublishPending 表示业务上麦成功,但客户端/RTC 尚未确认音频发布成功。
|
||||
@ -103,6 +108,62 @@ type RankItem struct {
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// TreasureRewardGrant 记录一次宝箱开箱时给单个用户结算出的奖励。
|
||||
type TreasureRewardGrant struct {
|
||||
// RewardRole 区分 in_room、top1、igniter 三种奖励归因,客户端据此分组展示。
|
||||
RewardRole string
|
||||
// UserID 是奖励归属用户;top1 和 igniter 即使离房也必须用满能量时锁定的用户。
|
||||
UserID int64
|
||||
// RewardItemID 是后台奖励池内的稳定项 ID,用于排障和客户端去重。
|
||||
RewardItemID string
|
||||
// ResourceGroupID 是 wallet-service 发放资源组 ID。
|
||||
ResourceGroupID int64
|
||||
// DisplayName/IconURL 是开箱 IM 展示快照,避免客户端为历史消息回查后台配置。
|
||||
DisplayName string
|
||||
IconURL string
|
||||
// GrantID 是 wallet-service 资源发放回执;空值表示该奖励未实际发放。
|
||||
GrantID string
|
||||
// Status 表达发放结果,当前成功路径写 succeeded。
|
||||
Status string
|
||||
}
|
||||
|
||||
// TreasureState 保存 Room Cell 内当前宝箱进度和最近一次开箱结果。
|
||||
type TreasureState struct {
|
||||
// CurrentLevel 是当前正在积攒的宝箱等级,UTC 日重置后回到 1。
|
||||
CurrentLevel int32
|
||||
// CurrentProgress 是当前等级已累计能量,满级后的溢出不跨等级继承。
|
||||
CurrentProgress int64
|
||||
// EnergyThreshold 是当前等级阈值快照,便于客户端不依赖配置二次计算。
|
||||
EnergyThreshold int64
|
||||
// Status 区分 idle/countdown,倒计时期间送礼能量无效。
|
||||
Status string
|
||||
// CountdownStartedAtMS/OpenAtMS/OpenedAtMS 描述最近一次满能量到开箱的时间线。
|
||||
CountdownStartedAtMS int64
|
||||
OpenAtMS int64
|
||||
OpenedAtMS int64
|
||||
// ResetAtMS 是下一次 UTC 自然日重置毫秒时间。
|
||||
ResetAtMS int64
|
||||
// Top1UserID 和 IgniterUserID 在满能量瞬间锁定,用户离房或下线仍按这里发奖。
|
||||
Top1UserID int64
|
||||
IgniterUserID int64
|
||||
// BoxID 是单次宝箱从积攒到开箱的幂等标识,奖励抽取和 wallet command_id 都依赖它。
|
||||
BoxID string
|
||||
// ConfigVersion 记录本轮宝箱使用的后台配置版本,开箱时不被中途配置变更影响。
|
||||
ConfigVersion int64
|
||||
// LastRewards 保存最近一次开箱发奖结果,用于断线重连后补 UI。
|
||||
LastRewards []TreasureRewardGrant
|
||||
}
|
||||
|
||||
// Clone 深拷贝宝箱状态,避免 Room Cell 失败命令污染当前状态。
|
||||
func (t *TreasureState) Clone() *TreasureState {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *t
|
||||
cloned.LastRewards = append([]TreasureRewardGrant(nil), t.LastRewards...)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
// RoomState 是 Room Cell 的核心业务状态。
|
||||
// 这里刻意不放钱包余额、完整用户资料和跨房活动状态。
|
||||
type RoomState struct {
|
||||
@ -136,6 +197,8 @@ type RoomState struct {
|
||||
GiftRank []RankItem
|
||||
// RoomExt 预留少量房间扩展字段,避免 v1 频繁改结构。
|
||||
RoomExt map[string]string
|
||||
// Treasure 是语音房宝箱状态,送礼能量和开箱必须跟随 Room Cell 串行提交。
|
||||
Treasure *TreasureState
|
||||
// Version 是房间状态版本,成功变更后递增,快照和 command log 都依赖它。
|
||||
Version int64
|
||||
}
|
||||
@ -191,6 +254,7 @@ func (s *RoomState) Clone() *RoomState {
|
||||
Heat: s.Heat,
|
||||
GiftRank: append([]RankItem(nil), s.GiftRank...),
|
||||
RoomExt: make(map[string]string, len(s.RoomExt)),
|
||||
Treasure: cloneTreasureState(s.Treasure),
|
||||
Version: s.Version,
|
||||
}
|
||||
|
||||
@ -332,6 +396,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
|
||||
Version: s.Version,
|
||||
RoomShortId: s.RoomExt["room_short_id"],
|
||||
Locked: s.RoomPasswordHash != "",
|
||||
Treasure: treasureStateToProto(s.Treasure),
|
||||
}
|
||||
}
|
||||
|
||||
@ -357,6 +422,7 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
|
||||
MuteUsers: make(map[int64]bool, len(snapshot.GetMuteUserIds())),
|
||||
GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())),
|
||||
RoomExt: cloneStringMap(snapshot.GetRoomExt()),
|
||||
Treasure: treasureStateFromProto(snapshot.GetTreasure()),
|
||||
Heat: snapshot.GetHeat(),
|
||||
Version: snapshot.GetVersion(),
|
||||
}
|
||||
@ -441,3 +507,80 @@ func cloneStringMap(input map[string]string) map[string]string {
|
||||
|
||||
return cloned
|
||||
}
|
||||
|
||||
func cloneTreasureState(input *TreasureState) *TreasureState {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *input
|
||||
cloned.LastRewards = append([]TreasureRewardGrant(nil), input.LastRewards...)
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func treasureStateToProto(input *TreasureState) *roomv1.RoomTreasureState {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
rewards := make([]*roomv1.RoomTreasureRewardGrant, 0, len(input.LastRewards))
|
||||
for _, reward := range input.LastRewards {
|
||||
rewards = append(rewards, &roomv1.RoomTreasureRewardGrant{
|
||||
RewardRole: reward.RewardRole,
|
||||
UserId: reward.UserID,
|
||||
RewardItemId: reward.RewardItemID,
|
||||
ResourceGroupId: reward.ResourceGroupID,
|
||||
DisplayName: reward.DisplayName,
|
||||
IconUrl: reward.IconURL,
|
||||
GrantId: reward.GrantID,
|
||||
Status: reward.Status,
|
||||
})
|
||||
}
|
||||
return &roomv1.RoomTreasureState{
|
||||
CurrentLevel: input.CurrentLevel,
|
||||
CurrentProgress: input.CurrentProgress,
|
||||
EnergyThreshold: input.EnergyThreshold,
|
||||
Status: input.Status,
|
||||
CountdownStartedAtMs: input.CountdownStartedAtMS,
|
||||
OpenAtMs: input.OpenAtMS,
|
||||
OpenedAtMs: input.OpenedAtMS,
|
||||
ResetAtMs: input.ResetAtMS,
|
||||
Top1UserId: input.Top1UserID,
|
||||
IgniterUserId: input.IgniterUserID,
|
||||
BoxId: input.BoxID,
|
||||
ConfigVersion: input.ConfigVersion,
|
||||
LastRewards: rewards,
|
||||
}
|
||||
}
|
||||
|
||||
func treasureStateFromProto(input *roomv1.RoomTreasureState) *TreasureState {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
rewards := make([]TreasureRewardGrant, 0, len(input.GetLastRewards()))
|
||||
for _, reward := range input.GetLastRewards() {
|
||||
rewards = append(rewards, TreasureRewardGrant{
|
||||
RewardRole: reward.GetRewardRole(),
|
||||
UserID: reward.GetUserId(),
|
||||
RewardItemID: reward.GetRewardItemId(),
|
||||
ResourceGroupID: reward.GetResourceGroupId(),
|
||||
DisplayName: reward.GetDisplayName(),
|
||||
IconURL: reward.GetIconUrl(),
|
||||
GrantID: reward.GetGrantId(),
|
||||
Status: reward.GetStatus(),
|
||||
})
|
||||
}
|
||||
return &TreasureState{
|
||||
CurrentLevel: input.GetCurrentLevel(),
|
||||
CurrentProgress: input.GetCurrentProgress(),
|
||||
EnergyThreshold: input.GetEnergyThreshold(),
|
||||
Status: input.GetStatus(),
|
||||
CountdownStartedAtMS: input.GetCountdownStartedAtMs(),
|
||||
OpenAtMS: input.GetOpenAtMs(),
|
||||
OpenedAtMS: input.GetOpenedAtMs(),
|
||||
ResetAtMS: input.GetResetAtMs(),
|
||||
Top1UserID: input.GetTop1UserId(),
|
||||
IgniterUserID: input.GetIgniterUserId(),
|
||||
BoxID: input.GetBoxId(),
|
||||
ConfigVersion: input.GetConfigVersion(),
|
||||
LastRewards: rewards,
|
||||
}
|
||||
}
|
||||
|
||||
@ -226,8 +226,65 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_treasure_configs (
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
config_version BIGINT NOT NULL DEFAULT 1,
|
||||
energy_source VARCHAR(32) NOT NULL,
|
||||
open_delay_ms BIGINT NOT NULL,
|
||||
broadcast_enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
broadcast_scope VARCHAR(32) NOT NULL,
|
||||
broadcast_delay_ms BIGINT NOT NULL DEFAULT 0,
|
||||
reward_stack_policy VARCHAR(32) NOT NULL,
|
||||
levels_json JSON NOT NULL,
|
||||
gift_energy_rules_json JSON NOT NULL,
|
||||
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code),
|
||||
KEY idx_room_treasure_enabled (app_code, enabled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`INSERT IGNORE INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms)
|
||||
VALUES ('lalu', '[10,15,20,25,30]', 15, 0, 0)`,
|
||||
`INSERT IGNORE INTO room_treasure_configs (
|
||||
app_code,
|
||||
enabled,
|
||||
config_version,
|
||||
energy_source,
|
||||
open_delay_ms,
|
||||
broadcast_enabled,
|
||||
broadcast_scope,
|
||||
broadcast_delay_ms,
|
||||
reward_stack_policy,
|
||||
levels_json,
|
||||
gift_energy_rules_json,
|
||||
updated_by_admin_id,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
) VALUES (
|
||||
'lalu',
|
||||
0,
|
||||
1,
|
||||
'gift_point_added',
|
||||
30000,
|
||||
1,
|
||||
'region',
|
||||
0,
|
||||
'allow_stack',
|
||||
JSON_ARRAY(
|
||||
JSON_OBJECT('level', 1, 'energyThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 2, 'energyThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 3, 'energyThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 4, 'energyThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 5, 'energyThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 6, 'energyThreshold', 21000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()),
|
||||
JSON_OBJECT('level', 7, 'energyThreshold', 28000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY())
|
||||
),
|
||||
JSON_ARRAY(),
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)`,
|
||||
`ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
`ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
`ALTER TABLE room_user_presence MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
@ -238,6 +295,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
`ALTER TABLE room_follows MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
`ALTER TABLE room_region_pins MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
`ALTER TABLE room_seat_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
`ALTER TABLE room_treasure_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
}
|
||||
|
||||
for _, statement := range statements {
|
||||
@ -672,6 +730,111 @@ func (r *Repository) UpsertRoomSeatConfig(ctx context.Context, config roomservic
|
||||
return err
|
||||
}
|
||||
|
||||
// GetRoomTreasureConfig 读取当前 App 的语音房宝箱配置。
|
||||
func (r *Repository) GetRoomTreasureConfig(ctx context.Context) (roomservice.RoomTreasureConfig, bool, error) {
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT app_code, enabled, config_version, energy_source, open_delay_ms,
|
||||
broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy,
|
||||
levels_json, gift_energy_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
FROM room_treasure_configs
|
||||
WHERE app_code = ?`,
|
||||
appcode.FromContext(ctx),
|
||||
)
|
||||
|
||||
var config roomservice.RoomTreasureConfig
|
||||
var enabled int
|
||||
var broadcastEnabled int
|
||||
var rawLevels string
|
||||
var rawRules string
|
||||
if err := row.Scan(
|
||||
&config.AppCode,
|
||||
&enabled,
|
||||
&config.ConfigVersion,
|
||||
&config.EnergySource,
|
||||
&config.OpenDelayMS,
|
||||
&broadcastEnabled,
|
||||
&config.BroadcastScope,
|
||||
&config.BroadcastDelayMS,
|
||||
&config.RewardStackPolicy,
|
||||
&rawLevels,
|
||||
&rawRules,
|
||||
&config.UpdatedByAdminID,
|
||||
&config.CreatedAtMS,
|
||||
&config.UpdatedAtMS,
|
||||
); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return roomservice.RoomTreasureConfig{}, false, nil
|
||||
}
|
||||
return roomservice.RoomTreasureConfig{}, false, err
|
||||
}
|
||||
config.Enabled = enabled == 1
|
||||
config.BroadcastEnabled = broadcastEnabled == 1
|
||||
if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil {
|
||||
return roomservice.RoomTreasureConfig{}, false, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(rawRules), &config.GiftEnergyRules); err != nil {
|
||||
return roomservice.RoomTreasureConfig{}, false, err
|
||||
}
|
||||
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
// UpsertRoomTreasureConfig 写入当前 App 的语音房宝箱配置。
|
||||
func (r *Repository) UpsertRoomTreasureConfig(ctx context.Context, config roomservice.RoomTreasureConfig) error {
|
||||
appCode := normalizedRecordAppCode(ctx, config.AppCode)
|
||||
rawLevels, err := json.Marshal(config.Levels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rawRules, err := json.Marshal(config.GiftEnergyRules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nowMS := config.UpdatedAtMS
|
||||
if nowMS <= 0 {
|
||||
nowMS = time.Now().UTC().UnixMilli()
|
||||
}
|
||||
createdAtMS := config.CreatedAtMS
|
||||
if createdAtMS <= 0 {
|
||||
createdAtMS = nowMS
|
||||
}
|
||||
_, err = r.db.ExecContext(ctx,
|
||||
`INSERT INTO room_treasure_configs (
|
||||
app_code, enabled, config_version, energy_source, open_delay_ms,
|
||||
broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy,
|
||||
levels_json, gift_energy_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
config_version = VALUES(config_version),
|
||||
energy_source = VALUES(energy_source),
|
||||
open_delay_ms = VALUES(open_delay_ms),
|
||||
broadcast_enabled = VALUES(broadcast_enabled),
|
||||
broadcast_scope = VALUES(broadcast_scope),
|
||||
broadcast_delay_ms = VALUES(broadcast_delay_ms),
|
||||
reward_stack_policy = VALUES(reward_stack_policy),
|
||||
levels_json = VALUES(levels_json),
|
||||
gift_energy_rules_json = VALUES(gift_energy_rules_json),
|
||||
updated_by_admin_id = VALUES(updated_by_admin_id),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
appCode,
|
||||
boolToInt(config.Enabled),
|
||||
config.ConfigVersion,
|
||||
config.EnergySource,
|
||||
config.OpenDelayMS,
|
||||
boolToInt(config.BroadcastEnabled),
|
||||
config.BroadcastScope,
|
||||
config.BroadcastDelayMS,
|
||||
config.RewardStackPolicy,
|
||||
string(rawLevels),
|
||||
string(rawRules),
|
||||
config.UpdatedByAdminID,
|
||||
createdAtMS,
|
||||
nowMS,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListCommandsAfter 返回快照版本之后的可回放命令日志。
|
||||
func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) {
|
||||
// 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。
|
||||
@ -1771,6 +1934,13 @@ func normalizedRecordAppCode(ctx context.Context, value string) string {
|
||||
return appcode.Normalize(value)
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func mapRoomMetaDuplicateError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
|
||||
@ -390,6 +390,18 @@ func (s *Server) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapsho
|
||||
return mapServiceResult(s.svc.GetRoomSnapshot(ctx, req))
|
||||
}
|
||||
|
||||
// GetRoomTreasure 代理到房间宝箱只读查询。
|
||||
func (s *Server) GetRoomTreasure(ctx context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error) {
|
||||
// 宝箱查询不刷新 presence;当前进度仍以 Room Cell 快照和 UTC 日边界为准。
|
||||
ctx = contextWithMetaApp(ctx, req.GetMeta())
|
||||
if resp, forwarded, err := forwardQuery(s, ctx, req.GetMeta().GetAppCode(), req.GetRoomId(), func(callCtx context.Context, client roomv1.RoomQueryServiceClient) (*roomv1.GetRoomTreasureResponse, error) {
|
||||
return client.GetRoomTreasure(callCtx, req)
|
||||
}); forwarded {
|
||||
return resp, err
|
||||
}
|
||||
return mapServiceResult(s.svc.GetRoomTreasure(ctx, req))
|
||||
}
|
||||
|
||||
// ListRoomOnlineUsers 代理到房间在线用户分页读模型。
|
||||
func (s *Server) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
|
||||
// 在线人数弹窗只读 room_user_presence 投影,不拉完整 RoomSnapshot 做分页。
|
||||
|
||||
@ -30,6 +30,11 @@ func (fakeWallet) DebitGift(context.Context, *walletv1.DebitGiftRequest) (*walle
|
||||
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-test"}, nil
|
||||
}
|
||||
|
||||
// GrantResourceGroup 返回固定发放结果,避免 transport 测试依赖 wallet-service 资源组实现。
|
||||
func (fakeWallet) GrantResourceGroup(context.Context, *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
|
||||
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-test"}}, nil
|
||||
}
|
||||
|
||||
// newTransportTestServer 构造完整领域服务,确保测试覆盖真实 service -> transport 错误边界。
|
||||
func newTransportTestServer(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
@ -73,6 +73,7 @@ type Receipt struct {
|
||||
ChargeAmount int64
|
||||
GiftPointAdded int64
|
||||
HeatValue int64
|
||||
GiftTypeCode string
|
||||
PriceVersion string
|
||||
BalanceAfter int64
|
||||
}
|
||||
|
||||
@ -1372,6 +1372,7 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger
|
||||
ChargeAmount: chargeAmount,
|
||||
GiftPointAdded: metadata.GiftPointAdded,
|
||||
HeatValue: metadata.HeatValue,
|
||||
GiftTypeCode: metadata.GiftTypeCode,
|
||||
PriceVersion: metadata.PriceVersion,
|
||||
BalanceAfter: metadata.BalanceAfter,
|
||||
}
|
||||
|
||||
@ -49,6 +49,7 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest)
|
||||
ChargeAmount: receipt.ChargeAmount,
|
||||
GiftPointAdded: receipt.GiftPointAdded,
|
||||
HeatValue: receipt.HeatValue,
|
||||
GiftTypeCode: receipt.GiftTypeCode,
|
||||
PriceVersion: receipt.PriceVersion,
|
||||
}, nil
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user