机器人房

This commit is contained in:
ZuoZuo 2026-06-15 23:53:30 +08:00
parent 604fcebda4
commit d455d24016
64 changed files with 6341 additions and 1699 deletions

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.0
// protoc v5.29.2
// source: proto/activity/v1/activity.proto
package activityv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.0
// - protoc v5.29.2
// source: proto/activity/v1/activity.proto
package activityv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.0
// protoc v5.29.2
// source: proto/events/room/v1/events.proto
package roomeventsv1
@ -1209,6 +1209,10 @@ type RoomGiftSent struct {
GiftAnimationUrl string `protobuf:"bytes,17,opt,name=gift_animation_url,json=giftAnimationUrl,proto3" json:"gift_animation_url,omitempty"`
TargetGiftValue int64 `protobuf:"varint,18,opt,name=target_gift_value,json=targetGiftValue,proto3" json:"target_gift_value,omitempty"`
GiftEffectTypes []string `protobuf:"bytes,19,rep,name=gift_effect_types,json=giftEffectTypes,proto3" json:"gift_effect_types,omitempty"`
// is_robot_gift 标记机器人房间展示用礼物事实;下游真实统计、任务和结算消费者必须跳过。
IsRobotGift bool `protobuf:"varint,20,opt,name=is_robot_gift,json=isRobotGift,proto3" json:"is_robot_gift,omitempty"`
// synthetic_lucky_gift 标记幸运礼物只用于房间表现,不写真实抽奖流水、不扣真实奖池、不发真实奖励。
SyntheticLuckyGift bool `protobuf:"varint,21,opt,name=synthetic_lucky_gift,json=syntheticLuckyGift,proto3" json:"synthetic_lucky_gift,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -1376,6 +1380,186 @@ func (x *RoomGiftSent) GetGiftEffectTypes() []string {
return nil
}
func (x *RoomGiftSent) GetIsRobotGift() bool {
if x != nil {
return x.IsRobotGift
}
return false
}
func (x *RoomGiftSent) GetSyntheticLuckyGift() bool {
if x != nil {
return x.SyntheticLuckyGift
}
return false
}
// RoomRobotLuckyGiftDrawn 是机器人房间专用幸运礼物展示事件。
// 它复用客户端 lucky_gift_drawn 表现协议,但不代表真实奖池抽奖和钱包返奖事实。
type RoomRobotLuckyGiftDrawn struct {
state protoimpl.MessageState `protogen:"open.v1"`
DrawId string `protobuf:"bytes,1,opt,name=draw_id,json=drawId,proto3" json:"draw_id,omitempty"`
CommandId string `protobuf:"bytes,2,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
SenderUserId int64 `protobuf:"varint,3,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"`
TargetUserId int64 `protobuf:"varint,4,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
GiftId string `protobuf:"bytes,5,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
GiftCount int32 `protobuf:"varint,6,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
PoolId string `protobuf:"bytes,7,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
MultiplierPpm int64 `protobuf:"varint,8,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
BaseRewardCoins int64 `protobuf:"varint,9,opt,name=base_reward_coins,json=baseRewardCoins,proto3" json:"base_reward_coins,omitempty"`
RoomAtmosphereRewardCoins int64 `protobuf:"varint,10,opt,name=room_atmosphere_reward_coins,json=roomAtmosphereRewardCoins,proto3" json:"room_atmosphere_reward_coins,omitempty"`
ActivitySubsidyCoins int64 `protobuf:"varint,11,opt,name=activity_subsidy_coins,json=activitySubsidyCoins,proto3" json:"activity_subsidy_coins,omitempty"`
EffectiveRewardCoins int64 `protobuf:"varint,12,opt,name=effective_reward_coins,json=effectiveRewardCoins,proto3" json:"effective_reward_coins,omitempty"`
CreatedAtMs int64 `protobuf:"varint,13,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
VisibleRegionId int64 `protobuf:"varint,14,opt,name=visible_region_id,json=visibleRegionId,proto3" json:"visible_region_id,omitempty"`
IsRobot bool `protobuf:"varint,15,opt,name=is_robot,json=isRobot,proto3" json:"is_robot,omitempty"`
Synthetic bool `protobuf:"varint,16,opt,name=synthetic,proto3" json:"synthetic,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RoomRobotLuckyGiftDrawn) Reset() {
*x = RoomRobotLuckyGiftDrawn{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomRobotLuckyGiftDrawn) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RoomRobotLuckyGiftDrawn) ProtoMessage() {}
func (x *RoomRobotLuckyGiftDrawn) 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 RoomRobotLuckyGiftDrawn.ProtoReflect.Descriptor instead.
func (*RoomRobotLuckyGiftDrawn) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{17}
}
func (x *RoomRobotLuckyGiftDrawn) GetDrawId() string {
if x != nil {
return x.DrawId
}
return ""
}
func (x *RoomRobotLuckyGiftDrawn) GetCommandId() string {
if x != nil {
return x.CommandId
}
return ""
}
func (x *RoomRobotLuckyGiftDrawn) GetSenderUserId() int64 {
if x != nil {
return x.SenderUserId
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetTargetUserId() int64 {
if x != nil {
return x.TargetUserId
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetGiftId() string {
if x != nil {
return x.GiftId
}
return ""
}
func (x *RoomRobotLuckyGiftDrawn) GetGiftCount() int32 {
if x != nil {
return x.GiftCount
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetPoolId() string {
if x != nil {
return x.PoolId
}
return ""
}
func (x *RoomRobotLuckyGiftDrawn) GetMultiplierPpm() int64 {
if x != nil {
return x.MultiplierPpm
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetBaseRewardCoins() int64 {
if x != nil {
return x.BaseRewardCoins
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetRoomAtmosphereRewardCoins() int64 {
if x != nil {
return x.RoomAtmosphereRewardCoins
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetActivitySubsidyCoins() int64 {
if x != nil {
return x.ActivitySubsidyCoins
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetEffectiveRewardCoins() int64 {
if x != nil {
return x.EffectiveRewardCoins
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetCreatedAtMs() int64 {
if x != nil {
return x.CreatedAtMs
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetVisibleRegionId() int64 {
if x != nil {
return x.VisibleRegionId
}
return 0
}
func (x *RoomRobotLuckyGiftDrawn) GetIsRobot() bool {
if x != nil {
return x.IsRobot
}
return false
}
func (x *RoomRobotLuckyGiftDrawn) GetSynthetic() bool {
if x != nil {
return x.Synthetic
}
return false
}
// RoomHeatChanged 表达热度变化结果。
type RoomHeatChanged struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1387,7 +1571,7 @@ type RoomHeatChanged struct {
func (x *RoomHeatChanged) Reset() {
*x = RoomHeatChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[17]
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1399,7 +1583,7 @@ func (x *RoomHeatChanged) String() string {
func (*RoomHeatChanged) ProtoMessage() {}
func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[17]
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1412,7 +1596,7 @@ func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomHeatChanged.ProtoReflect.Descriptor instead.
func (*RoomHeatChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{17}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{18}
}
func (x *RoomHeatChanged) GetDelta() int64 {
@ -1441,7 +1625,7 @@ type RoomRankChanged struct {
func (x *RoomRankChanged) Reset() {
*x = RoomRankChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1453,7 +1637,7 @@ func (x *RoomRankChanged) String() string {
func (*RoomRankChanged) ProtoMessage() {}
func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1466,7 +1650,7 @@ func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRankChanged.ProtoReflect.Descriptor instead.
func (*RoomRankChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{18}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{19}
}
func (x *RoomRankChanged) GetUserId() int64 {
@ -1506,7 +1690,7 @@ type RoomRocketRewardGrant struct {
func (x *RoomRocketRewardGrant) Reset() {
*x = RoomRocketRewardGrant{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1518,7 +1702,7 @@ func (x *RoomRocketRewardGrant) String() string {
func (*RoomRocketRewardGrant) ProtoMessage() {}
func (x *RoomRocketRewardGrant) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1531,7 +1715,7 @@ func (x *RoomRocketRewardGrant) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketRewardGrant.ProtoReflect.Descriptor instead.
func (*RoomRocketRewardGrant) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{19}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{20}
}
func (x *RoomRocketRewardGrant) GetRewardRole() string {
@ -1613,7 +1797,7 @@ type RoomRocketFuelChanged struct {
func (x *RoomRocketFuelChanged) Reset() {
*x = RoomRocketFuelChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1625,7 +1809,7 @@ func (x *RoomRocketFuelChanged) String() string {
func (*RoomRocketFuelChanged) ProtoMessage() {}
func (x *RoomRocketFuelChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1638,7 +1822,7 @@ func (x *RoomRocketFuelChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketFuelChanged.ProtoReflect.Descriptor instead.
func (*RoomRocketFuelChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{20}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{21}
}
func (x *RoomRocketFuelChanged) GetRocketId() string {
@ -1762,7 +1946,7 @@ type RoomRocketIgnited struct {
func (x *RoomRocketIgnited) Reset() {
*x = RoomRocketIgnited{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1774,7 +1958,7 @@ func (x *RoomRocketIgnited) String() string {
func (*RoomRocketIgnited) ProtoMessage() {}
func (x *RoomRocketIgnited) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1787,7 +1971,7 @@ func (x *RoomRocketIgnited) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketIgnited.ProtoReflect.Descriptor instead.
func (*RoomRocketIgnited) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{21}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{22}
}
func (x *RoomRocketIgnited) GetRocketId() string {
@ -1906,7 +2090,7 @@ type RoomRocketLaunched struct {
func (x *RoomRocketLaunched) Reset() {
*x = RoomRocketLaunched{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1918,7 +2102,7 @@ func (x *RoomRocketLaunched) String() string {
func (*RoomRocketLaunched) ProtoMessage() {}
func (x *RoomRocketLaunched) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1931,7 +2115,7 @@ func (x *RoomRocketLaunched) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketLaunched.ProtoReflect.Descriptor instead.
func (*RoomRocketLaunched) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{22}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{23}
}
func (x *RoomRocketLaunched) GetRocketId() string {
@ -2009,7 +2193,7 @@ type RoomRocketRewardGranted struct {
func (x *RoomRocketRewardGranted) Reset() {
*x = RoomRocketRewardGranted{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
mi := &file_proto_events_room_v1_events_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2021,7 +2205,7 @@ func (x *RoomRocketRewardGranted) String() string {
func (*RoomRocketRewardGranted) ProtoMessage() {}
func (x *RoomRocketRewardGranted) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
mi := &file_proto_events_room_v1_events_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2034,7 +2218,7 @@ func (x *RoomRocketRewardGranted) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketRewardGranted.ProtoReflect.Descriptor instead.
func (*RoomRocketRewardGranted) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{23}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{24}
}
func (x *RoomRocketRewardGranted) GetRocketId() string {
@ -2160,7 +2344,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\\\n" +
"\x10RoomUserUnbanned\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\xb5\x05\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\"\x8b\x06\n" +
"\fRoomGiftSent\x12$\n" +
"\x0esender_user_id\x18\x01 \x01(\x03R\fsenderUserId\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
@ -2186,7 +2370,29 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\rgift_icon_url\x18\x10 \x01(\tR\vgiftIconUrl\x12,\n" +
"\x12gift_animation_url\x18\x11 \x01(\tR\x10giftAnimationUrl\x12*\n" +
"\x11target_gift_value\x18\x12 \x01(\x03R\x0ftargetGiftValue\x12*\n" +
"\x11gift_effect_types\x18\x13 \x03(\tR\x0fgiftEffectTypes\"J\n" +
"\x11gift_effect_types\x18\x13 \x03(\tR\x0fgiftEffectTypes\x12\"\n" +
"\ris_robot_gift\x18\x14 \x01(\bR\visRobotGift\x120\n" +
"\x14synthetic_lucky_gift\x18\x15 \x01(\bR\x12syntheticLuckyGift\"\xf7\x04\n" +
"\x17RoomRobotLuckyGiftDrawn\x12\x17\n" +
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
"\n" +
"command_id\x18\x02 \x01(\tR\tcommandId\x12$\n" +
"\x0esender_user_id\x18\x03 \x01(\x03R\fsenderUserId\x12$\n" +
"\x0etarget_user_id\x18\x04 \x01(\x03R\ftargetUserId\x12\x17\n" +
"\agift_id\x18\x05 \x01(\tR\x06giftId\x12\x1d\n" +
"\n" +
"gift_count\x18\x06 \x01(\x05R\tgiftCount\x12\x17\n" +
"\apool_id\x18\a \x01(\tR\x06poolId\x12%\n" +
"\x0emultiplier_ppm\x18\b \x01(\x03R\rmultiplierPpm\x12*\n" +
"\x11base_reward_coins\x18\t \x01(\x03R\x0fbaseRewardCoins\x12?\n" +
"\x1croom_atmosphere_reward_coins\x18\n" +
" \x01(\x03R\x19roomAtmosphereRewardCoins\x124\n" +
"\x16activity_subsidy_coins\x18\v \x01(\x03R\x14activitySubsidyCoins\x124\n" +
"\x16effective_reward_coins\x18\f \x01(\x03R\x14effectiveRewardCoins\x12\"\n" +
"\rcreated_at_ms\x18\r \x01(\x03R\vcreatedAtMs\x12*\n" +
"\x11visible_region_id\x18\x0e \x01(\x03R\x0fvisibleRegionId\x12\x19\n" +
"\bis_robot\x18\x0f \x01(\bR\aisRobot\x12\x1c\n" +
"\tsynthetic\x18\x10 \x01(\bR\tsynthetic\"J\n" +
"\x0fRoomHeatChanged\x12\x14\n" +
"\x05delta\x18\x01 \x01(\x03R\x05delta\x12!\n" +
"\fcurrent_heat\x18\x02 \x01(\x03R\vcurrentHeat\"_\n" +
@ -2272,7 +2478,7 @@ 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, 24)
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 25)
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
@ -2291,18 +2497,19 @@ var file_proto_events_room_v1_events_proto_goTypes = []any{
(*RoomUserKicked)(nil), // 14: hyapp.events.room.v1.RoomUserKicked
(*RoomUserUnbanned)(nil), // 15: hyapp.events.room.v1.RoomUserUnbanned
(*RoomGiftSent)(nil), // 16: hyapp.events.room.v1.RoomGiftSent
(*RoomHeatChanged)(nil), // 17: hyapp.events.room.v1.RoomHeatChanged
(*RoomRankChanged)(nil), // 18: hyapp.events.room.v1.RoomRankChanged
(*RoomRocketRewardGrant)(nil), // 19: hyapp.events.room.v1.RoomRocketRewardGrant
(*RoomRocketFuelChanged)(nil), // 20: hyapp.events.room.v1.RoomRocketFuelChanged
(*RoomRocketIgnited)(nil), // 21: hyapp.events.room.v1.RoomRocketIgnited
(*RoomRocketLaunched)(nil), // 22: hyapp.events.room.v1.RoomRocketLaunched
(*RoomRocketRewardGranted)(nil), // 23: hyapp.events.room.v1.RoomRocketRewardGranted
(*RoomRobotLuckyGiftDrawn)(nil), // 17: hyapp.events.room.v1.RoomRobotLuckyGiftDrawn
(*RoomHeatChanged)(nil), // 18: hyapp.events.room.v1.RoomHeatChanged
(*RoomRankChanged)(nil), // 19: hyapp.events.room.v1.RoomRankChanged
(*RoomRocketRewardGrant)(nil), // 20: hyapp.events.room.v1.RoomRocketRewardGrant
(*RoomRocketFuelChanged)(nil), // 21: hyapp.events.room.v1.RoomRocketFuelChanged
(*RoomRocketIgnited)(nil), // 22: hyapp.events.room.v1.RoomRocketIgnited
(*RoomRocketLaunched)(nil), // 23: hyapp.events.room.v1.RoomRocketLaunched
(*RoomRocketRewardGranted)(nil), // 24: hyapp.events.room.v1.RoomRocketRewardGranted
}
var file_proto_events_room_v1_events_proto_depIdxs = []int32{
4, // 0: hyapp.events.room.v1.RoomUserJoined.entry_vehicle:type_name -> hyapp.events.room.v1.RoomEntryVehicleSnapshot
19, // 1: hyapp.events.room.v1.RoomRocketLaunched.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
19, // 2: hyapp.events.room.v1.RoomRocketRewardGranted.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
20, // 1: hyapp.events.room.v1.RoomRocketLaunched.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
20, // 2: hyapp.events.room.v1.RoomRocketRewardGranted.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
3, // [3:3] is the sub-list for method output_type
3, // [3:3] is the sub-list for method input_type
3, // [3:3] is the sub-list for extension type_name
@ -2321,7 +2528,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: 24,
NumMessages: 25,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -166,6 +166,31 @@ message RoomGiftSent {
string gift_animation_url = 17;
int64 target_gift_value = 18;
repeated string gift_effect_types = 19;
// is_robot_gift
bool is_robot_gift = 20;
// synthetic_lucky_gift
bool synthetic_lucky_gift = 21;
}
// RoomRobotLuckyGiftDrawn
// lucky_gift_drawn
message RoomRobotLuckyGiftDrawn {
string draw_id = 1;
string command_id = 2;
int64 sender_user_id = 3;
int64 target_user_id = 4;
string gift_id = 5;
int32 gift_count = 6;
string pool_id = 7;
int64 multiplier_ppm = 8;
int64 base_reward_coins = 9;
int64 room_atmosphere_reward_coins = 10;
int64 activity_subsidy_coins = 11;
int64 effective_reward_coins = 12;
int64 created_at_ms = 13;
int64 visible_region_id = 14;
bool is_robot = 15;
bool synthetic = 16;
}
// RoomHeatChanged

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.0
// protoc v5.29.2
// source: proto/game/v1/game.proto
package gamev1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.0
// - protoc v5.29.2
// source: proto/game/v1/game.proto
package gamev1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.0
// protoc v5.29.2
// source: proto/robot/v1/robot.proto
package robotv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.0
// - protoc v5.29.2
// source: proto/robot/v1/robot.proto
package robotv1

File diff suppressed because it is too large Load Diff

View File

@ -338,6 +338,85 @@ message AdminCancelRoomPinResponse {
int64 server_time_ms = 2;
}
message AdminRobotRoomGiftRule {
repeated string gift_ids = 1;
repeated string lucky_gift_ids = 2;
int64 normal_gift_interval_ms = 3;
int64 lucky_combo_min = 4;
int64 lucky_combo_max = 5;
int64 lucky_pause_min_ms = 6;
int64 lucky_pause_max_ms = 7;
}
message AdminRobotRoom {
string app_code = 1;
string room_id = 2;
string room_short_id = 3;
string title = 4;
string cover_url = 5;
int64 visible_region_id = 6;
string status = 7;
int64 owner_robot_user_id = 8;
repeated int64 robot_user_ids = 9;
AdminRobotRoomGiftRule gift_rule = 10;
uint64 created_by_admin_id = 11;
int64 created_at_ms = 12;
int64 updated_at_ms = 13;
}
message AdminListRobotRoomsRequest {
RequestMeta meta = 1;
int32 page = 2;
int32 page_size = 3;
string status = 4;
}
message AdminListRobotRoomsResponse {
repeated AdminRobotRoom rooms = 1;
int64 total = 2;
int64 server_time_ms = 3;
}
message AdminCreateRobotRoomRequest {
RequestMeta meta = 1;
int64 owner_robot_user_id = 2;
repeated int64 candidate_robot_user_ids = 3;
int32 min_robot_count = 4;
int32 max_robot_count = 5;
string room_name = 6;
string room_avatar = 7;
int64 visible_region_id = 8;
AdminRobotRoomGiftRule gift_rule = 9;
uint64 admin_id = 10;
}
message AdminCreateRobotRoomResponse {
AdminRobotRoom room = 1;
int64 server_time_ms = 2;
}
message AdminSetRobotRoomStatusRequest {
RequestMeta meta = 1;
string status = 2;
uint64 admin_id = 3;
}
message AdminSetRobotRoomStatusResponse {
AdminRobotRoom room = 1;
int64 server_time_ms = 2;
}
message AdminFilterAvailableRoomRobotsRequest {
RequestMeta meta = 1;
repeated int64 user_ids = 2;
}
message AdminFilterAvailableRoomRobotsResponse {
repeated int64 available_user_ids = 1;
repeated int64 occupied_user_ids = 2;
int64 server_time_ms = 3;
}
// RoomBanState ban
message RoomBanState {
int64 user_id = 1;
@ -440,6 +519,10 @@ message CreateRoomRequest {
string room_short_id = 8;
// owner_country_code gateway user-service
string owner_country_code = 9;
// robot_room gateway
bool robot_room = 10;
// robot_user_ids
repeated int64 robot_user_ids = 11;
}
// CreateRoomResponse
@ -1149,6 +1232,8 @@ service RoomCommandService {
rpc AdminUpdateRoomSeatConfig(AdminUpdateRoomSeatConfigRequest) returns (AdminUpdateRoomSeatConfigResponse);
rpc AdminCreateRoomPin(AdminCreateRoomPinRequest) returns (AdminCreateRoomPinResponse);
rpc AdminCancelRoomPin(AdminCancelRoomPinRequest) returns (AdminCancelRoomPinResponse);
rpc AdminCreateRobotRoom(AdminCreateRobotRoomRequest) returns (AdminCreateRobotRoomResponse);
rpc AdminSetRobotRoomStatus(AdminSetRobotRoomStatusRequest) returns (AdminSetRobotRoomStatusResponse);
rpc MicUp(MicUpRequest) returns (MicUpResponse);
rpc MicDown(MicDownRequest) returns (MicDownResponse);
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
@ -1182,6 +1267,8 @@ service RoomQueryService {
rpc AdminGetRoomRocketConfig(AdminGetRoomRocketConfigRequest) returns (AdminGetRoomRocketConfigResponse);
rpc AdminGetRoomSeatConfig(AdminGetRoomSeatConfigRequest) returns (AdminGetRoomSeatConfigResponse);
rpc AdminListRoomPins(AdminListRoomPinsRequest) returns (AdminListRoomPinsResponse);
rpc AdminListRobotRooms(AdminListRobotRoomsRequest) returns (AdminListRobotRoomsResponse);
rpc AdminFilterAvailableRoomRobots(AdminFilterAvailableRoomRobotsRequest) returns (AdminFilterAvailableRoomRobotsResponse);
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
rpc ListRoomFeeds(ListRoomFeedsRequest) returns (ListRoomsResponse);
rpc ListRoomGiftLeaderboard(ListRoomGiftLeaderboardRequest) returns (ListRoomGiftLeaderboardResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.0
// - protoc v5.29.2
// source: proto/room/v1/room.proto
package roomv1
@ -33,6 +33,8 @@ const (
RoomCommandService_AdminUpdateRoomSeatConfig_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoomSeatConfig"
RoomCommandService_AdminCreateRoomPin_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCreateRoomPin"
RoomCommandService_AdminCancelRoomPin_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCancelRoomPin"
RoomCommandService_AdminCreateRobotRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminCreateRobotRoom"
RoomCommandService_AdminSetRobotRoomStatus_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminSetRobotRoomStatus"
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
@ -73,6 +75,8 @@ type RoomCommandServiceClient interface {
AdminUpdateRoomSeatConfig(ctx context.Context, in *AdminUpdateRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomSeatConfigResponse, error)
AdminCreateRoomPin(ctx context.Context, in *AdminCreateRoomPinRequest, opts ...grpc.CallOption) (*AdminCreateRoomPinResponse, error)
AdminCancelRoomPin(ctx context.Context, in *AdminCancelRoomPinRequest, opts ...grpc.CallOption) (*AdminCancelRoomPinResponse, error)
AdminCreateRobotRoom(ctx context.Context, in *AdminCreateRobotRoomRequest, opts ...grpc.CallOption) (*AdminCreateRobotRoomResponse, error)
AdminSetRobotRoomStatus(ctx context.Context, in *AdminSetRobotRoomStatusRequest, opts ...grpc.CallOption) (*AdminSetRobotRoomStatusResponse, error)
MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error)
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
@ -241,6 +245,26 @@ func (c *roomCommandServiceClient) AdminCancelRoomPin(ctx context.Context, in *A
return out, nil
}
func (c *roomCommandServiceClient) AdminCreateRobotRoom(ctx context.Context, in *AdminCreateRobotRoomRequest, opts ...grpc.CallOption) (*AdminCreateRobotRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminCreateRobotRoomResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminCreateRobotRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) AdminSetRobotRoomStatus(ctx context.Context, in *AdminSetRobotRoomStatusRequest, opts ...grpc.CallOption) (*AdminSetRobotRoomStatusResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminSetRobotRoomStatusResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminSetRobotRoomStatus_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(MicUpResponse)
@ -441,6 +465,8 @@ type RoomCommandServiceServer interface {
AdminUpdateRoomSeatConfig(context.Context, *AdminUpdateRoomSeatConfigRequest) (*AdminUpdateRoomSeatConfigResponse, error)
AdminCreateRoomPin(context.Context, *AdminCreateRoomPinRequest) (*AdminCreateRoomPinResponse, error)
AdminCancelRoomPin(context.Context, *AdminCancelRoomPinRequest) (*AdminCancelRoomPinResponse, error)
AdminCreateRobotRoom(context.Context, *AdminCreateRobotRoomRequest) (*AdminCreateRobotRoomResponse, error)
AdminSetRobotRoomStatus(context.Context, *AdminSetRobotRoomStatusRequest) (*AdminSetRobotRoomStatusResponse, error)
MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error)
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
@ -511,6 +537,12 @@ func (UnimplementedRoomCommandServiceServer) AdminCreateRoomPin(context.Context,
func (UnimplementedRoomCommandServiceServer) AdminCancelRoomPin(context.Context, *AdminCancelRoomPinRequest) (*AdminCancelRoomPinResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminCancelRoomPin not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminCreateRobotRoom(context.Context, *AdminCreateRobotRoomRequest) (*AdminCreateRobotRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminCreateRobotRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminSetRobotRoomStatus(context.Context, *AdminSetRobotRoomStatusRequest) (*AdminSetRobotRoomStatusResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminSetRobotRoomStatus not implemented")
}
func (UnimplementedRoomCommandServiceServer) MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error) {
return nil, status.Error(codes.Unimplemented, "method MicUp not implemented")
}
@ -838,6 +870,42 @@ func _RoomCommandService_AdminCancelRoomPin_Handler(srv interface{}, ctx context
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminCreateRobotRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminCreateRobotRoomRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminCreateRobotRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminCreateRobotRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminCreateRobotRoom(ctx, req.(*AdminCreateRobotRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminSetRobotRoomStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminSetRobotRoomStatusRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminSetRobotRoomStatus(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminSetRobotRoomStatus_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminSetRobotRoomStatus(ctx, req.(*AdminSetRobotRoomStatusRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_MicUp_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(MicUpRequest)
if err := dec(in); err != nil {
@ -1225,6 +1293,14 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AdminCancelRoomPin",
Handler: _RoomCommandService_AdminCancelRoomPin_Handler,
},
{
MethodName: "AdminCreateRobotRoom",
Handler: _RoomCommandService_AdminCreateRobotRoom_Handler,
},
{
MethodName: "AdminSetRobotRoomStatus",
Handler: _RoomCommandService_AdminSetRobotRoomStatus_Handler,
},
{
MethodName: "MicUp",
Handler: _RoomCommandService_MicUp_Handler,
@ -1452,6 +1528,8 @@ const (
RoomQueryService_AdminGetRoomRocketConfig_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminGetRoomRocketConfig"
RoomQueryService_AdminGetRoomSeatConfig_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminGetRoomSeatConfig"
RoomQueryService_AdminListRoomPins_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminListRoomPins"
RoomQueryService_AdminListRobotRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminListRobotRooms"
RoomQueryService_AdminFilterAvailableRoomRobots_FullMethodName = "/hyapp.room.v1.RoomQueryService/AdminFilterAvailableRoomRobots"
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
RoomQueryService_ListRoomFeeds_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomFeeds"
RoomQueryService_ListRoomGiftLeaderboard_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRoomGiftLeaderboard"
@ -1475,6 +1553,8 @@ type RoomQueryServiceClient interface {
AdminGetRoomRocketConfig(ctx context.Context, in *AdminGetRoomRocketConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomRocketConfigResponse, error)
AdminGetRoomSeatConfig(ctx context.Context, in *AdminGetRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminGetRoomSeatConfigResponse, error)
AdminListRoomPins(ctx context.Context, in *AdminListRoomPinsRequest, opts ...grpc.CallOption) (*AdminListRoomPinsResponse, error)
AdminListRobotRooms(ctx context.Context, in *AdminListRobotRoomsRequest, opts ...grpc.CallOption) (*AdminListRobotRoomsResponse, error)
AdminFilterAvailableRoomRobots(ctx context.Context, in *AdminFilterAvailableRoomRobotsRequest, opts ...grpc.CallOption) (*AdminFilterAvailableRoomRobotsResponse, error)
ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
ListRoomFeeds(ctx context.Context, in *ListRoomFeedsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
ListRoomGiftLeaderboard(ctx context.Context, in *ListRoomGiftLeaderboardRequest, opts ...grpc.CallOption) (*ListRoomGiftLeaderboardResponse, error)
@ -1545,6 +1625,26 @@ func (c *roomQueryServiceClient) AdminListRoomPins(ctx context.Context, in *Admi
return out, nil
}
func (c *roomQueryServiceClient) AdminListRobotRooms(ctx context.Context, in *AdminListRobotRoomsRequest, opts ...grpc.CallOption) (*AdminListRobotRoomsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminListRobotRoomsResponse)
err := c.cc.Invoke(ctx, RoomQueryService_AdminListRobotRooms_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) AdminFilterAvailableRoomRobots(ctx context.Context, in *AdminFilterAvailableRoomRobotsRequest, opts ...grpc.CallOption) (*AdminFilterAvailableRoomRobotsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminFilterAvailableRoomRobotsResponse)
err := c.cc.Invoke(ctx, RoomQueryService_AdminFilterAvailableRoomRobots_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomQueryServiceClient) ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoomsResponse)
@ -1656,6 +1756,8 @@ type RoomQueryServiceServer interface {
AdminGetRoomRocketConfig(context.Context, *AdminGetRoomRocketConfigRequest) (*AdminGetRoomRocketConfigResponse, error)
AdminGetRoomSeatConfig(context.Context, *AdminGetRoomSeatConfigRequest) (*AdminGetRoomSeatConfigResponse, error)
AdminListRoomPins(context.Context, *AdminListRoomPinsRequest) (*AdminListRoomPinsResponse, error)
AdminListRobotRooms(context.Context, *AdminListRobotRoomsRequest) (*AdminListRobotRoomsResponse, error)
AdminFilterAvailableRoomRobots(context.Context, *AdminFilterAvailableRoomRobotsRequest) (*AdminFilterAvailableRoomRobotsResponse, error)
ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error)
ListRoomFeeds(context.Context, *ListRoomFeedsRequest) (*ListRoomsResponse, error)
ListRoomGiftLeaderboard(context.Context, *ListRoomGiftLeaderboardRequest) (*ListRoomGiftLeaderboardResponse, error)
@ -1691,6 +1793,12 @@ func (UnimplementedRoomQueryServiceServer) AdminGetRoomSeatConfig(context.Contex
func (UnimplementedRoomQueryServiceServer) AdminListRoomPins(context.Context, *AdminListRoomPinsRequest) (*AdminListRoomPinsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminListRoomPins not implemented")
}
func (UnimplementedRoomQueryServiceServer) AdminListRobotRooms(context.Context, *AdminListRobotRoomsRequest) (*AdminListRobotRoomsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminListRobotRooms not implemented")
}
func (UnimplementedRoomQueryServiceServer) AdminFilterAvailableRoomRobots(context.Context, *AdminFilterAvailableRoomRobotsRequest) (*AdminFilterAvailableRoomRobotsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminFilterAvailableRoomRobots not implemented")
}
func (UnimplementedRoomQueryServiceServer) ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRooms not implemented")
}
@ -1832,6 +1940,42 @@ func _RoomQueryService_AdminListRoomPins_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_AdminListRobotRooms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminListRobotRoomsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).AdminListRobotRooms(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_AdminListRobotRooms_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).AdminListRobotRooms(ctx, req.(*AdminListRobotRoomsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_AdminFilterAvailableRoomRobots_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminFilterAvailableRoomRobotsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomQueryServiceServer).AdminFilterAvailableRoomRobots(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomQueryService_AdminFilterAvailableRoomRobots_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomQueryServiceServer).AdminFilterAvailableRoomRobots(ctx, req.(*AdminFilterAvailableRoomRobotsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomQueryService_ListRooms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoomsRequest)
if err := dec(in); err != nil {
@ -2039,6 +2183,14 @@ var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AdminListRoomPins",
Handler: _RoomQueryService_AdminListRoomPins_Handler,
},
{
MethodName: "AdminListRobotRooms",
Handler: _RoomQueryService_AdminListRobotRooms_Handler,
},
{
MethodName: "AdminFilterAvailableRoomRobots",
Handler: _RoomQueryService_AdminFilterAvailableRoomRobots_Handler,
},
{
MethodName: "ListRooms",
Handler: _RoomQueryService_ListRooms_Handler,

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.0
// protoc v5.29.2
// source: proto/user/v1/auth.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.0
// - protoc v5.29.2
// source: proto/user/v1/auth.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.0
// protoc v5.29.2
// source: proto/user/v1/host.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.0
// - protoc v5.29.2
// source: proto/user/v1/host.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v7.35.0
// protoc v5.29.2
// source: proto/user/v1/user.proto
package userv1

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.0
// - protoc v5.29.2
// source: proto/user/v1/user.proto
package userv1

File diff suppressed because it is too large Load Diff

View File

@ -91,6 +91,21 @@ message BatchDebitGiftResponse {
repeated BatchDebitGiftReceipt receipts = 2;
}
// DebitRobotGiftRequest
// ROBOT_COIN
message DebitRobotGiftRequest {
string command_id = 1;
string room_id = 2;
int64 sender_user_id = 3;
int64 target_user_id = 4;
string gift_id = 5;
int32 gift_count = 6;
string price_version = 7;
string app_code = 8;
int64 region_id = 9;
int64 sender_region_id = 10;
}
// AssetBalance
message AssetBalance {
string asset_type = 1;
@ -1829,6 +1844,7 @@ service WalletCronService {
service WalletService {
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
rpc BatchDebitGift(BatchDebitGiftRequest) returns (BatchDebitGiftResponse);
rpc DebitRobotGift(DebitRobotGiftRequest) returns (DebitGiftResponse);
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
rpc GetActiveHostSalaryPolicy(GetActiveHostSalaryPolicyRequest) returns (GetActiveHostSalaryPolicyResponse);
rpc GetHostSalaryProgress(GetHostSalaryProgressRequest) returns (GetHostSalaryProgressResponse);

View File

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.6.2
// - protoc v7.35.0
// - protoc v5.29.2
// source: proto/wallet/v1/wallet.proto
package walletv1
@ -203,6 +203,7 @@ var WalletCronService_ServiceDesc = grpc.ServiceDesc{
const (
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
WalletService_BatchDebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/BatchDebitGift"
WalletService_DebitRobotGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitRobotGift"
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
WalletService_GetActiveHostSalaryPolicy_FullMethodName = "/hyapp.wallet.v1.WalletService/GetActiveHostSalaryPolicy"
WalletService_GetHostSalaryProgress_FullMethodName = "/hyapp.wallet.v1.WalletService/GetHostSalaryProgress"
@ -290,6 +291,7 @@ const (
type WalletServiceClient interface {
DebitGift(ctx context.Context, in *DebitGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
BatchDebitGift(ctx context.Context, in *BatchDebitGiftRequest, opts ...grpc.CallOption) (*BatchDebitGiftResponse, error)
DebitRobotGift(ctx context.Context, in *DebitRobotGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error)
GetActiveHostSalaryPolicy(ctx context.Context, in *GetActiveHostSalaryPolicyRequest, opts ...grpc.CallOption) (*GetActiveHostSalaryPolicyResponse, error)
GetHostSalaryProgress(ctx context.Context, in *GetHostSalaryProgressRequest, opts ...grpc.CallOption) (*GetHostSalaryProgressResponse, error)
@ -397,6 +399,16 @@ func (c *walletServiceClient) BatchDebitGift(ctx context.Context, in *BatchDebit
return out, nil
}
func (c *walletServiceClient) DebitRobotGift(ctx context.Context, in *DebitRobotGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(DebitGiftResponse)
err := c.cc.Invoke(ctx, WalletService_DebitRobotGift_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *walletServiceClient) GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetBalancesResponse)
@ -1175,6 +1187,7 @@ func (c *walletServiceClient) RetryRedPacketRefund(ctx context.Context, in *Retr
type WalletServiceServer interface {
DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error)
BatchDebitGift(context.Context, *BatchDebitGiftRequest) (*BatchDebitGiftResponse, error)
DebitRobotGift(context.Context, *DebitRobotGiftRequest) (*DebitGiftResponse, error)
GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error)
GetActiveHostSalaryPolicy(context.Context, *GetActiveHostSalaryPolicyRequest) (*GetActiveHostSalaryPolicyResponse, error)
GetHostSalaryProgress(context.Context, *GetHostSalaryProgressRequest) (*GetHostSalaryProgressResponse, error)
@ -1268,6 +1281,9 @@ func (UnimplementedWalletServiceServer) DebitGift(context.Context, *DebitGiftReq
func (UnimplementedWalletServiceServer) BatchDebitGift(context.Context, *BatchDebitGiftRequest) (*BatchDebitGiftResponse, error) {
return nil, status.Error(codes.Unimplemented, "method BatchDebitGift not implemented")
}
func (UnimplementedWalletServiceServer) DebitRobotGift(context.Context, *DebitRobotGiftRequest) (*DebitGiftResponse, error) {
return nil, status.Error(codes.Unimplemented, "method DebitRobotGift not implemented")
}
func (UnimplementedWalletServiceServer) GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetBalances not implemented")
}
@ -1556,6 +1572,24 @@ func _WalletService_BatchDebitGift_Handler(srv interface{}, ctx context.Context,
return interceptor(ctx, in, info, handler)
}
func _WalletService_DebitRobotGift_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(DebitRobotGiftRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(WalletServiceServer).DebitRobotGift(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: WalletService_DebitRobotGift_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(WalletServiceServer).DebitRobotGift(ctx, req.(*DebitRobotGiftRequest))
}
return interceptor(ctx, in, info, handler)
}
func _WalletService_GetBalances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetBalancesRequest)
if err := dec(in); err != nil {
@ -2957,6 +2991,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "BatchDebitGift",
Handler: _WalletService_BatchDebitGift_Handler,
},
{
MethodName: "DebitRobotGift",
Handler: _WalletService_DebitRobotGift_Handler,
},
{
MethodName: "GetBalances",
Handler: _WalletService_GetBalances_Handler,

View File

@ -0,0 +1,98 @@
# 机器人房间产品文档
## 背景
语音房冷启动阶段,房间列表和新房首屏容易显得冷清。机器人房间用于在内部可控范围内营造房间活跃氛围:机器人账号创建房间、进入房间、静音上麦,并在房间内相互送礼,触发客户端已有的礼物动效和幸运礼物展示。
机器人房间不面向真人主播发放收益,不参与真实抽奖、真实奖池、榜单和结算。
## 产品目标
- 后台可创建和控制机器人房间。
- 每个机器人房间自动拉取一批未被占用的机器人账号进房。
- 机器人可静音上麦,让房间麦位呈现活跃状态。
- 机器人之间自动送普通礼物和幸运礼物。
- 幸运礼物展示复用客户端现有飘屏/房间展示协议,但不影响真实幸运礼物奖池和流水。
- 机器人房间积分和麦位热度正常增长,用于房间内氛围展示。
- 机器人礼物不产生真人收益,不污染充值、消费、结算和排行榜数据。
## 使用入口
后台管理系统路径:
`房间管理 / 机器人房间`
权限:
- `room-robot:view`:查看机器人房间。
- `room-robot:create`:创建机器人房间。
- `room-robot:update`:启动/停止机器人房间。
## 创建机器人房间
点击 `增加机器人房间` 后配置:
- 房间名称:机器人房间对外展示名称。
- 房主机器人:从未被机器人房间占用的房间机器人账号中选择。
- 候选机器人:从未被机器人房间占用的机器人账号中选择,默认可选择全部可用机器人。
- 进房人数范围:例如 `6-10`,系统会从候选机器人中随机抽取一个人数。
- 礼物合集:普通礼物池,机器人普通送礼时随机选择。
- 幸运礼物合集:幸运礼物展示池,机器人幸运送礼时随机选择。
- 普通礼物频次:每个机器人每隔固定毫秒数送一次普通礼物。
- 幸运礼物连击范围:每轮幸运礼物随机生成连击次数。
- 幸运礼物间隔范围:一轮幸运礼物结束后,随机等待一段时间再开始下一轮。
## 送礼规则
### 普通礼物
- 每个机器人独立执行。
- 到达配置间隔后,从普通礼物合集中随机选择 1 个礼物。
- 随机选择同房间另一个机器人作为收礼人。
- 每次只送 1 个目标机器人。
### 幸运礼物
- 每个机器人独立执行。
- 从幸运礼物合集中随机选择 1 个礼物。
- 当前机器人向同房间所有其他机器人送礼。
- 按配置范围随机生成连击次数,例如 `100-10000`
- 连击完成后,按配置间隔范围随机等待,例如 `5-20 秒`,再开始下一轮。
## 状态管理
机器人房间支持:
- 运行中:机器人送礼循环运行。
- 已停止:机器人送礼循环停止,配置和房间占用保留。
后台列表中可使用状态开关快速启动或停止。
## 数据隔离
机器人房间遵循以下隔离规则:
- 机器人只给同房间机器人送礼。
- 服务端禁止机器人给真人送礼。
- 机器人礼物使用机器人专用金币 `ROBOT_COIN`
- 不消耗真人金币 `COIN`
- 不产生主播钻石和工资周期钻石。
- 不写真实礼物墙。
- 不参与真实幸运礼物奖池、抽奖、返奖和统计。
- 不参与房间排行榜、活动排行榜和结算。
## 运营建议
- 单房机器人数量建议从 `6-10` 开始。
- 普通礼物频次建议不要过低,避免房间消息过密。
- 幸运礼物连击范围建议根据活动氛围配置,测试环境可用较小范围验证。
- 礼物合集应选择低价或专门用于展示的礼物,避免用户误解真实返奖。
- 机器人房间应优先用于冷启动、活动预热和列表氛围补足。
## 风险与限制
- 机器人房间不应和真人主播收益挂钩。
- 不建议使用真人主播账号作为机器人账号。
- 如果候选机器人不足,创建会失败。
- 同一个机器人不能同时被多个 active 机器人房间占用。
- 停止房间只停止送礼循环,不删除房间配置。

View File

@ -0,0 +1,243 @@
# 机器人房间技术架构文档
## 目标
机器人房间提供后台配置驱动的内部房间编排能力,支持机器人创建房间、进房、虚拟静音上麦、普通礼物展示、幸运礼物展示,并保证与真实钱包、真实幸运礼物、真实奖池、真实榜单和结算隔离。
## 模块边界
### admin-platform
- 提供机器人房间配置页面。
- 调用 admin-server HTTP 接口。
- 不直接访问业务服务。
### admin-server
- 暴露后台 HTTP API。
- 做权限、审计、参数校验和 DTO 转换。
- 通过 gRPC client 调用 room-service 和 robot-service。
- 通过 wallet-service gRPC 获取礼物配置。
- 只读用户库补齐机器人展示资料。
### robot-service
- 只拥有机器人账号池事实。
- 房间机器人池表:`hyapp_robot.robot_room_robots`
- 不处理进房、麦位、送礼和 IM。
### room-service
- 机器人房间配置 owner。
- 创建真实房间状态。
- 管理机器人房间配置表:`hyapp_room.room_robot_rooms`
- 执行机器人进房、虚拟静音上麦、送礼循环。
- 强校验机器人送礼仅限同房间机器人。
- 生成机器人幸运礼物展示事件。
### wallet-service
- 机器人礼物扣费入口:`DebitRobotGift`
- 使用专用资产 `ROBOT_COIN`
- 自动补足机器人专用金币后扣减。
- 不发出真实 `WalletGiftDebited` 事件。
- 不写主播周期钻石和真实礼物墙。
### activity/statistics
- 消费房间送礼事件时跳过 `is_robot_gift`
- 机器人礼物不进入真实活动、榜单、任务、成长值和统计。
## 核心数据表
### `hyapp_robot.robot_room_robots`
机器人账号池事实表。
关键字段:
- `app_code`
- `room_scene`
- `user_id`
- `status`
- `last_used_at_ms`
- `used_count`
### `hyapp_room.room_robot_rooms`
机器人房间配置和占用事实表。
关键字段:
- `room_id`
- `room_short_id`
- `status`
- `owner_robot_user_id`
- `robot_user_ids_json`
- `gift_ids_json`
- `lucky_gift_ids_json`
- `normal_gift_interval_ms`
- `lucky_combo_min`
- `lucky_combo_max`
- `lucky_pause_min_ms`
- `lucky_pause_max_ms`
## 后台 API
后台 HTTP
- `GET /api/v1/admin/rooms/robot-rooms`
- `GET /api/v1/admin/rooms/robot-rooms/available-robots`
- `POST /api/v1/admin/rooms/robot-rooms`
- `POST /api/v1/admin/rooms/robot-rooms/{room_id}/start`
- `POST /api/v1/admin/rooms/robot-rooms/{room_id}/stop`
room-service gRPC
- `AdminListRobotRooms`
- `AdminFilterAvailableRoomRobots`
- `AdminCreateRobotRoom`
- `AdminSetRobotRoomStatus`
wallet-service gRPC
- `DebitRobotGift`
## 创建流程
```mermaid
sequenceDiagram
participant Admin as admin-platform
participant AS as admin-server
participant RS as room-service
participant RB as robot-service
participant DB as MySQL
Admin->>AS: POST /admin/rooms/robot-rooms
AS->>RS: AdminCreateRobotRoom
RS->>DB: 查询 active 机器人房占用
RS->>RS: 从候选机器人随机抽取人数
RS->>RS: CreateRoom(robot_room=true)
RS->>RS: JoinRoom for robots
RS->>RS: RobotVirtualMicUp for robots
RS->>DB: 写 room_robot_rooms
RS->>RS: start runtime loops
RS-->>AS: AdminRobotRoom
AS-->>Admin: 机器人房间详情
```
## 运行时循环
room-service 启动时会扫描所有 active 机器人房间并恢复运行时循环。
每个 active 机器人房间:
- 为每个机器人启动普通礼物 loop。
- 为每个机器人启动幸运礼物 loop。
- stop 后取消对应房间的 context。
普通礼物 loop
1. 等待 `normal_gift_interval_ms`
2. 从 `gift_ids_json` 随机选择礼物。
3. 从同房间其他机器人随机选择 1 个目标。
4. 调用 `RobotSendGift`
幸运礼物 loop
1. 从 `lucky_gift_ids_json` 随机选择礼物。
2. 随机生成 `lucky_combo_min-lucky_combo_max` 连击次数。
3. 当前机器人向同房间其他机器人逐个送礼。
4. 使用 `synthetic_lucky_gift=true`,跳过真实抽奖。
5. 等待 `lucky_pause_min_ms-lucky_pause_max_ms` 后继续。
## 服务端安全校验
room-service 在机器人送礼时强校验:
- 当前房间必须是机器人房间。
- sender 必须在该机器人房间 `robot_user_ids_json` 内。
- target 必须在同一机器人房间 `robot_user_ids_json` 内。
- sender 不能给真人送礼。
- 当前机器人礼物只允许单目标调用。
## 钱包隔离
机器人礼物使用 `DebitRobotGift`
- 资产类型固定为 `ROBOT_COIN`
- 如果机器人 `ROBOT_COIN` 不足,同一事务内自动补足。
- 立即扣减本次礼物价格。
- 交易类型为 `robot_gift_debit`
- 不写真实 `gift_debit`
- 不发 `WalletGiftDebited`
- 不写 `user_gift_wall`
- 不写 `host_period_diamond_accounts`
## 幸运礼物隔离
机器人幸运礼物不会调用真实幸运抽奖链路。
room-service 只生成展示事件:
- `RoomRobotLuckyGiftDrawn`
- 对 IM 客户端映射为 `lucky_gift_drawn`
- 标记 `synthetic=true`
- 携带机器人房间、送礼人、收礼人、礼物和展示金额字段
真实奖池、真实流水、真实返奖、真实统计完全不写入。
## 排行榜与统计隔离
`RoomGiftSent` 增加:
- `is_robot_gift`
- `synthetic_lucky_gift`
下游服务看到 `is_robot_gift=true` 时跳过:
- 活动播报统计
- 周星
- 房间流水奖励
- 每日任务
- 成长值
- statistics 礼物消费与幸运礼物统计
room-service 本地仍允许机器人房内:
- 房间热度增长
- 麦位热度增长
- 房内展示事件发送
## 本地接口测试
测试工具:
```bash
go run ./tools/robot-room-api-test
```
默认依赖:
- admin-server: `http://127.0.0.1:13100/api/v1`
- MySQL: `127.0.0.1:23306`
- 管理员账号: `admin / admin123`
工具会:
1. 种 12 个房间机器人账号。
2. 种 2 个礼物配置和价格。
3. 登录 admin-server。
4. 调用可用机器人列表接口。
5. 创建机器人房间。
6. 查询 active 列表。
7. 停止、启动、再次停止机器人房间。
8. 校验数据库中机器人房间配置和真实 `gift_debit` 隔离。
## 运维注意事项
- 多实例 room-service 下,机器人房间 runtime 是进程内循环;重启会从 MySQL active 配置恢复。
- 机器人房间配置以 MySQL 为准。
- 同一机器人账号不能被多个 active 机器人房间占用。
- 停止机器人房间只停止 runtime不删除房间。
- 如果需要彻底释放机器人账号,应增加删除/归档能力后再开放。

View File

@ -18,6 +18,7 @@ import (
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
@ -197,6 +198,13 @@ func main() {
defer roomConn.Close()
roomClient := roomclient.NewGRPC(roomConn)
robotConn, err := dialBackendGRPC(cfg.RobotService.Addr)
if err != nil {
fatalRuntime("connect_robot_service_failed", err)
}
defer robotConn.Close()
robotClient := robotclient.NewGRPC(robotConn)
activityConn, err := dialBackendGRPC(cfg.ActivityService.Addr)
if err != nil {
fatalRuntime("connect_activity_service_failed", err)
@ -279,7 +287,7 @@ func main() {
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler),
RoomAdmin: roomadminmodule.New(userDB, roomClient, auditHandler),
RoomAdmin: roomadminmodule.New(userDB, roomClient, robotClient, auditHandler),
RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
RoomTurnoverReward: roomturnoverrewardmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Search: searchmodule.New(store),

View File

@ -47,6 +47,9 @@ wallet_service:
room_service:
addr: "10.2.1.16:13001"
request_timeout: "3s"
robot_service:
addr: "10.2.1.16:13011"
request_timeout: "3s"
activity_service:
addr: "10.2.1.16:13006"
request_timeout: "3s"

View File

@ -46,6 +46,9 @@ wallet_service:
room_service:
addr: "127.0.0.1:13001"
request_timeout: "3s"
robot_service:
addr: "127.0.0.1:13011"
request_timeout: "3s"
activity_service:
addr: "127.0.0.1:13006"
request_timeout: "3s"

View File

@ -38,6 +38,7 @@ type Config struct {
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
RobotService RobotServiceConfig `yaml:"robot_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
StatisticsService StatisticsServiceConfig `yaml:"statistics_service"`
@ -64,6 +65,11 @@ type RoomServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type RobotServiceConfig struct {
Addr string `yaml:"addr"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type ActivityServiceConfig struct {
Addr string `yaml:"addr"`
RequestTimeout time.Duration `yaml:"request_timeout"`
@ -168,6 +174,10 @@ func Default() Config {
Addr: "127.0.0.1:13001",
RequestTimeout: 3 * time.Second,
},
RobotService: RobotServiceConfig{
Addr: "127.0.0.1:13011",
RequestTimeout: 3 * time.Second,
},
ActivityService: ActivityServiceConfig{
Addr: "127.0.0.1:13006",
RequestTimeout: 3 * time.Second,
@ -281,6 +291,13 @@ func (cfg *Config) Normalize() {
if cfg.RoomService.RequestTimeout <= 0 {
cfg.RoomService.RequestTimeout = 3 * time.Second
}
cfg.RobotService.Addr = strings.TrimSpace(cfg.RobotService.Addr)
if cfg.RobotService.Addr == "" {
cfg.RobotService.Addr = "127.0.0.1:13011"
}
if cfg.RobotService.RequestTimeout <= 0 {
cfg.RobotService.RequestTimeout = 3 * time.Second
}
cfg.ActivityService.Addr = strings.TrimSpace(cfg.ActivityService.Addr)
if cfg.ActivityService.RequestTimeout <= 0 {
cfg.ActivityService.RequestTimeout = 3 * time.Second
@ -394,6 +411,12 @@ func (cfg Config) Validate() error {
if cfg.RoomService.RequestTimeout <= 0 {
return errors.New("room_service.request_timeout must be greater than 0")
}
if strings.TrimSpace(cfg.RobotService.Addr) == "" {
return errors.New("robot_service.addr is required")
}
if cfg.RobotService.RequestTimeout <= 0 {
return errors.New("robot_service.request_timeout must be greater than 0")
}
if strings.TrimSpace(cfg.ActivityService.Addr) == "" {
return errors.New("activity_service.addr is required")
}

View File

@ -0,0 +1,83 @@
package robotclient
import (
"context"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
robotv1 "hyapp.local/api/proto/robot/v1"
"google.golang.org/grpc"
)
type Client interface {
ListRoomRobots(ctx context.Context, req ListRoomRobotsRequest) (ListRoomRobotsResult, error)
}
type Robot struct {
UserID int64
Status string
RoomScene string
CreatedAtMS int64
UpdatedAtMS int64
LastUsedAtMS int64
UsedCount int64
}
type ListRoomRobotsRequest struct {
RoomScene string
Status string
PageSize int32
Cursor string
}
type ListRoomRobotsResult struct {
Robots []Robot
NextCursor string
ServerTimeMS int64
}
type GRPCClient struct {
client robotv1.RoomRobotServiceClient
}
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{client: robotv1.NewRoomRobotServiceClient(conn)}
}
func (c *GRPCClient) ListRoomRobots(ctx context.Context, req ListRoomRobotsRequest) (ListRoomRobotsResult, error) {
if c == nil || c.client == nil {
return ListRoomRobotsResult{}, fmt.Errorf("robot service client is not configured")
}
resp, err := c.client.ListRoomRobots(ctx, &robotv1.ListRoomRobotsRequest{
Meta: &robotv1.RequestMeta{
RequestId: fmt.Sprintf("admin-room-robots-%d", time.Now().UnixMilli()),
Caller: "admin-server",
AppCode: appctx.FromContext(ctx),
SentAtMs: time.Now().UnixMilli(),
ActorUserId: 0,
},
RoomScene: strings.TrimSpace(req.RoomScene),
Status: strings.TrimSpace(req.Status),
PageSize: req.PageSize,
Cursor: strings.TrimSpace(req.Cursor),
})
if err != nil {
return ListRoomRobotsResult{}, err
}
items := make([]Robot, 0, len(resp.GetRobots()))
for _, item := range resp.GetRobots() {
items = append(items, Robot{
UserID: item.GetUserId(),
Status: item.GetStatus(),
RoomScene: item.GetRoomScene(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
LastUsedAtMS: item.GetLastUsedAtMs(),
UsedCount: item.GetUsedCount(),
})
}
return ListRoomRobotsResult{Robots: items, NextCursor: resp.GetNextCursor(), ServerTimeMS: resp.GetServerTimeMs()}, nil
}

View File

@ -24,6 +24,10 @@ type Client interface {
ListRoomPins(ctx context.Context, req ListRoomPinsRequest) (ListRoomPinsResult, error)
CreateRoomPin(ctx context.Context, req CreateRoomPinRequest) (RoomPin, error)
CancelRoomPin(ctx context.Context, req CancelRoomPinRequest) (RoomPin, error)
ListRobotRooms(ctx context.Context, req ListRobotRoomsRequest) (ListRobotRoomsResult, error)
CreateRobotRoom(ctx context.Context, req CreateRobotRoomRequest) (RobotRoom, error)
SetRobotRoomStatus(ctx context.Context, req SetRobotRoomStatusRequest) (RobotRoom, error)
FilterAvailableRoomRobots(ctx context.Context, userIDs []int64) (FilterAvailableRoomRobotsResult, error)
CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error)
ReopenRoom(ctx context.Context, req ReopenRoomRequest) (*CloseRoomResult, error)
}
@ -214,6 +218,66 @@ type CancelRoomPinRequest struct {
AdminID uint64
}
type RobotRoomGiftRule struct {
GiftIDs []string
LuckyGiftIDs []string
NormalGiftIntervalMS int64
LuckyComboMin int64
LuckyComboMax int64
LuckyPauseMinMS int64
LuckyPauseMaxMS int64
}
type RobotRoom struct {
AppCode string
RoomID string
RoomShortID string
Title string
CoverURL string
VisibleRegionID int64
Status string
OwnerRobotUserID int64
RobotUserIDs []int64
GiftRule RobotRoomGiftRule
CreatedByAdminID uint64
CreatedAtMS int64
UpdatedAtMS int64
}
type ListRobotRoomsRequest struct {
Page int
PageSize int
Status string
}
type ListRobotRoomsResult struct {
Rooms []RobotRoom
Total int64
}
type CreateRobotRoomRequest struct {
OwnerRobotUserID int64
CandidateRobotUserIDs []int64
MinRobotCount int32
MaxRobotCount int32
RoomName string
RoomAvatar string
VisibleRegionID int64
GiftRule RobotRoomGiftRule
AdminID uint64
}
type SetRobotRoomStatusRequest struct {
RoomID string
Status string
AdminID uint64
}
type FilterAvailableRoomRobotsResult struct {
AvailableUserIDs []int64
OccupiedUserIDs []int64
}
type CloseRoomRequest struct {
RequestID string
RoomID string
@ -400,6 +464,68 @@ func (c *GRPCClient) CancelRoomPin(ctx context.Context, req CancelRoomPinRequest
return roomPinFromProto(resp.GetPin()), nil
}
func (c *GRPCClient) ListRobotRooms(ctx context.Context, req ListRobotRoomsRequest) (ListRobotRoomsResult, error) {
resp, err := c.queryClient.AdminListRobotRooms(ctx, &roomv1.AdminListRobotRoomsRequest{
Meta: requestMeta(ctx, "", 0, "admin-list-robot-rooms"),
Page: int32(req.Page),
PageSize: int32(req.PageSize),
Status: strings.TrimSpace(req.Status),
})
if err != nil {
return ListRobotRoomsResult{}, err
}
items := make([]RobotRoom, 0, len(resp.GetRooms()))
for _, item := range resp.GetRooms() {
items = append(items, robotRoomFromProto(item))
}
return ListRobotRoomsResult{Rooms: items, Total: resp.GetTotal()}, nil
}
func (c *GRPCClient) CreateRobotRoom(ctx context.Context, req CreateRobotRoomRequest) (RobotRoom, error) {
resp, err := c.client.AdminCreateRobotRoom(ctx, &roomv1.AdminCreateRobotRoomRequest{
Meta: requestMeta(ctx, "", int64(req.AdminID), "admin-create-robot-room"),
OwnerRobotUserId: req.OwnerRobotUserID,
CandidateRobotUserIds: append([]int64(nil), req.CandidateRobotUserIDs...),
MinRobotCount: req.MinRobotCount,
MaxRobotCount: req.MaxRobotCount,
RoomName: strings.TrimSpace(req.RoomName),
RoomAvatar: strings.TrimSpace(req.RoomAvatar),
VisibleRegionId: req.VisibleRegionID,
GiftRule: robotRoomGiftRuleToProto(req.GiftRule),
AdminId: req.AdminID,
})
if err != nil {
return RobotRoom{}, err
}
return robotRoomFromProto(resp.GetRoom()), nil
}
func (c *GRPCClient) SetRobotRoomStatus(ctx context.Context, req SetRobotRoomStatusRequest) (RobotRoom, error) {
resp, err := c.client.AdminSetRobotRoomStatus(ctx, &roomv1.AdminSetRobotRoomStatusRequest{
Meta: requestMeta(ctx, strings.TrimSpace(req.RoomID), int64(req.AdminID), "admin-set-robot-room-status"),
Status: strings.TrimSpace(req.Status),
AdminId: req.AdminID,
})
if err != nil {
return RobotRoom{}, err
}
return robotRoomFromProto(resp.GetRoom()), nil
}
func (c *GRPCClient) FilterAvailableRoomRobots(ctx context.Context, userIDs []int64) (FilterAvailableRoomRobotsResult, error) {
resp, err := c.queryClient.AdminFilterAvailableRoomRobots(ctx, &roomv1.AdminFilterAvailableRoomRobotsRequest{
Meta: requestMeta(ctx, "", 0, "admin-filter-available-room-robots"),
UserIds: append([]int64(nil), userIDs...),
})
if err != nil {
return FilterAvailableRoomRobotsResult{}, err
}
return FilterAvailableRoomRobotsResult{
AvailableUserIDs: append([]int64(nil), resp.GetAvailableUserIds()...),
OccupiedUserIDs: append([]int64(nil), resp.GetOccupiedUserIds()...),
}, nil
}
func (c *GRPCClient) CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error) {
return c.setRoomLifecycle(ctx, req.RequestID, req.RoomID, req.ActorUserID, firstNonEmpty(req.Reason, "admin_closed"))
}
@ -560,6 +686,54 @@ func roomPinFromProto(input *roomv1.AdminRoomPin) RoomPin {
}
}
func robotRoomFromProto(input *roomv1.AdminRobotRoom) RobotRoom {
if input == nil {
return RobotRoom{}
}
return RobotRoom{
AppCode: input.GetAppCode(),
RoomID: input.GetRoomId(),
RoomShortID: input.GetRoomShortId(),
Title: input.GetTitle(),
CoverURL: input.GetCoverUrl(),
VisibleRegionID: input.GetVisibleRegionId(),
Status: input.GetStatus(),
OwnerRobotUserID: input.GetOwnerRobotUserId(),
RobotUserIDs: append([]int64(nil), input.GetRobotUserIds()...),
GiftRule: robotRoomGiftRuleFromProto(input.GetGiftRule()),
CreatedByAdminID: input.GetCreatedByAdminId(),
CreatedAtMS: input.GetCreatedAtMs(),
UpdatedAtMS: input.GetUpdatedAtMs(),
}
}
func robotRoomGiftRuleFromProto(input *roomv1.AdminRobotRoomGiftRule) RobotRoomGiftRule {
if input == nil {
return RobotRoomGiftRule{}
}
return RobotRoomGiftRule{
GiftIDs: append([]string(nil), input.GetGiftIds()...),
LuckyGiftIDs: append([]string(nil), input.GetLuckyGiftIds()...),
NormalGiftIntervalMS: input.GetNormalGiftIntervalMs(),
LuckyComboMin: input.GetLuckyComboMin(),
LuckyComboMax: input.GetLuckyComboMax(),
LuckyPauseMinMS: input.GetLuckyPauseMinMs(),
LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(),
}
}
func robotRoomGiftRuleToProto(input RobotRoomGiftRule) *roomv1.AdminRobotRoomGiftRule {
return &roomv1.AdminRobotRoomGiftRule{
GiftIds: append([]string(nil), input.GiftIDs...),
LuckyGiftIds: append([]string(nil), input.LuckyGiftIDs...),
NormalGiftIntervalMs: input.NormalGiftIntervalMS,
LuckyComboMin: input.LuckyComboMin,
LuckyComboMax: input.LuckyComboMax,
LuckyPauseMinMs: input.LuckyPauseMinMS,
LuckyPauseMaxMs: input.LuckyPauseMaxMS,
}
}
func roomRocketConfigToProto(input RoomRocketConfig) *roomv1.AdminRoomRocketConfig {
return &roomv1.AdminRoomRocketConfig{
AppCode: input.AppCode,

View File

@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
@ -19,8 +20,8 @@ type Handler struct {
audit shared.OperationLogger
}
func New(userDB *sql.DB, roomClient roomclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userDB, roomClient), audit: audit}
func New(userDB *sql.DB, roomClient roomclient.Client, robotClient robotclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userDB, roomClient, robotClient), audit: audit}
}
func (h *Handler) ListRooms(c *gin.Context) {
@ -78,6 +79,48 @@ func (h *Handler) CancelRoomPin(c *gin.Context) {
response.OK(c, pin)
}
func (h *Handler) ListRobotRooms(c *gin.Context) {
query := parseRobotRoomListQuery(c)
items, total, err := h.service.ListRobotRooms(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取机器人房间列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func (h *Handler) ListAvailableRoomRobots(c *gin.Context) {
items, err := h.service.ListAvailableRoomRobots(c.Request.Context())
if err != nil {
response.ServerError(c, "获取可用机器人失败")
return
}
response.OK(c, items)
}
func (h *Handler) CreateRobotRoom(c *gin.Context) {
var req createRobotRoomRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "机器人房间参数不正确")
return
}
room, err := h.service.CreateRobotRoom(c.Request.Context(), req, shared.ActorFromContext(c))
if err != nil {
writeMutationError(c, err, "创建机器人房间失败")
return
}
writeRoomAuditLog(c, h.audit, "create-robot-room", "room_robot_rooms", room.RoomID, "success", "create robot room")
response.Created(c, room)
}
func (h *Handler) StartRobotRoom(c *gin.Context) {
h.setRobotRoomStatus(c, "active", "start-robot-room", "启动机器人房间失败")
}
func (h *Handler) StopRobotRoom(c *gin.Context) {
h.setRobotRoomStatus(c, "stopped", "stop-robot-room", "停止机器人房间失败")
}
func (h *Handler) GetRoomConfig(c *gin.Context) {
config, err := h.service.GetRoomConfig(c.Request.Context())
if err != nil {
@ -134,6 +177,20 @@ func (h *Handler) DeleteRoom(c *gin.Context) {
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) setRobotRoomStatus(c *gin.Context, status string, action string, fallback string) {
roomID, ok := parseRoomID(c)
if !ok {
return
}
room, err := h.service.SetRobotRoomStatus(c.Request.Context(), roomID, status, shared.ActorFromContext(c))
if err != nil {
writeMutationError(c, err, fallback)
return
}
writeRoomAuditLog(c, h.audit, action, "room_robot_rooms", roomID, "success", action)
response.OK(c, room)
}
func parseListQuery(c *gin.Context) (listQuery, bool) {
options := shared.ListOptions(c)
query := listQuery{
@ -168,6 +225,15 @@ func parseRoomPinListQuery(c *gin.Context) (roomPinListQuery, bool) {
}), true
}
func parseRobotRoomListQuery(c *gin.Context) robotRoomListQuery {
options := shared.ListOptions(c)
return robotRoomListQuery{
Page: options.Page,
PageSize: options.PageSize,
Status: options.Status,
}
}
func firstQueryValue(c *gin.Context, names ...string) string {
for _, name := range names {
if value := strings.TrimSpace(c.Query(name)); value != "" {

View File

@ -19,6 +19,29 @@ type roomPinListQuery struct {
Status string
}
type robotRoomListQuery struct {
Page int
PageSize int
Status string
}
type createRobotRoomRequest struct {
OwnerRobotUserID int64 `json:"ownerRobotUserId"`
CandidateRobotUserIDs []int64 `json:"candidateRobotUserIds"`
MinRobotCount int32 `json:"minRobotCount"`
MaxRobotCount int32 `json:"maxRobotCount"`
RoomName string `json:"roomName"`
RoomAvatar string `json:"roomAvatar"`
VisibleRegionID int64 `json:"visibleRegionId"`
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
}
type createRoomPinRequest struct {
DurationDays int64 `json:"durationDays"`
ExpiresAtMS int64 `json:"expiresAtMs"`

View File

@ -0,0 +1,300 @@
package roomadmin
import (
"context"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/modules/shared"
)
const defaultRoomRobotScene = "voice"
type RobotRoom struct {
AppCode string `json:"appCode"`
RoomID string `json:"roomId"`
RoomShortID string `json:"roomShortId"`
Title string `json:"title"`
CoverURL string `json:"coverUrl"`
VisibleRegionID int64 `json:"visibleRegionId"`
RegionName string `json:"regionName"`
Status string `json:"status"`
OwnerRobotUserID string `json:"ownerRobotUserId"`
Owner RoomOwner `json:"owner"`
RobotUserIDs []string `json:"robotUserIds"`
Robots []RoomOwner `json:"robots"`
GiftRule RobotGiftRule `json:"giftRule"`
CreatedByAdminID uint64 `json:"createdByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type RobotGiftRule struct {
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
}
type AvailableRoomRobot struct {
UserID string `json:"userId"`
Status string `json:"status"`
RoomScene string `json:"roomScene"`
LastUsedAtMS int64 `json:"lastUsedAtMs"`
UsedCount int64 `json:"usedCount"`
User RoomOwner `json:"user"`
}
func (s *Service) ListRobotRooms(ctx context.Context, query robotRoomListQuery) ([]RobotRoom, int64, error) {
if s.roomClient == nil {
return nil, 0, fmt.Errorf("room service client is not configured")
}
if query.Page <= 0 {
query.Page = 1
}
if query.PageSize <= 0 || query.PageSize > 100 {
query.PageSize = 50
}
result, err := s.roomClient.ListRobotRooms(ctx, roomclient.ListRobotRoomsRequest{
Page: query.Page,
PageSize: query.PageSize,
Status: normalizeRobotRoomListStatus(query.Status),
})
if err != nil {
return nil, 0, err
}
items := robotRoomsFromClient(result.Rooms)
if err := s.fillRobotRoomDetails(ctx, items); err != nil {
return nil, 0, err
}
return items, result.Total, nil
}
func normalizeRobotRoomListStatus(status string) string {
switch strings.TrimSpace(status) {
case "active", "stopped":
return strings.TrimSpace(status)
default:
return ""
}
}
func (s *Service) ListAvailableRoomRobots(ctx context.Context) ([]AvailableRoomRobot, error) {
if s.robotClient == nil || s.roomClient == nil {
return nil, fmt.Errorf("robot or room service client is not configured")
}
result, err := s.robotClient.ListRoomRobots(ctx, robotclient.ListRoomRobotsRequest{
RoomScene: defaultRoomRobotScene,
Status: "active",
PageSize: 200,
})
if err != nil {
return nil, err
}
userIDs := make([]int64, 0, len(result.Robots))
for _, item := range result.Robots {
if item.UserID > 0 {
userIDs = append(userIDs, item.UserID)
}
}
available, err := s.roomClient.FilterAvailableRoomRobots(ctx, userIDs)
if err != nil {
return nil, err
}
allowed := make(map[int64]bool, len(available.AvailableUserIDs))
for _, userID := range available.AvailableUserIDs {
allowed[userID] = true
}
owners, err := s.queryRoomOwners(ctx, available.AvailableUserIDs)
if err != nil {
return nil, err
}
items := make([]AvailableRoomRobot, 0, len(available.AvailableUserIDs))
for _, robot := range result.Robots {
if !allowed[robot.UserID] {
continue
}
items = append(items, AvailableRoomRobot{
UserID: strconv.FormatInt(robot.UserID, 10),
Status: robot.Status,
RoomScene: robot.RoomScene,
LastUsedAtMS: robot.LastUsedAtMS,
UsedCount: robot.UsedCount,
User: owners[robot.UserID],
})
}
return items, nil
}
func (s *Service) CreateRobotRoom(ctx context.Context, req createRobotRoomRequest, actor shared.Actor) (RobotRoom, error) {
if s.roomClient == nil {
return RobotRoom{}, fmt.Errorf("room service client is not configured")
}
if _, err := normalizeCreateRobotRoomRequest(req); err != nil {
return RobotRoom{}, err
}
created, err := s.roomClient.CreateRobotRoom(ctx, roomclient.CreateRobotRoomRequest{
OwnerRobotUserID: req.OwnerRobotUserID,
CandidateRobotUserIDs: req.CandidateRobotUserIDs,
MinRobotCount: req.MinRobotCount,
MaxRobotCount: req.MaxRobotCount,
RoomName: req.RoomName,
RoomAvatar: req.RoomAvatar,
VisibleRegionID: req.VisibleRegionID,
GiftRule: roomclient.RobotRoomGiftRule{
GiftIDs: req.GiftIDs,
LuckyGiftIDs: req.LuckyGiftIDs,
NormalGiftIntervalMS: req.NormalGiftIntervalMS,
LuckyComboMin: req.LuckyComboMin,
LuckyComboMax: req.LuckyComboMax,
LuckyPauseMinMS: req.LuckyPauseMinMS,
LuckyPauseMaxMS: req.LuckyPauseMaxMS,
},
AdminID: uint64(actor.UserID),
})
if err != nil {
return RobotRoom{}, mapRoomClientError(err)
}
items := []RobotRoom{robotRoomFromClient(created)}
if err := s.fillRobotRoomDetails(ctx, items); err != nil {
return RobotRoom{}, err
}
return items[0], nil
}
func (s *Service) SetRobotRoomStatus(ctx context.Context, roomID string, status string, actor shared.Actor) (RobotRoom, error) {
if s.roomClient == nil {
return RobotRoom{}, fmt.Errorf("room service client is not configured")
}
roomID = strings.TrimSpace(roomID)
status = strings.TrimSpace(status)
if roomID == "" || (status != "active" && status != "stopped") {
return RobotRoom{}, fmt.Errorf("%w: 机器人房间状态参数不正确", ErrInvalidArgument)
}
item, err := s.roomClient.SetRobotRoomStatus(ctx, roomclient.SetRobotRoomStatusRequest{
RoomID: roomID,
Status: status,
AdminID: uint64(actor.UserID),
})
if err != nil {
return RobotRoom{}, mapRoomClientError(err)
}
items := []RobotRoom{robotRoomFromClient(item)}
if err := s.fillRobotRoomDetails(ctx, items); err != nil {
return RobotRoom{}, err
}
return items[0], nil
}
func normalizeCreateRobotRoomRequest(req createRobotRoomRequest) (createRobotRoomRequest, error) {
if req.OwnerRobotUserID <= 0 {
return req, fmt.Errorf("%w: 请选择房主机器人", ErrInvalidArgument)
}
if req.MinRobotCount <= 0 || req.MaxRobotCount < req.MinRobotCount {
return req, fmt.Errorf("%w: 机器人数量范围不正确", ErrInvalidArgument)
}
if len(req.CandidateRobotUserIDs) == 0 {
return req, fmt.Errorf("%w: 请选择候选机器人", ErrInvalidArgument)
}
if len(req.GiftIDs) == 0 {
return req, fmt.Errorf("%w: 请选择普通礼物合集", ErrInvalidArgument)
}
if len(req.LuckyGiftIDs) == 0 {
return req, fmt.Errorf("%w: 请选择幸运礼物合集", ErrInvalidArgument)
}
if req.NormalGiftIntervalMS <= 0 {
return req, fmt.Errorf("%w: 普通礼物频次不正确", ErrInvalidArgument)
}
if req.LuckyComboMin <= 0 || req.LuckyComboMax < req.LuckyComboMin {
return req, fmt.Errorf("%w: 幸运礼物连击范围不正确", ErrInvalidArgument)
}
if req.LuckyPauseMinMS <= 0 || req.LuckyPauseMaxMS < req.LuckyPauseMinMS {
return req, fmt.Errorf("%w: 幸运礼物间隔范围不正确", ErrInvalidArgument)
}
return req, nil
}
func robotRoomsFromClient(items []roomclient.RobotRoom) []RobotRoom {
out := make([]RobotRoom, 0, len(items))
for _, item := range items {
out = append(out, robotRoomFromClient(item))
}
return out
}
func robotRoomFromClient(item roomclient.RobotRoom) RobotRoom {
robotUserIDs := make([]string, 0, len(item.RobotUserIDs))
for _, userID := range item.RobotUserIDs {
robotUserIDs = append(robotUserIDs, strconv.FormatInt(userID, 10))
}
return RobotRoom{
AppCode: item.AppCode,
RoomID: item.RoomID,
RoomShortID: item.RoomShortID,
Title: item.Title,
CoverURL: item.CoverURL,
VisibleRegionID: item.VisibleRegionID,
Status: item.Status,
OwnerRobotUserID: strconv.FormatInt(item.OwnerRobotUserID, 10),
RobotUserIDs: robotUserIDs,
GiftRule: RobotGiftRule{
GiftIDs: item.GiftRule.GiftIDs,
LuckyGiftIDs: item.GiftRule.LuckyGiftIDs,
NormalGiftIntervalMS: item.GiftRule.NormalGiftIntervalMS,
LuckyComboMin: item.GiftRule.LuckyComboMin,
LuckyComboMax: item.GiftRule.LuckyComboMax,
LuckyPauseMinMS: item.GiftRule.LuckyPauseMinMS,
LuckyPauseMaxMS: item.GiftRule.LuckyPauseMaxMS,
},
CreatedByAdminID: item.CreatedByAdminID,
CreatedAtMS: item.CreatedAtMS,
UpdatedAtMS: item.UpdatedAtMS,
}
}
func (s *Service) fillRobotRoomDetails(ctx context.Context, items []RobotRoom) error {
if s.userDB == nil || len(items) == 0 {
return nil
}
userIDs := make([]int64, 0)
regionIDs := make([]int64, 0)
for _, item := range items {
if ownerID, err := strconv.ParseInt(item.OwnerRobotUserID, 10, 64); err == nil && ownerID > 0 {
userIDs = append(userIDs, ownerID)
}
for _, rawID := range item.RobotUserIDs {
if userID, err := strconv.ParseInt(rawID, 10, 64); err == nil && userID > 0 {
userIDs = append(userIDs, userID)
}
}
if item.VisibleRegionID > 0 {
regionIDs = append(regionIDs, item.VisibleRegionID)
}
}
owners, err := s.queryRoomOwners(ctx, uniqueInt64s(userIDs))
if err != nil {
return err
}
regions, err := s.queryRegionNames(ctx, uniqueInt64s(regionIDs))
if err != nil {
return err
}
for index := range items {
if ownerID, err := strconv.ParseInt(items[index].OwnerRobotUserID, 10, 64); err == nil {
items[index].Owner = owners[ownerID]
}
for _, rawID := range items[index].RobotUserIDs {
if userID, err := strconv.ParseInt(rawID, 10, 64); err == nil {
items[index].Robots = append(items[index].Robots, owners[userID])
}
}
items[index].RegionName = regions[items[index].VisibleRegionID]
}
return nil
}

View File

@ -15,6 +15,11 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/rooms/pins", middleware.RequirePermission("room-pin:view"), h.ListRoomPins)
protected.POST("/admin/rooms/pins", middleware.RequirePermission("room-pin:create"), h.CreateRoomPin)
protected.DELETE("/admin/rooms/pins/:pin_id", middleware.RequirePermission("room-pin:cancel"), h.CancelRoomPin)
protected.GET("/admin/rooms/robot-rooms", middleware.RequirePermission("room-robot:view"), h.ListRobotRooms)
protected.GET("/admin/rooms/robot-rooms/available-robots", middleware.RequirePermission("room-robot:view"), h.ListAvailableRoomRobots)
protected.POST("/admin/rooms/robot-rooms", middleware.RequirePermission("room-robot:create"), h.CreateRobotRoom)
protected.POST("/admin/rooms/robot-rooms/:room_id/start", middleware.RequirePermission("room-robot:update"), h.StartRobotRoom)
protected.POST("/admin/rooms/robot-rooms/:room_id/stop", middleware.RequirePermission("room-robot:update"), h.StopRobotRoom)
protected.GET("/admin/rooms/config", middleware.RequirePermission("room-config:view"), h.GetRoomConfig)
protected.PUT("/admin/rooms/config", middleware.RequirePermission("room-config:update"), h.UpdateRoomConfig)
protected.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), h.UpdateRoom)

View File

@ -12,6 +12,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/robotclient"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/modules/shared"
)
@ -24,6 +25,7 @@ var (
type Service struct {
userDB *sql.DB
roomClient roomclient.Client
robotClient robotclient.Client
}
type RoomOwner struct {
@ -58,8 +60,8 @@ type Room struct {
VisibleRegionID int64 `json:"visibleRegionId"`
}
func NewService(userDB *sql.DB, roomClient roomclient.Client) *Service {
return &Service{userDB: userDB, roomClient: roomClient}
func NewService(userDB *sql.DB, roomClient roomclient.Client, robotClient robotclient.Client) *Service {
return &Service{userDB: userDB, roomClient: roomClient, robotClient: robotClient}
}
func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64, error) {

View File

@ -36,6 +36,9 @@ var defaultPermissions = []model.Permission{
{Name: "房间置顶取消", Code: "room-pin:cancel", Kind: "button"},
{Name: "房间配置查看", Code: "room-config:view", Kind: "menu"},
{Name: "房间配置更新", Code: "room-config:update", Kind: "button"},
{Name: "机器人房间查看", Code: "room-robot:view", Kind: "menu"},
{Name: "机器人房间创建", Code: "room-robot:create", Kind: "button"},
{Name: "机器人房间更新", Code: "room-robot:update", Kind: "button"},
{Name: "APP 配置查看", Code: "app-config:view", Kind: "menu"},
{Name: "APP 配置更新", Code: "app-config:update", Kind: "button"},
{Name: "版本管理查看", Code: "app-version:view", Kind: "menu"},
@ -255,6 +258,7 @@ func (s *Store) seedMenus() error {
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
{ParentID: &roomsID, Title: "房间置顶", Code: "room-pins", Path: "/rooms/pins", Icon: "push_pin", PermissionCode: "room-pin:view", Sort: 66, Visible: true},
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true},
{ParentID: &roomsID, Title: "机器人房间", Code: "room-robots", Path: "/rooms/robots", Icon: "team", PermissionCode: "room-robot:view", Sort: 68, Visible: true},
{ParentID: &appConfigID, Title: "H5配置", Code: "app-config-h5", Path: "/app-config/h5", Icon: "settings", PermissionCode: "app-config:view", Sort: 66, Visible: true},
{ParentID: &appConfigID, Title: "BANNER配置", Code: "app-config-banners", Path: "/app-config/banners", Icon: "image", PermissionCode: "app-config:view", Sort: 67, Visible: true},
{ParentID: &appConfigID, Title: "开屏配置", Code: "app-config-splash-screens", Path: "/app-config/splash-screens", Icon: "image", PermissionCode: "app-config:view", Sort: 68, Visible: true},
@ -511,7 +515,7 @@ func defaultRolePermissionCodes(code string) []string {
"level-config:view", "level-config:update",
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
"region-block:view", "region-block:update",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update", "room-robot:view", "room-robot:create", "room-robot:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"resource:view", "resource:create", "resource:update",
@ -542,7 +546,7 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -554,6 +558,7 @@ func defaultRolePermissionCodes(code string) []string {
"room:view",
"room-pin:view",
"room-config:view",
"room-robot:view",
"app-config:view",
"app-version:view",
"resource:view",
@ -605,7 +610,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
case "ops-admin":
return []string{
"app-user:update", "app-user:status", "app-user:password",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update", "room-robot:view", "room-robot:create", "room-robot:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"level-config:view", "level-config:update",
@ -635,7 +640,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"weekly-star:view", "weekly-star:create", "weekly-star:update", "weekly-star:settle",
}
case "auditor", "readonly":
return []string{"level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view"}
return []string{"level-config:view", "pretty-id:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "room-robot:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-seller-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-third-party:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-rocket:view", "red-packet:view", "cp-config:view", "vip-config:view", "weekly-star:view"}
default:
return nil
}

View File

@ -0,0 +1,41 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
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-robot:view', 'menu', '', @now_ms, @now_ms),
('机器人房间创建', 'room-robot:create', 'button', '', @now_ms, @now_ms),
('机器人房间更新', 'room-robot: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)
SELECT parent.id, '机器人房间', 'room-robots', '/rooms/robots', 'team', 'room-robot:view', 68, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'rooms'
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 IN ('platform-admin', 'ops-admin')
AND admin_permission.code IN ('room-robot:view', 'room-robot:create', 'room-robot: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-robot:view';

View File

@ -323,6 +323,9 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return broadcastdomain.ConsumeRoomEventResult{}, err
}
if gift.GetIsRobotGift() {
return result, nil
}
if gift.GetVisibleRegionId() <= 0 || !s.shouldBroadcastGift(&gift) {
// visible_region_id 来自房间/主播可见区域,不能用客户端当前 IP 或本地时区推断。
return result, nil

View File

@ -257,6 +257,9 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return 0, err
}
if gift.GetIsRobotGift() {
return 0, nil
}
if gift.GetSenderUserId() <= 0 || gift.GetTargetUserId() <= 0 || gift.GetGiftValue() <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "room gift event is incomplete")
}

View File

@ -157,6 +157,9 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return domain.EventResult{}, err
}
if gift.GetIsRobotGift() {
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil
}
if strings.TrimSpace(envelope.GetRoomId()) == "" || gift.GetCoinSpent() <= 0 {
// 缺房间或 0 金币不会形成有效房间贡献;这类事件本身没有可补偿数据,跳过比失败重放更安全。
return domain.EventResult{EventID: envelope.GetEventId(), Status: domain.EventStatusSkipped}, nil

View File

@ -207,6 +207,9 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return 0, err
}
if gift.GetIsRobotGift() {
return 0, nil
}
if gift.GetSenderUserId() <= 0 || gift.GetGiftId() == "" || gift.GetGiftCount() <= 0 {
return 0, nil
}

View File

@ -163,6 +163,9 @@ func (s *Service) HandleRoomEvent(ctx context.Context, envelope *roomeventsv1.Ev
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return 0, err
}
if gift.GetIsRobotGift() {
return 0, nil
}
if gift.GetSenderUserId() <= 0 || gift.GetGiftId() == "" || gift.GetCoinSpent() <= 0 {
return 0, nil
}

View File

@ -225,6 +225,31 @@ CREATE TABLE IF NOT EXISTS room_rocket_configs (
KEY idx_room_rocket_enabled (app_code, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='语音房火箭后台配置表';
CREATE TABLE IF NOT EXISTS room_robot_rooms (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
room_id VARCHAR(64) NOT NULL COMMENT '机器人房间 ID',
room_short_id VARCHAR(32) NOT NULL DEFAULT '' COMMENT '房间短 ID',
title VARCHAR(128) NOT NULL DEFAULT '' COMMENT '房间名称',
cover_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '房间头像',
visible_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '可见区域 ID',
status VARCHAR(32) NOT NULL COMMENT '运行状态active/stopped',
owner_robot_user_id BIGINT NOT NULL COMMENT '房主机器人用户 ID',
robot_user_ids_json JSON NOT NULL COMMENT '本房间占用的机器人用户 ID 集合',
gift_ids_json JSON NOT NULL COMMENT '普通礼物 ID 集合',
lucky_gift_ids_json JSON NOT NULL COMMENT '幸运礼物 ID 集合',
normal_gift_interval_ms BIGINT NOT NULL COMMENT '普通礼物间隔毫秒',
lucky_combo_min BIGINT NOT NULL COMMENT '幸运礼物最小连击次数',
lucky_combo_max BIGINT NOT NULL COMMENT '幸运礼物最大连击次数',
lucky_pause_min_ms BIGINT NOT NULL COMMENT '幸运礼物连击后最小暂停毫秒',
lucky_pause_max_ms BIGINT NOT NULL COMMENT '幸运礼物连击后最大暂停毫秒',
created_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, room_id),
KEY idx_room_robot_rooms_status (app_code, status, updated_at_ms, room_id),
KEY idx_room_robot_rooms_owner (app_code, owner_robot_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='机器人房间后台配置表';
INSERT IGNORE INTO room_seat_configs (
app_code,
allowed_seat_counts,

View File

@ -391,6 +391,9 @@ func (a *App) Run() error {
})
})
}
a.workerWG.Go(func() {
a.service.RunRobotRoomRuntimeManager(a.workerCtx, 10*time.Second)
})
err := a.grpcServer.Serve(a.listener)
a.health.MarkGRPCStopped()

View File

@ -16,6 +16,8 @@ type WalletClient interface {
DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error)
// BatchDebitGift 在 wallet-service 单事务内结算多目标送礼;任一目标失败时整批失败。
BatchDebitGift(ctx context.Context, req *walletv1.BatchDebitGiftRequest) (*walletv1.BatchDebitGiftResponse, error)
// DebitRobotGift 扣机器人专用金币;只用于机器人房间内部展示礼物。
DebitRobotGift(ctx context.Context, req *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error)
// GrantResourceGroup 发放火箭奖励资源组;命令 ID 必须由 room-service 按火箭结算事实生成。
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
}

View File

@ -34,6 +34,11 @@ func (c *grpcWalletClient) BatchDebitGift(ctx context.Context, req *walletv1.Bat
return c.client.BatchDebitGift(ctx, req)
}
// DebitRobotGift 直接转调 wallet-service 机器人专用金币扣费接口。
func (c *grpcWalletClient) DebitRobotGift(ctx context.Context, req *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) {
return c.client.DebitRobotGift(ctx, req)
}
// GrantResourceGroup 直接转调 wallet-service 资源组发放接口。
func (c *grpcWalletClient) GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
// 火箭奖励以 resource group 为后台配置单位wallet-service 负责展开资产和权益。

View File

@ -315,6 +315,36 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"gift_effect_types": string(effectTypesJSON),
}
return base, true, nil
case "RoomRobotLuckyGiftDrawn":
// 机器人幸运礼物只发房间展示协议,不进入 activity-service 真实抽奖和奖池链路。
var body roomeventsv1.RoomRobotLuckyGiftDrawn
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId()
base.GiftValue = body.GetEffectiveRewardCoins()
base.Attributes = map[string]string{
"event_type": "lucky_gift_drawn",
"draw_id": body.GetDrawId(),
"command_id": body.GetCommandId(),
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
"target_user_id": fmt.Sprintf("%d", body.GetTargetUserId()),
"gift_id": body.GetGiftId(),
"gift_count": fmt.Sprintf("%d", body.GetGiftCount()),
"pool_id": body.GetPoolId(),
"multiplier_ppm": fmt.Sprintf("%d", body.GetMultiplierPpm()),
"base_reward_coins": fmt.Sprintf("%d", body.GetBaseRewardCoins()),
"room_atmosphere_reward_coins": fmt.Sprintf("%d", body.GetRoomAtmosphereRewardCoins()),
"activity_subsidy_coins": fmt.Sprintf("%d", body.GetActivitySubsidyCoins()),
"effective_reward_coins": fmt.Sprintf("%d", body.GetEffectiveRewardCoins()),
"created_at_ms": fmt.Sprintf("%d", body.GetCreatedAtMs()),
"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
"is_robot": fmt.Sprintf("%t", body.GetIsRobot()),
"synthetic": fmt.Sprintf("%t", body.GetSynthetic()),
"reward_status": "granted",
}
return base, true, nil
case "RoomRocketFuelChanged":
var body roomeventsv1.RoomRocketFuelChanged
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
@ -441,6 +471,8 @@ func eventTypeForClient(eventType string) string {
return "room_user_unbanned"
case "RoomGiftSent":
return "room_gift_sent"
case "RoomRobotLuckyGiftDrawn":
return "lucky_gift_drawn"
case "RoomRocketFuelChanged":
return "room_rocket_fuel_changed"
case "RoomRocketIgnited":

View File

@ -74,6 +74,10 @@ type CreateRoom struct {
RoomDescription string `json:"room_description"`
// RoomShortID 是房间短 ID首版等于创建者当前展示短号。
RoomShortID string `json:"room_short_id"`
// RobotRoom 标记该房间由内部机器人编排器创建,机器人礼物校验依赖它 fail-close。
RobotRoom bool `json:"robot_room,omitempty"`
// RobotUserIDs 是该机器人房间允许参与虚拟上麦和机器人送礼的用户集合。
RobotUserIDs []int64 `json:"robot_user_ids,omitempty"`
}
// Type 返回命令类型。
@ -200,6 +204,17 @@ type MicUp struct {
// Type 返回命令类型。
func (MicUp) Type() string { return "mic_up" }
// RobotMicUp 定义机器人虚拟静音占麦请求。
// 它不等待 RTC 发流确认,也不会被 pending_publish 超时任务释放。
type RobotMicUp struct {
Base
TargetUserID int64 `json:"target_user_id"`
SeatNo int32 `json:"seat_no"`
MicSessionID string `json:"mic_session_id"`
}
func (RobotMicUp) Type() string { return "robot_mic_up" }
// MicDown 定义下麦请求。
type MicDown struct {
Base
@ -459,6 +474,14 @@ type SendGift struct {
RocketPendingLaunches []RocketPendingLaunch `json:"rocket_pending_launches,omitempty"`
// RocketCurrentContributions 保存当前等级真实加燃料贡献,恢复时用于继续按本级贡献锁 top1。
RocketCurrentContributions []RocketContribution `json:"rocket_current_contributions,omitempty"`
// RobotGift 表示本次礼物只服务机器人房间展示;不写真实统计、任务、周星和房间贡献榜。
RobotGift bool `json:"robot_gift,omitempty"`
// SyntheticLuckyGift 表示幸运礼物只生成展示事件,不调用真实 activity 抽奖和奖池。
SyntheticLuckyGift bool `json:"synthetic_lucky_gift,omitempty"`
// SyntheticLuckyRewardCoins 是机器人幸运礼物展示用爆奖金币值,不代表真实钱包返奖。
SyntheticLuckyRewardCoins int64 `json:"synthetic_lucky_reward_coins,omitempty"`
// SyntheticLuckyMultiplierPPM 是机器人幸运礼物展示用倍率。
SyntheticLuckyMultiplierPPM int64 `json:"synthetic_lucky_multiplier_ppm,omitempty"`
}
// Type 返回命令类型。
@ -624,6 +647,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &AdminDeleteRoom{}
case MicUp{}.Type():
cmd = &MicUp{}
case RobotMicUp{}.Type():
cmd = &RobotMicUp{}
case MicDown{}.Type():
cmd = &MicDown{}
case ConfirmMicPublishing{}.Type():

View File

@ -0,0 +1,566 @@
package service
import (
"context"
"fmt"
"log/slog"
"math/rand"
"slices"
"strings"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
)
const (
robotRoomStatusActive = "active"
robotRoomStatusStopped = "stopped"
robotRoomMode = "voice"
robotRoomLuckyPoolID = "robot_lucky_display"
)
func (s *Service) AdminListRobotRooms(ctx context.Context, req *roomv1.AdminListRobotRoomsRequest) (*roomv1.AdminListRobotRoomsResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
items, total, err := s.repository.ListRobotRooms(ctx, RobotRoomListQuery{
AppCode: appcode.FromContext(ctx),
Status: strings.TrimSpace(req.GetStatus()),
Page: int(req.GetPage()),
PageSize: int(req.GetPageSize()),
})
if err != nil {
return nil, err
}
resp := &roomv1.AdminListRobotRoomsResponse{Total: total, ServerTimeMs: s.clock.Now().UnixMilli()}
for _, item := range items {
resp.Rooms = append(resp.Rooms, robotRoomConfigToProto(item))
}
return resp, nil
}
func (s *Service) AdminFilterAvailableRoomRobots(ctx context.Context, req *roomv1.AdminFilterAvailableRoomRobotsRequest) (*roomv1.AdminFilterAvailableRoomRobotsResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
ids := normalizeRobotUserIDs(req.GetUserIds())
occupied, err := s.repository.OccupiedRobotUserIDs(ctx, ids)
if err != nil {
return nil, err
}
resp := &roomv1.AdminFilterAvailableRoomRobotsResponse{ServerTimeMs: s.clock.Now().UnixMilli()}
for _, userID := range ids {
if occupied[userID] {
resp.OccupiedUserIds = append(resp.OccupiedUserIds, userID)
continue
}
resp.AvailableUserIds = append(resp.AvailableUserIds, userID)
}
slices.Sort(resp.AvailableUserIds)
slices.Sort(resp.OccupiedUserIds)
return resp, nil
}
func (s *Service) AdminCreateRobotRoom(ctx context.Context, req *roomv1.AdminCreateRobotRoomRequest) (*roomv1.AdminCreateRobotRoomResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
now := s.clock.Now()
config, err := s.buildRobotRoomConfig(ctx, req, now)
if err != nil {
return nil, err
}
if err := s.bootstrapRobotRoom(ctx, config); err != nil {
logx.Error(ctx, "admin_robot_room_bootstrap_failed", err,
slog.String("room_id", config.RoomID),
slog.Int64("owner_robot_user_id", config.OwnerRobotUserID),
slog.Int("robot_count", len(config.RobotUserIDs)),
)
return nil, err
}
saved, err := s.repository.CreateRobotRoomConfig(ctx, CreateRobotRoomConfigInput{Config: config, NowMS: now.UnixMilli()})
if err != nil {
logx.Error(ctx, "admin_robot_room_config_save_failed", err,
slog.String("room_id", config.RoomID),
slog.Int64("owner_robot_user_id", config.OwnerRobotUserID),
slog.Int("robot_count", len(config.RobotUserIDs)),
)
return nil, err
}
s.startRobotRoomRuntime(ctx, saved)
return &roomv1.AdminCreateRobotRoomResponse{Room: robotRoomConfigToProto(saved), ServerTimeMs: now.UnixMilli()}, nil
}
func (s *Service) AdminSetRobotRoomStatus(ctx context.Context, req *roomv1.AdminSetRobotRoomStatusRequest) (*roomv1.AdminSetRobotRoomStatusResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
roomID := strings.TrimSpace(req.GetMeta().GetRoomId())
status := normalizeRobotRoomStatus(req.GetStatus())
if roomID == "" {
return nil, xerr.New(xerr.InvalidArgument, "room_id is required")
}
if status == "" {
return nil, xerr.New(xerr.InvalidArgument, "robot room status is invalid")
}
config, exists, err := s.repository.UpdateRobotRoomStatus(ctx, roomID, status, s.clock.Now().UnixMilli())
if err != nil {
return nil, err
}
if !exists {
return nil, xerr.New(xerr.NotFound, "robot room not found")
}
if status == robotRoomStatusActive {
s.startRobotRoomRuntime(ctx, config)
} else {
s.stopRobotRoomRuntime(roomID)
}
return &roomv1.AdminSetRobotRoomStatusResponse{Room: robotRoomConfigToProto(config), ServerTimeMs: s.clock.Now().UnixMilli()}, nil
}
func (s *Service) buildRobotRoomConfig(ctx context.Context, req *roomv1.AdminCreateRobotRoomRequest, now time.Time) (RobotRoomConfig, error) {
ownerID := req.GetOwnerRobotUserId()
if ownerID <= 0 {
return RobotRoomConfig{}, xerr.New(xerr.InvalidArgument, "owner_robot_user_id is required")
}
candidates := normalizeRobotUserIDs(req.GetCandidateRobotUserIds())
containsOwner := false
for _, userID := range candidates {
if userID == ownerID {
containsOwner = true
break
}
}
if !containsOwner {
candidates = append(candidates, ownerID)
}
occupied, err := s.repository.OccupiedRobotUserIDs(ctx, candidates)
if err != nil {
return RobotRoomConfig{}, err
}
if occupied[ownerID] {
return RobotRoomConfig{}, xerr.New(xerr.Conflict, "owner robot already assigned to active robot room")
}
available := make([]int64, 0, len(candidates))
for _, userID := range candidates {
if !occupied[userID] {
available = append(available, userID)
}
}
minCount, maxCount := normalizeRobotCountRange(req.GetMinRobotCount(), req.GetMaxRobotCount())
if len(available) < minCount {
return RobotRoomConfig{}, xerr.New(xerr.Conflict, "available robot count is not enough")
}
rng := rand.New(rand.NewSource(now.UnixNano()))
selectedCount := randomIntRange(rng, minCount, maxCount)
if selectedCount > len(available) {
selectedCount = len(available)
}
selected := selectRobotUsers(rng, ownerID, available, selectedCount)
rule, err := normalizeRobotRoomGiftRule(req.GetGiftRule())
if err != nil {
return RobotRoomConfig{}, err
}
roomID := fmt.Sprintf("robot_%d_%d", now.UnixMilli(), rng.Intn(900000)+100000)
if !roomid.ValidStringID(roomID) {
return RobotRoomConfig{}, xerr.New(xerr.Internal, "generated robot room id is invalid")
}
title := strings.TrimSpace(req.GetRoomName())
if title == "" {
title = fmt.Sprintf("Robot Room %d", ownerID)
}
coverURL := strings.TrimSpace(req.GetRoomAvatar())
if coverURL == "" {
coverURL = defaultRoomAvatar
}
return RobotRoomConfig{
AppCode: appcode.FromContext(ctx),
RoomID: roomID,
RoomShortID: fmt.Sprintf("%d", ownerID),
Title: title,
CoverURL: coverURL,
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
Status: robotRoomStatusActive,
OwnerRobotUserID: ownerID,
RobotUserIDs: selected,
GiftRule: rule,
CreatedByAdminID: req.GetAdminId(),
CreatedAtMS: now.UnixMilli(),
UpdatedAtMS: now.UnixMilli(),
}, nil
}
func (s *Service) bootstrapRobotRoom(ctx context.Context, config RobotRoomConfig) error {
_, err := s.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: fmt.Sprintf("admin-robot-room-create-%s", config.RoomID),
CommandId: fmt.Sprintf("admin-robot-room:%s:create", config.RoomID),
ActorUserId: config.OwnerRobotUserID,
RoomId: config.RoomID,
AppCode: config.AppCode,
SentAtMs: s.clock.Now().UnixMilli(),
},
SeatCount: int32(max(10, len(config.RobotUserIDs))),
Mode: robotRoomMode,
VisibleRegionId: config.VisibleRegionID,
RoomName: config.Title,
RoomAvatar: config.CoverURL,
RoomShortId: config.RoomShortID,
RobotRoom: true,
RobotUserIds: config.RobotUserIDs,
})
if err != nil {
return err
}
for _, userID := range config.RobotUserIDs {
if userID == config.OwnerRobotUserID {
continue
}
if _, err := s.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: robotRoomCommandMeta(config, userID, fmt.Sprintf("join:%d", userID)),
Role: "audience",
}); err != nil {
return err
}
}
for index, userID := range config.RobotUserIDs {
if _, err := s.RobotVirtualMicUp(ctx, RobotMicUpInput{
Meta: robotRoomCommandMeta(config, config.OwnerRobotUserID, fmt.Sprintf("mic:%d", userID)),
TargetUserID: userID,
SeatNo: int32(index + 1),
}); err != nil {
return err
}
}
return nil
}
// RunRobotRoomRuntimeManager 恢复 active 机器人房间的进程内送礼循环;配置事实仍以 MySQL 为准。
func (s *Service) RunRobotRoomRuntimeManager(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = 10 * time.Second
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
s.startActiveRobotRooms(ctx)
for {
select {
case <-ctx.Done():
s.stopAllRobotRoomRuntimes()
return
case <-ticker.C:
s.startActiveRobotRooms(ctx)
}
}
}
func (s *Service) startActiveRobotRooms(ctx context.Context) {
configs, err := s.repository.ListActiveRobotRooms(ctx)
if err != nil {
logx.Warn(ctx, "robot_room_runtime_scan_failed", slog.String("error", err.Error()))
return
}
for _, config := range configs {
s.startRobotRoomRuntime(ctx, config)
}
}
func (s *Service) startRobotRoomRuntime(parent context.Context, config RobotRoomConfig) {
if config.Status != robotRoomStatusActive || len(config.RobotUserIDs) < 2 {
return
}
s.robotRuntimeMu.Lock()
if s.robotRuntimes == nil {
s.robotRuntimes = make(map[string]context.CancelFunc)
}
if _, exists := s.robotRuntimes[config.RoomID]; exists {
s.robotRuntimeMu.Unlock()
return
}
ctx, cancel := context.WithCancel(appcode.WithContext(parent, config.AppCode))
s.robotRuntimes[config.RoomID] = cancel
s.robotRuntimeMu.Unlock()
go s.runRobotRoomRuntime(ctx, config)
}
func (s *Service) stopRobotRoomRuntime(roomID string) {
s.robotRuntimeMu.Lock()
cancel := s.robotRuntimes[roomID]
delete(s.robotRuntimes, roomID)
s.robotRuntimeMu.Unlock()
if cancel != nil {
cancel()
}
}
func (s *Service) stopAllRobotRoomRuntimes() {
s.robotRuntimeMu.Lock()
cancels := make([]context.CancelFunc, 0, len(s.robotRuntimes))
for roomID, cancel := range s.robotRuntimes {
cancels = append(cancels, cancel)
delete(s.robotRuntimes, roomID)
}
s.robotRuntimeMu.Unlock()
for _, cancel := range cancels {
cancel()
}
}
func (s *Service) runRobotRoomRuntime(ctx context.Context, config RobotRoomConfig) {
for _, senderID := range config.RobotUserIDs {
senderID := senderID
go s.runRobotNormalGiftLoop(ctx, config, senderID)
go s.runRobotLuckyGiftLoop(ctx, config, senderID)
}
<-ctx.Done()
}
func (s *Service) runRobotNormalGiftLoop(ctx context.Context, config RobotRoomConfig, senderID int64) {
interval := time.Duration(config.GiftRule.NormalGiftIntervalMS) * time.Millisecond
if interval <= 0 || len(config.GiftRule.GiftIDs) == 0 {
return
}
rng := rand.New(rand.NewSource(time.Now().UnixNano() + senderID))
timer := time.NewTimer(randomJitter(interval, rng))
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
targetID := randomRobotTarget(rng, config.RobotUserIDs, senderID)
giftID := randomString(rng, config.GiftRule.GiftIDs)
if targetID > 0 && giftID != "" {
s.sendRobotGiftBestEffort(ctx, config, senderID, targetID, giftID, "", "normal")
}
timer.Reset(interval)
}
}
}
func (s *Service) runRobotLuckyGiftLoop(ctx context.Context, config RobotRoomConfig, senderID int64) {
if len(config.GiftRule.LuckyGiftIDs) == 0 || config.GiftRule.LuckyComboMax <= 0 {
return
}
rng := rand.New(rand.NewSource(time.Now().UnixNano() + senderID*17))
for {
combo := randomInt64Range(rng, config.GiftRule.LuckyComboMin, config.GiftRule.LuckyComboMax)
for i := int64(0); i < combo; i++ {
giftID := randomString(rng, config.GiftRule.LuckyGiftIDs)
for _, targetID := range config.RobotUserIDs {
if targetID == senderID {
continue
}
if err := ctx.Err(); err != nil {
return
}
s.sendRobotGiftBestEffort(ctx, config, senderID, targetID, giftID, robotRoomLuckyPoolID, "lucky")
}
}
pause := time.Duration(randomInt64Range(rng, config.GiftRule.LuckyPauseMinMS, config.GiftRule.LuckyPauseMaxMS)) * time.Millisecond
if pause <= 0 {
pause = 5 * time.Second
}
timer := time.NewTimer(pause)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
func (s *Service) sendRobotGiftBestEffort(ctx context.Context, config RobotRoomConfig, senderID int64, targetID int64, giftID string, poolID string, kind string) {
_, err := s.RobotSendGift(ctx, RobotSendGiftInput{
Meta: robotRoomCommandMeta(config, senderID, fmt.Sprintf("gift:%s:%d:%d", kind, targetID, time.Now().UTC().UnixNano())),
TargetUserID: targetID,
GiftID: giftID,
GiftCount: 1,
PoolID: poolID,
RobotUserIDs: config.RobotUserIDs,
SyntheticRewardCoins: 0,
SyntheticMultiplierPPM: 0,
})
if err != nil {
logx.Warn(ctx, "robot_room_send_gift_failed", slog.String("room_id", config.RoomID), slog.String("kind", kind), slog.Int64("sender_user_id", senderID), slog.Int64("target_user_id", targetID), slog.String("gift_id", giftID), slog.String("error", err.Error()))
}
}
func robotRoomCommandMeta(config RobotRoomConfig, actorUserID int64, suffix string) *roomv1.RequestMeta {
commandID := fmt.Sprintf("robot-room:%s:%s", config.RoomID, strings.TrimSpace(suffix))
if len(commandID) > 128 {
commandID = commandID[:128]
}
return &roomv1.RequestMeta{
RequestId: commandID,
CommandId: commandID,
ActorUserId: actorUserID,
RoomId: config.RoomID,
AppCode: config.AppCode,
SentAtMs: time.Now().UTC().UnixMilli(),
}
}
func normalizeRobotRoomGiftRule(input *roomv1.AdminRobotRoomGiftRule) (RobotRoomGiftRule, error) {
if input == nil {
return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "gift_rule is required")
}
rule := RobotRoomGiftRule{
GiftIDs: normalizeStringSet(input.GetGiftIds()),
LuckyGiftIDs: normalizeStringSet(input.GetLuckyGiftIds()),
NormalGiftIntervalMS: input.GetNormalGiftIntervalMs(),
LuckyComboMin: input.GetLuckyComboMin(),
LuckyComboMax: input.GetLuckyComboMax(),
LuckyPauseMinMS: input.GetLuckyPauseMinMs(),
LuckyPauseMaxMS: input.GetLuckyPauseMaxMs(),
}
if len(rule.GiftIDs) == 0 {
return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "gift_ids is required")
}
if len(rule.LuckyGiftIDs) == 0 {
return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "lucky_gift_ids is required")
}
if rule.NormalGiftIntervalMS <= 0 {
return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "normal_gift_interval_ms is required")
}
if rule.LuckyComboMin <= 0 || rule.LuckyComboMax < rule.LuckyComboMin {
return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "lucky combo range is invalid")
}
if rule.LuckyPauseMinMS <= 0 || rule.LuckyPauseMaxMS < rule.LuckyPauseMinMS {
return RobotRoomGiftRule{}, xerr.New(xerr.InvalidArgument, "lucky pause range is invalid")
}
return rule, nil
}
func robotRoomConfigToProto(config RobotRoomConfig) *roomv1.AdminRobotRoom {
return &roomv1.AdminRobotRoom{
AppCode: config.AppCode,
RoomId: config.RoomID,
RoomShortId: config.RoomShortID,
Title: config.Title,
CoverUrl: config.CoverURL,
VisibleRegionId: config.VisibleRegionID,
Status: config.Status,
OwnerRobotUserId: config.OwnerRobotUserID,
RobotUserIds: append([]int64(nil), config.RobotUserIDs...),
GiftRule: &roomv1.AdminRobotRoomGiftRule{
GiftIds: append([]string(nil), config.GiftRule.GiftIDs...),
LuckyGiftIds: append([]string(nil), config.GiftRule.LuckyGiftIDs...),
NormalGiftIntervalMs: config.GiftRule.NormalGiftIntervalMS,
LuckyComboMin: config.GiftRule.LuckyComboMin,
LuckyComboMax: config.GiftRule.LuckyComboMax,
LuckyPauseMinMs: config.GiftRule.LuckyPauseMinMS,
LuckyPauseMaxMs: config.GiftRule.LuckyPauseMaxMS,
},
CreatedByAdminId: config.CreatedByAdminID,
CreatedAtMs: config.CreatedAtMS,
UpdatedAtMs: config.UpdatedAtMS,
}
}
func normalizeRobotCountRange(minCount int32, maxCount int32) (int, int) {
minValue := int(minCount)
maxValue := int(maxCount)
if minValue <= 0 {
minValue = 6
}
if maxValue <= 0 {
maxValue = 8
}
if maxValue < minValue {
maxValue = minValue
}
return minValue, maxValue
}
func selectRobotUsers(rng *rand.Rand, ownerID int64, available []int64, count int) []int64 {
others := make([]int64, 0, len(available))
for _, userID := range available {
if userID != ownerID {
others = append(others, userID)
}
}
rng.Shuffle(len(others), func(i, j int) { others[i], others[j] = others[j], others[i] })
selected := []int64{ownerID}
for _, userID := range others {
if len(selected) >= count {
break
}
selected = append(selected, userID)
}
slices.Sort(selected)
return selected
}
func normalizeRobotRoomStatus(status string) string {
switch strings.TrimSpace(status) {
case robotRoomStatusActive:
return robotRoomStatusActive
case robotRoomStatusStopped:
return robotRoomStatusStopped
default:
return ""
}
}
func normalizeStringSet(values []string) []string {
seen := make(map[string]bool, len(values))
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
slices.Sort(out)
return out
}
func int64SetKeys(values map[int64]bool) []int64 {
out := make([]int64, 0, len(values))
for value := range values {
out = append(out, value)
}
slices.Sort(out)
return out
}
func randomString(rng *rand.Rand, values []string) string {
if len(values) == 0 {
return ""
}
return values[rng.Intn(len(values))]
}
func randomRobotTarget(rng *rand.Rand, robots []int64, senderID int64) int64 {
targets := make([]int64, 0, len(robots))
for _, userID := range robots {
if userID != senderID {
targets = append(targets, userID)
}
}
if len(targets) == 0 {
return 0
}
return targets[rng.Intn(len(targets))]
}
func randomIntRange(rng *rand.Rand, minValue int, maxValue int) int {
if maxValue <= minValue {
return minValue
}
return minValue + rng.Intn(maxValue-minValue+1)
}
func randomInt64Range(rng *rand.Rand, minValue int64, maxValue int64) int64 {
if maxValue <= minValue {
return minValue
}
return minValue + rng.Int63n(maxValue-minValue+1)
}
func randomJitter(interval time.Duration, rng *rand.Rand) time.Duration {
if interval <= time.Second {
return interval
}
return time.Duration(rng.Int63n(int64(interval))) + time.Second
}

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"unicode/utf8"
@ -27,6 +28,8 @@ const (
roomExtShortIDKey = "room_short_id"
roomExtOwnerCountryKey = "owner_country_code"
roomExtBackgroundKey = "background_url"
roomExtRobotRoomKey = "robot_room"
roomExtRobotUserIDsKey = "robot_user_ids"
// defaultRoomAvatar 只服务房间资料更新/后台清空封面后的兜底;创建房间必须显式提交封面。
defaultRoomAvatar = "hyapp://room/default-avatar"
@ -104,6 +107,8 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
RoomAvatar: profile.Avatar,
RoomDescription: profile.Description,
RoomShortID: normalizeRoomShortID(req.GetRoomShortId(), actorUserID),
RobotRoom: req.GetRobotRoom(),
RobotUserIDs: normalizeRobotUserIDs(req.GetRobotUserIds()),
}
idempotencyStartedAt := time.Now()
@ -183,6 +188,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
ShortID: cmd.RoomShortID,
OwnerCountryCode: cmd.OwnerCountryCode,
})
applyRobotRoomExt(roomState, cmd.RobotRoom, cmd.RobotUserIDs)
roomState.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
UserID: cmd.ActorUserID(),
Role: "owner",
@ -338,3 +344,46 @@ func applyRoomProfileExt(roomState *state.RoomState, profile roomProfileInput) {
roomState.RoomExt[roomExtShortIDKey] = profile.ShortID
roomState.RoomExt[roomExtOwnerCountryKey] = normalizeRoomCountryCode(profile.OwnerCountryCode)
}
func applyRobotRoomExt(roomState *state.RoomState, robotRoom bool, robotUserIDs []int64) {
if roomState == nil || !robotRoom {
return
}
if roomState.RoomExt == nil {
roomState.RoomExt = make(map[string]string)
}
roomState.RoomExt[roomExtRobotRoomKey] = "true"
roomState.RoomExt[roomExtRobotUserIDsKey] = joinInt64CSV(normalizeRobotUserIDs(robotUserIDs))
}
func normalizeRobotUserIDs(values []int64) []int64 {
seen := make(map[int64]bool, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func joinInt64CSV(values []int64) string {
parts := make([]string, 0, len(values))
for _, value := range values {
parts = append(parts, strconv.FormatInt(value, 10))
}
return strings.Join(parts, ",")
}
func parseInt64CSV(raw string) map[int64]bool {
result := make(map[int64]bool)
for _, part := range strings.Split(raw, ",") {
value, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err == nil && value > 0 {
result[value] = true
}
}
return result
}

View File

@ -24,6 +24,10 @@ func (followTestWallet) BatchDebitGift(context.Context, *walletv1.BatchDebitGift
return &walletv1.BatchDebitGiftResponse{Aggregate: &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-follow-test"}}, nil
}
func (followTestWallet) DebitRobotGift(context.Context, *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) {
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-robot-follow-test"}, nil
}
func (followTestWallet) GrantResourceGroup(context.Context, *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{GrantId: "grant-follow-test"}}, nil
}

View File

@ -23,6 +23,46 @@ import (
// SendGift 先同步调用 wallet再在 Room Cell 内完成热度、榜单和房间事件落地。
func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
return s.sendGift(ctx, req, robotGiftOptions{})
}
type RobotSendGiftInput struct {
Meta *roomv1.RequestMeta
TargetUserID int64
GiftID string
GiftCount int32
PoolID string
RobotUserIDs []int64
SyntheticRewardCoins int64
SyntheticMultiplierPPM int64
}
type robotGiftOptions struct {
Enabled bool
RobotUserIDs []int64
SyntheticRewardCoins int64
SyntheticMultiplierPPM int64
}
func (s *Service) RobotSendGift(ctx context.Context, input RobotSendGiftInput) (*roomv1.SendGiftResponse, error) {
req := &roomv1.SendGiftRequest{
Meta: input.Meta,
TargetUserId: input.TargetUserID,
TargetUserIds: []int64{input.TargetUserID},
GiftId: input.GiftID,
GiftCount: input.GiftCount,
TargetType: "user",
PoolId: input.PoolID,
}
return s.sendGift(ctx, req, robotGiftOptions{
Enabled: true,
RobotUserIDs: input.RobotUserIDs,
SyntheticRewardCoins: input.SyntheticRewardCoins,
SyntheticMultiplierPPM: input.SyntheticMultiplierPPM,
})
}
func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, robotOptions robotGiftOptions) (*roomv1.SendGiftResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
// SendGift 的账务不在 room-service 内结算,但房间表现、热度和榜单由 Room Cell 同步更新。
cmd := command.SendGift{
@ -38,6 +78,10 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
TargetHostRegionID: req.GetTargetHostRegionId(),
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
RobotGift: robotOptions.Enabled,
SyntheticLuckyGift: robotOptions.Enabled && strings.TrimSpace(req.GetPoolId()) != "",
SyntheticLuckyRewardCoins: firstPositiveInt64(robotOptions.SyntheticRewardCoins, 0),
SyntheticLuckyMultiplierPPM: firstPositiveInt64(robotOptions.SyntheticMultiplierPPM, 1000000),
}
if cmd.TargetType == "" {
cmd.TargetType = "user"
@ -65,6 +109,11 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not in room")
}
}
if cmd.RobotGift {
if err := requireRobotGiftParticipants(current, cmd.ActorUserID(), cmd.TargetUserIDs, robotOptions.RobotUserIDs); err != nil {
return mutationResult{}, nil, err
}
}
if cmd.GiftID == "" || cmd.GiftCount <= 0 {
// 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。
@ -78,7 +127,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
}
luckyEnabled := false
if cmd.PoolID != "" {
if cmd.PoolID != "" && !cmd.SyntheticLuckyGift {
// 客户端显式传 pool_id 时必须先检查活动规则;规则未启用就拒绝,避免先扣费再发现不能抽奖。
if s.luckyGift == nil {
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift service is not configured")
@ -129,11 +178,16 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded()
settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey()
if !luckyEnabled {
if cmd.SyntheticLuckyGift {
luckyGift = syntheticLuckyGiftResult(cmd, targetBillings, now)
if luckyGift != nil {
luckyGifts = []*roomv1.LuckyGiftDrawResult{luckyGift}
}
} else if !luckyEnabled {
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
}
if luckyEnabled {
if !cmd.SyntheticLuckyGift && luckyEnabled {
luckyGifts, err = s.executeLuckyGiftDraws(ctx, req.GetMeta(), cmd, roomMeta, targetBillings, now)
if err != nil {
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
@ -207,6 +261,8 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
TargetGiftValue: targetCurrentGiftValues[targetBilling.TargetUserID],
// effect_types 来自后台礼物配置,房间事件必须原样带出,后续 activity-service 才能识别全服广播标识。
GiftEffectTypes: targetBilling.Billing.GetGiftEffectTypes(),
IsRobotGift: cmd.RobotGift,
SyntheticLuckyGift: cmd.SyntheticLuckyGift,
})
if err != nil {
return mutationResult{}, nil, err
@ -249,8 +305,32 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
}
records = append(records, ignitedEvent)
}
if cmd.SyntheticLuckyGift && luckyGift != nil {
drawEvent, err := outbox.Build(current.RoomID, "RoomRobotLuckyGiftDrawn", current.Version, now, &roomeventsv1.RoomRobotLuckyGiftDrawn{
DrawId: luckyGift.GetDrawId(),
CommandId: cmd.ID(),
SenderUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
PoolId: cmd.PoolID,
MultiplierPpm: luckyGift.GetMultiplierPpm(),
BaseRewardCoins: luckyGift.GetBaseRewardCoins(),
RoomAtmosphereRewardCoins: luckyGift.GetRoomAtmosphereRewardCoins(),
ActivitySubsidyCoins: luckyGift.GetActivitySubsidyCoins(),
EffectiveRewardCoins: luckyGift.GetEffectiveRewardCoins(),
CreatedAtMs: luckyGift.GetCreatedAtMs(),
VisibleRegionId: roomMeta.VisibleRegionID,
IsRobot: true,
Synthetic: true,
})
if err != nil {
return mutationResult{}, nil, err
}
records = append(records, drawEvent)
}
return mutationResult{
result := mutationResult{
snapshot: current.ToProto(),
billingReceiptID: billing.GetBillingReceiptId(),
roomHeat: current.Heat,
@ -301,9 +381,17 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
"gift_icon_url": billing.GetGiftIconUrl(),
"gift_animation_url": billing.GetGiftAnimationUrl(),
"target_gift_value": fmt.Sprintf("%d", targetCurrentGiftValues[cmd.TargetUserID]),
"is_robot_gift": fmt.Sprintf("%t", cmd.RobotGift),
"synthetic_lucky_gift": fmt.Sprintf("%t", cmd.SyntheticLuckyGift),
},
},
}, records, nil
}
if cmd.RobotGift {
// 机器人房间可以增加房间热度和麦位收礼热度,但不进入跨房礼物榜和房间送礼统计。
result.roomGiftLeaderboard = nil
result.roomUserGiftStats = nil
}
return result, records, nil
})
if err != nil {
return nil, err
@ -328,6 +416,26 @@ type giftTargetBilling struct {
}
func (s *Service) debitGiftTargets(ctx context.Context, cmd command.SendGift, roomMeta RoomMeta) (*walletv1.DebitGiftResponse, []giftTargetBilling, error) {
if cmd.RobotGift {
if len(cmd.TargetUserIDs) != 1 {
return nil, nil, xerr.New(xerr.InvalidArgument, "robot gift only supports one target")
}
billing, err := s.wallet.DebitRobotGift(ctx, &walletv1.DebitRobotGiftRequest{
CommandId: cmd.ID(),
RoomId: cmd.RoomID(),
SenderUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
GiftId: cmd.GiftID,
GiftCount: cmd.GiftCount,
AppCode: appcode.FromContext(ctx),
RegionId: roomMeta.VisibleRegionID,
SenderRegionId: cmd.SenderRegionID,
})
if err != nil {
return nil, nil, err
}
return billing, []giftTargetBilling{{TargetUserID: cmd.TargetUserID, CommandID: cmd.ID(), Billing: billing}}, nil
}
if len(cmd.TargetUserIDs) == 1 {
// 单目标保留原 wallet command_id避免改变已有幂等键、账务回执和排障路径。
billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{
@ -657,3 +765,56 @@ func findRankItem(items []state.RankItem, userID int64) state.RankItem {
return state.RankItem{UserID: userID}
}
func requireRobotGiftParticipants(current *state.RoomState, senderUserID int64, targetUserIDs []int64, runtimeRobotUserIDs []int64) error {
if current == nil || current.RoomExt[roomExtRobotRoomKey] != "true" {
return xerr.New(xerr.PermissionDenied, "robot gift requires robot room")
}
allowed := parseInt64CSV(current.RoomExt[roomExtRobotUserIDsKey])
for _, userID := range runtimeRobotUserIDs {
if userID > 0 {
allowed[userID] = true
}
}
if !allowed[senderUserID] {
return xerr.New(xerr.PermissionDenied, "robot sender is not allowed")
}
for _, targetUserID := range targetUserIDs {
if !allowed[targetUserID] {
return xerr.New(xerr.PermissionDenied, "robot gift target is not allowed")
}
}
return nil
}
func syntheticLuckyGiftResult(cmd command.SendGift, targetBillings []giftTargetBilling, now time.Time) *roomv1.LuckyGiftDrawResult {
if len(targetBillings) == 0 || targetBillings[0].Billing == nil {
return nil
}
rewardCoins := firstPositiveInt64(cmd.SyntheticLuckyRewardCoins, targetBillings[0].Billing.GetCoinSpent()*5)
multiplier := firstPositiveInt64(cmd.SyntheticLuckyMultiplierPPM, 5000000)
return &roomv1.LuckyGiftDrawResult{
Enabled: true,
DrawId: fmt.Sprintf("robot_lucky_%s_%d", cmd.ID(), now.UnixMilli()),
CommandId: cmd.ID(),
PoolId: cmd.PoolID,
GiftId: cmd.GiftID,
MultiplierPpm: multiplier,
BaseRewardCoins: rewardCoins,
EffectiveRewardCoins: rewardCoins,
RewardStatus: "granted",
StageFeedback: true,
HighMultiplier: multiplier >= 5000000,
CreatedAtMs: now.UnixMilli(),
TargetUserId: cmd.TargetUserID,
}
}
func firstPositiveInt64(values ...int64) int64 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}

View File

@ -130,6 +130,7 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
Description: typed.RoomDescription,
ShortID: typed.RoomShortID,
})
applyRobotRoomExt(current, typed.RobotRoom, typed.RobotUserIDs)
// 创建命令的 owner/mode 等基础 meta 已由 RoomMeta 或快照恢复,这里只保证版本进入初始提交态。
current.Version = 1
case *command.UpdateRoomProfile:
@ -212,6 +213,20 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
current.MicSeats[index].MicSessionRoomVersion = current.Version
// pending_publish 还没有接受 RTC/客户端事件,恢复时保持 0避免客户端时间慢于服务端命令时间时无法确认。
current.MicSeats[index].LastPublishEventTimeMS = 0
case *command.RobotMicUp:
index := current.SeatIndex(typed.SeatNo)
if index < 0 {
return fmt.Errorf("robot_mic_up replay references missing seat: %d", typed.SeatNo)
}
current.Version++
current.MicSeats[index].UserID = typed.TargetUserID
current.MicSeats[index].PublishState = state.MicPublishPublishing
current.MicSeats[index].MicSessionID = typed.MicSessionID
current.MicSeats[index].PublishDeadlineMS = 0
current.MicSeats[index].MicSessionRoomVersion = current.Version
current.MicSeats[index].LastPublishEventTimeMS = typed.SentAtMS
current.MicSeats[index].MicHeartbeatAtMS = typed.SentAtMS
current.MicSeats[index].MicMuted = true
case *command.MicDown:
seat, exists := current.SeatByUser(typed.TargetUserID)
if !exists {

View File

@ -309,6 +309,48 @@ type GiftFuelRuleConfig struct {
Excluded bool `json:"excluded"`
}
// RobotRoomGiftRule 是后台配置的机器人房间送礼节奏;它只驱动机器人展示链路,不进入真实活动奖池。
type RobotRoomGiftRule struct {
GiftIDs []string
LuckyGiftIDs []string
NormalGiftIntervalMS int64
LuckyComboMin int64
LuckyComboMax int64
LuckyPauseMinMS int64
LuckyPauseMaxMS int64
}
// RobotRoomConfig 是 room-service 持久化的机器人房间配置和账号占用事实。
type RobotRoomConfig struct {
AppCode string
RoomID string
RoomShortID string
Title string
CoverURL string
VisibleRegionID int64
Status string
OwnerRobotUserID int64
RobotUserIDs []int64
GiftRule RobotRoomGiftRule
CreatedByAdminID uint64
CreatedAtMS int64
UpdatedAtMS int64
}
// RobotRoomListQuery 是后台机器人房间列表查询条件。
type RobotRoomListQuery struct {
AppCode string
Status string
Page int
PageSize int
}
// CreateRobotRoomConfigInput 在同一事务中锁定机器人账号并写入房间配置。
type CreateRobotRoomConfigInput struct {
Config RobotRoomConfig
NowMS int64
}
// RoomPresence 是当前用户可恢复房间的轻量读模型。
// 它从 RoomSnapshot 投影而来,只用于 App 启动/回前台探测,不替代 Room Cell 权威状态。
type RoomPresence struct {
@ -577,6 +619,12 @@ type Repository interface {
AdminListRoomPins(ctx context.Context, query AdminRoomPinQuery) ([]AdminRoomPinEntry, int64, error)
AdminCreateRoomPin(ctx context.Context, input AdminCreateRoomPinInput) (AdminRoomPinEntry, bool, error)
AdminCancelRoomPin(ctx context.Context, pinID int64, adminID uint64, nowMS int64) (AdminRoomPinEntry, bool, error)
ListRobotRooms(ctx context.Context, query RobotRoomListQuery) ([]RobotRoomConfig, int64, error)
GetRobotRoom(ctx context.Context, roomID string) (RobotRoomConfig, bool, error)
CreateRobotRoomConfig(ctx context.Context, input CreateRobotRoomConfigInput) (RobotRoomConfig, error)
UpdateRobotRoomStatus(ctx context.Context, roomID string, status string, nowMS int64) (RobotRoomConfig, bool, error)
OccupiedRobotUserIDs(ctx context.Context, userIDs []int64) (map[int64]bool, error)
ListActiveRobotRooms(ctx context.Context) ([]RobotRoomConfig, error)
}
// RoomPresenceSnapshot 是 repository 投影接口的稳定入参,避免存储层反向依赖 protobuf。

View File

@ -0,0 +1,126 @@
package service
import (
"context"
"time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/tencentim"
"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"
)
// RobotMicUpInput 是内部机器人编排器占麦输入;调用方必须只传 robot-service 登记过的机器人用户。
type RobotMicUpInput struct {
Meta *roomv1.RequestMeta
TargetUserID int64
SeatNo int32
}
// RobotVirtualMicUp 把机器人放到静音虚拟麦位,不要求真实 RTC 推流。
func (s *Service) RobotVirtualMicUp(ctx context.Context, input RobotMicUpInput) (*roomv1.MicUpResponse, error) {
ctx = contextFromMeta(ctx, input.Meta)
cmd := command.RobotMicUp{
Base: baseFromMeta(input.Meta),
TargetUserID: input.TargetUserID,
SeatNo: input.SeatNo,
}
if cmd.TargetUserID <= 0 {
cmd.TargetUserID = cmd.ActorUserID()
}
cmd.MicSessionID = micSessionIDForCommand(cmd.RoomID(), cmd.ID())
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if err := requireActiveRoom(current); err != nil {
return mutationResult{}, nil, err
}
if _, exists := current.OnlineUsers[cmd.TargetUserID]; !exists {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "robot not in room")
}
if _, exists := current.SeatByUser(cmd.TargetUserID); exists {
return mutationResult{}, nil, xerr.New(xerr.Conflict, "robot already on seat")
}
index := current.SeatIndex(cmd.SeatNo)
if index < 0 {
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "seat does not exist")
}
if current.MicSeats[index].Locked {
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "seat is locked")
}
if current.MicSeats[index].UserID != 0 {
return mutationResult{}, nil, xerr.New(xerr.Conflict, "seat is occupied")
}
current.Version++
current.MicSeats[index].UserID = cmd.TargetUserID
current.MicSeats[index].PublishState = state.MicPublishPublishing
current.MicSeats[index].MicSessionID = cmd.MicSessionID
current.MicSeats[index].PublishDeadlineMS = 0
current.MicSeats[index].MicSessionRoomVersion = current.Version
current.MicSeats[index].LastPublishEventTimeMS = now.UnixMilli()
current.MicSeats[index].MicHeartbeatAtMS = now.UnixMilli()
current.MicSeats[index].MicMuted = true
seatStatus := current.MicSeats[index].SeatStatus()
targetGiftValue := roomUserGiftValue(current, cmd.TargetUserID)
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
ActorUserId: cmd.ActorUserID(),
TargetUserId: cmd.TargetUserID,
FromSeat: 0,
ToSeat: cmd.SeatNo,
Action: "robot_up",
MicSessionId: cmd.MicSessionID,
PublishState: state.MicPublishPublishing,
PublishDeadlineMs: 0,
SeatStatus: seatStatus,
TargetGiftValue: targetGiftValue,
})
if err != nil {
return mutationResult{}, nil, err
}
return mutationResult{
snapshot: current.ToProto(),
seatNo: cmd.SeatNo,
micSessionID: cmd.MicSessionID,
publishDeadlineMS: 0,
syncEvent: &tencentim.RoomEvent{
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_publish_confirmed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
SeatNo: cmd.SeatNo,
RoomVersion: current.Version,
Attributes: map[string]string{
"action": "robot_up",
"publish_state": state.MicPublishPublishing,
"mic_session_id": cmd.MicSessionID,
"seat_status": seatStatus,
"mic_muted": "true",
"target_gift_value": int64String(targetGiftValue),
},
},
}, []outbox.Record{micEvent}, nil
})
if err != nil {
return nil, err
}
if result.micSessionID == "" {
if seat := protoSeatByUser(result.snapshot, cmd.TargetUserID); seat != nil {
result.seatNo = seat.GetSeatNo()
result.micSessionID = seat.GetMicSessionId()
}
}
return &roomv1.MicUpResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
SeatNo: result.seatNo,
Room: result.snapshot,
MicSessionId: result.micSessionID,
PublishDeadlineMs: 0,
}, nil
}

View File

@ -81,6 +81,15 @@ func (w *rocketTestWallet) BatchDebitGift(_ context.Context, req *walletv1.Batch
return &walletv1.BatchDebitGiftResponse{Aggregate: aggregate, Receipts: receipts}, nil
}
func (w *rocketTestWallet) DebitRobotGift(_ context.Context, req *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) {
if len(w.debits) == 0 {
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-robot-default", HeatValue: 1, CoinSpent: 1, ChargeAmount: 1, ChargeAssetType: "ROBOT_COIN"}, nil
}
next := w.debits[0]
w.debits = w.debits[1:]
return next, nil
}
func joinReceiptIDs(left string, right string) string {
if left == "" {
return right

View File

@ -2,6 +2,7 @@
package service
import (
"context"
"errors"
"sync"
"sync/atomic"
@ -89,6 +90,11 @@ type Service struct {
mu sync.Mutex
// cells 保存当前节点已经装载的单房间执行单元。
cells map[string]*cell.RoomCell
// robotRuntimeMu 保护后台机器人房间运行循环;配置变更时要取消旧循环后重建。
robotRuntimeMu sync.Mutex
// robotRuntimes 保存 room_id -> cancel避免同一房间被重复启动多个送礼循环。
robotRuntimes map[string]context.CancelFunc
}
// mutationResult 是命令在 Room Cell 内执行后的统一结果包。
@ -203,6 +209,7 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
luckyGiftSendLockTTL: luckyGiftSendLockTTL,
rtcUserRemover: cfg.RTCUserRemover,
cells: make(map[string]*cell.RoomCell),
robotRuntimes: make(map[string]context.CancelFunc),
}
}

View File

@ -275,6 +275,30 @@ func (r *Repository) Migrate(ctx context.Context) error {
PRIMARY KEY (app_code),
KEY idx_room_rocket_enabled (app_code, enabled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS room_robot_rooms (
app_code VARCHAR(32) NOT NULL,
room_id VARCHAR(64) NOT NULL,
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
title VARCHAR(128) NOT NULL DEFAULT '',
cover_url VARCHAR(512) NOT NULL DEFAULT '',
visible_region_id BIGINT NOT NULL DEFAULT 0,
status VARCHAR(32) NOT NULL,
owner_robot_user_id BIGINT NOT NULL,
robot_user_ids_json JSON NOT NULL,
gift_ids_json JSON NOT NULL,
lucky_gift_ids_json JSON NOT NULL,
normal_gift_interval_ms BIGINT NOT NULL,
lucky_combo_min BIGINT NOT NULL,
lucky_combo_max BIGINT NOT NULL,
lucky_pause_min_ms BIGINT NOT NULL,
lucky_pause_max_ms BIGINT NOT NULL,
created_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, room_id),
KEY idx_room_robot_rooms_status (app_code, status, updated_at_ms, room_id),
KEY idx_room_robot_rooms_owner (app_code, owner_robot_user_id)
) 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_rocket_configs (
@ -325,6 +349,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
`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_rocket_configs MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
`ALTER TABLE room_robot_rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
}
for _, statement := range statements {
@ -1130,6 +1155,298 @@ func (r *Repository) UpsertRoomRocketConfig(ctx context.Context, config roomserv
return err
}
// ListRobotRooms 读取后台机器人房间列表;机器人房间配置归 room-service 所有,后台不直连房间库。
func (r *Repository) ListRobotRooms(ctx context.Context, query roomservice.RobotRoomListQuery) ([]roomservice.RobotRoomConfig, int64, error) {
appCode := normalizedRecordAppCode(ctx, query.AppCode)
page := query.Page
if page <= 0 {
page = 1
}
pageSize := query.PageSize
if pageSize <= 0 || pageSize > 100 {
pageSize = 50
}
status := strings.TrimSpace(query.Status)
args := []any{appCode}
where := `WHERE app_code = ?`
if status != "" {
where += ` AND status = ?`
args = append(args, status)
}
var total int64
if err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM room_robot_rooms `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
args = append(args, pageSize, (page-1)*pageSize)
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
created_by_admin_id, created_at_ms, updated_at_ms
FROM room_robot_rooms `+where+`
ORDER BY updated_at_ms DESC, room_id DESC
LIMIT ? OFFSET ?`,
args...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, err := scanRobotRoomConfigs(rows)
return items, total, err
}
// GetRobotRoom 精确读取一个机器人房间配置。
func (r *Repository) GetRobotRoom(ctx context.Context, roomID string) (roomservice.RobotRoomConfig, bool, error) {
row := r.db.QueryRowContext(ctx,
`SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
created_by_admin_id, created_at_ms, updated_at_ms
FROM room_robot_rooms
WHERE app_code = ? AND room_id = ?`,
appcode.FromContext(ctx),
strings.TrimSpace(roomID),
)
item, err := scanRobotRoomConfig(row)
if errors.Is(err, sql.ErrNoRows) {
return roomservice.RobotRoomConfig{}, false, nil
}
return item, err == nil, err
}
// CreateRobotRoomConfig 写入机器人房间配置,并用事务锁定候选账号避免两个 active 机器人房复用同一批账号。
func (r *Repository) CreateRobotRoomConfig(ctx context.Context, input roomservice.CreateRobotRoomConfigInput) (roomservice.RobotRoomConfig, error) {
config := input.Config
appCode := normalizedRecordAppCode(ctx, config.AppCode)
nowMS := input.NowMS
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
config.AppCode = appCode
config.CreatedAtMS = firstPositiveInt64(config.CreatedAtMS, nowMS)
config.UpdatedAtMS = firstPositiveInt64(config.UpdatedAtMS, nowMS)
if config.Status == "" {
config.Status = "active"
}
rawRobots, rawGifts, rawLuckyGifts, err := robotRoomJSON(config)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
defer func() { _ = tx.Rollback() }()
occupied, err := occupiedRobotUserIDsTx(ctx, tx, appCode, config.RobotUserIDs)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
if len(occupied) > 0 {
return roomservice.RobotRoomConfig{}, xerr.New(xerr.Conflict, "robot user already assigned to active robot room")
}
_, err = tx.ExecContext(ctx,
`INSERT INTO room_robot_rooms (
app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
created_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
config.AppCode,
config.RoomID,
config.RoomShortID,
config.Title,
config.CoverURL,
config.VisibleRegionID,
config.Status,
config.OwnerRobotUserID,
string(rawRobots),
string(rawGifts),
string(rawLuckyGifts),
config.GiftRule.NormalGiftIntervalMS,
config.GiftRule.LuckyComboMin,
config.GiftRule.LuckyComboMax,
config.GiftRule.LuckyPauseMinMS,
config.GiftRule.LuckyPauseMaxMS,
config.CreatedByAdminID,
config.CreatedAtMS,
config.UpdatedAtMS,
)
if err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := tx.Commit(); err != nil {
return roomservice.RobotRoomConfig{}, err
}
return config, nil
}
// UpdateRobotRoomStatus 只切换机器人房间运行态;停止后账号会从“已占用”集合释放。
func (r *Repository) UpdateRobotRoomStatus(ctx context.Context, roomID string, status string, nowMS int64) (roomservice.RobotRoomConfig, bool, error) {
status = strings.TrimSpace(status)
if nowMS <= 0 {
nowMS = time.Now().UTC().UnixMilli()
}
res, err := r.db.ExecContext(ctx,
`UPDATE room_robot_rooms
SET status = ?, updated_at_ms = ?
WHERE app_code = ? AND room_id = ?`,
status,
nowMS,
appcode.FromContext(ctx),
strings.TrimSpace(roomID),
)
if err != nil {
return roomservice.RobotRoomConfig{}, false, err
}
affected, _ := res.RowsAffected()
if affected == 0 {
return roomservice.RobotRoomConfig{}, false, nil
}
item, exists, err := r.GetRobotRoom(ctx, roomID)
return item, exists, err
}
// OccupiedRobotUserIDs 返回仍被 active 机器人房占用的机器人账号集合。
func (r *Repository) OccupiedRobotUserIDs(ctx context.Context, userIDs []int64) (map[int64]bool, error) {
return occupiedRobotUserIDsQuery(ctx, r.db, appcode.FromContext(ctx), userIDs, false)
}
// ListActiveRobotRooms 为 room-service 重启恢复运行时循环提供当前 active 配置。
func (r *Repository) ListActiveRobotRooms(ctx context.Context) ([]roomservice.RobotRoomConfig, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT app_code, room_id, room_short_id, title, cover_url, visible_region_id, status,
owner_robot_user_id, robot_user_ids_json, gift_ids_json, lucky_gift_ids_json,
normal_gift_interval_ms, lucky_combo_min, lucky_combo_max, lucky_pause_min_ms, lucky_pause_max_ms,
created_by_admin_id, created_at_ms, updated_at_ms
FROM room_robot_rooms
WHERE status = 'active'
ORDER BY app_code ASC, updated_at_ms DESC, room_id DESC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRobotRoomConfigs(rows)
}
type robotRoomScanner interface {
Scan(dest ...any) error
}
func scanRobotRoomConfigs(rows *sql.Rows) ([]roomservice.RobotRoomConfig, error) {
items := make([]roomservice.RobotRoomConfig, 0)
for rows.Next() {
item, err := scanRobotRoomConfig(rows)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func scanRobotRoomConfig(row robotRoomScanner) (roomservice.RobotRoomConfig, error) {
var item roomservice.RobotRoomConfig
var rawRobots string
var rawGifts string
var rawLuckyGifts string
if err := row.Scan(
&item.AppCode,
&item.RoomID,
&item.RoomShortID,
&item.Title,
&item.CoverURL,
&item.VisibleRegionID,
&item.Status,
&item.OwnerRobotUserID,
&rawRobots,
&rawGifts,
&rawLuckyGifts,
&item.GiftRule.NormalGiftIntervalMS,
&item.GiftRule.LuckyComboMin,
&item.GiftRule.LuckyComboMax,
&item.GiftRule.LuckyPauseMinMS,
&item.GiftRule.LuckyPauseMaxMS,
&item.CreatedByAdminID,
&item.CreatedAtMS,
&item.UpdatedAtMS,
); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawRobots), &item.RobotUserIDs); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawGifts), &item.GiftRule.GiftIDs); err != nil {
return roomservice.RobotRoomConfig{}, err
}
if err := json.Unmarshal([]byte(rawLuckyGifts), &item.GiftRule.LuckyGiftIDs); err != nil {
return roomservice.RobotRoomConfig{}, err
}
return item, nil
}
func robotRoomJSON(config roomservice.RobotRoomConfig) ([]byte, []byte, []byte, error) {
rawRobots, err := json.Marshal(config.RobotUserIDs)
if err != nil {
return nil, nil, nil, err
}
rawGifts, err := json.Marshal(config.GiftRule.GiftIDs)
if err != nil {
return nil, nil, nil, err
}
rawLuckyGifts, err := json.Marshal(config.GiftRule.LuckyGiftIDs)
if err != nil {
return nil, nil, nil, err
}
return rawRobots, rawGifts, rawLuckyGifts, nil
}
func occupiedRobotUserIDsTx(ctx context.Context, tx *sql.Tx, appCode string, userIDs []int64) (map[int64]bool, error) {
return occupiedRobotUserIDsQuery(ctx, tx, appCode, userIDs, true)
}
type dbQuerier interface {
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}
func occupiedRobotUserIDsQuery(ctx context.Context, db dbQuerier, appCode string, userIDs []int64, lock bool) (map[int64]bool, error) {
ids := uniquePositiveInt64(userIDs)
occupied := make(map[int64]bool, len(ids))
if len(ids) == 0 {
return occupied, nil
}
query := `SELECT robot_user_ids_json FROM room_robot_rooms WHERE app_code = ? AND status = 'active'`
if lock {
query += ` FOR UPDATE`
}
rows, err := db.QueryContext(ctx, query, appcode.Normalize(appCode))
if err != nil {
return nil, err
}
defer rows.Close()
wanted := make(map[int64]bool, len(ids))
for _, id := range ids {
wanted[id] = true
}
for rows.Next() {
var raw string
if err := rows.Scan(&raw); err != nil {
return nil, err
}
var roomRobotIDs []int64
if err := json.Unmarshal([]byte(raw), &roomRobotIDs); err != nil {
return nil, err
}
for _, id := range roomRobotIDs {
if wanted[id] {
occupied[id] = true
}
}
}
return occupied, rows.Err()
}
// ListCommandsAfter 返回快照版本之后的可回放命令日志。
func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) {
// 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。
@ -2640,6 +2957,29 @@ func boolToInt(value bool) int {
return 0
}
func firstPositiveInt64(values ...int64) int64 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
func uniquePositiveInt64(values []int64) []int64 {
seen := make(map[int64]bool, len(values))
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func mapRoomMetaDuplicateError(err error) error {
if err == nil {
return nil

View File

@ -187,6 +187,16 @@ func (s *Server) AdminCancelRoomPin(ctx context.Context, req *roomv1.AdminCancel
return mapServiceResult(s.svc.AdminCancelRoomPin(ctx, req))
}
func (s *Server) AdminCreateRobotRoom(ctx context.Context, req *roomv1.AdminCreateRobotRoomRequest) (*roomv1.AdminCreateRobotRoomResponse, error) {
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.AdminCreateRobotRoom(ctx, req))
}
func (s *Server) AdminSetRobotRoomStatus(ctx context.Context, req *roomv1.AdminSetRobotRoomStatusRequest) (*roomv1.AdminSetRobotRoomStatusResponse, error) {
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.AdminSetRobotRoomStatus(ctx, req))
}
// MicUp 代理到领域服务。
func (s *Server) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) {
// 麦位修改由 Room Cell 串行执行gRPC 层不做状态判断。
@ -443,6 +453,16 @@ func (s *Server) AdminListRoomPins(ctx context.Context, req *roomv1.AdminListRoo
return mapServiceResult(s.svc.AdminListRoomPins(ctx, req))
}
func (s *Server) AdminListRobotRooms(ctx context.Context, req *roomv1.AdminListRobotRoomsRequest) (*roomv1.AdminListRobotRoomsResponse, error) {
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.AdminListRobotRooms(ctx, req))
}
func (s *Server) AdminFilterAvailableRoomRobots(ctx context.Context, req *roomv1.AdminFilterAvailableRoomRobotsRequest) (*roomv1.AdminFilterAvailableRoomRobotsResponse, error) {
ctx = contextWithMetaApp(ctx, req.GetMeta())
return mapServiceResult(s.svc.AdminFilterAvailableRoomRobots(ctx, req))
}
// ListRooms 代理到 room-service 列表读模型。
func (s *Server) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
// 列表查询只读 MySQL 投影,不扫描 Room Cell 内存集合。

View File

@ -35,6 +35,11 @@ func (fakeWallet) BatchDebitGift(context.Context, *walletv1.BatchDebitGiftReques
return &walletv1.BatchDebitGiftResponse{Aggregate: &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-test"}}, nil
}
// DebitRobotGift 返回固定结果,避免 transport 测试依赖 wallet-service 机器人金币实现。
func (fakeWallet) DebitRobotGift(context.Context, *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) {
return &walletv1.DebitGiftResponse{BillingReceiptId: "receipt-robot-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

View File

@ -371,6 +371,9 @@ func roomGiftEvent(body []byte) (mysqlstorage.RoomGiftEvent, bool, error) {
if err := proto.Unmarshal(envelope.GetBody(), &gift); err != nil {
return mysqlstorage.RoomGiftEvent{}, false, err
}
if gift.GetIsRobotGift() {
return mysqlstorage.RoomGiftEvent{}, false, nil
}
return mysqlstorage.RoomGiftEvent{
AppCode: envelope.GetAppCode(),
EventID: envelope.GetEventId(),

View File

@ -7,6 +7,8 @@ const (
AssetCoin = "COIN"
// AssetCoinSellerCoin 是币商专用金币资产,只能通过币商转账兑换成玩家普通 COIN。
AssetCoinSellerCoin = "COIN_SELLER_COIN"
// AssetRobotCoin 是机器人房间专用金币资产,只允许内部机器人送礼链路扣减,不进入真人充值消费口径。
AssetRobotCoin = "ROBOT_COIN"
// AssetGiftPoint 是历史礼物积分资产,只保留历史账本查询兼容;新送礼不会再给它入账。
AssetGiftPoint = "GIFT_POINT"
// AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。
@ -146,6 +148,8 @@ type DebitGiftCommand struct {
TargetHostRegionID int64
// TargetAgencyOwnerUserID 是送礼瞬间主播上级代理的收款用户快照;结算按快照发放,避免月底组织变化错账。
TargetAgencyOwnerUserID int64
// RobotGift 表示本次扣费只服务机器人房间展示:扣 ROBOT_COIN不给主播钻石不进入真实礼物墙投影。
RobotGift bool
}
// DebitGiftTargetCommand 是批量送礼中单个接收方的账务语义。
@ -1127,7 +1131,7 @@ type RedPacketExpireResult struct {
// ValidAssetType 限定账本允许落账的资产类型,避免写入任意字符串资产。
func ValidAssetType(assetType string) bool {
switch assetType {
case AssetCoin, AssetCoinSellerCoin, AssetGiftPoint, AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD:
case AssetCoin, AssetCoinSellerCoin, AssetRobotCoin, AssetGiftPoint, AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD:
return true
default:
return false

View File

@ -164,6 +164,20 @@ func (s *Service) SetExternalRechargeConfig(config ExternalRechargeConfig) {
// DebitGift 校验送礼扣费命令,并交给 repository 做原子落账和幂等。
func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
command.RobotGift = false
return s.debitGift(ctx, command)
}
// DebitRobotGift 校验机器人房间专用送礼扣费命令,并强制使用 ROBOT_COIN 账务语义。
func (s *Service) DebitRobotGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
command.RobotGift = true
command.TargetIsHost = false
command.TargetHostRegionID = 0
command.TargetAgencyOwnerUserID = 0
return s.debitGift(ctx, command)
}
func (s *Service) debitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.TargetUserID <= 0 || command.GiftID == "" {
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "billing command is incomplete")
}

View File

@ -24,6 +24,7 @@ import (
const (
transactionStatusSucceeded = "succeeded"
bizTypeGiftDebit = "gift_debit"
bizTypeRobotGiftDebit = "robot_gift_debit"
bizTypeManualCredit = "manual_credit"
bizTypeTaskReward = "task_reward"
bizTypeLuckyGiftReward = "lucky_gift_reward"
@ -365,7 +366,8 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
}()
requestHash := debitRequestHash(command)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeGiftDebit); err != nil || exists {
bizType := giftDebitBizType(command)
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizType); err != nil || exists {
if err != nil || !exists {
return ledger.Receipt{}, err
}
@ -388,6 +390,11 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
if err != nil {
return ledger.Receipt{}, err
}
chargeAssetType := price.ChargeAssetType
if command.RobotGift {
// 机器人礼物复用真实礼物价格和素材快照,但扣专用 ROBOT_COIN避免污染真人 COIN 充值消费口径。
chargeAssetType = ledger.AssetRobotCoin
}
chargeAmount, err := checkedMul(price.CoinPrice, int64(command.GiftCount))
if err != nil {
return ledger.Receipt{}, err
@ -406,7 +413,7 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
hostPeriodCycleKey := ""
giftDiamondRatioPercent := "0.00"
giftDiamondRatioRegionID := int64(0)
if command.TargetIsHost {
if command.TargetIsHost && !command.RobotGift {
ratio, ratioRegionID, err := r.resolveGlobalGiftDiamondRatio(ctx, tx, command.AppCode, giftConfig.GiftTypeCode)
if err != nil {
return ledger.Receipt{}, err
@ -421,13 +428,19 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
}
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, price.ChargeAssetType, chargeAmount == 0, nowMs)
sender, err := r.lockAccount(ctx, tx, command.SenderUserID, chargeAssetType, chargeAmount == 0, nowMs)
if err != nil {
return ledger.Receipt{}, err
}
if sender.AvailableAmount < chargeAmount {
robotCoinTopUp := int64(0)
if command.RobotGift && sender.AvailableAmount < chargeAmount {
// 机器人房间使用专用 ROBOT_COIN 做展示账务,按次补足后立刻扣减,不要求真实充值预算。
robotCoinTopUp = chargeAmount - sender.AvailableAmount
}
if !command.RobotGift && sender.AvailableAmount < chargeAmount {
return ledger.Receipt{}, xerr.New(xerr.InsufficientBalance, "insufficient balance")
}
senderAfter := sender.AvailableAmount + robotCoinTopUp - chargeAmount
transactionID := transactionID(command.AppCode, command.CommandID)
metadata := giftMetadata{
AppCode: command.AppCode,
@ -445,12 +458,12 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
SortOrder: giftConfig.SortOrder,
GiftCount: command.GiftCount,
PriceVersion: price.PriceVersion,
ChargeAssetType: price.ChargeAssetType,
ChargeAssetType: chargeAssetType,
ChargeAmount: chargeAmount,
CoinSpent: chargeAmount,
GiftPointAdded: 0,
HeatValue: heatValue,
BalanceAfter: sender.AvailableAmount - chargeAmount,
BalanceAfter: senderAfter,
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
SenderUserID: command.SenderUserID,
SenderRegionID: command.SenderRegionID,
@ -469,18 +482,42 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
CoinPrice: price.CoinPrice,
GiftPointAmount: 0,
HeatUnitValue: price.CoinPrice,
RobotGift: command.RobotGift,
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
return ledger.Receipt{}, err
}
events := make([]walletOutboxEvent, 0, 3)
if robotCoinTopUp > 0 {
if err := r.applyAccountDelta(ctx, tx, sender, robotCoinTopUp, 0, nowMs); err != nil {
return ledger.Receipt{}, err
}
topUpAfter := sender.AvailableAmount + robotCoinTopUp
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: chargeAssetType,
AvailableDelta: robotCoinTopUp,
FrozenDelta: 0,
AvailableAfter: topUpAfter,
FrozenAfter: sender.FrozenAmount,
CounterpartyUserID: 0,
RoomID: command.RoomID,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.Receipt{}, err
}
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, robotCoinTopUp, 0, topUpAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
sender.AvailableAmount = topUpAfter
sender.Version++
}
if err := r.applyAccountDelta(ctx, tx, sender, -chargeAmount, 0, nowMs); err != nil {
return ledger.Receipt{}, err
}
senderAfter := sender.AvailableAmount - chargeAmount
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SenderUserID,
AssetType: price.ChargeAssetType,
AssetType: chargeAssetType,
AvailableDelta: -chargeAmount,
FrozenDelta: 0,
AvailableAfter: senderAfter,
@ -501,9 +538,10 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
metadata.HostPeriodDiamondVersion = hostPeriodDiamondVersion
}
events := []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs),
giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, price.ChargeAssetType, -chargeAmount, 0, metadata, nowMs),
events = append(events, balanceChangedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, senderAfter, sender.FrozenAmount, sender.Version+1, metadata, nowMs))
if !command.RobotGift {
// 只有真人礼物产生 WalletGiftDebited机器人礼物不投影礼物墙也不进入真实消费类下游。
events = append(events, giftDebitedEvent(transactionID, command.CommandID, command.SenderUserID, chargeAssetType, -chargeAmount, 0, metadata, nowMs))
}
if hostPeriodDiamondAdded > 0 {
events = append(events, hostPeriodDiamondCreditedEvent(transactionID, command.CommandID, metadata, nowMs))
@ -809,6 +847,10 @@ func (r *Repository) GetBalances(ctx context.Context, userID int64, assetTypes [
}
func (r *Repository) upsertUserGiftWall(ctx context.Context, tx *sql.Tx, metadata giftMetadata, nowMs int64) error {
if metadata.RobotGift {
// 机器人房间礼物只服务房间展示,不进入真人用户礼物墙。
return nil
}
chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
chargeAmount := metadata.ChargeAmount
if chargeAmount == 0 {
@ -2957,6 +2999,7 @@ type giftMetadata struct {
RoomRegionID int64 `json:"room_region_id"`
RoomContributionRatioPercent string `json:"room_contribution_ratio_percent"`
RoomContributionRatioRegionID int64 `json:"room_contribution_ratio_region_id"`
RobotGift bool `json:"robot_gift,omitempty"`
}
type adminCreditMetadata struct {
@ -3137,6 +3180,9 @@ type coinSellerStockMetadata struct {
func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt {
chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
if metadata.RobotGift {
chargeAssetType = ledger.AssetRobotCoin
}
chargeAmount := metadata.ChargeAmount
if chargeAmount == 0 {
chargeAmount = metadata.CoinSpent
@ -3831,6 +3877,13 @@ func debitGiftCommandFromBatchTarget(command ledger.BatchDebitGiftCommand, targe
}
}
func giftDebitBizType(command ledger.DebitGiftCommand) string {
if command.RobotGift {
return bizTypeRobotGiftDebit
}
return bizTypeGiftDebit
}
func aggregateGiftReceipts(targets []ledger.BatchGiftTargetReceipt) (ledger.Receipt, error) {
aggregate := ledger.Receipt{}
billingReceiptIDs := make([]string, 0, len(targets))
@ -3897,7 +3950,7 @@ func hostPeriodCycleKeyFromMS(ms int64) string {
func debitRequestHash(command ledger.DebitGiftCommand) string {
// 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d",
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d|%t",
appcode.Normalize(command.AppCode),
command.RoomID,
command.SenderUserID,
@ -3910,6 +3963,7 @@ func debitRequestHash(command ledger.DebitGiftCommand) string {
command.TargetIsHost,
command.TargetHostRegionID,
command.TargetAgencyOwnerUserID,
command.RobotGift,
))
}

View File

@ -91,6 +91,27 @@ func (s *Server) BatchDebitGift(ctx context.Context, req *walletv1.BatchDebitGif
return response, nil
}
// DebitRobotGift 扣机器人专用金币;该入口只供内部机器人房间编排链路调用。
func (s *Server) DebitRobotGift(ctx context.Context, req *walletv1.DebitRobotGiftRequest) (*walletv1.DebitGiftResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.DebitRobotGift(ctx, ledger.DebitGiftCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
RoomID: req.GetRoomId(),
SenderUserID: req.GetSenderUserId(),
TargetUserID: req.GetTargetUserId(),
GiftID: req.GetGiftId(),
GiftCount: req.GetGiftCount(),
PriceVersion: req.GetPriceVersion(),
RegionID: req.GetRegionId(),
SenderRegionID: req.GetSenderRegionId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return debitGiftResponseFromReceipt(receipt), nil
}
func debitGiftResponseFromReceipt(receipt ledger.Receipt) *walletv1.DebitGiftResponse {
return &walletv1.DebitGiftResponse{
BillingReceiptId: receipt.BillingReceiptID,

View File

@ -0,0 +1,484 @@
package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
const (
defaultAdminBaseURL = "http://127.0.0.1:13100/api/v1"
defaultAdminUser = "admin"
defaultAdminPass = "admin123"
defaultMySQLDSN = "hyapp:hyapp@tcp(127.0.0.1:23306)/%s?parseTime=true&charset=utf8mb4&loc=UTC"
appCode = "lalu"
roomScene = "voice"
normalGiftID = "robot_room_test_normal"
luckyGiftID = "robot_room_test_lucky"
robotUserStart = int64(920001)
robotUserCount = 12
)
type apiBody struct {
Code int `json:"code"`
Message string `json:"message"`
Data json.RawMessage `json:"data"`
}
type loginData struct {
AccessToken string `json:"accessToken"`
}
type availableRobot struct {
UserID string `json:"userId"`
}
type robotRoom struct {
RoomID string `json:"roomId"`
Status string `json:"status"`
OwnerRobotUserID string `json:"ownerRobotUserId"`
RobotUserIDs []string `json:"robotUserIds"`
GiftRule struct {
GiftIDs []string `json:"giftIds"`
LuckyGiftIDs []string `json:"luckyGiftIds"`
NormalGiftIntervalMS int64 `json:"normalGiftIntervalMs"`
LuckyComboMin int64 `json:"luckyComboMin"`
LuckyComboMax int64 `json:"luckyComboMax"`
LuckyPauseMinMS int64 `json:"luckyPauseMinMs"`
LuckyPauseMaxMS int64 `json:"luckyPauseMaxMs"`
} `json:"giftRule"`
}
type pageData struct {
Items []robotRoom `json:"items"`
Total int64 `json:"total"`
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
baseURL := env("ROBOT_ROOM_ADMIN_BASE_URL", defaultAdminBaseURL)
mysqlDSN := env("ROBOT_ROOM_MYSQL_DSN", defaultMySQLDSN)
adminUser := env("ROBOT_ROOM_ADMIN_USERNAME", defaultAdminUser)
adminPass := env("ROBOT_ROOM_ADMIN_PASSWORD", defaultAdminPass)
dbs, err := openDBs(ctx, mysqlDSN)
if err != nil {
fail(err)
}
defer dbs.close()
if err := seed(ctx, dbs); err != nil {
fail(fmt.Errorf("seed local data: %w", err))
}
fmt.Println("seed: ok")
client := &http.Client{Timeout: 10 * time.Second}
token, err := login(ctx, client, baseURL, adminUser, adminPass)
if err != nil {
fail(err)
}
fmt.Println("login: ok")
available, err := getJSON[[]availableRobot](ctx, client, baseURL+"/admin/rooms/robot-rooms/available-robots", token)
if err != nil {
fail(err)
}
if len(available) < 10 {
fail(fmt.Errorf("available robots = %d, want at least 10", len(available)))
}
fmt.Printf("available robots: %d\n", len(available))
payload := map[string]any{
"ownerRobotUserId": robotUserStart,
"candidateRobotUserIds": robotIDs(),
"minRobotCount": 6,
"maxRobotCount": 8,
"roomName": "本地接口测试机器人房",
"visibleRegionId": 0,
"giftIds": []string{normalGiftID},
"luckyGiftIds": []string{luckyGiftID},
"normalGiftIntervalMs": 60000,
"luckyComboMin": 2,
"luckyComboMax": 3,
"luckyPauseMinMs": 600000,
"luckyPauseMaxMs": 600000,
}
created, err := postJSON[robotRoom](ctx, client, baseURL+"/admin/rooms/robot-rooms", token, payload)
if err != nil {
fail(err)
}
if created.RoomID == "" || created.Status != "active" {
fail(fmt.Errorf("created room invalid: room_id=%q status=%q", created.RoomID, created.Status))
}
if len(created.RobotUserIDs) < 6 || len(created.RobotUserIDs) > 8 {
fail(fmt.Errorf("created robot count = %d, want 6-8", len(created.RobotUserIDs)))
}
fmt.Printf("create robot room: %s robots=%d\n", created.RoomID, len(created.RobotUserIDs))
list, err := getJSON[pageData](ctx, client, baseURL+"/admin/rooms/robot-rooms?status=active&page=1&page_size=20", token)
if err != nil {
fail(err)
}
if !containsRoom(list.Items, created.RoomID) {
fail(fmt.Errorf("created room %s not found in active list", created.RoomID))
}
fmt.Printf("list active robot rooms: total=%d\n", list.Total)
stopped, err := postJSON[robotRoom](ctx, client, baseURL+"/admin/rooms/robot-rooms/"+created.RoomID+"/stop", token, nil)
if err != nil {
fail(err)
}
if stopped.Status != "stopped" {
fail(fmt.Errorf("stop status = %q, want stopped", stopped.Status))
}
fmt.Println("stop robot room: ok")
started, err := postJSON[robotRoom](ctx, client, baseURL+"/admin/rooms/robot-rooms/"+created.RoomID+"/start", token, nil)
if err != nil {
fail(err)
}
if started.Status != "active" {
fail(fmt.Errorf("start status = %q, want active", started.Status))
}
fmt.Println("start robot room: ok")
if _, err := postJSON[robotRoom](ctx, client, baseURL+"/admin/rooms/robot-rooms/"+created.RoomID+"/stop", token, nil); err != nil {
fail(err)
}
if err := verifyDB(ctx, dbs, created.RoomID); err != nil {
fail(err)
}
fmt.Println("db verify: ok")
fmt.Println("PASS robot room admin API smoke")
}
type dbs struct {
user *sql.DB
wallet *sql.DB
robot *sql.DB
room *sql.DB
}
func openDBs(ctx context.Context, dsnTemplate string) (dbs, error) {
open := func(name string) (*sql.DB, error) {
dsn := fmt.Sprintf(dsnTemplate, name)
if !strings.Contains(dsnTemplate, "%s") {
dsn = dsnTemplate
}
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, err
}
if err := db.PingContext(ctx); err != nil {
_ = db.Close()
return nil, fmt.Errorf("%s: %w", name, err)
}
return db, nil
}
userDB, err := open("hyapp_user")
if err != nil {
return dbs{}, err
}
walletDB, err := open("hyapp_wallet")
if err != nil {
_ = userDB.Close()
return dbs{}, err
}
robotDB, err := open("hyapp_robot")
if err != nil {
_ = userDB.Close()
_ = walletDB.Close()
return dbs{}, err
}
roomDB, err := open("hyapp_room")
if err != nil {
_ = userDB.Close()
_ = walletDB.Close()
_ = robotDB.Close()
return dbs{}, err
}
return dbs{user: userDB, wallet: walletDB, robot: robotDB, room: roomDB}, nil
}
func (d dbs) close() {
_ = d.user.Close()
_ = d.wallet.Close()
_ = d.robot.Close()
_ = d.room.Close()
}
func seed(ctx context.Context, dbs dbs) error {
now := time.Now().UnixMilli()
ids := robotIDs()
args := make([]any, 0, len(ids)+1)
args = append(args, appCode)
in := make([]string, 0, len(ids))
for _, id := range ids {
in = append(in, "?")
args = append(args, id)
}
idClause := strings.Join(in, ",")
for _, statement := range []struct {
db *sql.DB
sql string
args []any
}{
{dbs.room, "DELETE FROM room_command_log WHERE app_code = ? AND (room_id LIKE 'robot\\_%' OR command_id LIKE 'admin-robot-room:%')", []any{appCode}},
{dbs.room, "DELETE FROM room_snapshots WHERE app_code = ? AND room_id LIKE 'robot\\_%'", []any{appCode}},
{dbs.room, "DELETE FROM room_outbox WHERE app_code = ? AND room_id LIKE 'robot\\_%'", []any{appCode}},
{dbs.room, "DELETE FROM room_robot_rooms WHERE app_code = ? AND owner_robot_user_id IN (" + idClause + ")", args},
{dbs.room, "DELETE FROM rooms WHERE app_code = ? AND owner_user_id IN (" + idClause + ")", args},
{dbs.room, "DELETE FROM room_list_entries WHERE app_code = ? AND owner_user_id IN (" + idClause + ")", args},
{dbs.room, "DELETE FROM room_user_presence WHERE app_code = ? AND user_id IN (" + idClause + ")", args},
} {
if _, err := statement.db.ExecContext(ctx, statement.sql, statement.args...); err != nil {
return err
}
}
for _, id := range ids {
display := strconv.FormatInt(id, 10)
if _, err := dbs.user.ExecContext(ctx, `
INSERT INTO users (
app_code, user_id, default_display_user_id, current_display_user_id, current_display_user_id_kind,
username, gender, country, region_id, avatar, profile_completed, onboarding_status, status,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 'default', ?, 'unknown', 'SA', 0, '', 1, 'completed', 'active', ?, ?)
ON DUPLICATE KEY UPDATE
username = VALUES(username),
current_display_user_id = VALUES(current_display_user_id),
status = 'active',
updated_at_ms = VALUES(updated_at_ms)`,
appCode, id, display, display, fmt.Sprintf("机器人%d", id), now, now,
); err != nil {
return err
}
if _, err := dbs.robot.ExecContext(ctx, `
INSERT INTO robot_room_robots (
app_code, room_scene, user_id, status, created_by_admin_id, created_at_ms, updated_at_ms, last_used_at_ms, used_count
) VALUES (?, ?, ?, 'active', 1, ?, ?, 0, 0)
ON DUPLICATE KEY UPDATE status = 'active', updated_at_ms = VALUES(updated_at_ms)`,
appCode, roomScene, id, now, now,
); err != nil {
return err
}
}
if err := seedGift(ctx, dbs.wallet, normalGiftID, "机器人普通测试礼物", 1001, 1, now); err != nil {
return err
}
if err := seedGift(ctx, dbs.wallet, luckyGiftID, "机器人幸运测试礼物", 1002, 2, now); err != nil {
return err
}
if _, err := dbs.wallet.ExecContext(ctx, `
INSERT INTO gift_diamond_ratio_configs (
app_code, region_id, gift_type_code, status, ratio_percent, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, 0, 'normal', 'active', 100.00, 1, 1, ?, ?)
ON DUPLICATE KEY UPDATE status = 'active', ratio_percent = VALUES(ratio_percent), updated_at_ms = VALUES(updated_at_ms)`,
appCode, now, now,
); err != nil {
return err
}
return nil
}
func seedGift(ctx context.Context, db *sql.DB, giftID string, name string, resourceID int64, coinPrice int64, now int64) error {
if _, err := db.ExecContext(ctx, `
INSERT INTO resources (
resource_id, app_code, resource_code, resource_type, name, status, grantable, manager_grant_enabled,
grant_strategy, wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount,
usage_scope_json, asset_url, preview_url, animation_url, metadata_json, sort_order,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 'gift', ?, 'active', true, false, 'none', '', 0, 'coin', ?, 0, '{}', '', '', '', '{}', 0, 1, 1, ?, ?)
ON DUPLICATE KEY UPDATE name = VALUES(name), status = 'active', coin_price = VALUES(coin_price), updated_at_ms = VALUES(updated_at_ms)`,
resourceID, appCode, giftID+"_resource", name, coinPrice, now, now,
); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
INSERT INTO gift_type_configs (
app_code, type_code, name, tab_key, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, 'normal', '普通礼物', 'normal', 'active', 0, 1, 1, ?, ?)
ON DUPLICATE KEY UPDATE status = 'active', updated_at_ms = VALUES(updated_at_ms)`,
appCode, now, now,
); err != nil {
return err
}
if _, err := db.ExecContext(ctx, `
INSERT INTO gift_configs (
app_code, gift_id, resource_id, status, name, sort_order, presentation_json, gift_type_code,
effective_from_ms, effective_to_ms, effect_types_json, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 'active', ?, 0, '{}', 'normal', 0, 0, '["global_broadcast"]', 1, 1, ?, ?)
ON DUPLICATE KEY UPDATE status = 'active', name = VALUES(name), resource_id = VALUES(resource_id), updated_at_ms = VALUES(updated_at_ms)`,
appCode, giftID, resourceID, name, now, now,
); err != nil {
return err
}
_, err := db.ExecContext(ctx, `
INSERT INTO wallet_gift_prices (
app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount, heat_value,
effective_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, 'robot_room_test_v1', 'active', 'COIN', ?, 0, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE status = 'active', coin_price = VALUES(coin_price), heat_value = VALUES(heat_value), updated_at_ms = VALUES(updated_at_ms)`,
appCode, giftID, coinPrice, coinPrice, now, now,
)
return err
}
func verifyDB(ctx context.Context, dbs dbs, roomID string) error {
var status string
var owner int64
var rawRobots string
err := dbs.room.QueryRowContext(ctx, `
SELECT status, owner_robot_user_id, CAST(robot_user_ids_json AS CHAR)
FROM room_robot_rooms
WHERE app_code = ? AND room_id = ?`,
appCode, roomID,
).Scan(&status, &owner, &rawRobots)
if err != nil {
return err
}
if status != "stopped" {
return fmt.Errorf("db status = %s, want stopped", status)
}
if owner != robotUserStart {
return fmt.Errorf("db owner = %d, want %d", owner, robotUserStart)
}
var robotIDs []int64
if err := json.Unmarshal([]byte(rawRobots), &robotIDs); err != nil {
return err
}
if len(robotIDs) < 6 || len(robotIDs) > 8 {
return fmt.Errorf("db robot count = %d, want 6-8", len(robotIDs))
}
var realGiftDebits int64
if err := dbs.wallet.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM wallet_transactions
WHERE app_code = ? AND biz_type = 'gift_debit' AND JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.room_id')) = ?`,
appCode, roomID,
).Scan(&realGiftDebits); err != nil {
return err
}
if realGiftDebits != 0 {
return fmt.Errorf("real gift debit rows for robot room = %d, want 0", realGiftDebits)
}
return nil
}
func login(ctx context.Context, client *http.Client, baseURL string, username string, password string) (string, error) {
data, err := postJSON[loginData](ctx, client, baseURL+"/auth/login", "", map[string]string{
"username": username,
"password": password,
})
if err != nil {
return "", err
}
if strings.TrimSpace(data.AccessToken) == "" {
return "", errors.New("login returned empty access token")
}
return data.AccessToken, nil
}
func getJSON[T any](ctx context.Context, client *http.Client, url string, token string) (T, error) {
var zero T
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return zero, err
}
return doJSON[T](client, req, token)
}
func postJSON[T any](ctx context.Context, client *http.Client, url string, token string, payload any) (T, error) {
var zero T
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return zero, err
}
body = bytes.NewReader(raw)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body)
if err != nil {
return zero, err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
return doJSON[T](client, req, token)
}
func doJSON[T any](client *http.Client, req *http.Request, token string) (T, error) {
var zero T
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := client.Do(req)
if err != nil {
return zero, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return zero, fmt.Errorf("%s %s: status %d body %s", req.Method, req.URL.Path, resp.StatusCode, strings.TrimSpace(string(raw)))
}
var wrapped apiBody
if err := json.Unmarshal(raw, &wrapped); err != nil {
return zero, err
}
if wrapped.Code != 0 {
return zero, fmt.Errorf("%s %s: code %d message %s", req.Method, req.URL.Path, wrapped.Code, wrapped.Message)
}
var out T
if len(wrapped.Data) == 0 || string(wrapped.Data) == "null" {
return out, nil
}
if err := json.Unmarshal(wrapped.Data, &out); err != nil {
return zero, err
}
return out, nil
}
func robotIDs() []int64 {
ids := make([]int64, 0, robotUserCount)
for i := 0; i < robotUserCount; i++ {
ids = append(ids, robotUserStart+int64(i))
}
return ids
}
func containsRoom(items []robotRoom, roomID string) bool {
for _, item := range items {
if item.RoomID == roomID {
return true
}
}
return false
}
func env(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func fail(err error) {
fmt.Fprintln(os.Stderr, "FAIL:", err)
os.Exit(1)
}