房间语音房宝箱

This commit is contained in:
zhx 2026-05-20 14:34:10 +08:00
parent 7499582c45
commit 36da9df0c7
81 changed files with 7477 additions and 1021 deletions

View File

@ -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 等本地配置只能用开发值,线上必须走安全配置源。

View File

@ -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,
},

View File

@ -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

View File

@ -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);
}

View File

@ -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,

View File

@ -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" +

View File

@ -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

View File

@ -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` 只表示事件已进入 RocketMQactivity、notice、IM bridge 的成功状态由各自消费位点负责。
- `rocketmq.treasure_open.enabled: true` 后,宝箱倒计时使用毫秒级 `open_at_ms` 投递延迟消息;消息到达后 room-service 仍要重新校验 Room Cell 状态,不能把 MQ 当成宝箱状态来源。
- 本地 Docker 默认关闭 MQ使用 direct publisher 维持无云依赖的可运行形态。
## 真实数据验证
本地真实 MySQL/Redis 验证:

View File

@ -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` outboxworker 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线上推荐使用 RocketMQactivity-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 不影响领取接口的后端金额校验 |

View File

@ -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` 写余额 outboxnotice 不需要新增特殊账务逻辑,只要客户端按 `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`,独立重试和死信。
## 真实库验证

View File

@ -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

View File

@ -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` |

View File

@ -81,7 +81,7 @@
| 礼物配置 | 礼物 ID、价格、热度、表现可配置 |
| SendGift | 扣费成功后进入 Room Cell |
| 房间表现 | 热度、本地榜、礼物消息同步变化 |
| outbox | activity/audit 后续可消费完整事件 |
| outbox | activity、notice、IM bridge 可通过 direct 或 RocketMQ 消费完整房间事件 |
验收:用户送普通礼物后金币减少、房间热度增加、榜单变化、消息广播成功。

View File

@ -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 |

View File

@ -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 发布 RocketMQactivity、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` roundCloseRoom 必须先提交开箱快照或取消策略;推荐先快照结算,再关闭房间 |
| 房间无人 | 在房奖励参与人为空;点火人和贡献第一仍按 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 只投 RocketMQactivity/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 logMQ 后续可重试 |
| 到点开箱 | 即使房间未加载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
View File

@ -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
View File

@ -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
View 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
View 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
}

View 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)
}
}

View File

@ -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),

View 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)
}

View 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)
}

View 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
}

View 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)
}
}

View File

@ -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
}

View File

@ -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)

View 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';

View File

@ -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');

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
}
}

View File

@ -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
}

View 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)
}
}

View File

@ -12,6 +12,8 @@ const (
TypeSuperGift = "super_gift"
// TypeRedPacket 是红包入口播报,资金事实必须由 wallet/red-packet 领域持久化。
TypeRedPacket = "red_packet"
// TypeRoomTreasure 是语音房宝箱满能量后的开箱倒计时播报。
TypeRoomTreasure = "room_treasure"
// StatusPending 表示消息已持久化,尚未被 worker claim。
StatusPending = "pending"

View File

@ -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 {

View File

@ -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)
}

View File

@ -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)

View File

@ -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 {

View File

@ -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,

View File

@ -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) {

View File

@ -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{}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,
}
}

View File

@ -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 {

View 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)
}
}

View File

@ -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")
}

View File

@ -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 {

View File

@ -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

View File

@ -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"

View File

@ -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 只投 MQactivity/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"

View File

@ -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"

View File

@ -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
);

View File

@ -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 替代自研 IMroom-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()
}
}

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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),
},
})
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。

View 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
}

View 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
}

View File

@ -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
}

View File

@ -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),
}
}

View File

@ -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,
}
}

View File

@ -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

View File

@ -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 做分页。

View File

@ -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()

View File

@ -73,6 +73,7 @@ type Receipt struct {
ChargeAmount int64
GiftPointAdded int64
HeatValue int64
GiftTypeCode string
PriceVersion string
BalanceAfter int64
}

View File

@ -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,
}

View File

@ -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
}