房间语音房宝箱

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 服务。 - `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` 同步调用它。 - `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL`room-service``SendGift` 同步调用它。
- `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。 - `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 批处理;它不直接拥有房间、账务、用户或活动业务状态。 - `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` - 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`
- 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md` - 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`
@ -23,7 +23,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
- 客户端命令面:`gateway-service` 暴露 HTTP JSON。 - 客户端命令面:`gateway-service` 暴露 HTTP JSON。
- 客户端实时面:腾讯云 IM SDK客户端用 gateway 签发的 UserSig 登录。 - 客户端实时面:腾讯云 IM SDK客户端用 gateway 签发的 UserSig 登录。
- 服务内部调用gRPC + protobuf契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。 - 服务内部调用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 ## 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` - `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` 由前端按用户动作生成,用于服务端幂等。 - `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。
- `room-service``CreateRoom` 成功后写 `room_outbox`,由 outbox worker 异步创建或补偿同名腾讯云 IM 群组。 - `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 单聊。 - `notice-service` 消费钱包余额变更 outbox 和房间踢人 outbox向单个用户发送 `wallet_notice``room_notice` 私有自定义消息owner service 不直接调用腾讯云 IM 单聊。
- `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence``CheckSpeakPermission` - `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence``CheckSpeakPermission`
@ -130,6 +130,21 @@ docs/notice-service架构.md
docs/flutter对接/Flutter App 通知与钱包余额对接.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 ## Storage Model
MySQL 是本地和线上运行的持久化底座: MySQL 是本地和线上运行的持久化底座:
@ -197,7 +212,7 @@ services/notice-service/ async user notice delivery workers
- 腾讯云 IM 回调入口已经落在 gateway线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup``Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。 - 腾讯云 IM 回调入口已经落在 gateway线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup``Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。
- 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id``X-Request-ID` 不需要也不应该由前端传入。 - 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id``X-Request-ID` 不需要也不应该由前端传入。
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet` - `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` - `notice-service` 本地默认不启用腾讯云 IM worker线上启用钱包通知、房间通知 MySQL worker 或 RocketMQ room outbox consumer 时,必须同时启用 `tencent_im.enabled=true`
- `activity-service` 已承接每日任务和消息/活动基础能力;`room_outbox` 到 activity/audit 的实际消费链路仍需按独立消费位点补齐 - `activity-service` 已承接每日任务、消息/活动基础能力和 room outbox consumer本地可走 direct gRPC线上推荐走 RocketMQ `hyapp_room_outbox`
- `room-service` App 已启动 `room_outbox` 补偿 worker该状态只表示腾讯云 IM 系统消息补偿结果activity/audit 后续必须使用独立消费位点 - `room-service` App 已启动 `room_outbox` 补偿 worker`direct` 模式表示直连 publisher 完成,`mq` 模式表示事件已发布到 RocketMQ下游成功由各服务消费位点保证
- JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。 - JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT. // Code generated by protoc-gen-go. DO NOT EDIT.
// versions: // versions:
// protoc-gen-go v1.36.11 // protoc-gen-go v1.36.11
// protoc v5.28.3 // protoc (unknown)
// source: proto/events/room/v1/events.proto // source: proto/events/room/v1/events.proto
package roomeventsv1 package roomeventsv1
@ -1175,6 +1175,550 @@ func (x *RoomRankChanged) GetGiftValue() int64 {
return 0 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 var File_proto_events_room_v1_events_proto protoreflect.FileDescriptor
const file_proto_events_room_v1_events_proto_rawDesc = "" + 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" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x14\n" +
"\x05score\x18\x02 \x01(\x03R\x05score\x12\x1d\n" + "\x05score\x18\x02 \x01(\x03R\x05score\x12\x1d\n" +
"\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 ( var (
file_proto_events_room_v1_events_proto_rawDescOnce sync.Once 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 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{ var file_proto_events_room_v1_events_proto_goTypes = []any{
(*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope (*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope
(*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated (*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated
(*RoomProfileUpdated)(nil), // 2: hyapp.events.room.v1.RoomProfileUpdated (*RoomProfileUpdated)(nil), // 2: hyapp.events.room.v1.RoomProfileUpdated
(*RoomUserJoined)(nil), // 3: hyapp.events.room.v1.RoomUserJoined (*RoomUserJoined)(nil), // 3: hyapp.events.room.v1.RoomUserJoined
(*RoomUserLeft)(nil), // 4: hyapp.events.room.v1.RoomUserLeft (*RoomUserLeft)(nil), // 4: hyapp.events.room.v1.RoomUserLeft
(*RoomClosed)(nil), // 5: hyapp.events.room.v1.RoomClosed (*RoomClosed)(nil), // 5: hyapp.events.room.v1.RoomClosed
(*RoomMicChanged)(nil), // 6: hyapp.events.room.v1.RoomMicChanged (*RoomMicChanged)(nil), // 6: hyapp.events.room.v1.RoomMicChanged
(*RoomMicSeatLocked)(nil), // 7: hyapp.events.room.v1.RoomMicSeatLocked (*RoomMicSeatLocked)(nil), // 7: hyapp.events.room.v1.RoomMicSeatLocked
(*RoomChatEnabledChanged)(nil), // 8: hyapp.events.room.v1.RoomChatEnabledChanged (*RoomChatEnabledChanged)(nil), // 8: hyapp.events.room.v1.RoomChatEnabledChanged
(*RoomPasswordChanged)(nil), // 9: hyapp.events.room.v1.RoomPasswordChanged (*RoomPasswordChanged)(nil), // 9: hyapp.events.room.v1.RoomPasswordChanged
(*RoomAdminChanged)(nil), // 10: hyapp.events.room.v1.RoomAdminChanged (*RoomAdminChanged)(nil), // 10: hyapp.events.room.v1.RoomAdminChanged
(*RoomUserMuted)(nil), // 11: hyapp.events.room.v1.RoomUserMuted (*RoomUserMuted)(nil), // 11: hyapp.events.room.v1.RoomUserMuted
(*RoomUserKicked)(nil), // 12: hyapp.events.room.v1.RoomUserKicked (*RoomUserKicked)(nil), // 12: hyapp.events.room.v1.RoomUserKicked
(*RoomUserUnbanned)(nil), // 13: hyapp.events.room.v1.RoomUserUnbanned (*RoomUserUnbanned)(nil), // 13: hyapp.events.room.v1.RoomUserUnbanned
(*RoomGiftSent)(nil), // 14: hyapp.events.room.v1.RoomGiftSent (*RoomGiftSent)(nil), // 14: hyapp.events.room.v1.RoomGiftSent
(*RoomHeatChanged)(nil), // 15: hyapp.events.room.v1.RoomHeatChanged (*RoomHeatChanged)(nil), // 15: hyapp.events.room.v1.RoomHeatChanged
(*RoomRankChanged)(nil), // 16: hyapp.events.room.v1.RoomRankChanged (*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{ var file_proto_events_room_v1_events_proto_depIdxs = []int32{
0, // [0:0] is the sub-list for method output_type 17, // 0: hyapp.events.room.v1.RoomTreasureOpened.rewards:type_name -> hyapp.events.room.v1.RoomTreasureRewardGrant
0, // [0:0] is the sub-list for method input_type 17, // 1: hyapp.events.room.v1.RoomTreasureRewardGranted.rewards:type_name -> hyapp.events.room.v1.RoomTreasureRewardGrant
0, // [0:0] is the sub-list for extension type_name 2, // [2:2] is the sub-list for method output_type
0, // [0:0] is the sub-list for extension extendee 2, // [2:2] is the sub-list for method input_type
0, // [0:0] is the sub-list for field type_name 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() } 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(), 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)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_events_room_v1_events_proto_rawDesc), len(file_proto_events_room_v1_events_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 17, NumMessages: 22,
NumExtensions: 0, NumExtensions: 0,
NumServices: 0, NumServices: 0,
}, },

View File

@ -141,3 +141,67 @@ message RoomRankChanged {
int64 score = 2; int64 score = 2;
int64 gift_value = 3; 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; 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 // RoomSnapshot room-service
message RoomSnapshot { message RoomSnapshot {
string room_id = 1; string room_id = 1;
@ -84,6 +147,8 @@ message RoomSnapshot {
int64 visible_region_id = 18; int64 visible_region_id = 18;
// locked HTTP JSON // locked HTTP JSON
bool locked = 19; bool locked = 19;
// treasure GetRoomTreasure
RoomTreasureState treasure = 20;
} }
// CreateRoomRequest // CreateRoomRequest
@ -409,6 +474,7 @@ message SendGiftResponse {
int64 room_heat = 3; int64 room_heat = 3;
repeated RankItem gift_rank = 4; repeated RankItem gift_rank = 4;
RoomSnapshot room = 5; RoomSnapshot room = 5;
RoomTreasureState treasure = 6;
} }
// CheckSpeakPermissionRequest IM gateway // CheckSpeakPermissionRequest IM gateway
@ -549,6 +615,19 @@ message GetRoomSnapshotResponse {
bool is_followed = 3; 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 // ListRoomOnlineUsersRequest 线 RoomSnapshot
message ListRoomOnlineUsersRequest { message ListRoomOnlineUsersRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
@ -634,5 +713,6 @@ service RoomQueryService {
rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse); rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse);
rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse); rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse);
rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse); rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse);
rpc GetRoomTreasure(GetRoomTreasureRequest) returns (GetRoomTreasureResponse);
rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse); rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse);
} }

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.2 // - protoc-gen-go-grpc v1.6.2
// - protoc v5.28.3 // - protoc (unknown)
// source: proto/room/v1/room.proto // source: proto/room/v1/room.proto
package roomv1 package roomv1
@ -1110,6 +1110,7 @@ const (
RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom" RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom"
RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom" RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom"
RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot" RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot"
RoomQueryService_GetRoomTreasure_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomTreasure"
RoomQueryService_ListRoomOnlineUsers_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomOnlineUsers" 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) GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error)
GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error) GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error)
GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, 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) 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 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) { func (c *roomQueryServiceClient) ListRoomOnlineUsers(ctx context.Context, in *ListRoomOnlineUsersRequest, opts ...grpc.CallOption) (*ListRoomOnlineUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoomOnlineUsersResponse) out := new(ListRoomOnlineUsersResponse)
@ -1206,6 +1218,7 @@ type RoomQueryServiceServer interface {
GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error) GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error)
GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error) GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error)
GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error)
GetRoomTreasure(context.Context, *GetRoomTreasureRequest) (*GetRoomTreasureResponse, error)
ListRoomOnlineUsers(context.Context, *ListRoomOnlineUsersRequest) (*ListRoomOnlineUsersResponse, error) ListRoomOnlineUsers(context.Context, *ListRoomOnlineUsersRequest) (*ListRoomOnlineUsersResponse, error)
mustEmbedUnimplementedRoomQueryServiceServer() mustEmbedUnimplementedRoomQueryServiceServer()
} }
@ -1232,6 +1245,9 @@ func (UnimplementedRoomQueryServiceServer) GetCurrentRoom(context.Context, *GetC
func (UnimplementedRoomQueryServiceServer) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) { func (UnimplementedRoomQueryServiceServer) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetRoomSnapshot not implemented") 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) { func (UnimplementedRoomQueryServiceServer) ListRoomOnlineUsers(context.Context, *ListRoomOnlineUsersRequest) (*ListRoomOnlineUsersResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRoomOnlineUsers not implemented") 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) 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) { func _RoomQueryService_ListRoomOnlineUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoomOnlineUsersRequest) in := new(ListRoomOnlineUsersRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -1391,6 +1425,10 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetRoomSnapshot", MethodName: "GetRoomSnapshot",
Handler: _RoomQueryService_GetRoomSnapshot_Handler, Handler: _RoomQueryService_GetRoomSnapshot_Handler,
}, },
{
MethodName: "GetRoomTreasure",
Handler: _RoomQueryService_GetRoomTreasure_Handler,
},
{ {
MethodName: "ListRoomOnlineUsers", MethodName: "ListRoomOnlineUsers",
Handler: _RoomQueryService_ListRoomOnlineUsers_Handler, 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"` 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"` 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"` 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 unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -242,6 +243,13 @@ func (x *DebitGiftResponse) GetChargeAmount() int64 {
return 0 return 0
} }
func (x *DebitGiftResponse) GetGiftTypeCode() string {
if x != nil {
return x.GiftTypeCode
}
return ""
}
// AssetBalance 是用户某类资产的余额投影。 // AssetBalance 是用户某类资产的余额投影。
type AssetBalance struct { type AssetBalance struct {
state protoimpl.MessageState `protogen:"open.v1"` 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" + "gift_count\x18\x06 \x01(\x05R\tgiftCount\x12#\n" +
"\rprice_version\x18\a \x01(\tR\fpriceVersion\x12\x19\n" + "\rprice_version\x18\a \x01(\tR\fpriceVersion\x12\x19\n" +
"\bapp_code\x18\b \x01(\tR\aappCode\x12\x1b\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" + "\x11DebitGiftResponse\x12,\n" +
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" + "\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\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" + "\rprice_version\x18\x06 \x01(\tR\fpriceVersion\x12#\n" +
"\rbalance_after\x18\a \x01(\x03R\fbalanceAfter\x12*\n" + "\rbalance_after\x18\a \x01(\x03R\fbalanceAfter\x12*\n" +
"\x11charge_asset_type\x18\b \x01(\tR\x0fchargeAssetType\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" + "\fAssetBalance\x12\x1d\n" +
"\n" + "\n" +
"asset_type\x18\x01 \x01(\tR\tassetType\x12)\n" + "asset_type\x18\x01 \x01(\tR\tassetType\x12)\n" +

View File

@ -31,6 +31,7 @@ message DebitGiftResponse {
int64 balance_after = 7; int64 balance_after = 7;
string charge_asset_type = 8; string charge_asset_type = 8;
int64 charge_amount = 9; int64 charge_amount = 9;
string gift_type_code = 10;
} }
// AssetBalance // AssetBalance

View File

@ -133,6 +133,22 @@ python3 deploy/tencent-tat/validate_rendered_configs.py \
- 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存Redis 不能作为业务恢复事实来源。 - 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存Redis 不能作为业务恢复事实来源。
- 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。 - 腾讯云 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 验证: 本地真实 MySQL/Redis 验证:

View File

@ -18,8 +18,8 @@ docs/房间Outbox补偿开发.md
| Capability | Current Owner | Current Rule | | Capability | Current Owner | Current Rule |
| --- | --- | --- | | --- | --- | --- |
| IM UserSig | `gateway-service` | `/api/v1/im/usersig` 只签发 `sdk_app_id/user_id/user_sig/expire_at_ms` | | 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` | `CreateRoom` `RoomCreated` outboxworker direct 投递或 IM bridge consumer 从 MQ 消费后腾讯 IM REST `create_group``GroupID = room_id` |
| 房间系统消息 | `room-service` | Room Cell 命令成功后向 `GroupID = room_id``room_*` 自定义消息 | | 房间系统消息 | `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 | | IM 入群/发言回调 | `gateway-service` | 当前把所有 `GroupId` 都当作 `room_id`,再回查 `room-service` guard |
| 系统/活动 inbox | `activity-service` | backend inbox/fanout非实时 IM 群 | | 系统/活动 inbox | `activity-service` | backend inbox/fanout非实时 IM 群 |
@ -38,6 +38,7 @@ docs/房间Outbox补偿开发.md
| 播报发送能力 | `activity-service` | 服务端向全局/区域播报群发送自定义消息 | | 播报发送能力 | `activity-service` | 服务端向全局/区域播报群发送自定义消息 |
| 旧区域群成员移除 | `activity-service` | 用户区域变化后,对旧区域群执行 `delete_group_member(user_id)` | | 旧区域群成员移除 | `activity-service` | 用户区域变化后,对旧区域群执行 `delete_group_member(user_id)` |
| 贵重礼物播报触发 | `activity-service` consuming room outbox | 基于 `RoomGiftSent` 事实判断阈值并发区域播报 | | 贵重礼物播报触发 | `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 只携带入口和展示字段 | | 红包资金事实 | `wallet-service` 或独立 red-packet domain | IM 只携带入口和展示字段 |
| 客户端 IM 长连接 | Tencent IM SDK | 本仓库不自建 WebSocket | | 客户端 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 ## Client Flow
@ -362,6 +363,7 @@ sequenceDiagram
participant G as gateway-service participant G as gateway-service
participant R as room-service participant R as room-service
participant W as wallet-service participant W as wallet-service
participant MQ as RocketMQ
participant A as activity-service participant A as activity-service
participant IM as Tencent IM participant IM as Tencent IM
@ -372,11 +374,14 @@ sequenceDiagram
R->>R: Room Cell heat/rank + RoomGiftSent outbox R->>R: Room Cell heat/rank + RoomGiftSent outbox
R-->>G: SendGiftResponse R-->>G: SendGiftResponse
G-->>C: room result 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->>A: threshold decision
A->>IM: send_group_msg(region broadcast group) A->>IM: send_group_msg(region broadcast group)
``` ```
本地未启用 MQ 时,`room-service` outbox worker 可以 direct gRPC 投递 activity-service线上推荐使用 RocketMQactivity-service 不需要回查 room-service 主链路。
阈值策略属于播报配置,不属于 Room Cell 阈值策略属于播报配置,不属于 Room Cell
| Config | Meaning | | Config | Meaning |
@ -399,6 +404,54 @@ sequenceDiagram
如果现有事件字段不足,应优先扩展 protobuf event而不是让 activity-service 反查多个服务拼凑事实。 如果现有事件字段不足,应优先扩展 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 ## Red Packet Broadcast
红包资金事实必须由 wallet/red-packet 领域持久化: 红包资金事实必须由 wallet/red-packet 领域持久化:
@ -443,7 +496,8 @@ sequenceDiagram
5. `activity-service` 增加 broadcast group reconciler启动时确保全局和 active 区域群。 5. `activity-service` 增加 broadcast group reconciler启动时确保全局和 active 区域群。
6. `activity-service` 增加 `PublishRegionBroadcast/PublishGlobalBroadcast` 和 broadcast outbox。 6. `activity-service` 增加 `PublishRegionBroadcast/PublishGlobalBroadcast` 和 broadcast outbox。
7. 扩展 `RoomGiftSent` 事件字段activity-service 消费后按阈值发 `super_gift` 区域播报。 7. 扩展 `RoomGiftSent` 事件字段activity-service 消费后按阈值发 `super_gift` 区域播报。
8. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。 8. 消费 `RoomTreasureCountdownStarted`,按后台配置发 `room_treasure` 区域或全局播报。
9. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。
## Test Expectations ## Test Expectations
@ -459,4 +513,5 @@ sequenceDiagram
| group reconciler | 已存在群视为成功,失败可重试 | | group reconciler | 已存在群视为成功,失败可重试 |
| broadcast outbox | 同一 `event_id` 幂等,失败后 retry | | broadcast outbox | 同一 `event_id` 幂等,失败后 retry |
| super gift | 未达阈值不播报,达阈值发送到用户房间所在区域群 | | super gift | 未达阈值不播报,达阈值发送到用户房间所在区域群 |
| room treasure | 宝箱满能量后只按 `RoomTreasureCountdownStarted` 播报一次;区域/全局 scope 来自后台配置 |
| red packet | IM payload 不影响领取接口的后端金额校验 | | red packet | IM payload 不影响领取接口的后端金额校验 |

View File

@ -10,7 +10,8 @@
- `notice-service` 抢占该事件,写自己的 `notice_delivery_events` 投递位点。 - `notice-service` 抢占该事件,写自己的 `notice_delivery_events` 投递位点。
- `notice-service` 通过腾讯云 IM C2C `TIMCustomElem` 给用户发送 `wallet_notice` - `notice-service` 通过腾讯云 IM C2C `TIMCustomElem` 给用户发送 `wallet_notice`
- 客户端收到通知后按 `asset_type + balance_version` 更新本地余额;乱序或重复事件直接丢弃。 - 客户端收到通知后按 `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 被拆成两种通道: 房间 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` - 用户私有通道:被踢本人可能已经先被移出 IM 群,不能保证收到群消息;因此 notice-service 监听同一条 `RoomUserKicked` 事实,发送 C2C `room_notice``target_user_id`
## 当前目录结构 ## 当前目录结构
@ -91,6 +92,7 @@ sequenceDiagram
participant Gateway as gateway-service participant Gateway as gateway-service
participant Room as room-service participant Room as room-service
participant RoomDB as hyapp_room participant RoomDB as hyapp_room
participant RocketMQ as RocketMQ
participant Notice as notice-service participant Notice as notice-service
participant NoticeDB as hyapp_notice participant NoticeDB as hyapp_notice
participant TencentIM as Tencent IM participant TencentIM as Tencent IM
@ -101,15 +103,24 @@ sequenceDiagram
Room->>Room: remove presence, release mic, write ban Room->>Room: remove presence, release mic, write ban
Room->>RoomDB: command log + snapshot + room_outbox.RoomUserKicked Room->>RoomDB: command log + snapshot + room_outbox.RoomUserKicked
Room-->>Gateway: success Room-->>Gateway: success
Room-->>TencentIM: room outbox worker deletes group member and sends group event Room-->>TencentIM: direct/IM bridge deletes group member and sends group event
Notice->>RoomDB: claim RoomUserKicked 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->>NoticeDB: notice_delivery_events=delivering
Notice->>TencentIM: C2C TIMCustomElem(room_notice) Notice->>TencentIM: C2C TIMCustomElem(room_notice)
TencentIM-->>Target: private kicked notice TencentIM-->>Target: private kicked notice
Notice->>NoticeDB: delivered / retryable / failed 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 约定 ## Payload 约定
@ -172,6 +183,13 @@ wallet_notice_worker:
room_notice_worker: room_notice_worker:
enabled: false enabled: false
rocketmq:
enabled: false
room_outbox:
enabled: false
topic: "hyapp_room_outbox"
consumer_group: "hyapp-notice-room-outbox"
``` ```
线上启用私有通知必须同时开启腾讯云 IM并配置对应 owner outbox 所在库名: 线上启用私有通知必须同时开启腾讯云 IM并配置对应 owner outbox 所在库名:
@ -199,18 +217,33 @@ room_notice_worker:
batch_size: 100 batch_size: 100
lock_ttl: 30s lock_ttl: 30s
max_retry_count: 10 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 端口。 `notice-service` 使用 `13009` 作为 gRPC health 端口,`13109` 作为 HTTP health 端口。
启用 MQ 消费后,可以按部署阶段选择是否保留 MySQL 扫描 worker。两者同时开启时不会重复发通知因为 `notice_delivery_events` 的主键会挡住同一 `event_id + channel` 的重复投递;长期线上推荐以 MQ consumer 作为实时入口MySQL 扫描只作为补偿或独立验证工具。
## 后续扩展顺序 ## 后续扩展顺序
1. 钱包余额通知:已按 `walletnotice` 模块落地。 1. 钱包余额通知:已按 `walletnotice` 模块落地。
2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。 2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。
3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outboxnotice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。 3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outboxnotice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。
4. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox复用 `notice_delivery_events` 或新增同语义位点表。 4. 语音房宝箱私有通知:如产品需要私聊提醒,可在 `roomnotice` 中消费 `RoomTreasureRewardGranted` 或由活动/系统消息模块转发;奖励入账事实仍由 room-service 调 wallet 完成notice 只投通知。
5. 站内 inbox新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。 5. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox复用 `notice_delivery_events` 或新增同语义位点表。
6. Push新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。 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` | 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` | 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` | region rebuild task | `UserCronService.ProcessRegionRebuildBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 |
| `user-service` | login IP risk job | `UserCronService.ProcessLoginIPRiskBatch` | 已迁移到 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 或事件总线 | | `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 执行命令链路 | | Room Cell stale presence cleanup | 依赖本节点已装载 Room Cell 和 Redis lease必须由 `room-service` owner 执行命令链路 |
| Mic publish timeout cleanup | 必须对当前 Room Cell 状态做二次校验并执行 `MicDown`,不能让 cron 直接清麦 | | 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 ledger transaction | 金币、钻石、积分、余额的加减和冻结必须在 `wallet-service` 事务内完成 |
| wallet outbox publish | 属于 wallet-service outbox relay 或 MQ 投递,不应该由通用 cron 直接扫表改状态 | | wallet outbox publish | 属于 wallet-service outbox relay 或 MQ 投递,不应该由通用 cron 直接扫表改状态 |
| invite recharge outbox consumer | 当前使用 in-memory cursor 扫 wallet outbox迁移前必须先做 durable offset 或正式事件总线 | | invite recharge outbox consumer | 当前使用 in-memory cursor 扫 wallet outbox迁移前必须先做 durable offset 或正式事件总线 |
@ -265,8 +267,11 @@ cron-service 可以直接开启多个实例;调度层通过 `cron_task_leases`
- room mic outbox consumer。 - room mic outbox consumer。
- wallet outbox relay。 - wallet outbox relay。
- activity room outbox consumer。 - 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 ### Future Scheduled Business Jobs

View File

@ -1,32 +1,32 @@
# Room Outbox Worker # 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 | | 状态 owner | Room Cell 和 MySQL snapshot/command log 是房间状态权威来源 |
| 持久化 | 命令变更、command log、room_outbox 在同一提交链路内完成 | | 持久事实 | `room_outbox` 是房间外事件的持久事实源,和房间命令提交链路一起落 MySQL |
| worker | 读取 `room_outbox`,投递到配置的 outbox publisher fanout | | 发布模式 | `outbox_worker.publish_mode` 支持 `direct``mq``dual` |
| 腾讯云 IM | `RoomCreated` 由 worker 确保群组存在,其他房间事件由 worker 发送群系统消息 | | MQ 职责 | RocketMQ 只做事件分发和宝箱延迟唤醒,不保存房间核心状态 |
| activity-service | 由 room outbox worker 异步投递,失败进入 room_outbox 重试 | | 下游语义 | 下游服务按 `event_id` 幂等消费,失败由自己的位点、重试和死信处理 |
| 幂等 | 房间命令靠 `command_id`outbox 投递靠 `event_id` | | 主链路 | HTTP/gRPC 请求不等待 IM REST、activity、notice、push 或 inbox 投递完成 |
不做的事: 不做的事:
| 不做 | 原因 | | 不做 | 原因 |
| --- | --- | | --- | --- |
| 在 HTTP/gRPC 请求里等待 IM REST | 外部慢调用会拖住房间串行命令队列 | | 在 Room Cell 中保存投递状态 | 投递状态属于 outbox 或下游消费位点,不属于房间权威状态 |
| 在 Room Cell 中保存投递状态 | 投递状态属于 outbox不属于房间权威状态 | | 用 Redis 替代 outbox | Redis 只保存热状态和短 TTL lease不能作为恢复事实来源 |
| 直接用 Redis 做 outbox | MySQL 是恢复和补偿的持久来源 | | 用 RocketMQ 替代 `room_outbox` | 房间状态提交成功但 MQ 发布失败时,必须能从 MySQL 补偿发布 |
| 精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once消费必须按 `event_id` 去重 | | 追求精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once消费必须按 `event_id` 去重 |
## 写命令语义 ## 写命令链路
```text ```text
gateway -> room-service command gateway -> room-service command
-> idempotency check -> command_id idempotency check
-> ensure Redis lease and Room Cell -> ensure Redis lease and Room Cell
-> mutate cloned RoomState -> mutate cloned RoomState
-> optional wallet debit before room gift state is committed -> optional wallet debit before room gift state is committed
@ -37,11 +37,25 @@ gateway -> room-service command
outbox worker outbox worker
-> claim pending/retryable rows -> 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 -> 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 处理 | | `pending` | 已落库,尚未被 worker 处理 |
| `delivering` | 已被 worker 抢占,`lock_until_ms` 前其他 worker 不抢 | | `delivering` | 已被 worker 抢占,`lock_until_ms` 前其他 worker 不抢 |
| `delivered` | 所有配置的 outbox publisher 已处理成功 | | `delivered` | 当前发布模式要求的 publisher 已处理成功MQ 模式下表示 RocketMQ 已接收 |
| `retryable` | 上次投递失败,等待 `next_retry_at_ms` 后重试 | | `retryable` | 上次发布失败,等待 `next_retry_at_ms` 后重试 |
| `failed` | 达到最大重试次数,进入死信,需要人工处理或后台工具重放 | | `failed` | 达到最大重试次数,进入死信,需要后台工具查看或重放 |
旧数据里可能存在历史 `published` 状态。当前 worker 不再写这个状态,新链路只写上表状态。 旧数据里可能存在历史 `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 ```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: outbox_worker:
enabled: true enabled: true
publish_mode: "direct"
poll_interval: "1s" poll_interval: "1s"
batch_size: 100 batch_size: 100
publish_timeout: "3s" 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 | | `poll_interval` | worker 空轮询间隔,必须大于 0避免 busy loop |
| `batch_size` | 每轮最大抢占条数,必须大于 0避免全表扫描 | | `batch_size` | 每轮最大抢占条数,必须大于 0避免全表扫描 |
| `publish_timeout` | 单条 outbox 投递 deadline避免外部系统拖死 worker | | `publish_timeout` | 单条 outbox 发布 deadline避免外部系统拖死 worker |
| `retry_strategy` | 当前为 `exponential_backoff` | | `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久刷日志 |
| `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久污染日志 |
| `initial_backoff` | 第一次失败后的最小退避 | ## 投递对象
| `max_backoff` | 指数退避上限 |
| 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 ```text
publish failed publish failed
@ -101,16 +163,7 @@ publish failed
`failed` 不再自动重试。后台应提供人工查看和重放工具,重放时必须保留原 `event_id` 或生成明确的新修复事件,避免消费端无法去重。 `failed` 不再自动重试。后台应提供人工查看和重放工具,重放时必须保留原 `event_id` 或生成明确的新修复事件,避免消费端无法去重。
## 投递对象 RocketMQ consumer 失败时不修改 `room_outbox`。consumer 应依赖 RocketMQ 重投、自己的幂等表和死信处理;对于已经执行业务副作用但 ACK 失败的窗口,下一次消费必须按 `event_id` 安全去重。
当前 `CompositeOutboxPublisher` fanout 到多个 publisher
| Publisher | 行为 |
| --- | --- |
| Tencent IM | `RoomCreated` 确保群存在;房间系统事件发送腾讯云 IM 群自定义消息 |
| Activity | 将 room outbox 事件投递给 `activity-service` 的消费入口 |
fanout 要求所有 publisher 成功后才标记 `delivered`。任一失败都会进入重试,因此消费端必须幂等处理重复事件。
## 阶段耗时日志 ## 阶段耗时日志
@ -133,12 +186,13 @@ fanout 要求所有 publisher 成功后才标记 `delivered`。任一失败都
## 验收 ## 验收
必须满足:
| 场景 | 验收 | | 场景 | 验收 |
| --- | --- | | --- | --- |
| IM 不可用 | 房间写命令仍成功outbox 进入 `retryable` 或最终 `failed` | | IM 不可用,`direct/dual` | 房间写命令仍成功outbox 进入 `retryable` 或最终 `failed` |
| activity 不可用 | 房间写命令仍成功outbox 进入重试 | | activity 不可用,`direct/dual` | 房间写命令仍成功outbox 进入重试 |
| MQ 不可用,`mq/dual` | 房间写命令仍成功outbox 进入重试MQ 恢复后补发 |
| activity consumer 不可用,`mq` | `room_outbox` 不回退RocketMQ 和 activity 消费位点负责积压与重试 |
| poison event | 达到 `max_retry_count` 后进入 `failed`,不再每秒刷日志 | | poison event | 达到 `max_retry_count` 后进入 `failed`,不再每秒刷日志 |
| 正常投递 | 同房间事件最终全部 `delivered` | | 宝箱倒计时 | `RoomTreasureCountdownStarted` 发布延迟唤醒;到点后 room-service 重新校验状态再开箱 |
| 正常投递 | 同一 `event_id` 可被多个 consumer group 独立消费且重复投递不产生重复副作用 |
| 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` | | 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` |

View File

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

View File

@ -135,14 +135,15 @@
| 本地礼物榜 | `DONE` | `LocalRank``gift_rank` 快照 | 全局榜、跨房榜、榜单重置未做 | | 本地礼物榜 | `DONE` | `LocalRank``gift_rank` 快照 | 全局榜、跨房榜、榜单重置未做 |
| 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 | | 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 |
| 背包/道具 | `PARTIAL` | `user_resource_entitlements``user_resource_equipment` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 | | 背包/道具 | `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 ## Activity And Lucky Gift
| Feature | Status | Current Evidence | Remaining Work | | Feature | Status | Current Evidence | Remaining Work |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| activity-service 底座 | `PARTIAL` | gRPC Ping/GetActivityStatus、MySQL activity/outbox 表存在 | 具体活动规则和奖励结算未实现 | | 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 | | 幸运礼物开发文档 | `DONE` | `docs/幸运礼物Activity服务开发.md` | 按文档实现 proto、pool、risk、draw |
| 活动配置 | `TODO` | 只有 activity status projection | 活动规则、时间、奖池、风控配置未实现 | | 活动配置 | `TODO` | 只有 activity status projection | 活动规则、时间、奖池、风控配置未实现 |
| LuckyGift Check | `TODO` | 文档有设计proto/code 未实现 | `CheckLuckyGift` RPC 和规则校验 | | LuckyGift Check | `TODO` | 文档有设计proto/code 未实现 | `CheckLuckyGift` RPC 和规则校验 |
@ -235,7 +236,7 @@
| Task | Status | Acceptance | | 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 可用 | | LuckyGift proto 和 service | `TODO` | CheckLuckyGift/ExecuteLuckyGiftDraw 可用 |
| 三层奖池和风控 | `TODO` | 平台、房间、礼物奖池不会透支 | | 三层奖池和风控 | `TODO` | 平台、房间、礼物奖池不会透支 |
| 奖励入账 | `TODO` | wallet 支持 CreditLuckyReward失败进入 activity outbox | | 奖励入账 | `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 后才增加宝箱能量 | | App 初始化 | `GET /api/v1/rooms/{room_id}/treasure` 返回 7 级物料、当前等级、当前进度、重置时间、奖励展示配置 |
| 7 级宝箱 | 每个 UTC 自然日从 1 级开始,最多开启 7 个等级 | | 送礼累积 | `SendGift` 同步调用 `wallet-service.DebitGift` 成功后Room Cell 内更新热度、礼物榜和宝箱进度 |
| 满能量倒计时开启 | 能量达到当前等级阈值后锁定本轮,按后台 `open_delay_ms` 设置 `open_at_ms` | | 满能量 | 当前等级进度封顶为阈值,锁定点火人、贡献第一、`box_id``open_at_ms` 和 UTC `reset_at_ms` |
| 满能量播报 | 满能量后立即或按后台延迟发起播报,播报范围支持 `none/region/global` | | 溢出和倒计时送礼 | 当前等级溢出能量作废;倒计时期间送礼不累加到下一级,也不改变点火人或贡献第一 |
| 开箱抽奖 | `open_at_ms` 到达后快照房间在线用户、点火人和贡献第一,生成奖励结算事实 | | 到期开箱 | `room-service` 通过 RocketMQ 延迟消息到 `open_at_ms` 唤醒;本地已加载 Cell 扫描 worker 仍作为兜底 |
| 自动发奖 | 奖励由 activity-service 幂等抽奖并调用 wallet-service 发放资源组,不需要 App 手动领取 | | 奖励结算 | `room-service` 在开箱命令内按配置加权抽取奖励,并调用 `wallet-service.GrantResourceGroup` 幂等发放 |
| UTC 重置 | 进度、贡献和已开启等级按 UTC 日期重置,不使用服务器本地时区或客户端时区 | | 离房/下线 | `top1``igniter` 在倒计时开始时锁定;开箱前离房或下线仍结算对应角色奖励 |
| 在房奖励 | 只发给开箱命令执行时 Room Cell `OnlineUsers` 中的用户 |
| 播报 | `activity-service` 消费 `RoomTreasureCountdownStarted`,按 `broadcast_scope` 发区域或全局播报 |
| 事件分发 | `room_outbox` 先落 MySQL再由 outbox worker 发布 RocketMQactivity、notice、IM bridge 各自 consumer group 消费 |
## Non-Goals ## Non-Goals
| 不做 | 原因 | | 不做 | 原因 |
| --- | --- | | --- | --- |
| 客户端提交能量、奖励或中奖结果 | 能量和奖励必须来自服务端扣费、配置和随机结果 | | 客户端提交能量、奖励或中奖结果 | 能量和奖励必须来自服务端扣费、配置和确定性抽奖 |
| 在 room-service 保存钱包余额或发奖账本 | 钱包资产和资源发放仍由 wallet-service 拥有 | | 只用 Redis 保存宝箱进度 | MySQL snapshot、command log 和 `room_outbox` 才是恢复来源 |
| 由 activity-service 判断谁在房 | 房间 presence 和开箱快照必须由 Room Cell 产出 | | activity-service 判断谁在房 | 房间 presence 和开箱快照必须由 Room Cell 产出 |
| 只用 Redis 保存宝箱进度 | MySQL snapshot/command log/宝箱表才是恢复来源 | | activity-service 发放宝箱奖励 | 当前发奖在 `room-service` 开箱命令内调用 `wallet-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 | 奖励实际入账、用户完整资料、全局/区域播报群成员 | | `room-service` | 宝箱进度、等级、状态机、点火人、贡献第一、开箱在线用户快照、抽奖结果、房间宝箱 IM outbox、RocketMQ 延迟开箱唤醒消费 | 用户完整资料、全局/区域播报群成员、钱包余额账本 |
| `activity-service` | 宝箱奖励抽奖、奖励记录、发奖重试、全局/区域播报 outbox | Room Cell presence、房间核心状态 | | `wallet-service` | 送礼扣费、资源组发放、钱包账务、发放幂等 | 宝箱进度、中奖资格、房间 presence |
| `wallet-service` | 资源组发放、金币/钻石入账、钱包 outbox、幂等账本 | 宝箱进度和中奖资格 | | `activity-service` | 满能量后区域/全局播报、成长值等房间事件派生消费 | Room Cell 状态、宝箱奖励结算 |
| `gateway-service` | App HTTP envelope、鉴权、聚合 room/activity/wallet 读模型 | 能量、抽奖和发奖事实 | | `notice-service` | 通过 room outbox/MQ 消费需要私信的房间事实,写自己的投递位点和死信 | 房间群系统消息、群成员控制、房间状态 |
| `server/admin` | 后台权限、表单校验、审计、调用 owner service 管理 RPC | 直接写 room/activity/wallet 业务表 | | `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 ```mermaid
stateDiagram-v2 stateDiagram-v2
[*] --> disabled [*] --> idle
disabled --> charging: config enabled idle --> idle: SendGift progress<threshold
charging --> countdown: energy >= threshold idle --> countdown: SendGift progress>=threshold
countdown --> opening: now >= open_at_ms countdown --> opened: open_at_ms reached
opening --> settling: RoomTreasureChestOpened outbox committed opened --> idle: next level
settling --> settled: rewards terminal opened --> exhausted: level 7 opened
settled --> charging: next level exists idle --> idle: UTC day reset
settled --> exhausted: level 7 settled countdown --> idle: UTC day reset before open
exhausted --> charging: next UTC day exhausted --> idle: next UTC day
charging --> charging: UTC daily reset
``` ```
| 状态 | 语义 | | 状态 | 语义 |
| --- | --- | | --- | --- |
| `disabled` | 后台关闭,不累计能量,不发宝箱 IM | | `idle` | 当前等级可累积能量 |
| `charging` | 当前等级可累积能量 | | `countdown` | 当前等级已满,`open_at_ms` 已确定,本轮进度、点火人、贡献第一和配置版本锁定 |
| `countdown` | 当前等级已满,`open_at_ms` 已确定,本轮进度、点火人和配置版本锁定 | | `opened` | 本轮已开箱,奖励已在命令内完成抽取和资源组发放请求 |
| `opening` | 到期开箱命令正在 Room Cell 内提交,快照参与用户 |
| `settling` | activity-service 正在抽奖和调用 wallet 发奖 |
| `settled` | 本轮奖励达到终态,房间已发结算 IM |
| `exhausted` | 当天 7 级都已开启,等下一个 UTC 自然日 | | `exhausted` | 当天 7 级都已开启,等下一个 UTC 自然日 |
`round_id` 建议稳定生成 当前实现没有单独持久化 `round_id` 表。`box_id` 是单轮宝箱幂等和奖励选择的核心 ID开箱命令 ID 固定为:
```text ```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 级别倍率或覆盖值 | | 能量来源 | 默认使用 `DebitGiftResponse.gift_point_added`;后台可配置 `gift_id``gift_type_code` 的倍率、覆盖值和排除规则 |
| 生效条件 | wallet 扣费成功、room-service 已提交 `RoomGiftSent`、礼物能量值大于 0 | | 生效条件 | wallet 扣费成功、Room Cell 提交 `SendGift`、礼物能量值大于 0 |
| 幂等 | 同一 `SendGift.command_id` 只能增加一次宝箱能量 | | 幂等 | 同一 `SendGift.command_id` 只能增加一次宝箱能量 |
| 点火人 | 第一笔让当前等级 `progress >= threshold` 的送礼用户 | | 点火人 | 第一笔让当前等级 `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 | | `in_room` | 开箱命令执行时 Room Cell `OnlineUsers` 内的每个用户 |
| 播报 | activity-service 消费 countdown 事件,按配置发 `region/global` 播报;失败只重试播报,不回滚宝箱 | | `top1` | 倒计时开始时锁定的贡献第一用户 |
| 到期开箱 | room-service worker 或 cron-service 触发 `ProcessDueTreasureChests`,由 Room Cell 提交开箱命令 | | `igniter` | 倒计时开始时锁定的点火人 |
| 开箱快照 | 以开箱命令执行时的 `OnlineUsers` 为准生成在房奖励参与人 |
| 房间关闭 | 如果已有 `countdown` roundCloseRoom 必须先提交开箱快照或取消策略;推荐先快照结算,再关闭房间 |
| 房间无人 | 在房奖励参与人为空;点火人和贡献第一仍按 round 贡献事实抽奖 |
点火人和贡献第一不要求开箱时仍在房或在线。只要用户在本 round 已经形成点火人或贡献第一事实,即使开箱前离房、断线或下线,也必须保留对应角色奖励;在房奖励仍只发给开箱快照里的 `OnlineUsers` 奖励配置按等级拆分,每个角色是一个加权奖励池。奖励项引用 `resource_group_id`,由 `wallet-service` 按资源组发放金币、钻石或资源权益。
## Reward Rules
| 奖励槽位 | 资格 | 默认是否可与其他槽位叠加 |
| --- | --- | --- |
| `in_room` | 开箱时 Room Cell `OnlineUsers` 内的每个用户 | 可叠加 |
| `top1` | 当前 round 贡献第一用户 | 可叠加 |
| `igniter` | 当前 round 点火人 | 可叠加 |
奖励配置按等级拆分,每个槽位是一个加权奖励池。奖励项推荐引用 `resource_group_id`,由 wallet-service 按资源组快照发放金币、钻石或资源权益。
```json ```json
{ {
"level": 3, "level": 3,
"slot": "top1", "reward_role": "top1",
"items": [ "items": [
{ {
"reward_item_id": "lv3_top1_a", "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 ```http
GET /api/v1/rooms/{room_id}/treasure-chest GET /api/v1/rooms/{room_id}/treasure
Authorization: Bearer <access_token> Authorization: Bearer <access_token>
``` ```
Response `data`: Response `data` 核心字段:
```json ```json
{ {
"enabled": true, "enabled": true,
"server_time_ms": 1779120000000, "server_time_ms": 1779120000000,
"reset_at_ms": 1779148800000, "reset_at_ms": 1779148800000,
"config_version": 12, "broadcast_scope": "region",
"current": { "open_delay_ms": 30000,
"cycle_day": "2026-05-19", "broadcast_delay_ms": 0,
"round_id": "treasure:lalu_room_1:2026-05-19:3:1", "reward_stack_policy": "allow_stack",
"level": 3, "state": {
"status": "charging", "current_level": 3,
"progress": 3600, "current_progress": 3600,
"threshold": 10000, "energy_threshold": 10000,
"progress_ppm": 360000, "status": "idle",
"opened_level_count": 2,
"open_at_ms": 0, "open_at_ms": 0,
"top1_user_id": "10001",
"igniter_user_id": "0", "igniter_user_id": "0",
"top1_user_id": "10001" "box_id": "box_xxx",
"last_rewards": []
}, },
"levels": [ "levels": [
{ {
"level": 1, "level": 1,
"threshold": 3000, "energy_threshold": 3000,
"cover_url": "https://cdn.example/chest/lv1/cover.png", "cover_url": "https://cdn.example/chest/lv1/cover.png",
"animation_url": "https://cdn.example/chest/lv1/idle.svga", "animation_url": "https://cdn.example/chest/lv1/idle.svga",
"opening_animation_url": "https://cdn.example/chest/lv1/opening.svga", "opening_animation_url": "https://cdn.example/chest/lv1/opening.svga",
"opened_image_url": "https://cdn.example/chest/lv1/opened.png", "opened_image_url": "https://cdn.example/chest/lv1/opened.png",
"rewards": { "in_room_rewards": [],
"in_room": [], "top1_rewards": [],
"top1": [], "igniter_rewards": []
"igniter": []
}
} }
], ]
"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 零点,不是用户本地零点。 - `reset_at_ms` 是下一个 UTC 零点,不是用户本地零点。
- `progress_ppm = floor(progress * 1000000 / threshold)`,客户端不自行除浮点 - 客户端只展示服务端返回的当前等级进度;不要自行把溢出能量展示到下一等级。
- 倒计时期间送礼和单笔溢出的无效能量不返回为下一等级进度;客户端只展示服务端返回的当前等级进度 - 当前没有单独的 App 领奖接口,奖励自动发放,最近一次开箱奖励通过 `state.last_rewards` 展示
### Get Treasure Rewards ### SendGift 响应
```http `SendGiftResponse` 已携带 `treasure` 状态,送礼人成功送礼后可立即更新宝箱 UI不必等待 IM。
GET /api/v1/rooms/{room_id}/treasure-chest/rewards?round_id=treasure:...&scope=mine
Authorization: Bearer <access_token>
```
Response `data`: ## 房间 IM 事件
```json 房间内宝箱 IM 走腾讯云房间群 `TIMCustomElem`,由 room outbox 事件转换。当前事件类型:
{
"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": ""
}
```
`scope=public` 只返回公开摘要,例如点火人、贡献第一和最多 N 条可展示奖励;不要把所有用户的私派奖励大列表塞进 IM payload。 | Event | 客户端 `event_type` | 用途 |
| --- | --- | --- |
## IM Contracts | `RoomTreasureProgressChanged` | `room_treasure_progress_changed` | 宝箱进度增加 |
| `RoomTreasureCountdownStarted` | `room_treasure_countdown_started` | 满能量进入倒计时 |
房间内宝箱 IM 仍走腾讯云房间群 `TIMCustomElem`,公共字段保持稳定: | `RoomTreasureOpened` | `room_treasure_opened` | 宝箱打开并给出公开奖励摘要 |
| `RoomTreasureRewardGranted` | `room_treasure_reward_granted` | 发奖结果事件,包含奖励列表 |
```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
}
```
### Progress Changed ### Progress Changed
`event_type=room_treasure_progress_changed`
```json ```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_id": "rose",
"gift_count": 10, "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
} }
``` ```
### Countdown Started ### Countdown Started
`event_type=room_treasure_countdown_started`
```json ```json
{ {
"progress_after": 10000, "event_type": "room_treasure_countdown_started",
"threshold": 10000, "box_id": "box_xxx",
"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",
"level": 3, "level": 3,
"current_progress": 10000,
"energy_threshold": 10000,
"open_at_ms": 1779120030000, "open_at_ms": 1779120030000,
"action": { "broadcast_scope": "region",
"type": "enter_room", "visible_region_id": 1001,
"room_id": "lalu_room_1" "top1_user_id": "10001",
} "igniter_user_id": "10002"
} }
``` ```
### Chest Opened ### Opened / Reward Granted
`event_type=room_treasure_opened`
```json ```json
{ {
"event_type": "room_treasure_opened",
"box_id": "box_xxx",
"level": 3,
"next_level": 4,
"opened_at_ms": 1779120030000, "opened_at_ms": 1779120030000,
"participant_count": 86,
"igniter_user_id": "10002",
"top1_user_id": "10001", "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 后台配置带 `config_version`。倒计时开始后,当前宝箱的 `box_id`、等级、进度、`open_at_ms`、点火人和贡献第一不再因配置修改而变化;当前实现开箱时读取最新启用配置的奖励池,因此运营在倒计时窗口内修改奖励配置要按发布窗口处理。
{
"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 使用锁定的配置快照。
| 字段 | 含义 | | 字段 | 含义 |
| --- | --- | | --- | --- |
@ -377,214 +289,90 @@ Response `data`:
| `open_delay_ms` | 满能量到开箱的倒计时 | | `open_delay_ms` | 满能量到开箱的倒计时 |
| `broadcast_enabled` | 是否满能量后播报 | | `broadcast_enabled` | 是否满能量后播报 |
| `broadcast_scope` | `none/region/global` | | `broadcast_scope` | `none/region/global` |
| `broadcast_delay_ms` | 满能量后多久发播报,默认 0 | | `broadcast_delay_ms` | 满能量后多久发播报,当前代码写入配置和读模型,实际倒计时事件立即进入 room outbox |
| `reward_stack_policy` | `allow_stack``priority_only` | | `reward_stack_policy` | `allow_stack``priority_only` |
| `levels` | 7 个等级配置,包含阈值、物料 URL 和奖励池 | | `levels` | 7 个等级配置,包含阈值、物料 URL 和三类奖励池 |
| `gift_energy_rules` | gift_id 或 gift_type_code 的能量倍率、覆盖值和排除规则 | | `gift_energy_rules` | gift_id 或 gift_type_code 的能量倍率、覆盖值和排除规则 |
| `effective_from_ms` | 配置开始生效时间UTC epoch ms |
| `updated_by_admin_id` | 审计字段 | | `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。 - `resource_group_id` 必须存在且 active。
- 同一槽位权重总和必须大于 0。 - 同一奖励池权重总和必须大于 0。
- 展示字段可以保存快照,但发放仍以 wallet-service 资源组事实为准。 - 展示字段可以保存快照,但发放仍以 wallet-service 资源组事实为准。
- 配置不能直接填写金币数量绕过资源组,除非新增明确的钱包奖励配置类型。 - 配置不能直接填写金币数量绕过资源组,除非新增明确的钱包奖励配置类型。
## Internal Events ## room_outbox 与 RocketMQ
建议在 `api/proto/events/room/v1/events.proto` 增加: `room_outbox` 是 MySQL 内的可靠事实源RocketMQ 是发布后的事件总线。不能用 MQ 替代 outbox因为房间状态提交成功但 MQ 发布失败时必须能从 MySQL 补偿。
| Event | 生产方 | 消费方 | 用途 | 当前 topic
| --- | --- | --- | --- |
| `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 |
`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 ```yaml
level outbox_worker:
config_version enabled: true
room_id publish_mode: "mq"
visible_region_id
opened_at_ms rocketmq:
participant_user_ids enabled: true
igniter_user_id room_outbox:
top1_user_id enabled: true
contribution_top topic: "hyapp_room_outbox"
reward_config_snapshot_json treasure_open:
enabled: true
topic: "hyapp_room_treasure_open"
``` ```
activity-service 不反查 Room Cell 判断参与人,避免开奖时与房间状态发生竞态。 `publish_mode`
## Data Model Sketch | 模式 | 行为 |
| --- | --- |
room-service 持久化: | `direct` | room outbox worker 直接投 activity gRPC 和 Tencent IM适合本地默认 |
| `mq` | room outbox worker 只投 RocketMQactivity/notice/IM bridge 各自消费,推荐线上 |
```sql | `dual` | direct + MQ 双写,只用于迁移验证,所有消费者必须幂等 |
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 仍是写命令恢复的关键事实。
## Daily Reset ## Daily Reset
| 规则 | 决策 | | 规则 | 决策 |
| --- | --- | | --- | --- |
| 日期来源 | `time.Now().UTC()``cycle_day=YYYY-MM-DD` | | 日期来源 | `time.Now().UTC()` |
| 重置时间 | 每天 UTC `00:00:00.000` | | 重置时间 | 每天 UTC `00:00:00.000` |
| 时间区间 | `[day_start_ms, next_day_start_ms)` | | 时间区间 | `[day_start_ms, next_day_start_ms)` |
| charging round | 到达新 UTC 日时清空进度、贡献和 queued energy创建新 day level 1 round | | idle | 到达新 UTC 日时回到 level 1清空进度和贡献 |
| countdown/opening/settling round | 已满能量的 round 继续开箱和发奖,使用原 `cycle_day` | | countdown | 如果 `open_at_ms` 跨过 `reset_at_ms`UTC 日界优先,旧宝箱不再结算 |
| exhausted | 下一个 UTC 日恢复 level 1 | | 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 | | 送礼成功 | wallet 扣费成功Room Cell 进度增加,`SendGiftResponse.treasure` 返回最新状态 |
| room outbox 投递 IM 失败 | 房间状态已提交outbox 重试或死信App 可用 GET 接口补状态 | | 单笔溢出 | 进度封顶当前阈值,溢出能量作废,不进入下一等级 |
| broadcast 失败 | activity broadcast outbox 重试,不影响开箱 | | 倒计时送礼 | 礼物正常扣费和加热度,但宝箱进度、点火人、贡献第一不变化 |
| due open worker 宕机 | MySQL `open_at_ms/status` 可被下一轮 worker/cron 重新扫描 | | 满能量 | 写 `RoomTreasureCountdownStarted`outbox worker 安排 RocketMQ delayed open |
| activity 抽奖失败 | settlement 保持 `retryable`,不发“已发奖” IM | | 提前延迟消息 | 不写开箱 no-op command logMQ 后续可重试 |
| wallet 发奖部分失败 | 成功记录保持 granted失败记录重试房间 IM 标记 `partial` 或等待全部终态后再发 | | 到点开箱 | 即使房间未加载MQ 唤醒后 room-service 恢复 Room Cell 并开箱 |
| App 错过 IM | `GET /treasure-chest``GET /treasure-chest/rewards` 补齐 | | top1/igniter 离房 | 仍发角色奖励 |
| 在房奖励 | 只发开箱瞬间在线用户 |
| UTC 重置 | 到达 UTC 零点后进度回到 level 1跨日倒计时不结算 |
| MQ/outbox 重投 | `event_id``box_id`、wallet grant command 保证幂等 |
## Tests ## 代码位置
必须覆盖: | 模块 | 路径 |
| 场景 | 验证 |
| --- | --- | | --- | --- |
| 送礼幂等 | 同一 `command_id` 重试不重复加能量 | | 宝箱状态机和结算 | `services/room-service/internal/room/service/room_treasure.go` |
| 阈值边界 | `progress = threshold - 1` 不点火,`progress = threshold` 点火一次 | | MQ delayed open | `services/room-service/internal/room/service/room_treasure_mq.go` |
| 溢出能量 | 单笔礼物超过阈值时只补满当前等级,剩余能量作废且不进入下一等级 | | outbox worker | `services/room-service/internal/room/service/outbox_worker.go` |
| 倒计时送礼 | countdown/opening/settling 期间送礼不增加宝箱能量,不改变点火人和 top1 | | RocketMQ 适配 | `pkg/rocketmqx``pkg/roommq``services/room-service/internal/integration/rocketmq_outbox.go` |
| 倒计时开箱 | `open_at_ms` 前不开,等于或超过时只开一次 | | App HTTP | `services/gateway-service/internal/transport/http/roomapi` |
| 在线快照 | 开箱时在线用户获得 in_room 抽奖资格,离房用户不获得 in_room 奖励 | | 后台配置 | `server/admin/internal/modules/roomtreasure` |
| top1/igniter | 同分 tie-break 固定;离房、断线或下线后仍结算角色奖励 |
| UTC 重置 | 覆盖 UTC 日开始、结束前一毫秒、结束边界 |
| room close | countdown 后关房不会丢掉已满宝箱奖励 |
| 发奖幂等 | reward_record 重试不重复发资源组 |
| 播报范围 | `none/region/global` 分别不发、发区域、发全局 |
| IM 补偿 | outbox 重试不会产生不同 event_id 的重复客户端消息 |

17
go.mod
View File

@ -3,6 +3,7 @@ module hyapp
go 1.26.3 go 1.26.3
require ( require (
github.com/apache/rocketmq-client-go/v2 v2.1.2
github.com/go-sql-driver/mysql v1.9.3 github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.2.1 github.com/golang-jwt/jwt/v5 v5.2.1
github.com/redis/go-redis/v9 v9.18.0 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/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect github.com/clbanning/mxj v1.8.4 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // 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/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/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/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/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/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 go.uber.org/atomic v1.11.0 // indirect
golang.org/x/net v0.29.0 // indirect golang.org/x/net v0.29.0 // indirect
golang.org/x/sys 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 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 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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/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 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 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 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= 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= 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/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 h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 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 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 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 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 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 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 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 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 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.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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 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 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 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 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= 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.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.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 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/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/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 h1:O4O6EsozBoDjxWbltr3iULgkI7WPj/BFNlYTXDuE64E=
github.com/tencentyun/cos-go-sdk-v5 v0.7.66/go.mod h1:8+hG+mQMuRP/OIS9d83syAvXvrMj9HhkND6Q1fLghw0= 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 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= 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 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 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 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 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 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= 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 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= 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 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= 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 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 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 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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" registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
resourcemodule "hyapp-admin-server/internal/modules/resource" resourcemodule "hyapp-admin-server/internal/modules/resource"
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin" roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
roomtreasuremodule "hyapp-admin-server/internal/modules/roomtreasure"
searchmodule "hyapp-admin-server/internal/modules/search" searchmodule "hyapp-admin-server/internal/modules/search"
sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin" sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin"
uploadmodule "hyapp-admin-server/internal/modules/upload" uploadmodule "hyapp-admin-server/internal/modules/upload"
@ -232,6 +233,7 @@ func main() {
RegionBlock: regionblockmodule.New(userDB, auditHandler), RegionBlock: regionblockmodule.New(userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler), Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler), RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler),
RoomTreasure: roomtreasuremodule.New(roomDB, auditHandler),
Search: searchmodule.New(store), Search: searchmodule.New(store),
SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, 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: "seven-day-checkin:update", Kind: "button"},
{Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"}, {Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"},
{Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"}, {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:view", Kind: "menu"},
{Name: "角色创建", Code: "role:create", Kind: "button"}, {Name: "角色创建", Code: "role:create", Kind: "button"},
{Name: "角色更新", Code: "role:update", Kind: "button"}, {Name: "角色更新", Code: "role:update", Kind: "button"},
@ -126,7 +128,7 @@ var deprecatedPermissionCodes = []string{
"service:view", "service:create", "service:update", "service:operate", "service:view", "service:create", "service:update", "service:operate",
"notification:view", "notification:read", "notification:read-all", "notification:delete", "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 { func (s *Store) seedPermissions() error {
return s.syncDefaultPermissions(s.db) 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: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 71, Visible: true},
{Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 72, 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: "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 { for _, menu := range menus {
syncedMenu, err := s.syncDefaultMenu(s.db, menu) 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", "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"achievement:view", "achievement:create", "achievement:update", "achievement:view", "achievement:create", "achievement:update",
"seven-day-checkin:view", "seven-day-checkin:update", "seven-day-checkin:view", "seven-day-checkin:update",
"room-treasure:view", "room-treasure:update",
"log:view", "log:view",
"job:view", "job:cancel", "export:create", "job:view", "job:cancel", "export:create",
"upload:create", "upload:create",
} }
case "auditor": 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": case "readonly":
return []string{ return []string{
"overview:view", "overview:view",
@ -509,6 +511,7 @@ func defaultRolePermissionCodes(code string) []string {
"daily-task:view", "daily-task:view",
"achievement:view", "achievement:view",
"seven-day-checkin:view", "seven-day-checkin:view",
"room-treasure:view",
"role:view", "role:view",
"permission:view", "permission:view",
"menu:view", "menu:view",
@ -545,9 +548,10 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
"achievement:view", "achievement:create", "achievement:update", "achievement:view", "achievement:create", "achievement:update",
"seven-day-checkin:view", "seven-day-checkin:update", "seven-day-checkin:view", "seven-day-checkin:update",
"room-treasure:view", "room-treasure:update",
} }
case "auditor", "readonly": 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: default:
return nil return nil
} }

View File

@ -27,6 +27,7 @@ import (
"hyapp-admin-server/internal/modules/registrationreward" "hyapp-admin-server/internal/modules/registrationreward"
resourcemodule "hyapp-admin-server/internal/modules/resource" resourcemodule "hyapp-admin-server/internal/modules/resource"
"hyapp-admin-server/internal/modules/roomadmin" "hyapp-admin-server/internal/modules/roomadmin"
"hyapp-admin-server/internal/modules/roomtreasure"
"hyapp-admin-server/internal/modules/search" "hyapp-admin-server/internal/modules/search"
"hyapp-admin-server/internal/modules/sevendaycheckin" "hyapp-admin-server/internal/modules/sevendaycheckin"
"hyapp-admin-server/internal/modules/upload" "hyapp-admin-server/internal/modules/upload"
@ -61,6 +62,7 @@ type Handlers struct {
RegionBlock *regionblock.Handler RegionBlock *regionblock.Handler
Resource *resourcemodule.Handler Resource *resourcemodule.Handler
RoomAdmin *roomadmin.Handler RoomAdmin *roomadmin.Handler
RoomTreasure *roomtreasure.Handler
Search *search.Handler Search *search.Handler
SevenDayCheckIn *sevendaycheckin.Handler SevenDayCheckIn *sevendaycheckin.Handler
Upload *upload.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) regionblock.RegisterRoutes(protected, h.RegionBlock)
gamemanagement.RegisterRoutes(protected, h.Game) gamemanagement.RegisterRoutes(protected, h.Game)
roomadmin.RegisterRoutes(protected, h.RoomAdmin) roomadmin.RegisterRoutes(protected, h.RoomAdmin)
roomtreasure.RegisterRoutes(protected, h.RoomTreasure)
dashboard.RegisterRoutes(protected, h.Dashboard) dashboard.RegisterRoutes(protected, h.Dashboard)
hostorg.RegisterRoutes(protected, h.HostOrg) hostorg.RegisterRoutes(protected, h.HostOrg)
audit.RegisterRoutes(protected, h.Audit) 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_lock_ttl: "30s"
worker_max_retry: 8 worker_max_retry: 8
ensure_groups_on_startup: true 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: consumer:
room_outbox_poll_interval_ms: 1000 room_outbox_poll_interval_ms: 1000
batch_size: 100 batch_size: 100

View File

@ -31,6 +31,19 @@ broadcast:
worker_lock_ttl: "30s" worker_lock_ttl: "30s"
worker_max_retry: 8 worker_max_retry: 8
ensure_groups_on_startup: true 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: consumer:
room_outbox_poll_interval_ms: 1000 room_outbox_poll_interval_ms: 1000
batch_size: 100 batch_size: 100

View File

@ -31,6 +31,19 @@ broadcast:
worker_lock_ttl: "30s" worker_lock_ttl: "30s"
worker_max_retry: 8 worker_max_retry: 8
ensure_groups_on_startup: true 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: consumer:
room_outbox_poll_interval_ms: 1000 room_outbox_poll_interval_ms: 1000
batch_size: 100 batch_size: 100

View File

@ -3,6 +3,7 @@ package app
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"net" "net"
"sync" "sync"
"time" "time"
@ -12,10 +13,13 @@ import (
healthgrpc "google.golang.org/grpc/health/grpc_health_v1" healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1" walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth" "hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown" "hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp" "hyapp/pkg/healthhttp"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
"hyapp/services/activity-service/internal/client" "hyapp/services/activity-service/internal/client"
"hyapp/services/activity-service/internal/config" "hyapp/services/activity-service/internal/config"
@ -41,6 +45,7 @@ type App struct {
mysqlRepo *mysqlstorage.Repository mysqlRepo *mysqlstorage.Repository
broadcast *broadcastservice.Service broadcast *broadcastservice.Service
broadcastWorkerEnabled bool broadcastWorkerEnabled bool
mqConsumers []*rocketmqx.Consumer
userConn *grpc.ClientConn userConn *grpc.ClientConn
walletConn *grpc.ClientConn walletConn *grpc.ClientConn
workerCtx context.Context workerCtx context.Context
@ -134,6 +139,36 @@ func New(cfg config.Config) (*App, error) {
broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc, growthSvc) broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc, growthSvc)
activityv1.RegisterBroadcastServiceServer(server, broadcastServer) activityv1.RegisterBroadcastServiceServer(server, broadcastServer)
activityv1.RegisterRoomEventConsumerServiceServer(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{ health := grpchealth.NewServingChecker("activity-service", grpchealth.Dependency{
Name: "mysql", Name: "mysql",
Check: repository.Ping, Check: repository.Ping,
@ -157,6 +192,7 @@ func New(cfg config.Config) (*App, error) {
mysqlRepo: repository, mysqlRepo: repository,
broadcast: broadcastSvc, broadcast: broadcastSvc,
broadcastWorkerEnabled: cfg.Broadcast.Enabled, broadcastWorkerEnabled: cfg.Broadcast.Enabled,
mqConsumers: mqConsumers,
userConn: userConn, userConn: userConn,
walletConn: walletConn, walletConn: walletConn,
workerCtx: workerCtx, workerCtx: workerCtx,
@ -168,6 +204,10 @@ func New(cfg config.Config) (*App, error) {
func (a *App) Run() error { func (a *App) Run() error {
a.runHealthHTTP() a.runHealthHTTP()
a.health.MarkServing() a.health.MarkServing()
if err := a.startMQ(); err != nil {
a.health.MarkStopped()
return err
}
if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil { if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil {
a.workerWG.Go(func() { a.workerWG.Go(func() {
a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{}) a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{})
@ -178,6 +218,7 @@ func (a *App) Run() error {
a.workerStop() a.workerStop()
} }
a.workerWG.Wait() a.workerWG.Wait()
a.shutdownMQ()
a.health.MarkStopped() a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) { if errors.Is(err, grpc.ErrServerStopped) {
return nil return nil
@ -194,6 +235,7 @@ func (a *App) Close() {
a.workerStop() a.workerStop()
} }
a.workerWG.Wait() a.workerWG.Wait()
a.shutdownMQ()
grpcshutdown.GracefulStop(a.server, 15*time.Second) grpcshutdown.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP() a.closeHealthHTTP()
if a.userConn != nil { if a.userConn != nil {
@ -228,3 +270,40 @@ func (a *App) closeHealthHTTP() {
defer cancel() defer cancel()
_ = a.healthHTTP.Close(ctx) _ = 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 package config
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -28,6 +29,8 @@ type Config struct {
TencentIM TencentIMConfig `yaml:"tencent_im"` TencentIM TencentIMConfig `yaml:"tencent_im"`
// Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。 // Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。
Broadcast BroadcastConfig `yaml:"broadcast"` Broadcast BroadcastConfig `yaml:"broadcast"`
// RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。
RocketMQ RocketMQConfig `yaml:"rocketmq"`
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Consumer ConsumerConfig `yaml:"consumer"` Consumer ConsumerConfig `yaml:"consumer"`
@ -86,6 +89,27 @@ type BroadcastConfig struct {
EnsureGroupsOnStartup bool `yaml:"ensure_groups_on_startup"` 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 返回本地默认配置。 // Default 返回本地默认配置。
func Default() Config { func Default() Config {
return Config{ return Config{
@ -114,6 +138,7 @@ func Default() Config {
WorkerMaxRetry: 8, WorkerMaxRetry: 8,
EnsureGroupsOnStartup: true, EnsureGroupsOnStartup: true,
}, },
RocketMQ: defaultRocketMQConfig(),
MySQLAutoMigrate: false, MySQLAutoMigrate: false,
Consumer: ConsumerConfig{ Consumer: ConsumerConfig{
RoomOutboxPollIntervalMs: 1000, 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 配置。 // Load 从独立 config.yaml 读取 activity-service 配置。
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
cfg := Default() cfg := Default()
@ -195,6 +232,52 @@ func Load(path string) (Config, error) {
if cfg.Broadcast.WorkerMaxRetry <= 0 { if cfg.Broadcast.WorkerMaxRetry <= 0 {
cfg.Broadcast.WorkerMaxRetry = 8 cfg.Broadcast.WorkerMaxRetry = 8
} }
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ)
if err != nil {
return Config{}, err
}
cfg.RocketMQ = rocketMQ
return cfg, nil return cfg, nil
} }
func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
defaults := defaultRocketMQConfig()
cfg.NameServers = normalizeStringSlice(cfg.NameServers)
cfg.NameServerDomain = strings.TrimSpace(cfg.NameServerDomain)
cfg.AccessKey = strings.TrimSpace(cfg.AccessKey)
cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
cfg.SecurityToken = strings.TrimSpace(cfg.SecurityToken)
cfg.Namespace = strings.TrimSpace(cfg.Namespace)
if cfg.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" TypeSuperGift = "super_gift"
// TypeRedPacket 是红包入口播报,资金事实必须由 wallet/red-packet 领域持久化。 // TypeRedPacket 是红包入口播报,资金事实必须由 wallet/red-packet 领域持久化。
TypeRedPacket = "red_packet" TypeRedPacket = "red_packet"
// TypeRoomTreasure 是语音房宝箱满能量后的开箱倒计时播报。
TypeRoomTreasure = "room_treasure"
// StatusPending 表示消息已持久化,尚未被 worker claim。 // StatusPending 表示消息已持久化,尚未被 worker claim。
StatusPending = "pending" StatusPending = "pending"

View File

@ -266,8 +266,11 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
} }
eventCtx := appcode.WithContext(ctx, envelope.GetAppCode()) eventCtx := appcode.WithContext(ctx, envelope.GetAppCode())
result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped} result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped}
if envelope.GetEventType() == "RoomTreasureCountdownStarted" {
return s.handleRoomTreasureCountdown(eventCtx, envelope)
}
if envelope.GetEventType() != "RoomGiftSent" { if envelope.GetEventType() != "RoomGiftSent" {
// 当前只消费礼物事实;未来红包或运营事件应显式增加事件类型分支和测试。 // 当前只消费礼物和房间宝箱事实;未来红包或运营事件应显式增加事件类型分支和测试。
return result, nil return result, nil
} }
var gift roomeventsv1.RoomGiftSent var gift roomeventsv1.RoomGiftSent
@ -301,6 +304,53 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
}, nil }, 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 都走同一套参数归一化。 // WorkerOptions 描述一次 worker 批处理请求cron 和常驻 worker 都走同一套参数归一化。
type WorkerOptions struct { type WorkerOptions struct {
WorkerID string WorkerID string
@ -423,6 +473,41 @@ func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.R
return string(encoded), err 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 { func trimError(value string) string {
value = strings.TrimSpace(value) value = strings.TrimSpace(value)
if len(value) > 512 { if len(value) > 512 {

View File

@ -46,6 +46,7 @@ type RoomQueryClient interface {
GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error)
GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error)
GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, 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) 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) 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) { func (c *grpcRoomQueryClient) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
return c.client.ListRoomOnlineUsers(ctx, req) return c.client.ListRoomOnlineUsers(ctx, req)
} }

View File

@ -94,6 +94,7 @@ type RoomHandlers struct {
GetRoomDetail http.HandlerFunc GetRoomDetail http.HandlerFunc
ListRoomOnlineUsers http.HandlerFunc ListRoomOnlineUsers http.HandlerFunc
GetRoomGiftPanel http.HandlerFunc GetRoomGiftPanel http.HandlerFunc
GetRoomTreasure http.HandlerFunc
FollowRoom http.HandlerFunc FollowRoom http.HandlerFunc
CreateRoom http.HandlerFunc CreateRoom http.HandlerFunc
UpdateRoomProfile 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}/detail", http.MethodGet, h.GetRoomDetail)
r.profile("/rooms/{room_id}/online-users", http.MethodGet, h.ListRoomOnlineUsers) 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}/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/{room_id}/follow", "", h.FollowRoom)
r.profile("/rooms/create", http.MethodPost, h.CreateRoom) r.profile("/rooms/create", http.MethodPost, h.CreateRoom)
r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile) r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile)

View File

@ -279,18 +279,21 @@ type fakeRoomQueryClient struct {
lastMyRoom *roomv1.GetMyRoomRequest lastMyRoom *roomv1.GetMyRoomRequest
lastCurrent *roomv1.GetCurrentRoomRequest lastCurrent *roomv1.GetCurrentRoomRequest
lastSnapshot *roomv1.GetRoomSnapshotRequest lastSnapshot *roomv1.GetRoomSnapshotRequest
lastTreasure *roomv1.GetRoomTreasureRequest
lastOnline *roomv1.ListRoomOnlineUsersRequest lastOnline *roomv1.ListRoomOnlineUsersRequest
resp *roomv1.ListRoomsResponse resp *roomv1.ListRoomsResponse
feedsResp *roomv1.ListRoomsResponse feedsResp *roomv1.ListRoomsResponse
myRoomResp *roomv1.GetMyRoomResponse myRoomResp *roomv1.GetMyRoomResponse
currentResp *roomv1.GetCurrentRoomResponse currentResp *roomv1.GetCurrentRoomResponse
snapshotResp *roomv1.GetRoomSnapshotResponse snapshotResp *roomv1.GetRoomSnapshotResponse
treasureResp *roomv1.GetRoomTreasureResponse
onlineResp *roomv1.ListRoomOnlineUsersResponse onlineResp *roomv1.ListRoomOnlineUsersResponse
err error err error
feedsErr error feedsErr error
myRoomErr error myRoomErr error
currentErr error currentErr error
snapshotErr error snapshotErr error
treasureErr error
onlineErr error onlineErr error
} }
@ -698,6 +701,17 @@ func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.Get
return &roomv1.GetRoomSnapshotResponse{}, nil 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) { func (f *fakeRoomQueryClient) ListRoomOnlineUsers(_ context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
f.lastOnline = req f.lastOnline = req
if f.onlineErr != nil { if f.onlineErr != nil {

View File

@ -52,6 +52,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers {
GetRoomDetail: h.getRoomDetail, GetRoomDetail: h.getRoomDetail,
ListRoomOnlineUsers: h.listRoomOnlineUsers, ListRoomOnlineUsers: h.listRoomOnlineUsers,
GetRoomGiftPanel: h.getRoomGiftPanel, GetRoomGiftPanel: h.getRoomGiftPanel,
GetRoomTreasure: h.getRoomTreasure,
FollowRoom: h.handleRoomFollow, FollowRoom: h.handleRoomFollow,
CreateRoom: h.createRoom, CreateRoom: h.createRoom,
UpdateRoomProfile: h.updateRoomProfile, 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 建立或取消当前用户对房间的关注关系。 // handleRoomFollow 建立或取消当前用户对房间的关注关系。
// 关注关系由 room-service 保存gateway 只负责鉴权用户和 path room_id 的协议转换。 // 关注关系由 room-service 保存gateway 只负责鉴权用户和 path room_id 的协议转换。
func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Request) { func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Request) {

View File

@ -115,6 +115,64 @@ type roomGiftPanelData struct {
QuantityPresets []int32 `json:"quantity_presets"` 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 { type roomGiftRecipientData struct {
TargetType string `json:"target_type"` TargetType string `json:"target_type"`
UserID string `json:"user_id,omitempty"` UserID string `json:"user_id,omitempty"`
@ -245,6 +303,93 @@ func myRoomDataFromProto(resp *roomv1.GetMyRoomResponse) myRoomData {
return data 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 { func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData {
if room == nil { if room == nil {
return roomListItemData{} return roomListItemData{}

View File

@ -37,6 +37,20 @@ room_notice_worker:
initial_backoff: 5s initial_backoff: 5s
max_backoff: 5m 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: log:
level: info level: info
format: json format: json

View File

@ -37,6 +37,20 @@ room_notice_worker:
initial_backoff: 5s initial_backoff: 5s
max_backoff: 5m 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: log:
level: info level: info
format: json format: json

View File

@ -37,6 +37,20 @@ room_notice_worker:
initial_backoff: 5s initial_backoff: 5s
max_backoff: 5m 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: log:
level: info level: info
format: json format: json

View File

@ -4,16 +4,20 @@ package app
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"net" "net"
"sync" "sync"
"time" "time"
"google.golang.org/grpc" "google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1" healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth" "hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown" "hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp" "hyapp/pkg/healthhttp"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
"hyapp/services/notice-service/internal/config" "hyapp/services/notice-service/internal/config"
"hyapp/services/notice-service/internal/modules/roomnotice" "hyapp/services/notice-service/internal/modules/roomnotice"
@ -34,6 +38,7 @@ type App struct {
roomNoticeService *roomnotice.Service roomNoticeService *roomnotice.Service
roomWorkerOptions roomnotice.RoomNoticeWorkerOptions roomWorkerOptions roomnotice.RoomNoticeWorkerOptions
roomWorkerEnabled bool roomWorkerEnabled bool
mqConsumers []*rocketmqx.Consumer
workerCtx context.Context workerCtx context.Context
workerCancel context.CancelFunc workerCancel context.CancelFunc
workerWG sync.WaitGroup workerWG sync.WaitGroup
@ -87,6 +92,11 @@ func New(cfg config.Config) (*App, error) {
_ = store.Close() _ = store.Close()
return nil, errors.New("room notice worker requires tencent_im.enabled") 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"))) server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("notice-service")))
health := grpchealth.NewServingChecker("notice-service", grpchealth.Dependency{ health := grpchealth.NewServingChecker("notice-service", grpchealth.Dependency{
@ -101,6 +111,31 @@ func New(cfg config.Config) (*App, error) {
return nil, err 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()) workerCtx, workerCancel := context.WithCancel(context.Background())
return &App{ return &App{
server: server, server: server,
@ -108,12 +143,13 @@ func New(cfg config.Config) (*App, error) {
health: health, health: health,
healthHTTP: healthHTTP, healthHTTP: healthHTTP,
store: store, store: store,
walletNoticeService: walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher), walletNoticeService: walletSvc,
walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker), walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker),
walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled, walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled,
roomNoticeService: roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher), roomNoticeService: roomSvc,
roomWorkerOptions: roomNoticeWorkerOptions(cfg.RoomNoticeWorker), roomWorkerOptions: roomOptions,
roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled, roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled,
mqConsumers: mqConsumers,
workerCtx: workerCtx, workerCtx: workerCtx,
workerCancel: workerCancel, workerCancel: workerCancel,
}, nil }, nil
@ -123,11 +159,16 @@ func New(cfg config.Config) (*App, error) {
func (a *App) Run() error { func (a *App) Run() error {
a.runHealthHTTP() a.runHealthHTTP()
a.health.MarkServing() a.health.MarkServing()
if err := a.startMQ(); err != nil {
a.health.MarkStopped()
return err
}
defer func() { defer func() {
if a.workerCancel != nil { if a.workerCancel != nil {
a.workerCancel() a.workerCancel()
} }
a.workerWG.Wait() a.workerWG.Wait()
a.shutdownMQ()
a.health.MarkStopped() a.health.MarkStopped()
}() }()
if a.walletWorkerEnabled && a.walletNoticeService != nil { if a.walletWorkerEnabled && a.walletNoticeService != nil {
@ -155,6 +196,7 @@ func (a *App) Close() {
a.workerCancel() a.workerCancel()
} }
a.workerWG.Wait() a.workerWG.Wait()
a.shutdownMQ()
grpcshutdown.GracefulStop(a.server, 15*time.Second) grpcshutdown.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP() a.closeHealthHTTP()
if a.store != nil { if a.store != nil {
@ -206,3 +248,40 @@ func roomNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) roomnotice.Roo
MaxBackoff: cfg.MaxBackoff, 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 package config
import ( import (
"errors"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@ -30,8 +31,10 @@ type Config struct {
WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"` WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"`
// RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。 // RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。
RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"` RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` // RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。
Log logx.Config `yaml:"log"` RocketMQ RocketMQConfig `yaml:"rocketmq"`
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Log logx.Config `yaml:"log"`
} }
// TencentIMConfig 描述服务端调用腾讯云 IM REST API 的配置。 // TencentIMConfig 描述服务端调用腾讯云 IM REST API 的配置。
@ -73,6 +76,27 @@ type WalletNoticeWorkerConfig struct {
MaxBackoff time.Duration `yaml:"max_backoff"` 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 返回本地开发默认配置。 // Default 返回本地开发默认配置。
func Default() Config { func Default() Config {
return Config{ return Config{
@ -112,6 +136,7 @@ func Default() Config {
InitialBackoff: 5 * time.Second, InitialBackoff: 5 * time.Second,
MaxBackoff: 5 * time.Minute, MaxBackoff: 5 * time.Minute,
}, },
RocketMQ: defaultRocketMQConfig(),
Log: logx.Config{ Log: logx.Config{
Level: "info", Level: "info",
Format: "json", 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 配置。 // Load 从独立 config.yaml 读取 notice-service 配置。
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
cfg := Default() cfg := Default()
@ -159,6 +196,11 @@ func Load(path string) (Config, error) {
} }
cfg.WalletNoticeWorker = normalizeWalletNoticeWorker(cfg.WalletNoticeWorker) cfg.WalletNoticeWorker = normalizeWalletNoticeWorker(cfg.WalletNoticeWorker)
cfg.RoomNoticeWorker = normalizeWalletNoticeWorker(cfg.RoomNoticeWorker) 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) == "" { if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" {
cfg.TencentIM.AdminIdentifier = "administrator" cfg.TencentIM.AdminIdentifier = "administrator"
} }
@ -174,6 +216,47 @@ func Load(path string) (Config, error) {
return cfg, nil return cfg, nil
} }
func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) {
defaults := defaultRocketMQConfig()
cfg.NameServers = normalizeStringSlice(cfg.NameServers)
cfg.NameServerDomain = strings.TrimSpace(cfg.NameServerDomain)
cfg.AccessKey = strings.TrimSpace(cfg.AccessKey)
cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
cfg.SecurityToken = strings.TrimSpace(cfg.SecurityToken)
cfg.Namespace = strings.TrimSpace(cfg.Namespace)
if cfg.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 { func normalizeWalletNoticeWorker(cfg WalletNoticeWorkerConfig) WalletNoticeWorkerConfig {
def := Default().WalletNoticeWorker def := Default().WalletNoticeWorker
if cfg.PollInterval <= 0 { 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 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) { func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, candidate roomKickCandidate) (RoomKickEvent, error) {
var ( var (
event RoomKickEvent 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 { if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
return RoomKickEvent{}, err 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 { if envelope.GetEventType() != eventRoomUserKicked {
return RoomKickEvent{}, fmt.Errorf("unexpected room event type %q", envelope.GetEventType()) 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 { if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return RoomKickEvent{}, err return RoomKickEvent{}, err
} }
event.RoomVersion = envelope.GetRoomVersion() event := RoomKickEvent{
event.OccurredAtMS = envelope.GetOccurredAtMs() AppCode: appcode.Normalize(envelope.GetAppCode()),
event.ActorUserID = body.GetActorUserId() EventID: envelope.GetEventId(),
event.TargetUserID = body.GetTargetUserId() 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 { if event.TargetUserID <= 0 {
return RoomKickEvent{}, fmt.Errorf("room kick event target_user_id is invalid") return RoomKickEvent{}, fmt.Errorf("room kick event target_user_id is invalid")
} }

View File

@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"time" "time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
) )
@ -14,6 +15,7 @@ import (
// Repository 是 room notice 模块需要的持久化边界。 // Repository 是 room notice 模块需要的持久化边界。
type Repository interface { type Repository interface {
ClaimRoomKickEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]RoomKickEvent, error) 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 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 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 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 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 { func (s *Service) publishRoomKickEvent(ctx context.Context, event RoomKickEvent, options RoomNoticeWorkerOptions) error {
payload, err := roomKickNoticePayload(event) payload, err := roomKickNoticePayload(event)
if err != nil { if err != nil {

View File

@ -7,6 +7,7 @@ import (
"testing" "testing"
"time" "time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
) )
@ -114,6 +115,13 @@ func (r *fakeRepository) ClaimRoomKickEvents(context.Context, string, int, time.
return r.events, nil 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 { func (r *fakeRepository) MarkRoomKickDelivered(_ context.Context, event RoomKickEvent, _ []byte, _ int64) error {
r.deliveredEventID = event.EventID r.deliveredEventID = event.EventID
return nil return nil

View File

@ -48,9 +48,34 @@ presence_stale_after: "2m"
presence_stale_scan_interval: "30s" presence_stale_scan_interval: "30s"
mic_publish_timeout: "15s" mic_publish_timeout: "15s"
mic_publish_scan_interval: "1s" 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: outbox_worker:
# Docker 本地默认启动补偿 worker验证 IM/activity 异步投递、退避和死信闭环。 # Docker 本地默认启动补偿 worker验证 IM/activity 异步投递、退避和死信闭环。
enabled: true enabled: true
publish_mode: "direct"
poll_interval: "1s" poll_interval: "1s"
batch_size: 100 batch_size: 100
publish_timeout: "3s" publish_timeout: "3s"

View File

@ -57,9 +57,35 @@ presence_stale_after: "2m"
presence_stale_scan_interval: "30s" presence_stale_scan_interval: "30s"
mic_publish_timeout: "15s" mic_publish_timeout: "15s"
mic_publish_scan_interval: "1s" mic_publish_scan_interval: "1s"
outbox_worker: room_treasure_open_scan_interval: "1s"
# 线上默认启动补偿 worker腾讯云 IM/activity-service 抖动时不回滚 Room Cell 状态。 rocketmq:
# 线上建议开启room_outbox 只投 MQactivity/notice/IM bridge 各自用 consumer group 解耦消费。
enabled: true 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" poll_interval: "1s"
batch_size: 100 batch_size: 100
publish_timeout: "3s" publish_timeout: "3s"

View File

@ -51,9 +51,34 @@ presence_stale_after: "2m"
presence_stale_scan_interval: "30s" presence_stale_scan_interval: "30s"
mic_publish_timeout: "15s" mic_publish_timeout: "15s"
mic_publish_scan_interval: "1s" 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: outbox_worker:
# room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;失败退避重试,超过上限转死信。 # room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;失败退避重试,超过上限转死信。
enabled: true enabled: true
publish_mode: "direct"
poll_interval: "1s" poll_interval: "1s"
batch_size: 100 batch_size: 100
publish_timeout: "3s" publish_timeout: "3s"

View File

@ -174,6 +174,25 @@ CREATE TABLE IF NOT EXISTS room_seat_configs (
PRIMARY KEY (app_code) PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间麦位配置表'; ) 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 ( INSERT IGNORE INTO room_seat_configs (
app_code, app_code,
allowed_seat_counts, allowed_seat_counts,
@ -187,3 +206,43 @@ INSERT IGNORE INTO room_seat_configs (
0, 0,
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" healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth" "hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown" "hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp" "hyapp/pkg/healthhttp"
"hyapp/pkg/logx" "hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
"hyapp/pkg/tencentrtc" "hyapp/pkg/tencentrtc"
"hyapp/services/room-service/internal/config" "hyapp/services/room-service/internal/config"
@ -53,6 +56,10 @@ type App struct {
health *healthcheck.State health *healthcheck.State
// healthHTTP 给 CLB 和发布脚本提供 HTTP readiness不承载业务请求。 // healthHTTP 给 CLB 和发布脚本提供 HTTP readiness不承载业务请求。
healthHTTP *healthhttp.Server 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 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。
workerCtx context.Context workerCtx context.Context
// workerCancel 停止 presence stale 和 outbox 补偿 worker。 // workerCancel 停止 presence stale 和 outbox 补偿 worker。
@ -76,10 +83,13 @@ func New(cfg config.Config) (*App, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) var activityConn *grpc.ClientConn
if err != nil { if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
_ = walletConn.Close() activityConn, err = grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
return nil, err if err != nil {
_ = walletConn.Close()
return nil, err
}
} }
startupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) startupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
@ -88,7 +98,7 @@ func New(cfg config.Config) (*App, error) {
// MySQL 是房间恢复的持久来源,启动阶段必须确认连接池可用。 // MySQL 是房间恢复的持久来源,启动阶段必须确认连接池可用。
repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN, cfg.MySQLMaxOpenConns, cfg.MySQLMaxIdleConns) repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN, cfg.MySQLMaxOpenConns, cfg.MySQLMaxIdleConns)
if err != nil { if err != nil {
_ = activityConn.Close() closeClientConn(activityConn)
_ = walletConn.Close() _ = walletConn.Close()
return nil, err return nil, err
} }
@ -97,7 +107,7 @@ func New(cfg config.Config) (*App, error) {
// 本地和测试环境可自动建表,线上应通过显式迁移控制结构变更。 // 本地和测试环境可自动建表,线上应通过显式迁移控制结构变更。
if err := repository.Migrate(startupCtx); err != nil { if err := repository.Migrate(startupCtx); err != nil {
_ = repository.Close() _ = repository.Close()
_ = activityConn.Close() closeClientConn(activityConn)
_ = walletConn.Close() _ = walletConn.Close()
return nil, err 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) redisClient, err := router.NewRedisClient(startupCtx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB)
if err != nil { if err != nil {
_ = repository.Close() _ = repository.Close()
_ = activityConn.Close() closeClientConn(activityConn)
_ = walletConn.Close() _ = walletConn.Close()
return nil, err return nil, err
} }
directory := router.NewRedisDirectory(redisClient) directory := router.NewRedisDirectory(redisClient)
roomPublisher := integration.NewNoopRoomEventPublisher() roomPublisher := integration.NewNoopRoomEventPublisher()
activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second) outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
outboxPublishers := []integration.OutboxPublisher{activityPublisher}
var rtcUserRemover integration.RTCUserRemover var rtcUserRemover integration.RTCUserRemover
var tencentPublisher *integration.TencentIMPublisher
if cfg.TencentIM.Enabled { if cfg.TencentIM.Enabled {
// 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。 // 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。
tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig()) tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
if err != nil { if err != nil {
_ = repository.Close() _ = repository.Close()
_ = activityConn.Close() closeClientConn(activityConn)
_ = walletConn.Close() _ = walletConn.Close()
_ = redisClient.Close() _ = redisClient.Close()
return nil, err return nil, err
} }
tencentPublisher := integration.NewTencentIMPublisher(tencentClient) tencentPublisher = integration.NewTencentIMPublisher(tencentClient)
roomPublisher = tencentPublisher roomPublisher = tencentPublisher
outboxPublishers = append(outboxPublishers, tencentPublisher)
} }
if cfg.TencentRTC.Enabled { if cfg.TencentRTC.Enabled {
rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig()) rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig())
if err != nil { if err != nil {
_ = repository.Close() _ = repository.Close()
_ = activityConn.Close() closeClientConn(activityConn)
_ = walletConn.Close() _ = walletConn.Close()
_ = redisClient.Close() _ = redisClient.Close()
return nil, err return nil, err
} }
rtcUserRemover = rtcClient 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...) outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...)
// 领域服务只依赖接口App 层负责选择 MySQL/Redis/gRPC 具体实现。 // 领域服务只依赖接口App 层负责选择 MySQL/Redis/gRPC 具体实现。
svc := roomservice.New(roomservice.Config{ svc := roomservice.New(roomservice.Config{
NodeID: cfg.NodeID, NodeID: cfg.NodeID,
LeaseTTL: cfg.LeaseTTL, LeaseTTL: cfg.LeaseTTL,
RankLimit: cfg.RankLimit, RankLimit: cfg.RankLimit,
SnapshotEveryN: cfg.SnapshotEveryN, SnapshotEveryN: cfg.SnapshotEveryN,
PresenceStaleAfter: cfg.PresenceStaleAfter, PresenceStaleAfter: cfg.PresenceStaleAfter,
MicPublishTimeout: cfg.MicPublishTimeout, MicPublishTimeout: cfg.MicPublishTimeout,
RTCUserRemover: rtcUserRemover, RTCUserRemover: rtcUserRemover,
RoomTreasureOpenScheduler: treasureOpenScheduler,
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher) }, 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"))) server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service")))
roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout)) roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout))
@ -165,7 +267,7 @@ func New(cfg config.Config) (*App, error) {
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(healthState)) healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(healthState))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, healthState) healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, healthState)
if err != nil { if err != nil {
_ = activityConn.Close() closeClientConn(activityConn)
_ = walletConn.Close() _ = walletConn.Close()
_ = repository.Close() _ = repository.Close()
_ = redisClient.Close() _ = redisClient.Close()
@ -176,7 +278,7 @@ func New(cfg config.Config) (*App, error) {
listener, err := net.Listen("tcp", cfg.GRPCAddr) listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil { if err != nil {
_ = healthHTTP.Close(context.Background()) _ = healthHTTP.Close(context.Background())
_ = activityConn.Close() closeClientConn(activityConn)
_ = walletConn.Close() _ = walletConn.Close()
_ = repository.Close() _ = repository.Close()
_ = redisClient.Close() _ = redisClient.Close()
@ -196,6 +298,8 @@ func New(cfg config.Config) (*App, error) {
nodeRegistry: directory, nodeRegistry: directory,
health: healthState, health: healthState,
healthHTTP: healthHTTP, healthHTTP: healthHTTP,
mqProducers: mqProducers,
mqConsumers: mqConsumers,
workerCtx: workerCtx, workerCtx: workerCtx,
workerCancel: workerCancel, workerCancel: workerCancel,
}, nil }, nil
@ -210,11 +314,16 @@ func (a *App) Run() error {
a.health.MarkGRPCStopped() a.health.MarkGRPCStopped()
return err return err
} }
if err := a.startMQ(); err != nil {
a.health.MarkGRPCStopped()
return err
}
defer func() { defer func() {
if a.workerCancel != nil { if a.workerCancel != nil {
a.workerCancel() a.workerCancel()
} }
a.workerWG.Wait() a.workerWG.Wait()
a.shutdownMQ()
}() }()
a.workerWG.Go(func() { a.workerWG.Go(func() {
a.runNodeRegistryHeartbeat(a.workerCtx) a.runNodeRegistryHeartbeat(a.workerCtx)
@ -231,6 +340,12 @@ func (a *App) Run() error {
a.service.RunMicPublishTimeoutWorker(a.workerCtx, a.cfg.MicPublishScanInterval) 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 { if a.cfg.OutboxWorker.Enabled {
a.workerWG.Go(func() { a.workerWG.Go(func() {
a.service.RunOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{ a.service.RunOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{
@ -268,6 +383,7 @@ func (a *App) Close() {
a.workerCancel() a.workerCancel()
} }
a.workerWG.Wait() a.workerWG.Wait()
a.shutdownMQ()
// GracefulStop 让已进入的 gRPC 请求自然结束。 // GracefulStop 让已进入的 gRPC 请求自然结束。
grpcshutdown.GracefulStop(a.grpcServer, 15*time.Second) grpcshutdown.GracefulStop(a.grpcServer, 15*time.Second)
if a.service != nil { if a.service != nil {
@ -365,3 +481,70 @@ func (a *App) closeHealthHTTP() {
defer cancel() defer cancel()
_ = a.healthHTTP.Close(ctx) _ = 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" "hyapp/pkg/tencentrtc"
) )
const (
OutboxPublishModeDirect = "direct"
OutboxPublishModeMQ = "mq"
OutboxPublishModeDual = "dual"
)
// Config 描述 room-service 进程启动所需的最小配置。 // Config 描述 room-service 进程启动所需的最小配置。
type Config struct { type Config struct {
// ServiceName 是日志和部署识别使用的稳定服务名。 // ServiceName 是日志和部署识别使用的稳定服务名。
@ -70,6 +76,10 @@ type Config struct {
MicPublishTimeout time.Duration `yaml:"mic_publish_timeout"` MicPublishTimeout time.Duration `yaml:"mic_publish_timeout"`
// MicPublishScanInterval 是本节点扫描 pending_publish 超时麦位的周期。 // MicPublishScanInterval 是本节点扫描 pending_publish 超时麦位的周期。
MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"` 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 控制 room_outbox 到腾讯云 IM 补偿投递 worker。
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
// Log 控制 stdout 结构化日志格式、等级和 access body 策略。 // Log 控制 stdout 结构化日志格式、等级和 access body 策略。
@ -80,6 +90,8 @@ type Config struct {
type OutboxWorkerConfig struct { type OutboxWorkerConfig struct {
// Enabled 为 false 时不启动补偿 worker测试或临时止血可用。 // Enabled 为 false 时不启动补偿 worker测试或临时止血可用。
Enabled bool `yaml:"enabled"` Enabled bool `yaml:"enabled"`
// PublishMode 控制 room_outbox 发往 direct、mq 或 dual避免 MQ 消费者与直接投递重复发同一条 IM。
PublishMode string `yaml:"publish_mode"`
// PollInterval 是两轮 pending outbox 扫描之间的固定间隔。 // PollInterval 是两轮 pending outbox 扫描之间的固定间隔。
PollInterval time.Duration `yaml:"poll_interval"` PollInterval time.Duration `yaml:"poll_interval"`
// BatchSize 是单轮最多处理的 pending outbox 数,防止全表扫描。 // BatchSize 是单轮最多处理的 pending outbox 数,防止全表扫描。
@ -96,6 +108,41 @@ type OutboxWorkerConfig struct {
MaxBackoff time.Duration `yaml:"max_backoff"` 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 的配置。 // TencentIMConfig 描述 room-service 调用腾讯云 IM REST API 的配置。
type TencentIMConfig struct { type TencentIMConfig struct {
// Enabled 控制是否启用腾讯云 IM REST 桥接;关闭时只保留 Room Cell 内部提交。 // Enabled 控制是否启用腾讯云 IM REST 桥接;关闭时只保留 Room Cell 内部提交。
@ -200,17 +247,19 @@ func Default() Config {
Endpoint: tencentrtc.DefaultRESTEndpoint, Endpoint: tencentrtc.DefaultRESTEndpoint,
RequestTimeout: 5 * time.Second, RequestTimeout: 5 * time.Second,
}, },
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC",
MySQLMaxOpenConns: 20, MySQLMaxOpenConns: 20,
MySQLMaxIdleConns: 10, MySQLMaxIdleConns: 10,
MySQLAutoMigrate: true, MySQLAutoMigrate: true,
RedisAddr: "127.0.0.1:13379", RedisAddr: "127.0.0.1:13379",
RedisDB: 0, RedisDB: 0,
PresenceStaleAfter: 2 * time.Minute, PresenceStaleAfter: 2 * time.Minute,
PresenceStaleScanInterval: 30 * time.Second, PresenceStaleScanInterval: 30 * time.Second,
MicPublishTimeout: 15 * time.Second, MicPublishTimeout: 15 * time.Second,
MicPublishScanInterval: time.Second, MicPublishScanInterval: time.Second,
OutboxWorker: defaultOutboxWorkerConfig(), RoomTreasureOpenScanInterval: time.Second,
RocketMQ: defaultRocketMQConfig(),
OutboxWorker: defaultOutboxWorkerConfig(),
Log: logx.Config{ Log: logx.Config{
Level: "info", Level: "info",
Format: "json", Format: "json",
@ -223,6 +272,7 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
// 默认启动补偿 worker使用退避和死信避免外部故障刷屏。 // 默认启动补偿 worker使用退避和死信避免外部故障刷屏。
return OutboxWorkerConfig{ return OutboxWorkerConfig{
Enabled: true, Enabled: true,
PublishMode: OutboxPublishModeDirect,
PollInterval: time.Second, PollInterval: time.Second,
BatchSize: 100, BatchSize: 100,
PublishTimeout: 3 * time.Second, 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 补齐动态配置默认值,并拒绝会改变补偿语义的未知策略。 // Normalize 补齐动态配置默认值,并拒绝会改变补偿语义的未知策略。
func Normalize(cfg Config) (Config, error) { func Normalize(cfg Config) (Config, error) {
// 配置归一化只处理需要跨环境保持语义的字段,其他默认值由 Default 提供。 // 配置归一化只处理需要跨环境保持语义的字段,其他默认值由 Default 提供。
@ -301,6 +374,11 @@ func Normalize(cfg Config) (Config, error) {
return Config{}, err return Config{}, err
} }
cfg.OutboxWorker = outboxWorker cfg.OutboxWorker = outboxWorker
rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ, cfg.OutboxWorker.PublishMode)
if err != nil {
return Config{}, err
}
cfg.RocketMQ = rocketMQ
return cfg, nil 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) 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 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 配置。 // Load 从独立 config.yaml 读取 room-service 配置。
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
cfg := Default() cfg := Default()

View File

@ -34,9 +34,12 @@ func TestLoad(t *testing.T) {
if cfg.WalletServiceAddr != "127.0.0.1:13004" { if cfg.WalletServiceAddr != "127.0.0.1:13004" {
t.Fatalf("unexpected wallet addr: %s", cfg.WalletServiceAddr) 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) 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 模板能覆盖本地默认配置。 // TestLoadTencentExample 验证线上腾讯云 IM 模板能覆盖本地默认配置。
@ -67,6 +70,9 @@ func TestLoadTencentExample(t *testing.T) {
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 { if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 {
t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker) t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker)
} }
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.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) { 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) { func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
cfg := Default() cfg := Default()
cfg.OutboxWorker.PollInterval = 0 cfg.OutboxWorker.PollInterval = 0
@ -92,7 +116,7 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Normalize failed: %v", err) 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) t.Fatalf("outbox worker defaults not restored: %+v", normalized.OutboxWorker)
} }
} }

View File

@ -13,6 +13,8 @@ import (
type WalletClient interface { type WalletClient interface {
// DebitGift 执行送礼扣费;失败时 room-service 不进入 Room Cell 状态提交。 // DebitGift 执行送礼扣费;失败时 room-service 不进入 Room Cell 状态提交。
DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) 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 直接桥接能力;房间命令主链路不再调用它。 // RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。
@ -34,3 +36,20 @@ type OutboxPublisher interface {
// PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 转为 retryable 状态重试。 // PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 转为 retryable 状态重试。
PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error 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 房间状态变更。 // 这里不吞错,账务失败必须原样阻断 SendGift 房间状态变更。
return c.client.DebitGift(ctx, req) 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 ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"google.golang.org/protobuf/proto" "google.golang.org/protobuf/proto"
@ -257,6 +258,85 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"billing_receipt_id": body.GetBillingReceiptId(), "billing_receipt_id": body.GetBillingReceiptId(),
} }
return base, true, nil 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: default:
// RoomCreated/HeatChanged/RankChanged 不单独推客户端,避免一条命令产生多条系统消息。 // RoomCreated/HeatChanged/RankChanged 不单独推客户端,避免一条命令产生多条系统消息。
return tencentim.RoomEvent{}, false, nil return tencentim.RoomEvent{}, false, nil
@ -292,6 +372,14 @@ func eventTypeForClient(eventType string) string {
return "room_user_unbanned" return "room_user_unbanned"
case "RoomGiftSent": case "RoomGiftSent":
return "room_gift_sent" 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: default:
return eventType return eventType
} }

View File

@ -338,11 +338,69 @@ type SendGift struct {
HeatValue int64 `json:"heat_value,omitempty"` HeatValue int64 `json:"heat_value,omitempty"`
// PriceVersion 是本次钱包结算使用的服务端礼物价格版本。 // PriceVersion 是本次钱包结算使用的服务端礼物价格版本。
PriceVersion string `json:"price_version,omitempty"` 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 返回命令类型。 // Type 返回命令类型。
func (SendGift) Type() string { return "send_gift" } 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 持久化所需的稳定载荷。 // Serialize 把命令编码成 command log 持久化所需的稳定载荷。
func Serialize(cmd Command) ([]byte, error) { func Serialize(cmd Command) ([]byte, error) {
// 当前 command log 使用 JSON 载荷,便于排查;命令类型由独立列保存。 // 当前 command log 使用 JSON 载荷,便于排查;命令类型由独立列保存。
@ -374,6 +432,31 @@ func IdempotencyPayload(payload []byte) ([]byte, error) {
delete(values, "gift_point_added") delete(values, "gift_point_added")
delete(values, "heat_value") delete(values, "heat_value")
delete(values, "price_version") 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 哈希确认是否同一密码。 // SetRoomPassword 的哈希带随机盐;通用 payload 比较先去掉哈希service 层再用 transient 明文和已存 bcrypt 哈希确认是否同一密码。
delete(values, "password_hash") delete(values, "password_hash")
// MicUp 的发流确认 deadline 由 room-service 生成,客户端重试同一 command_id 时不能因此冲突。 // MicUp 的发流确认 deadline 由 room-service 生成,客户端重试同一 command_id 时不能因此冲突。
@ -430,6 +513,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &UnbanUser{} cmd = &UnbanUser{}
case SendGift{}.Type(): case SendGift{}.Type():
cmd = &SendGift{} cmd = &SendGift{}
case OpenRoomTreasure{}.Type():
cmd = &OpenRoomTreasure{}
default: default:
return nil, fmt.Errorf("unknown room command type: %s", commandType) 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 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) { func TestRoomFollowAffectsSnapshotAndFollowedFeed(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)

View File

@ -65,6 +65,10 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
if !exists { if !exists {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found") 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 提交态。 // 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
walletStartedAt := time.Now() walletStartedAt := time.Now()
@ -92,15 +96,20 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
settledCommand.GiftPointAdded = billing.GetGiftPointAdded() settledCommand.GiftPointAdded = billing.GetGiftPointAdded()
settledCommand.HeatValue = heatValue settledCommand.HeatValue = heatValue
settledCommand.PriceVersion = billing.GetPriceVersion() settledCommand.PriceVersion = billing.GetPriceVersion()
commandPayload, err := command.Serialize(settledCommand) settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
if err != nil {
return mutationResult{}, nil, err
}
// 扣费成功后Room Cell 同步更新热度和本地礼物榜。 // 扣费成功后Room Cell 同步更新热度和本地礼物榜。
current.Heat += heatValue current.Heat += heatValue
current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now) current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now)
current.Version++ 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{ 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 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{ return mutationResult{
snapshot: current.ToProto(), snapshot: current.ToProto(),
billingReceiptID: billing.GetBillingReceiptId(), billingReceiptID: billing.GetBillingReceiptId(),
@ -161,7 +186,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
"price_version": settledCommand.PriceVersion, "price_version": settledCommand.PriceVersion,
}, },
}, },
}, []outbox.Record{giftEvent, heatEvent, rankEvent}, nil }, records, nil
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -173,6 +198,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
RoomHeat: result.roomHeat, RoomHeat: result.roomHeat,
GiftRank: result.giftRank, GiftRank: result.giftRank,
Room: result.snapshot, Room: result.snapshot,
Treasure: result.snapshot.GetTreasure(),
}, nil }, nil
} }

View File

@ -155,9 +155,9 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
continue continue
} }
publishCtx, cancel := context.WithTimeout(context.Background(), options.PublishTimeout) publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
// 发布上下文不继承 worker ctx避免 shutdown 取消导致已开始的单条外部投递无界处于未知状态。 // 发布上下文不继承 worker ctx避免 shutdown 取消导致已开始的单条外部投递无界处于未知状态。
err := s.outboxPublisher.PublishOutboxEvent(publishCtx, record.Envelope) err := s.publishOutboxRecord(publishCtx, record)
cancel() cancel()
if err != nil { if err != nil {
// 投递失败转为 retryable并写入指数退避时间等待下一轮 worker 到期后再抢占。 // 投递失败转为 retryable并写入指数退避时间等待下一轮 worker 到期后再抢占。
@ -203,6 +203,17 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
return nil 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。 // outboxBackoff 根据失败次数计算指数退避,且永远不超过 MaxBackoff。
func outboxBackoff(retryCount int, options OutboxWorkerOptions) time.Duration { func outboxBackoff(retryCount int, options OutboxWorkerOptions) time.Duration {
if retryCount <= 1 { 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++ current.Version++
} }
return nil 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 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 是当前用户可恢复房间的轻量读模型。 // RoomPresence 是当前用户可恢复房间的轻量读模型。
// 它从 RoomSnapshot 投影而来,只用于 App 启动/回前台探测,不替代 Room Cell 权威状态。 // 它从 RoomSnapshot 投影而来,只用于 App 启动/回前台探测,不替代 Room Cell 权威状态。
type RoomPresence struct { type RoomPresence struct {
@ -331,6 +381,10 @@ type Repository interface {
GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, bool, error) GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, bool, error)
// UpsertRoomSeatConfig 写入房间座位数配置,供本地验证和后台配置共享同一张表。 // UpsertRoomSeatConfig 写入房间座位数配置,供本地验证和后台配置共享同一张表。
UpsertRoomSeatConfig(ctx context.Context, config RoomSeatConfig) error 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 读取快照版本之后的命令,恢复时按版本顺序回放。
ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error)
// SaveSnapshot 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。 // 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 MicPublishTimeout time.Duration
// RTCUserRemover 是平台级封禁后把用户从腾讯 RTC 房间移出的外部边界。 // RTCUserRemover 是平台级封禁后把用户从腾讯 RTC 房间移出的外部边界。
RTCUserRemover integration.RTCUserRemover RTCUserRemover integration.RTCUserRemover
// RoomTreasureOpenScheduler 把倒计时开箱唤醒交给外部延迟消息,避免只靠已加载 Cell 扫描。
RoomTreasureOpenScheduler integration.RoomTreasureOpenScheduler
// Clock 允许测试注入稳定时间,生产默认使用系统时钟。 // Clock 允许测试注入稳定时间,生产默认使用系统时钟。
Clock clock.Clock Clock clock.Clock
} }
@ -62,6 +64,8 @@ type Service struct {
syncPublisher integration.RoomEventPublisher syncPublisher integration.RoomEventPublisher
// outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。 // outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。
outboxPublisher integration.OutboxPublisher outboxPublisher integration.OutboxPublisher
// roomTreasureOpenScheduler 在 countdown outbox 投递成功后安排 open_at_ms 唤醒。
roomTreasureOpenScheduler integration.RoomTreasureOpenScheduler
// rtcUserRemover 在 Room Cell 提交驱逐后同步移除实时音频房连接。 // rtcUserRemover 在 Room Cell 提交驱逐后同步移除实时音频房连接。
rtcUserRemover integration.RTCUserRemover rtcUserRemover integration.RTCUserRemover
// draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。 // draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。
@ -142,20 +146,21 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
} }
return &Service{ return &Service{
nodeID: cfg.NodeID, nodeID: cfg.NodeID,
leaseTTL: cfg.LeaseTTL, leaseTTL: cfg.LeaseTTL,
rankLimit: rankLimit, rankLimit: rankLimit,
snapshotEveryN: snapshotEveryN, snapshotEveryN: snapshotEveryN,
presenceStaleAfter: presenceStaleAfter, presenceStaleAfter: presenceStaleAfter,
micPublishTimeout: micPublishTimeout, micPublishTimeout: micPublishTimeout,
clock: usedClock, clock: usedClock,
directory: directory, directory: directory,
repository: repository, repository: repository,
wallet: wallet, wallet: wallet,
syncPublisher: syncPublisher, syncPublisher: syncPublisher,
outboxPublisher: outboxPublisher, outboxPublisher: outboxPublisher,
rtcUserRemover: cfg.RTCUserRemover, roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler,
cells: make(map[string]*cell.RoomCell), rtcUserRemover: cfg.RTCUserRemover,
cells: make(map[string]*cell.RoomCell),
} }
} }

View File

@ -18,6 +18,11 @@ const (
// RoomStatusClosed 表示房间已经关闭guard 和进房都必须拒绝。 // RoomStatusClosed 表示房间已经关闭guard 和进房都必须拒绝。
RoomStatusClosed = "closed" RoomStatusClosed = "closed"
// TreasureStatusIdle 表示当前等级宝箱还在积攒能量。
TreasureStatusIdle = "idle"
// TreasureStatusCountdown 表示当前等级已经满能量,等待后台 worker 到点开箱。
TreasureStatusCountdown = "countdown"
// MicPublishIdle 表示麦位没有正在确认或已确认的 RTC 发流会话。 // MicPublishIdle 表示麦位没有正在确认或已确认的 RTC 发流会话。
MicPublishIdle = "idle" MicPublishIdle = "idle"
// MicPublishPending 表示业务上麦成功,但客户端/RTC 尚未确认音频发布成功。 // MicPublishPending 表示业务上麦成功,但客户端/RTC 尚未确认音频发布成功。
@ -103,6 +108,62 @@ type RankItem struct {
UpdatedAtMS int64 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 的核心业务状态。 // RoomState 是 Room Cell 的核心业务状态。
// 这里刻意不放钱包余额、完整用户资料和跨房活动状态。 // 这里刻意不放钱包余额、完整用户资料和跨房活动状态。
type RoomState struct { type RoomState struct {
@ -136,6 +197,8 @@ type RoomState struct {
GiftRank []RankItem GiftRank []RankItem
// RoomExt 预留少量房间扩展字段,避免 v1 频繁改结构。 // RoomExt 预留少量房间扩展字段,避免 v1 频繁改结构。
RoomExt map[string]string RoomExt map[string]string
// Treasure 是语音房宝箱状态,送礼能量和开箱必须跟随 Room Cell 串行提交。
Treasure *TreasureState
// Version 是房间状态版本,成功变更后递增,快照和 command log 都依赖它。 // Version 是房间状态版本,成功变更后递增,快照和 command log 都依赖它。
Version int64 Version int64
} }
@ -191,6 +254,7 @@ func (s *RoomState) Clone() *RoomState {
Heat: s.Heat, Heat: s.Heat,
GiftRank: append([]RankItem(nil), s.GiftRank...), GiftRank: append([]RankItem(nil), s.GiftRank...),
RoomExt: make(map[string]string, len(s.RoomExt)), RoomExt: make(map[string]string, len(s.RoomExt)),
Treasure: cloneTreasureState(s.Treasure),
Version: s.Version, Version: s.Version,
} }
@ -332,6 +396,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
Version: s.Version, Version: s.Version,
RoomShortId: s.RoomExt["room_short_id"], RoomShortId: s.RoomExt["room_short_id"],
Locked: s.RoomPasswordHash != "", 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())), MuteUsers: make(map[int64]bool, len(snapshot.GetMuteUserIds())),
GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())), GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())),
RoomExt: cloneStringMap(snapshot.GetRoomExt()), RoomExt: cloneStringMap(snapshot.GetRoomExt()),
Treasure: treasureStateFromProto(snapshot.GetTreasure()),
Heat: snapshot.GetHeat(), Heat: snapshot.GetHeat(),
Version: snapshot.GetVersion(), Version: snapshot.GetVersion(),
} }
@ -441,3 +507,80 @@ func cloneStringMap(input map[string]string) map[string]string {
return cloned 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, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code) PRIMARY KEY (app_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, ) 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) `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)`, 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 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_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_user_presence 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_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_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_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 { for _, statement := range statements {
@ -672,6 +730,111 @@ func (r *Repository) UpsertRoomSeatConfig(ctx context.Context, config roomservic
return err 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 返回快照版本之后的可回放命令日志。 // ListCommandsAfter 返回快照版本之后的可回放命令日志。
func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) { func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) {
// 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。 // 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。
@ -1771,6 +1934,13 @@ func normalizedRecordAppCode(ctx context.Context, value string) string {
return appcode.Normalize(value) return appcode.Normalize(value)
} }
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
func mapRoomMetaDuplicateError(err error) error { func mapRoomMetaDuplicateError(err error) error {
if err == nil { if err == nil {
return 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)) 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 代理到房间在线用户分页读模型。 // ListRoomOnlineUsers 代理到房间在线用户分页读模型。
func (s *Server) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) { func (s *Server) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) {
// 在线人数弹窗只读 room_user_presence 投影,不拉完整 RoomSnapshot 做分页。 // 在线人数弹窗只读 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 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 错误边界。 // newTransportTestServer 构造完整领域服务,确保测试覆盖真实 service -> transport 错误边界。
func newTransportTestServer(t *testing.T) *Server { func newTransportTestServer(t *testing.T) *Server {
t.Helper() t.Helper()

View File

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

View File

@ -1372,6 +1372,7 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger
ChargeAmount: chargeAmount, ChargeAmount: chargeAmount,
GiftPointAdded: metadata.GiftPointAdded, GiftPointAdded: metadata.GiftPointAdded,
HeatValue: metadata.HeatValue, HeatValue: metadata.HeatValue,
GiftTypeCode: metadata.GiftTypeCode,
PriceVersion: metadata.PriceVersion, PriceVersion: metadata.PriceVersion,
BalanceAfter: metadata.BalanceAfter, BalanceAfter: metadata.BalanceAfter,
} }

View File

@ -49,6 +49,7 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest)
ChargeAmount: receipt.ChargeAmount, ChargeAmount: receipt.ChargeAmount,
GiftPointAdded: receipt.GiftPointAdded, GiftPointAdded: receipt.GiftPointAdded,
HeatValue: receipt.HeatValue, HeatValue: receipt.HeatValue,
GiftTypeCode: receipt.GiftTypeCode,
PriceVersion: receipt.PriceVersion, PriceVersion: receipt.PriceVersion,
}, nil }, nil
} }