From 36da9df0c703ac5f3b74bc48cded35c05937b365 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 20 May 2026 14:34:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=88=BF=E9=97=B4=E8=AF=AD=E9=9F=B3=E6=88=BF?= =?UTF-8?q?=E5=AE=9D=E7=AE=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 31 +- api/proto/events/room/v1/events.pb.go | 664 ++++++- api/proto/events/room/v1/events.proto | 64 + api/proto/room/v1/room.pb.go | 1580 ++++++++++++----- api/proto/room/v1/room.proto | 80 + api/proto/room/v1/room_grpc.pb.go | 40 +- api/proto/wallet/v1/wallet.pb.go | 14 +- api/proto/wallet/v1/wallet.proto | 1 + deploy/README.md | 16 + docs/IM全局与区域广播架构.md | 65 +- docs/notice-service架构.md | 49 +- docs/定时任务服务架构.md | 11 +- docs/房间Outbox补偿开发.md | 140 +- docs/语音房业务路线图.md | 2 +- docs/语音房功能清单.md | 7 +- docs/语音房宝箱功能架构.md | 646 +++---- go.mod | 17 + go.sum | 156 +- pkg/rocketmqx/rocketmqx.go | 315 ++++ pkg/roommq/messages.go | 106 ++ pkg/roommq/messages_test.go | 57 + server/admin/cmd/server/main.go | 2 + .../internal/modules/roomtreasure/handler.go | 49 + .../internal/modules/roomtreasure/routes.go | 16 + .../internal/modules/roomtreasure/service.go | 422 +++++ .../modules/roomtreasure/service_test.go | 79 + server/admin/internal/repository/seed.go | 12 +- server/admin/internal/router/router.go | 3 + .../018_room_treasure_navigation.sql | 59 + .../019_remove_hidden_navigation_menus.sql | 6 + .../configs/config.docker.yaml | 13 + .../configs/config.tencent.example.yaml | 13 + services/activity-service/configs/config.yaml | 13 + services/activity-service/internal/app/app.go | 79 + .../internal/config/config.go | 83 + .../internal/config/config_test.go | 23 + .../internal/domain/broadcast/broadcast.go | 2 + .../internal/service/broadcast/service.go | 87 +- .../internal/client/room_client.go | 5 + .../transport/http/httproutes/router.go | 2 + .../internal/transport/http/response_test.go | 14 + .../transport/http/roomapi/handler.go | 1 + .../transport/http/roomapi/room_handler.go | 23 + .../transport/http/roomapi/room_view.go | 145 ++ .../notice-service/configs/config.docker.yaml | 14 + .../configs/config.tencent.example.yaml | 14 + services/notice-service/configs/config.yaml | 14 + services/notice-service/internal/app/app.go | 85 +- .../notice-service/internal/config/config.go | 87 +- .../internal/config/config_test.go | 23 + .../modules/roomnotice/mysql_repository.go | 67 +- .../internal/modules/roomnotice/service.go | 23 + .../modules/roomnotice/service_test.go | 8 + .../room-service/configs/config.docker.yaml | 25 + .../configs/config.tencent.example.yaml | 30 +- services/room-service/configs/config.yaml | 25 + .../deploy/mysql/initdb/001_room_service.sql | 59 + services/room-service/internal/app/app.go | 227 ++- .../room-service/internal/config/config.go | 176 +- .../internal/config/config_test.go | 28 +- .../internal/integration/clients.go | 19 + .../internal/integration/clients_grpc.go | 6 + .../internal/integration/rocketmq_outbox.go | 90 + .../internal/integration/tencent_im.go | 88 + .../internal/room/command/command.go | 85 + .../internal/room/service/follow_test.go | 4 + .../internal/room/service/gift.go | 36 +- .../internal/room/service/outbox_worker.go | 15 +- .../internal/room/service/recovery.go | 56 + .../internal/room/service/repository.go | 54 + .../internal/room/service/room_treasure.go | 932 ++++++++++ .../internal/room/service/room_treasure_mq.go | 111 ++ .../room/service/room_treasure_test.go | 519 ++++++ .../internal/room/service/service.go | 33 +- .../room-service/internal/room/state/state.go | 143 ++ .../internal/storage/mysql/repository.go | 170 ++ .../internal/transport/grpc/server.go | 12 + .../internal/transport/grpc/server_test.go | 5 + .../internal/domain/ledger/ledger.go | 1 + .../internal/storage/mysql/repository.go | 1 + .../internal/transport/grpc/server.go | 1 + 81 files changed, 7477 insertions(+), 1021 deletions(-) create mode 100644 pkg/rocketmqx/rocketmqx.go create mode 100644 pkg/roommq/messages.go create mode 100644 pkg/roommq/messages_test.go create mode 100644 server/admin/internal/modules/roomtreasure/handler.go create mode 100644 server/admin/internal/modules/roomtreasure/routes.go create mode 100644 server/admin/internal/modules/roomtreasure/service.go create mode 100644 server/admin/internal/modules/roomtreasure/service_test.go create mode 100644 server/admin/migrations/018_room_treasure_navigation.sql create mode 100644 server/admin/migrations/019_remove_hidden_navigation_menus.sql create mode 100644 services/activity-service/internal/config/config_test.go create mode 100644 services/notice-service/internal/config/config_test.go create mode 100644 services/room-service/internal/integration/rocketmq_outbox.go create mode 100644 services/room-service/internal/room/service/room_treasure.go create mode 100644 services/room-service/internal/room/service/room_treasure_mq.go create mode 100644 services/room-service/internal/room/service/room_treasure_test.go diff --git a/README.md b/README.md index 01d0ee7c..dc92b87b 100644 --- a/README.md +++ b/README.md @@ -9,12 +9,12 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连 任何开发,不需要历史兼容,目前只是在开发阶段 - `gateway-service` 是客户端 HTTP JSON 入口,负责鉴权、协议转换、签发腾讯云 IM UserSig,以及调用内部 gRPC 服务。 -- `room-service` 是房间业务状态 owner,负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、snapshot、command log、outbox、Redis lease,以及腾讯云 IM 群组/系统消息桥接。 +- `room-service` 是房间业务状态 owner,负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、语音房宝箱、snapshot、command log、outbox、Redis lease、RocketMQ room outbox relay,以及腾讯云 IM 群组/系统消息桥接。 - `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL;`room-service` 的 `SendGift` 同步调用它。 - `user-service` 当前承接登录、三方注册、密码、refresh session、用户主数据、默认短号和临时靓号基础能力。 -- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路按独立消费位点接入。 +- `activity-service` 当前承接每日任务、活动/系统消息基础能力和房间事件 consumer;可通过 room outbox direct gRPC 或 RocketMQ `hyapp_room_outbox` 独立 consumer group 消费房间事实。 - `cron-service` 当前负责后台调度、任务运行记录,以及通过内部 gRPC 触发 owner service 批处理;它不直接拥有房间、账务、用户或活动业务状态。 -- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件和 `room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理。 +- `notice-service` 当前消费 `wallet_outbox` 的余额变更事件和 `room_outbox` 的踢人事件,异步投递腾讯云 IM 单聊私有通知;房间群消息和群成员控制仍由 `room-service` 的房间 IM bridge 处理,MQ 模式下通过独立 consumer group 解耦。 - 腾讯云 IM 承担客户端长连接、群消息、公屏、单聊、离线/漫游和全球接入能力;本仓库不再部署 `im-service`。 - 多 App 共用同一套后端服务,入口由 `gateway-service` 通过包名或 `app_code` 解析租户;所有业务库表按 `app_code` 隔离,设计细节见 `docs/多App租户架构.md`。 @@ -23,7 +23,7 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连 - 客户端命令面:`gateway-service` 暴露 HTTP JSON。 - 客户端实时面:腾讯云 IM SDK,客户端用 gateway 签发的 UserSig 登录。 - 服务内部调用:gRPC + protobuf,契约在独立 Go module `api`,业务代码通过 `hyapp.local/api/proto/...` 引用生成包。 -- 房间外事件:protobuf outbox,当前先落 MySQL。 +- 房间外事件:protobuf outbox 先落 MySQL;生产可由 RocketMQ fanout 到 activity、notice、IM bridge 和后续 push/inbox,消费端按 `event_id` 幂等。 ## Time Model @@ -106,7 +106,7 @@ Authorization: Bearer - `gateway-service` 创建房间时从 access token 取当前 `user_id` 写入 `RequestMeta.actor_user_id`;`room-service` 由该 actor 收敛 owner,外部 CreateRoom 请求体不接收 `owner_user_id`。 - `X-Request-ID`/`request_id` 由后端生成,只做链路追踪;写操作的 `command_id` 由前端按用户动作生成,用于服务端幂等。 - `room-service` 在 `CreateRoom` 成功后写 `room_outbox`,由 outbox worker 异步创建或补偿同名腾讯云 IM 群组。 -- `room-service` 在上下麦、禁言、踢人、送礼等命令提交后只写房间状态、command log 和 outbox;腾讯云 IM 房间系统自定义消息由 outbox worker 异步投递。 +- `room-service` 在上下麦、禁言、踢人、送礼、宝箱进度和开箱等命令提交后只写房间状态、command log 和 outbox;腾讯云 IM 房间系统自定义消息由 outbox worker direct 投递,或由 IM bridge consumer 从 RocketMQ 消费后投递。 - `notice-service` 消费钱包余额变更 outbox 和房间踢人 outbox,向单个用户发送 `wallet_notice`、`room_notice` 私有自定义消息;owner service 不直接调用腾讯云 IM 单聊。 - `gateway-service` 已提供 `/api/v1/tencent-im/callback`,支持腾讯云 IM 入群前和发言前回调,并分别回查 `VerifyRoomPresence` 和 `CheckSpeakPermission`。 @@ -130,6 +130,21 @@ docs/notice-service架构.md docs/flutter对接/Flutter App 通知与钱包余额对接.md ``` +## RocketMQ + +本地默认不依赖 RocketMQ,`room-service` 使用 `outbox_worker.publish_mode=direct` 直连腾讯云 IM publisher 和 activity gRPC publisher。线上推荐启用腾讯云 RocketMQ,并把 `room-service` 配成 `publish_mode=mq`,让 room outbox 只发布到消息总线,下游服务各自 consumer group 消费。 + +当前 topic 和 consumer group: + +| Topic | Producer | Consumer Group | 用途 | +| --- | --- | --- | --- | +| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费 `RoomGiftSent`、`RoomTreasureCountdownStarted` 等房间事实 | +| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费 `RoomUserKicked` 等私有通知事实 | +| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 | +| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 room-service 开箱命令 | + +RocketMQ 不拥有房间状态。`room_outbox` 仍是 MySQL 内的可靠事实源;MQ 发布失败时 outbox worker 退避重试,到达重试上限后进入死信。宝箱延迟消息只负责唤醒,到点后 room-service 会重新校验 `box_id`、等级、`open_at_ms`、状态和 UTC 重置边界。 + ## Storage Model MySQL 是本地和线上运行的持久化底座: @@ -197,7 +212,7 @@ services/notice-service/ async user notice delivery workers - 腾讯云 IM 回调入口已经落在 gateway;线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup` 和 `Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。 - 前端对同一次用户写动作的 HTTP 重试必须复用同一个 `command_id`;`X-Request-ID` 不需要也不应该由前端传入。 - `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`。 -- `notice-service` 本地默认不启用腾讯云 IM worker;线上启用 `wallet_notice_worker.enabled=true` 或 `room_notice_worker.enabled=true` 时必须同时启用 `tencent_im.enabled=true`。 -- `activity-service` 已承接每日任务和消息/活动基础能力;`room_outbox` 到 activity/audit 的实际消费链路仍需按独立消费位点补齐。 -- `room-service` App 已启动 `room_outbox` 补偿 worker;该状态只表示腾讯云 IM 系统消息补偿结果,activity/audit 后续必须使用独立消费位点。 +- `notice-service` 本地默认不启用腾讯云 IM worker;线上启用钱包通知、房间通知 MySQL worker 或 RocketMQ room outbox consumer 时,必须同时启用 `tencent_im.enabled=true`。 +- `activity-service` 已承接每日任务、消息/活动基础能力和 room outbox consumer;本地可走 direct gRPC,线上推荐走 RocketMQ `hyapp_room_outbox`。 +- `room-service` App 已启动 `room_outbox` 补偿 worker;`direct` 模式表示直连 publisher 完成,`mq` 模式表示事件已发布到 RocketMQ,下游成功由各服务消费位点保证。 - JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。 diff --git a/api/proto/events/room/v1/events.pb.go b/api/proto/events/room/v1/events.pb.go index c0ba2491..9c0ff95a 100644 --- a/api/proto/events/room/v1/events.pb.go +++ b/api/proto/events/room/v1/events.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc (unknown) // source: proto/events/room/v1/events.proto package roomeventsv1 @@ -1175,6 +1175,550 @@ func (x *RoomRankChanged) GetGiftValue() int64 { return 0 } +type RoomTreasureRewardGrant struct { + state protoimpl.MessageState `protogen:"open.v1"` + RewardRole string `protobuf:"bytes,1,opt,name=reward_role,json=rewardRole,proto3" json:"reward_role,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + RewardItemId string `protobuf:"bytes,3,opt,name=reward_item_id,json=rewardItemId,proto3" json:"reward_item_id,omitempty"` + ResourceGroupId int64 `protobuf:"varint,4,opt,name=resource_group_id,json=resourceGroupId,proto3" json:"resource_group_id,omitempty"` + DisplayName string `protobuf:"bytes,5,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + IconUrl string `protobuf:"bytes,6,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"` + GrantId string `protobuf:"bytes,7,opt,name=grant_id,json=grantId,proto3" json:"grant_id,omitempty"` + Status string `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomTreasureRewardGrant) Reset() { + *x = RoomTreasureRewardGrant{} + mi := &file_proto_events_room_v1_events_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomTreasureRewardGrant) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomTreasureRewardGrant) ProtoMessage() {} + +func (x *RoomTreasureRewardGrant) ProtoReflect() protoreflect.Message { + mi := &file_proto_events_room_v1_events_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoomTreasureRewardGrant.ProtoReflect.Descriptor instead. +func (*RoomTreasureRewardGrant) Descriptor() ([]byte, []int) { + return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{17} +} + +func (x *RoomTreasureRewardGrant) GetRewardRole() string { + if x != nil { + return x.RewardRole + } + return "" +} + +func (x *RoomTreasureRewardGrant) GetUserId() int64 { + if x != nil { + return x.UserId + } + return 0 +} + +func (x *RoomTreasureRewardGrant) GetRewardItemId() string { + if x != nil { + return x.RewardItemId + } + return "" +} + +func (x *RoomTreasureRewardGrant) GetResourceGroupId() int64 { + if x != nil { + return x.ResourceGroupId + } + return 0 +} + +func (x *RoomTreasureRewardGrant) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *RoomTreasureRewardGrant) GetIconUrl() string { + if x != nil { + return x.IconUrl + } + return "" +} + +func (x *RoomTreasureRewardGrant) GetGrantId() string { + if x != nil { + return x.GrantId + } + return "" +} + +func (x *RoomTreasureRewardGrant) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +// RoomTreasureProgressChanged 表达送礼扣费成功后宝箱有效能量发生变化。 +type RoomTreasureProgressChanged struct { + state protoimpl.MessageState `protogen:"open.v1"` + BoxId string `protobuf:"bytes,1,opt,name=box_id,json=boxId,proto3" json:"box_id,omitempty"` + Level int32 `protobuf:"varint,2,opt,name=level,proto3" json:"level,omitempty"` + AddedEnergy int64 `protobuf:"varint,3,opt,name=added_energy,json=addedEnergy,proto3" json:"added_energy,omitempty"` + EffectiveAddedEnergy int64 `protobuf:"varint,4,opt,name=effective_added_energy,json=effectiveAddedEnergy,proto3" json:"effective_added_energy,omitempty"` + OverflowEnergy int64 `protobuf:"varint,5,opt,name=overflow_energy,json=overflowEnergy,proto3" json:"overflow_energy,omitempty"` + CurrentProgress int64 `protobuf:"varint,6,opt,name=current_progress,json=currentProgress,proto3" json:"current_progress,omitempty"` + EnergyThreshold int64 `protobuf:"varint,7,opt,name=energy_threshold,json=energyThreshold,proto3" json:"energy_threshold,omitempty"` + Status string `protobuf:"bytes,8,opt,name=status,proto3" json:"status,omitempty"` + ResetAtMs int64 `protobuf:"varint,9,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,omitempty"` + SenderUserId int64 `protobuf:"varint,10,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"` + GiftId string `protobuf:"bytes,11,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"` + GiftCount int32 `protobuf:"varint,12,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"` + CommandId string `protobuf:"bytes,13,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + VisibleRegionId int64 `protobuf:"varint,14,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomTreasureProgressChanged) Reset() { + *x = RoomTreasureProgressChanged{} + mi := &file_proto_events_room_v1_events_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomTreasureProgressChanged) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomTreasureProgressChanged) ProtoMessage() {} + +func (x *RoomTreasureProgressChanged) ProtoReflect() protoreflect.Message { + mi := &file_proto_events_room_v1_events_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoomTreasureProgressChanged.ProtoReflect.Descriptor instead. +func (*RoomTreasureProgressChanged) Descriptor() ([]byte, []int) { + return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{18} +} + +func (x *RoomTreasureProgressChanged) GetBoxId() string { + if x != nil { + return x.BoxId + } + return "" +} + +func (x *RoomTreasureProgressChanged) GetLevel() int32 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *RoomTreasureProgressChanged) GetAddedEnergy() int64 { + if x != nil { + return x.AddedEnergy + } + return 0 +} + +func (x *RoomTreasureProgressChanged) GetEffectiveAddedEnergy() int64 { + if x != nil { + return x.EffectiveAddedEnergy + } + return 0 +} + +func (x *RoomTreasureProgressChanged) GetOverflowEnergy() int64 { + if x != nil { + return x.OverflowEnergy + } + return 0 +} + +func (x *RoomTreasureProgressChanged) GetCurrentProgress() int64 { + if x != nil { + return x.CurrentProgress + } + return 0 +} + +func (x *RoomTreasureProgressChanged) GetEnergyThreshold() int64 { + if x != nil { + return x.EnergyThreshold + } + return 0 +} + +func (x *RoomTreasureProgressChanged) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *RoomTreasureProgressChanged) GetResetAtMs() int64 { + if x != nil { + return x.ResetAtMs + } + return 0 +} + +func (x *RoomTreasureProgressChanged) GetSenderUserId() int64 { + if x != nil { + return x.SenderUserId + } + return 0 +} + +func (x *RoomTreasureProgressChanged) GetGiftId() string { + if x != nil { + return x.GiftId + } + return "" +} + +func (x *RoomTreasureProgressChanged) GetGiftCount() int32 { + if x != nil { + return x.GiftCount + } + return 0 +} + +func (x *RoomTreasureProgressChanged) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +func (x *RoomTreasureProgressChanged) GetVisibleRegionId() int64 { + if x != nil { + return x.VisibleRegionId + } + return 0 +} + +// RoomTreasureCountdownStarted 表达宝箱满能量后进入倒计时,并作为区域/全局播报的事实源。 +type RoomTreasureCountdownStarted struct { + state protoimpl.MessageState `protogen:"open.v1"` + BoxId string `protobuf:"bytes,1,opt,name=box_id,json=boxId,proto3" json:"box_id,omitempty"` + Level int32 `protobuf:"varint,2,opt,name=level,proto3" json:"level,omitempty"` + CurrentProgress int64 `protobuf:"varint,3,opt,name=current_progress,json=currentProgress,proto3" json:"current_progress,omitempty"` + EnergyThreshold int64 `protobuf:"varint,4,opt,name=energy_threshold,json=energyThreshold,proto3" json:"energy_threshold,omitempty"` + CountdownStartedAtMs int64 `protobuf:"varint,5,opt,name=countdown_started_at_ms,json=countdownStartedAtMs,proto3" json:"countdown_started_at_ms,omitempty"` + OpenAtMs int64 `protobuf:"varint,6,opt,name=open_at_ms,json=openAtMs,proto3" json:"open_at_ms,omitempty"` + BroadcastScope string `protobuf:"bytes,7,opt,name=broadcast_scope,json=broadcastScope,proto3" json:"broadcast_scope,omitempty"` + VisibleRegionId int64 `protobuf:"varint,8,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"` + Top1UserId int64 `protobuf:"varint,9,opt,name=top1_user_id,json=top1UserId,proto3" json:"top1_user_id,omitempty"` + IgniterUserId int64 `protobuf:"varint,10,opt,name=igniter_user_id,json=igniterUserId,proto3" json:"igniter_user_id,omitempty"` + CommandId string `protobuf:"bytes,11,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomTreasureCountdownStarted) Reset() { + *x = RoomTreasureCountdownStarted{} + mi := &file_proto_events_room_v1_events_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomTreasureCountdownStarted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomTreasureCountdownStarted) ProtoMessage() {} + +func (x *RoomTreasureCountdownStarted) ProtoReflect() protoreflect.Message { + mi := &file_proto_events_room_v1_events_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoomTreasureCountdownStarted.ProtoReflect.Descriptor instead. +func (*RoomTreasureCountdownStarted) Descriptor() ([]byte, []int) { + return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{19} +} + +func (x *RoomTreasureCountdownStarted) GetBoxId() string { + if x != nil { + return x.BoxId + } + return "" +} + +func (x *RoomTreasureCountdownStarted) GetLevel() int32 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *RoomTreasureCountdownStarted) GetCurrentProgress() int64 { + if x != nil { + return x.CurrentProgress + } + return 0 +} + +func (x *RoomTreasureCountdownStarted) GetEnergyThreshold() int64 { + if x != nil { + return x.EnergyThreshold + } + return 0 +} + +func (x *RoomTreasureCountdownStarted) GetCountdownStartedAtMs() int64 { + if x != nil { + return x.CountdownStartedAtMs + } + return 0 +} + +func (x *RoomTreasureCountdownStarted) GetOpenAtMs() int64 { + if x != nil { + return x.OpenAtMs + } + return 0 +} + +func (x *RoomTreasureCountdownStarted) GetBroadcastScope() string { + if x != nil { + return x.BroadcastScope + } + return "" +} + +func (x *RoomTreasureCountdownStarted) GetVisibleRegionId() int64 { + if x != nil { + return x.VisibleRegionId + } + return 0 +} + +func (x *RoomTreasureCountdownStarted) GetTop1UserId() int64 { + if x != nil { + return x.Top1UserId + } + return 0 +} + +func (x *RoomTreasureCountdownStarted) GetIgniterUserId() int64 { + if x != nil { + return x.IgniterUserId + } + return 0 +} + +func (x *RoomTreasureCountdownStarted) GetCommandId() string { + if x != nil { + return x.CommandId + } + return "" +} + +// RoomTreasureOpened 表达宝箱已经打开,在线用户名单按开箱瞬间 Room Cell presence 结算。 +type RoomTreasureOpened struct { + state protoimpl.MessageState `protogen:"open.v1"` + BoxId string `protobuf:"bytes,1,opt,name=box_id,json=boxId,proto3" json:"box_id,omitempty"` + Level int32 `protobuf:"varint,2,opt,name=level,proto3" json:"level,omitempty"` + NextLevel int32 `protobuf:"varint,3,opt,name=next_level,json=nextLevel,proto3" json:"next_level,omitempty"` + OpenedAtMs int64 `protobuf:"varint,4,opt,name=opened_at_ms,json=openedAtMs,proto3" json:"opened_at_ms,omitempty"` + ResetAtMs int64 `protobuf:"varint,5,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,omitempty"` + Top1UserId int64 `protobuf:"varint,6,opt,name=top1_user_id,json=top1UserId,proto3" json:"top1_user_id,omitempty"` + IgniterUserId int64 `protobuf:"varint,7,opt,name=igniter_user_id,json=igniterUserId,proto3" json:"igniter_user_id,omitempty"` + InRoomUserIds []int64 `protobuf:"varint,8,rep,packed,name=in_room_user_ids,json=inRoomUserIds,proto3" json:"in_room_user_ids,omitempty"` + Rewards []*RoomTreasureRewardGrant `protobuf:"bytes,9,rep,name=rewards,proto3" json:"rewards,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomTreasureOpened) Reset() { + *x = RoomTreasureOpened{} + mi := &file_proto_events_room_v1_events_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomTreasureOpened) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomTreasureOpened) ProtoMessage() {} + +func (x *RoomTreasureOpened) ProtoReflect() protoreflect.Message { + mi := &file_proto_events_room_v1_events_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoomTreasureOpened.ProtoReflect.Descriptor instead. +func (*RoomTreasureOpened) Descriptor() ([]byte, []int) { + return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{20} +} + +func (x *RoomTreasureOpened) GetBoxId() string { + if x != nil { + return x.BoxId + } + return "" +} + +func (x *RoomTreasureOpened) GetLevel() int32 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *RoomTreasureOpened) GetNextLevel() int32 { + if x != nil { + return x.NextLevel + } + return 0 +} + +func (x *RoomTreasureOpened) GetOpenedAtMs() int64 { + if x != nil { + return x.OpenedAtMs + } + return 0 +} + +func (x *RoomTreasureOpened) GetResetAtMs() int64 { + if x != nil { + return x.ResetAtMs + } + return 0 +} + +func (x *RoomTreasureOpened) GetTop1UserId() int64 { + if x != nil { + return x.Top1UserId + } + return 0 +} + +func (x *RoomTreasureOpened) GetIgniterUserId() int64 { + if x != nil { + return x.IgniterUserId + } + return 0 +} + +func (x *RoomTreasureOpened) GetInRoomUserIds() []int64 { + if x != nil { + return x.InRoomUserIds + } + return nil +} + +func (x *RoomTreasureOpened) GetRewards() []*RoomTreasureRewardGrant { + if x != nil { + return x.Rewards + } + return nil +} + +// RoomTreasureRewardGranted 是客户端展示发奖弹窗和私有通知的最小事实。 +type RoomTreasureRewardGranted struct { + state protoimpl.MessageState `protogen:"open.v1"` + BoxId string `protobuf:"bytes,1,opt,name=box_id,json=boxId,proto3" json:"box_id,omitempty"` + Level int32 `protobuf:"varint,2,opt,name=level,proto3" json:"level,omitempty"` + Rewards []*RoomTreasureRewardGrant `protobuf:"bytes,3,rep,name=rewards,proto3" json:"rewards,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomTreasureRewardGranted) Reset() { + *x = RoomTreasureRewardGranted{} + mi := &file_proto_events_room_v1_events_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomTreasureRewardGranted) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomTreasureRewardGranted) ProtoMessage() {} + +func (x *RoomTreasureRewardGranted) ProtoReflect() protoreflect.Message { + mi := &file_proto_events_room_v1_events_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoomTreasureRewardGranted.ProtoReflect.Descriptor instead. +func (*RoomTreasureRewardGranted) Descriptor() ([]byte, []int) { + return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{21} +} + +func (x *RoomTreasureRewardGranted) GetBoxId() string { + if x != nil { + return x.BoxId + } + return "" +} + +func (x *RoomTreasureRewardGranted) GetLevel() int32 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *RoomTreasureRewardGranted) GetRewards() []*RoomTreasureRewardGrant { + if x != nil { + return x.Rewards + } + return nil +} + var File_proto_events_room_v1_events_proto protoreflect.FileDescriptor const file_proto_events_room_v1_events_proto_rawDesc = "" + @@ -1273,7 +1817,68 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x14\n" + "\x05score\x18\x02 \x01(\x03R\x05score\x12\x1d\n" + "\n" + - "gift_value\x18\x03 \x01(\x03R\tgiftValueB3Z1hyapp.local/api/proto/events/room/v1;roomeventsv1b\x06proto3" + "gift_value\x18\x03 \x01(\x03R\tgiftValue\"\x96\x02\n" + + "\x17RoomTreasureRewardGrant\x12\x1f\n" + + "\vreward_role\x18\x01 \x01(\tR\n" + + "rewardRole\x12\x17\n" + + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12$\n" + + "\x0ereward_item_id\x18\x03 \x01(\tR\frewardItemId\x12*\n" + + "\x11resource_group_id\x18\x04 \x01(\x03R\x0fresourceGroupId\x12!\n" + + "\fdisplay_name\x18\x05 \x01(\tR\vdisplayName\x12\x19\n" + + "\bicon_url\x18\x06 \x01(\tR\aiconUrl\x12\x19\n" + + "\bgrant_id\x18\a \x01(\tR\agrantId\x12\x16\n" + + "\x06status\x18\b \x01(\tR\x06status\"\x83\x04\n" + + "\x1bRoomTreasureProgressChanged\x12\x15\n" + + "\x06box_id\x18\x01 \x01(\tR\x05boxId\x12\x14\n" + + "\x05level\x18\x02 \x01(\x05R\x05level\x12!\n" + + "\fadded_energy\x18\x03 \x01(\x03R\vaddedEnergy\x124\n" + + "\x16effective_added_energy\x18\x04 \x01(\x03R\x14effectiveAddedEnergy\x12'\n" + + "\x0foverflow_energy\x18\x05 \x01(\x03R\x0eoverflowEnergy\x12)\n" + + "\x10current_progress\x18\x06 \x01(\x03R\x0fcurrentProgress\x12)\n" + + "\x10energy_threshold\x18\a \x01(\x03R\x0fenergyThreshold\x12\x16\n" + + "\x06status\x18\b \x01(\tR\x06status\x12\x1e\n" + + "\vreset_at_ms\x18\t \x01(\x03R\tresetAtMs\x12$\n" + + "\x0esender_user_id\x18\n" + + " \x01(\x03R\fsenderUserId\x12\x17\n" + + "\agift_id\x18\v \x01(\tR\x06giftId\x12\x1d\n" + + "\n" + + "gift_count\x18\f \x01(\x05R\tgiftCount\x12\x1d\n" + + "\n" + + "command_id\x18\r \x01(\tR\tcommandId\x12*\n" + + "\x11visible_region_id\x18\x0e \x01(\x03R\x0fvisibleRegionId\"\xb4\x03\n" + + "\x1cRoomTreasureCountdownStarted\x12\x15\n" + + "\x06box_id\x18\x01 \x01(\tR\x05boxId\x12\x14\n" + + "\x05level\x18\x02 \x01(\x05R\x05level\x12)\n" + + "\x10current_progress\x18\x03 \x01(\x03R\x0fcurrentProgress\x12)\n" + + "\x10energy_threshold\x18\x04 \x01(\x03R\x0fenergyThreshold\x125\n" + + "\x17countdown_started_at_ms\x18\x05 \x01(\x03R\x14countdownStartedAtMs\x12\x1c\n" + + "\n" + + "open_at_ms\x18\x06 \x01(\x03R\bopenAtMs\x12'\n" + + "\x0fbroadcast_scope\x18\a \x01(\tR\x0ebroadcastScope\x12*\n" + + "\x11visible_region_id\x18\b \x01(\x03R\x0fvisibleRegionId\x12 \n" + + "\ftop1_user_id\x18\t \x01(\x03R\n" + + "top1UserId\x12&\n" + + "\x0figniter_user_id\x18\n" + + " \x01(\x03R\rigniterUserId\x12\x1d\n" + + "\n" + + "command_id\x18\v \x01(\tR\tcommandId\"\xde\x02\n" + + "\x12RoomTreasureOpened\x12\x15\n" + + "\x06box_id\x18\x01 \x01(\tR\x05boxId\x12\x14\n" + + "\x05level\x18\x02 \x01(\x05R\x05level\x12\x1d\n" + + "\n" + + "next_level\x18\x03 \x01(\x05R\tnextLevel\x12 \n" + + "\fopened_at_ms\x18\x04 \x01(\x03R\n" + + "openedAtMs\x12\x1e\n" + + "\vreset_at_ms\x18\x05 \x01(\x03R\tresetAtMs\x12 \n" + + "\ftop1_user_id\x18\x06 \x01(\x03R\n" + + "top1UserId\x12&\n" + + "\x0figniter_user_id\x18\a \x01(\x03R\rigniterUserId\x12'\n" + + "\x10in_room_user_ids\x18\b \x03(\x03R\rinRoomUserIds\x12G\n" + + "\arewards\x18\t \x03(\v2-.hyapp.events.room.v1.RoomTreasureRewardGrantR\arewards\"\x91\x01\n" + + "\x19RoomTreasureRewardGranted\x12\x15\n" + + "\x06box_id\x18\x01 \x01(\tR\x05boxId\x12\x14\n" + + "\x05level\x18\x02 \x01(\x05R\x05level\x12G\n" + + "\arewards\x18\x03 \x03(\v2-.hyapp.events.room.v1.RoomTreasureRewardGrantR\arewardsB3Z1hyapp.local/api/proto/events/room/v1;roomeventsv1b\x06proto3" var ( file_proto_events_room_v1_events_proto_rawDescOnce sync.Once @@ -1287,32 +1892,39 @@ func file_proto_events_room_v1_events_proto_rawDescGZIP() []byte { return file_proto_events_room_v1_events_proto_rawDescData } -var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 22) var file_proto_events_room_v1_events_proto_goTypes = []any{ - (*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope - (*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated - (*RoomProfileUpdated)(nil), // 2: hyapp.events.room.v1.RoomProfileUpdated - (*RoomUserJoined)(nil), // 3: hyapp.events.room.v1.RoomUserJoined - (*RoomUserLeft)(nil), // 4: hyapp.events.room.v1.RoomUserLeft - (*RoomClosed)(nil), // 5: hyapp.events.room.v1.RoomClosed - (*RoomMicChanged)(nil), // 6: hyapp.events.room.v1.RoomMicChanged - (*RoomMicSeatLocked)(nil), // 7: hyapp.events.room.v1.RoomMicSeatLocked - (*RoomChatEnabledChanged)(nil), // 8: hyapp.events.room.v1.RoomChatEnabledChanged - (*RoomPasswordChanged)(nil), // 9: hyapp.events.room.v1.RoomPasswordChanged - (*RoomAdminChanged)(nil), // 10: hyapp.events.room.v1.RoomAdminChanged - (*RoomUserMuted)(nil), // 11: hyapp.events.room.v1.RoomUserMuted - (*RoomUserKicked)(nil), // 12: hyapp.events.room.v1.RoomUserKicked - (*RoomUserUnbanned)(nil), // 13: hyapp.events.room.v1.RoomUserUnbanned - (*RoomGiftSent)(nil), // 14: hyapp.events.room.v1.RoomGiftSent - (*RoomHeatChanged)(nil), // 15: hyapp.events.room.v1.RoomHeatChanged - (*RoomRankChanged)(nil), // 16: hyapp.events.room.v1.RoomRankChanged + (*EventEnvelope)(nil), // 0: hyapp.events.room.v1.EventEnvelope + (*RoomCreated)(nil), // 1: hyapp.events.room.v1.RoomCreated + (*RoomProfileUpdated)(nil), // 2: hyapp.events.room.v1.RoomProfileUpdated + (*RoomUserJoined)(nil), // 3: hyapp.events.room.v1.RoomUserJoined + (*RoomUserLeft)(nil), // 4: hyapp.events.room.v1.RoomUserLeft + (*RoomClosed)(nil), // 5: hyapp.events.room.v1.RoomClosed + (*RoomMicChanged)(nil), // 6: hyapp.events.room.v1.RoomMicChanged + (*RoomMicSeatLocked)(nil), // 7: hyapp.events.room.v1.RoomMicSeatLocked + (*RoomChatEnabledChanged)(nil), // 8: hyapp.events.room.v1.RoomChatEnabledChanged + (*RoomPasswordChanged)(nil), // 9: hyapp.events.room.v1.RoomPasswordChanged + (*RoomAdminChanged)(nil), // 10: hyapp.events.room.v1.RoomAdminChanged + (*RoomUserMuted)(nil), // 11: hyapp.events.room.v1.RoomUserMuted + (*RoomUserKicked)(nil), // 12: hyapp.events.room.v1.RoomUserKicked + (*RoomUserUnbanned)(nil), // 13: hyapp.events.room.v1.RoomUserUnbanned + (*RoomGiftSent)(nil), // 14: hyapp.events.room.v1.RoomGiftSent + (*RoomHeatChanged)(nil), // 15: hyapp.events.room.v1.RoomHeatChanged + (*RoomRankChanged)(nil), // 16: hyapp.events.room.v1.RoomRankChanged + (*RoomTreasureRewardGrant)(nil), // 17: hyapp.events.room.v1.RoomTreasureRewardGrant + (*RoomTreasureProgressChanged)(nil), // 18: hyapp.events.room.v1.RoomTreasureProgressChanged + (*RoomTreasureCountdownStarted)(nil), // 19: hyapp.events.room.v1.RoomTreasureCountdownStarted + (*RoomTreasureOpened)(nil), // 20: hyapp.events.room.v1.RoomTreasureOpened + (*RoomTreasureRewardGranted)(nil), // 21: hyapp.events.room.v1.RoomTreasureRewardGranted } var file_proto_events_room_v1_events_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 17, // 0: hyapp.events.room.v1.RoomTreasureOpened.rewards:type_name -> hyapp.events.room.v1.RoomTreasureRewardGrant + 17, // 1: hyapp.events.room.v1.RoomTreasureRewardGranted.rewards:type_name -> hyapp.events.room.v1.RoomTreasureRewardGrant + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_proto_events_room_v1_events_proto_init() } @@ -1326,7 +1938,7 @@ func file_proto_events_room_v1_events_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_events_room_v1_events_proto_rawDesc), len(file_proto_events_room_v1_events_proto_rawDesc)), NumEnums: 0, - NumMessages: 17, + NumMessages: 22, NumExtensions: 0, NumServices: 0, }, diff --git a/api/proto/events/room/v1/events.proto b/api/proto/events/room/v1/events.proto index cdf27fdd..8ce98f2f 100644 --- a/api/proto/events/room/v1/events.proto +++ b/api/proto/events/room/v1/events.proto @@ -141,3 +141,67 @@ message RoomRankChanged { int64 score = 2; int64 gift_value = 3; } + +message RoomTreasureRewardGrant { + string reward_role = 1; + int64 user_id = 2; + string reward_item_id = 3; + int64 resource_group_id = 4; + string display_name = 5; + string icon_url = 6; + string grant_id = 7; + string status = 8; +} + +// RoomTreasureProgressChanged 表达送礼扣费成功后宝箱有效能量发生变化。 +message RoomTreasureProgressChanged { + string box_id = 1; + int32 level = 2; + int64 added_energy = 3; + int64 effective_added_energy = 4; + int64 overflow_energy = 5; + int64 current_progress = 6; + int64 energy_threshold = 7; + string status = 8; + int64 reset_at_ms = 9; + int64 sender_user_id = 10; + string gift_id = 11; + int32 gift_count = 12; + string command_id = 13; + int64 visible_region_id = 14; +} + +// RoomTreasureCountdownStarted 表达宝箱满能量后进入倒计时,并作为区域/全局播报的事实源。 +message RoomTreasureCountdownStarted { + string box_id = 1; + int32 level = 2; + int64 current_progress = 3; + int64 energy_threshold = 4; + int64 countdown_started_at_ms = 5; + int64 open_at_ms = 6; + string broadcast_scope = 7; + int64 visible_region_id = 8; + int64 top1_user_id = 9; + int64 igniter_user_id = 10; + string command_id = 11; +} + +// RoomTreasureOpened 表达宝箱已经打开,在线用户名单按开箱瞬间 Room Cell presence 结算。 +message RoomTreasureOpened { + string box_id = 1; + int32 level = 2; + int32 next_level = 3; + int64 opened_at_ms = 4; + int64 reset_at_ms = 5; + int64 top1_user_id = 6; + int64 igniter_user_id = 7; + repeated int64 in_room_user_ids = 8; + repeated RoomTreasureRewardGrant rewards = 9; +} + +// RoomTreasureRewardGranted 是客户端展示发奖弹窗和私有通知的最小事实。 +message RoomTreasureRewardGranted { + string box_id = 1; + int32 level = 2; + repeated RoomTreasureRewardGrant rewards = 3; +} diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 1925f6dc..8095474f 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.11 -// protoc v5.28.3 +// protoc (unknown) // source: proto/room/v1/room.proto package roomv1 @@ -448,6 +448,535 @@ func (x *RankItem) GetUpdatedAtMs() int64 { return 0 } +// RoomTreasureRewardItem 是后台配置给客户端展示的宝箱奖励候选项。 +type RoomTreasureRewardItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + RewardItemId string `protobuf:"bytes,1,opt,name=reward_item_id,json=rewardItemId,proto3" json:"reward_item_id,omitempty"` + ResourceGroupId int64 `protobuf:"varint,2,opt,name=resource_group_id,json=resourceGroupId,proto3" json:"resource_group_id,omitempty"` + Weight int64 `protobuf:"varint,3,opt,name=weight,proto3" json:"weight,omitempty"` + DisplayName string `protobuf:"bytes,4,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + IconUrl string `protobuf:"bytes,5,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomTreasureRewardItem) Reset() { + *x = RoomTreasureRewardItem{} + mi := &file_proto_room_v1_room_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomTreasureRewardItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomTreasureRewardItem) ProtoMessage() {} + +func (x *RoomTreasureRewardItem) ProtoReflect() protoreflect.Message { + mi := &file_proto_room_v1_room_proto_msgTypes[5] + 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 RoomTreasureRewardItem.ProtoReflect.Descriptor instead. +func (*RoomTreasureRewardItem) Descriptor() ([]byte, []int) { + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{5} +} + +func (x *RoomTreasureRewardItem) GetRewardItemId() string { + if x != nil { + return x.RewardItemId + } + return "" +} + +func (x *RoomTreasureRewardItem) GetResourceGroupId() int64 { + if x != nil { + return x.ResourceGroupId + } + return 0 +} + +func (x *RoomTreasureRewardItem) GetWeight() int64 { + if x != nil { + return x.Weight + } + return 0 +} + +func (x *RoomTreasureRewardItem) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *RoomTreasureRewardItem) GetIconUrl() string { + if x != nil { + return x.IconUrl + } + return "" +} + +// RoomTreasureLevel 暴露单个等级宝箱的物料、阈值和三类奖励池。 +type RoomTreasureLevel struct { + state protoimpl.MessageState `protogen:"open.v1"` + Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"` + EnergyThreshold int64 `protobuf:"varint,2,opt,name=energy_threshold,json=energyThreshold,proto3" json:"energy_threshold,omitempty"` + CoverUrl string `protobuf:"bytes,3,opt,name=cover_url,json=coverUrl,proto3" json:"cover_url,omitempty"` + AnimationUrl string `protobuf:"bytes,4,opt,name=animation_url,json=animationUrl,proto3" json:"animation_url,omitempty"` + OpeningAnimationUrl string `protobuf:"bytes,5,opt,name=opening_animation_url,json=openingAnimationUrl,proto3" json:"opening_animation_url,omitempty"` + OpenedImageUrl string `protobuf:"bytes,6,opt,name=opened_image_url,json=openedImageUrl,proto3" json:"opened_image_url,omitempty"` + InRoomRewards []*RoomTreasureRewardItem `protobuf:"bytes,7,rep,name=in_room_rewards,json=inRoomRewards,proto3" json:"in_room_rewards,omitempty"` + Top1Rewards []*RoomTreasureRewardItem `protobuf:"bytes,8,rep,name=top1_rewards,json=top1Rewards,proto3" json:"top1_rewards,omitempty"` + IgniterRewards []*RoomTreasureRewardItem `protobuf:"bytes,9,rep,name=igniter_rewards,json=igniterRewards,proto3" json:"igniter_rewards,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomTreasureLevel) Reset() { + *x = RoomTreasureLevel{} + mi := &file_proto_room_v1_room_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomTreasureLevel) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomTreasureLevel) ProtoMessage() {} + +func (x *RoomTreasureLevel) ProtoReflect() protoreflect.Message { + mi := &file_proto_room_v1_room_proto_msgTypes[6] + 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 RoomTreasureLevel.ProtoReflect.Descriptor instead. +func (*RoomTreasureLevel) Descriptor() ([]byte, []int) { + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{6} +} + +func (x *RoomTreasureLevel) GetLevel() int32 { + if x != nil { + return x.Level + } + return 0 +} + +func (x *RoomTreasureLevel) GetEnergyThreshold() int64 { + if x != nil { + return x.EnergyThreshold + } + return 0 +} + +func (x *RoomTreasureLevel) GetCoverUrl() string { + if x != nil { + return x.CoverUrl + } + return "" +} + +func (x *RoomTreasureLevel) GetAnimationUrl() string { + if x != nil { + return x.AnimationUrl + } + return "" +} + +func (x *RoomTreasureLevel) GetOpeningAnimationUrl() string { + if x != nil { + return x.OpeningAnimationUrl + } + return "" +} + +func (x *RoomTreasureLevel) GetOpenedImageUrl() string { + if x != nil { + return x.OpenedImageUrl + } + return "" +} + +func (x *RoomTreasureLevel) GetInRoomRewards() []*RoomTreasureRewardItem { + if x != nil { + return x.InRoomRewards + } + return nil +} + +func (x *RoomTreasureLevel) GetTop1Rewards() []*RoomTreasureRewardItem { + if x != nil { + return x.Top1Rewards + } + return nil +} + +func (x *RoomTreasureLevel) GetIgniterRewards() []*RoomTreasureRewardItem { + if x != nil { + return x.IgniterRewards + } + return nil +} + +// RoomTreasureRewardGrant 是一次宝箱打开后给某个用户结算出的具体奖励。 +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_room_v1_room_proto_msgTypes[7] + 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_room_v1_room_proto_msgTypes[7] + 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_room_v1_room_proto_rawDescGZIP(), []int{7} +} + +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 "" +} + +// RoomTreasureState 是 Room Cell 持有并随快照恢复的当前宝箱状态。 +type RoomTreasureState struct { + state protoimpl.MessageState `protogen:"open.v1"` + CurrentLevel int32 `protobuf:"varint,1,opt,name=current_level,json=currentLevel,proto3" json:"current_level,omitempty"` + CurrentProgress int64 `protobuf:"varint,2,opt,name=current_progress,json=currentProgress,proto3" json:"current_progress,omitempty"` + EnergyThreshold int64 `protobuf:"varint,3,opt,name=energy_threshold,json=energyThreshold,proto3" json:"energy_threshold,omitempty"` + Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,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"` + OpenedAtMs int64 `protobuf:"varint,7,opt,name=opened_at_ms,json=openedAtMs,proto3" json:"opened_at_ms,omitempty"` + ResetAtMs int64 `protobuf:"varint,8,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,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"` + BoxId string `protobuf:"bytes,11,opt,name=box_id,json=boxId,proto3" json:"box_id,omitempty"` + ConfigVersion int64 `protobuf:"varint,12,opt,name=config_version,json=configVersion,proto3" json:"config_version,omitempty"` + LastRewards []*RoomTreasureRewardGrant `protobuf:"bytes,13,rep,name=last_rewards,json=lastRewards,proto3" json:"last_rewards,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomTreasureState) Reset() { + *x = RoomTreasureState{} + mi := &file_proto_room_v1_room_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomTreasureState) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomTreasureState) ProtoMessage() {} + +func (x *RoomTreasureState) ProtoReflect() protoreflect.Message { + mi := &file_proto_room_v1_room_proto_msgTypes[8] + 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 RoomTreasureState.ProtoReflect.Descriptor instead. +func (*RoomTreasureState) Descriptor() ([]byte, []int) { + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{8} +} + +func (x *RoomTreasureState) GetCurrentLevel() int32 { + if x != nil { + return x.CurrentLevel + } + return 0 +} + +func (x *RoomTreasureState) GetCurrentProgress() int64 { + if x != nil { + return x.CurrentProgress + } + return 0 +} + +func (x *RoomTreasureState) GetEnergyThreshold() int64 { + if x != nil { + return x.EnergyThreshold + } + return 0 +} + +func (x *RoomTreasureState) GetStatus() string { + if x != nil { + return x.Status + } + return "" +} + +func (x *RoomTreasureState) GetCountdownStartedAtMs() int64 { + if x != nil { + return x.CountdownStartedAtMs + } + return 0 +} + +func (x *RoomTreasureState) GetOpenAtMs() int64 { + if x != nil { + return x.OpenAtMs + } + return 0 +} + +func (x *RoomTreasureState) GetOpenedAtMs() int64 { + if x != nil { + return x.OpenedAtMs + } + return 0 +} + +func (x *RoomTreasureState) GetResetAtMs() int64 { + if x != nil { + return x.ResetAtMs + } + return 0 +} + +func (x *RoomTreasureState) GetTop1UserId() int64 { + if x != nil { + return x.Top1UserId + } + return 0 +} + +func (x *RoomTreasureState) GetIgniterUserId() int64 { + if x != nil { + return x.IgniterUserId + } + return 0 +} + +func (x *RoomTreasureState) GetBoxId() string { + if x != nil { + return x.BoxId + } + return "" +} + +func (x *RoomTreasureState) GetConfigVersion() int64 { + if x != nil { + return x.ConfigVersion + } + return 0 +} + +func (x *RoomTreasureState) GetLastRewards() []*RoomTreasureRewardGrant { + if x != nil { + return x.LastRewards + } + return nil +} + +// RoomTreasureInfo 是 App 初始化宝箱 UI 的完整读模型。 +type RoomTreasureInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + Levels []*RoomTreasureLevel `protobuf:"bytes,2,rep,name=levels,proto3" json:"levels,omitempty"` + State *RoomTreasureState `protobuf:"bytes,3,opt,name=state,proto3" json:"state,omitempty"` + ServerTimeMs int64 `protobuf:"varint,4,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"` + BroadcastScope string `protobuf:"bytes,5,opt,name=broadcast_scope,json=broadcastScope,proto3" json:"broadcast_scope,omitempty"` + OpenDelayMs int64 `protobuf:"varint,6,opt,name=open_delay_ms,json=openDelayMs,proto3" json:"open_delay_ms,omitempty"` + BroadcastDelayMs int64 `protobuf:"varint,7,opt,name=broadcast_delay_ms,json=broadcastDelayMs,proto3" json:"broadcast_delay_ms,omitempty"` + RewardStackPolicy string `protobuf:"bytes,8,opt,name=reward_stack_policy,json=rewardStackPolicy,proto3" json:"reward_stack_policy,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomTreasureInfo) Reset() { + *x = RoomTreasureInfo{} + mi := &file_proto_room_v1_room_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomTreasureInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomTreasureInfo) ProtoMessage() {} + +func (x *RoomTreasureInfo) ProtoReflect() protoreflect.Message { + mi := &file_proto_room_v1_room_proto_msgTypes[9] + 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 RoomTreasureInfo.ProtoReflect.Descriptor instead. +func (*RoomTreasureInfo) Descriptor() ([]byte, []int) { + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{9} +} + +func (x *RoomTreasureInfo) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *RoomTreasureInfo) GetLevels() []*RoomTreasureLevel { + if x != nil { + return x.Levels + } + return nil +} + +func (x *RoomTreasureInfo) GetState() *RoomTreasureState { + if x != nil { + return x.State + } + return nil +} + +func (x *RoomTreasureInfo) GetServerTimeMs() int64 { + if x != nil { + return x.ServerTimeMs + } + return 0 +} + +func (x *RoomTreasureInfo) GetBroadcastScope() string { + if x != nil { + return x.BroadcastScope + } + return "" +} + +func (x *RoomTreasureInfo) GetOpenDelayMs() int64 { + if x != nil { + return x.OpenDelayMs + } + return 0 +} + +func (x *RoomTreasureInfo) GetBroadcastDelayMs() int64 { + if x != nil { + return x.BroadcastDelayMs + } + return 0 +} + +func (x *RoomTreasureInfo) GetRewardStackPolicy() string { + if x != nil { + return x.RewardStackPolicy + } + return "" +} + // RoomSnapshot 把 room-service 对外需要暴露的房间投影集中返回。 type RoomSnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -469,14 +998,16 @@ type RoomSnapshot struct { RoomShortId string `protobuf:"bytes,17,opt,name=room_short_id,json=roomShortId,proto3" json:"room_short_id,omitempty"` VisibleRegionId int64 `protobuf:"varint,18,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"` // locked 只表达房间是否需要入房密码;具体密码哈希不对外映射到 HTTP JSON。 - Locked bool `protobuf:"varint,19,opt,name=locked,proto3" json:"locked,omitempty"` + Locked bool `protobuf:"varint,19,opt,name=locked,proto3" json:"locked,omitempty"` + // treasure 是语音房宝箱当前状态;配置物料通过 GetRoomTreasure 单独读取,避免快照过大。 + Treasure *RoomTreasureState `protobuf:"bytes,20,opt,name=treasure,proto3" json:"treasure,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *RoomSnapshot) Reset() { *x = RoomSnapshot{} - mi := &file_proto_room_v1_room_proto_msgTypes[5] + mi := &file_proto_room_v1_room_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -488,7 +1019,7 @@ func (x *RoomSnapshot) String() string { func (*RoomSnapshot) ProtoMessage() {} func (x *RoomSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[5] + mi := &file_proto_room_v1_room_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -501,7 +1032,7 @@ func (x *RoomSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomSnapshot.ProtoReflect.Descriptor instead. func (*RoomSnapshot) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{5} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{10} } func (x *RoomSnapshot) GetRoomId() string { @@ -630,6 +1161,13 @@ func (x *RoomSnapshot) GetLocked() bool { return false } +func (x *RoomSnapshot) GetTreasure() *RoomTreasureState { + if x != nil { + return x.Treasure + } + return nil +} + // CreateRoomRequest 创建一个新的房间执行单元。 // 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、mode 和 room_name。 // 同一个 meta.actor_user_id 只能作为 owner 创建一个房间;重复创建返回 Conflict。 @@ -656,7 +1194,7 @@ type CreateRoomRequest struct { func (x *CreateRoomRequest) Reset() { *x = CreateRoomRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[6] + mi := &file_proto_room_v1_room_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -668,7 +1206,7 @@ func (x *CreateRoomRequest) String() string { func (*CreateRoomRequest) ProtoMessage() {} func (x *CreateRoomRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[6] + mi := &file_proto_room_v1_room_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -681,7 +1219,7 @@ func (x *CreateRoomRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateRoomRequest.ProtoReflect.Descriptor instead. func (*CreateRoomRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{6} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{11} } func (x *CreateRoomRequest) GetMeta() *RequestMeta { @@ -751,7 +1289,7 @@ type CreateRoomResponse struct { func (x *CreateRoomResponse) Reset() { *x = CreateRoomResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[7] + mi := &file_proto_room_v1_room_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -763,7 +1301,7 @@ func (x *CreateRoomResponse) String() string { func (*CreateRoomResponse) ProtoMessage() {} func (x *CreateRoomResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[7] + mi := &file_proto_room_v1_room_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -776,7 +1314,7 @@ func (x *CreateRoomResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CreateRoomResponse.ProtoReflect.Descriptor instead. func (*CreateRoomResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{7} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{12} } func (x *CreateRoomResponse) GetResult() *CommandResult { @@ -808,7 +1346,7 @@ type UpdateRoomProfileRequest struct { func (x *UpdateRoomProfileRequest) Reset() { *x = UpdateRoomProfileRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[8] + mi := &file_proto_room_v1_room_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -820,7 +1358,7 @@ func (x *UpdateRoomProfileRequest) String() string { func (*UpdateRoomProfileRequest) ProtoMessage() {} func (x *UpdateRoomProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[8] + mi := &file_proto_room_v1_room_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -833,7 +1371,7 @@ func (x *UpdateRoomProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateRoomProfileRequest.ProtoReflect.Descriptor instead. func (*UpdateRoomProfileRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{8} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{13} } func (x *UpdateRoomProfileRequest) GetMeta() *RequestMeta { @@ -882,7 +1420,7 @@ type UpdateRoomProfileResponse struct { func (x *UpdateRoomProfileResponse) Reset() { *x = UpdateRoomProfileResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[9] + mi := &file_proto_room_v1_room_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -894,7 +1432,7 @@ func (x *UpdateRoomProfileResponse) String() string { func (*UpdateRoomProfileResponse) ProtoMessage() {} func (x *UpdateRoomProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[9] + mi := &file_proto_room_v1_room_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -907,7 +1445,7 @@ func (x *UpdateRoomProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UpdateRoomProfileResponse.ProtoReflect.Descriptor instead. func (*UpdateRoomProfileResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{9} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{14} } func (x *UpdateRoomProfileResponse) GetResult() *CommandResult { @@ -937,7 +1475,7 @@ type JoinRoomRequest struct { func (x *JoinRoomRequest) Reset() { *x = JoinRoomRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[10] + mi := &file_proto_room_v1_room_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -949,7 +1487,7 @@ func (x *JoinRoomRequest) String() string { func (*JoinRoomRequest) ProtoMessage() {} func (x *JoinRoomRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[10] + mi := &file_proto_room_v1_room_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -962,7 +1500,7 @@ func (x *JoinRoomRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use JoinRoomRequest.ProtoReflect.Descriptor instead. func (*JoinRoomRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{10} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{15} } func (x *JoinRoomRequest) GetMeta() *RequestMeta { @@ -998,7 +1536,7 @@ type JoinRoomResponse struct { func (x *JoinRoomResponse) Reset() { *x = JoinRoomResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[11] + mi := &file_proto_room_v1_room_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1010,7 +1548,7 @@ func (x *JoinRoomResponse) String() string { func (*JoinRoomResponse) ProtoMessage() {} func (x *JoinRoomResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[11] + mi := &file_proto_room_v1_room_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1023,7 +1561,7 @@ func (x *JoinRoomResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use JoinRoomResponse.ProtoReflect.Descriptor instead. func (*JoinRoomResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{11} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{16} } func (x *JoinRoomResponse) GetResult() *CommandResult { @@ -1058,7 +1596,7 @@ type RoomHeartbeatRequest struct { func (x *RoomHeartbeatRequest) Reset() { *x = RoomHeartbeatRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[12] + mi := &file_proto_room_v1_room_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1070,7 +1608,7 @@ func (x *RoomHeartbeatRequest) String() string { func (*RoomHeartbeatRequest) ProtoMessage() {} func (x *RoomHeartbeatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[12] + mi := &file_proto_room_v1_room_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1083,7 +1621,7 @@ func (x *RoomHeartbeatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomHeartbeatRequest.ProtoReflect.Descriptor instead. func (*RoomHeartbeatRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{12} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{17} } func (x *RoomHeartbeatRequest) GetMeta() *RequestMeta { @@ -1105,7 +1643,7 @@ type RoomHeartbeatResponse struct { func (x *RoomHeartbeatResponse) Reset() { *x = RoomHeartbeatResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[13] + mi := &file_proto_room_v1_room_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1117,7 +1655,7 @@ func (x *RoomHeartbeatResponse) String() string { func (*RoomHeartbeatResponse) ProtoMessage() {} func (x *RoomHeartbeatResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[13] + mi := &file_proto_room_v1_room_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1130,7 +1668,7 @@ func (x *RoomHeartbeatResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomHeartbeatResponse.ProtoReflect.Descriptor instead. func (*RoomHeartbeatResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{13} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{18} } func (x *RoomHeartbeatResponse) GetResult() *CommandResult { @@ -1164,7 +1702,7 @@ type LeaveRoomRequest struct { func (x *LeaveRoomRequest) Reset() { *x = LeaveRoomRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[14] + mi := &file_proto_room_v1_room_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1176,7 +1714,7 @@ func (x *LeaveRoomRequest) String() string { func (*LeaveRoomRequest) ProtoMessage() {} func (x *LeaveRoomRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[14] + mi := &file_proto_room_v1_room_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1189,7 +1727,7 @@ func (x *LeaveRoomRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use LeaveRoomRequest.ProtoReflect.Descriptor instead. func (*LeaveRoomRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{14} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{19} } func (x *LeaveRoomRequest) GetMeta() *RequestMeta { @@ -1210,7 +1748,7 @@ type LeaveRoomResponse struct { func (x *LeaveRoomResponse) Reset() { *x = LeaveRoomResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[15] + mi := &file_proto_room_v1_room_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1222,7 +1760,7 @@ func (x *LeaveRoomResponse) String() string { func (*LeaveRoomResponse) ProtoMessage() {} func (x *LeaveRoomResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[15] + mi := &file_proto_room_v1_room_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1235,7 +1773,7 @@ func (x *LeaveRoomResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use LeaveRoomResponse.ProtoReflect.Descriptor instead. func (*LeaveRoomResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{15} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{20} } func (x *LeaveRoomResponse) GetResult() *CommandResult { @@ -1263,7 +1801,7 @@ type CloseRoomRequest struct { func (x *CloseRoomRequest) Reset() { *x = CloseRoomRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[16] + mi := &file_proto_room_v1_room_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1275,7 +1813,7 @@ func (x *CloseRoomRequest) String() string { func (*CloseRoomRequest) ProtoMessage() {} func (x *CloseRoomRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[16] + mi := &file_proto_room_v1_room_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1288,7 +1826,7 @@ func (x *CloseRoomRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CloseRoomRequest.ProtoReflect.Descriptor instead. func (*CloseRoomRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{16} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{21} } func (x *CloseRoomRequest) GetMeta() *RequestMeta { @@ -1316,7 +1854,7 @@ type CloseRoomResponse struct { func (x *CloseRoomResponse) Reset() { *x = CloseRoomResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[17] + mi := &file_proto_room_v1_room_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1328,7 +1866,7 @@ func (x *CloseRoomResponse) String() string { func (*CloseRoomResponse) ProtoMessage() {} func (x *CloseRoomResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[17] + mi := &file_proto_room_v1_room_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1341,7 +1879,7 @@ func (x *CloseRoomResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CloseRoomResponse.ProtoReflect.Descriptor instead. func (*CloseRoomResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{17} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{22} } func (x *CloseRoomResponse) GetResult() *CommandResult { @@ -1369,7 +1907,7 @@ type MicUpRequest struct { func (x *MicUpRequest) Reset() { *x = MicUpRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[18] + mi := &file_proto_room_v1_room_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1381,7 +1919,7 @@ func (x *MicUpRequest) String() string { func (*MicUpRequest) ProtoMessage() {} func (x *MicUpRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[18] + mi := &file_proto_room_v1_room_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1394,7 +1932,7 @@ func (x *MicUpRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MicUpRequest.ProtoReflect.Descriptor instead. func (*MicUpRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{18} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{23} } func (x *MicUpRequest) GetMeta() *RequestMeta { @@ -1425,7 +1963,7 @@ type MicUpResponse struct { func (x *MicUpResponse) Reset() { *x = MicUpResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[19] + mi := &file_proto_room_v1_room_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1437,7 +1975,7 @@ func (x *MicUpResponse) String() string { func (*MicUpResponse) ProtoMessage() {} func (x *MicUpResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[19] + mi := &file_proto_room_v1_room_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1450,7 +1988,7 @@ func (x *MicUpResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MicUpResponse.ProtoReflect.Descriptor instead. func (*MicUpResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{19} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{24} } func (x *MicUpResponse) GetResult() *CommandResult { @@ -1501,7 +2039,7 @@ type MicDownRequest struct { func (x *MicDownRequest) Reset() { *x = MicDownRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[20] + mi := &file_proto_room_v1_room_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1513,7 +2051,7 @@ func (x *MicDownRequest) String() string { func (*MicDownRequest) ProtoMessage() {} func (x *MicDownRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[20] + mi := &file_proto_room_v1_room_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1526,7 +2064,7 @@ func (x *MicDownRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MicDownRequest.ProtoReflect.Descriptor instead. func (*MicDownRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{20} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{25} } func (x *MicDownRequest) GetMeta() *RequestMeta { @@ -1562,7 +2100,7 @@ type MicDownResponse struct { func (x *MicDownResponse) Reset() { *x = MicDownResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[21] + mi := &file_proto_room_v1_room_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1574,7 +2112,7 @@ func (x *MicDownResponse) String() string { func (*MicDownResponse) ProtoMessage() {} func (x *MicDownResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[21] + mi := &file_proto_room_v1_room_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1587,7 +2125,7 @@ func (x *MicDownResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MicDownResponse.ProtoReflect.Descriptor instead. func (*MicDownResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{21} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{26} } func (x *MicDownResponse) GetResult() *CommandResult { @@ -1623,7 +2161,7 @@ type ChangeMicSeatRequest struct { func (x *ChangeMicSeatRequest) Reset() { *x = ChangeMicSeatRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[22] + mi := &file_proto_room_v1_room_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1635,7 +2173,7 @@ func (x *ChangeMicSeatRequest) String() string { func (*ChangeMicSeatRequest) ProtoMessage() {} func (x *ChangeMicSeatRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[22] + mi := &file_proto_room_v1_room_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1648,7 +2186,7 @@ func (x *ChangeMicSeatRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeMicSeatRequest.ProtoReflect.Descriptor instead. func (*ChangeMicSeatRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{22} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{27} } func (x *ChangeMicSeatRequest) GetMeta() *RequestMeta { @@ -1683,7 +2221,7 @@ type ChangeMicSeatResponse struct { func (x *ChangeMicSeatResponse) Reset() { *x = ChangeMicSeatResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[23] + mi := &file_proto_room_v1_room_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1695,7 +2233,7 @@ func (x *ChangeMicSeatResponse) String() string { func (*ChangeMicSeatResponse) ProtoMessage() {} func (x *ChangeMicSeatResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[23] + mi := &file_proto_room_v1_room_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1708,7 +2246,7 @@ func (x *ChangeMicSeatResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ChangeMicSeatResponse.ProtoReflect.Descriptor instead. func (*ChangeMicSeatResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{23} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{28} } func (x *ChangeMicSeatResponse) GetResult() *CommandResult { @@ -1742,7 +2280,7 @@ type ConfirmMicPublishingRequest struct { func (x *ConfirmMicPublishingRequest) Reset() { *x = ConfirmMicPublishingRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[24] + mi := &file_proto_room_v1_room_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1754,7 +2292,7 @@ func (x *ConfirmMicPublishingRequest) String() string { func (*ConfirmMicPublishingRequest) ProtoMessage() {} func (x *ConfirmMicPublishingRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[24] + mi := &file_proto_room_v1_room_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1767,7 +2305,7 @@ func (x *ConfirmMicPublishingRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfirmMicPublishingRequest.ProtoReflect.Descriptor instead. func (*ConfirmMicPublishingRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{24} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{29} } func (x *ConfirmMicPublishingRequest) GetMeta() *RequestMeta { @@ -1824,7 +2362,7 @@ type ConfirmMicPublishingResponse struct { func (x *ConfirmMicPublishingResponse) Reset() { *x = ConfirmMicPublishingResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[25] + mi := &file_proto_room_v1_room_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1836,7 +2374,7 @@ func (x *ConfirmMicPublishingResponse) String() string { func (*ConfirmMicPublishingResponse) ProtoMessage() {} func (x *ConfirmMicPublishingResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[25] + mi := &file_proto_room_v1_room_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1849,7 +2387,7 @@ func (x *ConfirmMicPublishingResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfirmMicPublishingResponse.ProtoReflect.Descriptor instead. func (*ConfirmMicPublishingResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{25} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{30} } func (x *ConfirmMicPublishingResponse) GetResult() *CommandResult { @@ -1886,7 +2424,7 @@ type SetMicMuteRequest struct { func (x *SetMicMuteRequest) Reset() { *x = SetMicMuteRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[26] + mi := &file_proto_room_v1_room_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1898,7 +2436,7 @@ func (x *SetMicMuteRequest) String() string { func (*SetMicMuteRequest) ProtoMessage() {} func (x *SetMicMuteRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[26] + mi := &file_proto_room_v1_room_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1911,7 +2449,7 @@ func (x *SetMicMuteRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetMicMuteRequest.ProtoReflect.Descriptor instead. func (*SetMicMuteRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{26} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{31} } func (x *SetMicMuteRequest) GetMeta() *RequestMeta { @@ -1947,7 +2485,7 @@ type SetMicMuteResponse struct { func (x *SetMicMuteResponse) Reset() { *x = SetMicMuteResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[27] + mi := &file_proto_room_v1_room_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1959,7 +2497,7 @@ func (x *SetMicMuteResponse) String() string { func (*SetMicMuteResponse) ProtoMessage() {} func (x *SetMicMuteResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[27] + mi := &file_proto_room_v1_room_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1972,7 +2510,7 @@ func (x *SetMicMuteResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetMicMuteResponse.ProtoReflect.Descriptor instead. func (*SetMicMuteResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{27} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{32} } func (x *SetMicMuteResponse) GetResult() *CommandResult { @@ -2013,7 +2551,7 @@ type ApplyRTCEventRequest struct { func (x *ApplyRTCEventRequest) Reset() { *x = ApplyRTCEventRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[28] + mi := &file_proto_room_v1_room_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2025,7 +2563,7 @@ func (x *ApplyRTCEventRequest) String() string { func (*ApplyRTCEventRequest) ProtoMessage() {} func (x *ApplyRTCEventRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[28] + mi := &file_proto_room_v1_room_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2038,7 +2576,7 @@ func (x *ApplyRTCEventRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyRTCEventRequest.ProtoReflect.Descriptor instead. func (*ApplyRTCEventRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{28} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{33} } func (x *ApplyRTCEventRequest) GetMeta() *RequestMeta { @@ -2102,7 +2640,7 @@ type ApplyRTCEventResponse struct { func (x *ApplyRTCEventResponse) Reset() { *x = ApplyRTCEventResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[29] + mi := &file_proto_room_v1_room_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2114,7 +2652,7 @@ func (x *ApplyRTCEventResponse) String() string { func (*ApplyRTCEventResponse) ProtoMessage() {} func (x *ApplyRTCEventResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[29] + mi := &file_proto_room_v1_room_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2127,7 +2665,7 @@ func (x *ApplyRTCEventResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ApplyRTCEventResponse.ProtoReflect.Descriptor instead. func (*ApplyRTCEventResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{29} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{34} } func (x *ApplyRTCEventResponse) GetResult() *CommandResult { @@ -2163,7 +2701,7 @@ type SetMicSeatLockRequest struct { func (x *SetMicSeatLockRequest) Reset() { *x = SetMicSeatLockRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[30] + mi := &file_proto_room_v1_room_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2175,7 +2713,7 @@ func (x *SetMicSeatLockRequest) String() string { func (*SetMicSeatLockRequest) ProtoMessage() {} func (x *SetMicSeatLockRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[30] + mi := &file_proto_room_v1_room_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2188,7 +2726,7 @@ func (x *SetMicSeatLockRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetMicSeatLockRequest.ProtoReflect.Descriptor instead. func (*SetMicSeatLockRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{30} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{35} } func (x *SetMicSeatLockRequest) GetMeta() *RequestMeta { @@ -2223,7 +2761,7 @@ type SetMicSeatLockResponse struct { func (x *SetMicSeatLockResponse) Reset() { *x = SetMicSeatLockResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[31] + mi := &file_proto_room_v1_room_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2235,7 +2773,7 @@ func (x *SetMicSeatLockResponse) String() string { func (*SetMicSeatLockResponse) ProtoMessage() {} func (x *SetMicSeatLockResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[31] + mi := &file_proto_room_v1_room_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2248,7 +2786,7 @@ func (x *SetMicSeatLockResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetMicSeatLockResponse.ProtoReflect.Descriptor instead. func (*SetMicSeatLockResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{31} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{36} } func (x *SetMicSeatLockResponse) GetResult() *CommandResult { @@ -2276,7 +2814,7 @@ type SetChatEnabledRequest struct { func (x *SetChatEnabledRequest) Reset() { *x = SetChatEnabledRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[32] + mi := &file_proto_room_v1_room_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2288,7 +2826,7 @@ func (x *SetChatEnabledRequest) String() string { func (*SetChatEnabledRequest) ProtoMessage() {} func (x *SetChatEnabledRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[32] + mi := &file_proto_room_v1_room_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2301,7 +2839,7 @@ func (x *SetChatEnabledRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetChatEnabledRequest.ProtoReflect.Descriptor instead. func (*SetChatEnabledRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{32} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{37} } func (x *SetChatEnabledRequest) GetMeta() *RequestMeta { @@ -2329,7 +2867,7 @@ type SetChatEnabledResponse struct { func (x *SetChatEnabledResponse) Reset() { *x = SetChatEnabledResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[33] + mi := &file_proto_room_v1_room_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2341,7 +2879,7 @@ func (x *SetChatEnabledResponse) String() string { func (*SetChatEnabledResponse) ProtoMessage() {} func (x *SetChatEnabledResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[33] + mi := &file_proto_room_v1_room_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2354,7 +2892,7 @@ func (x *SetChatEnabledResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetChatEnabledResponse.ProtoReflect.Descriptor instead. func (*SetChatEnabledResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{33} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{38} } func (x *SetChatEnabledResponse) GetResult() *CommandResult { @@ -2384,7 +2922,7 @@ type SetRoomPasswordRequest struct { func (x *SetRoomPasswordRequest) Reset() { *x = SetRoomPasswordRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[34] + mi := &file_proto_room_v1_room_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2396,7 +2934,7 @@ func (x *SetRoomPasswordRequest) String() string { func (*SetRoomPasswordRequest) ProtoMessage() {} func (x *SetRoomPasswordRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[34] + mi := &file_proto_room_v1_room_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2409,7 +2947,7 @@ func (x *SetRoomPasswordRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetRoomPasswordRequest.ProtoReflect.Descriptor instead. func (*SetRoomPasswordRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{34} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{39} } func (x *SetRoomPasswordRequest) GetMeta() *RequestMeta { @@ -2444,7 +2982,7 @@ type SetRoomPasswordResponse struct { func (x *SetRoomPasswordResponse) Reset() { *x = SetRoomPasswordResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[35] + mi := &file_proto_room_v1_room_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2456,7 +2994,7 @@ func (x *SetRoomPasswordResponse) String() string { func (*SetRoomPasswordResponse) ProtoMessage() {} func (x *SetRoomPasswordResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[35] + mi := &file_proto_room_v1_room_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2469,7 +3007,7 @@ func (x *SetRoomPasswordResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetRoomPasswordResponse.ProtoReflect.Descriptor instead. func (*SetRoomPasswordResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{35} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{40} } func (x *SetRoomPasswordResponse) GetResult() *CommandResult { @@ -2498,7 +3036,7 @@ type SetRoomAdminRequest struct { func (x *SetRoomAdminRequest) Reset() { *x = SetRoomAdminRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[36] + mi := &file_proto_room_v1_room_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2510,7 +3048,7 @@ func (x *SetRoomAdminRequest) String() string { func (*SetRoomAdminRequest) ProtoMessage() {} func (x *SetRoomAdminRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[36] + mi := &file_proto_room_v1_room_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2523,7 +3061,7 @@ func (x *SetRoomAdminRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetRoomAdminRequest.ProtoReflect.Descriptor instead. func (*SetRoomAdminRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{36} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{41} } func (x *SetRoomAdminRequest) GetMeta() *RequestMeta { @@ -2558,7 +3096,7 @@ type SetRoomAdminResponse struct { func (x *SetRoomAdminResponse) Reset() { *x = SetRoomAdminResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[37] + mi := &file_proto_room_v1_room_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2570,7 +3108,7 @@ func (x *SetRoomAdminResponse) String() string { func (*SetRoomAdminResponse) ProtoMessage() {} func (x *SetRoomAdminResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[37] + mi := &file_proto_room_v1_room_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2583,7 +3121,7 @@ func (x *SetRoomAdminResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetRoomAdminResponse.ProtoReflect.Descriptor instead. func (*SetRoomAdminResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{37} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{42} } func (x *SetRoomAdminResponse) GetResult() *CommandResult { @@ -2612,7 +3150,7 @@ type MuteUserRequest struct { func (x *MuteUserRequest) Reset() { *x = MuteUserRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[38] + mi := &file_proto_room_v1_room_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2624,7 +3162,7 @@ func (x *MuteUserRequest) String() string { func (*MuteUserRequest) ProtoMessage() {} func (x *MuteUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[38] + mi := &file_proto_room_v1_room_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2637,7 +3175,7 @@ func (x *MuteUserRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use MuteUserRequest.ProtoReflect.Descriptor instead. func (*MuteUserRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{38} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{43} } func (x *MuteUserRequest) GetMeta() *RequestMeta { @@ -2672,7 +3210,7 @@ type MuteUserResponse struct { func (x *MuteUserResponse) Reset() { *x = MuteUserResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[39] + mi := &file_proto_room_v1_room_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2684,7 +3222,7 @@ func (x *MuteUserResponse) String() string { func (*MuteUserResponse) ProtoMessage() {} func (x *MuteUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[39] + mi := &file_proto_room_v1_room_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2697,7 +3235,7 @@ func (x *MuteUserResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use MuteUserResponse.ProtoReflect.Descriptor instead. func (*MuteUserResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{39} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{44} } func (x *MuteUserResponse) GetResult() *CommandResult { @@ -2725,7 +3263,7 @@ type KickUserRequest struct { func (x *KickUserRequest) Reset() { *x = KickUserRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[40] + mi := &file_proto_room_v1_room_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2737,7 +3275,7 @@ func (x *KickUserRequest) String() string { func (*KickUserRequest) ProtoMessage() {} func (x *KickUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[40] + mi := &file_proto_room_v1_room_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2750,7 +3288,7 @@ func (x *KickUserRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use KickUserRequest.ProtoReflect.Descriptor instead. func (*KickUserRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{40} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{45} } func (x *KickUserRequest) GetMeta() *RequestMeta { @@ -2782,7 +3320,7 @@ type KickUserResponse struct { func (x *KickUserResponse) Reset() { *x = KickUserResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[41] + mi := &file_proto_room_v1_room_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2794,7 +3332,7 @@ func (x *KickUserResponse) String() string { func (*KickUserResponse) ProtoMessage() {} func (x *KickUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[41] + mi := &file_proto_room_v1_room_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2807,7 +3345,7 @@ func (x *KickUserResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use KickUserResponse.ProtoReflect.Descriptor instead. func (*KickUserResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{41} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{46} } func (x *KickUserResponse) GetResult() *CommandResult { @@ -2849,7 +3387,7 @@ type UnbanUserRequest struct { func (x *UnbanUserRequest) Reset() { *x = UnbanUserRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[42] + mi := &file_proto_room_v1_room_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2861,7 +3399,7 @@ func (x *UnbanUserRequest) String() string { func (*UnbanUserRequest) ProtoMessage() {} func (x *UnbanUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[42] + mi := &file_proto_room_v1_room_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2874,7 +3412,7 @@ func (x *UnbanUserRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnbanUserRequest.ProtoReflect.Descriptor instead. func (*UnbanUserRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{42} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{47} } func (x *UnbanUserRequest) GetMeta() *RequestMeta { @@ -2902,7 +3440,7 @@ type UnbanUserResponse struct { func (x *UnbanUserResponse) Reset() { *x = UnbanUserResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[43] + mi := &file_proto_room_v1_room_proto_msgTypes[48] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2914,7 +3452,7 @@ func (x *UnbanUserResponse) String() string { func (*UnbanUserResponse) ProtoMessage() {} func (x *UnbanUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[43] + mi := &file_proto_room_v1_room_proto_msgTypes[48] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2927,7 +3465,7 @@ func (x *UnbanUserResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnbanUserResponse.ProtoReflect.Descriptor instead. func (*UnbanUserResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{43} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{48} } func (x *UnbanUserResponse) GetResult() *CommandResult { @@ -2960,7 +3498,7 @@ type SystemEvictUserRequest struct { func (x *SystemEvictUserRequest) Reset() { *x = SystemEvictUserRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[44] + mi := &file_proto_room_v1_room_proto_msgTypes[49] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2972,7 +3510,7 @@ func (x *SystemEvictUserRequest) String() string { func (*SystemEvictUserRequest) ProtoMessage() {} func (x *SystemEvictUserRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[44] + mi := &file_proto_room_v1_room_proto_msgTypes[49] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2985,7 +3523,7 @@ func (x *SystemEvictUserRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemEvictUserRequest.ProtoReflect.Descriptor instead. func (*SystemEvictUserRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{44} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{49} } func (x *SystemEvictUserRequest) GetMeta() *RequestMeta { @@ -3038,7 +3576,7 @@ type SystemEvictUserResponse struct { func (x *SystemEvictUserResponse) Reset() { *x = SystemEvictUserResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[45] + mi := &file_proto_room_v1_room_proto_msgTypes[50] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3050,7 +3588,7 @@ func (x *SystemEvictUserResponse) String() string { func (*SystemEvictUserResponse) ProtoMessage() {} func (x *SystemEvictUserResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[45] + mi := &file_proto_room_v1_room_proto_msgTypes[50] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3063,7 +3601,7 @@ func (x *SystemEvictUserResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SystemEvictUserResponse.ProtoReflect.Descriptor instead. func (*SystemEvictUserResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{45} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{50} } func (x *SystemEvictUserResponse) GetHadCurrentRoom() bool { @@ -3125,7 +3663,7 @@ type SendGiftRequest struct { func (x *SendGiftRequest) Reset() { *x = SendGiftRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[46] + mi := &file_proto_room_v1_room_proto_msgTypes[51] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3137,7 +3675,7 @@ func (x *SendGiftRequest) String() string { func (*SendGiftRequest) ProtoMessage() {} func (x *SendGiftRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[46] + mi := &file_proto_room_v1_room_proto_msgTypes[51] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3150,7 +3688,7 @@ func (x *SendGiftRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SendGiftRequest.ProtoReflect.Descriptor instead. func (*SendGiftRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{46} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{51} } func (x *SendGiftRequest) GetMeta() *RequestMeta { @@ -3203,13 +3741,14 @@ type SendGiftResponse struct { RoomHeat int64 `protobuf:"varint,3,opt,name=room_heat,json=roomHeat,proto3" json:"room_heat,omitempty"` GiftRank []*RankItem `protobuf:"bytes,4,rep,name=gift_rank,json=giftRank,proto3" json:"gift_rank,omitempty"` Room *RoomSnapshot `protobuf:"bytes,5,opt,name=room,proto3" json:"room,omitempty"` + Treasure *RoomTreasureState `protobuf:"bytes,6,opt,name=treasure,proto3" json:"treasure,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *SendGiftResponse) Reset() { *x = SendGiftResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[47] + mi := &file_proto_room_v1_room_proto_msgTypes[52] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3221,7 +3760,7 @@ func (x *SendGiftResponse) String() string { func (*SendGiftResponse) ProtoMessage() {} func (x *SendGiftResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[47] + mi := &file_proto_room_v1_room_proto_msgTypes[52] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3234,7 +3773,7 @@ func (x *SendGiftResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SendGiftResponse.ProtoReflect.Descriptor instead. func (*SendGiftResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{47} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{52} } func (x *SendGiftResponse) GetResult() *CommandResult { @@ -3272,6 +3811,13 @@ func (x *SendGiftResponse) GetRoom() *RoomSnapshot { return nil } +func (x *SendGiftResponse) GetTreasure() *RoomTreasureState { + if x != nil { + return x.Treasure + } + return nil +} + // CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。 type CheckSpeakPermissionRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3284,7 +3830,7 @@ type CheckSpeakPermissionRequest struct { func (x *CheckSpeakPermissionRequest) Reset() { *x = CheckSpeakPermissionRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[48] + mi := &file_proto_room_v1_room_proto_msgTypes[53] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3296,7 +3842,7 @@ func (x *CheckSpeakPermissionRequest) String() string { func (*CheckSpeakPermissionRequest) ProtoMessage() {} func (x *CheckSpeakPermissionRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[48] + mi := &file_proto_room_v1_room_proto_msgTypes[53] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3309,7 +3855,7 @@ func (x *CheckSpeakPermissionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckSpeakPermissionRequest.ProtoReflect.Descriptor instead. func (*CheckSpeakPermissionRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{48} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{53} } func (x *CheckSpeakPermissionRequest) GetRoomId() string { @@ -3345,7 +3891,7 @@ type CheckSpeakPermissionResponse struct { func (x *CheckSpeakPermissionResponse) Reset() { *x = CheckSpeakPermissionResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[49] + mi := &file_proto_room_v1_room_proto_msgTypes[54] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3357,7 +3903,7 @@ func (x *CheckSpeakPermissionResponse) String() string { func (*CheckSpeakPermissionResponse) ProtoMessage() {} func (x *CheckSpeakPermissionResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[49] + mi := &file_proto_room_v1_room_proto_msgTypes[54] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3370,7 +3916,7 @@ func (x *CheckSpeakPermissionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use CheckSpeakPermissionResponse.ProtoReflect.Descriptor instead. func (*CheckSpeakPermissionResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{49} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{54} } func (x *CheckSpeakPermissionResponse) GetAllowed() bool { @@ -3408,7 +3954,7 @@ type VerifyRoomPresenceRequest struct { func (x *VerifyRoomPresenceRequest) Reset() { *x = VerifyRoomPresenceRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[50] + mi := &file_proto_room_v1_room_proto_msgTypes[55] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3420,7 +3966,7 @@ func (x *VerifyRoomPresenceRequest) String() string { func (*VerifyRoomPresenceRequest) ProtoMessage() {} func (x *VerifyRoomPresenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[50] + mi := &file_proto_room_v1_room_proto_msgTypes[55] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3433,7 +3979,7 @@ func (x *VerifyRoomPresenceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyRoomPresenceRequest.ProtoReflect.Descriptor instead. func (*VerifyRoomPresenceRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{50} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{55} } func (x *VerifyRoomPresenceRequest) GetRoomId() string { @@ -3483,7 +4029,7 @@ type VerifyRoomPresenceResponse struct { func (x *VerifyRoomPresenceResponse) Reset() { *x = VerifyRoomPresenceResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[51] + mi := &file_proto_room_v1_room_proto_msgTypes[56] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3495,7 +4041,7 @@ func (x *VerifyRoomPresenceResponse) String() string { func (*VerifyRoomPresenceResponse) ProtoMessage() {} func (x *VerifyRoomPresenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[51] + mi := &file_proto_room_v1_room_proto_msgTypes[56] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3508,7 +4054,7 @@ func (x *VerifyRoomPresenceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use VerifyRoomPresenceResponse.ProtoReflect.Descriptor instead. func (*VerifyRoomPresenceResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{51} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{56} } func (x *VerifyRoomPresenceResponse) GetPresent() bool { @@ -3549,7 +4095,7 @@ type ListRoomsRequest struct { func (x *ListRoomsRequest) Reset() { *x = ListRoomsRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[52] + mi := &file_proto_room_v1_room_proto_msgTypes[57] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3561,7 +4107,7 @@ func (x *ListRoomsRequest) String() string { func (*ListRoomsRequest) ProtoMessage() {} func (x *ListRoomsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[52] + mi := &file_proto_room_v1_room_proto_msgTypes[57] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3574,7 +4120,7 @@ func (x *ListRoomsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRoomsRequest.ProtoReflect.Descriptor instead. func (*ListRoomsRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{52} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{57} } func (x *ListRoomsRequest) GetMeta() *RequestMeta { @@ -3645,7 +4191,7 @@ type ListRoomFeedsRequest struct { func (x *ListRoomFeedsRequest) Reset() { *x = ListRoomFeedsRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[53] + mi := &file_proto_room_v1_room_proto_msgTypes[58] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3657,7 +4203,7 @@ func (x *ListRoomFeedsRequest) String() string { func (*ListRoomFeedsRequest) ProtoMessage() {} func (x *ListRoomFeedsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[53] + mi := &file_proto_room_v1_room_proto_msgTypes[58] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3670,7 +4216,7 @@ func (x *ListRoomFeedsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRoomFeedsRequest.ProtoReflect.Descriptor instead. func (*ListRoomFeedsRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{53} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{58} } func (x *ListRoomFeedsRequest) GetMeta() *RequestMeta { @@ -3740,7 +4286,7 @@ type RoomFeedRelatedUser struct { func (x *RoomFeedRelatedUser) Reset() { *x = RoomFeedRelatedUser{} - mi := &file_proto_room_v1_room_proto_msgTypes[54] + mi := &file_proto_room_v1_room_proto_msgTypes[59] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3752,7 +4298,7 @@ func (x *RoomFeedRelatedUser) String() string { func (*RoomFeedRelatedUser) ProtoMessage() {} func (x *RoomFeedRelatedUser) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[54] + mi := &file_proto_room_v1_room_proto_msgTypes[59] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3765,7 +4311,7 @@ func (x *RoomFeedRelatedUser) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomFeedRelatedUser.ProtoReflect.Descriptor instead. func (*RoomFeedRelatedUser) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{54} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{59} } func (x *RoomFeedRelatedUser) GetUserId() int64 { @@ -3805,7 +4351,7 @@ type RoomListItem struct { func (x *RoomListItem) Reset() { *x = RoomListItem{} - mi := &file_proto_room_v1_room_proto_msgTypes[55] + mi := &file_proto_room_v1_room_proto_msgTypes[60] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3817,7 +4363,7 @@ func (x *RoomListItem) String() string { func (*RoomListItem) ProtoMessage() {} func (x *RoomListItem) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[55] + mi := &file_proto_room_v1_room_proto_msgTypes[60] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3830,7 +4376,7 @@ func (x *RoomListItem) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomListItem.ProtoReflect.Descriptor instead. func (*RoomListItem) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{55} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{60} } func (x *RoomListItem) GetRoomId() string { @@ -3942,7 +4488,7 @@ type ListRoomsResponse struct { func (x *ListRoomsResponse) Reset() { *x = ListRoomsResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[56] + mi := &file_proto_room_v1_room_proto_msgTypes[61] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3954,7 +4500,7 @@ func (x *ListRoomsResponse) String() string { func (*ListRoomsResponse) ProtoMessage() {} func (x *ListRoomsResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[56] + mi := &file_proto_room_v1_room_proto_msgTypes[61] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3967,7 +4513,7 @@ func (x *ListRoomsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRoomsResponse.ProtoReflect.Descriptor instead. func (*ListRoomsResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{56} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{61} } func (x *ListRoomsResponse) GetRooms() []*RoomListItem { @@ -3996,7 +4542,7 @@ type GetMyRoomRequest struct { func (x *GetMyRoomRequest) Reset() { *x = GetMyRoomRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[57] + mi := &file_proto_room_v1_room_proto_msgTypes[62] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4008,7 +4554,7 @@ func (x *GetMyRoomRequest) String() string { func (*GetMyRoomRequest) ProtoMessage() {} func (x *GetMyRoomRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[57] + mi := &file_proto_room_v1_room_proto_msgTypes[62] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4021,7 +4567,7 @@ func (x *GetMyRoomRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetMyRoomRequest.ProtoReflect.Descriptor instead. func (*GetMyRoomRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{57} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{62} } func (x *GetMyRoomRequest) GetMeta() *RequestMeta { @@ -4051,7 +4597,7 @@ type GetMyRoomResponse struct { func (x *GetMyRoomResponse) Reset() { *x = GetMyRoomResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[58] + mi := &file_proto_room_v1_room_proto_msgTypes[63] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4063,7 +4609,7 @@ func (x *GetMyRoomResponse) String() string { func (*GetMyRoomResponse) ProtoMessage() {} func (x *GetMyRoomResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[58] + mi := &file_proto_room_v1_room_proto_msgTypes[63] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4076,7 +4622,7 @@ func (x *GetMyRoomResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetMyRoomResponse.ProtoReflect.Descriptor instead. func (*GetMyRoomResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{58} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{63} } func (x *GetMyRoomResponse) GetHasRoom() bool { @@ -4112,7 +4658,7 @@ type GetCurrentRoomRequest struct { func (x *GetCurrentRoomRequest) Reset() { *x = GetCurrentRoomRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[59] + mi := &file_proto_room_v1_room_proto_msgTypes[64] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4124,7 +4670,7 @@ func (x *GetCurrentRoomRequest) String() string { func (*GetCurrentRoomRequest) ProtoMessage() {} func (x *GetCurrentRoomRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[59] + mi := &file_proto_room_v1_room_proto_msgTypes[64] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4137,7 +4683,7 @@ func (x *GetCurrentRoomRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetCurrentRoomRequest.ProtoReflect.Descriptor instead. func (*GetCurrentRoomRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{59} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{64} } func (x *GetCurrentRoomRequest) GetMeta() *RequestMeta { @@ -4173,7 +4719,7 @@ type GetCurrentRoomResponse struct { func (x *GetCurrentRoomResponse) Reset() { *x = GetCurrentRoomResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[60] + mi := &file_proto_room_v1_room_proto_msgTypes[65] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4185,7 +4731,7 @@ func (x *GetCurrentRoomResponse) String() string { func (*GetCurrentRoomResponse) ProtoMessage() {} func (x *GetCurrentRoomResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[60] + mi := &file_proto_room_v1_room_proto_msgTypes[65] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4198,7 +4744,7 @@ func (x *GetCurrentRoomResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetCurrentRoomResponse.ProtoReflect.Descriptor instead. func (*GetCurrentRoomResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{60} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{65} } func (x *GetCurrentRoomResponse) GetHasCurrentRoom() bool { @@ -4277,7 +4823,7 @@ type GetRoomSnapshotRequest struct { func (x *GetRoomSnapshotRequest) Reset() { *x = GetRoomSnapshotRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[61] + mi := &file_proto_room_v1_room_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4289,7 +4835,7 @@ func (x *GetRoomSnapshotRequest) String() string { func (*GetRoomSnapshotRequest) ProtoMessage() {} func (x *GetRoomSnapshotRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[61] + mi := &file_proto_room_v1_room_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4302,7 +4848,7 @@ func (x *GetRoomSnapshotRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetRoomSnapshotRequest.ProtoReflect.Descriptor instead. func (*GetRoomSnapshotRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{61} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{66} } func (x *GetRoomSnapshotRequest) GetMeta() *RequestMeta { @@ -4339,7 +4885,7 @@ type GetRoomSnapshotResponse struct { func (x *GetRoomSnapshotResponse) Reset() { *x = GetRoomSnapshotResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[62] + mi := &file_proto_room_v1_room_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4351,7 +4897,7 @@ func (x *GetRoomSnapshotResponse) String() string { func (*GetRoomSnapshotResponse) ProtoMessage() {} func (x *GetRoomSnapshotResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[62] + mi := &file_proto_room_v1_room_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4364,7 +4910,7 @@ func (x *GetRoomSnapshotResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetRoomSnapshotResponse.ProtoReflect.Descriptor instead. func (*GetRoomSnapshotResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{62} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{67} } func (x *GetRoomSnapshotResponse) GetRoom() *RoomSnapshot { @@ -4388,6 +4934,120 @@ func (x *GetRoomSnapshotResponse) GetIsFollowed() bool { return false } +// GetRoomTreasureRequest 主动读取当前房间宝箱配置物料和进度状态。 +// 该查询必须由 gateway 写入鉴权后的 viewer_user_id,客户端不能提交 viewer_user_id。 +type GetRoomTreasureRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + ViewerUserId int64 `protobuf:"varint,3,opt,name=viewer_user_id,json=viewerUserId,proto3" json:"viewer_user_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetRoomTreasureRequest) Reset() { + *x = GetRoomTreasureRequest{} + mi := &file_proto_room_v1_room_proto_msgTypes[68] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetRoomTreasureRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetRoomTreasureRequest) ProtoMessage() {} + +func (x *GetRoomTreasureRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_room_v1_room_proto_msgTypes[68] + 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 GetRoomTreasureRequest.ProtoReflect.Descriptor instead. +func (*GetRoomTreasureRequest) Descriptor() ([]byte, []int) { + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{68} +} + +func (x *GetRoomTreasureRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *GetRoomTreasureRequest) GetRoomId() string { + if x != nil { + return x.RoomId + } + return "" +} + +func (x *GetRoomTreasureRequest) GetViewerUserId() int64 { + if x != nil { + return x.ViewerUserId + } + return 0 +} + +type GetRoomTreasureResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Treasure *RoomTreasureInfo `protobuf:"bytes,1,opt,name=treasure,proto3" json:"treasure,omitempty"` + ServerTimeMs int64 `protobuf:"varint,2,opt,name=server_time_ms,json=serverTimeMs,proto3" json:"server_time_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetRoomTreasureResponse) Reset() { + *x = GetRoomTreasureResponse{} + mi := &file_proto_room_v1_room_proto_msgTypes[69] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetRoomTreasureResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetRoomTreasureResponse) ProtoMessage() {} + +func (x *GetRoomTreasureResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_room_v1_room_proto_msgTypes[69] + 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 GetRoomTreasureResponse.ProtoReflect.Descriptor instead. +func (*GetRoomTreasureResponse) Descriptor() ([]byte, []int) { + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{69} +} + +func (x *GetRoomTreasureResponse) GetTreasure() *RoomTreasureInfo { + if x != nil { + return x.Treasure + } + return nil +} + +func (x *GetRoomTreasureResponse) GetServerTimeMs() int64 { + if x != nil { + return x.ServerTimeMs + } + return 0 +} + // ListRoomOnlineUsersRequest 分页查询房间在线用户,不让客户端拉完整 RoomSnapshot 做列表分页。 type ListRoomOnlineUsersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4402,7 +5062,7 @@ type ListRoomOnlineUsersRequest struct { func (x *ListRoomOnlineUsersRequest) Reset() { *x = ListRoomOnlineUsersRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[63] + mi := &file_proto_room_v1_room_proto_msgTypes[70] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4414,7 +5074,7 @@ func (x *ListRoomOnlineUsersRequest) String() string { func (*ListRoomOnlineUsersRequest) ProtoMessage() {} func (x *ListRoomOnlineUsersRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[63] + mi := &file_proto_room_v1_room_proto_msgTypes[70] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4427,7 +5087,7 @@ func (x *ListRoomOnlineUsersRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRoomOnlineUsersRequest.ProtoReflect.Descriptor instead. func (*ListRoomOnlineUsersRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{63} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{70} } func (x *ListRoomOnlineUsersRequest) GetMeta() *RequestMeta { @@ -4478,7 +5138,7 @@ type ListRoomOnlineUsersResponse struct { func (x *ListRoomOnlineUsersResponse) Reset() { *x = ListRoomOnlineUsersResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[64] + mi := &file_proto_room_v1_room_proto_msgTypes[71] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4490,7 +5150,7 @@ func (x *ListRoomOnlineUsersResponse) String() string { func (*ListRoomOnlineUsersResponse) ProtoMessage() {} func (x *ListRoomOnlineUsersResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[64] + mi := &file_proto_room_v1_room_proto_msgTypes[71] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4503,7 +5163,7 @@ func (x *ListRoomOnlineUsersResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use ListRoomOnlineUsersResponse.ProtoReflect.Descriptor instead. func (*ListRoomOnlineUsersResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{64} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{71} } func (x *ListRoomOnlineUsersResponse) GetUsers() []*RoomUser { @@ -4554,7 +5214,7 @@ type FollowRoomRequest struct { func (x *FollowRoomRequest) Reset() { *x = FollowRoomRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[65] + mi := &file_proto_room_v1_room_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4566,7 +5226,7 @@ func (x *FollowRoomRequest) String() string { func (*FollowRoomRequest) ProtoMessage() {} func (x *FollowRoomRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[65] + mi := &file_proto_room_v1_room_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4579,7 +5239,7 @@ func (x *FollowRoomRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use FollowRoomRequest.ProtoReflect.Descriptor instead. func (*FollowRoomRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{65} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{72} } func (x *FollowRoomRequest) GetMeta() *RequestMeta { @@ -4615,7 +5275,7 @@ type FollowRoomResponse struct { func (x *FollowRoomResponse) Reset() { *x = FollowRoomResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[66] + mi := &file_proto_room_v1_room_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4627,7 +5287,7 @@ func (x *FollowRoomResponse) String() string { func (*FollowRoomResponse) ProtoMessage() {} func (x *FollowRoomResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[66] + mi := &file_proto_room_v1_room_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4640,7 +5300,7 @@ func (x *FollowRoomResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use FollowRoomResponse.ProtoReflect.Descriptor instead. func (*FollowRoomResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{66} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{73} } func (x *FollowRoomResponse) GetRoomId() string { @@ -4683,7 +5343,7 @@ type UnfollowRoomRequest struct { func (x *UnfollowRoomRequest) Reset() { *x = UnfollowRoomRequest{} - mi := &file_proto_room_v1_room_proto_msgTypes[67] + mi := &file_proto_room_v1_room_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4695,7 +5355,7 @@ func (x *UnfollowRoomRequest) String() string { func (*UnfollowRoomRequest) ProtoMessage() {} func (x *UnfollowRoomRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[67] + mi := &file_proto_room_v1_room_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4708,7 +5368,7 @@ func (x *UnfollowRoomRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use UnfollowRoomRequest.ProtoReflect.Descriptor instead. func (*UnfollowRoomRequest) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{67} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{74} } func (x *UnfollowRoomRequest) GetMeta() *RequestMeta { @@ -4743,7 +5403,7 @@ type UnfollowRoomResponse struct { func (x *UnfollowRoomResponse) Reset() { *x = UnfollowRoomResponse{} - mi := &file_proto_room_v1_room_proto_msgTypes[68] + mi := &file_proto_room_v1_room_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4755,7 +5415,7 @@ func (x *UnfollowRoomResponse) String() string { func (*UnfollowRoomResponse) ProtoMessage() {} func (x *UnfollowRoomResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_room_v1_room_proto_msgTypes[68] + mi := &file_proto_room_v1_room_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4768,7 +5428,7 @@ func (x *UnfollowRoomResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use UnfollowRoomResponse.ProtoReflect.Descriptor instead. func (*UnfollowRoomResponse) Descriptor() ([]byte, []int) { - return file_proto_room_v1_room_proto_rawDescGZIP(), []int{68} + return file_proto_room_v1_room_proto_rawDescGZIP(), []int{75} } func (x *UnfollowRoomResponse) GetRoomId() string { @@ -4838,7 +5498,60 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x05score\x18\x02 \x01(\x03R\x05score\x12\x1d\n" + "\n" + "gift_value\x18\x03 \x01(\x03R\tgiftValue\x12\"\n" + - "\rupdated_at_ms\x18\x04 \x01(\x03R\vupdatedAtMs\"\xe1\x05\n" + + "\rupdated_at_ms\x18\x04 \x01(\x03R\vupdatedAtMs\"\xc0\x01\n" + + "\x16RoomTreasureRewardItem\x12$\n" + + "\x0ereward_item_id\x18\x01 \x01(\tR\frewardItemId\x12*\n" + + "\x11resource_group_id\x18\x02 \x01(\x03R\x0fresourceGroupId\x12\x16\n" + + "\x06weight\x18\x03 \x01(\x03R\x06weight\x12!\n" + + "\fdisplay_name\x18\x04 \x01(\tR\vdisplayName\x12\x19\n" + + "\bicon_url\x18\x05 \x01(\tR\aiconUrl\"\xdd\x03\n" + + "\x11RoomTreasureLevel\x12\x14\n" + + "\x05level\x18\x01 \x01(\x05R\x05level\x12)\n" + + "\x10energy_threshold\x18\x02 \x01(\x03R\x0fenergyThreshold\x12\x1b\n" + + "\tcover_url\x18\x03 \x01(\tR\bcoverUrl\x12#\n" + + "\ranimation_url\x18\x04 \x01(\tR\fanimationUrl\x122\n" + + "\x15opening_animation_url\x18\x05 \x01(\tR\x13openingAnimationUrl\x12(\n" + + "\x10opened_image_url\x18\x06 \x01(\tR\x0eopenedImageUrl\x12M\n" + + "\x0fin_room_rewards\x18\a \x03(\v2%.hyapp.room.v1.RoomTreasureRewardItemR\rinRoomRewards\x12H\n" + + "\ftop1_rewards\x18\b \x03(\v2%.hyapp.room.v1.RoomTreasureRewardItemR\vtop1Rewards\x12N\n" + + "\x0figniter_rewards\x18\t \x03(\v2%.hyapp.room.v1.RoomTreasureRewardItemR\x0eigniterRewards\"\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\"\x90\x04\n" + + "\x11RoomTreasureState\x12#\n" + + "\rcurrent_level\x18\x01 \x01(\x05R\fcurrentLevel\x12)\n" + + "\x10current_progress\x18\x02 \x01(\x03R\x0fcurrentProgress\x12)\n" + + "\x10energy_threshold\x18\x03 \x01(\x03R\x0fenergyThreshold\x12\x16\n" + + "\x06status\x18\x04 \x01(\tR\x06status\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" + + "\fopened_at_ms\x18\a \x01(\x03R\n" + + "openedAtMs\x12\x1e\n" + + "\vreset_at_ms\x18\b \x01(\x03R\tresetAtMs\x12 \n" + + "\ftop1_user_id\x18\t \x01(\x03R\n" + + "top1UserId\x12&\n" + + "\x0figniter_user_id\x18\n" + + " \x01(\x03R\rigniterUserId\x12\x15\n" + + "\x06box_id\x18\v \x01(\tR\x05boxId\x12%\n" + + "\x0econfig_version\x18\f \x01(\x03R\rconfigVersion\x12I\n" + + "\flast_rewards\x18\r \x03(\v2&.hyapp.room.v1.RoomTreasureRewardGrantR\vlastRewards\"\xef\x02\n" + + "\x10RoomTreasureInfo\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x128\n" + + "\x06levels\x18\x02 \x03(\v2 .hyapp.room.v1.RoomTreasureLevelR\x06levels\x126\n" + + "\x05state\x18\x03 \x01(\v2 .hyapp.room.v1.RoomTreasureStateR\x05state\x12$\n" + + "\x0eserver_time_ms\x18\x04 \x01(\x03R\fserverTimeMs\x12'\n" + + "\x0fbroadcast_scope\x18\x05 \x01(\tR\x0ebroadcastScope\x12\"\n" + + "\ropen_delay_ms\x18\x06 \x01(\x03R\vopenDelayMs\x12,\n" + + "\x12broadcast_delay_ms\x18\a \x01(\x03R\x10broadcastDelayMs\x12.\n" + + "\x13reward_stack_policy\x18\b \x01(\tR\x11rewardStackPolicy\"\x9f\x06\n" + "\fRoomSnapshot\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" + "\rowner_user_id\x18\x02 \x01(\x03R\vownerUserId\x12\x12\n" + @@ -4859,7 +5572,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\bapp_code\x18\x10 \x01(\tR\aappCode\x12\"\n" + "\rroom_short_id\x18\x11 \x01(\tR\vroomShortId\x12*\n" + "\x11visible_region_id\x18\x12 \x01(\x03R\x0fvisibleRegionId\x12\x16\n" + - "\x06locked\x18\x13 \x01(\bR\x06locked\x1a:\n" + + "\x06locked\x18\x13 \x01(\bR\x06locked\x12<\n" + + "\btreasure\x18\x14 \x01(\v2 .hyapp.room.v1.RoomTreasureStateR\btreasure\x1a:\n" + "\fRoomExtEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xaf\x02\n" + @@ -5045,13 +5759,14 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "gift_count\x18\x04 \x01(\x05R\tgiftCount\x12\x1f\n" + "\vtarget_type\x18\x05 \x01(\tR\n" + "targetType\x12&\n" + - "\x0ftarget_user_ids\x18\x06 \x03(\x03R\rtargetUserIds\"\xfa\x01\n" + + "\x0ftarget_user_ids\x18\x06 \x03(\x03R\rtargetUserIds\"\xb8\x02\n" + "\x10SendGiftResponse\x124\n" + "\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" + "\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" + "\troom_heat\x18\x03 \x01(\x03R\broomHeat\x124\n" + "\tgift_rank\x18\x04 \x03(\v2\x17.hyapp.room.v1.RankItemR\bgiftRank\x12/\n" + - "\x04room\x18\x05 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\"j\n" + + "\x04room\x18\x05 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\x12<\n" + + "\btreasure\x18\x06 \x01(\v2 .hyapp.room.v1.RoomTreasureStateR\btreasure\"j\n" + "\x1bCheckSpeakPermissionRequest\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x19\n" + @@ -5141,7 +5856,14 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x04room\x18\x01 \x01(\v2\x1b.hyapp.room.v1.RoomSnapshotR\x04room\x12$\n" + "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\x12\x1f\n" + "\vis_followed\x18\x03 \x01(\bR\n" + - "isFollowed\"\xbc\x01\n" + + "isFollowed\"\x87\x01\n" + + "\x16GetRoomTreasureRequest\x12.\n" + + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12\x17\n" + + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12$\n" + + "\x0eviewer_user_id\x18\x03 \x01(\x03R\fviewerUserId\"|\n" + + "\x17GetRoomTreasureResponse\x12;\n" + + "\btreasure\x18\x01 \x01(\v2\x1f.hyapp.room.v1.RoomTreasureInfoR\btreasure\x12$\n" + + "\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xbc\x01\n" + "\x1aListRoomOnlineUsersRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12\x17\n" + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12$\n" + @@ -5200,13 +5922,14 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\fUnfollowRoom\x12\".hyapp.room.v1.UnfollowRoomRequest\x1a#.hyapp.room.v1.UnfollowRoomResponse2\xee\x01\n" + "\x10RoomGuardService\x12o\n" + "\x14CheckSpeakPermission\x12*.hyapp.room.v1.CheckSpeakPermissionRequest\x1a+.hyapp.room.v1.CheckSpeakPermissionResponse\x12i\n" + - "\x12VerifyRoomPresence\x12(.hyapp.room.v1.VerifyRoomPresenceRequest\x1a).hyapp.room.v1.VerifyRoomPresenceResponse2\xb9\x04\n" + + "\x12VerifyRoomPresence\x12(.hyapp.room.v1.VerifyRoomPresenceRequest\x1a).hyapp.room.v1.VerifyRoomPresenceResponse2\x9b\x05\n" + "\x10RoomQueryService\x12N\n" + "\tListRooms\x12\x1f.hyapp.room.v1.ListRoomsRequest\x1a .hyapp.room.v1.ListRoomsResponse\x12V\n" + "\rListRoomFeeds\x12#.hyapp.room.v1.ListRoomFeedsRequest\x1a .hyapp.room.v1.ListRoomsResponse\x12N\n" + "\tGetMyRoom\x12\x1f.hyapp.room.v1.GetMyRoomRequest\x1a .hyapp.room.v1.GetMyRoomResponse\x12]\n" + "\x0eGetCurrentRoom\x12$.hyapp.room.v1.GetCurrentRoomRequest\x1a%.hyapp.room.v1.GetCurrentRoomResponse\x12`\n" + - "\x0fGetRoomSnapshot\x12%.hyapp.room.v1.GetRoomSnapshotRequest\x1a&.hyapp.room.v1.GetRoomSnapshotResponse\x12l\n" + + "\x0fGetRoomSnapshot\x12%.hyapp.room.v1.GetRoomSnapshotRequest\x1a&.hyapp.room.v1.GetRoomSnapshotResponse\x12`\n" + + "\x0fGetRoomTreasure\x12%.hyapp.room.v1.GetRoomTreasureRequest\x1a&.hyapp.room.v1.GetRoomTreasureResponse\x12l\n" + "\x13ListRoomOnlineUsers\x12).hyapp.room.v1.ListRoomOnlineUsersRequest\x1a*.hyapp.room.v1.ListRoomOnlineUsersResponseB&Z$hyapp.local/api/proto/room/v1;roomv1b\x06proto3" var ( @@ -5221,230 +5944,249 @@ func file_proto_room_v1_room_proto_rawDescGZIP() []byte { return file_proto_room_v1_room_proto_rawDescData } -var file_proto_room_v1_room_proto_msgTypes = make([]protoimpl.MessageInfo, 70) +var file_proto_room_v1_room_proto_msgTypes = make([]protoimpl.MessageInfo, 77) var file_proto_room_v1_room_proto_goTypes = []any{ (*RequestMeta)(nil), // 0: hyapp.room.v1.RequestMeta (*CommandResult)(nil), // 1: hyapp.room.v1.CommandResult (*RoomUser)(nil), // 2: hyapp.room.v1.RoomUser (*SeatState)(nil), // 3: hyapp.room.v1.SeatState (*RankItem)(nil), // 4: hyapp.room.v1.RankItem - (*RoomSnapshot)(nil), // 5: hyapp.room.v1.RoomSnapshot - (*CreateRoomRequest)(nil), // 6: hyapp.room.v1.CreateRoomRequest - (*CreateRoomResponse)(nil), // 7: hyapp.room.v1.CreateRoomResponse - (*UpdateRoomProfileRequest)(nil), // 8: hyapp.room.v1.UpdateRoomProfileRequest - (*UpdateRoomProfileResponse)(nil), // 9: hyapp.room.v1.UpdateRoomProfileResponse - (*JoinRoomRequest)(nil), // 10: hyapp.room.v1.JoinRoomRequest - (*JoinRoomResponse)(nil), // 11: hyapp.room.v1.JoinRoomResponse - (*RoomHeartbeatRequest)(nil), // 12: hyapp.room.v1.RoomHeartbeatRequest - (*RoomHeartbeatResponse)(nil), // 13: hyapp.room.v1.RoomHeartbeatResponse - (*LeaveRoomRequest)(nil), // 14: hyapp.room.v1.LeaveRoomRequest - (*LeaveRoomResponse)(nil), // 15: hyapp.room.v1.LeaveRoomResponse - (*CloseRoomRequest)(nil), // 16: hyapp.room.v1.CloseRoomRequest - (*CloseRoomResponse)(nil), // 17: hyapp.room.v1.CloseRoomResponse - (*MicUpRequest)(nil), // 18: hyapp.room.v1.MicUpRequest - (*MicUpResponse)(nil), // 19: hyapp.room.v1.MicUpResponse - (*MicDownRequest)(nil), // 20: hyapp.room.v1.MicDownRequest - (*MicDownResponse)(nil), // 21: hyapp.room.v1.MicDownResponse - (*ChangeMicSeatRequest)(nil), // 22: hyapp.room.v1.ChangeMicSeatRequest - (*ChangeMicSeatResponse)(nil), // 23: hyapp.room.v1.ChangeMicSeatResponse - (*ConfirmMicPublishingRequest)(nil), // 24: hyapp.room.v1.ConfirmMicPublishingRequest - (*ConfirmMicPublishingResponse)(nil), // 25: hyapp.room.v1.ConfirmMicPublishingResponse - (*SetMicMuteRequest)(nil), // 26: hyapp.room.v1.SetMicMuteRequest - (*SetMicMuteResponse)(nil), // 27: hyapp.room.v1.SetMicMuteResponse - (*ApplyRTCEventRequest)(nil), // 28: hyapp.room.v1.ApplyRTCEventRequest - (*ApplyRTCEventResponse)(nil), // 29: hyapp.room.v1.ApplyRTCEventResponse - (*SetMicSeatLockRequest)(nil), // 30: hyapp.room.v1.SetMicSeatLockRequest - (*SetMicSeatLockResponse)(nil), // 31: hyapp.room.v1.SetMicSeatLockResponse - (*SetChatEnabledRequest)(nil), // 32: hyapp.room.v1.SetChatEnabledRequest - (*SetChatEnabledResponse)(nil), // 33: hyapp.room.v1.SetChatEnabledResponse - (*SetRoomPasswordRequest)(nil), // 34: hyapp.room.v1.SetRoomPasswordRequest - (*SetRoomPasswordResponse)(nil), // 35: hyapp.room.v1.SetRoomPasswordResponse - (*SetRoomAdminRequest)(nil), // 36: hyapp.room.v1.SetRoomAdminRequest - (*SetRoomAdminResponse)(nil), // 37: hyapp.room.v1.SetRoomAdminResponse - (*MuteUserRequest)(nil), // 38: hyapp.room.v1.MuteUserRequest - (*MuteUserResponse)(nil), // 39: hyapp.room.v1.MuteUserResponse - (*KickUserRequest)(nil), // 40: hyapp.room.v1.KickUserRequest - (*KickUserResponse)(nil), // 41: hyapp.room.v1.KickUserResponse - (*UnbanUserRequest)(nil), // 42: hyapp.room.v1.UnbanUserRequest - (*UnbanUserResponse)(nil), // 43: hyapp.room.v1.UnbanUserResponse - (*SystemEvictUserRequest)(nil), // 44: hyapp.room.v1.SystemEvictUserRequest - (*SystemEvictUserResponse)(nil), // 45: hyapp.room.v1.SystemEvictUserResponse - (*SendGiftRequest)(nil), // 46: hyapp.room.v1.SendGiftRequest - (*SendGiftResponse)(nil), // 47: hyapp.room.v1.SendGiftResponse - (*CheckSpeakPermissionRequest)(nil), // 48: hyapp.room.v1.CheckSpeakPermissionRequest - (*CheckSpeakPermissionResponse)(nil), // 49: hyapp.room.v1.CheckSpeakPermissionResponse - (*VerifyRoomPresenceRequest)(nil), // 50: hyapp.room.v1.VerifyRoomPresenceRequest - (*VerifyRoomPresenceResponse)(nil), // 51: hyapp.room.v1.VerifyRoomPresenceResponse - (*ListRoomsRequest)(nil), // 52: hyapp.room.v1.ListRoomsRequest - (*ListRoomFeedsRequest)(nil), // 53: hyapp.room.v1.ListRoomFeedsRequest - (*RoomFeedRelatedUser)(nil), // 54: hyapp.room.v1.RoomFeedRelatedUser - (*RoomListItem)(nil), // 55: hyapp.room.v1.RoomListItem - (*ListRoomsResponse)(nil), // 56: hyapp.room.v1.ListRoomsResponse - (*GetMyRoomRequest)(nil), // 57: hyapp.room.v1.GetMyRoomRequest - (*GetMyRoomResponse)(nil), // 58: hyapp.room.v1.GetMyRoomResponse - (*GetCurrentRoomRequest)(nil), // 59: hyapp.room.v1.GetCurrentRoomRequest - (*GetCurrentRoomResponse)(nil), // 60: hyapp.room.v1.GetCurrentRoomResponse - (*GetRoomSnapshotRequest)(nil), // 61: hyapp.room.v1.GetRoomSnapshotRequest - (*GetRoomSnapshotResponse)(nil), // 62: hyapp.room.v1.GetRoomSnapshotResponse - (*ListRoomOnlineUsersRequest)(nil), // 63: hyapp.room.v1.ListRoomOnlineUsersRequest - (*ListRoomOnlineUsersResponse)(nil), // 64: hyapp.room.v1.ListRoomOnlineUsersResponse - (*FollowRoomRequest)(nil), // 65: hyapp.room.v1.FollowRoomRequest - (*FollowRoomResponse)(nil), // 66: hyapp.room.v1.FollowRoomResponse - (*UnfollowRoomRequest)(nil), // 67: hyapp.room.v1.UnfollowRoomRequest - (*UnfollowRoomResponse)(nil), // 68: hyapp.room.v1.UnfollowRoomResponse - nil, // 69: hyapp.room.v1.RoomSnapshot.RoomExtEntry + (*RoomTreasureRewardItem)(nil), // 5: hyapp.room.v1.RoomTreasureRewardItem + (*RoomTreasureLevel)(nil), // 6: hyapp.room.v1.RoomTreasureLevel + (*RoomTreasureRewardGrant)(nil), // 7: hyapp.room.v1.RoomTreasureRewardGrant + (*RoomTreasureState)(nil), // 8: hyapp.room.v1.RoomTreasureState + (*RoomTreasureInfo)(nil), // 9: hyapp.room.v1.RoomTreasureInfo + (*RoomSnapshot)(nil), // 10: hyapp.room.v1.RoomSnapshot + (*CreateRoomRequest)(nil), // 11: hyapp.room.v1.CreateRoomRequest + (*CreateRoomResponse)(nil), // 12: hyapp.room.v1.CreateRoomResponse + (*UpdateRoomProfileRequest)(nil), // 13: hyapp.room.v1.UpdateRoomProfileRequest + (*UpdateRoomProfileResponse)(nil), // 14: hyapp.room.v1.UpdateRoomProfileResponse + (*JoinRoomRequest)(nil), // 15: hyapp.room.v1.JoinRoomRequest + (*JoinRoomResponse)(nil), // 16: hyapp.room.v1.JoinRoomResponse + (*RoomHeartbeatRequest)(nil), // 17: hyapp.room.v1.RoomHeartbeatRequest + (*RoomHeartbeatResponse)(nil), // 18: hyapp.room.v1.RoomHeartbeatResponse + (*LeaveRoomRequest)(nil), // 19: hyapp.room.v1.LeaveRoomRequest + (*LeaveRoomResponse)(nil), // 20: hyapp.room.v1.LeaveRoomResponse + (*CloseRoomRequest)(nil), // 21: hyapp.room.v1.CloseRoomRequest + (*CloseRoomResponse)(nil), // 22: hyapp.room.v1.CloseRoomResponse + (*MicUpRequest)(nil), // 23: hyapp.room.v1.MicUpRequest + (*MicUpResponse)(nil), // 24: hyapp.room.v1.MicUpResponse + (*MicDownRequest)(nil), // 25: hyapp.room.v1.MicDownRequest + (*MicDownResponse)(nil), // 26: hyapp.room.v1.MicDownResponse + (*ChangeMicSeatRequest)(nil), // 27: hyapp.room.v1.ChangeMicSeatRequest + (*ChangeMicSeatResponse)(nil), // 28: hyapp.room.v1.ChangeMicSeatResponse + (*ConfirmMicPublishingRequest)(nil), // 29: hyapp.room.v1.ConfirmMicPublishingRequest + (*ConfirmMicPublishingResponse)(nil), // 30: hyapp.room.v1.ConfirmMicPublishingResponse + (*SetMicMuteRequest)(nil), // 31: hyapp.room.v1.SetMicMuteRequest + (*SetMicMuteResponse)(nil), // 32: hyapp.room.v1.SetMicMuteResponse + (*ApplyRTCEventRequest)(nil), // 33: hyapp.room.v1.ApplyRTCEventRequest + (*ApplyRTCEventResponse)(nil), // 34: hyapp.room.v1.ApplyRTCEventResponse + (*SetMicSeatLockRequest)(nil), // 35: hyapp.room.v1.SetMicSeatLockRequest + (*SetMicSeatLockResponse)(nil), // 36: hyapp.room.v1.SetMicSeatLockResponse + (*SetChatEnabledRequest)(nil), // 37: hyapp.room.v1.SetChatEnabledRequest + (*SetChatEnabledResponse)(nil), // 38: hyapp.room.v1.SetChatEnabledResponse + (*SetRoomPasswordRequest)(nil), // 39: hyapp.room.v1.SetRoomPasswordRequest + (*SetRoomPasswordResponse)(nil), // 40: hyapp.room.v1.SetRoomPasswordResponse + (*SetRoomAdminRequest)(nil), // 41: hyapp.room.v1.SetRoomAdminRequest + (*SetRoomAdminResponse)(nil), // 42: hyapp.room.v1.SetRoomAdminResponse + (*MuteUserRequest)(nil), // 43: hyapp.room.v1.MuteUserRequest + (*MuteUserResponse)(nil), // 44: hyapp.room.v1.MuteUserResponse + (*KickUserRequest)(nil), // 45: hyapp.room.v1.KickUserRequest + (*KickUserResponse)(nil), // 46: hyapp.room.v1.KickUserResponse + (*UnbanUserRequest)(nil), // 47: hyapp.room.v1.UnbanUserRequest + (*UnbanUserResponse)(nil), // 48: hyapp.room.v1.UnbanUserResponse + (*SystemEvictUserRequest)(nil), // 49: hyapp.room.v1.SystemEvictUserRequest + (*SystemEvictUserResponse)(nil), // 50: hyapp.room.v1.SystemEvictUserResponse + (*SendGiftRequest)(nil), // 51: hyapp.room.v1.SendGiftRequest + (*SendGiftResponse)(nil), // 52: hyapp.room.v1.SendGiftResponse + (*CheckSpeakPermissionRequest)(nil), // 53: hyapp.room.v1.CheckSpeakPermissionRequest + (*CheckSpeakPermissionResponse)(nil), // 54: hyapp.room.v1.CheckSpeakPermissionResponse + (*VerifyRoomPresenceRequest)(nil), // 55: hyapp.room.v1.VerifyRoomPresenceRequest + (*VerifyRoomPresenceResponse)(nil), // 56: hyapp.room.v1.VerifyRoomPresenceResponse + (*ListRoomsRequest)(nil), // 57: hyapp.room.v1.ListRoomsRequest + (*ListRoomFeedsRequest)(nil), // 58: hyapp.room.v1.ListRoomFeedsRequest + (*RoomFeedRelatedUser)(nil), // 59: hyapp.room.v1.RoomFeedRelatedUser + (*RoomListItem)(nil), // 60: hyapp.room.v1.RoomListItem + (*ListRoomsResponse)(nil), // 61: hyapp.room.v1.ListRoomsResponse + (*GetMyRoomRequest)(nil), // 62: hyapp.room.v1.GetMyRoomRequest + (*GetMyRoomResponse)(nil), // 63: hyapp.room.v1.GetMyRoomResponse + (*GetCurrentRoomRequest)(nil), // 64: hyapp.room.v1.GetCurrentRoomRequest + (*GetCurrentRoomResponse)(nil), // 65: hyapp.room.v1.GetCurrentRoomResponse + (*GetRoomSnapshotRequest)(nil), // 66: hyapp.room.v1.GetRoomSnapshotRequest + (*GetRoomSnapshotResponse)(nil), // 67: hyapp.room.v1.GetRoomSnapshotResponse + (*GetRoomTreasureRequest)(nil), // 68: hyapp.room.v1.GetRoomTreasureRequest + (*GetRoomTreasureResponse)(nil), // 69: hyapp.room.v1.GetRoomTreasureResponse + (*ListRoomOnlineUsersRequest)(nil), // 70: hyapp.room.v1.ListRoomOnlineUsersRequest + (*ListRoomOnlineUsersResponse)(nil), // 71: hyapp.room.v1.ListRoomOnlineUsersResponse + (*FollowRoomRequest)(nil), // 72: hyapp.room.v1.FollowRoomRequest + (*FollowRoomResponse)(nil), // 73: hyapp.room.v1.FollowRoomResponse + (*UnfollowRoomRequest)(nil), // 74: hyapp.room.v1.UnfollowRoomRequest + (*UnfollowRoomResponse)(nil), // 75: hyapp.room.v1.UnfollowRoomResponse + nil, // 76: hyapp.room.v1.RoomSnapshot.RoomExtEntry } var file_proto_room_v1_room_proto_depIdxs = []int32{ - 3, // 0: hyapp.room.v1.RoomSnapshot.mic_seats:type_name -> hyapp.room.v1.SeatState - 2, // 1: hyapp.room.v1.RoomSnapshot.online_users:type_name -> hyapp.room.v1.RoomUser - 4, // 2: hyapp.room.v1.RoomSnapshot.gift_rank:type_name -> hyapp.room.v1.RankItem - 69, // 3: hyapp.room.v1.RoomSnapshot.room_ext:type_name -> hyapp.room.v1.RoomSnapshot.RoomExtEntry - 0, // 4: hyapp.room.v1.CreateRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 5: hyapp.room.v1.CreateRoomResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 6: hyapp.room.v1.CreateRoomResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 7: hyapp.room.v1.UpdateRoomProfileRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 8: hyapp.room.v1.UpdateRoomProfileResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 9: hyapp.room.v1.UpdateRoomProfileResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 10: hyapp.room.v1.JoinRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 11: hyapp.room.v1.JoinRoomResponse.result:type_name -> hyapp.room.v1.CommandResult - 2, // 12: hyapp.room.v1.JoinRoomResponse.user:type_name -> hyapp.room.v1.RoomUser - 5, // 13: hyapp.room.v1.JoinRoomResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 14: hyapp.room.v1.RoomHeartbeatRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 15: hyapp.room.v1.RoomHeartbeatResponse.result:type_name -> hyapp.room.v1.CommandResult - 2, // 16: hyapp.room.v1.RoomHeartbeatResponse.user:type_name -> hyapp.room.v1.RoomUser - 5, // 17: hyapp.room.v1.RoomHeartbeatResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 18: hyapp.room.v1.LeaveRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 19: hyapp.room.v1.LeaveRoomResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 20: hyapp.room.v1.LeaveRoomResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 21: hyapp.room.v1.CloseRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 22: hyapp.room.v1.CloseRoomResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 23: hyapp.room.v1.CloseRoomResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 24: hyapp.room.v1.MicUpRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 25: hyapp.room.v1.MicUpResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 26: hyapp.room.v1.MicUpResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 27: hyapp.room.v1.MicDownRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 28: hyapp.room.v1.MicDownResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 29: hyapp.room.v1.MicDownResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 30: hyapp.room.v1.ChangeMicSeatRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 31: hyapp.room.v1.ChangeMicSeatResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 32: hyapp.room.v1.ChangeMicSeatResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 33: hyapp.room.v1.ConfirmMicPublishingRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 34: hyapp.room.v1.ConfirmMicPublishingResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 35: hyapp.room.v1.ConfirmMicPublishingResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 36: hyapp.room.v1.SetMicMuteRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 37: hyapp.room.v1.SetMicMuteResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 38: hyapp.room.v1.SetMicMuteResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 39: hyapp.room.v1.ApplyRTCEventRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 40: hyapp.room.v1.ApplyRTCEventResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 41: hyapp.room.v1.ApplyRTCEventResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 42: hyapp.room.v1.SetMicSeatLockRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 43: hyapp.room.v1.SetMicSeatLockResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 44: hyapp.room.v1.SetMicSeatLockResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 45: hyapp.room.v1.SetChatEnabledRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 46: hyapp.room.v1.SetChatEnabledResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 47: hyapp.room.v1.SetChatEnabledResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 48: hyapp.room.v1.SetRoomPasswordRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 49: hyapp.room.v1.SetRoomPasswordResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 50: hyapp.room.v1.SetRoomPasswordResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 51: hyapp.room.v1.SetRoomAdminRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 52: hyapp.room.v1.SetRoomAdminResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 53: hyapp.room.v1.SetRoomAdminResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 54: hyapp.room.v1.MuteUserRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 55: hyapp.room.v1.MuteUserResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 56: hyapp.room.v1.MuteUserResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 57: hyapp.room.v1.KickUserRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 58: hyapp.room.v1.KickUserResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 59: hyapp.room.v1.KickUserResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 60: hyapp.room.v1.UnbanUserRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 61: hyapp.room.v1.UnbanUserResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 62: hyapp.room.v1.UnbanUserResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 63: hyapp.room.v1.SystemEvictUserRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 64: hyapp.room.v1.SystemEvictUserResponse.result:type_name -> hyapp.room.v1.CommandResult - 5, // 65: hyapp.room.v1.SystemEvictUserResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 66: hyapp.room.v1.SendGiftRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 1, // 67: hyapp.room.v1.SendGiftResponse.result:type_name -> hyapp.room.v1.CommandResult - 4, // 68: hyapp.room.v1.SendGiftResponse.gift_rank:type_name -> hyapp.room.v1.RankItem - 5, // 69: hyapp.room.v1.SendGiftResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 70: hyapp.room.v1.ListRoomsRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 0, // 71: hyapp.room.v1.ListRoomFeedsRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 54, // 72: hyapp.room.v1.ListRoomFeedsRequest.related_users:type_name -> hyapp.room.v1.RoomFeedRelatedUser - 55, // 73: hyapp.room.v1.ListRoomsResponse.rooms:type_name -> hyapp.room.v1.RoomListItem - 0, // 74: hyapp.room.v1.GetMyRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 55, // 75: hyapp.room.v1.GetMyRoomResponse.room:type_name -> hyapp.room.v1.RoomListItem - 0, // 76: hyapp.room.v1.GetCurrentRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 0, // 77: hyapp.room.v1.GetRoomSnapshotRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 5, // 78: hyapp.room.v1.GetRoomSnapshotResponse.room:type_name -> hyapp.room.v1.RoomSnapshot - 0, // 79: hyapp.room.v1.ListRoomOnlineUsersRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 2, // 80: hyapp.room.v1.ListRoomOnlineUsersResponse.users:type_name -> hyapp.room.v1.RoomUser - 0, // 81: hyapp.room.v1.FollowRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 0, // 82: hyapp.room.v1.UnfollowRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta - 6, // 83: hyapp.room.v1.RoomCommandService.CreateRoom:input_type -> hyapp.room.v1.CreateRoomRequest - 8, // 84: hyapp.room.v1.RoomCommandService.UpdateRoomProfile:input_type -> hyapp.room.v1.UpdateRoomProfileRequest - 10, // 85: hyapp.room.v1.RoomCommandService.JoinRoom:input_type -> hyapp.room.v1.JoinRoomRequest - 12, // 86: hyapp.room.v1.RoomCommandService.RoomHeartbeat:input_type -> hyapp.room.v1.RoomHeartbeatRequest - 14, // 87: hyapp.room.v1.RoomCommandService.LeaveRoom:input_type -> hyapp.room.v1.LeaveRoomRequest - 16, // 88: hyapp.room.v1.RoomCommandService.CloseRoom:input_type -> hyapp.room.v1.CloseRoomRequest - 18, // 89: hyapp.room.v1.RoomCommandService.MicUp:input_type -> hyapp.room.v1.MicUpRequest - 20, // 90: hyapp.room.v1.RoomCommandService.MicDown:input_type -> hyapp.room.v1.MicDownRequest - 22, // 91: hyapp.room.v1.RoomCommandService.ChangeMicSeat:input_type -> hyapp.room.v1.ChangeMicSeatRequest - 24, // 92: hyapp.room.v1.RoomCommandService.ConfirmMicPublishing:input_type -> hyapp.room.v1.ConfirmMicPublishingRequest - 26, // 93: hyapp.room.v1.RoomCommandService.SetMicMute:input_type -> hyapp.room.v1.SetMicMuteRequest - 28, // 94: hyapp.room.v1.RoomCommandService.ApplyRTCEvent:input_type -> hyapp.room.v1.ApplyRTCEventRequest - 30, // 95: hyapp.room.v1.RoomCommandService.SetMicSeatLock:input_type -> hyapp.room.v1.SetMicSeatLockRequest - 32, // 96: hyapp.room.v1.RoomCommandService.SetChatEnabled:input_type -> hyapp.room.v1.SetChatEnabledRequest - 34, // 97: hyapp.room.v1.RoomCommandService.SetRoomPassword:input_type -> hyapp.room.v1.SetRoomPasswordRequest - 36, // 98: hyapp.room.v1.RoomCommandService.SetRoomAdmin:input_type -> hyapp.room.v1.SetRoomAdminRequest - 38, // 99: hyapp.room.v1.RoomCommandService.MuteUser:input_type -> hyapp.room.v1.MuteUserRequest - 40, // 100: hyapp.room.v1.RoomCommandService.KickUser:input_type -> hyapp.room.v1.KickUserRequest - 42, // 101: hyapp.room.v1.RoomCommandService.UnbanUser:input_type -> hyapp.room.v1.UnbanUserRequest - 44, // 102: hyapp.room.v1.RoomCommandService.SystemEvictUser:input_type -> hyapp.room.v1.SystemEvictUserRequest - 46, // 103: hyapp.room.v1.RoomCommandService.SendGift:input_type -> hyapp.room.v1.SendGiftRequest - 65, // 104: hyapp.room.v1.RoomCommandService.FollowRoom:input_type -> hyapp.room.v1.FollowRoomRequest - 67, // 105: hyapp.room.v1.RoomCommandService.UnfollowRoom:input_type -> hyapp.room.v1.UnfollowRoomRequest - 48, // 106: hyapp.room.v1.RoomGuardService.CheckSpeakPermission:input_type -> hyapp.room.v1.CheckSpeakPermissionRequest - 50, // 107: hyapp.room.v1.RoomGuardService.VerifyRoomPresence:input_type -> hyapp.room.v1.VerifyRoomPresenceRequest - 52, // 108: hyapp.room.v1.RoomQueryService.ListRooms:input_type -> hyapp.room.v1.ListRoomsRequest - 53, // 109: hyapp.room.v1.RoomQueryService.ListRoomFeeds:input_type -> hyapp.room.v1.ListRoomFeedsRequest - 57, // 110: hyapp.room.v1.RoomQueryService.GetMyRoom:input_type -> hyapp.room.v1.GetMyRoomRequest - 59, // 111: hyapp.room.v1.RoomQueryService.GetCurrentRoom:input_type -> hyapp.room.v1.GetCurrentRoomRequest - 61, // 112: hyapp.room.v1.RoomQueryService.GetRoomSnapshot:input_type -> hyapp.room.v1.GetRoomSnapshotRequest - 63, // 113: hyapp.room.v1.RoomQueryService.ListRoomOnlineUsers:input_type -> hyapp.room.v1.ListRoomOnlineUsersRequest - 7, // 114: hyapp.room.v1.RoomCommandService.CreateRoom:output_type -> hyapp.room.v1.CreateRoomResponse - 9, // 115: hyapp.room.v1.RoomCommandService.UpdateRoomProfile:output_type -> hyapp.room.v1.UpdateRoomProfileResponse - 11, // 116: hyapp.room.v1.RoomCommandService.JoinRoom:output_type -> hyapp.room.v1.JoinRoomResponse - 13, // 117: hyapp.room.v1.RoomCommandService.RoomHeartbeat:output_type -> hyapp.room.v1.RoomHeartbeatResponse - 15, // 118: hyapp.room.v1.RoomCommandService.LeaveRoom:output_type -> hyapp.room.v1.LeaveRoomResponse - 17, // 119: hyapp.room.v1.RoomCommandService.CloseRoom:output_type -> hyapp.room.v1.CloseRoomResponse - 19, // 120: hyapp.room.v1.RoomCommandService.MicUp:output_type -> hyapp.room.v1.MicUpResponse - 21, // 121: hyapp.room.v1.RoomCommandService.MicDown:output_type -> hyapp.room.v1.MicDownResponse - 23, // 122: hyapp.room.v1.RoomCommandService.ChangeMicSeat:output_type -> hyapp.room.v1.ChangeMicSeatResponse - 25, // 123: hyapp.room.v1.RoomCommandService.ConfirmMicPublishing:output_type -> hyapp.room.v1.ConfirmMicPublishingResponse - 27, // 124: hyapp.room.v1.RoomCommandService.SetMicMute:output_type -> hyapp.room.v1.SetMicMuteResponse - 29, // 125: hyapp.room.v1.RoomCommandService.ApplyRTCEvent:output_type -> hyapp.room.v1.ApplyRTCEventResponse - 31, // 126: hyapp.room.v1.RoomCommandService.SetMicSeatLock:output_type -> hyapp.room.v1.SetMicSeatLockResponse - 33, // 127: hyapp.room.v1.RoomCommandService.SetChatEnabled:output_type -> hyapp.room.v1.SetChatEnabledResponse - 35, // 128: hyapp.room.v1.RoomCommandService.SetRoomPassword:output_type -> hyapp.room.v1.SetRoomPasswordResponse - 37, // 129: hyapp.room.v1.RoomCommandService.SetRoomAdmin:output_type -> hyapp.room.v1.SetRoomAdminResponse - 39, // 130: hyapp.room.v1.RoomCommandService.MuteUser:output_type -> hyapp.room.v1.MuteUserResponse - 41, // 131: hyapp.room.v1.RoomCommandService.KickUser:output_type -> hyapp.room.v1.KickUserResponse - 43, // 132: hyapp.room.v1.RoomCommandService.UnbanUser:output_type -> hyapp.room.v1.UnbanUserResponse - 45, // 133: hyapp.room.v1.RoomCommandService.SystemEvictUser:output_type -> hyapp.room.v1.SystemEvictUserResponse - 47, // 134: hyapp.room.v1.RoomCommandService.SendGift:output_type -> hyapp.room.v1.SendGiftResponse - 66, // 135: hyapp.room.v1.RoomCommandService.FollowRoom:output_type -> hyapp.room.v1.FollowRoomResponse - 68, // 136: hyapp.room.v1.RoomCommandService.UnfollowRoom:output_type -> hyapp.room.v1.UnfollowRoomResponse - 49, // 137: hyapp.room.v1.RoomGuardService.CheckSpeakPermission:output_type -> hyapp.room.v1.CheckSpeakPermissionResponse - 51, // 138: hyapp.room.v1.RoomGuardService.VerifyRoomPresence:output_type -> hyapp.room.v1.VerifyRoomPresenceResponse - 56, // 139: hyapp.room.v1.RoomQueryService.ListRooms:output_type -> hyapp.room.v1.ListRoomsResponse - 56, // 140: hyapp.room.v1.RoomQueryService.ListRoomFeeds:output_type -> hyapp.room.v1.ListRoomsResponse - 58, // 141: hyapp.room.v1.RoomQueryService.GetMyRoom:output_type -> hyapp.room.v1.GetMyRoomResponse - 60, // 142: hyapp.room.v1.RoomQueryService.GetCurrentRoom:output_type -> hyapp.room.v1.GetCurrentRoomResponse - 62, // 143: hyapp.room.v1.RoomQueryService.GetRoomSnapshot:output_type -> hyapp.room.v1.GetRoomSnapshotResponse - 64, // 144: hyapp.room.v1.RoomQueryService.ListRoomOnlineUsers:output_type -> hyapp.room.v1.ListRoomOnlineUsersResponse - 114, // [114:145] is the sub-list for method output_type - 83, // [83:114] is the sub-list for method input_type - 83, // [83:83] is the sub-list for extension type_name - 83, // [83:83] is the sub-list for extension extendee - 0, // [0:83] is the sub-list for field type_name + 5, // 0: hyapp.room.v1.RoomTreasureLevel.in_room_rewards:type_name -> hyapp.room.v1.RoomTreasureRewardItem + 5, // 1: hyapp.room.v1.RoomTreasureLevel.top1_rewards:type_name -> hyapp.room.v1.RoomTreasureRewardItem + 5, // 2: hyapp.room.v1.RoomTreasureLevel.igniter_rewards:type_name -> hyapp.room.v1.RoomTreasureRewardItem + 7, // 3: hyapp.room.v1.RoomTreasureState.last_rewards:type_name -> hyapp.room.v1.RoomTreasureRewardGrant + 6, // 4: hyapp.room.v1.RoomTreasureInfo.levels:type_name -> hyapp.room.v1.RoomTreasureLevel + 8, // 5: hyapp.room.v1.RoomTreasureInfo.state:type_name -> hyapp.room.v1.RoomTreasureState + 3, // 6: hyapp.room.v1.RoomSnapshot.mic_seats:type_name -> hyapp.room.v1.SeatState + 2, // 7: hyapp.room.v1.RoomSnapshot.online_users:type_name -> hyapp.room.v1.RoomUser + 4, // 8: hyapp.room.v1.RoomSnapshot.gift_rank:type_name -> hyapp.room.v1.RankItem + 76, // 9: hyapp.room.v1.RoomSnapshot.room_ext:type_name -> hyapp.room.v1.RoomSnapshot.RoomExtEntry + 8, // 10: hyapp.room.v1.RoomSnapshot.treasure:type_name -> hyapp.room.v1.RoomTreasureState + 0, // 11: hyapp.room.v1.CreateRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 12: hyapp.room.v1.CreateRoomResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 13: hyapp.room.v1.CreateRoomResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 14: hyapp.room.v1.UpdateRoomProfileRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 15: hyapp.room.v1.UpdateRoomProfileResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 16: hyapp.room.v1.UpdateRoomProfileResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 17: hyapp.room.v1.JoinRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 18: hyapp.room.v1.JoinRoomResponse.result:type_name -> hyapp.room.v1.CommandResult + 2, // 19: hyapp.room.v1.JoinRoomResponse.user:type_name -> hyapp.room.v1.RoomUser + 10, // 20: hyapp.room.v1.JoinRoomResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 21: hyapp.room.v1.RoomHeartbeatRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 22: hyapp.room.v1.RoomHeartbeatResponse.result:type_name -> hyapp.room.v1.CommandResult + 2, // 23: hyapp.room.v1.RoomHeartbeatResponse.user:type_name -> hyapp.room.v1.RoomUser + 10, // 24: hyapp.room.v1.RoomHeartbeatResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 25: hyapp.room.v1.LeaveRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 26: hyapp.room.v1.LeaveRoomResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 27: hyapp.room.v1.LeaveRoomResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 28: hyapp.room.v1.CloseRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 29: hyapp.room.v1.CloseRoomResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 30: hyapp.room.v1.CloseRoomResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 31: hyapp.room.v1.MicUpRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 32: hyapp.room.v1.MicUpResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 33: hyapp.room.v1.MicUpResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 34: hyapp.room.v1.MicDownRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 35: hyapp.room.v1.MicDownResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 36: hyapp.room.v1.MicDownResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 37: hyapp.room.v1.ChangeMicSeatRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 38: hyapp.room.v1.ChangeMicSeatResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 39: hyapp.room.v1.ChangeMicSeatResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 40: hyapp.room.v1.ConfirmMicPublishingRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 41: hyapp.room.v1.ConfirmMicPublishingResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 42: hyapp.room.v1.ConfirmMicPublishingResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 43: hyapp.room.v1.SetMicMuteRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 44: hyapp.room.v1.SetMicMuteResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 45: hyapp.room.v1.SetMicMuteResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 46: hyapp.room.v1.ApplyRTCEventRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 47: hyapp.room.v1.ApplyRTCEventResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 48: hyapp.room.v1.ApplyRTCEventResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 49: hyapp.room.v1.SetMicSeatLockRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 50: hyapp.room.v1.SetMicSeatLockResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 51: hyapp.room.v1.SetMicSeatLockResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 52: hyapp.room.v1.SetChatEnabledRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 53: hyapp.room.v1.SetChatEnabledResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 54: hyapp.room.v1.SetChatEnabledResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 55: hyapp.room.v1.SetRoomPasswordRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 56: hyapp.room.v1.SetRoomPasswordResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 57: hyapp.room.v1.SetRoomPasswordResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 58: hyapp.room.v1.SetRoomAdminRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 59: hyapp.room.v1.SetRoomAdminResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 60: hyapp.room.v1.SetRoomAdminResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 61: hyapp.room.v1.MuteUserRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 62: hyapp.room.v1.MuteUserResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 63: hyapp.room.v1.MuteUserResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 64: hyapp.room.v1.KickUserRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 65: hyapp.room.v1.KickUserResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 66: hyapp.room.v1.KickUserResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 67: hyapp.room.v1.UnbanUserRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 68: hyapp.room.v1.UnbanUserResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 69: hyapp.room.v1.UnbanUserResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 70: hyapp.room.v1.SystemEvictUserRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 71: hyapp.room.v1.SystemEvictUserResponse.result:type_name -> hyapp.room.v1.CommandResult + 10, // 72: hyapp.room.v1.SystemEvictUserResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 73: hyapp.room.v1.SendGiftRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 1, // 74: hyapp.room.v1.SendGiftResponse.result:type_name -> hyapp.room.v1.CommandResult + 4, // 75: hyapp.room.v1.SendGiftResponse.gift_rank:type_name -> hyapp.room.v1.RankItem + 10, // 76: hyapp.room.v1.SendGiftResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 8, // 77: hyapp.room.v1.SendGiftResponse.treasure:type_name -> hyapp.room.v1.RoomTreasureState + 0, // 78: hyapp.room.v1.ListRoomsRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 0, // 79: hyapp.room.v1.ListRoomFeedsRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 59, // 80: hyapp.room.v1.ListRoomFeedsRequest.related_users:type_name -> hyapp.room.v1.RoomFeedRelatedUser + 60, // 81: hyapp.room.v1.ListRoomsResponse.rooms:type_name -> hyapp.room.v1.RoomListItem + 0, // 82: hyapp.room.v1.GetMyRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 60, // 83: hyapp.room.v1.GetMyRoomResponse.room:type_name -> hyapp.room.v1.RoomListItem + 0, // 84: hyapp.room.v1.GetCurrentRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 0, // 85: hyapp.room.v1.GetRoomSnapshotRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 10, // 86: hyapp.room.v1.GetRoomSnapshotResponse.room:type_name -> hyapp.room.v1.RoomSnapshot + 0, // 87: hyapp.room.v1.GetRoomTreasureRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 9, // 88: hyapp.room.v1.GetRoomTreasureResponse.treasure:type_name -> hyapp.room.v1.RoomTreasureInfo + 0, // 89: hyapp.room.v1.ListRoomOnlineUsersRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 2, // 90: hyapp.room.v1.ListRoomOnlineUsersResponse.users:type_name -> hyapp.room.v1.RoomUser + 0, // 91: hyapp.room.v1.FollowRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 0, // 92: hyapp.room.v1.UnfollowRoomRequest.meta:type_name -> hyapp.room.v1.RequestMeta + 11, // 93: hyapp.room.v1.RoomCommandService.CreateRoom:input_type -> hyapp.room.v1.CreateRoomRequest + 13, // 94: hyapp.room.v1.RoomCommandService.UpdateRoomProfile:input_type -> hyapp.room.v1.UpdateRoomProfileRequest + 15, // 95: hyapp.room.v1.RoomCommandService.JoinRoom:input_type -> hyapp.room.v1.JoinRoomRequest + 17, // 96: hyapp.room.v1.RoomCommandService.RoomHeartbeat:input_type -> hyapp.room.v1.RoomHeartbeatRequest + 19, // 97: hyapp.room.v1.RoomCommandService.LeaveRoom:input_type -> hyapp.room.v1.LeaveRoomRequest + 21, // 98: hyapp.room.v1.RoomCommandService.CloseRoom:input_type -> hyapp.room.v1.CloseRoomRequest + 23, // 99: hyapp.room.v1.RoomCommandService.MicUp:input_type -> hyapp.room.v1.MicUpRequest + 25, // 100: hyapp.room.v1.RoomCommandService.MicDown:input_type -> hyapp.room.v1.MicDownRequest + 27, // 101: hyapp.room.v1.RoomCommandService.ChangeMicSeat:input_type -> hyapp.room.v1.ChangeMicSeatRequest + 29, // 102: hyapp.room.v1.RoomCommandService.ConfirmMicPublishing:input_type -> hyapp.room.v1.ConfirmMicPublishingRequest + 31, // 103: hyapp.room.v1.RoomCommandService.SetMicMute:input_type -> hyapp.room.v1.SetMicMuteRequest + 33, // 104: hyapp.room.v1.RoomCommandService.ApplyRTCEvent:input_type -> hyapp.room.v1.ApplyRTCEventRequest + 35, // 105: hyapp.room.v1.RoomCommandService.SetMicSeatLock:input_type -> hyapp.room.v1.SetMicSeatLockRequest + 37, // 106: hyapp.room.v1.RoomCommandService.SetChatEnabled:input_type -> hyapp.room.v1.SetChatEnabledRequest + 39, // 107: hyapp.room.v1.RoomCommandService.SetRoomPassword:input_type -> hyapp.room.v1.SetRoomPasswordRequest + 41, // 108: hyapp.room.v1.RoomCommandService.SetRoomAdmin:input_type -> hyapp.room.v1.SetRoomAdminRequest + 43, // 109: hyapp.room.v1.RoomCommandService.MuteUser:input_type -> hyapp.room.v1.MuteUserRequest + 45, // 110: hyapp.room.v1.RoomCommandService.KickUser:input_type -> hyapp.room.v1.KickUserRequest + 47, // 111: hyapp.room.v1.RoomCommandService.UnbanUser:input_type -> hyapp.room.v1.UnbanUserRequest + 49, // 112: hyapp.room.v1.RoomCommandService.SystemEvictUser:input_type -> hyapp.room.v1.SystemEvictUserRequest + 51, // 113: hyapp.room.v1.RoomCommandService.SendGift:input_type -> hyapp.room.v1.SendGiftRequest + 72, // 114: hyapp.room.v1.RoomCommandService.FollowRoom:input_type -> hyapp.room.v1.FollowRoomRequest + 74, // 115: hyapp.room.v1.RoomCommandService.UnfollowRoom:input_type -> hyapp.room.v1.UnfollowRoomRequest + 53, // 116: hyapp.room.v1.RoomGuardService.CheckSpeakPermission:input_type -> hyapp.room.v1.CheckSpeakPermissionRequest + 55, // 117: hyapp.room.v1.RoomGuardService.VerifyRoomPresence:input_type -> hyapp.room.v1.VerifyRoomPresenceRequest + 57, // 118: hyapp.room.v1.RoomQueryService.ListRooms:input_type -> hyapp.room.v1.ListRoomsRequest + 58, // 119: hyapp.room.v1.RoomQueryService.ListRoomFeeds:input_type -> hyapp.room.v1.ListRoomFeedsRequest + 62, // 120: hyapp.room.v1.RoomQueryService.GetMyRoom:input_type -> hyapp.room.v1.GetMyRoomRequest + 64, // 121: hyapp.room.v1.RoomQueryService.GetCurrentRoom:input_type -> hyapp.room.v1.GetCurrentRoomRequest + 66, // 122: hyapp.room.v1.RoomQueryService.GetRoomSnapshot:input_type -> hyapp.room.v1.GetRoomSnapshotRequest + 68, // 123: hyapp.room.v1.RoomQueryService.GetRoomTreasure:input_type -> hyapp.room.v1.GetRoomTreasureRequest + 70, // 124: hyapp.room.v1.RoomQueryService.ListRoomOnlineUsers:input_type -> hyapp.room.v1.ListRoomOnlineUsersRequest + 12, // 125: hyapp.room.v1.RoomCommandService.CreateRoom:output_type -> hyapp.room.v1.CreateRoomResponse + 14, // 126: hyapp.room.v1.RoomCommandService.UpdateRoomProfile:output_type -> hyapp.room.v1.UpdateRoomProfileResponse + 16, // 127: hyapp.room.v1.RoomCommandService.JoinRoom:output_type -> hyapp.room.v1.JoinRoomResponse + 18, // 128: hyapp.room.v1.RoomCommandService.RoomHeartbeat:output_type -> hyapp.room.v1.RoomHeartbeatResponse + 20, // 129: hyapp.room.v1.RoomCommandService.LeaveRoom:output_type -> hyapp.room.v1.LeaveRoomResponse + 22, // 130: hyapp.room.v1.RoomCommandService.CloseRoom:output_type -> hyapp.room.v1.CloseRoomResponse + 24, // 131: hyapp.room.v1.RoomCommandService.MicUp:output_type -> hyapp.room.v1.MicUpResponse + 26, // 132: hyapp.room.v1.RoomCommandService.MicDown:output_type -> hyapp.room.v1.MicDownResponse + 28, // 133: hyapp.room.v1.RoomCommandService.ChangeMicSeat:output_type -> hyapp.room.v1.ChangeMicSeatResponse + 30, // 134: hyapp.room.v1.RoomCommandService.ConfirmMicPublishing:output_type -> hyapp.room.v1.ConfirmMicPublishingResponse + 32, // 135: hyapp.room.v1.RoomCommandService.SetMicMute:output_type -> hyapp.room.v1.SetMicMuteResponse + 34, // 136: hyapp.room.v1.RoomCommandService.ApplyRTCEvent:output_type -> hyapp.room.v1.ApplyRTCEventResponse + 36, // 137: hyapp.room.v1.RoomCommandService.SetMicSeatLock:output_type -> hyapp.room.v1.SetMicSeatLockResponse + 38, // 138: hyapp.room.v1.RoomCommandService.SetChatEnabled:output_type -> hyapp.room.v1.SetChatEnabledResponse + 40, // 139: hyapp.room.v1.RoomCommandService.SetRoomPassword:output_type -> hyapp.room.v1.SetRoomPasswordResponse + 42, // 140: hyapp.room.v1.RoomCommandService.SetRoomAdmin:output_type -> hyapp.room.v1.SetRoomAdminResponse + 44, // 141: hyapp.room.v1.RoomCommandService.MuteUser:output_type -> hyapp.room.v1.MuteUserResponse + 46, // 142: hyapp.room.v1.RoomCommandService.KickUser:output_type -> hyapp.room.v1.KickUserResponse + 48, // 143: hyapp.room.v1.RoomCommandService.UnbanUser:output_type -> hyapp.room.v1.UnbanUserResponse + 50, // 144: hyapp.room.v1.RoomCommandService.SystemEvictUser:output_type -> hyapp.room.v1.SystemEvictUserResponse + 52, // 145: hyapp.room.v1.RoomCommandService.SendGift:output_type -> hyapp.room.v1.SendGiftResponse + 73, // 146: hyapp.room.v1.RoomCommandService.FollowRoom:output_type -> hyapp.room.v1.FollowRoomResponse + 75, // 147: hyapp.room.v1.RoomCommandService.UnfollowRoom:output_type -> hyapp.room.v1.UnfollowRoomResponse + 54, // 148: hyapp.room.v1.RoomGuardService.CheckSpeakPermission:output_type -> hyapp.room.v1.CheckSpeakPermissionResponse + 56, // 149: hyapp.room.v1.RoomGuardService.VerifyRoomPresence:output_type -> hyapp.room.v1.VerifyRoomPresenceResponse + 61, // 150: hyapp.room.v1.RoomQueryService.ListRooms:output_type -> hyapp.room.v1.ListRoomsResponse + 61, // 151: hyapp.room.v1.RoomQueryService.ListRoomFeeds:output_type -> hyapp.room.v1.ListRoomsResponse + 63, // 152: hyapp.room.v1.RoomQueryService.GetMyRoom:output_type -> hyapp.room.v1.GetMyRoomResponse + 65, // 153: hyapp.room.v1.RoomQueryService.GetCurrentRoom:output_type -> hyapp.room.v1.GetCurrentRoomResponse + 67, // 154: hyapp.room.v1.RoomQueryService.GetRoomSnapshot:output_type -> hyapp.room.v1.GetRoomSnapshotResponse + 69, // 155: hyapp.room.v1.RoomQueryService.GetRoomTreasure:output_type -> hyapp.room.v1.GetRoomTreasureResponse + 71, // 156: hyapp.room.v1.RoomQueryService.ListRoomOnlineUsers:output_type -> hyapp.room.v1.ListRoomOnlineUsersResponse + 125, // [125:157] is the sub-list for method output_type + 93, // [93:125] is the sub-list for method input_type + 93, // [93:93] is the sub-list for extension type_name + 93, // [93:93] is the sub-list for extension extendee + 0, // [0:93] is the sub-list for field type_name } func init() { file_proto_room_v1_room_proto_init() } @@ -5452,14 +6194,14 @@ func file_proto_room_v1_room_proto_init() { if File_proto_room_v1_room_proto != nil { return } - file_proto_room_v1_room_proto_msgTypes[8].OneofWrappers = []any{} + file_proto_room_v1_room_proto_msgTypes[13].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_room_v1_room_proto_rawDesc), len(file_proto_room_v1_room_proto_rawDesc)), NumEnums: 0, - NumMessages: 70, + NumMessages: 77, NumExtensions: 0, NumServices: 3, }, diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index 8763e278..9b0757e0 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -63,6 +63,69 @@ message RankItem { int64 updated_at_ms = 4; } +// RoomTreasureRewardItem 是后台配置给客户端展示的宝箱奖励候选项。 +message RoomTreasureRewardItem { + string reward_item_id = 1; + int64 resource_group_id = 2; + int64 weight = 3; + string display_name = 4; + string icon_url = 5; +} + +// RoomTreasureLevel 暴露单个等级宝箱的物料、阈值和三类奖励池。 +message RoomTreasureLevel { + int32 level = 1; + int64 energy_threshold = 2; + string cover_url = 3; + string animation_url = 4; + string opening_animation_url = 5; + string opened_image_url = 6; + repeated RoomTreasureRewardItem in_room_rewards = 7; + repeated RoomTreasureRewardItem top1_rewards = 8; + repeated RoomTreasureRewardItem igniter_rewards = 9; +} + +// RoomTreasureRewardGrant 是一次宝箱打开后给某个用户结算出的具体奖励。 +message RoomTreasureRewardGrant { + string reward_role = 1; + int64 user_id = 2; + string reward_item_id = 3; + int64 resource_group_id = 4; + string display_name = 5; + string icon_url = 6; + string grant_id = 7; + string status = 8; +} + +// RoomTreasureState 是 Room Cell 持有并随快照恢复的当前宝箱状态。 +message RoomTreasureState { + int32 current_level = 1; + int64 current_progress = 2; + int64 energy_threshold = 3; + string status = 4; + int64 countdown_started_at_ms = 5; + int64 open_at_ms = 6; + int64 opened_at_ms = 7; + int64 reset_at_ms = 8; + int64 top1_user_id = 9; + int64 igniter_user_id = 10; + string box_id = 11; + int64 config_version = 12; + repeated RoomTreasureRewardGrant last_rewards = 13; +} + +// RoomTreasureInfo 是 App 初始化宝箱 UI 的完整读模型。 +message RoomTreasureInfo { + bool enabled = 1; + repeated RoomTreasureLevel levels = 2; + RoomTreasureState state = 3; + int64 server_time_ms = 4; + string broadcast_scope = 5; + int64 open_delay_ms = 6; + int64 broadcast_delay_ms = 7; + string reward_stack_policy = 8; +} + // RoomSnapshot 把 room-service 对外需要暴露的房间投影集中返回。 message RoomSnapshot { string room_id = 1; @@ -84,6 +147,8 @@ message RoomSnapshot { int64 visible_region_id = 18; // locked 只表达房间是否需要入房密码;具体密码哈希不对外映射到 HTTP JSON。 bool locked = 19; + // treasure 是语音房宝箱当前状态;配置物料通过 GetRoomTreasure 单独读取,避免快照过大。 + RoomTreasureState treasure = 20; } // CreateRoomRequest 创建一个新的房间执行单元。 @@ -409,6 +474,7 @@ message SendGiftResponse { int64 room_heat = 3; repeated RankItem gift_rank = 4; RoomSnapshot room = 5; + RoomTreasureState treasure = 6; } // CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。 @@ -549,6 +615,19 @@ message GetRoomSnapshotResponse { bool is_followed = 3; } +// GetRoomTreasureRequest 主动读取当前房间宝箱配置物料和进度状态。 +// 该查询必须由 gateway 写入鉴权后的 viewer_user_id,客户端不能提交 viewer_user_id。 +message GetRoomTreasureRequest { + RequestMeta meta = 1; + string room_id = 2; + int64 viewer_user_id = 3; +} + +message GetRoomTreasureResponse { + RoomTreasureInfo treasure = 1; + int64 server_time_ms = 2; +} + // ListRoomOnlineUsersRequest 分页查询房间在线用户,不让客户端拉完整 RoomSnapshot 做列表分页。 message ListRoomOnlineUsersRequest { RequestMeta meta = 1; @@ -634,5 +713,6 @@ service RoomQueryService { rpc GetMyRoom(GetMyRoomRequest) returns (GetMyRoomResponse); rpc GetCurrentRoom(GetCurrentRoomRequest) returns (GetCurrentRoomResponse); rpc GetRoomSnapshot(GetRoomSnapshotRequest) returns (GetRoomSnapshotResponse); + rpc GetRoomTreasure(GetRoomTreasureRequest) returns (GetRoomTreasureResponse); rpc ListRoomOnlineUsers(ListRoomOnlineUsersRequest) returns (ListRoomOnlineUsersResponse); } diff --git a/api/proto/room/v1/room_grpc.pb.go b/api/proto/room/v1/room_grpc.pb.go index c784a02d..cf12e906 100644 --- a/api/proto/room/v1/room_grpc.pb.go +++ b/api/proto/room/v1/room_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v5.28.3 +// - protoc (unknown) // source: proto/room/v1/room.proto package roomv1 @@ -1110,6 +1110,7 @@ const ( RoomQueryService_GetMyRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetMyRoom" RoomQueryService_GetCurrentRoom_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetCurrentRoom" RoomQueryService_GetRoomSnapshot_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomSnapshot" + RoomQueryService_GetRoomTreasure_FullMethodName = "/hyapp.room.v1.RoomQueryService/GetRoomTreasure" RoomQueryService_ListRoomOnlineUsers_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomOnlineUsers" ) @@ -1124,6 +1125,7 @@ type RoomQueryServiceClient interface { GetMyRoom(ctx context.Context, in *GetMyRoomRequest, opts ...grpc.CallOption) (*GetMyRoomResponse, error) GetCurrentRoom(ctx context.Context, in *GetCurrentRoomRequest, opts ...grpc.CallOption) (*GetCurrentRoomResponse, error) GetRoomSnapshot(ctx context.Context, in *GetRoomSnapshotRequest, opts ...grpc.CallOption) (*GetRoomSnapshotResponse, error) + GetRoomTreasure(ctx context.Context, in *GetRoomTreasureRequest, opts ...grpc.CallOption) (*GetRoomTreasureResponse, error) ListRoomOnlineUsers(ctx context.Context, in *ListRoomOnlineUsersRequest, opts ...grpc.CallOption) (*ListRoomOnlineUsersResponse, error) } @@ -1185,6 +1187,16 @@ func (c *roomQueryServiceClient) GetRoomSnapshot(ctx context.Context, in *GetRoo return out, nil } +func (c *roomQueryServiceClient) GetRoomTreasure(ctx context.Context, in *GetRoomTreasureRequest, opts ...grpc.CallOption) (*GetRoomTreasureResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetRoomTreasureResponse) + err := c.cc.Invoke(ctx, RoomQueryService_GetRoomTreasure_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *roomQueryServiceClient) ListRoomOnlineUsers(ctx context.Context, in *ListRoomOnlineUsersRequest, opts ...grpc.CallOption) (*ListRoomOnlineUsersResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(ListRoomOnlineUsersResponse) @@ -1206,6 +1218,7 @@ type RoomQueryServiceServer interface { GetMyRoom(context.Context, *GetMyRoomRequest) (*GetMyRoomResponse, error) GetCurrentRoom(context.Context, *GetCurrentRoomRequest) (*GetCurrentRoomResponse, error) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) + GetRoomTreasure(context.Context, *GetRoomTreasureRequest) (*GetRoomTreasureResponse, error) ListRoomOnlineUsers(context.Context, *ListRoomOnlineUsersRequest) (*ListRoomOnlineUsersResponse, error) mustEmbedUnimplementedRoomQueryServiceServer() } @@ -1232,6 +1245,9 @@ func (UnimplementedRoomQueryServiceServer) GetCurrentRoom(context.Context, *GetC func (UnimplementedRoomQueryServiceServer) GetRoomSnapshot(context.Context, *GetRoomSnapshotRequest) (*GetRoomSnapshotResponse, error) { return nil, status.Error(codes.Unimplemented, "method GetRoomSnapshot not implemented") } +func (UnimplementedRoomQueryServiceServer) GetRoomTreasure(context.Context, *GetRoomTreasureRequest) (*GetRoomTreasureResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetRoomTreasure not implemented") +} func (UnimplementedRoomQueryServiceServer) ListRoomOnlineUsers(context.Context, *ListRoomOnlineUsersRequest) (*ListRoomOnlineUsersResponse, error) { return nil, status.Error(codes.Unimplemented, "method ListRoomOnlineUsers not implemented") } @@ -1346,6 +1362,24 @@ func _RoomQueryService_GetRoomSnapshot_Handler(srv interface{}, ctx context.Cont return interceptor(ctx, in, info, handler) } +func _RoomQueryService_GetRoomTreasure_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetRoomTreasureRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(RoomQueryServiceServer).GetRoomTreasure(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: RoomQueryService_GetRoomTreasure_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(RoomQueryServiceServer).GetRoomTreasure(ctx, req.(*GetRoomTreasureRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _RoomQueryService_ListRoomOnlineUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(ListRoomOnlineUsersRequest) if err := dec(in); err != nil { @@ -1391,6 +1425,10 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetRoomSnapshot", Handler: _RoomQueryService_GetRoomSnapshot_Handler, }, + { + MethodName: "GetRoomTreasure", + Handler: _RoomQueryService_GetRoomTreasure_Handler, + }, { MethodName: "ListRoomOnlineUsers", Handler: _RoomQueryService_ListRoomOnlineUsers_Handler, diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index 7ba5a95e..9a7596e8 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -145,6 +145,7 @@ type DebitGiftResponse struct { BalanceAfter int64 `protobuf:"varint,7,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"` ChargeAssetType string `protobuf:"bytes,8,opt,name=charge_asset_type,json=chargeAssetType,proto3" json:"charge_asset_type,omitempty"` ChargeAmount int64 `protobuf:"varint,9,opt,name=charge_amount,json=chargeAmount,proto3" json:"charge_amount,omitempty"` + GiftTypeCode string `protobuf:"bytes,10,opt,name=gift_type_code,json=giftTypeCode,proto3" json:"gift_type_code,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -242,6 +243,13 @@ func (x *DebitGiftResponse) GetChargeAmount() int64 { return 0 } +func (x *DebitGiftResponse) GetGiftTypeCode() string { + if x != nil { + return x.GiftTypeCode + } + return "" +} + // AssetBalance 是用户某类资产的余额投影。 type AssetBalance struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -8858,7 +8866,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "gift_count\x18\x06 \x01(\x05R\tgiftCount\x12#\n" + "\rprice_version\x18\a \x01(\tR\fpriceVersion\x12\x19\n" + "\bapp_code\x18\b \x01(\tR\aappCode\x12\x1b\n" + - "\tregion_id\x18\t \x01(\x03R\bregionId\"\xeb\x02\n" + + "\tregion_id\x18\t \x01(\x03R\bregionId\"\x91\x03\n" + "\x11DebitGiftResponse\x12,\n" + "\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" + "\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" + @@ -8870,7 +8878,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" + "\rprice_version\x18\x06 \x01(\tR\fpriceVersion\x12#\n" + "\rbalance_after\x18\a \x01(\x03R\fbalanceAfter\x12*\n" + "\x11charge_asset_type\x18\b \x01(\tR\x0fchargeAssetType\x12#\n" + - "\rcharge_amount\x18\t \x01(\x03R\fchargeAmount\"\xb2\x01\n" + + "\rcharge_amount\x18\t \x01(\x03R\fchargeAmount\x12$\n" + + "\x0egift_type_code\x18\n" + + " \x01(\tR\fgiftTypeCode\"\xb2\x01\n" + "\fAssetBalance\x12\x1d\n" + "\n" + "asset_type\x18\x01 \x01(\tR\tassetType\x12)\n" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index 9bdb5d10..cdf457a0 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -31,6 +31,7 @@ message DebitGiftResponse { int64 balance_after = 7; string charge_asset_type = 8; int64 charge_amount = 9; + string gift_type_code = 10; } // AssetBalance 是用户某类资产的余额投影。 diff --git a/deploy/README.md b/deploy/README.md index 588ffd52..2ff7642c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -133,6 +133,22 @@ python3 deploy/tencent-tat/validate_rendered_configs.py \ - 腾讯云 Redis 保存 owner route、node registry、登录风控和短 TTL 缓存;Redis 不能作为业务恢复事实来源。 - 腾讯云 MQ 只作为 outbox/event bus 的托管消息通道;创建 topic、consumer group 和权限变更走云产品流程,不混进服务滚动发布。 +当前 RocketMQ 资源约定: + +| Topic | Producer Group | Consumer Group | 说明 | +| --- | --- | --- | --- | +| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-activity-room-outbox` | activity-service 消费房间事实,处理贵重礼物播报、宝箱播报、成长等活动逻辑 | +| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关房间事实 | +| `hyapp_room_outbox` | `hyapp-room-outbox-producer` | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 | +| `hyapp_room_treasure_open` | `hyapp-room-treasure-open-producer` | `hyapp-room-treasure-open` | room-service 消费宝箱延迟开箱唤醒消息 | + +线上推荐配置: + +- `services/room-service/configs/config.tencent.example.yaml` 保持 `outbox_worker.publish_mode: "mq"`。 +- `rocketmq.room_outbox.enabled: true` 后,`room_outbox.status=delivered` 只表示事件已进入 RocketMQ;activity、notice、IM bridge 的成功状态由各自消费位点负责。 +- `rocketmq.treasure_open.enabled: true` 后,宝箱倒计时使用毫秒级 `open_at_ms` 投递延迟消息;消息到达后 room-service 仍要重新校验 Room Cell 状态,不能把 MQ 当成宝箱状态来源。 +- 本地 Docker 默认关闭 MQ,使用 direct publisher 维持无云依赖的可运行形态。 + ## 真实数据验证 本地真实 MySQL/Redis 验证: diff --git a/docs/IM全局与区域广播架构.md b/docs/IM全局与区域广播架构.md index 46596820..59c1ec87 100644 --- a/docs/IM全局与区域广播架构.md +++ b/docs/IM全局与区域广播架构.md @@ -18,8 +18,8 @@ docs/房间Outbox补偿开发.md | Capability | Current Owner | Current Rule | | --- | --- | --- | | IM UserSig | `gateway-service` | `/api/v1/im/usersig` 只签发 `sdk_app_id/user_id/user_sig/expire_at_ms` | -| 房间群建群 | `room-service` | `CreateRoom` 调腾讯 IM REST `create_group`,`GroupID = room_id` | -| 房间系统消息 | `room-service` | Room Cell 命令成功后向 `GroupID = room_id` 发 `room_*` 自定义消息 | +| 房间群建群 | `room-service` | `CreateRoom` 写 `RoomCreated` outbox,worker direct 投递或 IM bridge consumer 从 MQ 消费后调用腾讯 IM REST `create_group`,`GroupID = room_id` | +| 房间系统消息 | `room-service` | Room Cell 命令成功后写 `room_outbox`,worker direct 投递或 IM bridge consumer 从 MQ 消费后向 `GroupID = room_id` 发 `room_*` 自定义消息 | | IM 入群/发言回调 | `gateway-service` | 当前把所有 `GroupId` 都当作 `room_id`,再回查 `room-service` guard | | 系统/活动 inbox | `activity-service` | backend inbox/fanout,非实时 IM 群 | @@ -38,6 +38,7 @@ docs/房间Outbox补偿开发.md | 播报发送能力 | `activity-service` | 服务端向全局/区域播报群发送自定义消息 | | 旧区域群成员移除 | `activity-service` | 用户区域变化后,对旧区域群执行 `delete_group_member(user_id)` | | 贵重礼物播报触发 | `activity-service` consuming room outbox | 基于 `RoomGiftSent` 事实判断阈值并发区域播报 | +| 宝箱倒计时播报触发 | `activity-service` consuming room outbox | 基于 `RoomTreasureCountdownStarted` 事实按后台配置发区域或全局播报 | 本阶段不做: @@ -106,7 +107,7 @@ func Parse(groupID string) ParsedGroup | 红包资金事实 | `wallet-service` 或独立 red-packet domain | IM 只携带入口和展示字段 | | 客户端 IM 长连接 | Tencent IM SDK | 本仓库不自建 WebSocket | -`room-service` 只需要继续产出 `RoomGiftSent` outbox 事实。贵重礼物是否需要区域播报,由 `activity-service` 消费 outbox 后判断,这样不会把区域播报策略塞进 Room Cell 主链路。 +`room-service` 只需要继续产出 `RoomGiftSent`、`RoomTreasureCountdownStarted` 等 outbox 事实。贵重礼物和宝箱倒计时是否需要区域/全局播报,由 `activity-service` 通过 direct gRPC 或 RocketMQ `hyapp_room_outbox` consumer group 消费后判断,这样不会把区域播报策略塞进 Room Cell 主链路。 ## Client Flow @@ -362,6 +363,7 @@ sequenceDiagram participant G as gateway-service participant R as room-service participant W as wallet-service + participant MQ as RocketMQ participant A as activity-service participant IM as Tencent IM @@ -372,11 +374,14 @@ sequenceDiagram R->>R: Room Cell heat/rank + RoomGiftSent outbox R-->>G: SendGiftResponse G-->>C: room result - A->>R: consume room outbox / event stream + R->>MQ: publish hyapp_room_outbox when MQ mode is enabled + MQ->>A: consume RoomGiftSent by hyapp-activity-room-outbox A->>A: threshold decision A->>IM: send_group_msg(region broadcast group) ``` +本地未启用 MQ 时,`room-service` outbox worker 可以 direct gRPC 投递 activity-service;线上推荐使用 RocketMQ,activity-service 不需要回查 room-service 主链路。 + 阈值策略属于播报配置,不属于 Room Cell: | Config | Meaning | @@ -399,6 +404,54 @@ sequenceDiagram 如果现有事件字段不足,应优先扩展 protobuf event,而不是让 activity-service 反查多个服务拼凑事实。 +## Room Treasure Broadcast + +宝箱满能量进入倒计时时,Room Cell 写 `RoomTreasureCountdownStarted` outbox。activity-service 消费该事实后按后台配置决定发区域播报还是全局播报;开箱到点、奖励结算和宝箱状态推进仍由 room-service 负责。 + +```mermaid +sequenceDiagram + participant C as Client + participant G as gateway-service + participant R as room-service + participant W as wallet-service + participant MQ as RocketMQ + participant A as activity-service + participant IM as Tencent IM + + C->>G: POST /api/v1/rooms/gift/send + G->>R: SendGift + R->>W: DebitGift + W-->>R: receipt + heat/gift point + R->>R: add effective treasure energy + R->>R: status=countdown when threshold reached + R-->>G: SendGiftResponse + R->>MQ: RoomTreasureCountdownStarted + MQ->>A: consume by hyapp-activity-room-outbox + A->>IM: send_group_msg(region/global broadcast group) +``` + +播报 payload 的核心字段: + +```json +{ + "event_id": "room_treasure:lalu_room_xxx:box_1", + "broadcast_type": "room_treasure", + "scope": "region", + "app_code": "lalu", + "region_id": 1001, + "room_id": "lalu_room_xxx", + "box_id": "box_1", + "level": 3, + "open_at_ms": 1770000000000, + "action": { + "type": "enter_room", + "room_id": "lalu_room_xxx" + } +} +``` + +倒计时期间继续送礼不会累加到下一宝箱,溢出能量也不会跨级;这些规则由 room-service 在 `RoomTreasureProgressChanged` 和 `RoomTreasureCountdownStarted` 事实中表达,activity-service 不能自行推导宝箱进度。 + ## Red Packet Broadcast 红包资金事实必须由 wallet/red-packet 领域持久化: @@ -443,7 +496,8 @@ sequenceDiagram 5. `activity-service` 增加 broadcast group reconciler,启动时确保全局和 active 区域群。 6. `activity-service` 增加 `PublishRegionBroadcast/PublishGlobalBroadcast` 和 broadcast outbox。 7. 扩展 `RoomGiftSent` 事件字段,activity-service 消费后按阈值发 `super_gift` 区域播报。 -8. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。 +8. 消费 `RoomTreasureCountdownStarted`,按后台配置发 `room_treasure` 区域或全局播报。 +9. 红包领域落账务事实后,复用同一播报能力发送 `red_packet` 消息。 ## Test Expectations @@ -459,4 +513,5 @@ sequenceDiagram | group reconciler | 已存在群视为成功,失败可重试 | | broadcast outbox | 同一 `event_id` 幂等,失败后 retry | | super gift | 未达阈值不播报,达阈值发送到用户房间所在区域群 | +| room treasure | 宝箱满能量后只按 `RoomTreasureCountdownStarted` 播报一次;区域/全局 scope 来自后台配置 | | red packet | IM payload 不影响领取接口的后端金额校验 | diff --git a/docs/notice-service架构.md b/docs/notice-service架构.md index cda4d362..097a028e 100644 --- a/docs/notice-service架构.md +++ b/docs/notice-service架构.md @@ -10,7 +10,8 @@ - `notice-service` 抢占该事件,写自己的 `notice_delivery_events` 投递位点。 - `notice-service` 通过腾讯云 IM C2C `TIMCustomElem` 给用户发送 `wallet_notice`。 - 客户端收到通知后按 `asset_type + balance_version` 更新本地余额;乱序或重复事件直接丢弃。 -- `room-service` 写 `room_outbox.RoomUserKicked` 后,`notice-service` 只给被踢用户发送 `room_notice` C2C 私有通知;房间群系统消息和 IM 群成员移除仍由 room-service 的房间 IM bridge 负责。 +- `room-service` 写 `room_outbox.RoomUserKicked` 后,`notice-service` 可通过 MySQL 扫描或 RocketMQ `hyapp_room_outbox` fanout 消费该事实,只给被踢用户发送 `room_notice` C2C 私有通知;房间群系统消息和 IM 群成员移除仍由 room-service 的房间 IM bridge 负责。 +- RocketMQ 模式下 `notice-service` 不反写 `room_outbox.status`,只写 `notice_delivery_events`;room outbox 的发布状态属于 room-service,下游通知投递状态属于 notice-service。 ## 服务边界 @@ -26,7 +27,7 @@ 房间 IM 被拆成两种通道: -- 房间群通道:`RoomCreated` 建群、`RoomUserKicked` 移除群成员、`room_user_kicked` 群系统消息由 room-service outbox worker 投递。它依赖房间群生命周期和群成员控制,不进入 notice-service。 +- 房间群通道:`RoomCreated` 建群、`RoomUserKicked` 移除群成员、`room_user_kicked` 群系统消息由 room-service outbox worker direct 投递,或由 room-service 的 `hyapp-room-im-bridge` consumer group 从 `hyapp_room_outbox` 消费后投递。它依赖房间群生命周期和群成员控制,不进入 notice-service。 - 用户私有通道:被踢本人可能已经先被移出 IM 群,不能保证收到群消息;因此 notice-service 监听同一条 `RoomUserKicked` 事实,发送 C2C `room_notice` 给 `target_user_id`。 ## 当前目录结构 @@ -91,6 +92,7 @@ sequenceDiagram participant Gateway as gateway-service participant Room as room-service participant RoomDB as hyapp_room + participant RocketMQ as RocketMQ participant Notice as notice-service participant NoticeDB as hyapp_notice participant TencentIM as Tencent IM @@ -101,15 +103,24 @@ sequenceDiagram Room->>Room: remove presence, release mic, write ban Room->>RoomDB: command log + snapshot + room_outbox.RoomUserKicked Room-->>Gateway: success - Room-->>TencentIM: room outbox worker deletes group member and sends group event - Notice->>RoomDB: claim RoomUserKicked + Room-->>TencentIM: direct/IM bridge deletes group member and sends group event + Room->>RocketMQ: publish hyapp_room_outbox when MQ enabled + Notice->>RocketMQ: consume RoomUserKicked by hyapp-notice-room-outbox + Notice->>RoomDB: or claim RoomUserKicked by MySQL polling Notice->>NoticeDB: notice_delivery_events=delivering Notice->>TencentIM: C2C TIMCustomElem(room_notice) TencentIM-->>Target: private kicked notice Notice->>NoticeDB: delivered / retryable / failed ``` -房间群消息和 C2C 私有通知使用同一个 `event_id`,客户端按 `event_id` 去重即可。notice-service 不修改 `room_outbox.status`,它只用 `notice_delivery_events` 记录自己的 C2C 投递状态,避免和 room-service 群消息 worker 互相覆盖。 +房间群消息和 C2C 私有通知使用同一个 `event_id`,客户端按 `event_id` 去重即可。notice-service 不修改 `room_outbox.status`,它只用 `notice_delivery_events` 记录自己的 C2C 投递状态,避免和 room-service outbox worker 或 IM bridge consumer 互相覆盖。 + +MQ 和 MySQL 扫描是同一事实的两种入口: + +| 入口 | 使用场景 | 幂等边界 | +| --- | --- | --- | +| MySQL `room_notice_worker` | 本地或未接 MQ 的环境,直接从 `hyapp_room.room_outbox` claim `RoomUserKicked` | `notice_delivery_events(source_name, app_code, source_event_id, channel)` | +| RocketMQ `hyapp_room_outbox` | 线上推荐,notice 使用独立 consumer group `hyapp-notice-room-outbox` 解耦消费 | 同一张 `notice_delivery_events`,重复 MQ 消息不会重复投 C2C | ## Payload 约定 @@ -172,6 +183,13 @@ wallet_notice_worker: room_notice_worker: enabled: false + +rocketmq: + enabled: false + room_outbox: + enabled: false + topic: "hyapp_room_outbox" + consumer_group: "hyapp-notice-room-outbox" ``` 线上启用私有通知必须同时开启腾讯云 IM,并配置对应 owner outbox 所在库名: @@ -199,18 +217,33 @@ room_notice_worker: batch_size: 100 lock_ttl: 30s max_retry_count: 10 + +rocketmq: + enabled: true + name_servers: + - "TENCENT_ROCKETMQ_NAMESERVER:9876" + access_key: "TENCENT_ROCKETMQ_ACCESS_KEY" + secret_key: "TENCENT_ROCKETMQ_SECRET_KEY" + room_outbox: + enabled: true + topic: "hyapp_room_outbox" + consumer_group: "hyapp-notice-room-outbox" + consumer_max_reconsume_times: 16 ``` `notice-service` 使用 `13009` 作为 gRPC health 端口,`13109` 作为 HTTP health 端口。 +启用 MQ 消费后,可以按部署阶段选择是否保留 MySQL 扫描 worker。两者同时开启时不会重复发通知,因为 `notice_delivery_events` 的主键会挡住同一 `event_id + channel` 的重复投递;长期线上推荐以 MQ consumer 作为实时入口,MySQL 扫描只作为补偿或独立验证工具。 + ## 后续扩展顺序 1. 钱包余额通知:已按 `walletnotice` 模块落地。 2. 房间踢人私有通知:已按 `roomnotice` 模块落地,只消费 `RoomUserKicked` 的 C2C 通知。 3. 幸运礼物中奖:仍由 `wallet-service` 写余额 outbox;notice 不需要新增特殊账务逻辑,只要客户端按 `balance_version` 更新。 -4. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox,复用 `notice_delivery_events` 或新增同语义位点表。 -5. 站内 inbox:新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。 -6. Push:新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。 +4. 语音房宝箱私有通知:如产品需要私聊提醒,可在 `roomnotice` 中消费 `RoomTreasureRewardGranted` 或由活动/系统消息模块转发;奖励入账事实仍由 room-service 调 wallet 完成,notice 只投通知。 +5. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox,复用 `notice_delivery_events` 或新增同语义位点表。 +6. 站内 inbox:新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。 +7. Push:新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。 ## 真实库验证 diff --git a/docs/定时任务服务架构.md b/docs/定时任务服务架构.md index e4f1417b..22616dbd 100644 --- a/docs/定时任务服务架构.md +++ b/docs/定时任务服务架构.md @@ -17,7 +17,8 @@ | --- | --- | --- | --- | | `room-service` | stale presence cleanup | `services/room-service/internal/room/service/presence.go` | 留在 `room-service` | | `room-service` | mic publish timeout cleanup | `services/room-service/internal/room/service/mic_publish_timeout.go` | 留在 `room-service` | -| `room-service` | room outbox delivery to Tencent IM | `services/room-service/internal/room/service/outbox_worker.go` | 留在 `room-service` 或后续拆 `room-worker`,不进通用 cron | +| `room-service` | room outbox relay to direct publishers / RocketMQ | `services/room-service/internal/room/service/outbox_worker.go` | 留在 `room-service` 或后续拆 `room-worker`,不进通用 cron | +| `room-service` | room treasure delayed open wakeup | `services/room-service/internal/room/service/room_treasure.go`、`services/room-service/internal/room/service/room_treasure_mq.go` | 留在 `room-service`;RocketMQ 只唤醒,到点后仍走 Room Cell 命令链路 | | `user-service` | region rebuild task | `UserCronService.ProcessRegionRebuildBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 | | `user-service` | login IP risk job | `UserCronService.ProcessLoginIPRiskBatch` | 已迁移到 cron-service 调度,旧内置 ticker 已删除 | | `user-service` | invite recharge wallet outbox consumer | `services/user-service/internal/service/invite/service.go` | 先不迁,需补 durable cursor 或事件总线 | @@ -93,7 +94,8 @@ graph LR | --- | --- | | Room Cell stale presence cleanup | 依赖本节点已装载 Room Cell 和 Redis lease,必须由 `room-service` owner 执行命令链路 | | Mic publish timeout cleanup | 必须对当前 Room Cell 状态做二次校验并执行 `MicDown`,不能让 cron 直接清麦 | -| room outbox delivery | 这是 room-service 的事件投递补偿,不是通用定时任务;需要和 Tencent IM bridge、outbox 状态语义绑定 | +| room outbox relay | 这是 room-service 的事件投递补偿,不是通用定时任务;需要和 direct publisher、RocketMQ 发布、outbox 状态语义绑定 | +| room treasure open timer | 宝箱状态属于 Room Cell;延迟消息只负责唤醒,开箱必须由 room-service 重新校验 `box_id/open_at_ms/UTC reset` 后执行命令 | | wallet ledger transaction | 金币、钻石、积分、余额的加减和冻结必须在 `wallet-service` 事务内完成 | | wallet outbox publish | 属于 wallet-service outbox relay 或 MQ 投递,不应该由通用 cron 直接扫表改状态 | | invite recharge outbox consumer | 当前使用 in-memory cursor 扫 wallet outbox,迁移前必须先做 durable offset 或正式事件总线 | @@ -265,8 +267,11 @@ cron-service 可以直接开启多个实例;调度层通过 `cron_task_leases` - room mic outbox consumer。 - wallet outbox relay。 - activity room outbox consumer。 +- notice room outbox consumer。 +- room IM bridge MQ consumer。 +- room treasure delayed open consumer。 -这些需要的是事件消费基础设施:MQ、Redis Stream、或 MySQL durable offset。cron-service 不能用“每隔几秒从头扫 outbox”的方式替代事件消费者。 +这些需要的是事件消费基础设施:RocketMQ、Redis Stream、或 MySQL durable offset。cron-service 不能用“每隔几秒从头扫 outbox”的方式替代事件消费者。即使某些消费者内部有低频扫描补偿,也必须留在业务 owner service 中,因为它们需要理解 outbox 状态、消费幂等和领域状态校验。 ### Future Scheduled Business Jobs diff --git a/docs/房间Outbox补偿开发.md b/docs/房间Outbox补偿开发.md index 67c41d6b..9c76db54 100644 --- a/docs/房间Outbox补偿开发.md +++ b/docs/房间Outbox补偿开发.md @@ -1,32 +1,32 @@ # Room Outbox Worker -本文档记录当前 `room-service` outbox 的实际语义。房间写命令只负责提交 Room Cell 状态、command log、snapshot 和 outbox;腾讯云 IM、activity-service 等外部投递全部由 outbox worker 异步执行。 +本文档定义当前 `room-service` outbox 的实际语义。房间写命令只负责提交 Room Cell 状态、command log、snapshot 和 `room_outbox`;腾讯云 IM、activity-service、notice-service、后续 push/inbox 等外部副作用都通过 outbox worker 或 RocketMQ 下游 consumer 异步完成。 -## 边界 +## 核心结论 | 项目 | 当前规则 | | --- | --- | -| 主链路 | 不同步调用腾讯云 IM,不同步投递 activity-service | -| 持久化 | 命令变更、command log、room_outbox 在同一提交链路内完成 | -| worker | 读取 `room_outbox`,投递到配置的 outbox publisher fanout | -| 腾讯云 IM | `RoomCreated` 由 worker 确保群组存在,其他房间事件由 worker 发送群系统消息 | -| activity-service | 由 room outbox worker 异步投递,失败进入 room_outbox 重试 | -| 幂等 | 房间命令靠 `command_id`,outbox 投递靠 `event_id` | +| 状态 owner | Room Cell 和 MySQL snapshot/command log 是房间状态权威来源 | +| 持久事实 | `room_outbox` 是房间外事件的持久事实源,和房间命令提交链路一起落 MySQL | +| 发布模式 | `outbox_worker.publish_mode` 支持 `direct`、`mq`、`dual` | +| MQ 职责 | RocketMQ 只做事件分发和宝箱延迟唤醒,不保存房间核心状态 | +| 下游语义 | 下游服务按 `event_id` 幂等消费,失败由自己的位点、重试和死信处理 | +| 主链路 | HTTP/gRPC 请求不等待 IM REST、activity、notice、push 或 inbox 投递完成 | 不做的事: | 不做 | 原因 | | --- | --- | -| 在 HTTP/gRPC 请求里等待 IM REST | 外部慢调用会拖住房间串行命令队列 | -| 在 Room Cell 中保存投递状态 | 投递状态属于 outbox,不属于房间权威状态 | -| 直接用 Redis 做 outbox | MySQL 是恢复和补偿的持久来源 | -| 精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once,消费端必须按 `event_id` 去重 | +| 在 Room Cell 中保存投递状态 | 投递状态属于 outbox 或下游消费位点,不属于房间权威状态 | +| 用 Redis 替代 outbox | Redis 只保存热状态和短 TTL lease,不能作为恢复事实来源 | +| 用 RocketMQ 替代 `room_outbox` | 房间状态提交成功但 MQ 发布失败时,必须能从 MySQL 补偿发布 | +| 追求精确一次投递 | 外部系统和进程崩溃窗口只能做到 at-least-once,消费者必须按 `event_id` 去重 | -## 写命令语义 +## 写命令链路 ```text gateway -> room-service command - -> idempotency check + -> command_id idempotency check -> ensure Redis lease and Room Cell -> mutate cloned RoomState -> optional wallet debit before room gift state is committed @@ -37,11 +37,25 @@ gateway -> room-service command outbox worker -> claim pending/retryable rows - -> publish to composite outbox publisher + -> if RoomTreasureCountdownStarted, schedule treasure open delayed wakeup when configured + -> publish by mode: + direct: Tencent IM publisher + activity gRPC publisher + mq: RocketMQ room_outbox publisher + dual: direct publishers + RocketMQ publisher -> delivered / retryable / failed ``` -主链路成功只表示房间状态已经落库;不表示腾讯云 IM 或 activity-service 已经完成投递。客户端实时表现应以房间响应、房间 snapshot 和 IM 系统消息共同补偿。 +主请求成功只表示房间状态已经提交;不表示腾讯云 IM、activity-service、notice-service 或任何后续通知已经投递完成。客户端实时表现应以请求响应、房间 snapshot 和腾讯云 IM 系统消息共同补偿。 + +## 发布模式 + +| `publish_mode` | 行为 | 适用场景 | +| --- | --- | --- | +| `direct` | worker 直接调用腾讯云 IM publisher 和 activity gRPC publisher;所有 direct publisher 成功后才标记 `delivered` | 本地开发、未接 MQ 的隔离测试 | +| `mq` | worker 只把 `room_outbox_event` 发布到 `hyapp_room_outbox`;RocketMQ 接收成功后标记 `delivered` | 线上推荐模式,activity、notice、IM bridge 各自 consumer group 解耦消费 | +| `dual` | 同时 direct 投递和 MQ 发布,全部成功后标记 `delivered` | 迁移或灰度期;要求所有消费端都能按 `event_id` 去重 | + +在 `mq` 模式下,`room_outbox.status=delivered` 的含义是“事件已经可靠进入 RocketMQ”,不是“所有下游都已处理成功”。下游的成功、重试和死信由各自服务的消费位点负责,不能反写 `room_outbox.status`。 ## 数据状态 @@ -51,21 +65,54 @@ outbox worker | --- | --- | | `pending` | 已落库,尚未被 worker 处理 | | `delivering` | 已被 worker 抢占,`lock_until_ms` 前其他 worker 不抢 | -| `delivered` | 所有配置的 outbox publisher 已处理成功 | -| `retryable` | 上次投递失败,等待 `next_retry_at_ms` 后重试 | -| `failed` | 达到最大重试次数,进入死信,需要人工处理或后台工具重放 | +| `delivered` | 当前发布模式要求的 publisher 已处理成功;MQ 模式下表示 RocketMQ 已接收 | +| `retryable` | 上次发布失败,等待 `next_retry_at_ms` 后重试 | +| `failed` | 达到最大重试次数,进入死信,需要后台工具查看或重放 | 旧数据里可能存在历史 `published` 状态。当前 worker 不再写这个状态,新链路只写上表状态。 +## RocketMQ Topics + +| Topic | Producer | Consumer Group | 语义 | +| --- | --- | --- | --- | +| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-activity-room-outbox` | activity-service 消费房间事实,做活动、播报、系统消息等后续处理 | +| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-notice-room-outbox` | notice-service 消费私有通知相关事实,例如 `RoomUserKicked` | +| `hyapp_room_outbox` | `room-service` outbox worker | `hyapp-room-im-bridge` | room-service IM bridge 消费房间群系统消息和群成员控制事实 | +| `hyapp_room_treasure_open` | `room-service` treasure scheduler | `hyapp-room-treasure-open` | 宝箱倒计时到点唤醒 Room Cell 开箱命令 | + +`hyapp_room_treasure_open` 不是业务状态源。消息只携带 `app_code、room_id、box_id、level、open_at_ms、command_id` 等唤醒参数;真正能否开箱仍由 room-service 加载 Room Cell 后校验当前宝箱状态、等级、`box_id`、`open_at_ms` 和 UTC 重置边界。 + ## 配置 -`services/room-service/configs/*.yaml` 当前配置: +`services/room-service/configs/*.yaml` 需要同时维护本地、Docker 和腾讯云示例配置: ```yaml -snapshot_every_n: 10 +rocketmq: + enabled: false + name_servers: [] + name_server_domain: "" + access_key: "" + secret_key: "" + namespace: "" + send_timeout: "3s" + retry: 2 + room_outbox: + enabled: false + topic: "hyapp_room_outbox" + producer_group: "hyapp-room-outbox-producer" + tencent_im_consumer_enabled: false + tencent_im_consumer_group: "hyapp-room-im-bridge" + consumer_max_reconsume_times: 16 + treasure_open: + enabled: false + topic: "hyapp_room_treasure_open" + producer_group: "hyapp-room-treasure-open-producer" + consumer_group: "hyapp-room-treasure-open" + consumer_max_reconsume_times: 16 outbox_worker: enabled: true + publish_mode: "direct" poll_interval: "1s" batch_size: 100 publish_timeout: "3s" @@ -79,18 +126,33 @@ outbox_worker: | 字段 | 规则 | | --- | --- | -| `snapshot_every_n` | 开发和生产默认 `10`;命令日志仍保证恢复,不能依赖每条命令快照 | +| `rocketmq.enabled` | 控制 RocketMQ client 是否启用;本地默认关闭,线上示例开启 | +| `rocketmq.room_outbox.enabled` | 控制 room outbox 是否可发布到 MQ;`publish_mode=mq/dual` 时必须开启 | +| `rocketmq.room_outbox.tencent_im_consumer_enabled` | 控制 room-service 是否作为 IM bridge consumer 消费 `hyapp_room_outbox` | +| `rocketmq.treasure_open.enabled` | 控制宝箱倒计时是否发布和消费延迟唤醒消息 | +| `outbox_worker.publish_mode` | 本地默认 `direct`,线上建议 `mq`,迁移期可用 `dual` | | `poll_interval` | worker 空轮询间隔,必须大于 0,避免 busy loop | | `batch_size` | 每轮最大抢占条数,必须大于 0,避免全表扫描 | -| `publish_timeout` | 单条 outbox 投递 deadline,避免外部系统拖死 worker | -| `retry_strategy` | 当前为 `exponential_backoff` | -| `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久污染日志 | -| `initial_backoff` | 第一次失败后的最小退避 | -| `max_backoff` | 指数退避上限 | +| `publish_timeout` | 单条 outbox 发布 deadline,避免外部系统拖死 worker | +| `max_retry_count` | 达到次数后标记 `failed`,防止 poison event 永久刷日志 | + +## 投递对象 + +| Publisher / Consumer | 行为 | +| --- | --- | +| RocketMQ room outbox publisher | 把 protobuf outbox 包装成统一 `room_outbox_event`,发布到 `hyapp_room_outbox` | +| Tencent IM direct publisher | `RoomCreated` 确保群存在;房间系统事件发送腾讯云 IM 群自定义消息 | +| Activity direct publisher | 本地或未接 MQ 时直接调用 activity-service room event 消费入口 | +| Treasure open scheduler | 在 `RoomTreasureCountdownStarted` 后发布到点延迟消息,唤醒 room-service 开箱 | +| Activity MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,按 `event_id` 幂等推进活动/播报逻辑 | +| Notice MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,写 `notice_delivery_events` 后投 C2C 私有通知 | +| Room IM bridge MQ consumer | 独立 consumer group 消费 `hyapp_room_outbox`,投递房间群系统消息和群成员控制 | + +同一个事件允许被多个 consumer group 消费。任何 consumer 失败都不能阻塞 Room Cell 主链路,也不能影响其它 consumer group 的进度。 ## 重试和死信 -失败处理: +发布失败处理: ```text publish failed @@ -101,16 +163,7 @@ publish failed `failed` 不再自动重试。后台应提供人工查看和重放工具,重放时必须保留原 `event_id` 或生成明确的新修复事件,避免消费端无法去重。 -## 投递对象 - -当前 `CompositeOutboxPublisher` fanout 到多个 publisher: - -| Publisher | 行为 | -| --- | --- | -| Tencent IM | `RoomCreated` 确保群存在;房间系统事件发送腾讯云 IM 群自定义消息 | -| Activity | 将 room outbox 事件投递给 `activity-service` 的消费入口 | - -fanout 要求所有 publisher 成功后才标记 `delivered`。任一失败都会进入重试,因此消费端必须幂等处理重复事件。 +RocketMQ consumer 失败时不修改 `room_outbox`。consumer 应依赖 RocketMQ 重投、自己的幂等表和死信处理;对于已经执行业务副作用但 ACK 失败的窗口,下一次消费必须按 `event_id` 安全去重。 ## 阶段耗时日志 @@ -133,12 +186,13 @@ fanout 要求所有 publisher 成功后才标记 `delivered`。任一失败都 ## 验收 -必须满足: - | 场景 | 验收 | | --- | --- | -| IM 不可用 | 房间写命令仍成功,outbox 进入 `retryable` 或最终 `failed` | -| activity 不可用 | 房间写命令仍成功,outbox 进入重试 | +| IM 不可用,`direct/dual` | 房间写命令仍成功,outbox 进入 `retryable` 或最终 `failed` | +| activity 不可用,`direct/dual` | 房间写命令仍成功,outbox 进入重试 | +| MQ 不可用,`mq/dual` | 房间写命令仍成功,outbox 进入重试;MQ 恢复后补发 | +| activity consumer 不可用,`mq` | `room_outbox` 不回退;RocketMQ 和 activity 消费位点负责积压与重试 | | poison event | 达到 `max_retry_count` 后进入 `failed`,不再每秒刷日志 | -| 正常投递 | 同房间事件最终全部 `delivered` | +| 宝箱倒计时 | `RoomTreasureCountdownStarted` 发布延迟唤醒;到点后 room-service 重新校验状态再开箱 | +| 正常投递 | 同一 `event_id` 可被多个 consumer group 独立消费且重复投递不产生重复副作用 | | 真实链路 | create/join/mic/sendGift/leave 均有 `room_command_timing` | diff --git a/docs/语音房业务路线图.md b/docs/语音房业务路线图.md index dfc6b51e..a88b8399 100644 --- a/docs/语音房业务路线图.md +++ b/docs/语音房业务路线图.md @@ -81,7 +81,7 @@ | 礼物配置 | 礼物 ID、价格、热度、表现可配置 | | SendGift | 扣费成功后进入 Room Cell | | 房间表现 | 热度、本地榜、礼物消息同步变化 | -| outbox | activity/audit 后续可消费完整事件 | +| outbox | activity、notice、IM bridge 可通过 direct 或 RocketMQ 消费完整房间事件 | 验收:用户送普通礼物后金币减少、房间热度增加、榜单变化、消息广播成功。 diff --git a/docs/语音房功能清单.md b/docs/语音房功能清单.md index f46474d2..840c8033 100644 --- a/docs/语音房功能清单.md +++ b/docs/语音房功能清单.md @@ -135,14 +135,15 @@ | 本地礼物榜 | `DONE` | `LocalRank` 和 `gift_rank` 快照 | 全局榜、跨房榜、榜单重置未做 | | 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格和 `COIN`/`DIAMOND` 收费资产,`gift_configs` 提供礼物类型、有效期和特效;SendGift 请求只传 `gift_id/gift_count`;礼物配置已绑定 `resource_type=gift` 资源 | 客户端礼物表现联调、资源版本缓存刷新未做 | | 背包/道具 | `PARTIAL` | `user_resource_entitlements`、`user_resource_equipment` 支持资源发放、我的资源和可佩戴资源装备 | 背包扣减、免费礼物、道具过期扫描未做 | -| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox,由 worker 异步投 IM/activity | 客户端去重和展示策略未验收 | +| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox,由 worker direct 投 IM/activity,或发布到 RocketMQ 后由各 consumer group 消费 | 客户端去重和展示策略未验收 | +| 语音房宝箱 | `DONE` | `room_treasure_configs`、`GetRoomTreasure`、`RoomTreasureProgressChanged/CountdownStarted/Opened/RewardGranted`,开箱支持本地扫描和 RocketMQ 延迟唤醒 | App UI、后台配置运营验收和真实 MQ 联调 | ## Activity And Lucky Gift | Feature | Status | Current Evidence | Remaining Work | | --- | --- | --- | --- | | activity-service 底座 | `PARTIAL` | gRPC Ping/GetActivityStatus、MySQL activity/outbox 表存在 | 具体活动规则和奖励结算未实现 | -| room outbox 消费边界 | `DONE` | room outbox worker 异步投递 activity-service,失败跟随 room_outbox 重试/死信 | activity 内部消费幂等表和后台排障页需补 | +| room outbox 消费边界 | `DONE` | 本地 direct 投递 activity-service;线上可发布 `hyapp_room_outbox`,activity 使用 `hyapp-activity-room-outbox` 独立消费位点 | 后台排障页和消费积压告警需补 | | 幸运礼物开发文档 | `DONE` | `docs/幸运礼物Activity服务开发.md` | 按文档实现 proto、pool、risk、draw | | 活动配置 | `TODO` | 只有 activity status projection | 活动规则、时间、奖池、风控配置未实现 | | LuckyGift Check | `TODO` | 文档有设计,proto/code 未实现 | `CheckLuckyGift` RPC 和规则校验 | @@ -235,7 +236,7 @@ | Task | Status | Acceptance | | --- | --- | --- | -| room outbox -> activity consumer | `TODO` | activity-service 幂等消费 RoomGiftSent | +| LuckyGift event handler | `TODO` | activity-service 在现有 RoomGiftSent 消费入口上增加幸运礼物 Check/Draw | | LuckyGift proto 和 service | `TODO` | CheckLuckyGift/ExecuteLuckyGiftDraw 可用 | | 三层奖池和风控 | `TODO` | 平台、房间、礼物奖池不会透支 | | 奖励入账 | `TODO` | wallet 支持 CreditLuckyReward,失败进入 activity outbox | diff --git a/docs/语音房宝箱功能架构.md b/docs/语音房宝箱功能架构.md index 826f2bc0..197d182e 100644 --- a/docs/语音房宝箱功能架构.md +++ b/docs/语音房宝箱功能架构.md @@ -1,138 +1,142 @@ # 语音房宝箱功能架构 -本文定义语音房宝箱的产品规则、服务边界、App 接口、IM 事件、后台配置、奖励结算和验收边界。宝箱是房间内送礼驱动的互动玩法:用户在语音房送礼积攒能量,当前等级宝箱能量满后进入倒计时,倒计时结束时按房间快照抽奖并发放奖励。所有周期、重置和统计口径统一使用 UTC。 +本文定义当前语音房宝箱的产品规则、服务边界、App 接口、后台配置、MQ 唤醒、room outbox 事件和验收边界。宝箱是房间内送礼驱动的互动玩法:用户在语音房送礼积攒能量,当前等级满能量后进入倒计时,倒计时结束时由 `room-service` 按 Room Cell 快照结算并调用 `wallet-service` 发放资源组奖励。所有周期、重置和统计口径统一使用 UTC。 -## Goals +## 当前实现结论 -| 目标 | 规则 | +| 能力 | 当前事实 | | --- | --- | -| 礼物积攒能量 | 只有 `SendGift` 扣费成功并落 Room Cell 后才增加宝箱能量 | -| 7 级宝箱 | 每个 UTC 自然日从 1 级开始,最多开启 7 个等级 | -| 满能量倒计时开启 | 能量达到当前等级阈值后锁定本轮,按后台 `open_delay_ms` 设置 `open_at_ms` | -| 满能量播报 | 满能量后立即或按后台延迟发起播报,播报范围支持 `none/region/global` | -| 开箱抽奖 | `open_at_ms` 到达后快照房间在线用户、点火人和贡献第一,生成奖励结算事实 | -| 自动发奖 | 奖励由 activity-service 幂等抽奖并调用 wallet-service 发放资源组,不需要 App 手动领取 | -| UTC 重置 | 进度、贡献和已开启等级按 UTC 日期重置,不使用服务器本地时区或客户端时区 | +| App 初始化 | `GET /api/v1/rooms/{room_id}/treasure` 返回 7 级物料、当前等级、当前进度、重置时间、奖励展示配置 | +| 送礼累积 | `SendGift` 同步调用 `wallet-service.DebitGift` 成功后,Room Cell 内更新热度、礼物榜和宝箱进度 | +| 满能量 | 当前等级进度封顶为阈值,锁定点火人、贡献第一、`box_id`、`open_at_ms` 和 UTC `reset_at_ms` | +| 溢出和倒计时送礼 | 当前等级溢出能量作废;倒计时期间送礼不累加到下一级,也不改变点火人或贡献第一 | +| 到期开箱 | `room-service` 通过 RocketMQ 延迟消息到 `open_at_ms` 唤醒;本地已加载 Cell 扫描 worker 仍作为兜底 | +| 奖励结算 | `room-service` 在开箱命令内按配置加权抽取奖励,并调用 `wallet-service.GrantResourceGroup` 幂等发放 | +| 离房/下线 | `top1` 和 `igniter` 在倒计时开始时锁定;开箱前离房或下线仍结算对应角色奖励 | +| 在房奖励 | 只发给开箱命令执行时 Room Cell `OnlineUsers` 中的用户 | +| 播报 | `activity-service` 消费 `RoomTreasureCountdownStarted`,按 `broadcast_scope` 发区域或全局播报 | +| 事件分发 | `room_outbox` 先落 MySQL,再由 outbox worker 发布 RocketMQ;activity、notice、IM bridge 各自 consumer group 消费 | ## Non-Goals | 不做 | 原因 | | --- | --- | -| 客户端提交能量、奖励或中奖结果 | 能量和奖励必须来自服务端扣费、配置和随机结果 | -| 在 room-service 保存钱包余额或发奖账本 | 钱包资产和资源发放仍由 wallet-service 拥有 | -| 由 activity-service 判断谁在房 | 房间 presence 和开箱快照必须由 Room Cell 产出 | -| 只用 Redis 保存宝箱进度 | MySQL snapshot/command log/宝箱表才是恢复来源 | -| 宝箱发奖失败时提前展示已到账 | 客户端只能展示 wallet-service 已成功发放的奖励 | +| 客户端提交能量、奖励或中奖结果 | 能量和奖励必须来自服务端扣费、配置和确定性抽奖 | +| 只用 Redis 保存宝箱进度 | MySQL snapshot、command log 和 `room_outbox` 才是恢复来源 | +| activity-service 判断谁在房 | 房间 presence 和开箱快照必须由 Room Cell 产出 | +| activity-service 发放宝箱奖励 | 当前发奖在 `room-service` 开箱命令内调用 `wallet-service`,避免跨服务二次改写房间结算事实 | +| cron-service 到点开箱 | 到点唤醒属于房间事件消费和 Room Cell 命令触发,不是通用 cron 任务 | +| 倒计时期间继续排队能量 | 产品规则明确无效,不累加到下一个宝箱 | -## Missing Interfaces - -你的描述已经覆盖了核心 App 查询和 3 类 IM,但还缺这些接口和后台能力。 - -| 类型 | 接口 | 必要性 | -| --- | --- | --- | -| App 查询 | `GET /api/v1/rooms/{room_id}/treasure-chest` | 房间页主动同步宝箱配置、当前进度、倒计时、重置时间和最近结算 | -| App 查询 | `GET /api/v1/rooms/{room_id}/treasure-chest/rewards?round_id=...&scope=mine\|public` | 用户重连或错过 IM 后查询自己奖励、公开奖励摘要和发奖状态 | -| App 查询 | `JoinRoom` / `RoomDetail` 可内嵌 `treasure_chest_summary` | 避免进房首屏先空白,再等单独接口返回 | -| App 命令响应 | `SendGiftResponse` 增加 `treasure_chest_delta` | 送礼人成功送礼后立即更新能量和倒计时,不只依赖异步 IM | -| Admin 配置 | `GET/PUT /api/v1/admin/room-treasure/config` | 配置开关、7 级阈值、物料、倒计时、播报范围、奖励池和生效版本 | -| Admin 审计 | `GET /api/v1/admin/room-treasure/rounds` | 排查某房某天宝箱进度、点火人、贡献第一和状态 | -| Admin 审计 | `GET /api/v1/admin/room-treasure/reward-records` | 查询用户中奖、发奖、失败和重试记录 | -| Admin 补偿 | `POST /api/v1/admin/room-treasure/rounds/{round_id}/retry-settlement` | 对抽奖或 wallet 发放失败的轮次做幂等补偿 | -| Internal room | `ProcessDueTreasureChests` | cron 或 room-service worker 触发到期开箱,不能依赖单机内存 timer | -| Internal room | `ApplyTreasureChestSettlement` | activity-service 发奖完成后回写结算摘要,由 room-service 发房间发奖 IM | -| Internal activity | `ExecuteTreasureChestSettlement` 或消费 `RoomTreasureChestOpened` outbox | 按 Room Cell 快照做抽奖、写奖励记录、调用 wallet 发奖 | -| Internal wallet | 复用 `GrantResourceGroup`,后续补 `BatchGrantResourceGroup` | 每个在房用户都可能获奖,首版可逐个幂等发放,大房间需要批量优化 | - -如果坚持“所有奖励完全自动到账”,不需要 App `claim` 接口;如果产品改成手动领取,必须新增 `POST /api/v1/rooms/{room_id}/treasure-chest/rewards/{reward_id}/claim`,并重新定义过期和未领取回收规则。 - -## Ownership +## 服务边界 | 模块 | 拥有 | 不拥有 | | --- | --- | --- | -| `room-service` | 宝箱进度、等级、状态机、点火人、贡献榜、开箱在线用户快照、房间宝箱 IM | 奖励实际入账、用户完整资料、全局/区域播报群成员 | -| `activity-service` | 宝箱奖励抽奖、奖励记录、发奖重试、全局/区域播报 outbox | Room Cell presence、房间核心状态 | -| `wallet-service` | 资源组发放、金币/钻石入账、钱包 outbox、幂等账本 | 宝箱进度和中奖资格 | -| `gateway-service` | App HTTP envelope、鉴权、聚合 room/activity/wallet 读模型 | 能量、抽奖和发奖事实 | -| `server/admin` | 后台权限、表单校验、审计、调用 owner service 管理 RPC | 直接写 room/activity/wallet 业务表 | +| `room-service` | 宝箱进度、等级、状态机、点火人、贡献第一、开箱在线用户快照、抽奖结果、房间宝箱 IM outbox、RocketMQ 延迟开箱唤醒消费 | 用户完整资料、全局/区域播报群成员、钱包余额账本 | +| `wallet-service` | 送礼扣费、资源组发放、钱包账务、发放幂等 | 宝箱进度、中奖资格、房间 presence | +| `activity-service` | 满能量后区域/全局播报、成长值等房间事件派生消费 | Room Cell 状态、宝箱奖励结算 | +| `notice-service` | 通过 room outbox/MQ 消费需要私信的房间事实,写自己的投递位点和死信 | 房间群系统消息、群成员控制、房间状态 | +| `gateway-service` | App HTTP envelope、鉴权、协议转换、调用 room gRPC | 能量、抽奖和发奖事实 | +| `server/admin` | 后台菜单、配置表单、审计、调用 owner service 管理接口 | 直接写 room/wallet/activity 业务表 | -宝箱进度必须经过 Room Cell 命令链路。activity-service 可以消费 room outbox 做奖励结算和播报,但不能自己判断房间内用户、不能直接改宝箱进度。 +宝箱进度和开箱必须经过 Room Cell 命令链路。MQ 只承担“到点唤醒”和“已提交事实分发”,不能成为宝箱状态 owner。 -## State Machine +## 状态机 ```mermaid stateDiagram-v2 - [*] --> disabled - disabled --> charging: config enabled - charging --> countdown: energy >= threshold - countdown --> opening: now >= open_at_ms - opening --> settling: RoomTreasureChestOpened outbox committed - settling --> settled: rewards terminal - settled --> charging: next level exists - settled --> exhausted: level 7 settled - exhausted --> charging: next UTC day - charging --> charging: UTC daily reset + [*] --> idle + idle --> idle: SendGift progress countdown: SendGift progress>=threshold + countdown --> opened: open_at_ms reached + opened --> idle: next level + opened --> exhausted: level 7 opened + idle --> idle: UTC day reset + countdown --> idle: UTC day reset before open + exhausted --> idle: next UTC day ``` | 状态 | 语义 | | --- | --- | -| `disabled` | 后台关闭,不累计能量,不发宝箱 IM | -| `charging` | 当前等级可累积能量 | -| `countdown` | 当前等级已满,`open_at_ms` 已确定,本轮进度、点火人和配置版本锁定 | -| `opening` | 到期开箱命令正在 Room Cell 内提交,快照参与用户 | -| `settling` | activity-service 正在抽奖和调用 wallet 发奖 | -| `settled` | 本轮奖励达到终态,房间已发结算 IM | +| `idle` | 当前等级可累积能量 | +| `countdown` | 当前等级已满,`open_at_ms` 已确定,本轮进度、点火人、贡献第一和配置版本锁定 | +| `opened` | 本轮已开箱,奖励已在命令内完成抽取和资源组发放请求 | | `exhausted` | 当天 7 级都已开启,等下一个 UTC 自然日 | -`round_id` 建议稳定生成: +当前实现没有单独持久化 `round_id` 表。`box_id` 是单轮宝箱幂等和奖励选择的核心 ID,开箱命令 ID 固定为: ```text -treasure:::: +cmd_room_treasure_open_ ``` -`cycle_day` 固定为 UTC `YYYY-MM-DD`。同一房间同一 UTC 日同一等级只允许一个 active round。 +奖励发放命令 ID 固定由 `box_id + role + user_id + reward_item_id` 派生,重复开箱或 MQ 重投不会重复发放同一条奖励。 -## Energy Rules +## 能量规则 | 规则 | 决策 | | --- | --- | -| 能量来源 | 默认使用 `DebitGiftResponse.gift_point_added`;后台可配置 gift_id 级别倍率或覆盖值 | -| 生效条件 | wallet 扣费成功、room-service 已提交 `RoomGiftSent`、礼物能量值大于 0 | +| 能量来源 | 默认使用 `DebitGiftResponse.gift_point_added`;后台可配置 `gift_id` 或 `gift_type_code` 的倍率、覆盖值和排除规则 | +| 生效条件 | wallet 扣费成功、Room Cell 提交 `SendGift`、礼物能量值大于 0 | | 幂等 | 同一 `SendGift.command_id` 只能增加一次宝箱能量 | | 点火人 | 第一笔让当前等级 `progress >= threshold` 的送礼用户 | -| 贡献第一 | 当前 round 内累计能量最高用户;同分按首次达到该分值时间,再按 user_id 升序 | +| 贡献第一 | 当前等级倒计时前累计有效能量最高用户 | | 溢出能量 | 单笔礼物超过当前等级剩余阈值时,只计入补满当前等级所需能量,多余能量作废 | -| 倒计时期间送礼 | 当前等级已经满能量,继续送礼不再增加宝箱能量,也不会排队到下一等级 | -| 7 级后送礼 | `exhausted` 后当天不再累计宝箱能量,礼物仍正常扣费、热度和榜单照常 | +| 倒计时送礼 | 当前等级已经满能量,继续送礼不再增加宝箱能量,也不会排队到下一等级 | +| 7 级后送礼 | 当天不再累计宝箱能量,礼物仍正常扣费、热度和榜单照常更新 | -溢出和倒计时期间的无效能量只用于审计,不计入当前 round 贡献、不计入下一等级进度、不改变点火人或贡献第一。送礼本身仍正常扣费、热度和房间礼物榜照常更新。 +无效能量只进入 `RoomTreasureProgressChanged` 的审计字段,不计入当前等级贡献、不计入下一等级进度、不改变点火人或贡献第一。 -## Opening Rules +## 开箱与 MQ 唤醒 -| 场景 | 处理 | +```mermaid +sequenceDiagram + participant C as App + participant R as room-service + participant W as wallet-service + participant DB as hyapp_room + participant MQ as RocketMQ + participant A as activity-service + participant IM as Tencent IM + + C->>R: SendGift(command_id) + R->>W: DebitGift(command_id) + W-->>R: gift_point_added, heat_value + R->>R: Room Cell updates treasure + R->>DB: command_log + snapshot + room_outbox + R-->>C: SendGiftResponse.treasure + R->>DB: outbox worker claims RoomTreasureCountdownStarted + R->>MQ: delayed RoomTreasureOpenDue(open_at_ms) + R->>MQ: room_outbox event + MQ-->>A: RoomTreasureCountdownStarted + A-->>IM: region/global broadcast + MQ-->>R: RoomTreasureOpenDue at open_at_ms + R->>R: OpenRoomTreasure command + R->>W: GrantResourceGroup per reward + R->>DB: command_log + snapshot + opened/reward outbox +``` + +关键规则: + +- 延迟消息只唤醒,不直接修改状态。 +- MQ 提前投递时,`room-service` 先检查 `open_at_ms`,未到点返回可重试错误,不写 no-op command log,避免污染开箱 `command_id`。 +- MQ 重复投递、outbox 重试或扫描 worker 同时触发时,`command_id` 和 wallet grant command 保证幂等。 +- 如果 `reset_at_ms` 已过,UTC 日界优先,昨天倒计时宝箱不再结算。 +- 本地 `room_treasure_open_scan_interval` 只扫描本节点已加载且仍持有 lease 的 Cell,是兜底,不解决未加载房间;未加载房间依赖 MQ delayed wakeup。 + +## 奖励规则 + +| 奖励角色 | 资格 | | --- | --- | -| 满能量 | Room Cell 把 round 切到 `countdown`,写 `RoomTreasureChestCountdownStarted` outbox | -| 播报 | activity-service 消费 countdown 事件,按配置发 `region/global` 播报;失败只重试播报,不回滚宝箱 | -| 到期开箱 | room-service worker 或 cron-service 触发 `ProcessDueTreasureChests`,由 Room Cell 提交开箱命令 | -| 开箱快照 | 以开箱命令执行时的 `OnlineUsers` 为准生成在房奖励参与人 | -| 房间关闭 | 如果已有 `countdown` round,CloseRoom 必须先提交开箱快照或取消策略;推荐先快照结算,再关闭房间 | -| 房间无人 | 在房奖励参与人为空;点火人和贡献第一仍按 round 贡献事实抽奖 | +| `in_room` | 开箱命令执行时 Room Cell `OnlineUsers` 内的每个用户 | +| `top1` | 倒计时开始时锁定的贡献第一用户 | +| `igniter` | 倒计时开始时锁定的点火人 | -点火人和贡献第一不要求开箱时仍在房或在线。只要用户在本 round 已经形成点火人或贡献第一事实,即使开箱前离房、断线或下线,也必须保留对应角色奖励;在房奖励仍只发给开箱快照里的 `OnlineUsers`。 - -## Reward Rules - -| 奖励槽位 | 资格 | 默认是否可与其他槽位叠加 | -| --- | --- | --- | -| `in_room` | 开箱时 Room Cell `OnlineUsers` 内的每个用户 | 可叠加 | -| `top1` | 当前 round 贡献第一用户 | 可叠加 | -| `igniter` | 当前 round 点火人 | 可叠加 | - -奖励配置按等级拆分,每个槽位是一个加权奖励池。奖励项推荐引用 `resource_group_id`,由 wallet-service 按资源组快照发放金币、钻石或资源权益。 +奖励配置按等级拆分,每个角色是一个加权奖励池。奖励项引用 `resource_group_id`,由 `wallet-service` 按资源组发放金币、钻石或资源权益。 ```json { "level": 3, - "slot": "top1", + "reward_role": "top1", "items": [ { "reward_item_id": "lv3_top1_a", @@ -145,230 +149,138 @@ treasure:::: } ``` -发奖幂等键: +`reward_stack_policy`: -```text -treasure_reward::: -``` +| 策略 | 语义 | +| --- | --- | +| `allow_stack` | 同一用户可同时获得在房、top1、igniter 多个角色奖励 | +| `priority_only` | 同一用户只获得优先级最高的一份,优先级为 `igniter -> top1 -> in_room` | -activity-service 对每个奖励记录先写 `treasure_chest_reward_records`,再调用 wallet-service。wallet 成功后才能把该记录标记为 `granted`。失败进入 `retryable/failed`,后台补偿必须复用同一幂等键。 +## App 接口 -## App Interfaces - -### Get Treasure Chest +### 获取宝箱信息 ```http -GET /api/v1/rooms/{room_id}/treasure-chest +GET /api/v1/rooms/{room_id}/treasure Authorization: Bearer ``` -Response `data`: +Response `data` 核心字段: ```json { "enabled": true, "server_time_ms": 1779120000000, "reset_at_ms": 1779148800000, - "config_version": 12, - "current": { - "cycle_day": "2026-05-19", - "round_id": "treasure:lalu_room_1:2026-05-19:3:1", - "level": 3, - "status": "charging", - "progress": 3600, - "threshold": 10000, - "progress_ppm": 360000, - "opened_level_count": 2, + "broadcast_scope": "region", + "open_delay_ms": 30000, + "broadcast_delay_ms": 0, + "reward_stack_policy": "allow_stack", + "state": { + "current_level": 3, + "current_progress": 3600, + "energy_threshold": 10000, + "status": "idle", "open_at_ms": 0, + "top1_user_id": "10001", "igniter_user_id": "0", - "top1_user_id": "10001" + "box_id": "box_xxx", + "last_rewards": [] }, "levels": [ { "level": 1, - "threshold": 3000, + "energy_threshold": 3000, "cover_url": "https://cdn.example/chest/lv1/cover.png", "animation_url": "https://cdn.example/chest/lv1/idle.svga", "opening_animation_url": "https://cdn.example/chest/lv1/opening.svga", "opened_image_url": "https://cdn.example/chest/lv1/opened.png", - "rewards": { - "in_room": [], - "top1": [], - "igniter": [] - } + "in_room_rewards": [], + "top1_rewards": [], + "igniter_rewards": [] } - ], - "latest_round": { - "round_id": "treasure:lalu_room_1:2026-05-19:2:1", - "level": 2, - "status": "settled", - "opened_at_ms": 1779116400000, - "settled_at_ms": 1779116401200, - "my_rewards": [] - } + ] } ``` 规则: -- `levels` 固定返回 7 个等级的物料和奖励展示配置。 +- `levels` 固定返回 7 个等级。 - `reset_at_ms` 是下一个 UTC 零点,不是用户本地零点。 -- `progress_ppm = floor(progress * 1000000 / threshold)`,客户端不自行除浮点。 -- 倒计时期间送礼和单笔溢出的无效能量不返回为下一等级进度;客户端只展示服务端返回的当前等级进度。 +- 客户端只展示服务端返回的当前等级进度;不要自行把溢出能量展示到下一等级。 +- 当前没有单独的 App 领奖接口,奖励自动发放,最近一次开箱奖励通过 `state.last_rewards` 展示。 -### Get Treasure Rewards +### SendGift 响应 -```http -GET /api/v1/rooms/{room_id}/treasure-chest/rewards?round_id=treasure:...&scope=mine -Authorization: Bearer -``` +`SendGiftResponse` 已携带 `treasure` 状态,送礼人成功送礼后可立即更新宝箱 UI,不必等待 IM。 -Response `data`: +## 房间 IM 事件 -```json -{ - "round_id": "treasure:lalu_room_1:2026-05-19:3:1", - "level": 3, - "status": "settled", - "reward_status": "granted", - "items": [ - { - "reward_record_id": "treasure_reward:treasure:lalu_room_1:2026-05-19:3:1:in_room:10001", - "slot": "in_room", - "user_id": "10001", - "resource_group_id": 12001, - "display_name": "Room Reward", - "icon_url": "https://cdn.example/reward.png", - "grant_status": "granted", - "granted_at_ms": 1779120012000 - } - ], - "next_page_token": "" -} -``` +房间内宝箱 IM 走腾讯云房间群 `TIMCustomElem`,由 room outbox 事件转换。当前事件类型: -`scope=public` 只返回公开摘要,例如点火人、贡献第一和最多 N 条可展示奖励;不要把所有用户的私派奖励大列表塞进 IM payload。 - -## IM Contracts - -房间内宝箱 IM 仍走腾讯云房间群 `TIMCustomElem`,公共字段保持稳定: - -```json -{ - "event_id": "room_treasure:...", - "event_type": "room_treasure_progress_changed", - "room_id": "lalu_room_1", - "room_version": 102, - "round_id": "treasure:lalu_room_1:2026-05-19:3:1", - "level": 3, - "cycle_day": "2026-05-19", - "server_time_ms": 1779120000000 -} -``` +| Event | 客户端 `event_type` | 用途 | +| --- | --- | --- | +| `RoomTreasureProgressChanged` | `room_treasure_progress_changed` | 宝箱进度增加 | +| `RoomTreasureCountdownStarted` | `room_treasure_countdown_started` | 满能量进入倒计时 | +| `RoomTreasureOpened` | `room_treasure_opened` | 宝箱打开并给出公开奖励摘要 | +| `RoomTreasureRewardGranted` | `room_treasure_reward_granted` | 发奖结果事件,包含奖励列表 | ### Progress Changed -`event_type=room_treasure_progress_changed` - ```json { - "sender_user_id": "10001", + "event_type": "room_treasure_progress_changed", + "box_id": "box_xxx", + "level": 3, + "added_energy": 500, + "effective_added_energy": 500, + "overflow_energy": 0, + "current_progress": 3600, + "energy_threshold": 10000, + "status": "idle", + "reset_at_ms": 1779148800000, "gift_id": "rose", - "gift_count": 10, - "energy_added": 500, - "invalid_energy": 0, - "progress_before": 3100, - "progress_after": 3600, - "threshold": 10000, - "status": "charging", - "top1_user_id": "10001", - "reset_at_ms": 1779148800000 + "gift_count": 10 } ``` ### Countdown Started -`event_type=room_treasure_countdown_started` - ```json { - "progress_after": 10000, - "threshold": 10000, - "igniter_user_id": "10002", - "top1_user_id": "10001", - "full_at_ms": 1779120000000, - "open_at_ms": 1779120030000, - "broadcast_scope": "region" -} -``` - -该事件进入 room outbox 后,activity-service 按配置生成全局或区域播报: - -```json -{ - "event_id": "treasure_broadcast:treasure:lalu_room_1:2026-05-19:3:1", - "broadcast_type": "room_treasure_ready", - "scope": "region", - "region_id": 1001, - "room_id": "lalu_room_1", - "round_id": "treasure:lalu_room_1:2026-05-19:3:1", + "event_type": "room_treasure_countdown_started", + "box_id": "box_xxx", "level": 3, + "current_progress": 10000, + "energy_threshold": 10000, "open_at_ms": 1779120030000, - "action": { - "type": "enter_room", - "room_id": "lalu_room_1" - } + "broadcast_scope": "region", + "visible_region_id": 1001, + "top1_user_id": "10001", + "igniter_user_id": "10002" } ``` -### Chest Opened - -`event_type=room_treasure_opened` +### Opened / Reward Granted ```json { + "event_type": "room_treasure_opened", + "box_id": "box_xxx", + "level": 3, + "next_level": 4, "opened_at_ms": 1779120030000, - "participant_count": 86, - "igniter_user_id": "10002", "top1_user_id": "10001", - "settlement_status": "pending" + "igniter_user_id": "10002", + "rewards_json": "[...]" } ``` -### Rewards Settled +群 IM 承载公开摘要。后续如要做个人中奖私信或消息 Tab,应由 `notice-service` 或 inbox consumer 消费 `RoomTreasureRewardGranted`,不要让 `room-service` 同步投递私信。 -`event_type=room_treasure_rewards_settled` +## 后台配置 -```json -{ - "settled_at_ms": 1779120031200, - "reward_status": "granted", - "participant_reward_count": 86, - "top1_user_id": "10001", - "igniter_user_id": "10002", - "public_rewards": [ - { - "slot": "top1", - "user_id": "10001", - "display_name": "Top1 Reward", - "icon_url": "https://cdn.example/reward.png" - }, - { - "slot": "igniter", - "user_id": "10002", - "display_name": "Igniter Reward", - "icon_url": "https://cdn.example/reward.png" - } - ], - "full_result_required": true -} -``` - -群 IM 只承载公开摘要。用户自己的完整发奖结果通过 `GET /treasure-chest/rewards?scope=mine` 查询;后续接 notice-service 时,可以再补私有单聊或 inbox 通知。 - -## Admin Config - -后台配置必须带 `config_version`,保存后只影响新 round;已经进入 `countdown/opening/settling` 的 round 使用锁定的配置快照。 +后台配置带 `config_version`。倒计时开始后,当前宝箱的 `box_id`、等级、进度、`open_at_ms`、点火人和贡献第一不再因配置修改而变化;当前实现开箱时读取最新启用配置的奖励池,因此运营在倒计时窗口内修改奖励配置要按发布窗口处理。 | 字段 | 含义 | | --- | --- | @@ -377,214 +289,90 @@ Response `data`: | `open_delay_ms` | 满能量到开箱的倒计时 | | `broadcast_enabled` | 是否满能量后播报 | | `broadcast_scope` | `none/region/global` | -| `broadcast_delay_ms` | 满能量后多久发播报,默认 0 | +| `broadcast_delay_ms` | 满能量后多久发播报,当前代码写入配置和读模型,实际倒计时事件立即进入 room outbox | | `reward_stack_policy` | `allow_stack` 或 `priority_only` | -| `levels` | 7 个等级配置,包含阈值、物料 URL 和奖励池 | +| `levels` | 7 个等级配置,包含阈值、物料 URL 和三类奖励池 | | `gift_energy_rules` | gift_id 或 gift_type_code 的能量倍率、覆盖值和排除规则 | -| `effective_from_ms` | 配置开始生效时间,UTC epoch ms | | `updated_by_admin_id` | 审计字段 | -每个等级必须配置: - -| 字段 | 规则 | -| --- | --- | -| `level` | 1 到 7,不能缺级 | -| `threshold` | 大于 0,建议单调递增 | -| `cover_url` | 静态封面 | -| `animation_url` | 未开启待机动效 | -| `opening_animation_url` | 开启倒计时或开箱动效 | -| `opened_image_url` | 开启后静态图 | -| `in_room_rewards` | 在房用户奖励池 | -| `top1_rewards` | 贡献第一奖励池 | -| `igniter_rewards` | 点火人奖励池 | - 奖励池保存时应校验: - `resource_group_id` 必须存在且 active。 -- 同一槽位权重总和必须大于 0。 +- 同一奖励池权重总和必须大于 0。 - 展示字段可以保存快照,但发放仍以 wallet-service 资源组事实为准。 - 配置不能直接填写金币数量绕过资源组,除非新增明确的钱包奖励配置类型。 -## Internal Events +## room_outbox 与 RocketMQ -建议在 `api/proto/events/room/v1/events.proto` 增加: +`room_outbox` 是 MySQL 内的可靠事实源,RocketMQ 是发布后的事件总线。不能用 MQ 替代 outbox,因为房间状态提交成功但 MQ 发布失败时必须能从 MySQL 补偿。 -| Event | 生产方 | 消费方 | 用途 | -| --- | --- | --- | --- | -| `RoomTreasureChestProgressChanged` | room-service | Tencent IM/activity audit | 房间内进度 IM、审计 | -| `RoomTreasureChestCountdownStarted` | room-service | Tencent IM/activity broadcast | 房间倒计时 IM、区域/全局播报 | -| `RoomTreasureChestOpened` | room-service | activity-service | 奖励抽奖和发放输入,包含参与人快照和奖励配置快照 | -| `RoomTreasureChestSettlementApplied` | room-service | Tencent IM/audit | 发奖完成后的房间 IM | +当前 topic: -`RoomTreasureChestOpened` 必须包含: +| Topic | Producer | Consumer | +| --- | --- | --- | +| `hyapp_room_outbox` | `room-service` outbox worker | `activity-service`、`notice-service`、`room-service` IM bridge、后续 push/inbox | +| `hyapp_room_treasure_open` | `room-service` treasure open scheduler | `room-service` treasure open consumer | -```text -round_id -cycle_day -level -config_version -room_id -visible_region_id -opened_at_ms -participant_user_ids -igniter_user_id -top1_user_id -contribution_top -reward_config_snapshot_json +推荐线上配置: + +```yaml +outbox_worker: + enabled: true + publish_mode: "mq" + +rocketmq: + enabled: true + room_outbox: + enabled: true + topic: "hyapp_room_outbox" + treasure_open: + enabled: true + topic: "hyapp_room_treasure_open" ``` -activity-service 不反查 Room Cell 判断参与人,避免开奖时与房间状态发生竞态。 +`publish_mode`: -## Data Model Sketch - -room-service 持久化: - -```sql -room_treasure_rounds( - app_code, - room_id, - cycle_day, - round_id, - level, - status, - progress, - threshold, - invalid_energy, - config_version, - igniter_user_id, - top1_user_id, - participant_count, - full_at_ms, - open_at_ms, - opened_at_ms, - settled_at_ms, - reset_at_ms, - created_at_ms, - updated_at_ms -); - -room_treasure_contributions( - app_code, - round_id, - user_id, - energy, - first_contributed_at_ms, - last_contributed_at_ms -); -``` - -activity-service 持久化: - -```sql -treasure_chest_settlements( - app_code, - settlement_id, - round_id, - room_id, - level, - status, - reward_config_snapshot_json, - participant_count, - created_at_ms, - updated_at_ms -); - -treasure_chest_reward_records( - app_code, - reward_record_id, - settlement_id, - round_id, - slot, - user_id, - reward_item_id, - resource_group_id, - grant_command_id, - grant_status, - grant_id, - retry_count, - last_error, - created_at_ms, - updated_at_ms -); -``` - -Room Cell snapshot 也要包含当前宝箱轻量状态,保证节点恢复后不依赖 Redis 重建进度。表结构用于查询、due scan 和后台审计;command log 仍是写命令恢复的关键事实。 +| 模式 | 行为 | +| --- | --- | +| `direct` | room outbox worker 直接投 activity gRPC 和 Tencent IM,适合本地默认 | +| `mq` | room outbox worker 只投 RocketMQ,activity/notice/IM bridge 各自消费,推荐线上 | +| `dual` | direct + MQ 双写,只用于迁移验证,所有消费者必须幂等 | ## Daily Reset | 规则 | 决策 | | --- | --- | -| 日期来源 | `time.Now().UTC()`,`cycle_day=YYYY-MM-DD` | +| 日期来源 | `time.Now().UTC()` | | 重置时间 | 每天 UTC `00:00:00.000` | | 时间区间 | `[day_start_ms, next_day_start_ms)` | -| charging round | 到达新 UTC 日时清空进度、贡献和 queued energy,创建新 day level 1 round | -| countdown/opening/settling round | 已满能量的 round 继续开箱和发奖,使用原 `cycle_day` | +| idle | 到达新 UTC 日时回到 level 1,清空进度和贡献 | +| countdown | 如果 `open_at_ms` 跨过 `reset_at_ms`,UTC 日界优先,旧宝箱不再结算 | | exhausted | 下一个 UTC 日恢复 level 1 | -必须补 UTC 边界测试:`day_start_ms`、`next_day_start_ms - 1`、`next_day_start_ms`。不要重新引入 `time.Local`、`task_timezone` 或客户端时区。 +必须覆盖 UTC 边界测试:`day_start_ms`、`next_day_start_ms - 1`、`next_day_start_ms`。不要重新引入 `time.Local`、`task_timezone` 或客户端时区。 -## Implementation Flow +## 验收清单 -```mermaid -sequenceDiagram - participant C as Client - participant G as gateway-service - participant R as room-service - participant W as wallet-service - participant A as activity-service - participant IM as Tencent IM - - C->>G: POST /api/v1/rooms/gift/send - G->>R: SendGift(command_id) - R->>W: DebitGift(command_id) - W-->>R: gift_point_added, receipt - R->>R: Room Cell updates heat/rank/treasure - R-->>C: SendGiftResponse + treasure summary - R-->>IM: room_treasure_progress_changed - alt energy full - R-->>IM: room_treasure_countdown_started - R-->>A: room outbox RoomTreasureChestCountdownStarted - A-->>IM: region/global room_treasure_ready broadcast - end - R->>R: ProcessDueTreasureChests at open_at_ms - R-->>IM: room_treasure_opened - R-->>A: room outbox RoomTreasureChestOpened - A->>A: draw rewards idempotently - A->>W: GrantResourceGroup per reward - W-->>A: grant result - A->>R: ApplyTreasureChestSettlement(summary) - R-->>IM: room_treasure_rewards_settled -``` - -`ApplyTreasureChestSettlement` 是 gRPC/protobuf 边界,不允许 activity-service import room-service `internal` 包。它只提交结算摘要和可展示公共结果,不让 activity-service 改写宝箱进度。 - -## Failure Handling - -| 失败 | 处理 | +| 场景 | 验收 | | --- | --- | -| wallet DebitGift 失败 | 不增加能量,不写宝箱进度,不发宝箱 IM | -| room outbox 投递 IM 失败 | 房间状态已提交,outbox 重试或死信;App 可用 GET 接口补状态 | -| broadcast 失败 | activity broadcast outbox 重试,不影响开箱 | -| due open worker 宕机 | MySQL `open_at_ms/status` 可被下一轮 worker/cron 重新扫描 | -| activity 抽奖失败 | settlement 保持 `retryable`,不发“已发奖” IM | -| wallet 发奖部分失败 | 成功记录保持 granted,失败记录重试;房间 IM 标记 `partial` 或等待全部终态后再发 | -| App 错过 IM | `GET /treasure-chest` 和 `GET /treasure-chest/rewards` 补齐 | +| 送礼成功 | wallet 扣费成功,Room Cell 进度增加,`SendGiftResponse.treasure` 返回最新状态 | +| 单笔溢出 | 进度封顶当前阈值,溢出能量作废,不进入下一等级 | +| 倒计时送礼 | 礼物正常扣费和加热度,但宝箱进度、点火人、贡献第一不变化 | +| 满能量 | 写 `RoomTreasureCountdownStarted`,outbox worker 安排 RocketMQ delayed open | +| 提前延迟消息 | 不写开箱 no-op command log,MQ 后续可重试 | +| 到点开箱 | 即使房间未加载,MQ 唤醒后 room-service 恢复 Room Cell 并开箱 | +| top1/igniter 离房 | 仍发角色奖励 | +| 在房奖励 | 只发开箱瞬间在线用户 | +| UTC 重置 | 到达 UTC 零点后进度回到 level 1,跨日倒计时不结算 | +| MQ/outbox 重投 | `event_id`、`box_id`、wallet grant command 保证幂等 | -## Tests +## 代码位置 -必须覆盖: - -| 场景 | 验证 | +| 模块 | 路径 | | --- | --- | -| 送礼幂等 | 同一 `command_id` 重试不重复加能量 | -| 阈值边界 | `progress = threshold - 1` 不点火,`progress = threshold` 点火一次 | -| 溢出能量 | 单笔礼物超过阈值时只补满当前等级,剩余能量作废且不进入下一等级 | -| 倒计时送礼 | countdown/opening/settling 期间送礼不增加宝箱能量,不改变点火人和 top1 | -| 倒计时开箱 | `open_at_ms` 前不开,等于或超过时只开一次 | -| 在线快照 | 开箱时在线用户获得 in_room 抽奖资格,离房用户不获得 in_room 奖励 | -| top1/igniter | 同分 tie-break 固定;离房、断线或下线后仍结算角色奖励 | -| UTC 重置 | 覆盖 UTC 日开始、结束前一毫秒、结束边界 | -| room close | countdown 后关房不会丢掉已满宝箱奖励 | -| 发奖幂等 | reward_record 重试不重复发资源组 | -| 播报范围 | `none/region/global` 分别不发、发区域、发全局 | -| IM 补偿 | outbox 重试不会产生不同 event_id 的重复客户端消息 | +| 宝箱状态机和结算 | `services/room-service/internal/room/service/room_treasure.go` | +| MQ delayed open | `services/room-service/internal/room/service/room_treasure_mq.go` | +| outbox worker | `services/room-service/internal/room/service/outbox_worker.go` | +| RocketMQ 适配 | `pkg/rocketmqx`、`pkg/roommq`、`services/room-service/internal/integration/rocketmq_outbox.go` | +| App HTTP | `services/gateway-service/internal/transport/http/roomapi` | +| 后台配置 | `server/admin/internal/modules/roomtreasure` | diff --git a/go.mod b/go.mod index 9ebb07d3..c9a2d86d 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module hyapp go 1.26.3 require ( + github.com/apache/rocketmq-client-go/v2 v2.1.2 github.com/go-sql-driver/mysql v1.9.3 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/redis/go-redis/v9 v9.18.0 @@ -22,13 +23,29 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clbanning/mxj v1.8.4 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/emirpasic/gods v1.12.0 // indirect + github.com/golang/mock v1.3.1 // indirect github.com/google/go-querystring v1.0.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/konsorten/go-windows-terminal-sequences v1.0.1 // indirect github.com/mitchellh/mapstructure v1.4.3 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect github.com/mozillazg/go-httpheader v0.2.1 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect + github.com/pkg/errors v0.8.1 // indirect + github.com/sirupsen/logrus v1.4.0 // indirect github.com/stretchr/testify v1.9.0 // indirect + github.com/tidwall/gjson v1.13.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/net v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect + stathat.com/c/consistent v1.0.0 // indirect ) diff --git a/go.sum b/go.sum index 1716f44b..de7381f9 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,20 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/BurntSushi/toml v1.1.0 h1:ksErzDEI1khOiGPgpwuI7x2ebx/uXQNw7xJpn9Eq1+I= +github.com/BurntSushi/toml v1.1.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= github.com/QcloudApi/qcloud_sign_golang v0.0.0-20141224014652-e4130a326409/go.mod h1:1pk82RBxDY/JZnPQrtqHlUFfCctgdorsd9M06fMynOM= +github.com/apache/rocketmq-client-go/v2 v2.1.2 h1:yt73olKe5N6894Dbm+ojRf/JPiP0cxfDNNffKwhpJVg= +github.com/apache/rocketmq-client-go/v2 v2.1.2/go.mod h1:6I6vgxHR3hzrvn+6n/4mrhS+UTulzK/X9LB2Vk1U5gE= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I= github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -14,54 +22,200 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/emirpasic/gods v1.12.0 h1:QAUIPSaCu4G+POclxeqb3F+WPpdKqFGlw36+yOzGlrg= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/mock v1.3.1 h1:qGJ6qTW+x6xX/my+8YUVl4WNpX9B7+/l2tRsHGZ7f2s= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= +github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mitchellh/mapstructure v1.4.3 h1:OVowDSCllw/YjdLkam3/sm7wEtOy59d8ndGgCcyj8cs= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ= github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/sirupsen/logrus v1.4.0 h1:yKenngtzGh+cUSSh6GWbxW2abRqhYUSR/t/6+2QqNvE= +github.com/sirupsen/logrus v1.4.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= +github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y= github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0= github.com/tencentyun/cos-go-sdk-v5 v0.7.66 h1:O4O6EsozBoDjxWbltr3iULgkI7WPj/BFNlYTXDuE64E= github.com/tencentyun/cos-go-sdk-v5 v0.7.66/go.mod h1:8+hG+mQMuRP/OIS9d83syAvXvrMj9HhkND6Q1fLghw0= +github.com/tidwall/gjson v1.13.0 h1:3TFY9yxOQShrvmjdM76K+jc66zJeT6D3/VFFYCGQf7M= +github.com/tidwall/gjson v1.13.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.5.1/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +stathat.com/c/consistent v1.0.0 h1:ezyc51EGcRPJUxfHGSgJjWzJdj3NiMU9pNfLNGiXV0c= +stathat.com/c/consistent v1.0.0/go.mod h1:QkzMWzcbB+yQBL2AttO6sgsQS/JSTapcDISJalmCDS0= diff --git a/pkg/rocketmqx/rocketmqx.go b/pkg/rocketmqx/rocketmqx.go new file mode 100644 index 00000000..ffe48141 --- /dev/null +++ b/pkg/rocketmqx/rocketmqx.go @@ -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 +} diff --git a/pkg/roommq/messages.go b/pkg/roommq/messages.go new file mode 100644 index 00000000..ea2e0d90 --- /dev/null +++ b/pkg/roommq/messages.go @@ -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 +} diff --git a/pkg/roommq/messages_test.go b/pkg/roommq/messages_test.go new file mode 100644 index 00000000..b2f33068 --- /dev/null +++ b/pkg/roommq/messages_test.go @@ -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) + } +} diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 876e2952..e3e50e10 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -47,6 +47,7 @@ import ( registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward" resourcemodule "hyapp-admin-server/internal/modules/resource" roomadminmodule "hyapp-admin-server/internal/modules/roomadmin" + roomtreasuremodule "hyapp-admin-server/internal/modules/roomtreasure" searchmodule "hyapp-admin-server/internal/modules/search" sevendaycheckinmodule "hyapp-admin-server/internal/modules/sevendaycheckin" uploadmodule "hyapp-admin-server/internal/modules/upload" @@ -232,6 +233,7 @@ func main() { RegionBlock: regionblockmodule.New(userDB, auditHandler), Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler), RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler), + RoomTreasure: roomtreasuremodule.New(roomDB, auditHandler), Search: searchmodule.New(store), SevenDayCheckIn: sevendaycheckinmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler), diff --git a/server/admin/internal/modules/roomtreasure/handler.go b/server/admin/internal/modules/roomtreasure/handler.go new file mode 100644 index 00000000..c6d0d520 --- /dev/null +++ b/server/admin/internal/modules/roomtreasure/handler.go @@ -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) +} diff --git a/server/admin/internal/modules/roomtreasure/routes.go b/server/admin/internal/modules/roomtreasure/routes.go new file mode 100644 index 00000000..17e2d5e4 --- /dev/null +++ b/server/admin/internal/modules/roomtreasure/routes.go @@ -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) +} diff --git a/server/admin/internal/modules/roomtreasure/service.go b/server/admin/internal/modules/roomtreasure/service.go new file mode 100644 index 00000000..45507ab2 --- /dev/null +++ b/server/admin/internal/modules/roomtreasure/service.go @@ -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 +} diff --git a/server/admin/internal/modules/roomtreasure/service_test.go b/server/admin/internal/modules/roomtreasure/service_test.go new file mode 100644 index 00000000..07d7001c --- /dev/null +++ b/server/admin/internal/modules/roomtreasure/service_test.go @@ -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) + } +} diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index a0e3b258..c27806b7 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -96,6 +96,8 @@ var defaultPermissions = []model.Permission{ {Name: "七日签到更新", Code: "seven-day-checkin:update", Kind: "button"}, {Name: "幸运礼物查看", Code: "lucky-gift:view", Kind: "menu"}, {Name: "幸运礼物更新", Code: "lucky-gift:update", Kind: "button"}, + {Name: "房间宝箱查看", Code: "room-treasure:view", Kind: "menu"}, + {Name: "房间宝箱更新", Code: "room-treasure:update", Kind: "button"}, {Name: "角色查看", Code: "role:view", Kind: "menu"}, {Name: "角色创建", Code: "role:create", Kind: "button"}, {Name: "角色更新", Code: "role:update", Kind: "button"}, @@ -126,7 +128,7 @@ var deprecatedPermissionCodes = []string{ "service:view", "service:create", "service:update", "service:operate", "notification:view", "notification:read", "notification:read-all", "notification:delete", } -var deprecatedMenuCodes = []string{"services", "notifications"} +var deprecatedMenuCodes = []string{"services", "notifications", "jobs", "room-treasure"} func (s *Store) seedPermissions() error { return s.syncDefaultPermissions(s.db) @@ -158,7 +160,6 @@ func (s *Store) seedMenus() error { {Title: "游戏管理", Code: "games", Path: "", Icon: "sports_esports", PermissionCode: "", Sort: 71, Visible: true}, {Title: "地区管理", Code: "geo", Path: "", Icon: "map", PermissionCode: "", Sort: 72, Visible: true}, {Title: "团队管理", Code: "host-org", Path: "", Icon: "network", PermissionCode: "", Sort: 80, Visible: true}, - {Title: "任务中心", Code: "jobs", Path: "/jobs", Icon: "task", PermissionCode: "job:view", Sort: 90, Visible: true}, } for _, menu := range menus { syncedMenu, err := s.syncDefaultMenu(s.db, menu) @@ -471,12 +472,13 @@ func defaultRolePermissionCodes(code string) []string { "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", "achievement:view", "achievement:create", "achievement:update", "seven-day-checkin:view", "seven-day-checkin:update", + "room-treasure:view", "room-treasure:update", "log:view", "job:view", "job:cancel", "export:create", "upload:create", } case "auditor": - return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "role:view", "permission:view", "job:view"} + return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "role:view", "permission:view", "job:view"} case "readonly": return []string{ "overview:view", @@ -509,6 +511,7 @@ func defaultRolePermissionCodes(code string) []string { "daily-task:view", "achievement:view", "seven-day-checkin:view", + "room-treasure:view", "role:view", "permission:view", "menu:view", @@ -545,9 +548,10 @@ func defaultRolePermissionMigrationCodes(code string) []string { "daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status", "achievement:view", "achievement:create", "achievement:update", "seven-day-checkin:view", "seven-day-checkin:update", + "room-treasure:view", "room-treasure:update", } case "auditor", "readonly": - return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view"} + return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view"} default: return nil } diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index fc6569f3..5bb42b88 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -27,6 +27,7 @@ import ( "hyapp-admin-server/internal/modules/registrationreward" resourcemodule "hyapp-admin-server/internal/modules/resource" "hyapp-admin-server/internal/modules/roomadmin" + "hyapp-admin-server/internal/modules/roomtreasure" "hyapp-admin-server/internal/modules/search" "hyapp-admin-server/internal/modules/sevendaycheckin" "hyapp-admin-server/internal/modules/upload" @@ -61,6 +62,7 @@ type Handlers struct { RegionBlock *regionblock.Handler Resource *resourcemodule.Handler RoomAdmin *roomadmin.Handler + RoomTreasure *roomtreasure.Handler Search *search.Handler SevenDayCheckIn *sevendaycheckin.Handler Upload *upload.Handler @@ -93,6 +95,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { regionblock.RegisterRoutes(protected, h.RegionBlock) gamemanagement.RegisterRoutes(protected, h.Game) roomadmin.RegisterRoutes(protected, h.RoomAdmin) + roomtreasure.RegisterRoutes(protected, h.RoomTreasure) dashboard.RegisterRoutes(protected, h.Dashboard) hostorg.RegisterRoutes(protected, h.HostOrg) audit.RegisterRoutes(protected, h.Audit) diff --git a/server/admin/migrations/018_room_treasure_navigation.sql b/server/admin/migrations/018_room_treasure_navigation.sql new file mode 100644 index 00000000..e831fe11 --- /dev/null +++ b/server/admin/migrations/018_room_treasure_navigation.sql @@ -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'; diff --git a/server/admin/migrations/019_remove_hidden_navigation_menus.sql b/server/admin/migrations/019_remove_hidden_navigation_menus.sql new file mode 100644 index 00000000..c7288bd7 --- /dev/null +++ b/server/admin/migrations/019_remove_hidden_navigation_menus.sql @@ -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'); diff --git a/services/activity-service/configs/config.docker.yaml b/services/activity-service/configs/config.docker.yaml index 4ccf4380..b2e47484 100644 --- a/services/activity-service/configs/config.docker.yaml +++ b/services/activity-service/configs/config.docker.yaml @@ -31,6 +31,19 @@ broadcast: worker_lock_ttl: "30s" worker_max_retry: 8 ensure_groups_on_startup: true +rocketmq: + # Docker 本地默认关闭;接入外部 RocketMQ 后由独立 consumer group 消费房间事实。 + enabled: false + name_servers: [] + name_server_domain: "" + access_key: "" + secret_key: "" + namespace: "" + room_outbox: + enabled: false + topic: "hyapp_room_outbox" + consumer_group: "hyapp-activity-room-outbox" + consumer_max_reconsume_times: 16 consumer: room_outbox_poll_interval_ms: 1000 batch_size: 100 diff --git a/services/activity-service/configs/config.tencent.example.yaml b/services/activity-service/configs/config.tencent.example.yaml index aca87e64..04649fff 100644 --- a/services/activity-service/configs/config.tencent.example.yaml +++ b/services/activity-service/configs/config.tencent.example.yaml @@ -31,6 +31,19 @@ broadcast: worker_lock_ttl: "30s" worker_max_retry: 8 ensure_groups_on_startup: true +rocketmq: + enabled: true + name_servers: + - "TENCENT_ROCKETMQ_NAMESERVER:9876" + name_server_domain: "" + access_key: "TENCENT_ROCKETMQ_ACCESS_KEY" + secret_key: "TENCENT_ROCKETMQ_SECRET_KEY" + namespace: "hyapp-prod" + room_outbox: + enabled: true + topic: "hyapp_room_outbox" + consumer_group: "hyapp-activity-room-outbox" + consumer_max_reconsume_times: 16 consumer: room_outbox_poll_interval_ms: 1000 batch_size: 100 diff --git a/services/activity-service/configs/config.yaml b/services/activity-service/configs/config.yaml index 9e3c782e..e439c60d 100644 --- a/services/activity-service/configs/config.yaml +++ b/services/activity-service/configs/config.yaml @@ -31,6 +31,19 @@ broadcast: worker_lock_ttl: "30s" worker_max_retry: 8 ensure_groups_on_startup: true +rocketmq: + # 本地默认关闭,room-service 直接 gRPC 投递 activity;开启后消费 room_outbox MQ fanout。 + enabled: false + name_servers: [] + name_server_domain: "" + access_key: "" + secret_key: "" + namespace: "" + room_outbox: + enabled: false + topic: "hyapp_room_outbox" + consumer_group: "hyapp-activity-room-outbox" + consumer_max_reconsume_times: 16 consumer: room_outbox_poll_interval_ms: 1000 batch_size: 100 diff --git a/services/activity-service/internal/app/app.go b/services/activity-service/internal/app/app.go index a5470b11..0161dad2 100644 --- a/services/activity-service/internal/app/app.go +++ b/services/activity-service/internal/app/app.go @@ -3,6 +3,7 @@ package app import ( "context" "errors" + "log/slog" "net" "sync" "time" @@ -12,10 +13,13 @@ import ( healthgrpc "google.golang.org/grpc/health/grpc_health_v1" activityv1 "hyapp.local/api/proto/activity/v1" walletv1 "hyapp.local/api/proto/wallet/v1" + "hyapp/pkg/appcode" "hyapp/pkg/grpchealth" "hyapp/pkg/grpcshutdown" "hyapp/pkg/healthhttp" "hyapp/pkg/logx" + "hyapp/pkg/rocketmqx" + "hyapp/pkg/roommq" "hyapp/pkg/tencentim" "hyapp/services/activity-service/internal/client" "hyapp/services/activity-service/internal/config" @@ -41,6 +45,7 @@ type App struct { mysqlRepo *mysqlstorage.Repository broadcast *broadcastservice.Service broadcastWorkerEnabled bool + mqConsumers []*rocketmqx.Consumer userConn *grpc.ClientConn walletConn *grpc.ClientConn workerCtx context.Context @@ -134,6 +139,36 @@ func New(cfg config.Config) (*App, error) { broadcastServer := grpcserver.NewBroadcastServer(broadcastSvc, growthSvc) activityv1.RegisterBroadcastServiceServer(server, broadcastServer) activityv1.RegisterRoomEventConsumerServiceServer(server, broadcastServer) + mqConsumers := make([]*rocketmqx.Consumer, 0, 1) + if cfg.RocketMQ.RoomOutbox.Enabled { + consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ)) + if err != nil { + _ = walletConn.Close() + _ = userConn.Close() + _ = listener.Close() + _ = repository.Close() + return nil, err + } + if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { + envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body) + if err != nil { + return err + } + eventCtx := appcode.WithContext(ctx, envelope.GetAppCode()) + if _, err := broadcastSvc.HandleRoomEvent(eventCtx, envelope); err != nil { + return err + } + _, err = growthSvc.HandleRoomEvent(eventCtx, envelope) + return err + }); err != nil { + _ = walletConn.Close() + _ = userConn.Close() + _ = listener.Close() + _ = repository.Close() + return nil, err + } + mqConsumers = append(mqConsumers, consumer) + } health := grpchealth.NewServingChecker("activity-service", grpchealth.Dependency{ Name: "mysql", Check: repository.Ping, @@ -157,6 +192,7 @@ func New(cfg config.Config) (*App, error) { mysqlRepo: repository, broadcast: broadcastSvc, broadcastWorkerEnabled: cfg.Broadcast.Enabled, + mqConsumers: mqConsumers, userConn: userConn, walletConn: walletConn, workerCtx: workerCtx, @@ -168,6 +204,10 @@ func New(cfg config.Config) (*App, error) { func (a *App) Run() error { a.runHealthHTTP() a.health.MarkServing() + if err := a.startMQ(); err != nil { + a.health.MarkStopped() + return err + } if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil { a.workerWG.Go(func() { a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{}) @@ -178,6 +218,7 @@ func (a *App) Run() error { a.workerStop() } a.workerWG.Wait() + a.shutdownMQ() a.health.MarkStopped() if errors.Is(err, grpc.ErrServerStopped) { return nil @@ -194,6 +235,7 @@ func (a *App) Close() { a.workerStop() } a.workerWG.Wait() + a.shutdownMQ() grpcshutdown.GracefulStop(a.server, 15*time.Second) a.closeHealthHTTP() if a.userConn != nil { @@ -228,3 +270,40 @@ func (a *App) closeHealthHTTP() { defer cancel() _ = a.healthHTTP.Close(ctx) } + +func (a *App) startMQ() error { + for _, consumer := range a.mqConsumers { + if err := consumer.Start(); err != nil { + a.shutdownMQ() + return err + } + } + return nil +} + +func (a *App) shutdownMQ() { + for _, consumer := range a.mqConsumers { + if err := consumer.Shutdown(); err != nil { + logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error())) + } + } +} + +func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { + return rocketmqx.ConsumerConfig{ + EndpointConfig: rocketmqx.EndpointConfig{ + NameServers: cfg.NameServers, + NameServerDomain: cfg.NameServerDomain, + AccessKey: cfg.AccessKey, + SecretKey: cfg.SecretKey, + SecurityToken: cfg.SecurityToken, + Namespace: cfg.Namespace, + VIPChannel: cfg.VIPChannel, + }, + GroupName: cfg.RoomOutbox.ConsumerGroup, + MaxReconsumeTimes: cfg.RoomOutbox.ConsumerMaxReconsumeTimes, + ConsumeRetryDelay: time.Second, + ConsumePullBatch: 32, + ConsumePullTimeout: 15 * time.Minute, + } +} diff --git a/services/activity-service/internal/config/config.go b/services/activity-service/internal/config/config.go index cf1c52b9..6aa6c263 100644 --- a/services/activity-service/internal/config/config.go +++ b/services/activity-service/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "net/http" "strings" "time" @@ -28,6 +29,8 @@ type Config struct { TencentIM TencentIMConfig `yaml:"tencent_im"` // Broadcast 控制 IM 播报群 reconciler、outbox worker 和贵重礼物阈值。 Broadcast BroadcastConfig `yaml:"broadcast"` + // RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。 + RocketMQ RocketMQConfig `yaml:"rocketmq"` // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` Consumer ConsumerConfig `yaml:"consumer"` @@ -86,6 +89,27 @@ type BroadcastConfig struct { EnsureGroupsOnStartup bool `yaml:"ensure_groups_on_startup"` } +// RocketMQConfig 描述 activity-service 消费房间事实的 MQ 连接。 +type RocketMQConfig struct { + Enabled bool `yaml:"enabled"` + NameServers []string `yaml:"name_servers"` + NameServerDomain string `yaml:"name_server_domain"` + AccessKey string `yaml:"access_key"` + SecretKey string `yaml:"secret_key"` + SecurityToken string `yaml:"security_token"` + Namespace string `yaml:"namespace"` + VIPChannel bool `yaml:"vip_channel"` + RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` +} + +// RoomOutboxMQConfig 控制 activity 对 room_outbox topic 的消费位点。 +type RoomOutboxMQConfig struct { + Enabled bool `yaml:"enabled"` + Topic string `yaml:"topic"` + ConsumerGroup string `yaml:"consumer_group"` + ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` +} + // Default 返回本地默认配置。 func Default() Config { return Config{ @@ -114,6 +138,7 @@ func Default() Config { WorkerMaxRetry: 8, EnsureGroupsOnStartup: true, }, + RocketMQ: defaultRocketMQConfig(), MySQLAutoMigrate: false, Consumer: ConsumerConfig{ RoomOutboxPollIntervalMs: 1000, @@ -130,6 +155,18 @@ func Default() Config { } } +func defaultRocketMQConfig() RocketMQConfig { + return RocketMQConfig{ + Enabled: false, + RoomOutbox: RoomOutboxMQConfig{ + Enabled: false, + Topic: "hyapp_room_outbox", + ConsumerGroup: "hyapp-activity-room-outbox", + ConsumerMaxReconsumeTimes: 16, + }, + } +} + // Load 从独立 config.yaml 读取 activity-service 配置。 func Load(path string) (Config, error) { cfg := Default() @@ -195,6 +232,52 @@ func Load(path string) (Config, error) { if cfg.Broadcast.WorkerMaxRetry <= 0 { cfg.Broadcast.WorkerMaxRetry = 8 } + rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ) + if err != nil { + return Config{}, err + } + cfg.RocketMQ = rocketMQ return cfg, nil } + +func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) { + defaults := defaultRocketMQConfig() + cfg.NameServers = normalizeStringSlice(cfg.NameServers) + cfg.NameServerDomain = strings.TrimSpace(cfg.NameServerDomain) + cfg.AccessKey = strings.TrimSpace(cfg.AccessKey) + cfg.SecretKey = strings.TrimSpace(cfg.SecretKey) + cfg.SecurityToken = strings.TrimSpace(cfg.SecurityToken) + cfg.Namespace = strings.TrimSpace(cfg.Namespace) + if cfg.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" { + cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic + } + if cfg.RoomOutbox.ConsumerGroup = strings.TrimSpace(cfg.RoomOutbox.ConsumerGroup); cfg.RoomOutbox.ConsumerGroup == "" { + cfg.RoomOutbox.ConsumerGroup = defaults.RoomOutbox.ConsumerGroup + } + if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 { + cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes + } + if cfg.RoomOutbox.Enabled { + cfg.Enabled = true + } + if cfg.Enabled { + if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" { + return RocketMQConfig{}, errors.New("rocketmq name_servers or name_server_domain is required") + } + if (cfg.AccessKey == "") != (cfg.SecretKey == "") { + return RocketMQConfig{}, errors.New("rocketmq access_key and secret_key must be configured together") + } + } + return cfg, nil +} + +func normalizeStringSlice(input []string) []string { + out := make([]string, 0, len(input)) + for _, value := range input { + if value = strings.TrimSpace(value); value != "" { + out = append(out, value) + } + } + return out +} diff --git a/services/activity-service/internal/config/config_test.go b/services/activity-service/internal/config/config_test.go new file mode 100644 index 00000000..69191880 --- /dev/null +++ b/services/activity-service/internal/config/config_test.go @@ -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) + } +} diff --git a/services/activity-service/internal/domain/broadcast/broadcast.go b/services/activity-service/internal/domain/broadcast/broadcast.go index e271d516..d05ae24b 100644 --- a/services/activity-service/internal/domain/broadcast/broadcast.go +++ b/services/activity-service/internal/domain/broadcast/broadcast.go @@ -12,6 +12,8 @@ const ( TypeSuperGift = "super_gift" // TypeRedPacket 是红包入口播报,资金事实必须由 wallet/red-packet 领域持久化。 TypeRedPacket = "red_packet" + // TypeRoomTreasure 是语音房宝箱满能量后的开箱倒计时播报。 + TypeRoomTreasure = "room_treasure" // StatusPending 表示消息已持久化,尚未被 worker claim。 StatusPending = "pending" diff --git a/services/activity-service/internal/service/broadcast/service.go b/services/activity-service/internal/service/broadcast/service.go index cf3036ab..a9e80ded 100644 --- a/services/activity-service/internal/service/broadcast/service.go +++ b/services/activity-service/internal/service/broadcast/service.go @@ -266,8 +266,11 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev } eventCtx := appcode.WithContext(ctx, envelope.GetAppCode()) result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped} + if envelope.GetEventType() == "RoomTreasureCountdownStarted" { + return s.handleRoomTreasureCountdown(eventCtx, envelope) + } if envelope.GetEventType() != "RoomGiftSent" { - // 当前只消费礼物事实;未来红包或运营事件应显式增加事件类型分支和测试。 + // 当前只消费礼物和房间宝箱事实;未来红包或运营事件应显式增加事件类型分支和测试。 return result, nil } var gift roomeventsv1.RoomGiftSent @@ -301,6 +304,53 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev }, nil } +func (s *Service) handleRoomTreasureCountdown(ctx context.Context, envelope *roomeventsv1.EventEnvelope) (broadcastdomain.ConsumeRoomEventResult, error) { + result := broadcastdomain.ConsumeRoomEventResult{EventID: envelope.GetEventId(), Status: broadcastdomain.StatusSkipped} + var treasure roomeventsv1.RoomTreasureCountdownStarted + if err := proto.Unmarshal(envelope.GetBody(), &treasure); err != nil { + return broadcastdomain.ConsumeRoomEventResult{}, err + } + switch treasure.GetBroadcastScope() { + case broadcastdomain.ScopeRegion: + if treasure.GetVisibleRegionId() <= 0 { + return result, nil + } + case broadcastdomain.ScopeGlobal: + default: + return result, nil + } + + broadcastEventID := roomTreasureBroadcastEventID(envelope, &treasure) + payloadJSON, err := roomTreasurePayload(envelope, &treasure, broadcastEventID, s.now().UTC().UnixMilli()) + if err != nil { + return broadcastdomain.ConsumeRoomEventResult{}, err + } + var published broadcastdomain.PublishResult + if treasure.GetBroadcastScope() == broadcastdomain.ScopeGlobal { + published, err = s.PublishGlobalBroadcast(ctx, PublishInput{ + EventID: broadcastEventID, + BroadcastType: broadcastdomain.TypeRoomTreasure, + PayloadJSON: payloadJSON, + }) + } else { + published, err = s.PublishRegionBroadcast(ctx, PublishInput{ + EventID: broadcastEventID, + BroadcastType: broadcastdomain.TypeRoomTreasure, + RegionID: treasure.GetVisibleRegionId(), + PayloadJSON: payloadJSON, + }) + } + if err != nil { + return broadcastdomain.ConsumeRoomEventResult{}, err + } + return broadcastdomain.ConsumeRoomEventResult{ + EventID: envelope.GetEventId(), + Status: published.Status, + BroadcastEventID: published.EventID, + BroadcastCreated: published.Created, + }, nil +} + // WorkerOptions 描述一次 worker 批处理请求;cron 和常驻 worker 都走同一套参数归一化。 type WorkerOptions struct { WorkerID string @@ -423,6 +473,41 @@ func superGiftPayload(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.R return string(encoded), err } +func roomTreasureBroadcastEventID(envelope *roomeventsv1.EventEnvelope, treasure *roomeventsv1.RoomTreasureCountdownStarted) string { + if strings.TrimSpace(treasure.GetBoxId()) != "" { + return fmt.Sprintf("room_treasure_broadcast:%s:%s", envelope.GetRoomId(), treasure.GetBoxId()) + } + return "room_treasure_broadcast:" + envelope.GetEventId() +} + +func roomTreasurePayload(envelope *roomeventsv1.EventEnvelope, treasure *roomeventsv1.RoomTreasureCountdownStarted, eventID string, sentAtMS int64) (string, error) { + payload := map[string]any{ + "event_id": eventID, + "broadcast_type": broadcastdomain.TypeRoomTreasure, + "scope": treasure.GetBroadcastScope(), + "app_code": envelope.GetAppCode(), + "region_id": treasure.GetVisibleRegionId(), + "room_id": envelope.GetRoomId(), + "box_id": treasure.GetBoxId(), + "level": treasure.GetLevel(), + "open_at_ms": treasure.GetOpenAtMs(), + "top1_user_id": treasure.GetTop1UserId(), + "igniter_user_id": treasure.GetIgniterUserId(), + "sent_at_ms": sentAtMS, + "room_version": envelope.GetRoomVersion(), + "source_event_id": envelope.GetEventId(), + "current_progress": treasure.GetCurrentProgress(), + "energy_threshold": treasure.GetEnergyThreshold(), + "countdown_started_at_ms": treasure.GetCountdownStartedAtMs(), + "action": map[string]any{ + "type": "enter_room", + "room_id": envelope.GetRoomId(), + }, + } + encoded, err := json.Marshal(payload) + return string(encoded), err +} + func trimError(value string) string { value = strings.TrimSpace(value) if len(value) > 512 { diff --git a/services/gateway-service/internal/client/room_client.go b/services/gateway-service/internal/client/room_client.go index 584de4cc..ef141e0d 100644 --- a/services/gateway-service/internal/client/room_client.go +++ b/services/gateway-service/internal/client/room_client.go @@ -46,6 +46,7 @@ type RoomQueryClient interface { GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) (*roomv1.GetMyRoomResponse, error) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapshotRequest) (*roomv1.GetRoomSnapshotResponse, error) + GetRoomTreasure(ctx context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) } @@ -194,6 +195,10 @@ func (c *grpcRoomQueryClient) GetRoomSnapshot(ctx context.Context, req *roomv1.G return c.client.GetRoomSnapshot(ctx, req) } +func (c *grpcRoomQueryClient) GetRoomTreasure(ctx context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error) { + return c.client.GetRoomTreasure(ctx, req) +} + func (c *grpcRoomQueryClient) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) { return c.client.ListRoomOnlineUsers(ctx, req) } diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 978151cf..01d03c38 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -94,6 +94,7 @@ type RoomHandlers struct { GetRoomDetail http.HandlerFunc ListRoomOnlineUsers http.HandlerFunc GetRoomGiftPanel http.HandlerFunc + GetRoomTreasure http.HandlerFunc FollowRoom http.HandlerFunc CreateRoom http.HandlerFunc UpdateRoomProfile http.HandlerFunc @@ -285,6 +286,7 @@ func (r routes) registerRoomRoutes() { r.profile("/rooms/{room_id}/detail", http.MethodGet, h.GetRoomDetail) r.profile("/rooms/{room_id}/online-users", http.MethodGet, h.ListRoomOnlineUsers) r.profile("/rooms/{room_id}/gift-panel", http.MethodGet, h.GetRoomGiftPanel) + r.profile("/rooms/{room_id}/treasure", http.MethodGet, h.GetRoomTreasure) r.profile("/rooms/{room_id}/follow", "", h.FollowRoom) r.profile("/rooms/create", http.MethodPost, h.CreateRoom) r.profile("/rooms/profile/update", http.MethodPost, h.UpdateRoomProfile) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 6f450e00..628b3259 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -279,18 +279,21 @@ type fakeRoomQueryClient struct { lastMyRoom *roomv1.GetMyRoomRequest lastCurrent *roomv1.GetCurrentRoomRequest lastSnapshot *roomv1.GetRoomSnapshotRequest + lastTreasure *roomv1.GetRoomTreasureRequest lastOnline *roomv1.ListRoomOnlineUsersRequest resp *roomv1.ListRoomsResponse feedsResp *roomv1.ListRoomsResponse myRoomResp *roomv1.GetMyRoomResponse currentResp *roomv1.GetCurrentRoomResponse snapshotResp *roomv1.GetRoomSnapshotResponse + treasureResp *roomv1.GetRoomTreasureResponse onlineResp *roomv1.ListRoomOnlineUsersResponse err error feedsErr error myRoomErr error currentErr error snapshotErr error + treasureErr error onlineErr error } @@ -698,6 +701,17 @@ func (f *fakeRoomQueryClient) GetRoomSnapshot(_ context.Context, req *roomv1.Get return &roomv1.GetRoomSnapshotResponse{}, nil } +func (f *fakeRoomQueryClient) GetRoomTreasure(_ context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error) { + f.lastTreasure = req + if f.treasureErr != nil { + return nil, f.treasureErr + } + if f.treasureResp != nil { + return f.treasureResp, nil + } + return &roomv1.GetRoomTreasureResponse{}, nil +} + func (f *fakeRoomQueryClient) ListRoomOnlineUsers(_ context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) { f.lastOnline = req if f.onlineErr != nil { diff --git a/services/gateway-service/internal/transport/http/roomapi/handler.go b/services/gateway-service/internal/transport/http/roomapi/handler.go index 3c6a0e03..e6bcece8 100644 --- a/services/gateway-service/internal/transport/http/roomapi/handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/handler.go @@ -52,6 +52,7 @@ func (h *Handler) RoomHandlers() httproutes.RoomHandlers { GetRoomDetail: h.getRoomDetail, ListRoomOnlineUsers: h.listRoomOnlineUsers, GetRoomGiftPanel: h.getRoomGiftPanel, + GetRoomTreasure: h.getRoomTreasure, FollowRoom: h.handleRoomFollow, CreateRoom: h.createRoom, UpdateRoomProfile: h.updateRoomProfile, diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index 9dc3e464..0f32a575 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -514,6 +514,29 @@ func (h *Handler) getRoomGiftPanel(writer http.ResponseWriter, request *http.Req }) } +// getRoomTreasure 返回房间宝箱物料配置和当前进度。 +func (h *Handler) getRoomTreasure(writer http.ResponseWriter, request *http.Request) { + if h.roomQueryClient == nil { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + roomID := strings.TrimSpace(request.PathValue("room_id")) + if !roomid.ValidStringID(roomID) { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + resp, err := h.roomQueryClient.GetRoomTreasure(request.Context(), &roomv1.GetRoomTreasureRequest{ + Meta: httpkit.RoomMeta(request, roomID, ""), + RoomId: roomID, + ViewerUserId: auth.UserIDFromContext(request.Context()), + }) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } + httpkit.WriteOK(writer, request, roomTreasureDataFromProto(resp)) +} + // handleRoomFollow 建立或取消当前用户对房间的关注关系。 // 关注关系由 room-service 保存,gateway 只负责鉴权用户和 path room_id 的协议转换。 func (h *Handler) handleRoomFollow(writer http.ResponseWriter, request *http.Request) { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_view.go b/services/gateway-service/internal/transport/http/roomapi/room_view.go index 0a2c2de1..3836dee7 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_view.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_view.go @@ -115,6 +115,64 @@ type roomGiftPanelData struct { QuantityPresets []int32 `json:"quantity_presets"` } +type roomTreasureData struct { + Enabled bool `json:"enabled"` + Levels []roomTreasureLevelData `json:"levels"` + State roomTreasureStateData `json:"state"` + BroadcastScope string `json:"broadcast_scope"` + OpenDelayMS int64 `json:"open_delay_ms"` + BroadcastDelayMS int64 `json:"broadcast_delay_ms"` + RewardStackPolicy string `json:"reward_stack_policy"` + ServerTimeMS int64 `json:"server_time_ms"` +} + +type roomTreasureLevelData struct { + Level int32 `json:"level"` + EnergyThreshold int64 `json:"energy_threshold"` + CoverURL string `json:"cover_url,omitempty"` + AnimationURL string `json:"animation_url,omitempty"` + OpeningAnimationURL string `json:"opening_animation_url,omitempty"` + OpenedImageURL string `json:"opened_image_url,omitempty"` + InRoomRewards []roomTreasureRewardData `json:"in_room_rewards"` + Top1Rewards []roomTreasureRewardData `json:"top1_rewards"` + IgniterRewards []roomTreasureRewardData `json:"igniter_rewards"` +} + +type roomTreasureRewardData struct { + RewardItemID string `json:"reward_item_id"` + ResourceGroupID int64 `json:"resource_group_id"` + Weight int64 `json:"weight,omitempty"` + DisplayName string `json:"display_name,omitempty"` + IconURL string `json:"icon_url,omitempty"` +} + +type roomTreasureStateData struct { + CurrentLevel int32 `json:"current_level"` + CurrentProgress int64 `json:"current_progress"` + EnergyThreshold int64 `json:"energy_threshold"` + Status string `json:"status"` + CountdownStartedAtMS int64 `json:"countdown_started_at_ms,omitempty"` + OpenAtMS int64 `json:"open_at_ms,omitempty"` + OpenedAtMS int64 `json:"opened_at_ms,omitempty"` + ResetAtMS int64 `json:"reset_at_ms"` + Top1UserID string `json:"top1_user_id,omitempty"` + IgniterUserID string `json:"igniter_user_id,omitempty"` + BoxID string `json:"box_id,omitempty"` + ConfigVersion int64 `json:"config_version,omitempty"` + LastRewards []roomTreasureRewardGrantData `json:"last_rewards,omitempty"` +} + +type roomTreasureRewardGrantData struct { + RewardRole string `json:"reward_role"` + UserID string `json:"user_id"` + RewardItemID string `json:"reward_item_id"` + ResourceGroupID int64 `json:"resource_group_id"` + DisplayName string `json:"display_name,omitempty"` + IconURL string `json:"icon_url,omitempty"` + GrantID string `json:"grant_id,omitempty"` + Status string `json:"status,omitempty"` +} + type roomGiftRecipientData struct { TargetType string `json:"target_type"` UserID string `json:"user_id,omitempty"` @@ -245,6 +303,93 @@ func myRoomDataFromProto(resp *roomv1.GetMyRoomResponse) myRoomData { return data } +func roomTreasureDataFromProto(resp *roomv1.GetRoomTreasureResponse) roomTreasureData { + if resp == nil || resp.GetTreasure() == nil { + return roomTreasureData{} + } + treasure := resp.GetTreasure() + return roomTreasureData{ + Enabled: treasure.GetEnabled(), + Levels: roomTreasureLevelsFromProto(treasure.GetLevels()), + State: roomTreasureStateFromProto(treasure.GetState()), + BroadcastScope: treasure.GetBroadcastScope(), + OpenDelayMS: treasure.GetOpenDelayMs(), + BroadcastDelayMS: treasure.GetBroadcastDelayMs(), + RewardStackPolicy: treasure.GetRewardStackPolicy(), + ServerTimeMS: resp.GetServerTimeMs(), + } +} + +func roomTreasureLevelsFromProto(levels []*roomv1.RoomTreasureLevel) []roomTreasureLevelData { + out := make([]roomTreasureLevelData, 0, len(levels)) + for _, level := range levels { + out = append(out, roomTreasureLevelData{ + Level: level.GetLevel(), + EnergyThreshold: level.GetEnergyThreshold(), + CoverURL: level.GetCoverUrl(), + AnimationURL: level.GetAnimationUrl(), + OpeningAnimationURL: level.GetOpeningAnimationUrl(), + OpenedImageURL: level.GetOpenedImageUrl(), + InRoomRewards: roomTreasureRewardsFromProto(level.GetInRoomRewards()), + Top1Rewards: roomTreasureRewardsFromProto(level.GetTop1Rewards()), + IgniterRewards: roomTreasureRewardsFromProto(level.GetIgniterRewards()), + }) + } + return out +} + +func roomTreasureRewardsFromProto(rewards []*roomv1.RoomTreasureRewardItem) []roomTreasureRewardData { + out := make([]roomTreasureRewardData, 0, len(rewards)) + for _, reward := range rewards { + out = append(out, roomTreasureRewardData{ + RewardItemID: reward.GetRewardItemId(), + ResourceGroupID: reward.GetResourceGroupId(), + Weight: reward.GetWeight(), + DisplayName: reward.GetDisplayName(), + IconURL: reward.GetIconUrl(), + }) + } + return out +} + +func roomTreasureStateFromProto(state *roomv1.RoomTreasureState) roomTreasureStateData { + if state == nil { + return roomTreasureStateData{} + } + return roomTreasureStateData{ + CurrentLevel: state.GetCurrentLevel(), + CurrentProgress: state.GetCurrentProgress(), + EnergyThreshold: state.GetEnergyThreshold(), + Status: state.GetStatus(), + CountdownStartedAtMS: state.GetCountdownStartedAtMs(), + OpenAtMS: state.GetOpenAtMs(), + OpenedAtMS: state.GetOpenedAtMs(), + ResetAtMS: state.GetResetAtMs(), + Top1UserID: formatOptionalUserID(state.GetTop1UserId()), + IgniterUserID: formatOptionalUserID(state.GetIgniterUserId()), + BoxID: state.GetBoxId(), + ConfigVersion: state.GetConfigVersion(), + LastRewards: roomTreasureRewardGrantsFromProto(state.GetLastRewards()), + } +} + +func roomTreasureRewardGrantsFromProto(rewards []*roomv1.RoomTreasureRewardGrant) []roomTreasureRewardGrantData { + out := make([]roomTreasureRewardGrantData, 0, len(rewards)) + for _, reward := range rewards { + out = append(out, roomTreasureRewardGrantData{ + RewardRole: reward.GetRewardRole(), + UserID: formatOptionalUserID(reward.GetUserId()), + RewardItemID: reward.GetRewardItemId(), + ResourceGroupID: reward.GetResourceGroupId(), + DisplayName: reward.GetDisplayName(), + IconURL: reward.GetIconUrl(), + GrantID: reward.GetGrantId(), + Status: reward.GetStatus(), + }) + } + return out +} + func roomListItemDataFromProto(room *roomv1.RoomListItem) roomListItemData { if room == nil { return roomListItemData{} diff --git a/services/notice-service/configs/config.docker.yaml b/services/notice-service/configs/config.docker.yaml index fb720f83..448f29de 100644 --- a/services/notice-service/configs/config.docker.yaml +++ b/services/notice-service/configs/config.docker.yaml @@ -37,6 +37,20 @@ room_notice_worker: initial_backoff: 5s max_backoff: 5m +rocketmq: + # Docker 本地默认关闭;接入外部 RocketMQ 后可消费 room_outbox 私有通知事实。 + enabled: false + name_servers: [] + name_server_domain: "" + access_key: "" + secret_key: "" + namespace: "" + room_outbox: + enabled: false + topic: "hyapp_room_outbox" + consumer_group: "hyapp-notice-room-outbox" + consumer_max_reconsume_times: 16 + log: level: info format: json diff --git a/services/notice-service/configs/config.tencent.example.yaml b/services/notice-service/configs/config.tencent.example.yaml index 36b69b0a..7f1ebf32 100644 --- a/services/notice-service/configs/config.tencent.example.yaml +++ b/services/notice-service/configs/config.tencent.example.yaml @@ -37,6 +37,20 @@ room_notice_worker: initial_backoff: 5s max_backoff: 5m +rocketmq: + enabled: true + name_servers: + - "TENCENT_ROCKETMQ_NAMESERVER:9876" + name_server_domain: "" + access_key: "TENCENT_ROCKETMQ_ACCESS_KEY" + secret_key: "TENCENT_ROCKETMQ_SECRET_KEY" + namespace: "hyapp-prod" + room_outbox: + enabled: true + topic: "hyapp_room_outbox" + consumer_group: "hyapp-notice-room-outbox" + consumer_max_reconsume_times: 16 + log: level: info format: json diff --git a/services/notice-service/configs/config.yaml b/services/notice-service/configs/config.yaml index 65b485de..54f9edad 100644 --- a/services/notice-service/configs/config.yaml +++ b/services/notice-service/configs/config.yaml @@ -37,6 +37,20 @@ room_notice_worker: initial_backoff: 5s max_backoff: 5m +rocketmq: + # 本地默认关闭;开启后 notice 直接消费 room_outbox MQ fanout,仍写 notice_delivery_events 做幂等。 + enabled: false + name_servers: [] + name_server_domain: "" + access_key: "" + secret_key: "" + namespace: "" + room_outbox: + enabled: false + topic: "hyapp_room_outbox" + consumer_group: "hyapp-notice-room-outbox" + consumer_max_reconsume_times: 16 + log: level: info format: json diff --git a/services/notice-service/internal/app/app.go b/services/notice-service/internal/app/app.go index 800f8cb8..b9f58254 100644 --- a/services/notice-service/internal/app/app.go +++ b/services/notice-service/internal/app/app.go @@ -4,16 +4,20 @@ package app import ( "context" "errors" + "log/slog" "net" "sync" "time" "google.golang.org/grpc" healthgrpc "google.golang.org/grpc/health/grpc_health_v1" + "hyapp/pkg/appcode" "hyapp/pkg/grpchealth" "hyapp/pkg/grpcshutdown" "hyapp/pkg/healthhttp" "hyapp/pkg/logx" + "hyapp/pkg/rocketmqx" + "hyapp/pkg/roommq" "hyapp/pkg/tencentim" "hyapp/services/notice-service/internal/config" "hyapp/services/notice-service/internal/modules/roomnotice" @@ -34,6 +38,7 @@ type App struct { roomNoticeService *roomnotice.Service roomWorkerOptions roomnotice.RoomNoticeWorkerOptions roomWorkerEnabled bool + mqConsumers []*rocketmqx.Consumer workerCtx context.Context workerCancel context.CancelFunc workerWG sync.WaitGroup @@ -87,6 +92,11 @@ func New(cfg config.Config) (*App, error) { _ = store.Close() return nil, errors.New("room notice worker requires tencent_im.enabled") } + if cfg.RocketMQ.RoomOutbox.Enabled && publisher == nil { + _ = listener.Close() + _ = store.Close() + return nil, errors.New("room outbox mq consumer requires tencent_im.enabled") + } server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("notice-service"))) health := grpchealth.NewServingChecker("notice-service", grpchealth.Dependency{ @@ -101,6 +111,31 @@ func New(cfg config.Config) (*App, error) { return nil, err } + walletSvc := walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher) + roomSvc := roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher) + roomOptions := roomNoticeWorkerOptions(cfg.RoomNoticeWorker) + mqConsumers := make([]*rocketmqx.Consumer, 0, 1) + if cfg.RocketMQ.RoomOutbox.Enabled { + consumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ)) + if err != nil { + _ = listener.Close() + _ = store.Close() + return nil, err + } + if err := consumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { + envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body) + if err != nil { + return err + } + _, err = roomSvc.ProcessRoomOutboxEnvelope(appcode.WithContext(ctx, envelope.GetAppCode()), envelope, roomOptions) + return err + }); err != nil { + _ = listener.Close() + _ = store.Close() + return nil, err + } + mqConsumers = append(mqConsumers, consumer) + } workerCtx, workerCancel := context.WithCancel(context.Background()) return &App{ server: server, @@ -108,12 +143,13 @@ func New(cfg config.Config) (*App, error) { health: health, healthHTTP: healthHTTP, store: store, - walletNoticeService: walletnotice.New(walletnotice.Config{NodeID: cfg.NodeID}, walletRepo, publisher), + walletNoticeService: walletSvc, walletWorkerOptions: walletNoticeWorkerOptions(cfg.WalletNoticeWorker), walletWorkerEnabled: cfg.WalletNoticeWorker.Enabled, - roomNoticeService: roomnotice.New(roomnotice.Config{NodeID: cfg.NodeID}, roomRepo, publisher), - roomWorkerOptions: roomNoticeWorkerOptions(cfg.RoomNoticeWorker), + roomNoticeService: roomSvc, + roomWorkerOptions: roomOptions, roomWorkerEnabled: cfg.RoomNoticeWorker.Enabled, + mqConsumers: mqConsumers, workerCtx: workerCtx, workerCancel: workerCancel, }, nil @@ -123,11 +159,16 @@ func New(cfg config.Config) (*App, error) { func (a *App) Run() error { a.runHealthHTTP() a.health.MarkServing() + if err := a.startMQ(); err != nil { + a.health.MarkStopped() + return err + } defer func() { if a.workerCancel != nil { a.workerCancel() } a.workerWG.Wait() + a.shutdownMQ() a.health.MarkStopped() }() if a.walletWorkerEnabled && a.walletNoticeService != nil { @@ -155,6 +196,7 @@ func (a *App) Close() { a.workerCancel() } a.workerWG.Wait() + a.shutdownMQ() grpcshutdown.GracefulStop(a.server, 15*time.Second) a.closeHealthHTTP() if a.store != nil { @@ -206,3 +248,40 @@ func roomNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) roomnotice.Roo MaxBackoff: cfg.MaxBackoff, } } + +func (a *App) startMQ() error { + for _, consumer := range a.mqConsumers { + if err := consumer.Start(); err != nil { + a.shutdownMQ() + return err + } + } + return nil +} + +func (a *App) shutdownMQ() { + for _, consumer := range a.mqConsumers { + if err := consumer.Shutdown(); err != nil { + logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error())) + } + } +} + +func rocketMQConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig { + return rocketmqx.ConsumerConfig{ + EndpointConfig: rocketmqx.EndpointConfig{ + NameServers: cfg.NameServers, + NameServerDomain: cfg.NameServerDomain, + AccessKey: cfg.AccessKey, + SecretKey: cfg.SecretKey, + SecurityToken: cfg.SecurityToken, + Namespace: cfg.Namespace, + VIPChannel: cfg.VIPChannel, + }, + GroupName: cfg.RoomOutbox.ConsumerGroup, + MaxReconsumeTimes: cfg.RoomOutbox.ConsumerMaxReconsumeTimes, + ConsumeRetryDelay: time.Second, + ConsumePullBatch: 32, + ConsumePullTimeout: 15 * time.Minute, + } +} diff --git a/services/notice-service/internal/config/config.go b/services/notice-service/internal/config/config.go index dcfcb388..83018cd1 100644 --- a/services/notice-service/internal/config/config.go +++ b/services/notice-service/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "errors" "net/http" "strings" "time" @@ -30,8 +31,10 @@ type Config struct { WalletNoticeWorker WalletNoticeWorkerConfig `yaml:"wallet_notice_worker"` // RoomNoticeWorker 消费 room_outbox 中需要给单个用户发送的房间通知。 RoomNoticeWorker WalletNoticeWorkerConfig `yaml:"room_notice_worker"` - MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` - Log logx.Config `yaml:"log"` + // RocketMQ 可直接消费 room-service 发布的 room_outbox fanout topic。 + RocketMQ RocketMQConfig `yaml:"rocketmq"` + MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` + Log logx.Config `yaml:"log"` } // TencentIMConfig 描述服务端调用腾讯云 IM REST API 的配置。 @@ -73,6 +76,27 @@ type WalletNoticeWorkerConfig struct { MaxBackoff time.Duration `yaml:"max_backoff"` } +// RocketMQConfig 描述 notice-service 消费房间事实的 MQ 连接。 +type RocketMQConfig struct { + Enabled bool `yaml:"enabled"` + NameServers []string `yaml:"name_servers"` + NameServerDomain string `yaml:"name_server_domain"` + AccessKey string `yaml:"access_key"` + SecretKey string `yaml:"secret_key"` + SecurityToken string `yaml:"security_token"` + Namespace string `yaml:"namespace"` + VIPChannel bool `yaml:"vip_channel"` + RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` +} + +// RoomOutboxMQConfig 控制 notice 对 room_outbox topic 的消费位点。 +type RoomOutboxMQConfig struct { + Enabled bool `yaml:"enabled"` + Topic string `yaml:"topic"` + ConsumerGroup string `yaml:"consumer_group"` + ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` +} + // Default 返回本地开发默认配置。 func Default() Config { return Config{ @@ -112,6 +136,7 @@ func Default() Config { InitialBackoff: 5 * time.Second, MaxBackoff: 5 * time.Minute, }, + RocketMQ: defaultRocketMQConfig(), Log: logx.Config{ Level: "info", Format: "json", @@ -120,6 +145,18 @@ func Default() Config { } } +func defaultRocketMQConfig() RocketMQConfig { + return RocketMQConfig{ + Enabled: false, + RoomOutbox: RoomOutboxMQConfig{ + Enabled: false, + Topic: "hyapp_room_outbox", + ConsumerGroup: "hyapp-notice-room-outbox", + ConsumerMaxReconsumeTimes: 16, + }, + } +} + // Load 从独立 config.yaml 读取 notice-service 配置。 func Load(path string) (Config, error) { cfg := Default() @@ -159,6 +196,11 @@ func Load(path string) (Config, error) { } cfg.WalletNoticeWorker = normalizeWalletNoticeWorker(cfg.WalletNoticeWorker) cfg.RoomNoticeWorker = normalizeWalletNoticeWorker(cfg.RoomNoticeWorker) + rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ) + if err != nil { + return Config{}, err + } + cfg.RocketMQ = rocketMQ if strings.TrimSpace(cfg.TencentIM.AdminIdentifier) == "" { cfg.TencentIM.AdminIdentifier = "administrator" } @@ -174,6 +216,47 @@ func Load(path string) (Config, error) { return cfg, nil } +func normalizeRocketMQConfig(cfg RocketMQConfig) (RocketMQConfig, error) { + defaults := defaultRocketMQConfig() + cfg.NameServers = normalizeStringSlice(cfg.NameServers) + cfg.NameServerDomain = strings.TrimSpace(cfg.NameServerDomain) + cfg.AccessKey = strings.TrimSpace(cfg.AccessKey) + cfg.SecretKey = strings.TrimSpace(cfg.SecretKey) + cfg.SecurityToken = strings.TrimSpace(cfg.SecurityToken) + cfg.Namespace = strings.TrimSpace(cfg.Namespace) + if cfg.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" { + cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic + } + if cfg.RoomOutbox.ConsumerGroup = strings.TrimSpace(cfg.RoomOutbox.ConsumerGroup); cfg.RoomOutbox.ConsumerGroup == "" { + cfg.RoomOutbox.ConsumerGroup = defaults.RoomOutbox.ConsumerGroup + } + if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 { + cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes + } + if cfg.RoomOutbox.Enabled { + cfg.Enabled = true + } + if cfg.Enabled { + if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" { + return RocketMQConfig{}, errors.New("rocketmq name_servers or name_server_domain is required") + } + if (cfg.AccessKey == "") != (cfg.SecretKey == "") { + return RocketMQConfig{}, errors.New("rocketmq access_key and secret_key must be configured together") + } + } + return cfg, nil +} + +func normalizeStringSlice(input []string) []string { + out := make([]string, 0, len(input)) + for _, value := range input { + if value = strings.TrimSpace(value); value != "" { + out = append(out, value) + } + } + return out +} + func normalizeWalletNoticeWorker(cfg WalletNoticeWorkerConfig) WalletNoticeWorkerConfig { def := Default().WalletNoticeWorker if cfg.PollInterval <= 0 { diff --git a/services/notice-service/internal/config/config_test.go b/services/notice-service/internal/config/config_test.go new file mode 100644 index 00000000..69191880 --- /dev/null +++ b/services/notice-service/internal/config/config_test.go @@ -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) + } +} diff --git a/services/notice-service/internal/modules/roomnotice/mysql_repository.go b/services/notice-service/internal/modules/roomnotice/mysql_repository.go index ad979b12..2515e63b 100644 --- a/services/notice-service/internal/modules/roomnotice/mysql_repository.go +++ b/services/notice-service/internal/modules/roomnotice/mysql_repository.go @@ -172,6 +172,48 @@ func (r *MySQLRepository) claimRoomKickCandidate(ctx context.Context, candidate return event, true, nil } +// ClaimRoomKickEnvelope 把 MQ 收到的 room_outbox 信封认领到 notice_delivery_events。 +func (r *MySQLRepository) ClaimRoomKickEnvelope(ctx context.Context, workerID string, envelope *roomeventsv1.EventEnvelope, lockTTL time.Duration) (RoomKickEvent, bool, error) { + if r == nil || r.db == nil { + return RoomKickEvent{}, false, xerr.New(xerr.Unavailable, "notice repository is not configured") + } + workerID = strings.TrimSpace(workerID) + if workerID == "" { + return RoomKickEvent{}, false, xerr.New(xerr.InvalidArgument, "worker_id is required") + } + if lockTTL <= 0 { + lockTTL = 30 * time.Second + } + createdAtMS := int64(0) + if envelope != nil { + createdAtMS = envelope.GetOccurredAtMs() + } + event, err := roomKickEventFromEnvelope(envelope, createdAtMS) + if err != nil { + return RoomKickEvent{}, false, err + } + now := time.Now() + lockUntilMS := now.Add(lockTTL).UnixMilli() + nowMs := now.UnixMilli() + ctx = appcode.WithContext(ctx, event.AppCode) + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return RoomKickEvent{}, false, err + } + defer func() { _ = tx.Rollback() }() + + retryCount, claimed, err := r.claimDeliveryEvent(ctx, tx, event, workerID, lockUntilMS, nowMs) + if err != nil || !claimed { + return RoomKickEvent{}, false, err + } + event.RetryCount = retryCount + if err := tx.Commit(); err != nil { + return RoomKickEvent{}, false, err + } + return event, true, nil +} + func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, candidate roomKickCandidate) (RoomKickEvent, error) { var ( event RoomKickEvent @@ -202,6 +244,13 @@ func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, can if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil { return RoomKickEvent{}, err } + return roomKickEventFromEnvelope(&envelope, event.CreatedAtMS) +} + +func roomKickEventFromEnvelope(envelope *roomeventsv1.EventEnvelope, createdAtMS int64) (RoomKickEvent, error) { + if envelope == nil { + return RoomKickEvent{}, fmt.Errorf("room event envelope is required") + } if envelope.GetEventType() != eventRoomUserKicked { return RoomKickEvent{}, fmt.Errorf("unexpected room event type %q", envelope.GetEventType()) } @@ -209,10 +258,20 @@ func (r *MySQLRepository) lockRoomKickEvent(ctx context.Context, tx *sql.Tx, can if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { return RoomKickEvent{}, err } - event.RoomVersion = envelope.GetRoomVersion() - event.OccurredAtMS = envelope.GetOccurredAtMs() - event.ActorUserID = body.GetActorUserId() - event.TargetUserID = body.GetTargetUserId() + event := RoomKickEvent{ + AppCode: appcode.Normalize(envelope.GetAppCode()), + EventID: envelope.GetEventId(), + EventType: envelope.GetEventType(), + RoomID: envelope.GetRoomId(), + RoomVersion: envelope.GetRoomVersion(), + ActorUserID: body.GetActorUserId(), + TargetUserID: body.GetTargetUserId(), + OccurredAtMS: envelope.GetOccurredAtMs(), + CreatedAtMS: createdAtMS, + } + if event.AppCode == "" || event.EventID == "" || event.RoomID == "" { + return RoomKickEvent{}, fmt.Errorf("room kick event envelope is incomplete") + } if event.TargetUserID <= 0 { return RoomKickEvent{}, fmt.Errorf("room kick event target_user_id is invalid") } diff --git a/services/notice-service/internal/modules/roomnotice/service.go b/services/notice-service/internal/modules/roomnotice/service.go index dbed60d6..3ae5da5e 100644 --- a/services/notice-service/internal/modules/roomnotice/service.go +++ b/services/notice-service/internal/modules/roomnotice/service.go @@ -7,6 +7,7 @@ import ( "log/slog" "time" + roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/logx" "hyapp/pkg/tencentim" ) @@ -14,6 +15,7 @@ import ( // Repository 是 room notice 模块需要的持久化边界。 type Repository interface { ClaimRoomKickEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]RoomKickEvent, error) + ClaimRoomKickEnvelope(ctx context.Context, workerID string, envelope *roomeventsv1.EventEnvelope, lockTTL time.Duration) (RoomKickEvent, bool, error) MarkRoomKickDelivered(ctx context.Context, event RoomKickEvent, deliveredPayload []byte, nowMs int64) error MarkRoomKickRetryable(ctx context.Context, event RoomKickEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error MarkRoomKickFailed(ctx context.Context, event RoomKickEvent, retryCount int, lastErr string, nowMs int64) error @@ -64,6 +66,27 @@ func (s *Service) ProcessRoomKickNotices(ctx context.Context, options RoomNotice return processed, nil } +// ProcessRoomOutboxEnvelope handles one room_outbox MQ message. Unknown event +// types are acknowledged by returning handled=false; poison messages for known +// types still return an error so MQ can retry or dead-letter them. +func (s *Service) ProcessRoomOutboxEnvelope(ctx context.Context, envelope *roomeventsv1.EventEnvelope, options RoomNoticeWorkerOptions) (bool, error) { + if envelope == nil || envelope.GetEventType() != eventRoomUserKicked { + return false, nil + } + options = normalizeRoomNoticeWorkerOptions(options, s.cfg.NodeID) + if s.repository == nil { + return true, fmt.Errorf("notice repository is not configured") + } + if s.publisher == nil { + return true, fmt.Errorf("notice publisher is not configured") + } + event, claimed, err := s.repository.ClaimRoomKickEnvelope(ctx, options.WorkerID, envelope, options.LockTTL) + if err != nil || !claimed { + return true, err + } + return true, s.publishRoomKickEvent(ctx, event, options) +} + func (s *Service) publishRoomKickEvent(ctx context.Context, event RoomKickEvent, options RoomNoticeWorkerOptions) error { payload, err := roomKickNoticePayload(event) if err != nil { diff --git a/services/notice-service/internal/modules/roomnotice/service_test.go b/services/notice-service/internal/modules/roomnotice/service_test.go index a4e954d6..4fcf3ff4 100644 --- a/services/notice-service/internal/modules/roomnotice/service_test.go +++ b/services/notice-service/internal/modules/roomnotice/service_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/tencentim" ) @@ -114,6 +115,13 @@ func (r *fakeRepository) ClaimRoomKickEvents(context.Context, string, int, time. return r.events, nil } +func (r *fakeRepository) ClaimRoomKickEnvelope(context.Context, string, *roomeventsv1.EventEnvelope, time.Duration) (RoomKickEvent, bool, error) { + if len(r.events) == 0 { + return RoomKickEvent{}, false, nil + } + return r.events[0], true, nil +} + func (r *fakeRepository) MarkRoomKickDelivered(_ context.Context, event RoomKickEvent, _ []byte, _ int64) error { r.deliveredEventID = event.EventID return nil diff --git a/services/room-service/configs/config.docker.yaml b/services/room-service/configs/config.docker.yaml index 0c280792..289414e3 100644 --- a/services/room-service/configs/config.docker.yaml +++ b/services/room-service/configs/config.docker.yaml @@ -48,9 +48,34 @@ presence_stale_after: "2m" presence_stale_scan_interval: "30s" mic_publish_timeout: "15s" mic_publish_scan_interval: "1s" +room_treasure_open_scan_interval: "1s" +rocketmq: + # Docker 本地默认关闭;接入外部 RocketMQ 后可把 publish_mode 改为 mq 或 dual。 + enabled: false + name_servers: [] + name_server_domain: "" + access_key: "" + secret_key: "" + namespace: "" + send_timeout: "3s" + retry: 2 + room_outbox: + enabled: false + topic: "hyapp_room_outbox" + producer_group: "hyapp-room-outbox-producer" + tencent_im_consumer_enabled: false + tencent_im_consumer_group: "hyapp-room-im-bridge" + consumer_max_reconsume_times: 16 + treasure_open: + enabled: false + topic: "hyapp_room_treasure_open" + producer_group: "hyapp-room-treasure-open-producer" + consumer_group: "hyapp-room-treasure-open" + consumer_max_reconsume_times: 16 outbox_worker: # Docker 本地默认启动补偿 worker,验证 IM/activity 异步投递、退避和死信闭环。 enabled: true + publish_mode: "direct" poll_interval: "1s" batch_size: 100 publish_timeout: "3s" diff --git a/services/room-service/configs/config.tencent.example.yaml b/services/room-service/configs/config.tencent.example.yaml index ce820479..b3545fe3 100644 --- a/services/room-service/configs/config.tencent.example.yaml +++ b/services/room-service/configs/config.tencent.example.yaml @@ -57,9 +57,35 @@ presence_stale_after: "2m" presence_stale_scan_interval: "30s" mic_publish_timeout: "15s" mic_publish_scan_interval: "1s" -outbox_worker: - # 线上默认启动补偿 worker;腾讯云 IM/activity-service 抖动时不回滚 Room Cell 状态。 +room_treasure_open_scan_interval: "1s" +rocketmq: + # 线上建议开启:room_outbox 只投 MQ,activity/notice/IM bridge 各自用 consumer group 解耦消费。 enabled: true + name_servers: + - "TENCENT_ROCKETMQ_NAMESERVER:9876" + name_server_domain: "" + access_key: "TENCENT_ROCKETMQ_ACCESS_KEY" + secret_key: "TENCENT_ROCKETMQ_SECRET_KEY" + namespace: "hyapp-prod" + send_timeout: "3s" + retry: 2 + room_outbox: + enabled: true + topic: "hyapp_room_outbox" + producer_group: "hyapp-room-outbox-producer" + tencent_im_consumer_enabled: true + tencent_im_consumer_group: "hyapp-room-im-bridge" + consumer_max_reconsume_times: 16 + treasure_open: + enabled: true + topic: "hyapp_room_treasure_open" + producer_group: "hyapp-room-treasure-open-producer" + consumer_group: "hyapp-room-treasure-open" + consumer_max_reconsume_times: 16 +outbox_worker: + # mq 模式下 outbox worker 只把事实投 MQ,并顺带安排宝箱延迟开箱唤醒。 + enabled: true + publish_mode: "mq" poll_interval: "1s" batch_size: 100 publish_timeout: "3s" diff --git a/services/room-service/configs/config.yaml b/services/room-service/configs/config.yaml index af98075a..c0d89dd6 100644 --- a/services/room-service/configs/config.yaml +++ b/services/room-service/configs/config.yaml @@ -51,9 +51,34 @@ presence_stale_after: "2m" presence_stale_scan_interval: "30s" mic_publish_timeout: "15s" mic_publish_scan_interval: "1s" +room_treasure_open_scan_interval: "1s" +rocketmq: + # 本地默认关闭;开启后 room_outbox 可投 MQ,宝箱倒计时用延迟消息到点唤醒。 + enabled: false + name_servers: [] + name_server_domain: "" + access_key: "" + secret_key: "" + namespace: "" + send_timeout: "3s" + retry: 2 + room_outbox: + enabled: false + topic: "hyapp_room_outbox" + producer_group: "hyapp-room-outbox-producer" + tencent_im_consumer_enabled: false + tencent_im_consumer_group: "hyapp-room-im-bridge" + consumer_max_reconsume_times: 16 + treasure_open: + enabled: false + topic: "hyapp_room_treasure_open" + producer_group: "hyapp-room-treasure-open-producer" + consumer_group: "hyapp-room-treasure-open" + consumer_max_reconsume_times: 16 outbox_worker: # room_outbox 是腾讯云 IM 和 activity-service 的异步投递通道;失败退避重试,超过上限转死信。 enabled: true + publish_mode: "direct" poll_interval: "1s" batch_size: 100 publish_timeout: "3s" diff --git a/services/room-service/deploy/mysql/initdb/001_room_service.sql b/services/room-service/deploy/mysql/initdb/001_room_service.sql index f425d86c..f8716051 100644 --- a/services/room-service/deploy/mysql/initdb/001_room_service.sql +++ b/services/room-service/deploy/mysql/initdb/001_room_service.sql @@ -174,6 +174,25 @@ CREATE TABLE IF NOT EXISTS room_seat_configs ( PRIMARY KEY (app_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间麦位配置表'; +CREATE TABLE IF NOT EXISTS room_treasure_configs ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', + enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用房间宝箱', + config_version BIGINT NOT NULL DEFAULT 1 COMMENT '配置版本,后台每次保存递增', + energy_source VARCHAR(32) NOT NULL COMMENT '能量来源,默认按送礼价值累计', + open_delay_ms BIGINT NOT NULL COMMENT '能量满后开箱倒计时,UTC 毫秒时长', + broadcast_enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '倒计时开始后是否广播', + broadcast_scope VARCHAR(32) NOT NULL COMMENT '广播范围:none/region/global', + broadcast_delay_ms BIGINT NOT NULL DEFAULT 0 COMMENT '广播后到开箱的延迟,UTC 毫秒时长', + reward_stack_policy VARCHAR(32) NOT NULL COMMENT '同一用户多角色命中奖励时的叠加策略', + levels_json JSON NOT NULL COMMENT '7 级宝箱物料、阈值和奖励池配置', + gift_energy_rules_json JSON NOT NULL COMMENT '礼物能量倍率、固定能量和排除规则', + updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后更新管理员 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code), + KEY idx_room_treasure_enabled (app_code, enabled) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房宝箱后台配置表'; + INSERT IGNORE INTO room_seat_configs ( app_code, allowed_seat_counts, @@ -187,3 +206,43 @@ INSERT IGNORE INTO room_seat_configs ( 0, 0 ); + +INSERT IGNORE INTO room_treasure_configs ( + app_code, + enabled, + config_version, + energy_source, + open_delay_ms, + broadcast_enabled, + broadcast_scope, + broadcast_delay_ms, + reward_stack_policy, + levels_json, + gift_energy_rules_json, + updated_by_admin_id, + created_at_ms, + updated_at_ms +) VALUES ( + 'lalu', + 0, + 1, + 'gift_point_added', + 30000, + 1, + 'region', + 0, + 'allow_stack', + JSON_ARRAY( + JSON_OBJECT('level', 1, 'energyThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 2, 'energyThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 3, 'energyThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 4, 'energyThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 5, 'energyThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 6, 'energyThreshold', 21000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 7, 'energyThreshold', 28000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()) + ), + JSON_ARRAY(), + 0, + 0, + 0 +); diff --git a/services/room-service/internal/app/app.go b/services/room-service/internal/app/app.go index 0831f6dc..18ed71bb 100644 --- a/services/room-service/internal/app/app.go +++ b/services/room-service/internal/app/app.go @@ -14,10 +14,13 @@ import ( healthgrpc "google.golang.org/grpc/health/grpc_health_v1" activityv1 "hyapp.local/api/proto/activity/v1" roomv1 "hyapp.local/api/proto/room/v1" + "hyapp/pkg/appcode" "hyapp/pkg/grpchealth" "hyapp/pkg/grpcshutdown" "hyapp/pkg/healthhttp" "hyapp/pkg/logx" + "hyapp/pkg/rocketmqx" + "hyapp/pkg/roommq" "hyapp/pkg/tencentim" "hyapp/pkg/tencentrtc" "hyapp/services/room-service/internal/config" @@ -53,6 +56,10 @@ type App struct { health *healthcheck.State // healthHTTP 给 CLB 和发布脚本提供 HTTP readiness,不承载业务请求。 healthHTTP *healthhttp.Server + // mqProducers 在 outbox worker 启动前打开,承载 fanout 和延迟开箱唤醒。 + mqProducers []*rocketmqx.Producer + // mqConsumers 在 gRPC 服务启动前订阅,承接 room_outbox fanout 和 delayed open。 + mqConsumers []*rocketmqx.Consumer // workerCtx 统一控制本节点后台 worker,关闭时必须先停止 worker 再释放 MySQL/Redis。 workerCtx context.Context // workerCancel 停止 presence stale 和 outbox 补偿 worker。 @@ -76,10 +83,13 @@ func New(cfg config.Config) (*App, error) { if err != nil { return nil, err } - activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) - if err != nil { - _ = walletConn.Close() - return nil, err + var activityConn *grpc.ClientConn + if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual { + activityConn, err = grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + _ = walletConn.Close() + return nil, err + } } startupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -88,7 +98,7 @@ func New(cfg config.Config) (*App, error) { // MySQL 是房间恢复的持久来源,启动阶段必须确认连接池可用。 repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN, cfg.MySQLMaxOpenConns, cfg.MySQLMaxIdleConns) if err != nil { - _ = activityConn.Close() + closeClientConn(activityConn) _ = walletConn.Close() return nil, err } @@ -97,7 +107,7 @@ func New(cfg config.Config) (*App, error) { // 本地和测试环境可自动建表,线上应通过显式迁移控制结构变更。 if err := repository.Migrate(startupCtx); err != nil { _ = repository.Close() - _ = activityConn.Close() + closeClientConn(activityConn) _ = walletConn.Close() return nil, err } @@ -107,53 +117,145 @@ func New(cfg config.Config) (*App, error) { redisClient, err := router.NewRedisClient(startupCtx, cfg.RedisAddr, cfg.RedisPassword, cfg.RedisDB) if err != nil { _ = repository.Close() - _ = activityConn.Close() + closeClientConn(activityConn) _ = walletConn.Close() return nil, err } directory := router.NewRedisDirectory(redisClient) roomPublisher := integration.NewNoopRoomEventPublisher() - activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second) - outboxPublishers := []integration.OutboxPublisher{activityPublisher} + outboxPublishers := make([]integration.OutboxPublisher, 0, 3) var rtcUserRemover integration.RTCUserRemover + var tencentPublisher *integration.TencentIMPublisher if cfg.TencentIM.Enabled { // 腾讯云 IM 替代自研 IM;room-service 只负责服务端 REST 群消息桥接。 tencentClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig()) if err != nil { _ = repository.Close() - _ = activityConn.Close() + closeClientConn(activityConn) _ = walletConn.Close() _ = redisClient.Close() return nil, err } - tencentPublisher := integration.NewTencentIMPublisher(tencentClient) + tencentPublisher = integration.NewTencentIMPublisher(tencentClient) roomPublisher = tencentPublisher - outboxPublishers = append(outboxPublishers, tencentPublisher) } if cfg.TencentRTC.Enabled { rtcClient, err := tencentrtc.NewRESTClient(cfg.TencentRTC.RESTConfig()) if err != nil { _ = repository.Close() - _ = activityConn.Close() + closeClientConn(activityConn) _ = walletConn.Close() _ = redisClient.Close() return nil, err } rtcUserRemover = rtcClient } + + mqProducers := make([]*rocketmqx.Producer, 0, 2) + mqConsumers := make([]*rocketmqx.Consumer, 0, 2) + var treasureOpenScheduler integration.RoomTreasureOpenScheduler + if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual { + if activityConn != nil { + activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second) + outboxPublishers = append(outboxPublishers, activityPublisher) + } + if tencentPublisher != nil { + outboxPublishers = append(outboxPublishers, tencentPublisher) + } + } + if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeMQ || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual { + outboxProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ProducerGroup)) + if err != nil { + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, err + } + mqProducers = append(mqProducers, outboxProducer) + outboxPublishers = append(outboxPublishers, integration.NewRocketMQOutboxPublisher(outboxProducer, cfg.RocketMQ.RoomOutbox.Topic)) + } + if cfg.RocketMQ.TreasureOpen.Enabled { + treasureProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ProducerGroup)) + if err != nil { + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, err + } + mqProducers = append(mqProducers, treasureProducer) + treasureOpenScheduler = integration.NewRocketMQTreasureOpenScheduler(treasureProducer, cfg.RocketMQ.TreasureOpen.Topic) + } outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...) // 领域服务只依赖接口,App 层负责选择 MySQL/Redis/gRPC 具体实现。 svc := roomservice.New(roomservice.Config{ - NodeID: cfg.NodeID, - LeaseTTL: cfg.LeaseTTL, - RankLimit: cfg.RankLimit, - SnapshotEveryN: cfg.SnapshotEveryN, - PresenceStaleAfter: cfg.PresenceStaleAfter, - MicPublishTimeout: cfg.MicPublishTimeout, - RTCUserRemover: rtcUserRemover, + NodeID: cfg.NodeID, + LeaseTTL: cfg.LeaseTTL, + RankLimit: cfg.RankLimit, + SnapshotEveryN: cfg.SnapshotEveryN, + PresenceStaleAfter: cfg.PresenceStaleAfter, + MicPublishTimeout: cfg.MicPublishTimeout, + RTCUserRemover: rtcUserRemover, + RoomTreasureOpenScheduler: treasureOpenScheduler, }, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher) + if cfg.RocketMQ.TreasureOpen.Enabled { + treasureConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ConsumerGroup, cfg.RocketMQ.TreasureOpen.ConsumerMaxReconsumeTimes)) + if err != nil { + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, err + } + if err := treasureConsumer.Subscribe(cfg.RocketMQ.TreasureOpen.Topic, roommq.TagRoomTreasureOpenDue, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { + due, err := roommq.DecodeRoomTreasureOpenDueMessage(message.Body) + if err != nil { + return err + } + return svc.HandleRoomTreasureOpenDue(ctx, due) + }); err != nil { + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, err + } + mqConsumers = append(mqConsumers, treasureConsumer) + } + if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled && tencentPublisher == nil { + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, errors.New("rocketmq room_outbox tencent_im consumer requires tencent_im.enabled") + } + if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled && tencentPublisher != nil { + imConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.TencentIMConsumerGroup, cfg.RocketMQ.RoomOutbox.ConsumerMaxReconsumeTimes)) + if err != nil { + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, err + } + if err := imConsumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error { + envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body) + if err != nil { + return err + } + return tencentPublisher.PublishOutboxEvent(appcode.WithContext(ctx, envelope.GetAppCode()), envelope) + }); err != nil { + _ = repository.Close() + closeClientConn(activityConn) + _ = walletConn.Close() + _ = redisClient.Close() + return nil, err + } + mqConsumers = append(mqConsumers, imConsumer) + } server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service"))) roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout)) @@ -165,7 +267,7 @@ func New(cfg config.Config) (*App, error) { healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(healthState)) healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, healthState) if err != nil { - _ = activityConn.Close() + closeClientConn(activityConn) _ = walletConn.Close() _ = repository.Close() _ = redisClient.Close() @@ -176,7 +278,7 @@ func New(cfg config.Config) (*App, error) { listener, err := net.Listen("tcp", cfg.GRPCAddr) if err != nil { _ = healthHTTP.Close(context.Background()) - _ = activityConn.Close() + closeClientConn(activityConn) _ = walletConn.Close() _ = repository.Close() _ = redisClient.Close() @@ -196,6 +298,8 @@ func New(cfg config.Config) (*App, error) { nodeRegistry: directory, health: healthState, healthHTTP: healthHTTP, + mqProducers: mqProducers, + mqConsumers: mqConsumers, workerCtx: workerCtx, workerCancel: workerCancel, }, nil @@ -210,11 +314,16 @@ func (a *App) Run() error { a.health.MarkGRPCStopped() return err } + if err := a.startMQ(); err != nil { + a.health.MarkGRPCStopped() + return err + } defer func() { if a.workerCancel != nil { a.workerCancel() } a.workerWG.Wait() + a.shutdownMQ() }() a.workerWG.Go(func() { a.runNodeRegistryHeartbeat(a.workerCtx) @@ -231,6 +340,12 @@ func (a *App) Run() error { a.service.RunMicPublishTimeoutWorker(a.workerCtx, a.cfg.MicPublishScanInterval) }) } + if a.cfg.RoomTreasureOpenScanInterval > 0 { + // 宝箱开箱仍通过 Room Cell 命令链路提交,worker 只负责触发到点的系统命令。 + a.workerWG.Go(func() { + a.service.RunRoomTreasureOpenWorker(a.workerCtx, a.cfg.RoomTreasureOpenScanInterval) + }) + } if a.cfg.OutboxWorker.Enabled { a.workerWG.Go(func() { a.service.RunOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{ @@ -268,6 +383,7 @@ func (a *App) Close() { a.workerCancel() } a.workerWG.Wait() + a.shutdownMQ() // GracefulStop 让已进入的 gRPC 请求自然结束。 grpcshutdown.GracefulStop(a.grpcServer, 15*time.Second) if a.service != nil { @@ -365,3 +481,70 @@ func (a *App) closeHealthHTTP() { defer cancel() _ = a.healthHTTP.Close(ctx) } + +func (a *App) startMQ() error { + for _, producer := range a.mqProducers { + if err := producer.Start(); err != nil { + a.shutdownMQ() + return err + } + } + for _, consumer := range a.mqConsumers { + if err := consumer.Start(); err != nil { + a.shutdownMQ() + return err + } + } + return nil +} + +func (a *App) shutdownMQ() { + for _, consumer := range a.mqConsumers { + if err := consumer.Shutdown(); err != nil { + logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error())) + } + } + for _, producer := range a.mqProducers { + if err := producer.Shutdown(); err != nil { + logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error())) + } + } +} + +func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig { + return rocketmqx.ProducerConfig{ + EndpointConfig: rocketMQEndpointConfig(cfg), + GroupName: groupName, + SendTimeout: cfg.SendTimeout, + Retry: cfg.Retry, + } +} + +func rocketMQConsumerConfig(cfg config.RocketMQConfig, groupName string, maxReconsumeTimes int32) rocketmqx.ConsumerConfig { + return rocketmqx.ConsumerConfig{ + EndpointConfig: rocketMQEndpointConfig(cfg), + GroupName: groupName, + MaxReconsumeTimes: maxReconsumeTimes, + ConsumeRetryDelay: time.Second, + ConsumePullBatch: 32, + ConsumePullTimeout: 15 * time.Minute, + } +} + +func rocketMQEndpointConfig(cfg config.RocketMQConfig) rocketmqx.EndpointConfig { + return rocketmqx.EndpointConfig{ + NameServers: cfg.NameServers, + NameServerDomain: cfg.NameServerDomain, + AccessKey: cfg.AccessKey, + SecretKey: cfg.SecretKey, + SecurityToken: cfg.SecurityToken, + Namespace: cfg.Namespace, + VIPChannel: cfg.VIPChannel, + } +} + +func closeClientConn(conn *grpc.ClientConn) { + if conn != nil { + _ = conn.Close() + } +} diff --git a/services/room-service/internal/config/config.go b/services/room-service/internal/config/config.go index 0f7b4486..eb7801ed 100644 --- a/services/room-service/internal/config/config.go +++ b/services/room-service/internal/config/config.go @@ -14,6 +14,12 @@ import ( "hyapp/pkg/tencentrtc" ) +const ( + OutboxPublishModeDirect = "direct" + OutboxPublishModeMQ = "mq" + OutboxPublishModeDual = "dual" +) + // Config 描述 room-service 进程启动所需的最小配置。 type Config struct { // ServiceName 是日志和部署识别使用的稳定服务名。 @@ -70,6 +76,10 @@ type Config struct { MicPublishTimeout time.Duration `yaml:"mic_publish_timeout"` // MicPublishScanInterval 是本节点扫描 pending_publish 超时麦位的周期。 MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"` + // RoomTreasureOpenScanInterval 是本节点扫描到点开箱宝箱的周期。 + RoomTreasureOpenScanInterval time.Duration `yaml:"room_treasure_open_scan_interval"` + // RocketMQ 承载 room_outbox 事件分发和语音房宝箱延迟开箱唤醒。 + RocketMQ RocketMQConfig `yaml:"rocketmq"` // OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。 OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` // Log 控制 stdout 结构化日志格式、等级和 access body 策略。 @@ -80,6 +90,8 @@ type Config struct { type OutboxWorkerConfig struct { // Enabled 为 false 时不启动补偿 worker,测试或临时止血可用。 Enabled bool `yaml:"enabled"` + // PublishMode 控制 room_outbox 发往 direct、mq 或 dual,避免 MQ 消费者与直接投递重复发同一条 IM。 + PublishMode string `yaml:"publish_mode"` // PollInterval 是两轮 pending outbox 扫描之间的固定间隔。 PollInterval time.Duration `yaml:"poll_interval"` // BatchSize 是单轮最多处理的 pending outbox 数,防止全表扫描。 @@ -96,6 +108,41 @@ type OutboxWorkerConfig struct { MaxBackoff time.Duration `yaml:"max_backoff"` } +// RocketMQConfig 描述 room-service 使用的 RocketMQ 集群和业务 topic。 +type RocketMQConfig struct { + Enabled bool `yaml:"enabled"` + NameServers []string `yaml:"name_servers"` + NameServerDomain string `yaml:"name_server_domain"` + AccessKey string `yaml:"access_key"` + SecretKey string `yaml:"secret_key"` + SecurityToken string `yaml:"security_token"` + Namespace string `yaml:"namespace"` + VIPChannel bool `yaml:"vip_channel"` + SendTimeout time.Duration `yaml:"send_timeout"` + Retry int `yaml:"retry"` + RoomOutbox RoomOutboxMQConfig `yaml:"room_outbox"` + TreasureOpen TreasureOpenMQConfig `yaml:"treasure_open"` +} + +// RoomOutboxMQConfig 控制 room_outbox fanout topic。 +type RoomOutboxMQConfig struct { + Enabled bool `yaml:"enabled"` + Topic string `yaml:"topic"` + ProducerGroup string `yaml:"producer_group"` + TencentIMConsumerEnabled bool `yaml:"tencent_im_consumer_enabled"` + TencentIMConsumerGroup string `yaml:"tencent_im_consumer_group"` + ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` +} + +// TreasureOpenMQConfig 控制宝箱延迟开箱唤醒 topic。 +type TreasureOpenMQConfig struct { + Enabled bool `yaml:"enabled"` + Topic string `yaml:"topic"` + ProducerGroup string `yaml:"producer_group"` + ConsumerGroup string `yaml:"consumer_group"` + ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"` +} + // TencentIMConfig 描述 room-service 调用腾讯云 IM REST API 的配置。 type TencentIMConfig struct { // Enabled 控制是否启用腾讯云 IM REST 桥接;关闭时只保留 Room Cell 内部提交。 @@ -200,17 +247,19 @@ func Default() Config { Endpoint: tencentrtc.DefaultRESTEndpoint, RequestTimeout: 5 * time.Second, }, - MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC", - MySQLMaxOpenConns: 20, - MySQLMaxIdleConns: 10, - MySQLAutoMigrate: true, - RedisAddr: "127.0.0.1:13379", - RedisDB: 0, - PresenceStaleAfter: 2 * time.Minute, - PresenceStaleScanInterval: 30 * time.Second, - MicPublishTimeout: 15 * time.Second, - MicPublishScanInterval: time.Second, - OutboxWorker: defaultOutboxWorkerConfig(), + MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=UTC", + MySQLMaxOpenConns: 20, + MySQLMaxIdleConns: 10, + MySQLAutoMigrate: true, + RedisAddr: "127.0.0.1:13379", + RedisDB: 0, + PresenceStaleAfter: 2 * time.Minute, + PresenceStaleScanInterval: 30 * time.Second, + MicPublishTimeout: 15 * time.Second, + MicPublishScanInterval: time.Second, + RoomTreasureOpenScanInterval: time.Second, + RocketMQ: defaultRocketMQConfig(), + OutboxWorker: defaultOutboxWorkerConfig(), Log: logx.Config{ Level: "info", Format: "json", @@ -223,6 +272,7 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig { // 默认启动补偿 worker,使用退避和死信避免外部故障刷屏。 return OutboxWorkerConfig{ Enabled: true, + PublishMode: OutboxPublishModeDirect, PollInterval: time.Second, BatchSize: 100, PublishTimeout: 3 * time.Second, @@ -233,6 +283,29 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig { } } +func defaultRocketMQConfig() RocketMQConfig { + return RocketMQConfig{ + Enabled: false, + SendTimeout: 3 * time.Second, + Retry: 2, + RoomOutbox: RoomOutboxMQConfig{ + Enabled: false, + Topic: "hyapp_room_outbox", + ProducerGroup: "hyapp-room-outbox-producer", + TencentIMConsumerEnabled: false, + TencentIMConsumerGroup: "hyapp-room-im-bridge", + ConsumerMaxReconsumeTimes: 16, + }, + TreasureOpen: TreasureOpenMQConfig{ + Enabled: false, + Topic: "hyapp_room_treasure_open", + ProducerGroup: "hyapp-room-treasure-open-producer", + ConsumerGroup: "hyapp-room-treasure-open", + ConsumerMaxReconsumeTimes: 16, + }, + } +} + // Normalize 补齐动态配置默认值,并拒绝会改变补偿语义的未知策略。 func Normalize(cfg Config) (Config, error) { // 配置归一化只处理需要跨环境保持语义的字段,其他默认值由 Default 提供。 @@ -301,6 +374,11 @@ func Normalize(cfg Config) (Config, error) { return Config{}, err } cfg.OutboxWorker = outboxWorker + rocketMQ, err := normalizeRocketMQConfig(cfg.RocketMQ, cfg.OutboxWorker.PublishMode) + if err != nil { + return Config{}, err + } + cfg.RocketMQ = rocketMQ return cfg, nil } @@ -350,10 +428,86 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er // 当前只实现指数退避;未知策略直接启动失败,避免误以为退避或死信已生效。 return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker retry_strategy %q", cfg.RetryStrategy) } + cfg.PublishMode = strings.ToLower(strings.TrimSpace(cfg.PublishMode)) + if cfg.PublishMode == "" { + cfg.PublishMode = defaults.PublishMode + } + switch cfg.PublishMode { + case OutboxPublishModeDirect, OutboxPublishModeMQ, OutboxPublishModeDual: + default: + return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker publish_mode %q", cfg.PublishMode) + } return cfg, nil } +func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQConfig, error) { + defaults := defaultRocketMQConfig() + cfg.NameServers = normalizeStringSlice(cfg.NameServers) + cfg.NameServerDomain = strings.TrimSpace(cfg.NameServerDomain) + cfg.AccessKey = strings.TrimSpace(cfg.AccessKey) + cfg.SecretKey = strings.TrimSpace(cfg.SecretKey) + cfg.SecurityToken = strings.TrimSpace(cfg.SecurityToken) + cfg.Namespace = strings.TrimSpace(cfg.Namespace) + if cfg.SendTimeout <= 0 { + cfg.SendTimeout = defaults.SendTimeout + } + if cfg.Retry < 0 { + cfg.Retry = defaults.Retry + } + if cfg.RoomOutbox.Topic = strings.TrimSpace(cfg.RoomOutbox.Topic); cfg.RoomOutbox.Topic == "" { + cfg.RoomOutbox.Topic = defaults.RoomOutbox.Topic + } + if cfg.RoomOutbox.ProducerGroup = strings.TrimSpace(cfg.RoomOutbox.ProducerGroup); cfg.RoomOutbox.ProducerGroup == "" { + cfg.RoomOutbox.ProducerGroup = defaults.RoomOutbox.ProducerGroup + } + if cfg.RoomOutbox.TencentIMConsumerGroup = strings.TrimSpace(cfg.RoomOutbox.TencentIMConsumerGroup); cfg.RoomOutbox.TencentIMConsumerGroup == "" { + cfg.RoomOutbox.TencentIMConsumerGroup = defaults.RoomOutbox.TencentIMConsumerGroup + } + if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 { + cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes + } + if cfg.TreasureOpen.Topic = strings.TrimSpace(cfg.TreasureOpen.Topic); cfg.TreasureOpen.Topic == "" { + cfg.TreasureOpen.Topic = defaults.TreasureOpen.Topic + } + if cfg.TreasureOpen.ProducerGroup = strings.TrimSpace(cfg.TreasureOpen.ProducerGroup); cfg.TreasureOpen.ProducerGroup == "" { + cfg.TreasureOpen.ProducerGroup = defaults.TreasureOpen.ProducerGroup + } + if cfg.TreasureOpen.ConsumerGroup = strings.TrimSpace(cfg.TreasureOpen.ConsumerGroup); cfg.TreasureOpen.ConsumerGroup == "" { + cfg.TreasureOpen.ConsumerGroup = defaults.TreasureOpen.ConsumerGroup + } + if cfg.TreasureOpen.ConsumerMaxReconsumeTimes <= 0 { + cfg.TreasureOpen.ConsumerMaxReconsumeTimes = defaults.TreasureOpen.ConsumerMaxReconsumeTimes + } + if cfg.RoomOutbox.Enabled || cfg.TreasureOpen.Enabled || cfg.RoomOutbox.TencentIMConsumerEnabled { + cfg.Enabled = true + } + if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual { + if !cfg.RoomOutbox.Enabled { + return RocketMQConfig{}, fmt.Errorf("outbox_worker publish_mode %q requires rocketmq.room_outbox.enabled", publishMode) + } + } + if cfg.Enabled { + if len(cfg.NameServers) == 0 && cfg.NameServerDomain == "" { + return RocketMQConfig{}, fmt.Errorf("rocketmq name_servers or name_server_domain is required") + } + if (cfg.AccessKey == "") != (cfg.SecretKey == "") { + return RocketMQConfig{}, fmt.Errorf("rocketmq access_key and secret_key must be configured together") + } + } + return cfg, nil +} + +func normalizeStringSlice(input []string) []string { + out := make([]string, 0, len(input)) + for _, value := range input { + if value = strings.TrimSpace(value); value != "" { + out = append(out, value) + } + } + return out +} + // Load 从独立 config.yaml 读取 room-service 配置。 func Load(path string) (Config, error) { cfg := Default() diff --git a/services/room-service/internal/config/config_test.go b/services/room-service/internal/config/config_test.go index 96e90842..49025d12 100644 --- a/services/room-service/internal/config/config_test.go +++ b/services/room-service/internal/config/config_test.go @@ -34,9 +34,12 @@ func TestLoad(t *testing.T) { if cfg.WalletServiceAddr != "127.0.0.1:13004" { t.Fatalf("unexpected wallet addr: %s", cfg.WalletServiceAddr) } - if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.PublishTimeout != 3*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute { + if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PublishMode != OutboxPublishModeDirect || cfg.OutboxWorker.PollInterval != time.Second || cfg.OutboxWorker.BatchSize != 100 || cfg.OutboxWorker.PublishTimeout != 3*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute { t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker) } + if cfg.RocketMQ.Enabled || cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.TreasureOpen.Enabled { + t.Fatalf("local config must not require RocketMQ: %+v", cfg.RocketMQ) + } } // TestLoadTencentExample 验证线上腾讯云 IM 模板能覆盖本地默认配置。 @@ -67,6 +70,9 @@ func TestLoadTencentExample(t *testing.T) { if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 { t.Fatalf("tencent example must configure outbox worker: %+v", cfg.OutboxWorker) } + if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.TreasureOpen.Enabled { + t.Fatalf("tencent example must route room outbox and treasure open through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ) + } } func TestNormalizeRejectsUnknownOutboxRetryStrategy(t *testing.T) { @@ -78,6 +84,24 @@ func TestNormalizeRejectsUnknownOutboxRetryStrategy(t *testing.T) { } } +func TestNormalizeRejectsUnknownOutboxPublishMode(t *testing.T) { + cfg := Default() + cfg.OutboxWorker.PublishMode = "random" + + if _, err := Normalize(cfg); err == nil { + t.Fatal("Normalize should reject unsupported outbox publish_mode") + } +} + +func TestNormalizeRejectsMQPublishModeWithoutRoomOutboxMQ(t *testing.T) { + cfg := Default() + cfg.OutboxWorker.PublishMode = OutboxPublishModeMQ + + if _, err := Normalize(cfg); err == nil { + t.Fatal("Normalize should reject mq publish_mode without rocketmq.room_outbox.enabled") + } +} + func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) { cfg := Default() cfg.OutboxWorker.PollInterval = 0 @@ -92,7 +116,7 @@ func TestNormalizeOutboxWorkerKeepsSafeDefaults(t *testing.T) { if err != nil { t.Fatalf("Normalize failed: %v", err) } - if normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute { + if normalized.OutboxWorker.PublishMode != OutboxPublishModeDirect || normalized.OutboxWorker.PollInterval != time.Second || normalized.OutboxWorker.BatchSize != 100 || normalized.OutboxWorker.PublishTimeout != 3*time.Second || normalized.OutboxWorker.RetryStrategy != "exponential_backoff" || normalized.OutboxWorker.MaxRetryCount != 10 || normalized.OutboxWorker.InitialBackoff != 5*time.Second || normalized.OutboxWorker.MaxBackoff != 5*time.Minute { t.Fatalf("outbox worker defaults not restored: %+v", normalized.OutboxWorker) } } diff --git a/services/room-service/internal/integration/clients.go b/services/room-service/internal/integration/clients.go index 18eb7547..31d1f5ab 100644 --- a/services/room-service/internal/integration/clients.go +++ b/services/room-service/internal/integration/clients.go @@ -13,6 +13,8 @@ import ( type WalletClient interface { // DebitGift 执行送礼扣费;失败时 room-service 不进入 Room Cell 状态提交。 DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) + // GrantResourceGroup 发放宝箱奖励资源组;命令 ID 必须由 room-service 按宝箱结算事实生成。 + GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) } // RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。 @@ -34,3 +36,20 @@ type OutboxPublisher interface { // PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 转为 retryable 状态重试。 PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error } + +// RoomTreasureOpenSchedule 是 RoomTreasureCountdownStarted 派生出的延迟开箱唤醒任务。 +type RoomTreasureOpenSchedule struct { + AppCode string + RoomID string + BoxID string + Level int32 + OpenAtMS int64 + ResetAtMS int64 + CommandID string +} + +// RoomTreasureOpenScheduler 抽象“到 open_at_ms 唤醒 room-service”的外部延迟通道。 +type RoomTreasureOpenScheduler interface { + // ScheduleRoomTreasureOpen 只负责安排唤醒;真正开箱仍由 room-service 命令链路校验并提交。 + ScheduleRoomTreasureOpen(ctx context.Context, schedule RoomTreasureOpenSchedule) error +} diff --git a/services/room-service/internal/integration/clients_grpc.go b/services/room-service/internal/integration/clients_grpc.go index cce266ea..546ff35b 100644 --- a/services/room-service/internal/integration/clients_grpc.go +++ b/services/room-service/internal/integration/clients_grpc.go @@ -26,3 +26,9 @@ func (c *grpcWalletClient) DebitGift(ctx context.Context, req *walletv1.DebitGif // 这里不吞错,账务失败必须原样阻断 SendGift 房间状态变更。 return c.client.DebitGift(ctx, req) } + +// GrantResourceGroup 直接转调 wallet-service 资源组发放接口。 +func (c *grpcWalletClient) GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) { + // 宝箱奖励以 resource group 为后台配置单位,wallet-service 负责展开资产和权益。 + return c.client.GrantResourceGroup(ctx, req) +} diff --git a/services/room-service/internal/integration/rocketmq_outbox.go b/services/room-service/internal/integration/rocketmq_outbox.go new file mode 100644 index 00000000..b753cf40 --- /dev/null +++ b/services/room-service/internal/integration/rocketmq_outbox.go @@ -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), + }, + }) +} diff --git a/services/room-service/internal/integration/tencent_im.go b/services/room-service/internal/integration/tencent_im.go index 02f16531..1d95d1e5 100644 --- a/services/room-service/internal/integration/tencent_im.go +++ b/services/room-service/internal/integration/tencent_im.go @@ -2,6 +2,7 @@ package integration import ( "context" + "encoding/json" "fmt" "google.golang.org/protobuf/proto" @@ -257,6 +258,85 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room "billing_receipt_id": body.GetBillingReceiptId(), } return base, true, nil + case "RoomTreasureProgressChanged": + var body roomeventsv1.RoomTreasureProgressChanged + if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { + return tencentim.RoomEvent{}, false, err + } + base.ActorUserID = body.GetSenderUserId() + base.Attributes = map[string]string{ + "box_id": body.GetBoxId(), + "level": fmt.Sprintf("%d", body.GetLevel()), + "added_energy": fmt.Sprintf("%d", body.GetAddedEnergy()), + "effective_added_energy": fmt.Sprintf("%d", body.GetEffectiveAddedEnergy()), + "overflow_energy": fmt.Sprintf("%d", body.GetOverflowEnergy()), + "current_progress": fmt.Sprintf("%d", body.GetCurrentProgress()), + "energy_threshold": fmt.Sprintf("%d", body.GetEnergyThreshold()), + "status": body.GetStatus(), + "reset_at_ms": fmt.Sprintf("%d", body.GetResetAtMs()), + "gift_id": body.GetGiftId(), + "gift_count": fmt.Sprintf("%d", body.GetGiftCount()), + "command_id": body.GetCommandId(), + } + return base, true, nil + case "RoomTreasureCountdownStarted": + var body roomeventsv1.RoomTreasureCountdownStarted + if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { + return tencentim.RoomEvent{}, false, err + } + base.ActorUserID = body.GetIgniterUserId() + base.TargetUserID = body.GetTop1UserId() + base.Attributes = map[string]string{ + "box_id": body.GetBoxId(), + "level": fmt.Sprintf("%d", body.GetLevel()), + "current_progress": fmt.Sprintf("%d", body.GetCurrentProgress()), + "energy_threshold": fmt.Sprintf("%d", body.GetEnergyThreshold()), + "countdown_start_ms": fmt.Sprintf("%d", body.GetCountdownStartedAtMs()), + "open_at_ms": fmt.Sprintf("%d", body.GetOpenAtMs()), + "broadcast_scope": body.GetBroadcastScope(), + "visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()), + "top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()), + "igniter_user_id": fmt.Sprintf("%d", body.GetIgniterUserId()), + "command_id": body.GetCommandId(), + } + return base, true, nil + case "RoomTreasureOpened": + var body roomeventsv1.RoomTreasureOpened + if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { + return tencentim.RoomEvent{}, false, err + } + rewardsJSON, err := json.Marshal(body.GetRewards()) + if err != nil { + return tencentim.RoomEvent{}, false, err + } + base.ActorUserID = body.GetIgniterUserId() + base.TargetUserID = body.GetTop1UserId() + base.Attributes = map[string]string{ + "box_id": body.GetBoxId(), + "level": fmt.Sprintf("%d", body.GetLevel()), + "next_level": fmt.Sprintf("%d", body.GetNextLevel()), + "opened_at_ms": fmt.Sprintf("%d", body.GetOpenedAtMs()), + "reset_at_ms": fmt.Sprintf("%d", body.GetResetAtMs()), + "top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()), + "igniter_user_id": fmt.Sprintf("%d", body.GetIgniterUserId()), + "rewards_json": string(rewardsJSON), + } + return base, true, nil + case "RoomTreasureRewardGranted": + var body roomeventsv1.RoomTreasureRewardGranted + if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil { + return tencentim.RoomEvent{}, false, err + } + rewardsJSON, err := json.Marshal(body.GetRewards()) + if err != nil { + return tencentim.RoomEvent{}, false, err + } + base.Attributes = map[string]string{ + "box_id": body.GetBoxId(), + "level": fmt.Sprintf("%d", body.GetLevel()), + "rewards_json": string(rewardsJSON), + } + return base, true, nil default: // RoomCreated/HeatChanged/RankChanged 不单独推客户端,避免一条命令产生多条系统消息。 return tencentim.RoomEvent{}, false, nil @@ -292,6 +372,14 @@ func eventTypeForClient(eventType string) string { return "room_user_unbanned" case "RoomGiftSent": return "room_gift_sent" + case "RoomTreasureProgressChanged": + return "room_treasure_progress_changed" + case "RoomTreasureCountdownStarted": + return "room_treasure_countdown_started" + case "RoomTreasureOpened": + return "room_treasure_opened" + case "RoomTreasureRewardGranted": + return "room_treasure_reward_granted" default: return eventType } diff --git a/services/room-service/internal/room/command/command.go b/services/room-service/internal/room/command/command.go index c8294d32..f61b82a9 100644 --- a/services/room-service/internal/room/command/command.go +++ b/services/room-service/internal/room/command/command.go @@ -338,11 +338,69 @@ type SendGift struct { HeatValue int64 `json:"heat_value,omitempty"` // PriceVersion 是本次钱包结算使用的服务端礼物价格版本。 PriceVersion string `json:"price_version,omitempty"` + // GiftTypeCode 是 wallet-service 结算时锁定的礼物类型,用于宝箱礼物类型能量规则。 + GiftTypeCode string `json:"gift_type_code,omitempty"` + // TreasureTouched 表示本次送礼已经计算过宝箱状态,恢复时直接使用下列确定性快照。 + TreasureTouched bool `json:"treasure_touched,omitempty"` + // TreasureAddedEnergy 是按后台规则算出的理论能量,倒计时和溢出规则会让它不完全生效。 + TreasureAddedEnergy int64 `json:"treasure_added_energy,omitempty"` + // TreasureEffectiveEnergy 是实际写入当前等级进度的能量,溢出不会进入下一级。 + TreasureEffectiveEnergy int64 `json:"treasure_effective_energy,omitempty"` + // TreasureOverflowEnergy 是本次礼物在当前等级阈值之外被丢弃的能量。 + TreasureOverflowEnergy int64 `json:"treasure_overflow_energy,omitempty"` + // TreasureLevel/TreasureProgress/TreasureStatus 是送礼后宝箱状态快照。 + TreasureLevel int32 `json:"treasure_level,omitempty"` + TreasureProgress int64 `json:"treasure_progress,omitempty"` + TreasureStatus string `json:"treasure_status,omitempty"` + // TreasureEnergyThreshold 是当前等级阈值快照。 + TreasureEnergyThreshold int64 `json:"treasure_energy_threshold,omitempty"` + // TreasureCountdownStartedAtMS/TreasureOpenAtMS 只在点火进入倒计时时写入。 + TreasureCountdownStartedAtMS int64 `json:"treasure_countdown_started_at_ms,omitempty"` + TreasureOpenAtMS int64 `json:"treasure_open_at_ms,omitempty"` + // TreasureResetAtMS 是该宝箱进度所属 UTC 日的下一次重置时间。 + TreasureResetAtMS int64 `json:"treasure_reset_at_ms,omitempty"` + // TreasureTop1UserID/TreasureIgniterUserID 在满能量瞬间锁定,离房后仍用它们发奖。 + TreasureTop1UserID int64 `json:"treasure_top1_user_id,omitempty"` + TreasureIgniterUserID int64 `json:"treasure_igniter_user_id,omitempty"` + // TreasureBoxID 是本轮宝箱幂等 ID,开箱、抽奖和 IM 都围绕它关联。 + TreasureBoxID string `json:"treasure_box_id,omitempty"` + // TreasureConfigVersion 是本轮宝箱采用的后台配置版本。 + TreasureConfigVersion int64 `json:"treasure_config_version,omitempty"` } // Type 返回命令类型。 func (SendGift) Type() string { return "send_gift" } +// TreasureRewardGrant 记录开箱命令中已经结算出的资源组发放结果。 +type TreasureRewardGrant struct { + RewardRole string `json:"reward_role"` + UserID int64 `json:"user_id"` + RewardItemID string `json:"reward_item_id"` + ResourceGroupID int64 `json:"resource_group_id"` + DisplayName string `json:"display_name,omitempty"` + IconURL string `json:"icon_url,omitempty"` + GrantID string `json:"grant_id,omitempty"` + Status string `json:"status"` +} + +// OpenRoomTreasure 定义系统到点开箱命令。 +type OpenRoomTreasure struct { + Base + BoxID string `json:"box_id"` + Level int32 `json:"level"` + NextLevel int32 `json:"next_level"` + NextEnergyThreshold int64 `json:"next_energy_threshold"` + ConfigVersion int64 `json:"config_version"` + ResetAtMS int64 `json:"reset_at_ms"` + Top1UserID int64 `json:"top1_user_id,omitempty"` + IgniterUserID int64 `json:"igniter_user_id,omitempty"` + InRoomUserIDs []int64 `json:"in_room_user_ids,omitempty"` + Rewards []TreasureRewardGrant `json:"rewards,omitempty"` +} + +// Type 返回命令类型。 +func (OpenRoomTreasure) Type() string { return "open_room_treasure" } + // Serialize 把命令编码成 command log 持久化所需的稳定载荷。 func Serialize(cmd Command) ([]byte, error) { // 当前 command log 使用 JSON 载荷,便于排查;命令类型由独立列保存。 @@ -374,6 +432,31 @@ func IdempotencyPayload(payload []byte) ([]byte, error) { delete(values, "gift_point_added") delete(values, "heat_value") delete(values, "price_version") + delete(values, "gift_type_code") + delete(values, "treasure_touched") + delete(values, "treasure_added_energy") + delete(values, "treasure_effective_energy") + delete(values, "treasure_overflow_energy") + delete(values, "treasure_level") + delete(values, "treasure_progress") + delete(values, "treasure_status") + delete(values, "treasure_energy_threshold") + delete(values, "treasure_countdown_started_at_ms") + delete(values, "treasure_open_at_ms") + delete(values, "treasure_reset_at_ms") + delete(values, "treasure_top1_user_id") + delete(values, "treasure_igniter_user_id") + delete(values, "treasure_box_id") + delete(values, "treasure_config_version") + // OpenRoomTreasure 的以下字段是 worker 在开箱时结算出的结果,不属于触发开箱的幂等语义。 + delete(values, "next_level") + delete(values, "next_energy_threshold") + delete(values, "config_version") + delete(values, "reset_at_ms") + delete(values, "top1_user_id") + delete(values, "igniter_user_id") + delete(values, "in_room_user_ids") + delete(values, "rewards") // SetRoomPassword 的哈希带随机盐;通用 payload 比较先去掉哈希,service 层再用 transient 明文和已存 bcrypt 哈希确认是否同一密码。 delete(values, "password_hash") // MicUp 的发流确认 deadline 由 room-service 生成,客户端重试同一 command_id 时不能因此冲突。 @@ -430,6 +513,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) { cmd = &UnbanUser{} case SendGift{}.Type(): cmd = &SendGift{} + case OpenRoomTreasure{}.Type(): + cmd = &OpenRoomTreasure{} default: return nil, fmt.Errorf("unknown room command type: %s", commandType) } diff --git a/services/room-service/internal/room/service/follow_test.go b/services/room-service/internal/room/service/follow_test.go index 6d91317a..5141f5ad 100644 --- a/services/room-service/internal/room/service/follow_test.go +++ b/services/room-service/internal/room/service/follow_test.go @@ -20,6 +20,10 @@ func (followTestWallet) DebitGift(context.Context, *walletv1.DebitGiftRequest) ( return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-follow-test"}, nil } +func (followTestWallet) GrantResourceGroup(context.Context, *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) { + return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-follow-test"}}, nil +} + func TestRoomFollowAffectsSnapshotAndFollowedFeed(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) diff --git a/services/room-service/internal/room/service/gift.go b/services/room-service/internal/room/service/gift.go index 3e7bcdf7..bf4804d9 100644 --- a/services/room-service/internal/room/service/gift.go +++ b/services/room-service/internal/room/service/gift.go @@ -65,6 +65,10 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r if !exists { return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found") } + treasureConfig, err := s.roomTreasureConfig(ctx) + if err != nil { + return mutationResult{}, nil, err + } // 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。 walletStartedAt := time.Now() @@ -92,15 +96,20 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r settledCommand.GiftPointAdded = billing.GetGiftPointAdded() settledCommand.HeatValue = heatValue settledCommand.PriceVersion = billing.GetPriceVersion() - commandPayload, err := command.Serialize(settledCommand) - if err != nil { - return mutationResult{}, nil, err - } + settledCommand.GiftTypeCode = billing.GetGiftTypeCode() // 扣费成功后,Room Cell 同步更新热度和本地礼物榜。 current.Heat += heatValue current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now) current.Version++ + treasureApply, err := s.applyRoomTreasureGift(now, current, treasureConfig, cmd, &settledCommand, billing, roomMeta) + if err != nil { + return mutationResult{}, nil, err + } + commandPayload, err := command.Serialize(settledCommand) + if err != nil { + return mutationResult{}, nil, err + } // 送礼事件、热度事件和榜单事件使用同一个房间版本,方便消费者关联同一次命令。 giftEvent, err := outbox.Build(current.RoomID, "RoomGiftSent", current.Version, now, &roomeventsv1.RoomGiftSent{ @@ -135,6 +144,22 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r return mutationResult{}, nil, err } + records := []outbox.Record{giftEvent, heatEvent, rankEvent} + if treasureApply.progressEvent != nil { + progressEvent, err := outbox.Build(current.RoomID, "RoomTreasureProgressChanged", current.Version, now, treasureApply.progressEvent) + if err != nil { + return mutationResult{}, nil, err + } + records = append(records, progressEvent) + } + if treasureApply.countdown != nil { + countdownEvent, err := outbox.Build(current.RoomID, "RoomTreasureCountdownStarted", current.Version, now, treasureApply.countdown) + if err != nil { + return mutationResult{}, nil, err + } + records = append(records, countdownEvent) + } + return mutationResult{ snapshot: current.ToProto(), billingReceiptID: billing.GetBillingReceiptId(), @@ -161,7 +186,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r "price_version": settledCommand.PriceVersion, }, }, - }, []outbox.Record{giftEvent, heatEvent, rankEvent}, nil + }, records, nil }) if err != nil { return nil, err @@ -173,6 +198,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r RoomHeat: result.roomHeat, GiftRank: result.giftRank, Room: result.snapshot, + Treasure: result.snapshot.GetTreasure(), }, nil } diff --git a/services/room-service/internal/room/service/outbox_worker.go b/services/room-service/internal/room/service/outbox_worker.go index 7948e545..54b8fd7c 100644 --- a/services/room-service/internal/room/service/outbox_worker.go +++ b/services/room-service/internal/room/service/outbox_worker.go @@ -155,9 +155,9 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker continue } - publishCtx, cancel := context.WithTimeout(context.Background(), options.PublishTimeout) + publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout) // 发布上下文不继承 worker ctx,避免 shutdown 取消导致已开始的单条外部投递无界处于未知状态。 - err := s.outboxPublisher.PublishOutboxEvent(publishCtx, record.Envelope) + err := s.publishOutboxRecord(publishCtx, record) cancel() if err != nil { // 投递失败转为 retryable,并写入指数退避时间,等待下一轮 worker 到期后再抢占。 @@ -203,6 +203,17 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker return nil } +func (s *Service) publishOutboxRecord(ctx context.Context, record outbox.Record) error { + // Countdown 先安排延迟开箱,再对外广播事件;如果 MQ 延迟调度不可用,本条 outbox 保持 retryable。 + if err := s.scheduleRoomTreasureOpenFromEnvelope(ctx, record.Envelope); err != nil { + return err + } + if s.outboxPublisher == nil { + return nil + } + return s.outboxPublisher.PublishOutboxEvent(ctx, record.Envelope) +} + // outboxBackoff 根据失败次数计算指数退避,且永远不超过 MaxBackoff。 func outboxBackoff(retryCount int, options OutboxWorkerOptions) time.Duration { if retryCount <= 1 { diff --git a/services/room-service/internal/room/service/recovery.go b/services/room-service/internal/room/service/recovery.go index c927d606..4a7181f5 100644 --- a/services/room-service/internal/room/service/recovery.go +++ b/services/room-service/internal/room/service/recovery.go @@ -348,8 +348,64 @@ func replay(current *state.RoomState, cmd command.Command) error { }) } + if typed.TreasureTouched { + current.Treasure = treasureStateFromSettledGift(typed) + } + + current.Version++ + case *command.OpenRoomTreasure: + if current.Treasure == nil || current.Treasure.BoxID != typed.BoxID { + // 开箱命令必须对应当前倒计时宝箱;如果快照已包含更新后的下一轮状态,回放保持幂等。 + return nil + } + current.Treasure = treasureStateFromOpenCommand(typed) current.Version++ } return nil } + +func treasureStateFromSettledGift(cmd *command.SendGift) *state.TreasureState { + return &state.TreasureState{ + CurrentLevel: cmd.TreasureLevel, + CurrentProgress: cmd.TreasureProgress, + EnergyThreshold: cmd.TreasureEnergyThreshold, + Status: cmd.TreasureStatus, + CountdownStartedAtMS: cmd.TreasureCountdownStartedAtMS, + OpenAtMS: cmd.TreasureOpenAtMS, + ResetAtMS: cmd.TreasureResetAtMS, + Top1UserID: cmd.TreasureTop1UserID, + IgniterUserID: cmd.TreasureIgniterUserID, + BoxID: cmd.TreasureBoxID, + ConfigVersion: cmd.TreasureConfigVersion, + } +} + +func treasureStateFromOpenCommand(cmd *command.OpenRoomTreasure) *state.TreasureState { + return &state.TreasureState{ + CurrentLevel: cmd.NextLevel, + EnergyThreshold: cmd.NextEnergyThreshold, + Status: state.TreasureStatusIdle, + OpenedAtMS: cmd.SentAtMS, + ResetAtMS: cmd.ResetAtMS, + ConfigVersion: cmd.ConfigVersion, + LastRewards: treasureRewardsFromCommand(cmd.Rewards), + } +} + +func treasureRewardsFromCommand(input []command.TreasureRewardGrant) []state.TreasureRewardGrant { + rewards := make([]state.TreasureRewardGrant, 0, len(input)) + for _, reward := range input { + rewards = append(rewards, state.TreasureRewardGrant{ + RewardRole: reward.RewardRole, + UserID: reward.UserID, + RewardItemID: reward.RewardItemID, + ResourceGroupID: reward.ResourceGroupID, + DisplayName: reward.DisplayName, + IconURL: reward.IconURL, + GrantID: reward.GrantID, + Status: reward.Status, + }) + } + return rewards +} diff --git a/services/room-service/internal/room/service/repository.go b/services/room-service/internal/room/service/repository.go index 5e97852c..5701720b 100644 --- a/services/room-service/internal/room/service/repository.go +++ b/services/room-service/internal/room/service/repository.go @@ -139,6 +139,56 @@ type RoomSeatConfig struct { UpdatedAtMS int64 } +// RoomTreasureConfig 是后台语音房宝箱配置的运行时快照。 +type RoomTreasureConfig struct { + AppCode string + Enabled bool + ConfigVersion int64 + EnergySource string + OpenDelayMS int64 + BroadcastEnabled bool + BroadcastScope string + BroadcastDelayMS int64 + RewardStackPolicy string + Levels []RoomTreasureLevelConfig + GiftEnergyRules []GiftEnergyRuleConfig + UpdatedByAdminID int64 + CreatedAtMS int64 + UpdatedAtMS int64 +} + +// RoomTreasureLevelConfig 描述单个等级的物料、阈值和三类奖励池。 +type RoomTreasureLevelConfig struct { + Level int32 `json:"level"` + EnergyThreshold int64 `json:"energyThreshold"` + CoverURL string `json:"coverUrl"` + AnimationURL string `json:"animationUrl"` + OpeningAnimationURL string `json:"openingAnimationUrl"` + OpenedImageURL string `json:"openedImageUrl"` + InRoomRewards []RoomTreasureRewardItem `json:"inRoomRewards"` + Top1Rewards []RoomTreasureRewardItem `json:"top1Rewards"` + IgniterRewards []RoomTreasureRewardItem `json:"igniterRewards"` +} + +// RoomTreasureRewardItem 是后台配置中的奖励候选项。 +type RoomTreasureRewardItem struct { + RewardItemID string `json:"rewardItemId"` + ResourceGroupID int64 `json:"resourceGroupId"` + Weight int64 `json:"weight"` + DisplayName string `json:"displayName"` + IconURL string `json:"iconUrl"` +} + +// GiftEnergyRuleConfig 控制特定礼物或礼物类型如何覆盖默认能量计算。 +type GiftEnergyRuleConfig struct { + RuleID string `json:"ruleId"` + GiftID string `json:"giftId"` + GiftTypeCode string `json:"giftTypeCode"` + MultiplierPPM int64 `json:"multiplierPpm"` + FixedEnergy int64 `json:"fixedEnergy"` + Excluded bool `json:"excluded"` +} + // RoomPresence 是当前用户可恢复房间的轻量读模型。 // 它从 RoomSnapshot 投影而来,只用于 App 启动/回前台探测,不替代 Room Cell 权威状态。 type RoomPresence struct { @@ -331,6 +381,10 @@ type Repository interface { GetRoomSeatConfig(ctx context.Context) (RoomSeatConfig, bool, error) // UpsertRoomSeatConfig 写入房间座位数配置,供本地验证和后台配置共享同一张表。 UpsertRoomSeatConfig(ctx context.Context, config RoomSeatConfig) error + // GetRoomTreasureConfig 读取语音房宝箱低频配置;不存在时 service 层使用关闭态默认配置。 + GetRoomTreasureConfig(ctx context.Context) (RoomTreasureConfig, bool, error) + // UpsertRoomTreasureConfig 写入语音房宝箱配置,主要供集成测试和本地验证复用生产表结构。 + UpsertRoomTreasureConfig(ctx context.Context, config RoomTreasureConfig) error // ListCommandsAfter 读取快照版本之后的命令,恢复时按版本顺序回放。 ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error) // SaveSnapshot 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。 diff --git a/services/room-service/internal/room/service/room_treasure.go b/services/room-service/internal/room/service/room_treasure.go new file mode 100644 index 00000000..7250643b --- /dev/null +++ b/services/room-service/internal/room/service/room_treasure.go @@ -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 +} diff --git a/services/room-service/internal/room/service/room_treasure_mq.go b/services/room-service/internal/room/service/room_treasure_mq.go new file mode 100644 index 00000000..765af793 --- /dev/null +++ b/services/room-service/internal/room/service/room_treasure_mq.go @@ -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 +} diff --git a/services/room-service/internal/room/service/room_treasure_test.go b/services/room-service/internal/room/service/room_treasure_test.go new file mode 100644 index 00000000..84443760 --- /dev/null +++ b/services/room-service/internal/room/service/room_treasure_test.go @@ -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 +} diff --git a/services/room-service/internal/room/service/service.go b/services/room-service/internal/room/service/service.go index 182ccc4e..3db14239 100644 --- a/services/room-service/internal/room/service/service.go +++ b/services/room-service/internal/room/service/service.go @@ -32,6 +32,8 @@ type Config struct { MicPublishTimeout time.Duration // RTCUserRemover 是平台级封禁后把用户从腾讯 RTC 房间移出的外部边界。 RTCUserRemover integration.RTCUserRemover + // RoomTreasureOpenScheduler 把倒计时开箱唤醒交给外部延迟消息,避免只靠已加载 Cell 扫描。 + RoomTreasureOpenScheduler integration.RoomTreasureOpenScheduler // Clock 允许测试注入稳定时间,生产默认使用系统时钟。 Clock clock.Clock } @@ -62,6 +64,8 @@ type Service struct { syncPublisher integration.RoomEventPublisher // outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。 outboxPublisher integration.OutboxPublisher + // roomTreasureOpenScheduler 在 countdown outbox 投递成功后安排 open_at_ms 唤醒。 + roomTreasureOpenScheduler integration.RoomTreasureOpenScheduler // rtcUserRemover 在 Room Cell 提交驱逐后同步移除实时音频房连接。 rtcUserRemover integration.RTCUserRemover // draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。 @@ -142,20 +146,21 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i } return &Service{ - nodeID: cfg.NodeID, - leaseTTL: cfg.LeaseTTL, - rankLimit: rankLimit, - snapshotEveryN: snapshotEveryN, - presenceStaleAfter: presenceStaleAfter, - micPublishTimeout: micPublishTimeout, - clock: usedClock, - directory: directory, - repository: repository, - wallet: wallet, - syncPublisher: syncPublisher, - outboxPublisher: outboxPublisher, - rtcUserRemover: cfg.RTCUserRemover, - cells: make(map[string]*cell.RoomCell), + nodeID: cfg.NodeID, + leaseTTL: cfg.LeaseTTL, + rankLimit: rankLimit, + snapshotEveryN: snapshotEveryN, + presenceStaleAfter: presenceStaleAfter, + micPublishTimeout: micPublishTimeout, + clock: usedClock, + directory: directory, + repository: repository, + wallet: wallet, + syncPublisher: syncPublisher, + outboxPublisher: outboxPublisher, + roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler, + rtcUserRemover: cfg.RTCUserRemover, + cells: make(map[string]*cell.RoomCell), } } diff --git a/services/room-service/internal/room/state/state.go b/services/room-service/internal/room/state/state.go index fb9aae2f..e9c6e3b2 100644 --- a/services/room-service/internal/room/state/state.go +++ b/services/room-service/internal/room/state/state.go @@ -18,6 +18,11 @@ const ( // RoomStatusClosed 表示房间已经关闭,guard 和进房都必须拒绝。 RoomStatusClosed = "closed" + // TreasureStatusIdle 表示当前等级宝箱还在积攒能量。 + TreasureStatusIdle = "idle" + // TreasureStatusCountdown 表示当前等级已经满能量,等待后台 worker 到点开箱。 + TreasureStatusCountdown = "countdown" + // MicPublishIdle 表示麦位没有正在确认或已确认的 RTC 发流会话。 MicPublishIdle = "idle" // MicPublishPending 表示业务上麦成功,但客户端/RTC 尚未确认音频发布成功。 @@ -103,6 +108,62 @@ type RankItem struct { UpdatedAtMS int64 } +// TreasureRewardGrant 记录一次宝箱开箱时给单个用户结算出的奖励。 +type TreasureRewardGrant struct { + // RewardRole 区分 in_room、top1、igniter 三种奖励归因,客户端据此分组展示。 + RewardRole string + // UserID 是奖励归属用户;top1 和 igniter 即使离房也必须用满能量时锁定的用户。 + UserID int64 + // RewardItemID 是后台奖励池内的稳定项 ID,用于排障和客户端去重。 + RewardItemID string + // ResourceGroupID 是 wallet-service 发放资源组 ID。 + ResourceGroupID int64 + // DisplayName/IconURL 是开箱 IM 展示快照,避免客户端为历史消息回查后台配置。 + DisplayName string + IconURL string + // GrantID 是 wallet-service 资源发放回执;空值表示该奖励未实际发放。 + GrantID string + // Status 表达发放结果,当前成功路径写 succeeded。 + Status string +} + +// TreasureState 保存 Room Cell 内当前宝箱进度和最近一次开箱结果。 +type TreasureState struct { + // CurrentLevel 是当前正在积攒的宝箱等级,UTC 日重置后回到 1。 + CurrentLevel int32 + // CurrentProgress 是当前等级已累计能量,满级后的溢出不跨等级继承。 + CurrentProgress int64 + // EnergyThreshold 是当前等级阈值快照,便于客户端不依赖配置二次计算。 + EnergyThreshold int64 + // Status 区分 idle/countdown,倒计时期间送礼能量无效。 + Status string + // CountdownStartedAtMS/OpenAtMS/OpenedAtMS 描述最近一次满能量到开箱的时间线。 + CountdownStartedAtMS int64 + OpenAtMS int64 + OpenedAtMS int64 + // ResetAtMS 是下一次 UTC 自然日重置毫秒时间。 + ResetAtMS int64 + // Top1UserID 和 IgniterUserID 在满能量瞬间锁定,用户离房或下线仍按这里发奖。 + Top1UserID int64 + IgniterUserID int64 + // BoxID 是单次宝箱从积攒到开箱的幂等标识,奖励抽取和 wallet command_id 都依赖它。 + BoxID string + // ConfigVersion 记录本轮宝箱使用的后台配置版本,开箱时不被中途配置变更影响。 + ConfigVersion int64 + // LastRewards 保存最近一次开箱发奖结果,用于断线重连后补 UI。 + LastRewards []TreasureRewardGrant +} + +// Clone 深拷贝宝箱状态,避免 Room Cell 失败命令污染当前状态。 +func (t *TreasureState) Clone() *TreasureState { + if t == nil { + return nil + } + cloned := *t + cloned.LastRewards = append([]TreasureRewardGrant(nil), t.LastRewards...) + return &cloned +} + // RoomState 是 Room Cell 的核心业务状态。 // 这里刻意不放钱包余额、完整用户资料和跨房活动状态。 type RoomState struct { @@ -136,6 +197,8 @@ type RoomState struct { GiftRank []RankItem // RoomExt 预留少量房间扩展字段,避免 v1 频繁改结构。 RoomExt map[string]string + // Treasure 是语音房宝箱状态,送礼能量和开箱必须跟随 Room Cell 串行提交。 + Treasure *TreasureState // Version 是房间状态版本,成功变更后递增,快照和 command log 都依赖它。 Version int64 } @@ -191,6 +254,7 @@ func (s *RoomState) Clone() *RoomState { Heat: s.Heat, GiftRank: append([]RankItem(nil), s.GiftRank...), RoomExt: make(map[string]string, len(s.RoomExt)), + Treasure: cloneTreasureState(s.Treasure), Version: s.Version, } @@ -332,6 +396,7 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot { Version: s.Version, RoomShortId: s.RoomExt["room_short_id"], Locked: s.RoomPasswordHash != "", + Treasure: treasureStateToProto(s.Treasure), } } @@ -357,6 +422,7 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState { MuteUsers: make(map[int64]bool, len(snapshot.GetMuteUserIds())), GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())), RoomExt: cloneStringMap(snapshot.GetRoomExt()), + Treasure: treasureStateFromProto(snapshot.GetTreasure()), Heat: snapshot.GetHeat(), Version: snapshot.GetVersion(), } @@ -441,3 +507,80 @@ func cloneStringMap(input map[string]string) map[string]string { return cloned } + +func cloneTreasureState(input *TreasureState) *TreasureState { + if input == nil { + return nil + } + cloned := *input + cloned.LastRewards = append([]TreasureRewardGrant(nil), input.LastRewards...) + return &cloned +} + +func treasureStateToProto(input *TreasureState) *roomv1.RoomTreasureState { + if input == nil { + return nil + } + rewards := make([]*roomv1.RoomTreasureRewardGrant, 0, len(input.LastRewards)) + for _, reward := range input.LastRewards { + rewards = append(rewards, &roomv1.RoomTreasureRewardGrant{ + RewardRole: reward.RewardRole, + UserId: reward.UserID, + RewardItemId: reward.RewardItemID, + ResourceGroupId: reward.ResourceGroupID, + DisplayName: reward.DisplayName, + IconUrl: reward.IconURL, + GrantId: reward.GrantID, + Status: reward.Status, + }) + } + return &roomv1.RoomTreasureState{ + CurrentLevel: input.CurrentLevel, + CurrentProgress: input.CurrentProgress, + EnergyThreshold: input.EnergyThreshold, + Status: input.Status, + CountdownStartedAtMs: input.CountdownStartedAtMS, + OpenAtMs: input.OpenAtMS, + OpenedAtMs: input.OpenedAtMS, + ResetAtMs: input.ResetAtMS, + Top1UserId: input.Top1UserID, + IgniterUserId: input.IgniterUserID, + BoxId: input.BoxID, + ConfigVersion: input.ConfigVersion, + LastRewards: rewards, + } +} + +func treasureStateFromProto(input *roomv1.RoomTreasureState) *TreasureState { + if input == nil { + return nil + } + rewards := make([]TreasureRewardGrant, 0, len(input.GetLastRewards())) + for _, reward := range input.GetLastRewards() { + rewards = append(rewards, TreasureRewardGrant{ + RewardRole: reward.GetRewardRole(), + UserID: reward.GetUserId(), + RewardItemID: reward.GetRewardItemId(), + ResourceGroupID: reward.GetResourceGroupId(), + DisplayName: reward.GetDisplayName(), + IconURL: reward.GetIconUrl(), + GrantID: reward.GetGrantId(), + Status: reward.GetStatus(), + }) + } + return &TreasureState{ + CurrentLevel: input.GetCurrentLevel(), + CurrentProgress: input.GetCurrentProgress(), + EnergyThreshold: input.GetEnergyThreshold(), + Status: input.GetStatus(), + CountdownStartedAtMS: input.GetCountdownStartedAtMs(), + OpenAtMS: input.GetOpenAtMs(), + OpenedAtMS: input.GetOpenedAtMs(), + ResetAtMS: input.GetResetAtMs(), + Top1UserID: input.GetTop1UserId(), + IgniterUserID: input.GetIgniterUserId(), + BoxID: input.GetBoxId(), + ConfigVersion: input.GetConfigVersion(), + LastRewards: rewards, + } +} diff --git a/services/room-service/internal/storage/mysql/repository.go b/services/room-service/internal/storage/mysql/repository.go index bee99dcb..44fe623f 100644 --- a/services/room-service/internal/storage/mysql/repository.go +++ b/services/room-service/internal/storage/mysql/repository.go @@ -226,8 +226,65 @@ func (r *Repository) Migrate(ctx context.Context) error { updated_at_ms BIGINT NOT NULL, PRIMARY KEY (app_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS room_treasure_configs ( + app_code VARCHAR(32) NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 0, + config_version BIGINT NOT NULL DEFAULT 1, + energy_source VARCHAR(32) NOT NULL, + open_delay_ms BIGINT NOT NULL, + broadcast_enabled TINYINT(1) NOT NULL DEFAULT 1, + broadcast_scope VARCHAR(32) NOT NULL, + broadcast_delay_ms BIGINT NOT NULL DEFAULT 0, + reward_stack_policy VARCHAR(32) NOT NULL, + levels_json JSON NOT NULL, + gift_energy_rules_json JSON NOT NULL, + updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code), + KEY idx_room_treasure_enabled (app_code, enabled) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `INSERT IGNORE INTO room_seat_configs (app_code, allowed_seat_counts, default_seat_count, created_at_ms, updated_at_ms) VALUES ('lalu', '[10,15,20,25,30]', 15, 0, 0)`, + `INSERT IGNORE INTO room_treasure_configs ( + app_code, + enabled, + config_version, + energy_source, + open_delay_ms, + broadcast_enabled, + broadcast_scope, + broadcast_delay_ms, + reward_stack_policy, + levels_json, + gift_energy_rules_json, + updated_by_admin_id, + created_at_ms, + updated_at_ms + ) VALUES ( + 'lalu', + 0, + 1, + 'gift_point_added', + 30000, + 1, + 'region', + 0, + 'allow_stack', + JSON_ARRAY( + JSON_OBJECT('level', 1, 'energyThreshold', 1000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 2, 'energyThreshold', 3000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 3, 'energyThreshold', 6000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 4, 'energyThreshold', 10000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 5, 'energyThreshold', 15000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 6, 'energyThreshold', 21000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()), + JSON_OBJECT('level', 7, 'energyThreshold', 28000, 'coverUrl', '', 'animationUrl', '', 'openingAnimationUrl', '', 'openedImageUrl', '', 'inRoomRewards', JSON_ARRAY(), 'top1Rewards', JSON_ARRAY(), 'igniterRewards', JSON_ARRAY()) + ), + JSON_ARRAY(), + 0, + 0, + 0 + )`, `ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_user_presence MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, @@ -238,6 +295,7 @@ func (r *Repository) Migrate(ctx context.Context) error { `ALTER TABLE room_follows MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_region_pins MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, `ALTER TABLE room_seat_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, + `ALTER TABLE room_treasure_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`, } for _, statement := range statements { @@ -672,6 +730,111 @@ func (r *Repository) UpsertRoomSeatConfig(ctx context.Context, config roomservic return err } +// GetRoomTreasureConfig 读取当前 App 的语音房宝箱配置。 +func (r *Repository) GetRoomTreasureConfig(ctx context.Context) (roomservice.RoomTreasureConfig, bool, error) { + row := r.db.QueryRowContext(ctx, + `SELECT app_code, enabled, config_version, energy_source, open_delay_ms, + broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy, + levels_json, gift_energy_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms + FROM room_treasure_configs + WHERE app_code = ?`, + appcode.FromContext(ctx), + ) + + var config roomservice.RoomTreasureConfig + var enabled int + var broadcastEnabled int + var rawLevels string + var rawRules string + if err := row.Scan( + &config.AppCode, + &enabled, + &config.ConfigVersion, + &config.EnergySource, + &config.OpenDelayMS, + &broadcastEnabled, + &config.BroadcastScope, + &config.BroadcastDelayMS, + &config.RewardStackPolicy, + &rawLevels, + &rawRules, + &config.UpdatedByAdminID, + &config.CreatedAtMS, + &config.UpdatedAtMS, + ); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return roomservice.RoomTreasureConfig{}, false, nil + } + return roomservice.RoomTreasureConfig{}, false, err + } + config.Enabled = enabled == 1 + config.BroadcastEnabled = broadcastEnabled == 1 + if err := json.Unmarshal([]byte(rawLevels), &config.Levels); err != nil { + return roomservice.RoomTreasureConfig{}, false, err + } + if err := json.Unmarshal([]byte(rawRules), &config.GiftEnergyRules); err != nil { + return roomservice.RoomTreasureConfig{}, false, err + } + + return config, true, nil +} + +// UpsertRoomTreasureConfig 写入当前 App 的语音房宝箱配置。 +func (r *Repository) UpsertRoomTreasureConfig(ctx context.Context, config roomservice.RoomTreasureConfig) error { + appCode := normalizedRecordAppCode(ctx, config.AppCode) + rawLevels, err := json.Marshal(config.Levels) + if err != nil { + return err + } + rawRules, err := json.Marshal(config.GiftEnergyRules) + if err != nil { + return err + } + nowMS := config.UpdatedAtMS + if nowMS <= 0 { + nowMS = time.Now().UTC().UnixMilli() + } + createdAtMS := config.CreatedAtMS + if createdAtMS <= 0 { + createdAtMS = nowMS + } + _, err = r.db.ExecContext(ctx, + `INSERT INTO room_treasure_configs ( + app_code, enabled, config_version, energy_source, open_delay_ms, + broadcast_enabled, broadcast_scope, broadcast_delay_ms, reward_stack_policy, + levels_json, gift_energy_rules_json, updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + enabled = VALUES(enabled), + config_version = VALUES(config_version), + energy_source = VALUES(energy_source), + open_delay_ms = VALUES(open_delay_ms), + broadcast_enabled = VALUES(broadcast_enabled), + broadcast_scope = VALUES(broadcast_scope), + broadcast_delay_ms = VALUES(broadcast_delay_ms), + reward_stack_policy = VALUES(reward_stack_policy), + levels_json = VALUES(levels_json), + gift_energy_rules_json = VALUES(gift_energy_rules_json), + updated_by_admin_id = VALUES(updated_by_admin_id), + updated_at_ms = VALUES(updated_at_ms)`, + appCode, + boolToInt(config.Enabled), + config.ConfigVersion, + config.EnergySource, + config.OpenDelayMS, + boolToInt(config.BroadcastEnabled), + config.BroadcastScope, + config.BroadcastDelayMS, + config.RewardStackPolicy, + string(rawLevels), + string(rawRules), + config.UpdatedByAdminID, + createdAtMS, + nowMS, + ) + return err +} + // ListCommandsAfter 返回快照版本之后的可回放命令日志。 func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) { // 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。 @@ -1771,6 +1934,13 @@ func normalizedRecordAppCode(ctx context.Context, value string) string { return appcode.Normalize(value) } +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} + func mapRoomMetaDuplicateError(err error) error { if err == nil { return nil diff --git a/services/room-service/internal/transport/grpc/server.go b/services/room-service/internal/transport/grpc/server.go index 9b19f890..6a75628f 100644 --- a/services/room-service/internal/transport/grpc/server.go +++ b/services/room-service/internal/transport/grpc/server.go @@ -390,6 +390,18 @@ func (s *Server) GetRoomSnapshot(ctx context.Context, req *roomv1.GetRoomSnapsho return mapServiceResult(s.svc.GetRoomSnapshot(ctx, req)) } +// GetRoomTreasure 代理到房间宝箱只读查询。 +func (s *Server) GetRoomTreasure(ctx context.Context, req *roomv1.GetRoomTreasureRequest) (*roomv1.GetRoomTreasureResponse, error) { + // 宝箱查询不刷新 presence;当前进度仍以 Room Cell 快照和 UTC 日边界为准。 + ctx = contextWithMetaApp(ctx, req.GetMeta()) + if resp, forwarded, err := forwardQuery(s, ctx, req.GetMeta().GetAppCode(), req.GetRoomId(), func(callCtx context.Context, client roomv1.RoomQueryServiceClient) (*roomv1.GetRoomTreasureResponse, error) { + return client.GetRoomTreasure(callCtx, req) + }); forwarded { + return resp, err + } + return mapServiceResult(s.svc.GetRoomTreasure(ctx, req)) +} + // ListRoomOnlineUsers 代理到房间在线用户分页读模型。 func (s *Server) ListRoomOnlineUsers(ctx context.Context, req *roomv1.ListRoomOnlineUsersRequest) (*roomv1.ListRoomOnlineUsersResponse, error) { // 在线人数弹窗只读 room_user_presence 投影,不拉完整 RoomSnapshot 做分页。 diff --git a/services/room-service/internal/transport/grpc/server_test.go b/services/room-service/internal/transport/grpc/server_test.go index 6da93c9c..35d7f5b1 100644 --- a/services/room-service/internal/transport/grpc/server_test.go +++ b/services/room-service/internal/transport/grpc/server_test.go @@ -30,6 +30,11 @@ func (fakeWallet) DebitGift(context.Context, *walletv1.DebitGiftRequest) (*walle return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-test"}, nil } +// GrantResourceGroup 返回固定发放结果,避免 transport 测试依赖 wallet-service 资源组实现。 +func (fakeWallet) GrantResourceGroup(context.Context, *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) { + return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-test"}}, nil +} + // newTransportTestServer 构造完整领域服务,确保测试覆盖真实 service -> transport 错误边界。 func newTransportTestServer(t *testing.T) *Server { t.Helper() diff --git a/services/wallet-service/internal/domain/ledger/ledger.go b/services/wallet-service/internal/domain/ledger/ledger.go index 938a99da..773138e4 100644 --- a/services/wallet-service/internal/domain/ledger/ledger.go +++ b/services/wallet-service/internal/domain/ledger/ledger.go @@ -73,6 +73,7 @@ type Receipt struct { ChargeAmount int64 GiftPointAdded int64 HeatValue int64 + GiftTypeCode string PriceVersion string BalanceAfter int64 } diff --git a/services/wallet-service/internal/storage/mysql/repository.go b/services/wallet-service/internal/storage/mysql/repository.go index 7e455eb2..a6872e2b 100644 --- a/services/wallet-service/internal/storage/mysql/repository.go +++ b/services/wallet-service/internal/storage/mysql/repository.go @@ -1372,6 +1372,7 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger ChargeAmount: chargeAmount, GiftPointAdded: metadata.GiftPointAdded, HeatValue: metadata.HeatValue, + GiftTypeCode: metadata.GiftTypeCode, PriceVersion: metadata.PriceVersion, BalanceAfter: metadata.BalanceAfter, } diff --git a/services/wallet-service/internal/transport/grpc/server.go b/services/wallet-service/internal/transport/grpc/server.go index e488db59..db40cb4d 100644 --- a/services/wallet-service/internal/transport/grpc/server.go +++ b/services/wallet-service/internal/transport/grpc/server.go @@ -49,6 +49,7 @@ func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) ChargeAmount: receipt.ChargeAmount, GiftPointAdded: receipt.GiftPointAdded, HeatValue: receipt.HeatValue, + GiftTypeCode: receipt.GiftTypeCode, PriceVersion: receipt.PriceVersion, }, nil }