完善房间状态
This commit is contained in:
parent
cf9fc2c9aa
commit
975d21a63a
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v5.29.2
|
||||
// protoc v7.34.1
|
||||
// source: proto/events/room/v1/events.proto
|
||||
|
||||
package roomeventsv1
|
||||
@ -318,6 +318,62 @@ func (x *RoomUserLeft) GetUserId() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// RoomClosed 表达房间生命周期关闭完成。
|
||||
type RoomClosed struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ActorUserId int64 `protobuf:"varint,1,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"`
|
||||
Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
func (x *RoomClosed) Reset() {
|
||||
*x = RoomClosed{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *RoomClosed) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*RoomClosed) ProtoMessage() {}
|
||||
|
||||
func (x *RoomClosed) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use RoomClosed.ProtoReflect.Descriptor instead.
|
||||
func (*RoomClosed) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *RoomClosed) GetActorUserId() int64 {
|
||||
if x != nil {
|
||||
return x.ActorUserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *RoomClosed) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RoomMicChanged 表达任意上下麦和换麦位动作。
|
||||
type RoomMicChanged struct {
|
||||
state protoimpl.MessageState
|
||||
@ -342,7 +398,7 @@ type RoomMicChanged struct {
|
||||
func (x *RoomMicChanged) Reset() {
|
||||
*x = RoomMicChanged{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -355,7 +411,7 @@ func (x *RoomMicChanged) String() string {
|
||||
func (*RoomMicChanged) ProtoMessage() {}
|
||||
|
||||
func (x *RoomMicChanged) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[4]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -368,7 +424,7 @@ func (x *RoomMicChanged) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RoomMicChanged.ProtoReflect.Descriptor instead.
|
||||
func (*RoomMicChanged) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{4}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *RoomMicChanged) GetActorUserId() int64 {
|
||||
@ -455,7 +511,7 @@ type RoomMicSeatLocked struct {
|
||||
func (x *RoomMicSeatLocked) Reset() {
|
||||
*x = RoomMicSeatLocked{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -468,7 +524,7 @@ func (x *RoomMicSeatLocked) String() string {
|
||||
func (*RoomMicSeatLocked) ProtoMessage() {}
|
||||
|
||||
func (x *RoomMicSeatLocked) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[5]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -481,7 +537,7 @@ func (x *RoomMicSeatLocked) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RoomMicSeatLocked.ProtoReflect.Descriptor instead.
|
||||
func (*RoomMicSeatLocked) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{5}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *RoomMicSeatLocked) GetActorUserId() int64 {
|
||||
@ -518,7 +574,7 @@ type RoomChatEnabledChanged struct {
|
||||
func (x *RoomChatEnabledChanged) Reset() {
|
||||
*x = RoomChatEnabledChanged{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -531,7 +587,7 @@ func (x *RoomChatEnabledChanged) String() string {
|
||||
func (*RoomChatEnabledChanged) ProtoMessage() {}
|
||||
|
||||
func (x *RoomChatEnabledChanged) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[6]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -544,7 +600,7 @@ func (x *RoomChatEnabledChanged) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RoomChatEnabledChanged.ProtoReflect.Descriptor instead.
|
||||
func (*RoomChatEnabledChanged) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{6}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *RoomChatEnabledChanged) GetActorUserId() int64 {
|
||||
@ -575,7 +631,7 @@ type RoomAdminChanged struct {
|
||||
func (x *RoomAdminChanged) Reset() {
|
||||
*x = RoomAdminChanged{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -588,7 +644,7 @@ func (x *RoomAdminChanged) String() string {
|
||||
func (*RoomAdminChanged) ProtoMessage() {}
|
||||
|
||||
func (x *RoomAdminChanged) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[7]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -601,7 +657,7 @@ func (x *RoomAdminChanged) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RoomAdminChanged.ProtoReflect.Descriptor instead.
|
||||
func (*RoomAdminChanged) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{7}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *RoomAdminChanged) GetActorUserId() int64 {
|
||||
@ -639,7 +695,7 @@ type RoomHostTransferred struct {
|
||||
func (x *RoomHostTransferred) Reset() {
|
||||
*x = RoomHostTransferred{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -652,7 +708,7 @@ func (x *RoomHostTransferred) String() string {
|
||||
func (*RoomHostTransferred) ProtoMessage() {}
|
||||
|
||||
func (x *RoomHostTransferred) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[8]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -665,7 +721,7 @@ func (x *RoomHostTransferred) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RoomHostTransferred.ProtoReflect.Descriptor instead.
|
||||
func (*RoomHostTransferred) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{8}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *RoomHostTransferred) GetActorUserId() int64 {
|
||||
@ -703,7 +759,7 @@ type RoomUserMuted struct {
|
||||
func (x *RoomUserMuted) Reset() {
|
||||
*x = RoomUserMuted{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -716,7 +772,7 @@ func (x *RoomUserMuted) String() string {
|
||||
func (*RoomUserMuted) ProtoMessage() {}
|
||||
|
||||
func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[9]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -729,7 +785,7 @@ func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RoomUserMuted.ProtoReflect.Descriptor instead.
|
||||
func (*RoomUserMuted) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{9}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *RoomUserMuted) GetActorUserId() int64 {
|
||||
@ -766,7 +822,7 @@ type RoomUserKicked struct {
|
||||
func (x *RoomUserKicked) Reset() {
|
||||
*x = RoomUserKicked{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -779,7 +835,7 @@ func (x *RoomUserKicked) String() string {
|
||||
func (*RoomUserKicked) ProtoMessage() {}
|
||||
|
||||
func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[10]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -792,7 +848,7 @@ func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RoomUserKicked.ProtoReflect.Descriptor instead.
|
||||
func (*RoomUserKicked) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{10}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *RoomUserKicked) GetActorUserId() int64 {
|
||||
@ -822,7 +878,7 @@ type RoomUserUnbanned struct {
|
||||
func (x *RoomUserUnbanned) Reset() {
|
||||
*x = RoomUserUnbanned{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -835,7 +891,7 @@ func (x *RoomUserUnbanned) String() string {
|
||||
func (*RoomUserUnbanned) ProtoMessage() {}
|
||||
|
||||
func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -848,7 +904,7 @@ func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RoomUserUnbanned.ProtoReflect.Descriptor instead.
|
||||
func (*RoomUserUnbanned) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{11}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *RoomUserUnbanned) GetActorUserId() int64 {
|
||||
@ -882,7 +938,7 @@ type RoomGiftSent struct {
|
||||
func (x *RoomGiftSent) Reset() {
|
||||
*x = RoomGiftSent{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -895,7 +951,7 @@ func (x *RoomGiftSent) String() string {
|
||||
func (*RoomGiftSent) ProtoMessage() {}
|
||||
|
||||
func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -908,7 +964,7 @@ func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RoomGiftSent.ProtoReflect.Descriptor instead.
|
||||
func (*RoomGiftSent) Descriptor() ([]byte, []int) {
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{12}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *RoomGiftSent) GetSenderUserId() int64 {
|
||||
@ -966,7 +1022,7 @@ type RoomHeatChanged struct {
|
||||
func (x *RoomHeatChanged) Reset() {
|
||||
*x = RoomHeatChanged{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -979,7 +1035,7 @@ func (x *RoomHeatChanged) String() string {
|
||||
func (*RoomHeatChanged) ProtoMessage() {}
|
||||
|
||||
func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -992,7 +1048,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{13}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *RoomHeatChanged) GetDelta() int64 {
|
||||
@ -1023,7 +1079,7 @@ type RoomRankChanged struct {
|
||||
func (x *RoomRankChanged) Reset() {
|
||||
*x = RoomRankChanged{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1036,7 +1092,7 @@ func (x *RoomRankChanged) String() string {
|
||||
func (*RoomRankChanged) ProtoMessage() {}
|
||||
|
||||
func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
|
||||
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1049,7 +1105,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{14}
|
||||
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *RoomRankChanged) GetUserId() int64 {
|
||||
@ -1115,105 +1171,110 @@ var file_proto_events_room_v1_events_proto_rawDesc = []byte{
|
||||
0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x22, 0x27, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x55,
|
||||
0x73, 0x65, 0x72, 0x4c, 0x65, 0x66, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64,
|
||||
0x22, 0xee, 0x02, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x43, 0x68, 0x61, 0x6e,
|
||||
0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65,
|
||||
0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f,
|
||||
0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65,
|
||||
0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52,
|
||||
0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a,
|
||||
0x09, 0x66, 0x72, 0x6f, 0x6d, 0x5f, 0x73, 0x65, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05,
|
||||
0x52, 0x08, 0x66, 0x72, 0x6f, 0x6d, 0x53, 0x65, 0x61, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x6f,
|
||||
0x5f, 0x73, 0x65, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x74, 0x6f, 0x53,
|
||||
0x65, 0x61, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x6d,
|
||||
0x69, 0x63, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49,
|
||||
0x64, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x73, 0x74, 0x61,
|
||||
0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73,
|
||||
0x68, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e,
|
||||
0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x2e,
|
||||
0x0a, 0x13, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x64, 0x65, 0x61, 0x64, 0x6c, 0x69,
|
||||
0x6e, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x70, 0x75, 0x62,
|
||||
0x6c, 0x69, 0x73, 0x68, 0x44, 0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x73, 0x12, 0x31,
|
||||
0x0a, 0x15, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f,
|
||||
0x74, 0x69, 0x6d, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x70,
|
||||
0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x4d,
|
||||
0x73, 0x22, 0x68, 0x0a, 0x11, 0x52, 0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x53, 0x65, 0x61, 0x74,
|
||||
0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f,
|
||||
0x22, 0x48, 0x0a, 0x0a, 0x52, 0x6f, 0x6f, 0x6d, 0x43, 0x6c, 0x6f, 0x73, 0x65, 0x64, 0x12, 0x22,
|
||||
0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72,
|
||||
0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0xee, 0x02, 0x0a, 0x0e, 0x52,
|
||||
0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a,
|
||||
0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49,
|
||||
0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65,
|
||||
0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x72, 0x6f, 0x6d, 0x5f,
|
||||
0x73, 0x65, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x66, 0x72, 0x6f, 0x6d,
|
||||
0x53, 0x65, 0x61, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x6f, 0x5f, 0x73, 0x65, 0x61, 0x74, 0x18,
|
||||
0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x74, 0x6f, 0x53, 0x65, 0x61, 0x74, 0x12, 0x16, 0x0a,
|
||||
0x06, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61,
|
||||
0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x6d, 0x69, 0x63, 0x5f, 0x73, 0x65, 0x73,
|
||||
0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6d,
|
||||
0x69, 0x63, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x70,
|
||||
0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0c, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x53, 0x74, 0x61, 0x74, 0x65,
|
||||
0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x2e, 0x0a, 0x13, 0x70, 0x75, 0x62, 0x6c,
|
||||
0x69, 0x73, 0x68, 0x5f, 0x64, 0x65, 0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x5f, 0x6d, 0x73, 0x18,
|
||||
0x09, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68, 0x44, 0x65,
|
||||
0x61, 0x64, 0x6c, 0x69, 0x6e, 0x65, 0x4d, 0x73, 0x12, 0x31, 0x0a, 0x15, 0x70, 0x75, 0x62, 0x6c,
|
||||
0x69, 0x73, 0x68, 0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x6d,
|
||||
0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x73, 0x68,
|
||||
0x45, 0x76, 0x65, 0x6e, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x22, 0x68, 0x0a, 0x11, 0x52,
|
||||
0x6f, 0x6f, 0x6d, 0x4d, 0x69, 0x63, 0x53, 0x65, 0x61, 0x74, 0x4c, 0x6f, 0x63, 0x6b, 0x65, 0x64,
|
||||
0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73,
|
||||
0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x65, 0x61, 0x74, 0x5f, 0x6e, 0x6f, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x65, 0x61, 0x74, 0x4e, 0x6f, 0x12, 0x16, 0x0a,
|
||||
0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x6c,
|
||||
0x6f, 0x63, 0x6b, 0x65, 0x64, 0x22, 0x56, 0x0a, 0x16, 0x52, 0x6f, 0x6f, 0x6d, 0x43, 0x68, 0x61,
|
||||
0x74, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12,
|
||||
0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
|
||||
0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x76, 0x0a,
|
||||
0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65,
|
||||
0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55,
|
||||
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f,
|
||||
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74,
|
||||
0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65,
|
||||
0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e,
|
||||
0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x8b, 0x01, 0x0a, 0x13, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x6f,
|
||||
0x73, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x12, 0x22, 0x0a,
|
||||
0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49,
|
||||
0x64, 0x12, 0x27, 0x0a, 0x10, 0x6f, 0x6c, 0x64, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73,
|
||||
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6f, 0x6c, 0x64,
|
||||
0x48, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x10, 0x6e, 0x65,
|
||||
0x77, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6e, 0x65, 0x77, 0x48, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65,
|
||||
0x72, 0x49, 0x64, 0x22, 0x6f, 0x0a, 0x0d, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4d,
|
||||
0x75, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73,
|
||||
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74,
|
||||
0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
|
||||
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
|
||||
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14,
|
||||
0x0a, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d,
|
||||
0x75, 0x74, 0x65, 0x64, 0x22, 0x5a, 0x0a, 0x0e, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72,
|
||||
0x4b, 0x69, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f,
|
||||
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61,
|
||||
0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x73, 0x65,
|
||||
0x61, 0x74, 0x5f, 0x6e, 0x6f, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x73, 0x65, 0x61,
|
||||
0x74, 0x4e, 0x6f, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x03, 0x20,
|
||||
0x01, 0x28, 0x08, 0x52, 0x06, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x22, 0x56, 0x0a, 0x16, 0x52,
|
||||
0x6f, 0x6f, 0x6d, 0x43, 0x68, 0x61, 0x74, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x43, 0x68,
|
||||
0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75,
|
||||
0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63,
|
||||
0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61,
|
||||
0x62, 0x6c, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62,
|
||||
0x6c, 0x65, 0x64, 0x22, 0x76, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x41, 0x64, 0x6d, 0x69, 0x6e,
|
||||
0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72,
|
||||
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b,
|
||||
0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74,
|
||||
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
|
||||
0x64, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x8b, 0x01, 0x0a, 0x13,
|
||||
0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x6f, 0x73, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72,
|
||||
0x72, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65,
|
||||
0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f,
|
||||
0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x27, 0x0a, 0x10, 0x6f, 0x6c, 0x64, 0x5f, 0x68,
|
||||
0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x0d, 0x6f, 0x6c, 0x64, 0x48, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
|
||||
0x12, 0x27, 0x0a, 0x10, 0x6e, 0x65, 0x77, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x65,
|
||||
0x72, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6e, 0x65, 0x77, 0x48,
|
||||
0x6f, 0x73, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x6f, 0x0a, 0x0d, 0x52, 0x6f, 0x6f,
|
||||
0x6d, 0x55, 0x73, 0x65, 0x72, 0x4d, 0x75, 0x74, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63,
|
||||
0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24,
|
||||
0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73,
|
||||
0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x18, 0x03, 0x20,
|
||||
0x01, 0x28, 0x08, 0x52, 0x05, 0x6d, 0x75, 0x74, 0x65, 0x64, 0x22, 0x5a, 0x0a, 0x0e, 0x52, 0x6f,
|
||||
0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x4b, 0x69, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d,
|
||||
0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
|
||||
0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74,
|
||||
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0x5c, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73,
|
||||
0x65, 0x72, 0x55, 0x6e, 0x62, 0x61, 0x6e, 0x6e, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63,
|
||||
0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x0b, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24,
|
||||
0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73,
|
||||
0x65, 0x72, 0x49, 0x64, 0x22, 0xdf, 0x01, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66,
|
||||
0x74, 0x53, 0x65, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f,
|
||||
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73,
|
||||
0x65, 0x6e, 0x64, 0x65, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74,
|
||||
0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49,
|
||||
0x64, 0x12, 0x17, 0x0a, 0x07, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x06, 0x67, 0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69,
|
||||
0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09,
|
||||
0x67, 0x69, 0x66, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66,
|
||||
0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67,
|
||||
0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c,
|
||||
0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63,
|
||||
0x65, 0x69, 0x70, 0x74, 0x49, 0x64, 0x22, 0x4a, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65,
|
||||
0x61, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c,
|
||||
0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12,
|
||||
0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65,
|
||||
0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68,
|
||||
0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14,
|
||||
0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73,
|
||||
0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c,
|
||||
0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61,
|
||||
0x6c, 0x75, 0x65, 0x42, 0x33, 0x5a, 0x31, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63,
|
||||
0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65,
|
||||
0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f, 0x6f, 0x6d, 0x2f, 0x76, 0x31, 0x3b, 0x72, 0x6f, 0x6f, 0x6d,
|
||||
0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x63, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61,
|
||||
0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
|
||||
0x22, 0x5c, 0x0a, 0x10, 0x52, 0x6f, 0x6f, 0x6d, 0x55, 0x73, 0x65, 0x72, 0x55, 0x6e, 0x62, 0x61,
|
||||
0x6e, 0x6e, 0x65, 0x64, 0x12, 0x22, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73,
|
||||
0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x61, 0x63, 0x74,
|
||||
0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67,
|
||||
0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
|
||||
0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x22, 0xdf,
|
||||
0x01, 0x0a, 0x0c, 0x52, 0x6f, 0x6f, 0x6d, 0x47, 0x69, 0x66, 0x74, 0x53, 0x65, 0x6e, 0x74, 0x12,
|
||||
0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x55,
|
||||
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f,
|
||||
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74,
|
||||
0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x67,
|
||||
0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67, 0x69,
|
||||
0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43, 0x6f,
|
||||
0x75, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75,
|
||||
0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c,
|
||||
0x75, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65,
|
||||
0x63, 0x65, 0x69, 0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10,
|
||||
0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64,
|
||||
0x22, 0x4a, 0x0a, 0x0f, 0x52, 0x6f, 0x6f, 0x6d, 0x48, 0x65, 0x61, 0x74, 0x43, 0x68, 0x61, 0x6e,
|
||||
0x67, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x03, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x75, 0x72,
|
||||
0x72, 0x65, 0x6e, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52,
|
||||
0x0b, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x48, 0x65, 0x61, 0x74, 0x22, 0x5f, 0x0a, 0x0f,
|
||||
0x52, 0x6f, 0x6f, 0x6d, 0x52, 0x61, 0x6e, 0x6b, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12,
|
||||
0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03,
|
||||
0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x63, 0x6f, 0x72,
|
||||
0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x05, 0x73, 0x63, 0x6f, 0x72, 0x65, 0x12, 0x1d,
|
||||
0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x03, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x33, 0x5a,
|
||||
0x31, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69,
|
||||
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x2f, 0x72, 0x6f,
|
||||
0x6f, 0x6d, 0x2f, 0x76, 0x31, 0x3b, 0x72, 0x6f, 0x6f, 0x6d, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x73,
|
||||
0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
@ -1228,23 +1289,24 @@ 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, 15)
|
||||
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
|
||||
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
|
||||
(*RoomUserJoined)(nil), // 2: hyapp.events.room.v1.RoomUserJoined
|
||||
(*RoomUserLeft)(nil), // 3: hyapp.events.room.v1.RoomUserLeft
|
||||
(*RoomMicChanged)(nil), // 4: hyapp.events.room.v1.RoomMicChanged
|
||||
(*RoomMicSeatLocked)(nil), // 5: hyapp.events.room.v1.RoomMicSeatLocked
|
||||
(*RoomChatEnabledChanged)(nil), // 6: hyapp.events.room.v1.RoomChatEnabledChanged
|
||||
(*RoomAdminChanged)(nil), // 7: hyapp.events.room.v1.RoomAdminChanged
|
||||
(*RoomHostTransferred)(nil), // 8: hyapp.events.room.v1.RoomHostTransferred
|
||||
(*RoomUserMuted)(nil), // 9: hyapp.events.room.v1.RoomUserMuted
|
||||
(*RoomUserKicked)(nil), // 10: hyapp.events.room.v1.RoomUserKicked
|
||||
(*RoomUserUnbanned)(nil), // 11: hyapp.events.room.v1.RoomUserUnbanned
|
||||
(*RoomGiftSent)(nil), // 12: hyapp.events.room.v1.RoomGiftSent
|
||||
(*RoomHeatChanged)(nil), // 13: hyapp.events.room.v1.RoomHeatChanged
|
||||
(*RoomRankChanged)(nil), // 14: hyapp.events.room.v1.RoomRankChanged
|
||||
(*RoomClosed)(nil), // 4: hyapp.events.room.v1.RoomClosed
|
||||
(*RoomMicChanged)(nil), // 5: hyapp.events.room.v1.RoomMicChanged
|
||||
(*RoomMicSeatLocked)(nil), // 6: hyapp.events.room.v1.RoomMicSeatLocked
|
||||
(*RoomChatEnabledChanged)(nil), // 7: hyapp.events.room.v1.RoomChatEnabledChanged
|
||||
(*RoomAdminChanged)(nil), // 8: hyapp.events.room.v1.RoomAdminChanged
|
||||
(*RoomHostTransferred)(nil), // 9: hyapp.events.room.v1.RoomHostTransferred
|
||||
(*RoomUserMuted)(nil), // 10: hyapp.events.room.v1.RoomUserMuted
|
||||
(*RoomUserKicked)(nil), // 11: hyapp.events.room.v1.RoomUserKicked
|
||||
(*RoomUserUnbanned)(nil), // 12: hyapp.events.room.v1.RoomUserUnbanned
|
||||
(*RoomGiftSent)(nil), // 13: hyapp.events.room.v1.RoomGiftSent
|
||||
(*RoomHeatChanged)(nil), // 14: hyapp.events.room.v1.RoomHeatChanged
|
||||
(*RoomRankChanged)(nil), // 15: hyapp.events.room.v1.RoomRankChanged
|
||||
}
|
||||
var file_proto_events_room_v1_events_proto_depIdxs = []int32{
|
||||
0, // [0:0] is the sub-list for method output_type
|
||||
@ -1309,7 +1371,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomMicChanged); i {
|
||||
switch v := v.(*RoomClosed); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1321,7 +1383,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[5].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomMicSeatLocked); i {
|
||||
switch v := v.(*RoomMicChanged); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1333,7 +1395,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomChatEnabledChanged); i {
|
||||
switch v := v.(*RoomMicSeatLocked); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1345,7 +1407,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[7].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomAdminChanged); i {
|
||||
switch v := v.(*RoomChatEnabledChanged); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1357,7 +1419,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[8].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomHostTransferred); i {
|
||||
switch v := v.(*RoomAdminChanged); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1369,7 +1431,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[9].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomUserMuted); i {
|
||||
switch v := v.(*RoomHostTransferred); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1381,7 +1443,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[10].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomUserKicked); i {
|
||||
switch v := v.(*RoomUserMuted); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1393,7 +1455,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[11].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomUserUnbanned); i {
|
||||
switch v := v.(*RoomUserKicked); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1405,7 +1467,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[12].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomGiftSent); i {
|
||||
switch v := v.(*RoomUserUnbanned); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1417,7 +1479,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[13].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomHeatChanged); i {
|
||||
switch v := v.(*RoomGiftSent); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
@ -1429,6 +1491,18 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[14].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomHeatChanged); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_proto_events_room_v1_events_proto_msgTypes[15].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RoomRankChanged); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
@ -1447,7 +1521,7 @@ func file_proto_events_room_v1_events_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_proto_events_room_v1_events_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 15,
|
||||
NumMessages: 16,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@ -39,6 +39,12 @@ message RoomUserLeft {
|
||||
int64 user_id = 1;
|
||||
}
|
||||
|
||||
// RoomClosed 表达房间生命周期关闭完成。
|
||||
message RoomClosed {
|
||||
int64 actor_user_id = 1;
|
||||
string reason = 2;
|
||||
}
|
||||
|
||||
// RoomMicChanged 表达任意上下麦和换麦位动作。
|
||||
message RoomMicChanged {
|
||||
int64 actor_user_id = 1;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -144,6 +144,18 @@ message LeaveRoomResponse {
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// CloseRoomRequest 关闭房间并清理业务 presence 和麦位。
|
||||
message CloseRoomRequest {
|
||||
RequestMeta meta = 1;
|
||||
string reason = 2;
|
||||
}
|
||||
|
||||
// CloseRoomResponse 返回关闭后的房间快照。
|
||||
message CloseRoomResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// MicUpRequest 申请把用户放到指定麦位。
|
||||
message MicUpRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -401,6 +413,7 @@ service RoomCommandService {
|
||||
rpc JoinRoom(JoinRoomRequest) returns (JoinRoomResponse);
|
||||
rpc RoomHeartbeat(RoomHeartbeatRequest) returns (RoomHeartbeatResponse);
|
||||
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
|
||||
rpc CloseRoom(CloseRoomRequest) returns (CloseRoomResponse);
|
||||
rpc MicUp(MicUpRequest) returns (MicUpResponse);
|
||||
rpc MicDown(MicDownRequest) returns (MicDownResponse);
|
||||
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
|
||||
|
||||
@ -23,6 +23,7 @@ const (
|
||||
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
|
||||
RoomCommandService_RoomHeartbeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/RoomHeartbeat"
|
||||
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
|
||||
RoomCommandService_CloseRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CloseRoom"
|
||||
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
|
||||
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
|
||||
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
|
||||
@ -48,6 +49,7 @@ type RoomCommandServiceClient interface {
|
||||
JoinRoom(ctx context.Context, in *JoinRoomRequest, opts ...grpc.CallOption) (*JoinRoomResponse, error)
|
||||
RoomHeartbeat(ctx context.Context, in *RoomHeartbeatRequest, opts ...grpc.CallOption) (*RoomHeartbeatResponse, error)
|
||||
LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error)
|
||||
CloseRoom(ctx context.Context, in *CloseRoomRequest, opts ...grpc.CallOption) (*CloseRoomResponse, 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)
|
||||
@ -111,6 +113,16 @@ func (c *roomCommandServiceClient) LeaveRoom(ctx context.Context, in *LeaveRoomR
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) CloseRoom(ctx context.Context, in *CloseRoomRequest, opts ...grpc.CallOption) (*CloseRoomResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CloseRoomResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_CloseRoom_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)
|
||||
@ -251,6 +263,7 @@ type RoomCommandServiceServer interface {
|
||||
JoinRoom(context.Context, *JoinRoomRequest) (*JoinRoomResponse, error)
|
||||
RoomHeartbeat(context.Context, *RoomHeartbeatRequest) (*RoomHeartbeatResponse, error)
|
||||
LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error)
|
||||
CloseRoom(context.Context, *CloseRoomRequest) (*CloseRoomResponse, error)
|
||||
MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error)
|
||||
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
|
||||
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
|
||||
@ -286,6 +299,9 @@ func (UnimplementedRoomCommandServiceServer) RoomHeartbeat(context.Context, *Roo
|
||||
func (UnimplementedRoomCommandServiceServer) LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method LeaveRoom not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) CloseRoom(context.Context, *CloseRoomRequest) (*CloseRoomResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CloseRoom not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method MicUp not implemented")
|
||||
}
|
||||
@ -418,6 +434,24 @@ func _RoomCommandService_LeaveRoom_Handler(srv interface{}, ctx context.Context,
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_CloseRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CloseRoomRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).CloseRoom(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_CloseRoom_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).CloseRoom(ctx, req.(*CloseRoomRequest))
|
||||
}
|
||||
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 {
|
||||
@ -675,6 +709,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "LeaveRoom",
|
||||
Handler: _RoomCommandService_LeaveRoom_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CloseRoom",
|
||||
Handler: _RoomCommandService_CloseRoom_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "MicUp",
|
||||
Handler: _RoomCommandService_MicUp_Handler,
|
||||
|
||||
127
docs/room-cell-state-machines.md
Normal file
127
docs/room-cell-state-machines.md
Normal file
@ -0,0 +1,127 @@
|
||||
# Room Cell State Machines
|
||||
|
||||
本文档定义 `room-service` 在微服务多机部署下必须遵守的 Room Cell 状态机。Room Cell 的目标是让同一个房间在同一时间只有一个执行节点提交状态,并且在节点重启、发布、RTC 回调延迟和 outbox 重试时不丢状态、不双写、不重复扣费。
|
||||
|
||||
## Room
|
||||
|
||||
Room 是房间生命周期的主状态。只有 `active` 房间允许进房、麦位、管理和送礼命令。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> creating
|
||||
creating --> active: CreateRoom 持久化成功
|
||||
active --> closing: CloseRoom 开始收尾
|
||||
closing --> closed: command log/outbox/meta 提交
|
||||
active --> closed: CloseRoom 当前实现一次提交完成
|
||||
closed --> [*]
|
||||
```
|
||||
|
||||
状态语义:
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| `creating` | 正在创建房间外部依赖,不对客户端开放 |
|
||||
| `active` | 可进房、可管理、可上麦、可送礼 |
|
||||
| `closing` | 正在关闭,不再接收新业务命令 |
|
||||
| `closed` | 已关闭,`JoinRoom` 和 guard 查询都必须拒绝 |
|
||||
|
||||
`CloseRoom` 必须作为 Room Cell 命令提交,不能只从后台直接更新 `rooms.status`。关闭房间时必须清空业务 presence、释放麦位、写 command log、写 `RoomClosed` outbox、更新 `rooms` 和 `room_list_entries` 状态。
|
||||
|
||||
## Presence
|
||||
|
||||
Presence 是 room-service 自己维护的业务进房状态,不等同于腾讯云 IM 或 RTC 的真实连接。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> absent
|
||||
absent --> present: JoinRoom
|
||||
present --> present: JoinRoom 重试或 RoomHeartbeat 刷新 last_seen_at_ms
|
||||
present --> left: LeaveRoom
|
||||
present --> stale_left: stale worker 超时清理
|
||||
present --> kicked: KickUser
|
||||
present --> closed_left: CloseRoom
|
||||
left --> present: JoinRoom
|
||||
stale_left --> present: JoinRoom
|
||||
kicked --> present: UnbanUser 后重新 JoinRoom
|
||||
closed_left --> [*]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `RoomHeartbeat` 只能刷新已有 presence,不能创建 presence。
|
||||
- `LeaveRoom` 要同步释放用户占用的麦位。
|
||||
- RTC `room_exited` 可以清理 presence,但如果用户已经在事件时间之后重新刷新 presence,旧事件必须忽略。
|
||||
- `KickUser` 写入 ban 集合,用户必须先 `UnbanUser` 才能重新 `JoinRoom`。
|
||||
|
||||
## Mic
|
||||
|
||||
Mic 是麦位和 RTC 发流确认状态。业务上麦不等于已经在 RTC 发流。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> idle
|
||||
idle --> locked: SetMicSeatLock(true)
|
||||
locked --> idle: SetMicSeatLock(false)
|
||||
idle --> pending_publish: MicUp
|
||||
pending_publish --> publishing: ConfirmMicPublishing 或 RTC audio_started
|
||||
pending_publish --> idle: publish_timeout 或 MicDown 或 LeaveRoom
|
||||
publishing --> idle: MicDown 或 RTC audio_stopped 或 LeaveRoom
|
||||
publishing --> pending_publish: ChangeMicSeat 保留 mic_session
|
||||
pending_publish --> pending_publish: ChangeMicSeat 保留 mic_session
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `mic_session_id` 是单次上麦会话 ID,旧会话的确认、停止和超时任务不能清掉新会话。
|
||||
- `mic_session_room_version` 和 `last_publish_event_time_ms` 用于丢弃旧 RTC 事件。
|
||||
- `pending_publish` 超过 `publish_deadline_ms` 后由 worker 自动下麦。
|
||||
- `LeaveRoom` 与 RTC `room_exited` 并发时以业务 presence 的 `last_seen_at_ms` 和当前 `mic_session_id` 为准;旧的 RTC 退房事件不能释放用户重进房后的新麦位会话。
|
||||
- `CloseRoom` 必须释放全部麦位,但保留麦位编号和锁定结构。
|
||||
|
||||
## Lease
|
||||
|
||||
Lease 是多机部署下的房间执行权。Redis 只保存热路由,不是恢复来源;MySQL snapshot + command log 才是恢复来源。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> unowned
|
||||
unowned --> owned_by_node: EnsureOwner
|
||||
owned_by_node --> owned_by_node: 同节点续约并生成新 lease_token
|
||||
owned_by_node --> expired: TTL 到期
|
||||
expired --> owned_by_other_node: 新节点 EnsureOwner
|
||||
owned_by_node --> fenced_out: 提交前 VerifyOwner 失败
|
||||
fenced_out --> [*]
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- Redis runtime route key 统一使用 `app_code + "\0" + room_id`,包括默认 App;不能再使用裸 `room_id` 作为兼容 key。
|
||||
- 命令开始前通过 `EnsureOwner` 获得执行权。
|
||||
- 命令真正写 command log/outbox 前必须用 `lease_token` 再做一次 `VerifyOwner`。
|
||||
- 如果提交前 lease 已经过期或 token 已变化,旧节点必须返回 `UNAVAILABLE`,不能写房间状态。
|
||||
- `room_command_log` 保存 `owner_node_id` 和 `lease_token`,用于排查谁在什么 lease 下提交了命令。
|
||||
- stale presence worker 和 mic publish timeout worker 只能扫描本节点已加载 Cell;清理前必须确认当前 Redis lease 仍属于本节点,并在读完快照后再次用相同 `lease_token` 校验,最后由命令提交链路再做 fencing。
|
||||
- 节点进入 draining 后不能再接收新的 Room Cell 命令,也不能恢复或创建新的 Cell。
|
||||
|
||||
## Outbox
|
||||
|
||||
Outbox 是房间事件对外投递状态机。它允许重复投递,但多 worker 不应该同时抢同一批。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending
|
||||
pending --> delivering: ClaimPendingOutbox
|
||||
retryable --> delivering: ClaimPendingOutbox
|
||||
delivering --> delivered: 投递成功并确认
|
||||
delivering --> retryable: 投递失败或状态未知
|
||||
delivering --> failed: 明确不可重试
|
||||
retryable --> failed: 超过策略上限或人工判定
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- `ClaimPendingOutbox` 必须把事件从 `pending/retryable` 原子改成 `delivering`,并写入 `worker_id` 和 `lock_until`。
|
||||
- 其他 worker 不能抢到未过期的 `delivering` 事件。
|
||||
- 投递成功后才能标记 `delivered`。
|
||||
- 投递失败必须保留 envelope 和 last_error,转入 `retryable` 等待下一轮抢占。
|
||||
- outbox 事件必须带 `app_code`,消费者按 App 隔离幂等。
|
||||
@ -11,7 +11,7 @@
|
||||
| App 生命周期接入 | `room-service` 启动时启动 outbox worker,下线时先停止扫描新批次,再关闭 gRPC/MySQL/Redis |
|
||||
| worker 配置 | 支持启停、扫描间隔、批量大小、单条投递超时和固定间隔重试策略 |
|
||||
| 腾讯云 IM 补偿 | 同步 `PublishRoomEvent` 失败不回滚房间状态,pending outbox 后续补投到腾讯云 IM |
|
||||
| 可验证测试 | 单测覆盖同步失败后状态已提交、outbox pending、worker 成功补投并标记 published |
|
||||
| 可验证测试 | 单测覆盖同步失败后状态已提交、outbox pending、worker 成功补投并标记 delivered |
|
||||
|
||||
本阶段不做:
|
||||
|
||||
@ -29,17 +29,17 @@
|
||||
|
||||
| Component | Current Fact |
|
||||
| --- | --- |
|
||||
| `room-service/internal/room/service/pipeline.go` | 命令成功后先写 command log/outbox,再尝试同步 `PublishRoomEvent`;同步成功 best-effort 标记 `published`,同步失败不回滚状态 |
|
||||
| `room-service/internal/room/service/pipeline.go` | 命令成功后先写 command log/outbox,再尝试同步 `PublishRoomEvent`;同步成功 best-effort 标记 `delivered`,同步失败不回滚状态 |
|
||||
| `room-service/internal/room/service/outbox_worker.go` | 已有 `RunOutboxWorker(ctx, options)` 持续循环和 `ProcessPendingOutbox(ctx, options)` 单批处理 |
|
||||
| `room-service/internal/storage/mysql/repository.go` | 已有 `ListPendingOutbox`、`MarkOutboxPublished`、`MarkOutboxFailed` |
|
||||
| `room-service/internal/storage/mysql/repository.go` | 已有 `ClaimPendingOutbox`、`MarkOutboxDelivered`、`MarkOutboxFailed` |
|
||||
| `room-service/internal/integration/tencent_im.go` | 已能把 `EventEnvelope` 转换成腾讯云 IM 房间系统事件 |
|
||||
| `room-service/internal/app/app.go` | 已在进程生命周期中启动 presence stale worker 和 outbox worker,下线时先停 worker 再关闭 gRPC/MySQL/Redis |
|
||||
|
||||
当前风险:
|
||||
|
||||
- 代码接入前已经残留的 `pending` 事件仍会被补投;V1 接受 at-least-once,客户端按 `event_id` 去重。
|
||||
- `room_outbox.status` 目前只有 `pending/published`,适合先表达腾讯云 IM 补偿状态,不适合同时表达 activity/audit 消费状态。
|
||||
- 多个 room-service 实例同时扫描同一批 pending 时可能重复投递;V1 接受 at-least-once,客户端按 `event_id` 去重。
|
||||
- `room_outbox.status` 统一为 `pending/delivering/delivered/retryable/failed`,不再使用旧的 `published` 命名。
|
||||
- 多个 room-service 实例通过 `ClaimPendingOutbox` 抢占同一批事件;未过期的 `delivering` 记录不会被其他 worker 重复抢占。
|
||||
|
||||
## Target Semantics
|
||||
|
||||
@ -52,18 +52,19 @@ Room Cell command applied
|
||||
-> persist snapshot if needed
|
||||
-> replace in-memory RoomState
|
||||
-> try synchronous PublishRoomEvent
|
||||
-> if sync publish success, best-effort mark that event published
|
||||
-> if sync publish success, best-effort mark that event delivered
|
||||
-> if sync publish fails, keep pending for worker
|
||||
```
|
||||
|
||||
补偿 worker 语义:
|
||||
|
||||
```text
|
||||
scan room_outbox where status=pending
|
||||
claim room_outbox where status in (pending, retryable)
|
||||
-> status=delivering, set worker_id and lock_until
|
||||
-> publish one EventEnvelope to configured OutboxPublisher
|
||||
-> success: status=published, last_error=NULL
|
||||
-> failure: status=pending, retry_count=retry_count+1, last_error=<short error>
|
||||
-> next scan retries failed pending records
|
||||
-> success: status=delivered, last_error=NULL
|
||||
-> failure: status=retryable, retry_count=retry_count+1, last_error=<short error>
|
||||
-> next scan retries failed retryable records
|
||||
```
|
||||
|
||||
重要边界:
|
||||
@ -72,7 +73,7 @@ scan room_outbox where status=pending
|
||||
- worker 只处理已经持久化的 outbox envelope。
|
||||
- worker 投递失败不能影响 `room-service` ready;腾讯云 IM 抖动时 room 状态服务仍应接收房间命令。
|
||||
- `request_id` 仍然只做追踪;outbox 去重键是 `event_id`,房间命令幂等键是 `command_id`。
|
||||
- `published` 在本阶段表示腾讯云 IM 补偿通道已处理,不表示 activity/audit 已消费。
|
||||
- `delivered` 在本阶段表示腾讯云 IM 补偿通道已处理,不表示 activity/audit 已消费。
|
||||
|
||||
## Configuration
|
||||
|
||||
@ -165,7 +166,7 @@ type App struct {
|
||||
|
||||
- 不能在 worker 退出前关闭 MySQL,否则可能无法标记投递结果。
|
||||
- worker 收到 cancel 后不再拉取新批次;已经进入投递的事件可以完成成功标记或失败标记。
|
||||
- 如果进程被强杀,未标记成功的记录保持 `pending`,下次启动继续补偿。
|
||||
- 如果进程被强杀,已抢占未完成的记录保持 `delivering`,`lock_until` 过期后下次启动继续补偿。
|
||||
|
||||
## Worker API
|
||||
|
||||
@ -201,12 +202,12 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
|
||||
处理规则:
|
||||
|
||||
- 每轮调用 `repository.ListPendingOutbox(ctx, batch_size)`。
|
||||
- 每轮调用 `repository.ClaimPendingOutbox(ctx, worker_id, batch_size, lock_until)`,把 `pending/retryable` 事件抢占为 `delivering`。
|
||||
- 每条记录单独创建 `context.WithTimeout(ctx, publish_timeout)`。
|
||||
- `PublishOutboxEvent` 成功后调用 `MarkOutboxPublished`。
|
||||
- `PublishOutboxEvent` 成功后调用 `MarkOutboxDelivered`。
|
||||
- `PublishOutboxEvent` 失败后调用 `MarkOutboxFailed`。
|
||||
- 单条失败不阻断同批后续记录。
|
||||
- `ListPendingOutbox` 或 `MarkOutboxPublished/Failed` 失败属于本轮 worker 错误,记录后等待下一轮。
|
||||
- `ClaimPendingOutbox` 或 `MarkOutboxDelivered/Failed` 失败属于本轮 worker 错误,记录后等待下一轮。
|
||||
|
||||
## Sync Publish Interaction
|
||||
|
||||
@ -216,16 +217,16 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
if result.applied && result.syncEvent != nil:
|
||||
err := syncPublisher.PublishRoomEvent(ctx, *result.syncEvent)
|
||||
if err == nil:
|
||||
repository.MarkOutboxPublished(ctx, result.syncEvent.EventID)
|
||||
repository.MarkOutboxDelivered(ctx, result.syncEvent.EventID)
|
||||
if err != nil:
|
||||
keep room_outbox pending
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- `MarkOutboxPublished` 失败不能让已经成功提交的房间命令返回失败;最多造成后续重复投递。
|
||||
- 同步成功但进程在标记 published 前崩溃时,worker 后续可能重复投递;客户端必须用 `event_id` 去重。
|
||||
- 对 `RoomCreated`、`RoomHeatChanged`、`RoomRankChanged` 这类不推 IM 的事件,`PublishOutboxEvent` 返回 no-op success 后可标记 `published`,避免长期 pending。
|
||||
- `MarkOutboxDelivered` 失败不能让已经成功提交的房间命令返回失败;最多造成后续重复投递。
|
||||
- 同步成功但进程在标记 delivered 前崩溃时,worker 后续可能重复投递;客户端必须用 `event_id` 去重。
|
||||
- 对 `RoomCreated`、`RoomHeatChanged`、`RoomRankChanged` 这类不推 IM 的事件,`PublishOutboxEvent` 返回 no-op success 后可标记 `delivered`,避免长期 pending。
|
||||
|
||||
## Retry Strategy
|
||||
|
||||
@ -233,11 +234,11 @@ V1 采用固定间隔无限重试:
|
||||
|
||||
| Case | Behavior |
|
||||
| --- | --- |
|
||||
| 腾讯云 IM 网络错误 | `retry_count + 1`,保留 `pending`,下一轮继续 |
|
||||
| 腾讯云 IM 返回可重试错误 | `retry_count + 1`,保留 `pending`,下一轮继续 |
|
||||
| 腾讯云 IM 网络错误 | `retry_count + 1`,转为 `retryable`,下一轮继续 |
|
||||
| 腾讯云 IM 返回可重试错误 | `retry_count + 1`,转为 `retryable`,下一轮继续 |
|
||||
| envelope 反序列化失败 | worker 本轮返回错误,不删除记录;后续需要人工排查或补工具 |
|
||||
| 不需要推 IM 的事件类型 | no-op success,标记 `published` |
|
||||
| 进程退出 | 未成功标记的记录保持 `pending` |
|
||||
| 不需要推 IM 的事件类型 | no-op success,标记 `delivered` |
|
||||
| 进程退出 | 已抢占未完成的记录保持 `delivering`,`lock_until` 过期后可被重新抢占 |
|
||||
|
||||
为什么不加最大重试次数:
|
||||
|
||||
@ -257,7 +258,7 @@ V1 采用固定间隔无限重试:
|
||||
| `event_type` | RoomUserJoined、RoomGiftSent 等 |
|
||||
| `room_id` | 事件所属房间 |
|
||||
| `retry_count` | 当前失败次数 |
|
||||
| `status` | pending 或 published |
|
||||
| `status` | pending、delivering、delivered、retryable 或 failed |
|
||||
| `error` | 最近一次失败原因,不能包含密钥或完整腾讯响应体 |
|
||||
|
||||
指标后续可补:
|
||||
@ -277,9 +278,9 @@ room_outbox_oldest_pending_age_ms
|
||||
1. 扩展 room-service 配置结构和三份 YAML,增加 `outbox_worker` 配置。
|
||||
2. 给 `room/service` 增加 `OutboxWorkerOptions` 和 `RunOutboxWorker`。
|
||||
3. 调整 `ProcessPendingOutbox`,支持 `batch_size`、单条 `publish_timeout`、单条失败不中断批次。
|
||||
4. 调整命令链路:同步 `PublishRoomEvent` 成功后 best-effort `MarkOutboxPublished(event_id)`;失败保持 pending。
|
||||
4. 调整命令链路:同步 `PublishRoomEvent` 成功后 best-effort `MarkOutboxDelivered(event_id)`;同步失败保留初始 `pending`,worker 投递失败转为 `retryable`。
|
||||
5. 在 `app.Run` 启动 outbox worker,在 `app.Close` cancel 并等待 worker 退出。
|
||||
6. 增加单测覆盖同步失败补偿、重试失败保留 pending、worker cancel 退出。
|
||||
6. 增加单测覆盖同步失败补偿、重试失败转为 `retryable`、worker cancel 退出。
|
||||
7. 更新 README 当前限制,说明 outbox worker 已接入生命周期。
|
||||
|
||||
## Acceptance Tests
|
||||
@ -290,12 +291,12 @@ room_outbox_oldest_pending_age_ms
|
||||
| --- | --- |
|
||||
| 同步 IM 失败后命令成功 | `JoinRoom` 或 `SendGift` 返回成功,RoomSnapshot 已更新 |
|
||||
| 同步 IM 失败后 outbox pending | 对应 `event_id` 仍为 `pending`,`retry_count` 未因同步失败被提前增加 |
|
||||
| worker 补投成功 | fake outbox publisher 收到同一 `event_id`,repository 标记 `published` |
|
||||
| worker 补投失败 | 记录仍为 `pending`,`retry_count+1`,`last_error` 有值 |
|
||||
| 下一轮成功 | 第一次失败、第二次成功后状态变为 `published` |
|
||||
| 同步 IM 成功 | 对应 `event_id` best-effort 标记 `published`,worker 不再扫描该事件 |
|
||||
| worker 补投成功 | fake outbox publisher 收到同一 `event_id`,repository 标记 `delivered` |
|
||||
| worker 补投失败 | 记录转为 `retryable`,`retry_count+1`,`last_error` 有值 |
|
||||
| 下一轮成功 | 第一次失败、第二次成功后状态变为 `delivered` |
|
||||
| 同步 IM 成功 | 对应 `event_id` best-effort 标记 `delivered`,worker 不再扫描该事件 |
|
||||
| worker cancel | cancel 后不再扫描新批次,当前投递受 `publish_timeout` 约束后退出 |
|
||||
| 非 IM 事件 | `RoomCreated/RoomHeatChanged/RoomRankChanged` 不发送腾讯 IM,但会标记 `published` |
|
||||
| 非 IM 事件 | `RoomCreated/RoomHeatChanged/RoomRankChanged` 不发送腾讯 IM,但会标记 `delivered` |
|
||||
|
||||
已有测试 `TestRoomOutboxCompensationWhenSyncPublishFails` 可以作为基础,但需要补齐持续 worker 和同步成功去重场景。
|
||||
|
||||
@ -337,7 +338,7 @@ room command committed
|
||||
-> outbox remains pending
|
||||
-> app-level worker scans pending
|
||||
-> worker publishes same event_id to Tencent IM publisher
|
||||
-> outbox status becomes published
|
||||
-> outbox status becomes delivered
|
||||
-> process shutdown stops worker before closing MySQL
|
||||
```
|
||||
|
||||
|
||||
@ -2,6 +2,12 @@
|
||||
|
||||
本文档定义语音房 v1 基础闭环的实现顺序和验收边界。目标不是做完整语音房,而是先让“登录用户创建房间、进入房间、登录腾讯云 IM、加入房间群、接收房间系统消息、离房、重连恢复”成为可测闭环。
|
||||
|
||||
Room Cell 的完整状态机见:
|
||||
|
||||
```text
|
||||
docs/room-cell-state-machines.md
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
本阶段必须打通:
|
||||
|
||||
@ -29,7 +29,7 @@ presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
outbox_worker:
|
||||
# room_outbox 是腾讯云 IM 系统消息补偿通道;失败保持 pending 并固定间隔重试。
|
||||
# room_outbox 是腾讯云 IM 系统消息补偿通道;失败转为 retryable 并固定间隔重试。
|
||||
enabled: true
|
||||
poll_interval: "1s"
|
||||
batch_size: 100
|
||||
|
||||
@ -7,7 +7,7 @@ CREATE DATABASE IF NOT EXISTS hyapp_room
|
||||
USE hyapp_room;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
@ -25,7 +25,7 @@ CREATE TABLE IF NOT EXISTS rooms (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
|
||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||
@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_snapshots (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
room_version BIGINT NOT NULL,
|
||||
payload LONGBLOB NOT NULL,
|
||||
@ -61,12 +61,14 @@ CREATE TABLE IF NOT EXISTS room_snapshots (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_command_log (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
room_version BIGINT NOT NULL,
|
||||
command_id VARCHAR(128) NOT NULL,
|
||||
command_type VARCHAR(64) NOT NULL,
|
||||
owner_node_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
lease_token VARCHAR(128) NOT NULL DEFAULT '',
|
||||
payload LONGBLOB NOT NULL,
|
||||
replayable BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMP(3) NOT NULL,
|
||||
@ -75,16 +77,19 @@ CREATE TABLE IF NOT EXISTS room_command_log (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_outbox (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
worker_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
lock_until TIMESTAMP(3) NULL,
|
||||
envelope LONGBLOB NOT NULL,
|
||||
created_at TIMESTAMP(3) NOT NULL,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
KEY idx_room_outbox_pending (app_code, status, created_at)
|
||||
KEY idx_room_outbox_pending (app_code, status, created_at),
|
||||
KEY idx_room_outbox_claim (app_code, status, lock_until, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@ -206,6 +206,9 @@ func (a *App) Close() {
|
||||
a.closeOnce.Do(func() {
|
||||
// 先进入 draining,避免下线中的节点继续接管房间 lease 或接收新命令。
|
||||
a.health.MarkDraining()
|
||||
if a.service != nil {
|
||||
a.service.MarkDraining()
|
||||
}
|
||||
if a.workerCancel != nil {
|
||||
// 先停后台 worker 并等待当前投递结果落库,再关闭 gRPC/MySQL/Redis。
|
||||
a.workerCancel()
|
||||
|
||||
@ -25,6 +25,6 @@ type RoomEventPublisher interface {
|
||||
|
||||
// OutboxPublisher 抽象 outbox worker 对外部消费者的异步投递。
|
||||
type OutboxPublisher interface {
|
||||
// PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 保留 pending 状态重试。
|
||||
// PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 转为 retryable 状态重试。
|
||||
PublishOutboxEvent(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error
|
||||
}
|
||||
|
||||
@ -105,6 +105,16 @@ type LeaveRoom struct {
|
||||
// Type 返回命令类型。
|
||||
func (LeaveRoom) Type() string { return "leave_room" }
|
||||
|
||||
// CloseRoom 定义房间关闭请求。
|
||||
type CloseRoom struct {
|
||||
Base
|
||||
// Reason 记录关闭来源,例如 closed_by_owner 或 admin_closed。
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (CloseRoom) Type() string { return "close_room" }
|
||||
|
||||
// MicUp 定义上麦请求。
|
||||
type MicUp struct {
|
||||
Base
|
||||
@ -334,6 +344,8 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
|
||||
cmd = &RoomHeartbeat{}
|
||||
case LeaveRoom{}.Type():
|
||||
cmd = &LeaveRoom{}
|
||||
case CloseRoom{}.Type():
|
||||
cmd = &CloseRoom{}
|
||||
case MicUp{}.Type():
|
||||
cmd = &MicUp{}
|
||||
case MicDown{}.Type():
|
||||
|
||||
@ -13,8 +13,14 @@ import (
|
||||
const (
|
||||
// StatusPending 表示事件已经入库,但尚未投递完成。
|
||||
StatusPending = "pending"
|
||||
// StatusPublished 表示事件已成功投递到外部消费者。
|
||||
StatusPublished = "published"
|
||||
// StatusDelivering 表示事件已被某个 worker 抢占,正在投递中。
|
||||
StatusDelivering = "delivering"
|
||||
// StatusRetryable 表示上次投递失败,后续 worker 可继续重试。
|
||||
StatusRetryable = "retryable"
|
||||
// StatusFailed 表示事件不可重试,需要人工或专项补偿处理。
|
||||
StatusFailed = "failed"
|
||||
// StatusDelivered 表示事件已成功投递到外部消费者。
|
||||
StatusDelivered = "delivered"
|
||||
)
|
||||
|
||||
// Record 表达 room_outbox 表的一条逻辑记录。
|
||||
@ -37,6 +43,10 @@ type Record struct {
|
||||
RetryCount int
|
||||
// LastError 保存最近一次投递失败原因。
|
||||
LastError string
|
||||
// WorkerID 记录当前抢占该事件的 worker。
|
||||
WorkerID string
|
||||
// LockUntilMS 是 delivering 锁的截止时间。
|
||||
LockUntilMS int64
|
||||
}
|
||||
|
||||
// Build 把具体事件体封装成统一 outbox 记录。
|
||||
|
||||
@ -37,7 +37,7 @@ func actorRole(current *state.RoomState, actorUserID int64) roomRole {
|
||||
|
||||
func requireActiveRoom(current *state.RoomState) error {
|
||||
// 当前只有 active 房间可执行管理和麦位命令;CloseRoom 语义后续单独扩展。
|
||||
if current == nil || current.Status != "active" {
|
||||
if current == nil || current.Status != state.RoomStatusActive {
|
||||
return xerr.New(xerr.Conflict, "room is not active")
|
||||
}
|
||||
return nil
|
||||
|
||||
@ -65,6 +65,9 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
// CreateRoom 的 owner/host 默认来自已鉴权调用方,不允许匿名内部命令造房间。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "actor_user_id is required")
|
||||
}
|
||||
if s.isDraining() {
|
||||
return nil, xerr.New(xerr.Unavailable, "room service is draining")
|
||||
}
|
||||
|
||||
// protobuf 请求先收敛为 command,后续幂等、持久化和恢复都只认识 command 模型。
|
||||
cmd := command.CreateRoom{
|
||||
@ -135,6 +138,9 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
// 房间创建必须先保证外部群组可用。
|
||||
return nil, err
|
||||
}
|
||||
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 创建态直接初始化 RoomState,owner 作为首个在线用户进入房间。
|
||||
now := s.clock.Now()
|
||||
@ -175,30 +181,31 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
HostUserID: cmd.HostUserID,
|
||||
SeatCount: cmd.SeatCount,
|
||||
Mode: cmd.Mode,
|
||||
Status: "active",
|
||||
Status: state.RoomStatusActive,
|
||||
VisibleRegionID: cmd.VisibleRegionID,
|
||||
RoomShortID: cmd.RoomShortID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repository.SaveCommand(ctx, CommandRecord{
|
||||
AppCode: cmd.AppCode,
|
||||
RoomID: cmd.RoomID(),
|
||||
RoomVersion: roomState.Version,
|
||||
CommandID: cmd.ID(),
|
||||
CommandType: cmd.Type(),
|
||||
Payload: payload,
|
||||
Replayable: true,
|
||||
CreatedAt: now,
|
||||
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
||||
Command: CommandRecord{
|
||||
AppCode: cmd.AppCode,
|
||||
RoomID: cmd.RoomID(),
|
||||
RoomVersion: roomState.Version,
|
||||
CommandID: cmd.ID(),
|
||||
CommandType: cmd.Type(),
|
||||
Payload: payload,
|
||||
Replayable: true,
|
||||
OwnerNodeID: s.nodeID,
|
||||
LeaseToken: lease.LeaseToken,
|
||||
CreatedAt: now,
|
||||
},
|
||||
OutboxRecords: []outbox.Record{createdEvent},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repository.SaveOutbox(ctx, []outbox.Record{createdEvent}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
snapshot := snapshotWithApp(ctx, roomState.ToProto())
|
||||
if err := s.persistSnapshot(ctx, snapshot); err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -2,7 +2,6 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
@ -11,6 +10,7 @@ import (
|
||||
"hyapp/services/room-service/internal/room/cell"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
)
|
||||
|
||||
type loadedRoomRef struct {
|
||||
@ -49,11 +49,6 @@ func (s *Service) loadedRoomRefs() []loadedRoomRef {
|
||||
|
||||
func runtimeRoomKey(appCode string, roomID string) string {
|
||||
appCode = appcode.Normalize(appCode)
|
||||
if appCode == appcode.Default {
|
||||
// 默认 App 仍使用旧 key,测试和本地 Redis 可继续用 room_id 直接排查。
|
||||
return roomID
|
||||
}
|
||||
|
||||
return appCode + "\x00" + roomID
|
||||
}
|
||||
|
||||
@ -61,12 +56,30 @@ func runtimeRoomKeyFromContext(ctx context.Context, roomID string) string {
|
||||
return runtimeRoomKey(appcode.FromContext(ctx), roomID)
|
||||
}
|
||||
|
||||
func runtimeRoomRef(key string) loadedRoomRef {
|
||||
if app, roomID, ok := strings.Cut(key, "\x00"); ok {
|
||||
return loadedRoomRef{AppCode: appcode.Normalize(app), RoomID: roomID}
|
||||
func (s *Service) loadedRoomLease(ctx context.Context, roomRef loadedRoomRef, now time.Time) (router.Lease, bool, error) {
|
||||
lease, exists, err := s.directory.Lookup(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID))
|
||||
if err != nil || !exists {
|
||||
return router.Lease{}, false, err
|
||||
}
|
||||
if lease.NodeID != s.nodeID || !lease.ValidAt(now) {
|
||||
return router.Lease{}, false, nil
|
||||
}
|
||||
|
||||
return loadedRoomRef{AppCode: appcode.Default, RoomID: key}
|
||||
return lease, true, nil
|
||||
}
|
||||
|
||||
func (s *Service) verifyLoadedRoomLease(ctx context.Context, roomRef loadedRoomRef, lease router.Lease, now time.Time) (bool, error) {
|
||||
return s.directory.VerifyOwner(ctx, runtimeRoomKey(roomRef.AppCode, roomRef.RoomID), s.nodeID, lease.LeaseToken, now)
|
||||
}
|
||||
|
||||
func runtimeRoomRef(key string) loadedRoomRef {
|
||||
for index := 0; index < len(key); index++ {
|
||||
if key[index] == 0 {
|
||||
return loadedRoomRef{AppCode: appcode.Normalize(key[:index]), RoomID: key[index+1:]}
|
||||
}
|
||||
}
|
||||
|
||||
return loadedRoomRef{AppCode: "", RoomID: key}
|
||||
}
|
||||
|
||||
func commandResult(applied bool, roomVersion int64, now time.Time) *roomv1.CommandResult {
|
||||
|
||||
@ -3,8 +3,16 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
func (s *Service) requireRoomOpenForEntry(ctx context.Context, roomID string) error {
|
||||
@ -25,5 +33,70 @@ func (s *Service) isRoomClosed(ctx context.Context, roomID string) (bool, error)
|
||||
|
||||
func isClosedRoomStatus(status string) bool {
|
||||
value := strings.TrimSpace(status)
|
||||
return value != "" && !strings.EqualFold(value, "active")
|
||||
return value != "" && !strings.EqualFold(value, state.RoomStatusActive)
|
||||
}
|
||||
|
||||
// CloseRoom 关闭房间,统一清理 presence、麦位、读模型状态和房间外事件。
|
||||
func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (*roomv1.CloseRoomResponse, error) {
|
||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||
cmd := command.CloseRoom{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
Reason: strings.TrimSpace(req.GetReason()),
|
||||
}
|
||||
if cmd.Reason == "" {
|
||||
cmd.Reason = "closed_by_owner"
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if current == nil {
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
|
||||
}
|
||||
if current.Status == state.RoomStatusClosed {
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
if current.Status != state.RoomStatusActive && current.Status != state.RoomStatusClosing {
|
||||
return mutationResult{}, nil, xerr.New(xerr.Conflict, "room is not active")
|
||||
}
|
||||
if err := requireOwnerOrHostPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
current.Status = state.RoomStatusClosed
|
||||
current.OnlineUsers = map[int64]*state.RoomUserState{}
|
||||
for index := range current.MicSeats {
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
current.Version++
|
||||
|
||||
closedEvent, err := outbox.Build(current.RoomID, "RoomClosed", current.Version, now, &roomeventsv1.RoomClosed{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
Reason: cmd.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
roomStatus: state.RoomStatusClosed,
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: closedEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_closed",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"reason": cmd.Reason,
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{closedEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomv1.CloseRoomResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -240,7 +240,7 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
|
||||
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||||
if err := canSelfOrManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
|
||||
@ -38,6 +38,14 @@ func (s *Service) SweepMicPublishTimeouts(ctx context.Context) error {
|
||||
nowMs := now.UnixMilli()
|
||||
for _, roomRef := range s.loadedRoomRefs() {
|
||||
roomCtx := appcode.WithContext(ctx, roomRef.AppCode)
|
||||
lease, owned, err := s.loadedRoomLease(roomCtx, roomRef, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
continue
|
||||
}
|
||||
|
||||
roomCell := s.loadCell(roomCtx, roomRef.RoomID)
|
||||
if roomCell == nil {
|
||||
continue
|
||||
@ -47,8 +55,23 @@ func (s *Service) SweepMicPublishTimeouts(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, seat := range pendingPublishTimedOutSeats(currentState, nowMs) {
|
||||
owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
break
|
||||
}
|
||||
|
||||
cmd := command.MicDown{
|
||||
Base: command.Base{
|
||||
RequestID: idgen.New("req_mic_publish_timeout"),
|
||||
|
||||
@ -6,6 +6,8 @@ import (
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -96,8 +98,8 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
return fmt.Errorf("unsupported outbox retry_strategy %q", options.RetryStrategy)
|
||||
}
|
||||
|
||||
// 补偿 worker 从 repository 扫描 pending 事件;同步 room->im 失败不会回滚房间状态。
|
||||
records, err := s.repository.ListPendingOutbox(ctx, options.BatchSize)
|
||||
// 补偿 worker 先抢占 pending/retryable 事件,再投递,避免多机同时处理同一批。
|
||||
records, err := s.repository.ClaimPendingOutbox(ctx, s.nodeID, options.BatchSize, time.Now().Add(options.PublishTimeout))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -113,27 +115,27 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
err := s.outboxPublisher.PublishOutboxEvent(publishCtx, record.Envelope)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// 投递失败保持 pending,并记录错误和重试次数,等待下一轮 worker 再试。
|
||||
markCtx, markCancel := context.WithTimeout(context.Background(), options.PublishTimeout)
|
||||
// 投递失败转为 retryable,并记录错误和重试次数,等待下一轮 worker 再抢占。
|
||||
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
|
||||
markErr := s.repository.MarkOutboxFailed(markCtx, record.EventID, trimOutboxError(err.Error()))
|
||||
markCancel()
|
||||
if markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
|
||||
log.Printf("worker=%s node_id=%s event_id=%s event_type=%s room_id=%s retry_count=%d status=pending error=%q", outboxWorkerName, s.nodeID, record.EventID, record.EventType, record.RoomID, record.RetryCount+1, trimOutboxError(err.Error()))
|
||||
log.Printf("worker=%s node_id=%s event_id=%s event_type=%s room_id=%s retry_count=%d status=retryable error=%q", outboxWorkerName, s.nodeID, record.EventID, record.EventType, record.RoomID, record.RetryCount+1, trimOutboxError(err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
// 只有外部发布成功后才能标记 published。
|
||||
markCtx, markCancel := context.WithTimeout(context.Background(), options.PublishTimeout)
|
||||
markErr := s.repository.MarkOutboxPublished(markCtx, record.EventID)
|
||||
// 只有外部发布成功后才能标记 delivered。
|
||||
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
|
||||
markErr := s.repository.MarkOutboxDelivered(markCtx, record.EventID)
|
||||
markCancel()
|
||||
if markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
|
||||
log.Printf("worker=%s node_id=%s event_id=%s event_type=%s room_id=%s retry_count=%d status=published", outboxWorkerName, s.nodeID, record.EventID, record.EventType, record.RoomID, record.RetryCount)
|
||||
log.Printf("worker=%s node_id=%s event_id=%s event_type=%s room_id=%s retry_count=%d status=delivered", outboxWorkerName, s.nodeID, record.EventID, record.EventType, record.RoomID, record.RetryCount)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
"hyapp/services/room-service/internal/room/rank"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
)
|
||||
|
||||
func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayable bool, mutate func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error)) (mutationResult, error) {
|
||||
@ -19,6 +20,10 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
// command meta 是幂等、路由、审计和权限判断的共同基础,缺一不可。
|
||||
return mutationResult{}, xerr.New(xerr.InvalidArgument, "command meta is incomplete")
|
||||
}
|
||||
if s.isDraining() {
|
||||
// 下线中的节点只让已经进入执行链路的命令收尾,不再接收新的 Room Cell 命令。
|
||||
return mutationResult{}, xerr.New(xerr.Unavailable, "room service is draining")
|
||||
}
|
||||
|
||||
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
|
||||
if err != nil {
|
||||
@ -35,7 +40,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
}
|
||||
|
||||
// 确保当前节点持有房间 lease,并在没有内存 Cell 时完成恢复。
|
||||
roomCell, err := s.ensureCell(ctx, cmd.RoomID())
|
||||
roomCell, lease, err := s.ensureCell(ctx, cmd.RoomID())
|
||||
if err != nil {
|
||||
return mutationResult{}, err
|
||||
}
|
||||
@ -60,24 +65,26 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
|
||||
if result.snapshot.GetVersion() != current.Version {
|
||||
// 版本变化代表本命令产生实际状态变更,需要持久化命令和事件。
|
||||
if err := s.repository.SaveCommand(ctx, CommandRecord{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RoomID: cmd.RoomID(),
|
||||
RoomVersion: nextState.Version,
|
||||
CommandID: cmd.ID(),
|
||||
CommandType: cmd.Type(),
|
||||
Payload: commandPayloadForRecord(payload, result),
|
||||
Replayable: replayable,
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(outboxRecords) > 0 {
|
||||
// outbox 与 command log 共同构成恢复和房间外投递来源。
|
||||
if err := s.repository.SaveOutbox(ctx, outboxRecords); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
||||
Command: CommandRecord{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RoomID: cmd.RoomID(),
|
||||
RoomVersion: nextState.Version,
|
||||
CommandID: cmd.ID(),
|
||||
CommandType: cmd.Type(),
|
||||
Payload: commandPayloadForRecord(payload, result),
|
||||
Replayable: replayable,
|
||||
OwnerNodeID: s.nodeID,
|
||||
LeaseToken: lease.LeaseToken,
|
||||
CreatedAt: now,
|
||||
},
|
||||
OutboxRecords: outboxRecords,
|
||||
RoomStatus: result.roomStatus,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 持久化成功后再替换内存状态,避免内存提交领先于恢复来源。
|
||||
@ -88,15 +95,22 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
} else {
|
||||
// no-op 命令仍写入 command log,保证相同 command_id 重试可命中幂等,
|
||||
// 不同 payload 复用 command_id 会被后续入口拒绝为 CONFLICT。
|
||||
if err := s.repository.SaveCommand(ctx, CommandRecord{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RoomID: cmd.RoomID(),
|
||||
RoomVersion: current.Version,
|
||||
CommandID: cmd.ID(),
|
||||
CommandType: cmd.Type(),
|
||||
Payload: payload,
|
||||
Replayable: false,
|
||||
CreatedAt: now,
|
||||
if err := s.ensureLeaseStillOwned(ctx, cmd.RoomID(), lease); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
||||
Command: CommandRecord{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RoomID: cmd.RoomID(),
|
||||
RoomVersion: current.Version,
|
||||
CommandID: cmd.ID(),
|
||||
CommandType: cmd.Type(),
|
||||
Payload: payload,
|
||||
Replayable: false,
|
||||
OwnerNodeID: s.nodeID,
|
||||
LeaseToken: lease.LeaseToken,
|
||||
CreatedAt: now,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -124,12 +138,12 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
if result.applied && result.syncEvent != nil {
|
||||
// 同步腾讯云 IM 失败不回滚房间状态;outbox 后续负责补偿投递。
|
||||
if err := s.syncPublisher.PublishRoomEvent(ctx, *result.syncEvent); err == nil {
|
||||
// 同步投递成功后 best-effort 标记 published,避免补偿 worker 对同一 event_id 重复补投。
|
||||
markCtx, cancel := context.WithTimeout(context.Background(), defaultOutboxWorkerOptions().PublishTimeout)
|
||||
markErr := s.repository.MarkOutboxPublished(markCtx, result.syncEvent.EventID)
|
||||
// 同步投递成功后 best-effort 标记 delivered,避免补偿 worker 对同一 event_id 重复补投。
|
||||
markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), appcode.FromContext(ctx)), defaultOutboxWorkerOptions().PublishTimeout)
|
||||
markErr := s.repository.MarkOutboxDelivered(markCtx, result.syncEvent.EventID)
|
||||
cancel()
|
||||
if markErr != nil {
|
||||
log.Printf("worker=room_outbox node_id=%s event_id=%s room_id=%s status=mark_published_failed error=%q", s.nodeID, result.syncEvent.EventID, result.syncEvent.RoomID, trimOutboxError(markErr.Error()))
|
||||
log.Printf("worker=room_outbox node_id=%s event_id=%s room_id=%s status=mark_delivered_failed error=%q", s.nodeID, result.syncEvent.EventID, result.syncEvent.RoomID, trimOutboxError(markErr.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -171,3 +185,14 @@ func commandPayloadForRecord(defaultPayload []byte, result mutationResult) []byt
|
||||
}
|
||||
return defaultPayload
|
||||
}
|
||||
|
||||
func (s *Service) ensureLeaseStillOwned(ctx context.Context, roomID string, lease router.Lease) error {
|
||||
ok, err := s.directory.VerifyOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, lease.LeaseToken, s.clock.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return xerr.New(xerr.Unavailable, "room lease is no longer owned by this node")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -234,6 +234,14 @@ func (s *Service) SweepStalePresence(ctx context.Context) error {
|
||||
cutoffMs := now.Add(-s.presenceStaleAfter).UnixMilli()
|
||||
for _, roomRef := range s.loadedRoomRefs() {
|
||||
roomCtx := appcode.WithContext(ctx, roomRef.AppCode)
|
||||
lease, owned, err := s.loadedRoomLease(roomCtx, roomRef, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
continue
|
||||
}
|
||||
|
||||
roomCell := s.loadCell(roomCtx, roomRef.RoomID)
|
||||
if roomCell == nil {
|
||||
continue
|
||||
@ -243,8 +251,23 @@ func (s *Service) SweepStalePresence(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, userID := range stalePresenceUserIDs(currentState, cutoffMs) {
|
||||
owned, err = s.verifyLoadedRoomLease(roomCtx, roomRef, lease, s.clock.Now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !owned {
|
||||
break
|
||||
}
|
||||
|
||||
cmd := command.LeaveRoom{
|
||||
Base: command.Base{
|
||||
RequestID: idgen.New("req_presence_stale"),
|
||||
|
||||
@ -11,48 +11,53 @@ import (
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/room/rank"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
)
|
||||
|
||||
// ensureCell 确保当前节点拥有房间执行权,并在本地缺少 Cell 时从 MySQL 恢复。
|
||||
func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell, error) {
|
||||
func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell, router.Lease, error) {
|
||||
// 每次写命令先刷新或接管 lease,确保同一房间同一时刻只有一个执行节点。
|
||||
lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, s.clock.Now(), s.leaseTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, router.Lease{}, err
|
||||
}
|
||||
|
||||
if lease.NodeID != s.nodeID {
|
||||
// 仍有其他有效 owner 时当前节点不能执行该房间命令。
|
||||
return nil, xerr.New(xerr.Conflict, fmt.Sprintf("room is owned by %s", lease.NodeID))
|
||||
return nil, router.Lease{}, xerr.New(xerr.Conflict, fmt.Sprintf("room is owned by %s", lease.NodeID))
|
||||
}
|
||||
|
||||
if roomCell := s.loadCell(ctx, roomID); roomCell != nil {
|
||||
// 本节点已经装载该房间,直接复用已有 Room Cell。
|
||||
return roomCell, nil
|
||||
return roomCell, lease, nil
|
||||
}
|
||||
|
||||
if s.isDraining() {
|
||||
return nil, router.Lease{}, xerr.New(xerr.Unavailable, "room service is draining")
|
||||
}
|
||||
|
||||
// 没有内存 Cell 时从持久化恢复,支持 lease 过期后的新节点接管。
|
||||
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, router.Lease{}, err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// 没有 meta 说明房间从未创建,不能凭请求临时造状态。
|
||||
return nil, xerr.New(xerr.NotFound, "room not found")
|
||||
return nil, router.Lease{}, xerr.New(xerr.NotFound, "room not found")
|
||||
}
|
||||
|
||||
restoredState, err := s.recoverRoom(ctx, roomMeta)
|
||||
if err != nil {
|
||||
// 恢复失败时返回 unavailable,调用方应重试或等待节点修复,而不是返回 room not found。
|
||||
return nil, xerr.New(xerr.Unavailable, fmt.Sprintf("recover room failed: %v", err))
|
||||
return nil, router.Lease{}, xerr.New(xerr.Unavailable, fmt.Sprintf("recover room failed: %v", err))
|
||||
}
|
||||
|
||||
// 恢复完成后创建新的 Room Cell,并从恢复后的 GiftRank 重建 LocalRank 索引。
|
||||
roomCell := cell.New(restoredState, rank.FromState(restoredState.GiftRank, s.rankLimit))
|
||||
s.installCell(ctx, roomID, roomCell)
|
||||
|
||||
return roomCell, nil
|
||||
return roomCell, lease, nil
|
||||
}
|
||||
|
||||
// recoverRoom 按“最新快照 + 之后的可回放命令”重建 Room Cell 状态。
|
||||
@ -152,6 +157,13 @@ func replay(current *state.RoomState, cmd command.Command) error {
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
current.Version++
|
||||
case *command.CloseRoom:
|
||||
current.Status = state.RoomStatusClosed
|
||||
current.OnlineUsers = map[int64]*state.RoomUserState{}
|
||||
for index := range current.MicSeats {
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
current.Version++
|
||||
case *command.MicUp:
|
||||
index := current.SeatIndex(typed.SeatNo)
|
||||
if index < 0 {
|
||||
|
||||
@ -46,10 +46,24 @@ type CommandRecord struct {
|
||||
Payload []byte
|
||||
// Replayable 标记该命令是否参与恢复回放。
|
||||
Replayable bool
|
||||
// OwnerNodeID 是提交命令时持有房间 lease 的 room-service 节点。
|
||||
OwnerNodeID string
|
||||
// LeaseToken 是提交命令前通过 Redis fencing 校验的 lease token。
|
||||
LeaseToken string
|
||||
// CreatedAt 是命令成功落盘时间。
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// MutationCommit 表达一次 Room Cell 命令需要一起提交的持久化副作用。
|
||||
type MutationCommit struct {
|
||||
// Command 是本次命令日志,所有成功命令都必须写入。
|
||||
Command CommandRecord
|
||||
// OutboxRecords 是本次命令产生的房间外事件。
|
||||
OutboxRecords []outbox.Record
|
||||
// RoomStatus 非空时同步更新 rooms 和 room_list_entries 的生命周期状态。
|
||||
RoomStatus string
|
||||
}
|
||||
|
||||
// SnapshotRecord 对应 room_snapshots 中的一条最新快照。
|
||||
type SnapshotRecord struct {
|
||||
// AppCode 是快照所属 App,恢复时必须和房间 meta 保持一致。
|
||||
@ -136,6 +150,8 @@ type Repository interface {
|
||||
GetCommand(ctx context.Context, roomID string, commandID string) (CommandRecord, bool, error)
|
||||
// SaveCommand 追加成功命令日志,关键命令恢复必须依赖它。
|
||||
SaveCommand(ctx context.Context, record CommandRecord) error
|
||||
// SaveMutation 在同一事务中提交命令日志、outbox 事件和可选房间生命周期状态。
|
||||
SaveMutation(ctx context.Context, commit MutationCommit) error
|
||||
// ListCommandsAfter 读取快照版本之后的命令,恢复时按版本顺序回放。
|
||||
ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error)
|
||||
// SaveSnapshot 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。
|
||||
@ -146,9 +162,11 @@ type Repository interface {
|
||||
SaveOutbox(ctx context.Context, records []outbox.Record) error
|
||||
// ListPendingOutbox 扫描待补偿投递事件。
|
||||
ListPendingOutbox(ctx context.Context, limit int) ([]outbox.Record, error)
|
||||
// MarkOutboxPublished 标记事件已经投递成功。
|
||||
MarkOutboxPublished(ctx context.Context, eventID string) error
|
||||
// MarkOutboxFailed 记录一次投递失败并保留 pending 状态等待重试。
|
||||
// ClaimPendingOutbox 抢占一批待投递事件,避免多 worker 同时投递同一批。
|
||||
ClaimPendingOutbox(ctx context.Context, workerID string, limit int, lockUntil time.Time) ([]outbox.Record, error)
|
||||
// MarkOutboxDelivered 标记事件已经投递成功。
|
||||
MarkOutboxDelivered(ctx context.Context, eventID string) error
|
||||
// MarkOutboxFailed 记录一次投递失败并转为 retryable 状态等待重试。
|
||||
MarkOutboxFailed(ctx context.Context, eventID string, lastErr string) error
|
||||
// UpsertRoomListEntry 写入或更新房间列表读模型;失败不应破坏 Room Cell 已提交状态。
|
||||
UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error
|
||||
|
||||
@ -4,6 +4,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
@ -58,6 +59,8 @@ type Service struct {
|
||||
syncPublisher integration.RoomEventPublisher
|
||||
// outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。
|
||||
outboxPublisher integration.OutboxPublisher
|
||||
// draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。
|
||||
draining atomic.Bool
|
||||
|
||||
// mu 保护本进程内 room_id -> RoomCell 注册表。
|
||||
mu sync.Mutex
|
||||
@ -89,6 +92,8 @@ type mutationResult struct {
|
||||
commandPayload []byte
|
||||
// syncEvent 是需要同步推给腾讯云 IM 的低时延房间系统事件。
|
||||
syncEvent *tencentim.RoomEvent
|
||||
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
|
||||
roomStatus string
|
||||
}
|
||||
|
||||
// New 初始化 room-service 领域服务。
|
||||
@ -161,3 +166,15 @@ func (s *Service) HealthCheck() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkDraining 让领域服务停止接收新的 Room Cell 命令和新 lease 接管。
|
||||
func (s *Service) MarkDraining() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.draining.Store(true)
|
||||
}
|
||||
|
||||
func (s *Service) isDraining() bool {
|
||||
return s != nil && s.draining.Load()
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
@ -36,6 +37,24 @@ func (fakeWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (
|
||||
}, nil
|
||||
}
|
||||
|
||||
type takeoverWallet struct {
|
||||
roomID string
|
||||
directory *router.MemoryDirectory
|
||||
takeoverSvc *roomservice.Service
|
||||
}
|
||||
|
||||
func (w takeoverWallet) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||||
w.directory.ForceExpire(roomRouteKeyForTest(w.roomID))
|
||||
if _, err := w.takeoverSvc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(w.roomID, 3)}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fakeWallet{}.DebitGift(ctx, req)
|
||||
}
|
||||
|
||||
func roomRouteKeyForTest(roomID string) string {
|
||||
return appcode.Default + "\x00" + roomID
|
||||
}
|
||||
|
||||
// fakeSyncPublisher 记录 room-service 同步推给腾讯云 IM 的房间事件。
|
||||
type fakeSyncPublisher struct {
|
||||
// fail 为 true 时模拟腾讯云 IM PublishRoomEvent 失败。
|
||||
@ -152,6 +171,34 @@ func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomRouteKeyAlwaysIncludesDefaultAppCode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
directory := router.NewMemoryDirectory()
|
||||
svc := newRoomService("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
roomID := "room-route-default-app"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
RoomName: "Route Room",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
|
||||
if _, exists, err := directory.Lookup(ctx, roomID); err != nil || exists {
|
||||
t.Fatalf("raw room_id route key must not exist: exists=%v err=%v", exists, err)
|
||||
}
|
||||
lease, exists, err := directory.Lookup(ctx, roomRouteKeyForTest(roomID))
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("app-scoped route key should exist: exists=%v err=%v", exists, err)
|
||||
}
|
||||
if lease.NodeID != "node-a" {
|
||||
t.Fatalf("route owner mismatch: %+v", lease)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoomRejectsSecondRoomForSameOwner(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -569,6 +616,38 @@ func TestMicPublishingConfirmAndStaleEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudienceCanChangeOwnMicSeat(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
roomID := "room-self-change-mic"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||||
}
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||||
t.Fatalf("MicUp user2 failed: %v", err)
|
||||
}
|
||||
|
||||
changeResp, err := svc.ChangeMicSeat(ctx, &roomv1.ChangeMicSeatRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||||
TargetUserId: 2,
|
||||
SeatNo: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("audience should be able to change own mic seat: %v", err)
|
||||
}
|
||||
if from := findSeat(changeResp.GetRoom(), 1); from == nil || from.GetUserId() != 0 {
|
||||
t.Fatalf("source seat should be empty after switch: %+v", from)
|
||||
}
|
||||
if to := findSeat(changeResp.GetRoom(), 3); to == nil || to.GetUserId() != 2 {
|
||||
t.Fatalf("target seat should contain user2 after switch: %+v", to)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRTCEventConfirmsAudioAndClearsOnStopOrExit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -648,6 +727,65 @@ func TestApplyRTCEventConfirmsAudioAndClearsOnStopOrExit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldRTCRoomExitedDoesNotClearRejoinedUserOrNewMicSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||||
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||||
roomID := "room-rtc-exit-replay"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||||
}
|
||||
oldMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("first MicUp failed: %v", err)
|
||||
}
|
||||
oldExitEventTime := usedClock.now.Add(time.Second).UnixMilli()
|
||||
|
||||
usedClock.now = usedClock.now.Add(2 * time.Second)
|
||||
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("LeaveRoom failed: %v", err)
|
||||
}
|
||||
|
||||
usedClock.now = usedClock.now.Add(2 * time.Second)
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("rejoin user2 failed: %v", err)
|
||||
}
|
||||
newMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("second MicUp failed: %v", err)
|
||||
}
|
||||
if newMic.GetMicSessionId() == oldMic.GetMicSessionId() {
|
||||
t.Fatalf("rejoined MicUp must create new session: old=%s new=%s", oldMic.GetMicSessionId(), newMic.GetMicSessionId())
|
||||
}
|
||||
|
||||
exitResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||||
TargetUserId: 2,
|
||||
EventType: "room_exited",
|
||||
EventTimeMs: oldExitEventTime,
|
||||
Reason: "rtc_room_exited:old",
|
||||
Source: "tencent_rtc_callback",
|
||||
ExternalEventId: "rtc_evt_old_exit",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("old room_exited should be ignored without error: %v", err)
|
||||
}
|
||||
if exitResp.GetResult().GetApplied() {
|
||||
t.Fatalf("old room_exited should be a no-op: %+v", exitResp.GetResult())
|
||||
}
|
||||
if user := findRoomUser(exitResp.GetRoom(), 2); user == nil {
|
||||
t.Fatalf("old room_exited must not remove rejoined user")
|
||||
}
|
||||
if seat := findSeat(exitResp.GetRoom(), 2); seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != newMic.GetMicSessionId() {
|
||||
t.Fatalf("old room_exited must not clear new mic session: seat=%+v new=%+v old=%+v", seat, newMic, oldMic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -667,6 +805,9 @@ func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) {
|
||||
t.Fatalf("MicUp failed: %v", err)
|
||||
}
|
||||
usedClock.now = time.UnixMilli(micResp.GetPublishDeadlineMs())
|
||||
if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil {
|
||||
t.Fatalf("RoomHeartbeat owner before mic timeout sweep failed: %v", err)
|
||||
}
|
||||
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
|
||||
t.Fatalf("SweepMicPublishTimeouts failed: %v", err)
|
||||
}
|
||||
@ -874,6 +1015,64 @@ func TestJoinRoomRejectsClosedRoom(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseRoomClearsPresenceSeatsAndBlocksEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
syncPublisher := &fakeSyncPublisher{}
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
|
||||
roomID := "room-close-command"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Close Room"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||||
t.Fatalf("MicUp failed: %v", err)
|
||||
}
|
||||
|
||||
closeResp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), Reason: "owner_closed"})
|
||||
if err != nil {
|
||||
t.Fatalf("CloseRoom failed: %v", err)
|
||||
}
|
||||
if closeResp.GetRoom().GetStatus() != "closed" || len(closeResp.GetRoom().GetOnlineUsers()) != 0 {
|
||||
t.Fatalf("closed room should clear presence: %+v", closeResp.GetRoom())
|
||||
}
|
||||
if seat := findSeat(closeResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
|
||||
t.Fatalf("closed room should release seats: %+v", seat)
|
||||
}
|
||||
meta, exists, err := repository.GetRoomMeta(ctx, roomID)
|
||||
if err != nil || !exists || meta.Status != "closed" {
|
||||
t.Fatalf("room meta should be closed: exists=%v meta=%+v err=%v", exists, meta, err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); !xerr.IsCode(err, xerr.RoomClosed) {
|
||||
t.Fatalf("closed room should reject JoinRoom, got %v", err)
|
||||
}
|
||||
if !hasSyncEventType(syncPublisher.events, "room_closed") {
|
||||
t.Fatalf("CloseRoom should publish room_closed event: %+v", syncPublisher.events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainingRejectsNewRoomCommands(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
roomID := "room-draining"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Drain Room"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
svc.MarkDraining()
|
||||
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.Unavailable) {
|
||||
t.Fatalf("draining service should reject new JoinRoom, got %v", err)
|
||||
}
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta("room-draining-new", 3), SeatCount: 4, Mode: "social", RoomName: "New Room"}); !xerr.IsCode(err, xerr.Unavailable) {
|
||||
t.Fatalf("draining service should reject new CreateRoom, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandIDPayloadConflictIgnoresTraceFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -945,7 +1144,7 @@ func TestRoomManagementRecoveryReplay(t *testing.T) {
|
||||
t.Fatalf("UnbanUser failed: %v", err)
|
||||
}
|
||||
|
||||
directory.ForceExpire(roomID)
|
||||
directory.ForceExpire(roomRouteKeyForTest(roomID))
|
||||
serviceB := newRoomService("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
|
||||
if err != nil {
|
||||
@ -1129,7 +1328,7 @@ func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
|
||||
}
|
||||
|
||||
// 强制过期模拟 node-a 故障后 Redis lease 失效。
|
||||
directory.ForceExpire(roomID)
|
||||
directory.ForceExpire(roomRouteKeyForTest(roomID))
|
||||
|
||||
// node-b 收到命令时应先接管 lease,再恢复 Room Cell,而不是返回 room not found。
|
||||
serviceB := newRoomService("node-b", 2, directory, repository, &fakeSyncPublisher{}, outboxPublisher)
|
||||
@ -1150,7 +1349,7 @@ func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
|
||||
t.Fatalf("recovered rank mismatch: %+v", joinResp.GetRoom().GetGiftRank())
|
||||
}
|
||||
|
||||
lease, exists, err := directory.Lookup(ctx, roomID)
|
||||
lease, exists, err := directory.Lookup(ctx, roomRouteKeyForTest(roomID))
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("Lookup failed: %v exists=%v", err, exists)
|
||||
}
|
||||
@ -1160,6 +1359,51 @@ func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLeaseFencingRejectsOldOwnerAfterSlowWalletCommand(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
directory := router.NewMemoryDirectory()
|
||||
syncPublisherA := &fakeSyncPublisher{}
|
||||
outboxPublisher := &fakeOutboxPublisher{}
|
||||
serviceB := newRoomService("node-b", 1, directory, repository, &fakeSyncPublisher{}, outboxPublisher)
|
||||
serviceA := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-a",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
}, directory, repository, takeoverWallet{
|
||||
roomID: "room-lease-fencing",
|
||||
directory: directory,
|
||||
takeoverSvc: serviceB,
|
||||
}, syncPublisherA, outboxPublisher)
|
||||
|
||||
roomID := "room-lease-fencing"
|
||||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social", RoomName: "Fence Room"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom failed: %v", err)
|
||||
}
|
||||
|
||||
_, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||||
TargetUserId: 1,
|
||||
GiftId: "rose",
|
||||
GiftCount: 1,
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.Unavailable) {
|
||||
t.Fatalf("old owner should fail fencing before room commit, got %v", err)
|
||||
}
|
||||
|
||||
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
|
||||
if err != nil {
|
||||
t.Fatalf("new owner should keep serving room: %v", err)
|
||||
}
|
||||
if joinResp.GetRoom().GetHeat() != 0 {
|
||||
t.Fatalf("fenced SendGift must not update room heat: %+v", joinResp.GetRoom())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGuardRecoveryReplaysCommandsAfterSnapshot 验证无内存 Cell 的守卫查询会补回快照后的 command log。
|
||||
func TestGuardRecoveryReplaysCommandsAfterSnapshot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@ -1229,6 +1473,9 @@ func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) {
|
||||
}
|
||||
|
||||
usedClock.now = usedClock.now.Add(40 * time.Second)
|
||||
if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{Meta: roomservice.NewRequestMeta(roomID, 1)}); err != nil {
|
||||
t.Fatalf("RoomHeartbeat owner before stale sweep failed: %v", err)
|
||||
}
|
||||
if err := svc.SweepStalePresence(ctx); err != nil {
|
||||
t.Fatalf("SweepStalePresence failed: %v", err)
|
||||
}
|
||||
@ -1253,6 +1500,78 @@ func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepStalePresenceSkipsRoomAfterLeaseTakeover(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
directory := router.NewMemoryDirectory()
|
||||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||||
serviceA := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||||
serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||||
roomID := "room-stale-lease-takeover"
|
||||
|
||||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||||
}
|
||||
|
||||
usedClock.now = usedClock.now.Add(2 * time.Minute)
|
||||
directory.ForceExpire(roomRouteKeyForTest(roomID))
|
||||
if _, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
|
||||
t.Fatalf("JoinRoom takeover failed: %v", err)
|
||||
}
|
||||
if err := serviceA.SweepStalePresence(ctx); err != nil {
|
||||
t.Fatalf("old owner stale sweep failed: %v", err)
|
||||
}
|
||||
|
||||
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
|
||||
if err != nil {
|
||||
t.Fatalf("JoinRoom on current owner failed: %v", err)
|
||||
}
|
||||
if user := findRoomUser(joinResp.GetRoom(), 2); user == nil {
|
||||
t.Fatalf("old owner stale sweep must not remove users after takeover: %+v", joinResp.GetRoom().GetOnlineUsers())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSweepMicPublishTimeoutSkipsRoomAfterLeaseTakeover(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
directory := router.NewMemoryDirectory()
|
||||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||||
serviceA := newRoomServiceWithClock("node-a", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||||
serviceB := newRoomServiceWithClock("node-b", 1, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||||
roomID := "room-mic-timeout-lease-takeover"
|
||||
|
||||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice", RoomName: "Test Room"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||||
}
|
||||
micResp, err := serviceA.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("MicUp failed: %v", err)
|
||||
}
|
||||
|
||||
usedClock.now = time.UnixMilli(micResp.GetPublishDeadlineMs())
|
||||
directory.ForceExpire(roomRouteKeyForTest(roomID))
|
||||
if _, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
|
||||
t.Fatalf("JoinRoom takeover failed: %v", err)
|
||||
}
|
||||
if err := serviceA.SweepMicPublishTimeouts(ctx); err != nil {
|
||||
t.Fatalf("old owner mic timeout sweep failed: %v", err)
|
||||
}
|
||||
|
||||
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
|
||||
if err != nil {
|
||||
t.Fatalf("JoinRoom on current owner failed: %v", err)
|
||||
}
|
||||
if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != micResp.GetMicSessionId() || seat.GetPublishState() != "pending_publish" {
|
||||
t.Fatalf("old owner mic timeout sweep must not clear current owner's seat: seat=%+v mic=%+v", seat, micResp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoomOutboxCompensationWhenSyncPublishFails 验证同步广播失败不回滚房间状态,outbox 可补偿。
|
||||
func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@ -1311,11 +1630,11 @@ func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) {
|
||||
if remaining, err := repository.ListPendingOutbox(ctx, 100); err != nil {
|
||||
t.Fatalf("ListPendingOutbox after compensation failed: %v", err)
|
||||
} else if len(remaining) != 0 {
|
||||
t.Fatalf("expected all pending outbox records to be marked published, got %d", len(remaining))
|
||||
t.Fatalf("expected all pending outbox records to be marked delivered, got %d", len(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutboxWorkerRetriesFailedPublishAndLaterMarksPublished(t *testing.T) {
|
||||
func TestOutboxWorkerRetriesFailedPublishAndLaterMarksDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
outboxPublisher := &fakeOutboxPublisher{failCount: 1, err: errors.New("temporary im failure")}
|
||||
@ -1343,28 +1662,73 @@ func TestOutboxWorkerRetriesFailedPublishAndLaterMarksPublished(t *testing.T) {
|
||||
if !exists {
|
||||
t.Fatalf("outbox record missing after failure")
|
||||
}
|
||||
if failed.Status != outbox.StatusPending || failed.RetryCount != 1 || !strings.Contains(failed.LastError, "temporary im failure") {
|
||||
t.Fatalf("failed publish should remain pending with retry metadata: %+v", failed)
|
||||
if failed.Status != outbox.StatusRetryable || failed.RetryCount != 1 || !strings.Contains(failed.LastError, "temporary im failure") {
|
||||
t.Fatalf("failed publish should become retryable with retry metadata: %+v", failed)
|
||||
}
|
||||
|
||||
if err := svc.ProcessPendingOutbox(ctx, options); err != nil {
|
||||
t.Fatalf("second ProcessPendingOutbox failed: %v", err)
|
||||
}
|
||||
published, exists := repository.OutboxRecord(record.EventID)
|
||||
delivered, exists := repository.OutboxRecord(record.EventID)
|
||||
if !exists {
|
||||
t.Fatalf("outbox record missing after success")
|
||||
}
|
||||
if published.Status != outbox.StatusPublished || published.LastError != "" || len(outboxPublisher.envelopes) != 1 {
|
||||
t.Fatalf("second publish should mark record published: record=%+v envelopes=%d", published, len(outboxPublisher.envelopes))
|
||||
if delivered.Status != outbox.StatusDelivered || delivered.LastError != "" || len(outboxPublisher.envelopes) != 1 {
|
||||
t.Fatalf("second publish should mark record delivered: record=%+v envelopes=%d", delivered, len(outboxPublisher.envelopes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPublishSuccessMarksOutboxPublished(t *testing.T) {
|
||||
func TestOutboxClaimPreventsDuplicateWorkersAndRetriesAfterFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
record, err := outbox.Build("room-claim", "RoomGiftSent", 1, time.Now(), &roomeventsv1.RoomGiftSent{
|
||||
SenderUserId: 1,
|
||||
TargetUserId: 2,
|
||||
GiftId: "rose",
|
||||
GiftCount: 1,
|
||||
GiftValue: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build outbox failed: %v", err)
|
||||
}
|
||||
if err := repository.SaveOutbox(ctx, []outbox.Record{record}); err != nil {
|
||||
t.Fatalf("SaveOutbox failed: %v", err)
|
||||
}
|
||||
|
||||
claimedA, err := repository.ClaimPendingOutbox(ctx, "worker-a", 10, time.Now().Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("worker-a claim failed: %v", err)
|
||||
}
|
||||
if len(claimedA) != 1 || claimedA[0].Status != outbox.StatusDelivering || claimedA[0].WorkerID != "worker-a" {
|
||||
t.Fatalf("worker-a should claim one delivering record: %+v", claimedA)
|
||||
}
|
||||
claimedB, err := repository.ClaimPendingOutbox(ctx, "worker-b", 10, time.Now().Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("worker-b claim failed: %v", err)
|
||||
}
|
||||
if len(claimedB) != 0 {
|
||||
t.Fatalf("worker-b should not claim worker-a locked record: %+v", claimedB)
|
||||
}
|
||||
|
||||
if err := repository.MarkOutboxFailed(ctx, record.EventID, "temporary failure"); err != nil {
|
||||
t.Fatalf("MarkOutboxFailed failed: %v", err)
|
||||
}
|
||||
claimedRetry, err := repository.ClaimPendingOutbox(ctx, "worker-b", 10, time.Now().Add(time.Minute))
|
||||
if err != nil {
|
||||
t.Fatalf("retry claim failed: %v", err)
|
||||
}
|
||||
if len(claimedRetry) != 1 || claimedRetry[0].Status != outbox.StatusDelivering || claimedRetry[0].RetryCount != 1 {
|
||||
t.Fatalf("retryable record should be claimable again: %+v", claimedRetry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPublishSuccessMarksOutboxDelivered(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
syncPublisher := &fakeSyncPublisher{}
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
|
||||
roomID := "room-sync-published"
|
||||
roomID := "room-sync-delivered"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
@ -1388,8 +1752,8 @@ func TestSyncPublishSuccessMarksOutboxPublished(t *testing.T) {
|
||||
if !exists {
|
||||
t.Fatalf("sync event outbox record missing: %s", eventID)
|
||||
}
|
||||
if record.Status != outbox.StatusPublished {
|
||||
t.Fatalf("sync success should best-effort mark outbox published: %+v", record)
|
||||
if record.Status != outbox.StatusDelivered {
|
||||
t.Fatalf("sync success should best-effort mark outbox delivered: %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1472,3 +1836,13 @@ func hasSyncEventReason(events []tencentim.RoomEvent, reason string) bool {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSyncEventType(events []tencentim.RoomEvent, eventType string) bool {
|
||||
for _, event := range events {
|
||||
if event.EventType == eventType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@ -33,6 +33,10 @@ func (s *Service) currentSnapshot(ctx context.Context, roomID string) (*roomv1.R
|
||||
// 恢复失败说明持久化状态不可用,不能让外部 IM 入口自行放行。
|
||||
return nil, err
|
||||
}
|
||||
if s.isDraining() {
|
||||
// 下线期间守卫查询可以读取恢复后的快照,但不能再创建新的本地 Room Cell。
|
||||
return snapshotWithApp(ctx, restoredState.ToProto()), nil
|
||||
}
|
||||
|
||||
roomCell := cell.New(restoredState, rank.FromState(restoredState.GiftRank, s.rankLimit))
|
||||
s.installCell(ctx, roomID, roomCell)
|
||||
|
||||
@ -8,6 +8,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// RoomStatusCreating 表示房间正在创建外部依赖,尚未对客户端开放。
|
||||
RoomStatusCreating = "creating"
|
||||
// RoomStatusActive 表示房间可进入、可管理、可发麦位和礼物命令。
|
||||
RoomStatusActive = "active"
|
||||
// RoomStatusClosing 表示房间正在收尾,不再接受新业务命令。
|
||||
RoomStatusClosing = "closing"
|
||||
// RoomStatusClosed 表示房间已经关闭,guard 和进房都必须拒绝。
|
||||
RoomStatusClosed = "closed"
|
||||
|
||||
// MicPublishIdle 表示麦位没有正在确认或已确认的 RTC 发流会话。
|
||||
MicPublishIdle = "idle"
|
||||
// MicPublishPending 表示业务上麦成功,但客户端/RTC 尚未确认音频发布成功。
|
||||
@ -109,7 +118,7 @@ func NewRoomState(roomID string, ownerUserID int64, hostUserID int64, seatCount
|
||||
OwnerUserID: ownerUserID,
|
||||
HostUserID: hostUserID,
|
||||
Mode: mode,
|
||||
Status: "active",
|
||||
Status: RoomStatusActive,
|
||||
ChatEnabled: true,
|
||||
MicSeats: seats,
|
||||
OnlineUsers: make(map[int64]*RoomUserState),
|
||||
|
||||
@ -33,6 +33,8 @@ type Directory interface {
|
||||
Lookup(ctx context.Context, roomID string) (Lease, bool, error)
|
||||
// EnsureOwner 在无 owner、owner 过期或 owner 是当前节点时授予当前节点执行权。
|
||||
EnsureOwner(ctx context.Context, roomID string, nodeID string, now time.Time, ttl time.Duration) (Lease, error)
|
||||
// VerifyOwner 校验当前节点和 lease token 仍然持有执行权,不续租也不接管。
|
||||
VerifyOwner(ctx context.Context, roomID string, nodeID string, leaseToken string, now time.Time) (bool, error)
|
||||
// ForceExpire 只用于测试模拟租约过期。
|
||||
ForceExpire(roomID string)
|
||||
}
|
||||
@ -87,6 +89,19 @@ func (d *MemoryDirectory) EnsureOwner(_ context.Context, roomID string, nodeID s
|
||||
return next, nil
|
||||
}
|
||||
|
||||
// VerifyOwner 校验调用方手里的 lease token 仍是当前有效 owner。
|
||||
func (d *MemoryDirectory) VerifyOwner(_ context.Context, roomID string, nodeID string, leaseToken string, now time.Time) (bool, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
current, ok := d.leases[roomID]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return current.NodeID == nodeID && current.LeaseToken == leaseToken && current.ValidAt(now), nil
|
||||
}
|
||||
|
||||
// ForceExpire 只用于测试时模拟租约自然过期。
|
||||
func (d *MemoryDirectory) ForceExpire(roomID string) {
|
||||
d.mu.Lock()
|
||||
|
||||
@ -38,6 +38,24 @@ end
|
||||
return current
|
||||
`
|
||||
|
||||
const verifyOwnerScript = `
|
||||
local current = redis.call("GET", KEYS[1])
|
||||
if not current then
|
||||
return 0
|
||||
end
|
||||
|
||||
local ok, decoded = pcall(cjson.decode, current)
|
||||
if not ok then
|
||||
return 0
|
||||
end
|
||||
|
||||
if decoded["node_id"] == ARGV[2] and decoded["lease_token"] == ARGV[3] and tonumber(decoded["expires_at_ms"]) > tonumber(ARGV[1]) then
|
||||
return 1
|
||||
end
|
||||
|
||||
return 0
|
||||
`
|
||||
|
||||
// RedisDirectory 用 Redis 保存 room_id 到执行节点的带 TTL 租约。
|
||||
type RedisDirectory struct {
|
||||
// client 是 go-redis 客户端,连接生命周期由 app.App 关闭。
|
||||
@ -172,6 +190,22 @@ func (d *RedisDirectory) EnsureOwner(ctx context.Context, roomID string, nodeID
|
||||
return lease, nil
|
||||
}
|
||||
|
||||
// VerifyOwner 校验调用方持有的 lease token 仍是当前有效 owner。
|
||||
func (d *RedisDirectory) VerifyOwner(ctx context.Context, roomID string, nodeID string, leaseToken string, now time.Time) (bool, error) {
|
||||
value, err := d.client.Eval(ctx,
|
||||
verifyOwnerScript,
|
||||
[]string{routeKey(roomID)},
|
||||
strconv.FormatInt(now.UnixMilli(), 10),
|
||||
nodeID,
|
||||
leaseToken,
|
||||
).Int()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return value == 1, nil
|
||||
}
|
||||
|
||||
// ForceExpire 只服务测试,生产启动路径不会调用。
|
||||
func (d *RedisDirectory) ForceExpire(roomID string) {
|
||||
// Redis 实现直接删除 key 来模拟 lease 过期,避免等待真实 TTL。
|
||||
|
||||
@ -70,7 +70,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
// 这里保持与 deploy/mysql/initdb 的表结构一致,服务本地启动可自动建表。
|
||||
statements := []string{
|
||||
`CREATE TABLE IF NOT EXISTS rooms (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
@ -87,7 +87,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
KEY idx_rooms_region_status (app_code, visible_region_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
room_short_id VARCHAR(32) NOT NULL DEFAULT '',
|
||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||
@ -111,7 +111,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
KEY idx_room_list_owner (app_code, owner_user_id, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_snapshots (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
room_version BIGINT NOT NULL,
|
||||
payload LONGBLOB NOT NULL,
|
||||
@ -121,12 +121,14 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
INDEX idx_room_snapshots_version (app_code, room_id, room_version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_command_log (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
room_version BIGINT NOT NULL,
|
||||
command_id VARCHAR(128) NOT NULL,
|
||||
command_type VARCHAR(64) NOT NULL,
|
||||
owner_node_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
lease_token VARCHAR(128) NOT NULL DEFAULT '',
|
||||
payload LONGBLOB NOT NULL,
|
||||
replayable BOOLEAN NOT NULL,
|
||||
created_at TIMESTAMP(3) NOT NULL,
|
||||
@ -134,19 +136,27 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
KEY idx_room_command_replay (app_code, room_id, room_version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_outbox (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
app_code VARCHAR(32) NOT NULL,
|
||||
event_id VARCHAR(128) NOT NULL,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
worker_id VARCHAR(128) NOT NULL DEFAULT '',
|
||||
lock_until TIMESTAMP(3) NULL,
|
||||
envelope LONGBLOB NOT NULL,
|
||||
created_at TIMESTAMP(3) NOT NULL,
|
||||
retry_count INT NOT NULL DEFAULT 0,
|
||||
last_error TEXT NULL,
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (app_code, event_id),
|
||||
KEY idx_room_outbox_pending (app_code, status, created_at)
|
||||
KEY idx_room_outbox_pending (app_code, status, created_at),
|
||||
KEY idx_room_outbox_claim (app_code, status, lock_until, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`ALTER TABLE rooms MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
`ALTER TABLE room_list_entries MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
`ALTER TABLE room_snapshots MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
`ALTER TABLE room_command_log MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
`ALTER TABLE room_outbox MODIFY COLUMN app_code VARCHAR(32) NOT NULL`,
|
||||
}
|
||||
|
||||
for _, statement := range statements {
|
||||
@ -232,7 +242,7 @@ func (r *Repository) GetRoomMetaByOwner(ctx context.Context, ownerUserID int64)
|
||||
func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) {
|
||||
// 幂等检查只看 command log,只有成功提交过的命令才算 seen。
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT app_code, room_id, room_version, command_id, command_type, payload, replayable, created_at
|
||||
`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at
|
||||
FROM room_command_log
|
||||
WHERE app_code = ? AND room_id = ? AND command_id = ?
|
||||
LIMIT 1`,
|
||||
@ -242,7 +252,7 @@ func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID st
|
||||
)
|
||||
|
||||
var record roomservice.CommandRecord
|
||||
if err := row.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.Payload, &record.Replayable, &record.CreatedAt); err != nil {
|
||||
if err := row.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// 没有命令日志表示本命令尚未成功提交。
|
||||
return roomservice.CommandRecord{}, false, nil
|
||||
@ -259,14 +269,16 @@ func (r *Repository) SaveCommand(ctx context.Context, record roomservice.Command
|
||||
// ON DUPLICATE KEY 保证重复命令写入不会破坏已提交记录。
|
||||
appCode := normalizedRecordAppCode(ctx, record.AppCode)
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, payload, replayable, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE command_id = command_id`,
|
||||
appCode,
|
||||
record.RoomID,
|
||||
record.RoomVersion,
|
||||
record.CommandID,
|
||||
record.CommandType,
|
||||
record.OwnerNodeID,
|
||||
record.LeaseToken,
|
||||
record.Payload,
|
||||
record.Replayable,
|
||||
record.CreatedAt,
|
||||
@ -275,11 +287,96 @@ func (r *Repository) SaveCommand(ctx context.Context, record roomservice.Command
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveMutation 在同一事务中提交命令日志、outbox 和可选房间状态。
|
||||
func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.MutationCommit) error {
|
||||
appCode := normalizedRecordAppCode(ctx, commit.Command.AppCode)
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO room_command_log (app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE command_id = command_id`,
|
||||
appCode,
|
||||
commit.Command.RoomID,
|
||||
commit.Command.RoomVersion,
|
||||
commit.Command.CommandID,
|
||||
commit.Command.CommandType,
|
||||
commit.Command.OwnerNodeID,
|
||||
commit.Command.LeaseToken,
|
||||
commit.Command.Payload,
|
||||
commit.Command.Replayable,
|
||||
commit.Command.CreatedAt,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
for _, record := range commit.OutboxRecords {
|
||||
record.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
|
||||
if record.Envelope != nil {
|
||||
record.Envelope.AppCode = record.AppCode
|
||||
}
|
||||
envelopeBytes, err := proto.Marshal(record.Envelope)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO room_outbox (app_code, event_id, event_type, room_id, status, envelope, created_at, retry_count, last_error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE event_id = event_id`,
|
||||
record.AppCode,
|
||||
record.EventID,
|
||||
record.EventType,
|
||||
record.RoomID,
|
||||
record.Status,
|
||||
envelopeBytes,
|
||||
record.CreatedAt,
|
||||
record.RetryCount,
|
||||
nullableString(record.LastError),
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(commit.RoomStatus) != "" {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE rooms
|
||||
SET status = ?
|
||||
WHERE app_code = ? AND room_id = ?`,
|
||||
commit.RoomStatus,
|
||||
appCode,
|
||||
commit.Command.RoomID,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE room_list_entries
|
||||
SET status = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND room_id = ?`,
|
||||
commit.RoomStatus,
|
||||
commit.Command.CreatedAt.UnixMilli(),
|
||||
appCode,
|
||||
commit.Command.RoomID,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ListCommandsAfter 返回快照版本之后的可回放命令日志。
|
||||
func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]roomservice.CommandRecord, error) {
|
||||
// 恢复必须按 room_version 再按自增 id 排序,确保同版本异常数据也有稳定顺序。
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT app_code, room_id, room_version, command_id, command_type, payload, replayable, created_at
|
||||
`SELECT app_code, room_id, room_version, command_id, command_type, owner_node_id, lease_token, payload, replayable, created_at
|
||||
FROM room_command_log
|
||||
WHERE app_code = ? AND room_id = ? AND room_version > ?
|
||||
ORDER BY room_version ASC, id ASC`,
|
||||
@ -296,7 +393,7 @@ func (r *Repository) ListCommandsAfter(ctx context.Context, roomID string, roomV
|
||||
for rows.Next() {
|
||||
// payload 保持原始字节,领域层根据 command_type 反序列化。
|
||||
var record roomservice.CommandRecord
|
||||
if err := rows.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.Payload, &record.Replayable, &record.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&record.AppCode, &record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.OwnerNodeID, &record.LeaseToken, &record.Payload, &record.Replayable, &record.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@ -414,12 +511,14 @@ func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox
|
||||
|
||||
// 按创建时间顺序扫描 pending,尽量保持事件投递顺序。
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT app_code, event_id, event_type, room_id, status, envelope, created_at, retry_count, COALESCE(last_error, '')
|
||||
`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until, envelope, created_at, retry_count, COALESCE(last_error, '')
|
||||
FROM room_outbox
|
||||
WHERE status = ?
|
||||
WHERE app_code = ? AND status IN (?, ?)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?`,
|
||||
appcode.FromContext(ctx),
|
||||
outbox.StatusPending,
|
||||
outbox.StatusRetryable,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
@ -432,9 +531,13 @@ func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox
|
||||
// 每条 outbox 记录恢复 envelope,发布器只消费 protobuf 信封。
|
||||
var envelopeBytes []byte
|
||||
var record outbox.Record
|
||||
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &envelopeBytes, &record.CreatedAt, &record.RetryCount, &record.LastError); err != nil {
|
||||
var lockUntil sql.NullTime
|
||||
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAt, &record.RetryCount, &record.LastError); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lockUntil.Valid {
|
||||
record.LockUntilMS = lockUntil.Time.UnixMilli()
|
||||
}
|
||||
|
||||
var envelope roomeventsv1.EventEnvelope
|
||||
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
|
||||
@ -454,14 +557,118 @@ func (r *Repository) ListPendingOutbox(ctx context.Context, limit int) ([]outbox
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// MarkOutboxPublished 标记事件投递成功。
|
||||
func (r *Repository) MarkOutboxPublished(ctx context.Context, eventID string) error {
|
||||
// ClaimPendingOutbox 抢占一批待投递事件,避免多 worker 同时投递同一批。
|
||||
func (r *Repository) ClaimPendingOutbox(ctx context.Context, workerID string, limit int, lockUntil time.Time) ([]outbox.Record, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if strings.TrimSpace(workerID) == "" {
|
||||
workerID = "room-outbox-worker"
|
||||
}
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rows, err := tx.QueryContext(ctx,
|
||||
`SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until, envelope, created_at, retry_count, COALESCE(last_error, '')
|
||||
FROM room_outbox
|
||||
WHERE app_code = ?
|
||||
AND (
|
||||
status IN (?, ?)
|
||||
OR (status = ? AND lock_until IS NOT NULL AND lock_until <= ?)
|
||||
)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE SKIP LOCKED`,
|
||||
appcode.FromContext(ctx),
|
||||
outbox.StatusPending,
|
||||
outbox.StatusRetryable,
|
||||
outbox.StatusDelivering,
|
||||
now,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records := make([]outbox.Record, 0, limit)
|
||||
eventIDs := make([]string, 0, limit)
|
||||
for rows.Next() {
|
||||
var envelopeBytes []byte
|
||||
var record outbox.Record
|
||||
var lockUntilValue sql.NullTime
|
||||
if err := rows.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntilValue, &envelopeBytes, &record.CreatedAt, &record.RetryCount, &record.LastError); err != nil {
|
||||
_ = rows.Close()
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
if lockUntilValue.Valid {
|
||||
record.LockUntilMS = lockUntilValue.Time.UnixMilli()
|
||||
}
|
||||
|
||||
var envelope roomeventsv1.EventEnvelope
|
||||
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
|
||||
_ = rows.Close()
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
record.Envelope = &envelope
|
||||
record.Envelope.AppCode = normalizedRecordAppCode(ctx, record.AppCode)
|
||||
records = append(records, record)
|
||||
eventIDs = append(eventIDs, record.EventID)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, eventID := range eventIDs {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE room_outbox
|
||||
SET status = ?, worker_id = ?, lock_until = ?
|
||||
WHERE app_code = ? AND event_id = ?`,
|
||||
outbox.StatusDelivering,
|
||||
workerID,
|
||||
lockUntil,
|
||||
appcode.FromContext(ctx),
|
||||
eventID,
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for index := range records {
|
||||
records[index].Status = outbox.StatusDelivering
|
||||
records[index].WorkerID = workerID
|
||||
records[index].LockUntilMS = lockUntil.UnixMilli()
|
||||
}
|
||||
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// MarkOutboxDelivered 标记事件投递成功。
|
||||
func (r *Repository) MarkOutboxDelivered(ctx context.Context, eventID string) error {
|
||||
// 成功后清空 last_error,后续扫描不再返回该事件。
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE room_outbox
|
||||
SET status = ?, last_error = NULL
|
||||
WHERE event_id = ?`,
|
||||
outbox.StatusPublished,
|
||||
SET status = ?, worker_id = '', lock_until = NULL, last_error = NULL
|
||||
WHERE app_code = ? AND event_id = ?`,
|
||||
outbox.StatusDelivered,
|
||||
appcode.FromContext(ctx),
|
||||
eventID,
|
||||
)
|
||||
|
||||
@ -470,13 +677,14 @@ func (r *Repository) MarkOutboxPublished(ctx context.Context, eventID string) er
|
||||
|
||||
// MarkOutboxFailed 标记事件投递失败并累计重试次数。
|
||||
func (r *Repository) MarkOutboxFailed(ctx context.Context, eventID string, lastErr string) error {
|
||||
// 失败仍保持 pending,worker 下一轮会继续扫描到该事件。
|
||||
// 失败转入 retryable,worker 下一轮可重新抢占。
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`UPDATE room_outbox
|
||||
SET status = ?, retry_count = retry_count + 1, last_error = ?
|
||||
WHERE event_id = ?`,
|
||||
outbox.StatusPending,
|
||||
SET status = ?, worker_id = '', lock_until = NULL, retry_count = retry_count + 1, last_error = ?
|
||||
WHERE app_code = ? AND event_id = ?`,
|
||||
outbox.StatusRetryable,
|
||||
lastErr,
|
||||
appcode.FromContext(ctx),
|
||||
eventID,
|
||||
)
|
||||
|
||||
|
||||
@ -63,24 +63,28 @@ func (r *Repository) SetRoomStatus(roomID string, status string) {
|
||||
}
|
||||
}
|
||||
|
||||
// OutboxRecord reads one outbox row, including published rows that normal scans skip.
|
||||
// OutboxRecord reads one outbox row, including delivered rows that normal scans skip.
|
||||
func (r *Repository) OutboxRecord(eventID string) (outbox.Record, bool) {
|
||||
r.t.Helper()
|
||||
|
||||
row := r.schema.DB.QueryRowContext(context.Background(), `
|
||||
SELECT app_code, event_id, event_type, room_id, status, envelope, created_at, retry_count, COALESCE(last_error, '')
|
||||
SELECT app_code, event_id, event_type, room_id, status, worker_id, lock_until, envelope, created_at, retry_count, COALESCE(last_error, '')
|
||||
FROM room_outbox
|
||||
WHERE app_code = ? AND event_id = ?
|
||||
`, appcode.Default, eventID)
|
||||
|
||||
var envelopeBytes []byte
|
||||
var record outbox.Record
|
||||
if err := row.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &envelopeBytes, &record.CreatedAt, &record.RetryCount, &record.LastError); err != nil {
|
||||
var lockUntil sql.NullTime
|
||||
if err := row.Scan(&record.AppCode, &record.EventID, &record.EventType, &record.RoomID, &record.Status, &record.WorkerID, &lockUntil, &envelopeBytes, &record.CreatedAt, &record.RetryCount, &record.LastError); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return outbox.Record{}, false
|
||||
}
|
||||
r.t.Fatalf("query room outbox record failed: %v", err)
|
||||
}
|
||||
if lockUntil.Valid {
|
||||
record.LockUntilMS = lockUntil.Time.UnixMilli()
|
||||
}
|
||||
|
||||
var envelope roomeventsv1.EventEnvelope
|
||||
if err := proto.Unmarshal(envelopeBytes, &envelope); err != nil {
|
||||
|
||||
@ -68,6 +68,13 @@ func (s *Server) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*
|
||||
return mapServiceResult(s.svc.LeaveRoom(ctx, req))
|
||||
}
|
||||
|
||||
// CloseRoom 代理到领域服务。
|
||||
func (s *Server) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (*roomv1.CloseRoomResponse, error) {
|
||||
// CloseRoom 统一关闭房间生命周期、清理 presence/麦位并写出系统事件。
|
||||
ctx = contextWithMetaApp(ctx, req.GetMeta())
|
||||
return mapServiceResult(s.svc.CloseRoom(ctx, req))
|
||||
}
|
||||
|
||||
// MicUp 代理到领域服务。
|
||||
func (s *Server) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error) {
|
||||
// 麦位修改由 Room Cell 串行执行,gRPC 层不做状态判断。
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user