外管改动和消息确认

This commit is contained in:
zhx 2026-07-23 16:47:40 +08:00
parent 680cbc219d
commit 0439da10f8
108 changed files with 8739 additions and 1804 deletions

View File

@ -1098,6 +1098,112 @@ func (x *RoomClosed) GetReason() string {
return ""
}
// RoomBanned 表达后台持久封禁完成;区别于 closed房主进房不能自行恢复。
type RoomBanned struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RoomBanned) Reset() {
*x = RoomBanned{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomBanned) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RoomBanned) ProtoMessage() {}
func (x *RoomBanned) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RoomBanned.ProtoReflect.Descriptor instead.
func (*RoomBanned) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{11}
}
func (x *RoomBanned) GetActorUserId() int64 {
if x != nil {
return x.ActorUserId
}
return 0
}
func (x *RoomBanned) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
// RoomUnbanned 表达人工解封完成;房间只回到 closed仍需房主正常进房后恢复。
type RoomUnbanned struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RoomUnbanned) Reset() {
*x = RoomUnbanned{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RoomUnbanned) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RoomUnbanned) ProtoMessage() {}
func (x *RoomUnbanned) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RoomUnbanned.ProtoReflect.Descriptor instead.
func (*RoomUnbanned) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{12}
}
func (x *RoomUnbanned) GetActorUserId() int64 {
if x != nil {
return x.ActorUserId
}
return 0
}
func (x *RoomUnbanned) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
// RoomMicChanged 表达任意上下麦和换麦位动作。
type RoomMicChanged struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1128,7 +1234,7 @@ type RoomMicChanged struct {
func (x *RoomMicChanged) Reset() {
*x = RoomMicChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1140,7 +1246,7 @@ func (x *RoomMicChanged) String() string {
func (*RoomMicChanged) ProtoMessage() {}
func (x *RoomMicChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[11]
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1153,7 +1259,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{11}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{13}
}
func (x *RoomMicChanged) GetActorUserId() int64 {
@ -1287,7 +1393,7 @@ type RoomMicSeatLocked struct {
func (x *RoomMicSeatLocked) Reset() {
*x = RoomMicSeatLocked{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1299,7 +1405,7 @@ func (x *RoomMicSeatLocked) String() string {
func (*RoomMicSeatLocked) ProtoMessage() {}
func (x *RoomMicSeatLocked) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[12]
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1312,7 +1418,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{12}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{14}
}
func (x *RoomMicSeatLocked) GetActorUserId() int64 {
@ -1347,7 +1453,7 @@ type RoomChatEnabledChanged struct {
func (x *RoomChatEnabledChanged) Reset() {
*x = RoomChatEnabledChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1359,7 +1465,7 @@ func (x *RoomChatEnabledChanged) String() string {
func (*RoomChatEnabledChanged) ProtoMessage() {}
func (x *RoomChatEnabledChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[13]
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1372,7 +1478,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{13}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{15}
}
func (x *RoomChatEnabledChanged) GetActorUserId() int64 {
@ -1402,7 +1508,7 @@ type RoomPasswordChanged struct {
func (x *RoomPasswordChanged) Reset() {
*x = RoomPasswordChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
mi := &file_proto_events_room_v1_events_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1414,7 +1520,7 @@ func (x *RoomPasswordChanged) String() string {
func (*RoomPasswordChanged) ProtoMessage() {}
func (x *RoomPasswordChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[14]
mi := &file_proto_events_room_v1_events_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1427,7 +1533,7 @@ func (x *RoomPasswordChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomPasswordChanged.ProtoReflect.Descriptor instead.
func (*RoomPasswordChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{14}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{16}
}
func (x *RoomPasswordChanged) GetActorUserId() int64 {
@ -1463,7 +1569,7 @@ type RoomAdminChanged struct {
func (x *RoomAdminChanged) Reset() {
*x = RoomAdminChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
mi := &file_proto_events_room_v1_events_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1475,7 +1581,7 @@ func (x *RoomAdminChanged) String() string {
func (*RoomAdminChanged) ProtoMessage() {}
func (x *RoomAdminChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[15]
mi := &file_proto_events_room_v1_events_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1488,7 +1594,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{15}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{17}
}
func (x *RoomAdminChanged) GetActorUserId() int64 {
@ -1524,7 +1630,7 @@ type RoomUserMuted struct {
func (x *RoomUserMuted) Reset() {
*x = RoomUserMuted{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[16]
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1536,7 +1642,7 @@ func (x *RoomUserMuted) String() string {
func (*RoomUserMuted) ProtoMessage() {}
func (x *RoomUserMuted) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[16]
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1549,7 +1655,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{16}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{18}
}
func (x *RoomUserMuted) GetActorUserId() int64 {
@ -1584,7 +1690,7 @@ type RoomUserKicked struct {
func (x *RoomUserKicked) Reset() {
*x = RoomUserKicked{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[17]
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1596,7 +1702,7 @@ func (x *RoomUserKicked) String() string {
func (*RoomUserKicked) ProtoMessage() {}
func (x *RoomUserKicked) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[17]
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1609,7 +1715,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{17}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{19}
}
func (x *RoomUserKicked) GetActorUserId() int64 {
@ -1637,7 +1743,7 @@ type RoomUserUnbanned struct {
func (x *RoomUserUnbanned) Reset() {
*x = RoomUserUnbanned{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1649,7 +1755,7 @@ func (x *RoomUserUnbanned) String() string {
func (*RoomUserUnbanned) ProtoMessage() {}
func (x *RoomUserUnbanned) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[18]
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1662,7 +1768,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{18}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{20}
}
func (x *RoomUserUnbanned) GetActorUserId() int64 {
@ -1733,7 +1839,7 @@ type RoomGiftSent struct {
func (x *RoomGiftSent) Reset() {
*x = RoomGiftSent{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1745,7 +1851,7 @@ func (x *RoomGiftSent) String() string {
func (*RoomGiftSent) ProtoMessage() {}
func (x *RoomGiftSent) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[19]
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1758,7 +1864,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{19}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{21}
}
func (x *RoomGiftSent) GetSenderUserId() int64 {
@ -2032,7 +2138,7 @@ type RoomGiftBatchLuckyResult struct {
func (x *RoomGiftBatchLuckyResult) Reset() {
*x = RoomGiftBatchLuckyResult{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2044,7 +2150,7 @@ func (x *RoomGiftBatchLuckyResult) String() string {
func (*RoomGiftBatchLuckyResult) ProtoMessage() {}
func (x *RoomGiftBatchLuckyResult) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[20]
mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2057,7 +2163,7 @@ func (x *RoomGiftBatchLuckyResult) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomGiftBatchLuckyResult.ProtoReflect.Descriptor instead.
func (*RoomGiftBatchLuckyResult) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{20}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{22}
}
func (x *RoomGiftBatchLuckyResult) GetEnabled() bool {
@ -2193,7 +2299,7 @@ type RoomGiftBatchTarget struct {
func (x *RoomGiftBatchTarget) Reset() {
*x = RoomGiftBatchTarget{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2205,7 +2311,7 @@ func (x *RoomGiftBatchTarget) String() string {
func (*RoomGiftBatchTarget) ProtoMessage() {}
func (x *RoomGiftBatchTarget) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[21]
mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2218,7 +2324,7 @@ func (x *RoomGiftBatchTarget) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomGiftBatchTarget.ProtoReflect.Descriptor instead.
func (*RoomGiftBatchTarget) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{21}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{23}
}
func (x *RoomGiftBatchTarget) GetTargetUserId() int64 {
@ -2334,7 +2440,7 @@ type RoomGiftBatchSent struct {
func (x *RoomGiftBatchSent) Reset() {
*x = RoomGiftBatchSent{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
mi := &file_proto_events_room_v1_events_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2346,7 +2452,7 @@ func (x *RoomGiftBatchSent) String() string {
func (*RoomGiftBatchSent) ProtoMessage() {}
func (x *RoomGiftBatchSent) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[22]
mi := &file_proto_events_room_v1_events_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2359,7 +2465,7 @@ func (x *RoomGiftBatchSent) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomGiftBatchSent.ProtoReflect.Descriptor instead.
func (*RoomGiftBatchSent) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{22}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{24}
}
func (x *RoomGiftBatchSent) GetSenderUserId() int64 {
@ -2571,7 +2677,7 @@ type RoomLuckyGiftDrawn struct {
func (x *RoomLuckyGiftDrawn) Reset() {
*x = RoomLuckyGiftDrawn{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
mi := &file_proto_events_room_v1_events_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2583,7 +2689,7 @@ func (x *RoomLuckyGiftDrawn) String() string {
func (*RoomLuckyGiftDrawn) ProtoMessage() {}
func (x *RoomLuckyGiftDrawn) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[23]
mi := &file_proto_events_room_v1_events_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2596,7 +2702,7 @@ func (x *RoomLuckyGiftDrawn) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomLuckyGiftDrawn.ProtoReflect.Descriptor instead.
func (*RoomLuckyGiftDrawn) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{23}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{25}
}
func (x *RoomLuckyGiftDrawn) GetDrawId() string {
@ -2819,7 +2925,7 @@ type RoomRobotLuckyGiftDrawn struct {
func (x *RoomRobotLuckyGiftDrawn) Reset() {
*x = RoomRobotLuckyGiftDrawn{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[24]
mi := &file_proto_events_room_v1_events_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2831,7 +2937,7 @@ func (x *RoomRobotLuckyGiftDrawn) String() string {
func (*RoomRobotLuckyGiftDrawn) ProtoMessage() {}
func (x *RoomRobotLuckyGiftDrawn) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[24]
mi := &file_proto_events_room_v1_events_proto_msgTypes[26]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2844,7 +2950,7 @@ func (x *RoomRobotLuckyGiftDrawn) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRobotLuckyGiftDrawn.ProtoReflect.Descriptor instead.
func (*RoomRobotLuckyGiftDrawn) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{24}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{26}
}
func (x *RoomRobotLuckyGiftDrawn) GetDrawId() string {
@ -2956,7 +3062,7 @@ type RoomHeatChanged struct {
func (x *RoomHeatChanged) Reset() {
*x = RoomHeatChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[25]
mi := &file_proto_events_room_v1_events_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2968,7 +3074,7 @@ func (x *RoomHeatChanged) String() string {
func (*RoomHeatChanged) ProtoMessage() {}
func (x *RoomHeatChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[25]
mi := &file_proto_events_room_v1_events_proto_msgTypes[27]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2981,7 +3087,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{25}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{27}
}
func (x *RoomHeatChanged) GetDelta() int64 {
@ -3010,7 +3116,7 @@ type RoomRankChanged struct {
func (x *RoomRankChanged) Reset() {
*x = RoomRankChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[26]
mi := &file_proto_events_room_v1_events_proto_msgTypes[28]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -3022,7 +3128,7 @@ func (x *RoomRankChanged) String() string {
func (*RoomRankChanged) ProtoMessage() {}
func (x *RoomRankChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[26]
mi := &file_proto_events_room_v1_events_proto_msgTypes[28]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -3035,7 +3141,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{26}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{28}
}
func (x *RoomRankChanged) GetUserId() int64 {
@ -3075,7 +3181,7 @@ type RoomRocketRewardGrant struct {
func (x *RoomRocketRewardGrant) Reset() {
*x = RoomRocketRewardGrant{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[27]
mi := &file_proto_events_room_v1_events_proto_msgTypes[29]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -3087,7 +3193,7 @@ func (x *RoomRocketRewardGrant) String() string {
func (*RoomRocketRewardGrant) ProtoMessage() {}
func (x *RoomRocketRewardGrant) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[27]
mi := &file_proto_events_room_v1_events_proto_msgTypes[29]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -3100,7 +3206,7 @@ func (x *RoomRocketRewardGrant) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketRewardGrant.ProtoReflect.Descriptor instead.
func (*RoomRocketRewardGrant) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{27}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{29}
}
func (x *RoomRocketRewardGrant) GetRewardRole() string {
@ -3182,7 +3288,7 @@ type RoomRocketFuelChanged struct {
func (x *RoomRocketFuelChanged) Reset() {
*x = RoomRocketFuelChanged{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[28]
mi := &file_proto_events_room_v1_events_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -3194,7 +3300,7 @@ func (x *RoomRocketFuelChanged) String() string {
func (*RoomRocketFuelChanged) ProtoMessage() {}
func (x *RoomRocketFuelChanged) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[28]
mi := &file_proto_events_room_v1_events_proto_msgTypes[30]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -3207,7 +3313,7 @@ func (x *RoomRocketFuelChanged) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketFuelChanged.ProtoReflect.Descriptor instead.
func (*RoomRocketFuelChanged) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{28}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{30}
}
func (x *RoomRocketFuelChanged) GetRocketId() string {
@ -3335,7 +3441,7 @@ type RoomRocketIgnited struct {
func (x *RoomRocketIgnited) Reset() {
*x = RoomRocketIgnited{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[29]
mi := &file_proto_events_room_v1_events_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -3347,7 +3453,7 @@ func (x *RoomRocketIgnited) String() string {
func (*RoomRocketIgnited) ProtoMessage() {}
func (x *RoomRocketIgnited) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[29]
mi := &file_proto_events_room_v1_events_proto_msgTypes[31]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -3360,7 +3466,7 @@ func (x *RoomRocketIgnited) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketIgnited.ProtoReflect.Descriptor instead.
func (*RoomRocketIgnited) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{29}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{31}
}
func (x *RoomRocketIgnited) GetRocketId() string {
@ -3493,7 +3599,7 @@ type RoomRocketLaunched struct {
func (x *RoomRocketLaunched) Reset() {
*x = RoomRocketLaunched{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[30]
mi := &file_proto_events_room_v1_events_proto_msgTypes[32]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -3505,7 +3611,7 @@ func (x *RoomRocketLaunched) String() string {
func (*RoomRocketLaunched) ProtoMessage() {}
func (x *RoomRocketLaunched) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[30]
mi := &file_proto_events_room_v1_events_proto_msgTypes[32]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -3518,7 +3624,7 @@ func (x *RoomRocketLaunched) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketLaunched.ProtoReflect.Descriptor instead.
func (*RoomRocketLaunched) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{30}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{32}
}
func (x *RoomRocketLaunched) GetRocketId() string {
@ -3596,7 +3702,7 @@ type RoomRocketRewardGranted struct {
func (x *RoomRocketRewardGranted) Reset() {
*x = RoomRocketRewardGranted{}
mi := &file_proto_events_room_v1_events_proto_msgTypes[31]
mi := &file_proto_events_room_v1_events_proto_msgTypes[33]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -3608,7 +3714,7 @@ func (x *RoomRocketRewardGranted) String() string {
func (*RoomRocketRewardGranted) ProtoMessage() {}
func (x *RoomRocketRewardGranted) ProtoReflect() protoreflect.Message {
mi := &file_proto_events_room_v1_events_proto_msgTypes[31]
mi := &file_proto_events_room_v1_events_proto_msgTypes[33]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -3621,7 +3727,7 @@ func (x *RoomRocketRewardGranted) ProtoReflect() protoreflect.Message {
// Deprecated: Use RoomRocketRewardGranted.ProtoReflect.Descriptor instead.
func (*RoomRocketRewardGranted) Descriptor() ([]byte, []int) {
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{31}
return file_proto_events_room_v1_events_proto_rawDescGZIP(), []int{33}
}
func (x *RoomRocketRewardGranted) GetRocketId() string {
@ -3765,6 +3871,13 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"\n" +
"RoomClosed\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12\x16\n" +
"\x06reason\x18\x02 \x01(\tR\x06reason\"H\n" +
"\n" +
"RoomBanned\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12\x16\n" +
"\x06reason\x18\x02 \x01(\tR\x06reason\"J\n" +
"\fRoomUnbanned\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12\x16\n" +
"\x06reason\x18\x02 \x01(\tR\x06reason\"\x9d\x05\n" +
"\x0eRoomMicChanged\x12\"\n" +
"\ractor_user_id\x18\x01 \x01(\x03R\vactorUserId\x12$\n" +
@ -4062,7 +4175,7 @@ func file_proto_events_room_v1_events_proto_rawDescGZIP() []byte {
return file_proto_events_room_v1_events_proto_rawDescData
}
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 32)
var file_proto_events_room_v1_events_proto_msgTypes = make([]protoimpl.MessageInfo, 34)
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
@ -4075,36 +4188,38 @@ var file_proto_events_room_v1_events_proto_goTypes = []any{
(*RoomUserJoined)(nil), // 8: hyapp.events.room.v1.RoomUserJoined
(*RoomUserLeft)(nil), // 9: hyapp.events.room.v1.RoomUserLeft
(*RoomClosed)(nil), // 10: hyapp.events.room.v1.RoomClosed
(*RoomMicChanged)(nil), // 11: hyapp.events.room.v1.RoomMicChanged
(*RoomMicSeatLocked)(nil), // 12: hyapp.events.room.v1.RoomMicSeatLocked
(*RoomChatEnabledChanged)(nil), // 13: hyapp.events.room.v1.RoomChatEnabledChanged
(*RoomPasswordChanged)(nil), // 14: hyapp.events.room.v1.RoomPasswordChanged
(*RoomAdminChanged)(nil), // 15: hyapp.events.room.v1.RoomAdminChanged
(*RoomUserMuted)(nil), // 16: hyapp.events.room.v1.RoomUserMuted
(*RoomUserKicked)(nil), // 17: hyapp.events.room.v1.RoomUserKicked
(*RoomUserUnbanned)(nil), // 18: hyapp.events.room.v1.RoomUserUnbanned
(*RoomGiftSent)(nil), // 19: hyapp.events.room.v1.RoomGiftSent
(*RoomGiftBatchLuckyResult)(nil), // 20: hyapp.events.room.v1.RoomGiftBatchLuckyResult
(*RoomGiftBatchTarget)(nil), // 21: hyapp.events.room.v1.RoomGiftBatchTarget
(*RoomGiftBatchSent)(nil), // 22: hyapp.events.room.v1.RoomGiftBatchSent
(*RoomLuckyGiftDrawn)(nil), // 23: hyapp.events.room.v1.RoomLuckyGiftDrawn
(*RoomRobotLuckyGiftDrawn)(nil), // 24: hyapp.events.room.v1.RoomRobotLuckyGiftDrawn
(*RoomHeatChanged)(nil), // 25: hyapp.events.room.v1.RoomHeatChanged
(*RoomRankChanged)(nil), // 26: hyapp.events.room.v1.RoomRankChanged
(*RoomRocketRewardGrant)(nil), // 27: hyapp.events.room.v1.RoomRocketRewardGrant
(*RoomRocketFuelChanged)(nil), // 28: hyapp.events.room.v1.RoomRocketFuelChanged
(*RoomRocketIgnited)(nil), // 29: hyapp.events.room.v1.RoomRocketIgnited
(*RoomRocketLaunched)(nil), // 30: hyapp.events.room.v1.RoomRocketLaunched
(*RoomRocketRewardGranted)(nil), // 31: hyapp.events.room.v1.RoomRocketRewardGranted
(*RoomBanned)(nil), // 11: hyapp.events.room.v1.RoomBanned
(*RoomUnbanned)(nil), // 12: hyapp.events.room.v1.RoomUnbanned
(*RoomMicChanged)(nil), // 13: hyapp.events.room.v1.RoomMicChanged
(*RoomMicSeatLocked)(nil), // 14: hyapp.events.room.v1.RoomMicSeatLocked
(*RoomChatEnabledChanged)(nil), // 15: hyapp.events.room.v1.RoomChatEnabledChanged
(*RoomPasswordChanged)(nil), // 16: hyapp.events.room.v1.RoomPasswordChanged
(*RoomAdminChanged)(nil), // 17: hyapp.events.room.v1.RoomAdminChanged
(*RoomUserMuted)(nil), // 18: hyapp.events.room.v1.RoomUserMuted
(*RoomUserKicked)(nil), // 19: hyapp.events.room.v1.RoomUserKicked
(*RoomUserUnbanned)(nil), // 20: hyapp.events.room.v1.RoomUserUnbanned
(*RoomGiftSent)(nil), // 21: hyapp.events.room.v1.RoomGiftSent
(*RoomGiftBatchLuckyResult)(nil), // 22: hyapp.events.room.v1.RoomGiftBatchLuckyResult
(*RoomGiftBatchTarget)(nil), // 23: hyapp.events.room.v1.RoomGiftBatchTarget
(*RoomGiftBatchSent)(nil), // 24: hyapp.events.room.v1.RoomGiftBatchSent
(*RoomLuckyGiftDrawn)(nil), // 25: hyapp.events.room.v1.RoomLuckyGiftDrawn
(*RoomRobotLuckyGiftDrawn)(nil), // 26: hyapp.events.room.v1.RoomRobotLuckyGiftDrawn
(*RoomHeatChanged)(nil), // 27: hyapp.events.room.v1.RoomHeatChanged
(*RoomRankChanged)(nil), // 28: hyapp.events.room.v1.RoomRankChanged
(*RoomRocketRewardGrant)(nil), // 29: hyapp.events.room.v1.RoomRocketRewardGrant
(*RoomRocketFuelChanged)(nil), // 30: hyapp.events.room.v1.RoomRocketFuelChanged
(*RoomRocketIgnited)(nil), // 31: hyapp.events.room.v1.RoomRocketIgnited
(*RoomRocketLaunched)(nil), // 32: hyapp.events.room.v1.RoomRocketLaunched
(*RoomRocketRewardGranted)(nil), // 33: hyapp.events.room.v1.RoomRocketRewardGranted
}
var file_proto_events_room_v1_events_proto_depIdxs = []int32{
4, // 0: hyapp.events.room.v1.RoomBackgroundChanged.media:type_name -> hyapp.events.room.v1.RoomMediaSnapshot
4, // 1: hyapp.events.room.v1.RoomImageMessageSent.image:type_name -> hyapp.events.room.v1.RoomMediaSnapshot
7, // 2: hyapp.events.room.v1.RoomUserJoined.entry_vehicle:type_name -> hyapp.events.room.v1.RoomEntryVehicleSnapshot
20, // 3: hyapp.events.room.v1.RoomGiftBatchTarget.lucky_gift:type_name -> hyapp.events.room.v1.RoomGiftBatchLuckyResult
21, // 4: hyapp.events.room.v1.RoomGiftBatchSent.targets:type_name -> hyapp.events.room.v1.RoomGiftBatchTarget
27, // 5: hyapp.events.room.v1.RoomRocketLaunched.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
27, // 6: hyapp.events.room.v1.RoomRocketRewardGranted.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
22, // 3: hyapp.events.room.v1.RoomGiftBatchTarget.lucky_gift:type_name -> hyapp.events.room.v1.RoomGiftBatchLuckyResult
23, // 4: hyapp.events.room.v1.RoomGiftBatchSent.targets:type_name -> hyapp.events.room.v1.RoomGiftBatchTarget
29, // 5: hyapp.events.room.v1.RoomRocketLaunched.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
29, // 6: hyapp.events.room.v1.RoomRocketRewardGranted.rewards:type_name -> hyapp.events.room.v1.RoomRocketRewardGrant
7, // [7:7] is the sub-list for method output_type
7, // [7:7] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
@ -4123,7 +4238,7 @@ func file_proto_events_room_v1_events_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_events_room_v1_events_proto_rawDesc), len(file_proto_events_room_v1_events_proto_rawDesc)),
NumEnums: 0,
NumMessages: 32,
NumMessages: 34,
NumExtensions: 0,
NumServices: 0,
},

View File

@ -137,6 +137,18 @@ message RoomClosed {
string reason = 2;
}
// RoomBanned closed
message RoomBanned {
int64 actor_user_id = 1;
string reason = 2;
}
// RoomUnbanned closed
message RoomUnbanned {
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

View File

@ -896,6 +896,8 @@ message AdminListRoomsRequest {
string sort_by = 7;
string sort_direction = 8;
int64 owner_user_id = 9;
// owner_user_ids facade count/
repeated int64 owner_user_ids = 10;
}
message AdminListRoomsResponse {
@ -934,6 +936,30 @@ message AdminUpdateRoomResponse {
RoomSnapshot room = 2;
}
message AdminBanRoomRequest {
RequestMeta meta = 1;
string reason = 2;
uint64 admin_id = 3;
string admin_name = 4;
}
message AdminBanRoomResponse {
CommandResult result = 1;
RoomSnapshot room = 2;
}
message AdminUnbanRoomRequest {
RequestMeta meta = 1;
string reason = 2;
uint64 admin_id = 3;
string admin_name = 4;
}
message AdminUnbanRoomResponse {
CommandResult result = 1;
RoomSnapshot room = 2;
}
message AdminDeleteRoomRequest {
RequestMeta meta = 1;
uint64 admin_id = 2;
@ -1709,6 +1735,8 @@ service RoomCommandService {
rpc LeaveRoom(LeaveRoomRequest) returns (LeaveRoomResponse);
rpc CloseRoom(CloseRoomRequest) returns (CloseRoomResponse);
rpc AdminUpdateRoom(AdminUpdateRoomRequest) returns (AdminUpdateRoomResponse);
rpc AdminBanRoom(AdminBanRoomRequest) returns (AdminBanRoomResponse);
rpc AdminUnbanRoom(AdminUnbanRoomRequest) returns (AdminUnbanRoomResponse);
rpc AdminDeleteRoom(AdminDeleteRoomRequest) returns (AdminDeleteRoomResponse);
rpc AdminUpdateRoomRocketConfig(AdminUpdateRoomRocketConfigRequest) returns (AdminUpdateRoomRocketConfigResponse);
rpc AdminUpdateRoomSeatConfig(AdminUpdateRoomSeatConfigRequest) returns (AdminUpdateRoomSeatConfigResponse);

View File

@ -30,6 +30,8 @@ const (
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
RoomCommandService_CloseRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CloseRoom"
RoomCommandService_AdminUpdateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoom"
RoomCommandService_AdminBanRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminBanRoom"
RoomCommandService_AdminUnbanRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUnbanRoom"
RoomCommandService_AdminDeleteRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminDeleteRoom"
RoomCommandService_AdminUpdateRoomRocketConfig_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoomRocketConfig"
RoomCommandService_AdminUpdateRoomSeatConfig_FullMethodName = "/hyapp.room.v1.RoomCommandService/AdminUpdateRoomSeatConfig"
@ -77,6 +79,8 @@ type RoomCommandServiceClient interface {
LeaveRoom(ctx context.Context, in *LeaveRoomRequest, opts ...grpc.CallOption) (*LeaveRoomResponse, error)
CloseRoom(ctx context.Context, in *CloseRoomRequest, opts ...grpc.CallOption) (*CloseRoomResponse, error)
AdminUpdateRoom(ctx context.Context, in *AdminUpdateRoomRequest, opts ...grpc.CallOption) (*AdminUpdateRoomResponse, error)
AdminBanRoom(ctx context.Context, in *AdminBanRoomRequest, opts ...grpc.CallOption) (*AdminBanRoomResponse, error)
AdminUnbanRoom(ctx context.Context, in *AdminUnbanRoomRequest, opts ...grpc.CallOption) (*AdminUnbanRoomResponse, error)
AdminDeleteRoom(ctx context.Context, in *AdminDeleteRoomRequest, opts ...grpc.CallOption) (*AdminDeleteRoomResponse, error)
AdminUpdateRoomRocketConfig(ctx context.Context, in *AdminUpdateRoomRocketConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomRocketConfigResponse, error)
AdminUpdateRoomSeatConfig(ctx context.Context, in *AdminUpdateRoomSeatConfigRequest, opts ...grpc.CallOption) (*AdminUpdateRoomSeatConfigResponse, error)
@ -226,6 +230,26 @@ func (c *roomCommandServiceClient) AdminUpdateRoom(ctx context.Context, in *Admi
return out, nil
}
func (c *roomCommandServiceClient) AdminBanRoom(ctx context.Context, in *AdminBanRoomRequest, opts ...grpc.CallOption) (*AdminBanRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminBanRoomResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminBanRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) AdminUnbanRoom(ctx context.Context, in *AdminUnbanRoomRequest, opts ...grpc.CallOption) (*AdminUnbanRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminUnbanRoomResponse)
err := c.cc.Invoke(ctx, RoomCommandService_AdminUnbanRoom_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *roomCommandServiceClient) AdminDeleteRoom(ctx context.Context, in *AdminDeleteRoomRequest, opts ...grpc.CallOption) (*AdminDeleteRoomResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AdminDeleteRoomResponse)
@ -523,6 +547,8 @@ type RoomCommandServiceServer interface {
LeaveRoom(context.Context, *LeaveRoomRequest) (*LeaveRoomResponse, error)
CloseRoom(context.Context, *CloseRoomRequest) (*CloseRoomResponse, error)
AdminUpdateRoom(context.Context, *AdminUpdateRoomRequest) (*AdminUpdateRoomResponse, error)
AdminBanRoom(context.Context, *AdminBanRoomRequest) (*AdminBanRoomResponse, error)
AdminUnbanRoom(context.Context, *AdminUnbanRoomRequest) (*AdminUnbanRoomResponse, error)
AdminDeleteRoom(context.Context, *AdminDeleteRoomRequest) (*AdminDeleteRoomResponse, error)
AdminUpdateRoomRocketConfig(context.Context, *AdminUpdateRoomRocketConfigRequest) (*AdminUpdateRoomRocketConfigResponse, error)
AdminUpdateRoomSeatConfig(context.Context, *AdminUpdateRoomSeatConfigRequest) (*AdminUpdateRoomSeatConfigResponse, error)
@ -595,6 +621,12 @@ func (UnimplementedRoomCommandServiceServer) CloseRoom(context.Context, *CloseRo
func (UnimplementedRoomCommandServiceServer) AdminUpdateRoom(context.Context, *AdminUpdateRoomRequest) (*AdminUpdateRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminUpdateRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminBanRoom(context.Context, *AdminBanRoomRequest) (*AdminBanRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminBanRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminUnbanRoom(context.Context, *AdminUnbanRoomRequest) (*AdminUnbanRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminUnbanRoom not implemented")
}
func (UnimplementedRoomCommandServiceServer) AdminDeleteRoom(context.Context, *AdminDeleteRoomRequest) (*AdminDeleteRoomResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AdminDeleteRoom not implemented")
}
@ -898,6 +930,42 @@ func _RoomCommandService_AdminUpdateRoom_Handler(srv interface{}, ctx context.Co
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminBanRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminBanRoomRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminBanRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminBanRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminBanRoom(ctx, req.(*AdminBanRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminUnbanRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminUnbanRoomRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(RoomCommandServiceServer).AdminUnbanRoom(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: RoomCommandService_AdminUnbanRoom_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(RoomCommandServiceServer).AdminUnbanRoom(ctx, req.(*AdminUnbanRoomRequest))
}
return interceptor(ctx, in, info, handler)
}
func _RoomCommandService_AdminDeleteRoom_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AdminDeleteRoomRequest)
if err := dec(in); err != nil {
@ -1453,6 +1521,14 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AdminUpdateRoom",
Handler: _RoomCommandService_AdminUpdateRoom_Handler,
},
{
MethodName: "AdminBanRoom",
Handler: _RoomCommandService_AdminBanRoom_Handler,
},
{
MethodName: "AdminUnbanRoom",
Handler: _RoomCommandService_AdminUnbanRoom_Handler,
},
{
MethodName: "AdminDeleteRoom",
Handler: _RoomCommandService_AdminDeleteRoom_Handler,

File diff suppressed because it is too large Load Diff

View File

@ -49,6 +49,8 @@ message BDProfile {
int64 created_by_user_id = 6;
int64 created_at_ms = 7;
int64 updated_at_ms = 8;
// parent_owner_user_id owner App BD parent_leader_user_id
int64 parent_owner_user_id = 9;
}
// CoinSellerProfile wallet-service user-service
@ -177,6 +179,102 @@ message RoleInvitation {
int64 processed_at_ms = 14;
int64 created_at_ms = 15;
int64 updated_at_ms = 16;
// external_operator_user_id 0
int64 external_operator_user_id = 17;
// parent_owner_user_id BD owner
int64 parent_owner_user_id = 18;
// parent_agency_id Host Agency Agency
int64 parent_agency_id = 19;
}
// ExternalTeamUser user-service
// roles bd/agency/host LocalSuperAdmin App
message ExternalTeamUser {
int64 user_id = 1;
string default_display_user_id = 2;
string current_display_user_id = 3;
string username = 4;
string avatar = 5;
string status = 6;
int64 region_id = 7;
repeated string roles = 8;
int64 parent_owner_user_id = 9;
int64 parent_bd_user_id = 10;
int64 agency_id = 11;
}
message ExternalInviteBDRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 operator_user_id = 3;
int64 target_user_id = 4;
int64 expected_region_id = 5;
}
message ExternalInviteBDResponse {
RoleInvitation invitation = 1;
}
message ExternalInviteAgencyRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 operator_user_id = 3;
int64 parent_bd_user_id = 4;
int64 target_user_id = 5;
string agency_name = 6;
repeated int64 scope_owner_user_ids = 7;
int64 expected_region_id = 8;
}
message ExternalInviteAgencyResponse {
RoleInvitation invitation = 1;
}
message ExternalInviteHostRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 operator_user_id = 3;
int64 agency_id = 4;
int64 target_user_id = 5;
repeated int64 scope_owner_user_ids = 6;
int64 expected_region_id = 7;
}
message ExternalInviteHostResponse {
RoleInvitation invitation = 1;
}
message ListExternalTeamUsersRequest {
RequestMeta meta = 1;
repeated int64 scope_owner_user_ids = 2;
int64 region_id = 3;
// role bd/agency/host
string role = 4;
string status = 5;
string keyword = 6;
int32 page = 7;
int32 page_size = 8;
}
message ListExternalTeamUsersResponse {
repeated ExternalTeamUser users = 1;
int64 total = 2;
int32 page = 3;
int32 page_size = 4;
}
message SearchExternalInvitationCandidateRequest {
RequestMeta meta = 1;
int64 operator_user_id = 2;
int64 expected_region_id = 3;
// invitation_type
string invitation_type = 4;
// keyword ID/
string keyword = 5;
}
message SearchExternalInvitationCandidateResponse {
ExternalTeamUser user = 1;
}
message SearchAgenciesRequest {
@ -795,4 +893,11 @@ service UserHostAdminService {
rpc DeleteAgency(DeleteAgencyRequest) returns (DeleteAgencyResponse);
rpc SetAgencyStatus(SetAgencyStatusRequest) returns (SetAgencyStatusResponse);
rpc SetAgencyJoinEnabled(SetAgencyJoinEnabledRequest) returns (SetAgencyJoinEnabledResponse);
// ExternalInvite* user-service
rpc ExternalInviteBD(ExternalInviteBDRequest) returns (ExternalInviteBDResponse);
rpc ExternalInviteAgency(ExternalInviteAgencyRequest) returns (ExternalInviteAgencyResponse);
rpc ExternalInviteHost(ExternalInviteHostRequest) returns (ExternalInviteHostResponse);
// scope region_id fail-closed退 App
rpc ListExternalTeamUsers(ListExternalTeamUsersRequest) returns (ListExternalTeamUsersResponse);
rpc SearchExternalInvitationCandidate(SearchExternalInvitationCandidateRequest) returns (SearchExternalInvitationCandidateResponse);
}

View File

@ -1318,6 +1318,11 @@ const (
UserHostAdminService_DeleteAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/DeleteAgency"
UserHostAdminService_SetAgencyStatus_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyStatus"
UserHostAdminService_SetAgencyJoinEnabled_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SetAgencyJoinEnabled"
UserHostAdminService_ExternalInviteBD_FullMethodName = "/hyapp.user.v1.UserHostAdminService/ExternalInviteBD"
UserHostAdminService_ExternalInviteAgency_FullMethodName = "/hyapp.user.v1.UserHostAdminService/ExternalInviteAgency"
UserHostAdminService_ExternalInviteHost_FullMethodName = "/hyapp.user.v1.UserHostAdminService/ExternalInviteHost"
UserHostAdminService_ListExternalTeamUsers_FullMethodName = "/hyapp.user.v1.UserHostAdminService/ListExternalTeamUsers"
UserHostAdminService_SearchExternalInvitationCandidate_FullMethodName = "/hyapp.user.v1.UserHostAdminService/SearchExternalInvitationCandidate"
)
// UserHostAdminServiceClient is the client API for UserHostAdminService service.
@ -1340,6 +1345,13 @@ type UserHostAdminServiceClient interface {
DeleteAgency(ctx context.Context, in *DeleteAgencyRequest, opts ...grpc.CallOption) (*DeleteAgencyResponse, error)
SetAgencyStatus(ctx context.Context, in *SetAgencyStatusRequest, opts ...grpc.CallOption) (*SetAgencyStatusResponse, error)
SetAgencyJoinEnabled(ctx context.Context, in *SetAgencyJoinEnabledRequest, opts ...grpc.CallOption) (*SetAgencyJoinEnabledResponse, error)
// ExternalInvite* 是外管平台唯一邀请入口;区域和所选父级在 user-service 事务内重验。
ExternalInviteBD(ctx context.Context, in *ExternalInviteBDRequest, opts ...grpc.CallOption) (*ExternalInviteBDResponse, error)
ExternalInviteAgency(ctx context.Context, in *ExternalInviteAgencyRequest, opts ...grpc.CallOption) (*ExternalInviteAgencyResponse, error)
ExternalInviteHost(ctx context.Context, in *ExternalInviteHostRequest, opts ...grpc.CallOption) (*ExternalInviteHostResponse, error)
// 团队 scope 为空或 region_id 无效时 fail-closed不会退化成全 App 列表。
ListExternalTeamUsers(ctx context.Context, in *ListExternalTeamUsersRequest, opts ...grpc.CallOption) (*ListExternalTeamUsersResponse, error)
SearchExternalInvitationCandidate(ctx context.Context, in *SearchExternalInvitationCandidateRequest, opts ...grpc.CallOption) (*SearchExternalInvitationCandidateResponse, error)
}
type userHostAdminServiceClient struct {
@ -1480,6 +1492,56 @@ func (c *userHostAdminServiceClient) SetAgencyJoinEnabled(ctx context.Context, i
return out, nil
}
func (c *userHostAdminServiceClient) ExternalInviteBD(ctx context.Context, in *ExternalInviteBDRequest, opts ...grpc.CallOption) (*ExternalInviteBDResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExternalInviteBDResponse)
err := c.cc.Invoke(ctx, UserHostAdminService_ExternalInviteBD_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostAdminServiceClient) ExternalInviteAgency(ctx context.Context, in *ExternalInviteAgencyRequest, opts ...grpc.CallOption) (*ExternalInviteAgencyResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExternalInviteAgencyResponse)
err := c.cc.Invoke(ctx, UserHostAdminService_ExternalInviteAgency_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostAdminServiceClient) ExternalInviteHost(ctx context.Context, in *ExternalInviteHostRequest, opts ...grpc.CallOption) (*ExternalInviteHostResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ExternalInviteHostResponse)
err := c.cc.Invoke(ctx, UserHostAdminService_ExternalInviteHost_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostAdminServiceClient) ListExternalTeamUsers(ctx context.Context, in *ListExternalTeamUsersRequest, opts ...grpc.CallOption) (*ListExternalTeamUsersResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListExternalTeamUsersResponse)
err := c.cc.Invoke(ctx, UserHostAdminService_ListExternalTeamUsers_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostAdminServiceClient) SearchExternalInvitationCandidate(ctx context.Context, in *SearchExternalInvitationCandidateRequest, opts ...grpc.CallOption) (*SearchExternalInvitationCandidateResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SearchExternalInvitationCandidateResponse)
err := c.cc.Invoke(ctx, UserHostAdminService_SearchExternalInvitationCandidate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserHostAdminServiceServer is the server API for UserHostAdminService service.
// All implementations must embed UnimplementedUserHostAdminServiceServer
// for forward compatibility.
@ -1500,6 +1562,13 @@ type UserHostAdminServiceServer interface {
DeleteAgency(context.Context, *DeleteAgencyRequest) (*DeleteAgencyResponse, error)
SetAgencyStatus(context.Context, *SetAgencyStatusRequest) (*SetAgencyStatusResponse, error)
SetAgencyJoinEnabled(context.Context, *SetAgencyJoinEnabledRequest) (*SetAgencyJoinEnabledResponse, error)
// ExternalInvite* 是外管平台唯一邀请入口;区域和所选父级在 user-service 事务内重验。
ExternalInviteBD(context.Context, *ExternalInviteBDRequest) (*ExternalInviteBDResponse, error)
ExternalInviteAgency(context.Context, *ExternalInviteAgencyRequest) (*ExternalInviteAgencyResponse, error)
ExternalInviteHost(context.Context, *ExternalInviteHostRequest) (*ExternalInviteHostResponse, error)
// 团队 scope 为空或 region_id 无效时 fail-closed不会退化成全 App 列表。
ListExternalTeamUsers(context.Context, *ListExternalTeamUsersRequest) (*ListExternalTeamUsersResponse, error)
SearchExternalInvitationCandidate(context.Context, *SearchExternalInvitationCandidateRequest) (*SearchExternalInvitationCandidateResponse, error)
mustEmbedUnimplementedUserHostAdminServiceServer()
}
@ -1549,6 +1618,21 @@ func (UnimplementedUserHostAdminServiceServer) SetAgencyStatus(context.Context,
func (UnimplementedUserHostAdminServiceServer) SetAgencyJoinEnabled(context.Context, *SetAgencyJoinEnabledRequest) (*SetAgencyJoinEnabledResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SetAgencyJoinEnabled not implemented")
}
func (UnimplementedUserHostAdminServiceServer) ExternalInviteBD(context.Context, *ExternalInviteBDRequest) (*ExternalInviteBDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExternalInviteBD not implemented")
}
func (UnimplementedUserHostAdminServiceServer) ExternalInviteAgency(context.Context, *ExternalInviteAgencyRequest) (*ExternalInviteAgencyResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExternalInviteAgency not implemented")
}
func (UnimplementedUserHostAdminServiceServer) ExternalInviteHost(context.Context, *ExternalInviteHostRequest) (*ExternalInviteHostResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ExternalInviteHost not implemented")
}
func (UnimplementedUserHostAdminServiceServer) ListExternalTeamUsers(context.Context, *ListExternalTeamUsersRequest) (*ListExternalTeamUsersResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListExternalTeamUsers not implemented")
}
func (UnimplementedUserHostAdminServiceServer) SearchExternalInvitationCandidate(context.Context, *SearchExternalInvitationCandidateRequest) (*SearchExternalInvitationCandidateResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SearchExternalInvitationCandidate not implemented")
}
func (UnimplementedUserHostAdminServiceServer) mustEmbedUnimplementedUserHostAdminServiceServer() {}
func (UnimplementedUserHostAdminServiceServer) testEmbeddedByValue() {}
@ -1804,6 +1888,96 @@ func _UserHostAdminService_SetAgencyJoinEnabled_Handler(srv interface{}, ctx con
return interceptor(ctx, in, info, handler)
}
func _UserHostAdminService_ExternalInviteBD_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExternalInviteBDRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostAdminServiceServer).ExternalInviteBD(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostAdminService_ExternalInviteBD_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostAdminServiceServer).ExternalInviteBD(ctx, req.(*ExternalInviteBDRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostAdminService_ExternalInviteAgency_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExternalInviteAgencyRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostAdminServiceServer).ExternalInviteAgency(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostAdminService_ExternalInviteAgency_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostAdminServiceServer).ExternalInviteAgency(ctx, req.(*ExternalInviteAgencyRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostAdminService_ExternalInviteHost_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ExternalInviteHostRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostAdminServiceServer).ExternalInviteHost(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostAdminService_ExternalInviteHost_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostAdminServiceServer).ExternalInviteHost(ctx, req.(*ExternalInviteHostRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostAdminService_ListExternalTeamUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListExternalTeamUsersRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostAdminServiceServer).ListExternalTeamUsers(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostAdminService_ListExternalTeamUsers_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostAdminServiceServer).ListExternalTeamUsers(ctx, req.(*ListExternalTeamUsersRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostAdminService_SearchExternalInvitationCandidate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SearchExternalInvitationCandidateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostAdminServiceServer).SearchExternalInvitationCandidate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostAdminService_SearchExternalInvitationCandidate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostAdminServiceServer).SearchExternalInvitationCandidate(ctx, req.(*SearchExternalInvitationCandidateRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserHostAdminService_ServiceDesc is the grpc.ServiceDesc for UserHostAdminService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1863,6 +2037,26 @@ var UserHostAdminService_ServiceDesc = grpc.ServiceDesc{
MethodName: "SetAgencyJoinEnabled",
Handler: _UserHostAdminService_SetAgencyJoinEnabled_Handler,
},
{
MethodName: "ExternalInviteBD",
Handler: _UserHostAdminService_ExternalInviteBD_Handler,
},
{
MethodName: "ExternalInviteAgency",
Handler: _UserHostAdminService_ExternalInviteAgency_Handler,
},
{
MethodName: "ExternalInviteHost",
Handler: _UserHostAdminService_ExternalInviteHost_Handler,
},
{
MethodName: "ListExternalTeamUsers",
Handler: _UserHostAdminService_ListExternalTeamUsers_Handler,
},
{
MethodName: "SearchExternalInvitationCandidate",
Handler: _UserHostAdminService_SearchExternalInvitationCandidate_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/host.proto",

View File

@ -10327,6 +10327,9 @@ type UserEquippedResources struct {
Resources []*UserResourceEntitlement `protobuf:"bytes,2,rep,name=resources,proto3" json:"resources,omitempty"`
// allowed_vip_benefit_codes 只返回请求中最终可执行的权益;设置值本身不能越权进入此集合。
AllowedVipBenefitCodes []string `protobuf:"bytes,3,rep,name=allowed_vip_benefit_codes,json=allowedVipBenefitCodes,proto3" json:"allowed_vip_benefit_codes,omitempty"`
// effective_vip_level 在请求 vip_benefit_codes 时与权益判定共享同一付费/体验卡快照;
// 未请求时为 0调用方不得再按资源反推等级。
EffectiveVipLevel int32 `protobuf:"varint,4,opt,name=effective_vip_level,json=effectiveVipLevel,proto3" json:"effective_vip_level,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -10382,6 +10385,13 @@ func (x *UserEquippedResources) GetAllowedVipBenefitCodes() []string {
return nil
}
func (x *UserEquippedResources) GetEffectiveVipLevel() int32 {
if x != nil {
return x.EffectiveVipLevel
}
return 0
}
type BatchGetUserEquippedResourcesResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Users []*UserEquippedResources `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"`
@ -10434,6 +10444,12 @@ type ListResourceGrantsRequest struct {
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
Page int32 `protobuf:"varint,5,opt,name=page,proto3" json:"page,omitempty"`
PageSize int32 `protobuf:"varint,6,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
// operator_user_ids 由可信后台入口注入;外管历史同时包含当前 linked App user
// 和旧版本 synthetic external operator不能依赖前端传值做授权过滤。
OperatorUserIds []int64 `protobuf:"varint,7,rep,packed,name=operator_user_ids,json=operatorUserIds,proto3" json:"operator_user_ids,omitempty"`
// grant_source 与 operator_user_ids 共同限定发放入口,避免同一用户的活动奖励
// 或主后台发放混入外管个人历史。
GrantSource string `protobuf:"bytes,8,opt,name=grant_source,json=grantSource,proto3" json:"grant_source,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -10510,6 +10526,20 @@ func (x *ListResourceGrantsRequest) GetPageSize() int32 {
return 0
}
func (x *ListResourceGrantsRequest) GetOperatorUserIds() []int64 {
if x != nil {
return x.OperatorUserIds
}
return nil
}
func (x *ListResourceGrantsRequest) GetGrantSource() string {
if x != nil {
return x.GrantSource
}
return ""
}
type ListResourceGrantsResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Grants []*ResourceGrant `protobuf:"bytes,1,rep,name=grants,proto3" json:"grants,omitempty"`
@ -28311,13 +28341,14 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12\x19\n" +
"\buser_ids\x18\x03 \x03(\x03R\auserIds\x12%\n" +
"\x0eresource_types\x18\x04 \x03(\tR\rresourceTypes\x12*\n" +
"\x11vip_benefit_codes\x18\x05 \x03(\tR\x0fvipBenefitCodes\"\xb3\x01\n" +
"\x11vip_benefit_codes\x18\x05 \x03(\tR\x0fvipBenefitCodes\"\xe3\x01\n" +
"\x15UserEquippedResources\x12\x17\n" +
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12F\n" +
"\tresources\x18\x02 \x03(\v2(.hyapp.wallet.v1.UserResourceEntitlementR\tresources\x129\n" +
"\x19allowed_vip_benefit_codes\x18\x03 \x03(\tR\x16allowedVipBenefitCodes\"e\n" +
"\x19allowed_vip_benefit_codes\x18\x03 \x03(\tR\x16allowedVipBenefitCodes\x12.\n" +
"\x13effective_vip_level\x18\x04 \x01(\x05R\x11effectiveVipLevel\"e\n" +
"%BatchGetUserEquippedResourcesResponse\x12<\n" +
"\x05users\x18\x01 \x03(\v2&.hyapp.wallet.v1.UserEquippedResourcesR\x05users\"\xc4\x01\n" +
"\x05users\x18\x01 \x03(\v2&.hyapp.wallet.v1.UserEquippedResourcesR\x05users\"\x93\x02\n" +
"\x19ListResourceGrantsRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -28325,7 +28356,9 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x0etarget_user_id\x18\x03 \x01(\x03R\ftargetUserId\x12\x16\n" +
"\x06status\x18\x04 \x01(\tR\x06status\x12\x12\n" +
"\x04page\x18\x05 \x01(\x05R\x04page\x12\x1b\n" +
"\tpage_size\x18\x06 \x01(\x05R\bpageSize\"j\n" +
"\tpage_size\x18\x06 \x01(\x05R\bpageSize\x12*\n" +
"\x11operator_user_ids\x18\a \x03(\x03R\x0foperatorUserIds\x12!\n" +
"\fgrant_source\x18\b \x01(\tR\vgrantSource\"j\n" +
"\x1aListResourceGrantsResponse\x126\n" +
"\x06grants\x18\x01 \x03(\v2\x1e.hyapp.wallet.v1.ResourceGrantR\x06grants\x12\x14\n" +
"\x05total\x18\x02 \x01(\x03R\x05total\"\xa9\x02\n" +

View File

@ -1226,6 +1226,9 @@ message UserEquippedResources {
repeated UserResourceEntitlement resources = 2;
// allowed_vip_benefit_codes
repeated string allowed_vip_benefit_codes = 3;
// effective_vip_level vip_benefit_codes /
// 0
int32 effective_vip_level = 4;
}
message BatchGetUserEquippedResourcesResponse {
@ -1239,6 +1242,12 @@ message ListResourceGrantsRequest {
string status = 4;
int32 page = 5;
int32 page_size = 6;
// operator_user_ids linked App user
// synthetic external operator
repeated int64 operator_user_ids = 7;
// grant_source operator_user_ids
//
string grant_source = 8;
}
message ListResourceGrantsResponse {

View File

@ -493,7 +493,7 @@ final benefitHandlers = <String, VipBenefitHandler>{
- `effective_source` 取值为 `paid` / `trial` / `none`
- `paid_vip` 是付费会员事实;`equipped_trial_card` 是当前佩戴卡;`effective_vip` 是页面和房间展示的唯一真值。
- 后台关闭 `trial_card_enabled` 时,`equipped_trial_card` 原始背包事实仍返回,但 `effective_source` 会立即回落为 `paid`Flutter 不得继续沿用旧体验卡权限缓存。
- 当体验卡被佩戴时,不限制卡等级高低,它会覆盖付费 VIP 成为 `effective_vip`卸下或过期后自然回落到仍有效的 `paid_vip`
- 佩戴体验卡不能降低当前 paid/trial 最终等级;有效且等级不低于付费 VIP 的已佩戴卡会成为 `effective_vip`卸下或过期后自然回落到仍有效的 `paid_vip`
- 体验卡状态下,`daily_coin_rebate` 不会进入 `effective_benefits`。Flutter 不要只看卡等级猜测权益。
- 无 VIP 时,`effective_source="none"``effective_vip.active=false``effective_benefits=[]`
- `user_settings.updated_at_ms=0` 表示用户尚未修改;进房/上线通知默认为 `true`,隐藏个人数据/匿名访问/榜单隐身默认为 `false`。设置在无 VIP 时也保留,但不会单独授权。
@ -615,7 +615,7 @@ Flutter 收到后用 `event_id` 做会话内去重,再调 `GET /vip/me`。不
- Fami 高级升级:新截止时间 = 购买时间 + `duration_ms`,旧低等级剩余时间丢弃。例如 VIP3 剩 15 天购买 VIP4 30 天,结果是 VIP4 30 天。
- 购买更低等级由后端拒绝,错误 `VIP_DOWNGRADE_NOT_ALLOWED`
- 余额不足返回 `VIP_INSUFFICIENT_COIN`;套餐不存在、停用或不可购买返回 `VIP_PACKAGE_NOT_PURCHASABLE`;体系停用返回 `VIP_PROGRAM_INACTIVE`
- 如果购买时仍佩戴体验卡,购买结果会写入 `paid_vip`,但当前展示仍可能是 `effective_source="trial"`。购买成功页也必须以返回的 `state.effective_vip` 为准
- 如果购买时仍佩戴体验卡:目标等级大于等于体验等级时,后端会在购买事务内回收卡片及体验资源并返回 `effective_source="paid"`;目标等级低于体验等级时,`paid_vip` 独立计时且当前展示仍为 `trial`。购买成功页必须整体采用返回的 `state`
- 购买成功后用 `coin_balance.version` 防止旧响应覆盖新余额。`version=0` 只可能来自升级前的历史幂等订单,必须刷新钱包余额。
## 9. 体验卡背包列表
@ -1417,12 +1417,12 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
### 场景处理
- 在进程内保存 `process_boot_id`、当前 UTC 日和该日 `command_id`,不写 SharedPreferences/数据库/磁盘文件。
- 同进程同 UTC 日首次进入 App`/vip/me` 中具有 `online_global_notice` 资格、`online_global_notice_enabled=true` 时触发。成功后当日不再产生新 command。
- 超时/5xx 可用同一 `command_id` 重试;不能在同进程同日换 command 规避幂等
- 杀掉进程后内存清空,新进程产生新 `process_boot_id``command_id`,同日可再播一次,这是已确认产品规则
- 在进程内保存 `process_boot_id``session_nonce`当前 UTC 日和该日 `command_id`,不写 SharedPreferences/数据库/磁盘文件。
- 同一登录会话在 UTC 日内首次进入 App`/vip/me` 中具有 `online_global_notice` 资格、`online_global_notice_enabled=true` 时触发。成功后该登录会话当日不再产生新 command。
- 超时/5xx 可用同一 `command_id` 重试;退出登录或会话终态失效时必须轮换 `session_nonce`,下一次账号密码登录使用新 command不能被上一会话的服务端幂等结果吞掉
- 杀掉进程后内存清空,新进程产生新 `process_boot_id``session_nonce``command_id`,同日可再播一次。
- 权益过期、后台停用、体验卡不允许或用户关闭开关时,服务端返回 `VIP_BENEFIT_REQUIRED`Flutter 不做本地飘屏,可重拉 `/vip/me`
- 不把“每日一次”放到服务端持久化去重,否则无法满足杀进程后同日可再展示。
- 不把“每日一次”放到服务端持久化去重,否则无法满足新登录会话或杀进程后同日可再展示。
## 17. 权益场景和完成边界
@ -1431,7 +1431,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
| App 维度 VIP 体系 | 始终渲染 `program_config` 和接口返回的动态等级,不按 Fami/Lalu 写死业务分支 | 已强制。Fami 为 `tiered_privilege_v1`Lalu 为 `legacy_timed` |
| Fami 高级升级 | 直接展示购买响应的新截止时间 | 已强制,旧低级剩余时间丢弃 |
| 同级续期 | 不在本地计算,以响应为准 | 已强制,从旧截止时间累加 |
| 体验卡自由切换 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 已强制,不限等级,不作废旧卡,不改绝对截止时间 |
| 体验卡切换与购买冲突 | 按 `entitlement_id` 佩戴,每次直接替换全局 `state` | 佩戴不能降低最终等级;普通切换不改绝对截止时间;购买等级大于等于体验等级时回收当前卡片及体验资源 |
| 体验卡权益 | 只渲染 `effective_benefits` 资格 | 已强制过滤 `trial_enabled=false`,金币返现不给体验卡 |
| VIP 功能开关 | 读 `state.user_settings`,用 PATCH 局部修改 | 已按 App 持久化,无记录时通知开、隐私关,开关不单独授权 |
| 进房高亮通知 | 本人首屏读 Join HTTP `effective_vip`,其他成员消费当前房间 `room_user_joined` | 后端已同时校验权益与用户开关,并透传 Join HTTP 和当前房间 IM不发全服 |
@ -1440,7 +1440,7 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
| 平台封禁/风控/超级管理 | Flutter 不作 VIP 免责提示 | 独立 `SystemEvictUser` 治理链路会绕过防踢VIP 不能绕过平台治理 |
| 自定义房间背景 | 按现有房间背景页面调用 | `tiered_privilege_v1` 已由 room-service 强制校验 `custom_room_background` |
| 金币返现 | 消费 system inbox批量恢复状态手动领取后使用响应余额 | 已完成 UTC 日资格、异步系统消息、24 小时窗口和 wallet 原子入账;体验卡不参与 |
| 上线全服通知 | 按进程内 UTC 日规则请求后端,消费全局播报群 `vip_online_notice` | 已完成权益/开关校验、服务端资料与头像框、activity outbox 和腾讯云 IM服务端不做日级去重 |
| 上线全服通知 | 按登录会话内 UTC 日规则请求后端,消费全局播报群 `vip_online_notice` | 已完成权益/开关校验、服务端资料与头像框、activity outbox 和腾讯云 IM服务端不做日级去重 |
| 其他 VIP 装扮/功能 | 可根据 `effective_benefits` 展示锁定/解锁态;有素材时使用后端 URL | 权益矩阵已配置,但不等于各 owner 均已强制。Fami 初始 `resource_id=0`、素材为空,未绑定 owner/资源前不能宣称已实现 |
## 18. Flutter 建议状态流
@ -1451,5 +1451,5 @@ Flutter 先把 `action_param` 再解析为 JSON。点击前可将页面上所有
4. 背包页用 `vip_trial_card` 列表展示每张独立卡;当前佩戴态以 `/vip/me.state.equipped_trial_card.entitlement_id` 为准。
5. 所有房间、个人页和消息展示都使用 `effective_vip`,不要用 `paid_vip` 覆盖体验卡展示。
6. system inbox 遇到 `action_type=vip_coin_rebate_claim` 时,批量用 statuses 恢复真实状态;领取成功后替换余额与返现状态。
7. IM 登录并加入全局播报群后,按进程内 UTC 日规则触发 `/vip/online-notice`;收到 `vip_online_notice` 时用 `event_id` 去重。
7. IM 登录并加入全局播报群后,按登录会话内 UTC 日规则触发 `/vip/online-notice`退出登录时轮换 command nonce收到 `vip_online_notice` 时用 `event_id` 去重。
8. 修改 VIP 功能开关成功后,用 PATCH 响应替换本地 `user_settings`,不修改 `effective_benefits`

View File

@ -79,7 +79,7 @@ Fami 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长
- `user_resource_equipment`:当前单选佩戴指针;
- `user_vip_trial_cards`:等级、来源、时长和 program/version 快照。
发卡时即写入 `effective_at_ms``expires_at_ms`,但不自动佩戴。切换卡片只替换 `user_resource_equipment` 指针,不修改、删除、暂停或延长任一卡片。
发卡时即写入 `effective_at_ms``expires_at_ms`,但不自动佩戴。普通切换卡片只替换 `user_resource_equipment` 指针,不修改、删除、暂停或延长任一卡片;购买等级大于等于当前体验等级时是明确例外,购买事务会回收当前体验卡及该卡派生的体验资源
例如 A=14 天、B=20 天、C=30 天:佩戴 A 后立刻切 BB 生效A 仍保留原绝对到期时间;之后只要 A 未过期即可重新佩戴。体验卡等级不受当前付费 VIP 或上一张体验卡等级限制。
@ -130,7 +130,7 @@ Fami 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长
`GET /vip/me``GET /vip/packages`、购买、佩戴体验卡和卸下体验卡返回完全同构的 `VipState`。写接口成功响应不是 patchFlutter 必须原子替换 store。灰度期间如旧节点返回缺字段状态客户端应丢弃该局部对象并重拉 `/vip/me`
program 启用时的优先级固定为:`trial_card_enabled=true`体验卡有效已佩戴时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。
program 启用时的优先级固定为:`trial_card_enabled=true` 且有效已佩戴体验卡等级不低于付费等级时覆盖展示;否则使用有效付费会员;二者都无效则为 `none`。购买等级大于等于当前体验等级时,购买事务直接回收当前卡片和体验资源并返回 `effective_source=paid`;购买等级低于体验等级时,付费事实独立计时,体验卡继续生效,到期后再自然回落。关闭体验卡开关只停止它参与最终权限合并,背包和佩戴原始事实仍保留;关闭整个 program 时付费与卡片原始事实仍可查询,但最终身份为 `none`、不下发权益。
钱包只读取 `effective_vip.level` 对应的显式权益行。来源为 `trial` 时,再移除 `trial_enabled=false` 的权益;当前 `daily_coin_rebate` 不对体验卡开放。owner service 必须按 `benefit_code` 判断,不能改回 `level >= N`;进房和上线通知的执行方必须使用 wallet 已合并用户开关的结果。
@ -160,7 +160,7 @@ program 启用时的优先级固定为:`trial_card_enabled=true` 且体验卡
- 平台封禁、风控、超级管理员使用独立系统治理入口,不经过上述普通房管守卫。
- `room_entry_notice`:权益有效且用户开关开启时,随 `JoinRoomResponse``RoomUserJoined` 和腾讯云 IM 当前房间系统消息携带;只在用户进入的当前房间展示,不做全服广播。
`online_global_notice``POST /vip/online-notice` 触发gateway 先调用 wallet `CheckVipBenefit`,同时校验权益资格和用户开关;然后从 user-service 取服务端用户资料,最后写 activity-service 全局播报 outbox。腾讯云 IM 使用 `TIMCustomElem``Ext=im_broadcast``Desc=vip_online_notice``broadcast_type=vip_online_notice``scope=global`。Flutter 在进程内按 UTC 日控制触发:同一进程每天最多触发一次,不持久化该标记;杀进程重启后允许再次触发。服务端不做用户日级去重,但相同 `command_id` 的 HTTP 重试映射到同一 `event_id`。Flutter 不能直接向全服群发消息。
`online_global_notice``POST /vip/online-notice` 触发gateway 先调用 wallet `CheckVipBenefit`,同时校验权益资格和用户开关;然后从 user-service 取服务端用户资料,最后写 activity-service 全局播报 outbox。腾讯云 IM 使用 `TIMCustomElem``Ext=im_broadcast``Desc=vip_online_notice``broadcast_type=vip_online_notice``scope=global`。Flutter 按“登录会话 + UTC 日”控制触发:同一登录会话每天最多触发一次;退出登录或会话终态失效后必须轮换内存 command nonce下一次账号密码登录可产生独立事件旧会话的在途响应不能回显到新账号。杀进程重启后也会产生新会话标记。服务端不做用户日级去重,但相同 `command_id` 的 HTTP 重试映射到同一 `event_id`。Flutter 不能直接向全服群发消息。
## App HTTP API

View File

@ -21,6 +21,17 @@
| `country_id` / `region_id` | 国家/区域 | 聚合维度;未筛选国家时返回国家分解。 |
| `new_users` | 新增用户 | `UserRegistered` 注册 cohort 去重用户数。 |
| `profile_setup_view_users` | 填写资料页曝光人数 | Flutter 在未完成资料的新用户真正进入 `profile_setup` 页面后上报 `profile_start`Social 用户日读模型按 canonical 用户或设备主体、统计自然日去重。 |
| `registration_success_rate` | 注册成功率 | `新增用户 / 填写资料页曝光人数`;曝光人数为 0 时返回 0。 |
| `room_join_success_users` | 新用户进房人数 | 当天新增用户中Flutter `JoinRoom` 成功返回完整房间信息或服务端 `RoomUserJoined` 确认成功的去重用户数。 |
| `room_join_fail_users` | 新用户进房失败人数 | 当天新增用户中发起进房后因网络、加载或业务错误失败,且当天没有任何成功进房事实的去重用户数;同一用户先失败后成功只计成功。 |
| `room_join_success_rate` | 新用户进房成功率 | `新用户进房人数 / 新增用户`;新增用户为 0 时返回 0。 |
| `room_join_fail_rate` | 新用户进房失败率 | `新用户进房失败人数 / 新增用户`;新增用户为 0 时返回 0。 |
| `mic_up_users` | 新用户上麦人数 | 当天已成功进房的新用户中Flutter `ConfirmMicPublishing` 确认麦位进入 `publishing`,或服务端产生正麦上时长事实的去重人数;点击上麦、权限拒绝或报错均不计。 |
| `mic_up_rate` | 新用户上麦率 | `新用户上麦人数 / 新用户进房人数`;新用户进房人数为 0 时返回 0。 |
| `gift_users` | 新用户送礼人数 | 当天已成功进房的新用户中产生 `RoomGiftSent` 成功事实的去重人数普通、Lucky 和 Super Lucky 礼物均计入。 |
| `gift_rate` | 新用户送礼率 | `新用户送礼人数 / 新用户进房人数`;新用户进房人数为 0 时返回 0。 |
| `recharge_users` | 新用户充值人数 | 当天新增用户中成功完成 Google、第三方或币商充值的去重人数币商充值只认财务已审核或钱包成功到账事实失败及待审核订单不计。 |
| `recharge_rate` | 新用户充值率 | `新用户充值人数 / 新增用户`;新增用户为 0 时返回 0。 |
| `active_users` | 活跃用户 | `stat_social_user_day.active_user` 经设备日级 canonical 身份归并后的用户日去重数;多日汇总为活跃人天。 |
| `paid_users` | 付费用户 | `WalletRechargeRecorded` 写入 `stat_recharge_day_payers` 后按用户去重。 |
| `new_paid_users` | 新增付费用户 | 付费用户中注册 cohort 也落在查询周期内的用户数。 |

View File

@ -555,7 +555,7 @@ class VipEntitlementState {
- 同级购买:从原截止时间继续累加。
- Fami 高等级购买:立即替换低等级,从购买时间重新计时,低等级剩余时间丢弃。
- 低等级购买:服务端拒绝。
- 购买时佩戴体验卡:付费 VIP 会更新,但 `effective_vip` 仍可能来自体验卡,必须使用响应 `state`
- 购买时佩戴体验卡:目标等级大于等于体验等级时,体验卡及其体验资源会被回收,响应为真实付费 VIP目标等级低于体验等级时`paid_vip` 独立计时,`effective_vip` 继续来自体验卡。两种情况都必须整体使用响应 `state`
- 钱包缓存只使用 `coin_balance`。应用时比较 `version`,低版本响应不得覆盖本地高版本余额;`coin_balance_after` 仅保留旧客户端兼容。
- 历史幂等订单在升级前没有记录版本时可能返回 `coin_balance.version=0`Flutter 必须调用现有钱包余额接口刷新,不得把版本 0 写入余额 store。
@ -827,8 +827,8 @@ class VipEntitlementState {
}
```
- 体验卡可自由切换,不比较等级高低
- 切换和卸下不会暂停、延长或删除体验卡。
- 体验卡不能把当前 paid/trial 最终等级切低;同级或更高级卡仍按 `entitlement_id` 直接切换
- 普通切换和卸下不会暂停、延长或删除体验卡;购买等级大于等于当前体验等级时会回收当前卡片及其体验资源
- 每张卡的剩余时间按绝对 `expires_at_ms` 计算。
- 佩戴和卸下成功后立即使用响应 `state` 替换本地 VIP 状态。
@ -1064,13 +1064,13 @@ class VipEntitlementState {
```json
{
"command_id": "vip_online_<process_boot_id>_2026-07-15"
"command_id": "vip_online_<process_boot_id>_<session_nonce>_2026-07-15"
}
```
- `command_id` 最长 128 字节。
- 同一进程同一 UTC 日复用同一个 `command_id`
- `process_boot_id` 只保存在内存中,不写磁盘。
- 同一登录会话同一 UTC 日复用同一个 `command_id`
- `process_boot_id` `session_nonce` 只保存在内存中,不写磁盘;退出登录或会话终态失效时必须轮换 `session_nonce`
### 返回值

View File

@ -13,6 +13,10 @@
创建账号时必须先在当前 App 内用长 ID、短 ID 或靓号精确匹配用户,再提交服务端返回的稳定 `user_id`。昵称不参与绑定,避免重名用户拿到外管凭据。
- 外管身份只使用 `local``country_manager`(国家外管经理)和 `external_super_admin`外管SuperAdmin不会创建或修改 App 经理、BD Leader 等业务身份。历史账号由迁移统一成为无上级的国家外管经理。
- 外管团队邀请使用独立的 `external_bd``external_agency``external_host` 类型和 `parent_owner_user_id` 归属。它们不会回填或复用普通 App 的 Manager → BD Leader → BD、Agency、Host 关系Lalu 现有邀请、主播和公会链路继续只读取原字段。
- Local 不允许上级;国家外管经理可不绑定上级,也可绑定同 App、同当前区域的 active Local外管SuperAdmin 必须绑定同 App、同当前区域的 active 国家外管经理。身份创建后不可修改。
- 上级绑定使用账号 `permission_revision` 做乐观锁;真实变化与会话撤销同事务提交。`GET /parent-candidates` 只返回与目标用户当前区域一致的合法身份候选。
- `platform-admin`:查看、创建、配置权限、启停和重置密码。创建账号必须同时具备 `external-admin-user:create``external-admin-user:permissions`,避免只有凭据创建权限的调用方通过省略权限字段签发默认全权限账号。
- `ops-admin``operations-specialist`:查看、启停。运营岗位不能通过创建或重置账号间接获得自身权限矩阵中没有的 VIP 发放等能力。
- 外管登录账号名跨 App 全局唯一;绑定 App 用户仍按 `(app_code, linked_app_user_id)` 唯一。创建服务先做全局账号名检查,数据库唯一索引负责收敛并发创建竞态。
@ -20,7 +24,7 @@
## 权限配置
- `GET /api/v1/admin/external-admin-users/permission-catalog` 返回固定的 20 项业务权限目录;服务端只接受目录内 code不接受通配符或主后台 RBAC code。
- `GET /api/v1/admin/external-admin-users/permission-catalog` 返回固定业务权限目录;服务端只接受目录内 code不接受通配符或主后台 RBAC code。`bd-manager:list``super-admin:list``team:view` 即使残留在历史 JSON 中也不再生效。
- `GET /api/v1/admin/external-admin-users/:id/permissions` 按当前 App 返回权限快照和 `revision``PUT` 必须提交 `permissions``expectedRevision`。版本不一致返回 409防止两个管理员互相覆盖。
- 创建请求省略 `permissions` 时保留兼容行为,使用完整默认目录;显式 `[]` 表示零业务权限。无论是否省略字段,调用方都必须具备权限委派能力。
- 权限实际变化与撤销该账号全部活跃会话在同一事务提交,并递增 `permission_revision`;相同快照重复提交不递增版本、不重复撤销会话。
@ -31,12 +35,14 @@
外管只注册显式白名单路由,不提供主后台的通用代理能力:
- 用户:用户列表、资料编辑、封禁列表、封禁和解封。
- 组织主播、公会、BD、BD Manager、Super Admin 列表和我的团队
- 房间:房间列表和房间编辑
- 发放:特权道具、靓号、用户称号、房间背景图和财富/VIP 等级
- 运营Banner 创建。
- 组织主播、公会、BD,以及仅对 Local/国家外管经理开放的外管SuperAdmin列表。旧 BD Manager 与我的团队路由不再注册
- 房间:房间列表、房间编辑、持久封禁和人工解封;外管不能修改房间区域
- 发放:特权道具、靓号、用户称号、房间背景图和临时财富/VIP 体验卡;外管直接 VIP 路由不再注册
- 运营Banner 创建、编辑和删除
底层业务写入仍由各 owner service 或既有后台 Handler 负责外管模块只做独立认证、App 约束、权限白名单和审计,不复制用户、房间或钱包业务事实。
每次认证都会从绑定 App 用户实时解析 active `region_id`,再按 `Local → 国家外管经理 → 外管SuperAdmin` 重建 owner scope roots。区域为 0、用户/区域失效或父级链失效时,认证和改密仍可用,但全部业务路由 fail-closed区域和团队事实不会复制到 admin 数据库。
BD、Agency 和 Host 均只创建 pending 邀请user-service 在同一事务写入邀请、命令幂等事实和 outboxactivity-service 将邀请转换为带 Accept/Reject 动作的 `im_confirm`notice-service 再通过腾讯云 IM 投递给目标用户的 C2C 会话。目标用户在 App 消息列表确认后activity-service 才调用 user-service 接受邀请;接受事务会重新校验双方区域、所选 BD/Agency 及外管 owner 归属,全部有效才创建关系。拒绝、取消和幂等重试不会创建关系。
## 登录安全
@ -49,7 +55,7 @@
## 上线步骤
1. 通过 admin-server 迁移器依次应用 `migrations/098_external_admin_portal.sql``099_external_admin_credential_delegation.sql``100_external_admin_global_username.sql``101_external_admin_permission_assignment.sql`。发布前先执行 `EXPLAIN SELECT username, COUNT(*) FROM external_admin_accounts GROUP BY username HAVING COUNT(*) > 1` 评估访问计划,再执行同一查询确认结果为空。该检查和 100 的唯一索引构建只扫描小型凭据表不访问用户、房间或钱包表100、101 使用 `INPLACE + LOCK=NONE`但仍建议避开凭据集中创建时段。101 只为小型凭据表增加定长版本列并写入一项平台权限,不扫描或更新业务数据。若 100 检测到重复DDL 会 fail-closed不会自动改名或删除账号先由业务确认保留的登录名并处理冲突再清理迁移器 dirty 记录并重跑
1. 通过 admin-server 迁移器依次应用 `migrations/098_external_admin_portal.sql``099_external_admin_credential_delegation.sql``100_external_admin_global_username.sql``101_external_admin_permission_assignment.sql``121_external_admin_identity_hierarchy.sql``122_external_banner_region_scope_index.sql`。发布前先执行 `EXPLAIN SELECT username, COUNT(*) FROM external_admin_accounts GROUP BY username HAVING COUNT(*) > 1` 评估访问计划,再执行同一查询确认结果为空。121 只在小型凭据表增加定长身份、可空父级、自引用约束和层级索引,默认值直接将旧账号解释为国家外管经理;不读取用户、房间或钱包业务表。组合索引与外键需要校验该凭据表,并可能因 MySQL 8 小版本差异重建表因此先确认行数并避开外管账号集中创建时段。122 构建区域 Banner 复合索引时会扫描 `admin_app_banners`,上线前检查表大小并避开 Banner 集中发布窗口
2. 确认 Redis 已启用且 `/readyz` 为成功。生产配置缺少 Redis 时 admin-server 会拒绝启动,运行中限流 Redis 故障会阻断新登录但不破坏已建立会话。
3. 配置 `trusted_proxies` 为真实入口链路的精确 IP/CIDR。默认只信任 loopback不要信任 `0.0.0.0/0``::/0` 或整段未知私网。
4. 构建并发布前端 `dist/external-admin/`,保留 Nginx 对 `/external-admin` 的 301 和所有 `/external-admin/*` 深链回退到独立 HTML。

View File

@ -11,6 +11,17 @@ const (
)
type contextKey struct{}
type externalScopeKey struct{}
// ExternalScope contains only server-derived portal authority. Business modules may
// consume it to narrow owner-service calls, but must never construct it from request JSON.
type ExternalScope struct {
AccountID uint64
LinkedAppUserID int64
IdentityType string
RegionID int64
OwnerUserIDs []int64
}
func Normalize(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
@ -31,3 +42,20 @@ func FromContext(ctx context.Context) string {
value, _ := ctx.Value(contextKey{}).(string)
return Normalize(value)
}
func WithExternalScope(ctx context.Context, scope ExternalScope) context.Context {
scope.OwnerUserIDs = append([]int64(nil), scope.OwnerUserIDs...)
return context.WithValue(ctx, externalScopeKey{}, scope)
}
func ExternalScopeFromContext(ctx context.Context) (ExternalScope, bool) {
if ctx == nil {
return ExternalScope{}, false
}
scope, ok := ctx.Value(externalScopeKey{}).(ExternalScope)
if !ok {
return ExternalScope{}, false
}
scope.OwnerUserIDs = append([]int64(nil), scope.OwnerUserIDs...)
return scope, true
}

View File

@ -16,6 +16,8 @@ type Client interface {
ListRooms(ctx context.Context, req ListRoomsRequest) (*ListRoomsResult, error)
GetRoom(ctx context.Context, req GetRoomRequest) (*Room, error)
UpdateRoom(ctx context.Context, req UpdateRoomRequest) (*CloseRoomResult, error)
BanRoom(ctx context.Context, req ModerateRoomRequest) (*CloseRoomResult, error)
UnbanRoom(ctx context.Context, req ModerateRoomRequest) (*CloseRoomResult, error)
DeleteRoom(ctx context.Context, req DeleteRoomRequest) (*CloseRoomResult, error)
GetRoomRocketConfig(ctx context.Context) (RoomRocketConfig, error)
UpdateRoomRocketConfig(ctx context.Context, req UpdateRoomRocketConfigRequest) (RoomRocketConfig, error)
@ -42,6 +44,7 @@ type ListRoomsRequest struct {
Status string
RegionID int64
OwnerUserID int64
OwnerUserIDs []int64
SortBy string
SortDirection string
}
@ -79,6 +82,15 @@ type DeleteRoomRequest struct {
AdminName string
}
type ModerateRoomRequest struct {
RequestID string
RoomID string
ActorUserID int64
Reason string
AdminID uint64
AdminName string
}
type Room struct {
RoomID string
RoomShortID string
@ -383,6 +395,7 @@ func (c *GRPCClient) ListRooms(ctx context.Context, req ListRoomsRequest) (*List
Status: strings.TrimSpace(req.Status),
VisibleRegionId: req.RegionID,
OwnerUserId: req.OwnerUserID,
OwnerUserIds: append([]int64(nil), req.OwnerUserIDs...),
SortBy: strings.TrimSpace(req.SortBy),
SortDirection: strings.TrimSpace(req.SortDirection),
})
@ -427,6 +440,32 @@ func (c *GRPCClient) UpdateRoom(ctx context.Context, req UpdateRoomRequest) (*Cl
return closeRoomResultFromCommand(resp.GetResult(), resp.GetRoom()), nil
}
func (c *GRPCClient) BanRoom(ctx context.Context, req ModerateRoomRequest) (*CloseRoomResult, error) {
resp, err := c.client.AdminBanRoom(ctx, &roomv1.AdminBanRoomRequest{
Meta: requestMeta(ctx, req.RoomID, req.ActorUserID, firstNonEmpty(req.RequestID, "admin-ban-room")),
Reason: strings.TrimSpace(req.Reason),
AdminId: req.AdminID,
AdminName: strings.TrimSpace(req.AdminName),
})
if err != nil {
return nil, err
}
return closeRoomResultFromCommand(resp.GetResult(), resp.GetRoom()), nil
}
func (c *GRPCClient) UnbanRoom(ctx context.Context, req ModerateRoomRequest) (*CloseRoomResult, error) {
resp, err := c.client.AdminUnbanRoom(ctx, &roomv1.AdminUnbanRoomRequest{
Meta: requestMeta(ctx, req.RoomID, req.ActorUserID, firstNonEmpty(req.RequestID, "admin-unban-room")),
Reason: strings.TrimSpace(req.Reason),
AdminId: req.AdminID,
AdminName: strings.TrimSpace(req.AdminName),
})
if err != nil {
return nil, err
}
return closeRoomResultFromCommand(resp.GetResult(), resp.GetRoom()), nil
}
func (c *GRPCClient) DeleteRoom(ctx context.Context, req DeleteRoomRequest) (*CloseRoomResult, error) {
resp, err := c.client.AdminDeleteRoom(ctx, &roomv1.AdminDeleteRoomRequest{
Meta: requestMeta(ctx, req.RoomID, req.ActorUserID, firstNonEmpty(req.RequestID, "admin-delete-room")),

View File

@ -53,6 +53,16 @@ type Client interface {
QuickCreateAccount(ctx context.Context, req QuickCreateAccountRequest) (*QuickCreateAccountResult, error)
}
// ExternalTeamClient is intentionally separate from Client so existing modules and
// test doubles do not accidentally gain access to portal-only hierarchy commands.
type ExternalTeamClient interface {
ExternalInviteBD(ctx context.Context, req ExternalInviteBDRequest) (*ExternalRoleInvitation, error)
ExternalInviteAgency(ctx context.Context, req ExternalInviteAgencyRequest) (*ExternalRoleInvitation, error)
ExternalInviteHost(ctx context.Context, req ExternalInviteHostRequest) (*ExternalRoleInvitation, error)
ListExternalTeamUsers(ctx context.Context, req ListExternalTeamUsersRequest) (*ListExternalTeamUsersResult, error)
SearchExternalInvitationCandidate(ctx context.Context, req SearchExternalInvitationCandidateRequest) (*ExternalTeamUser, error)
}
type GetUserRequest struct {
RequestID string
Caller string
@ -852,6 +862,96 @@ type CreateAgencyResult struct {
Membership *AgencyMembership `json:"membership"`
}
type ExternalTeamUser struct {
UserID int64 `json:"userId,string"`
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
CurrentDisplayUserID string `json:"currentDisplayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
Status string `json:"status"`
RegionID int64 `json:"regionId"`
Roles []string `json:"roles"`
ParentOwnerUserID int64 `json:"parentOwnerUserId,string"`
ParentBDUserID int64 `json:"parentBdUserId,string"`
AgencyID int64 `json:"agencyId,string"`
}
type ExternalRoleInvitation struct {
InvitationID int64 `json:"invitationId,string"`
CommandID string `json:"commandId"`
InvitationType string `json:"invitationType"`
Status string `json:"status"`
ExternalOperatorID int64 `json:"externalOperatorUserId,string"`
TargetUserID int64 `json:"targetUserId,string"`
RegionID int64 `json:"regionId"`
AgencyName string `json:"agencyName,omitempty"`
ParentBDUserID int64 `json:"parentBdUserId,string,omitempty"`
ParentOwnerUserID int64 `json:"parentOwnerUserId,string,omitempty"`
ParentAgencyID int64 `json:"parentAgencyId,string,omitempty"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type ExternalInviteBDRequest struct {
RequestID string
Caller string
CommandID string
OperatorUserID int64
TargetUserID int64
ExpectedRegionID int64
}
type ExternalInviteAgencyRequest struct {
RequestID string
Caller string
CommandID string
OperatorUserID int64
ParentBDUserID int64
TargetUserID int64
AgencyName string
ScopeOwnerUserIDs []int64
ExpectedRegionID int64
}
type ExternalInviteHostRequest struct {
RequestID string
Caller string
CommandID string
OperatorUserID int64
AgencyID int64
TargetUserID int64
ScopeOwnerUserIDs []int64
ExpectedRegionID int64
}
type ListExternalTeamUsersRequest struct {
RequestID string
Caller string
ScopeOwnerUserIDs []int64
RegionID int64
Role string
Status string
Keyword string
Page int
PageSize int
}
type ListExternalTeamUsersResult struct {
Users []ExternalTeamUser `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type SearchExternalInvitationCandidateRequest struct {
RequestID string
Caller string
OperatorUserID int64
ExpectedRegionID int64
InvitationType string
Keyword string
}
func (c *GRPCClient) CreateBDLeader(ctx context.Context, req CreateBDLeaderRequest) (*BDProfile, error) {
resp, err := c.hostAdminClient.CreateBDLeader(ctx, &userv1.CreateBDLeaderRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
@ -1129,6 +1229,97 @@ func (c *GRPCClient) SetAgencyJoinEnabled(ctx context.Context, req SetAgencyJoin
return fromProtoAgency(resp.GetAgency()), nil
}
func (c *GRPCClient) ExternalInviteBD(ctx context.Context, req ExternalInviteBDRequest) (*ExternalRoleInvitation, error) {
resp, err := c.hostAdminClient.ExternalInviteBD(ctx, &userv1.ExternalInviteBDRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller), CommandId: req.CommandID,
OperatorUserId: req.OperatorUserID, TargetUserId: req.TargetUserID, ExpectedRegionId: req.ExpectedRegionID,
})
if err != nil {
return nil, err
}
return fromProtoExternalRoleInvitation(resp.GetInvitation()), nil
}
func (c *GRPCClient) ExternalInviteAgency(ctx context.Context, req ExternalInviteAgencyRequest) (*ExternalRoleInvitation, error) {
resp, err := c.hostAdminClient.ExternalInviteAgency(ctx, &userv1.ExternalInviteAgencyRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller), CommandId: req.CommandID,
OperatorUserId: req.OperatorUserID, ParentBdUserId: req.ParentBDUserID, TargetUserId: req.TargetUserID,
AgencyName: req.AgencyName, ScopeOwnerUserIds: append([]int64(nil), req.ScopeOwnerUserIDs...), ExpectedRegionId: req.ExpectedRegionID,
})
if err != nil {
return nil, err
}
return fromProtoExternalRoleInvitation(resp.GetInvitation()), nil
}
func (c *GRPCClient) ExternalInviteHost(ctx context.Context, req ExternalInviteHostRequest) (*ExternalRoleInvitation, error) {
resp, err := c.hostAdminClient.ExternalInviteHost(ctx, &userv1.ExternalInviteHostRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller), CommandId: req.CommandID,
OperatorUserId: req.OperatorUserID, AgencyId: req.AgencyID, TargetUserId: req.TargetUserID,
ScopeOwnerUserIds: append([]int64(nil), req.ScopeOwnerUserIDs...), ExpectedRegionId: req.ExpectedRegionID,
})
if err != nil {
return nil, err
}
return fromProtoExternalRoleInvitation(resp.GetInvitation()), nil
}
func (c *GRPCClient) ListExternalTeamUsers(ctx context.Context, req ListExternalTeamUsersRequest) (*ListExternalTeamUsersResult, error) {
resp, err := c.hostAdminClient.ListExternalTeamUsers(ctx, &userv1.ListExternalTeamUsersRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller), ScopeOwnerUserIds: append([]int64(nil), req.ScopeOwnerUserIDs...),
RegionId: req.RegionID, Role: req.Role, Status: req.Status, Keyword: req.Keyword,
Page: int32(req.Page), PageSize: int32(req.PageSize),
})
if err != nil {
return nil, err
}
result := &ListExternalTeamUsersResult{Total: resp.GetTotal(), Page: int(resp.GetPage()), PageSize: int(resp.GetPageSize())}
result.Users = make([]ExternalTeamUser, 0, len(resp.GetUsers()))
for _, item := range resp.GetUsers() {
if converted := fromProtoExternalTeamUser(item); converted != nil {
result.Users = append(result.Users, *converted)
}
}
return result, nil
}
func (c *GRPCClient) SearchExternalInvitationCandidate(ctx context.Context, req SearchExternalInvitationCandidateRequest) (*ExternalTeamUser, error) {
resp, err := c.hostAdminClient.SearchExternalInvitationCandidate(ctx, &userv1.SearchExternalInvitationCandidateRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller), OperatorUserId: req.OperatorUserID,
ExpectedRegionId: req.ExpectedRegionID, InvitationType: req.InvitationType, Keyword: req.Keyword,
})
if err != nil {
return nil, err
}
return fromProtoExternalTeamUser(resp.GetUser()), nil
}
func fromProtoExternalTeamUser(user *userv1.ExternalTeamUser) *ExternalTeamUser {
if user == nil {
return nil
}
return &ExternalTeamUser{
UserID: user.GetUserId(), DefaultDisplayUserID: user.GetDefaultDisplayUserId(),
CurrentDisplayUserID: user.GetCurrentDisplayUserId(), Username: user.GetUsername(), Avatar: user.GetAvatar(),
Status: user.GetStatus(), RegionID: user.GetRegionId(), Roles: append([]string(nil), user.GetRoles()...),
ParentOwnerUserID: user.GetParentOwnerUserId(), ParentBDUserID: user.GetParentBdUserId(), AgencyID: user.GetAgencyId(),
}
}
func fromProtoExternalRoleInvitation(invitation *userv1.RoleInvitation) *ExternalRoleInvitation {
if invitation == nil {
return nil
}
return &ExternalRoleInvitation{
InvitationID: invitation.GetInvitationId(), CommandID: invitation.GetCommandId(),
InvitationType: invitation.GetInvitationType(), Status: invitation.GetStatus(),
ExternalOperatorID: invitation.GetExternalOperatorUserId(), TargetUserID: invitation.GetTargetUserId(),
RegionID: invitation.GetRegionId(), AgencyName: invitation.GetAgencyName(),
ParentBDUserID: invitation.GetParentBdUserId(), ParentOwnerUserID: invitation.GetParentOwnerUserId(),
ParentAgencyID: invitation.GetParentAgencyId(), CreatedAtMS: invitation.GetCreatedAtMs(), UpdatedAtMS: invitation.GetUpdatedAtMs(),
}
}
func requestMeta(ctx context.Context, requestID string, caller string) *userv1.RequestMeta {
return &userv1.RequestMeta{
RequestId: requestID,

View File

@ -49,19 +49,27 @@ const (
ExternalAdminStatusActive = "active"
ExternalAdminStatusDisabled = "disabled"
// External-admin identities live only in the isolated portal hierarchy. They
// must never be inferred from or written into App manager/BD-leader profiles.
ExternalAdminIdentityLocal = "local"
ExternalAdminIdentityCountryManager = "country_manager"
ExternalAdminIdentityExternalSuperAdmin = "external_super_admin"
)
// ExternalAdminAccount is intentionally not related to admin_users. External operators
// use an isolated credential lifecycle and can never exchange this row for a main-admin JWT.
type ExternalAdminAccount struct {
ID uint64 `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;uniqueIndex:uk_external_admin_accounts_app_linked_user;index:idx_external_admin_accounts_app_status_updated,priority:1;not null" json:"appCode"`
AppCode string `gorm:"size:32;uniqueIndex:uk_external_admin_accounts_app_linked_user;index:idx_external_admin_accounts_app_status_updated,priority:1;index:idx_external_admin_accounts_hierarchy,priority:1;not null" json:"appCode"`
LinkedAppUserID int64 `gorm:"column:linked_app_user_id;uniqueIndex:uk_external_admin_accounts_app_linked_user;not null" json:"-"`
Username string `gorm:"size:64;uniqueIndex:uk_external_admin_accounts_username;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
PermissionsJSON string `gorm:"column:permissions_json;type:json;not null" json:"-"`
PermissionRevision uint64 `gorm:"column:permission_revision;not null;default:1" json:"permissionRevision"`
Status string `gorm:"size:24;index:idx_external_admin_accounts_app_status_updated,priority:2;not null;default:active" json:"status"`
IdentityType string `gorm:"column:identity_type;size:32;index:idx_external_admin_accounts_hierarchy,priority:3;not null;default:country_manager" json:"identityType"`
ParentAccountID *uint64 `gorm:"column:parent_account_id;index:idx_external_admin_accounts_hierarchy,priority:2" json:"parentAccountId,omitempty"`
Status string `gorm:"size:24;index:idx_external_admin_accounts_app_status_updated,priority:2;index:idx_external_admin_accounts_hierarchy,priority:4;not null;default:active" json:"status"`
PasswordChangeRequired bool `gorm:"not null;default:true" json:"passwordChangeRequired"`
FailedLoginCount uint `gorm:"not null;default:0" json:"-"`
LockedUntilMS int64 `gorm:"column:locked_until_ms;not null;default:0" json:"-"`
@ -230,8 +238,8 @@ func (AppConfig) TableName() string {
}
type AppBanner struct {
ID uint `gorm:"primaryKey" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_app_banners_app_sort;index:idx_admin_app_banner_activity_target,priority:1;not null;default:lalu" json:"appCode"`
ID uint `gorm:"primaryKey;index:idx_admin_app_banners_region_status_sort,priority:5" json:"id"`
AppCode string `gorm:"size:32;index:idx_admin_app_banners_app_sort;index:idx_admin_app_banner_activity_target,priority:1;index:idx_admin_app_banners_region_status_sort,priority:1;not null;default:lalu" json:"appCode"`
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"`
RoomSmallImageURL string `gorm:"column:room_small_image_url;size:1024;not null;default:'';comment:房间内小屏图 URL" json:"roomSmallImageUrl"`
BannerType string `gorm:"size:16;not null" json:"bannerType"`
@ -240,10 +248,10 @@ type AppBanner struct {
ActivityTemplateCode string `gorm:"column:activity_template_code;size:64;not null;default:''" json:"activityTemplateCode"`
DisplayScope string `gorm:"size:128;index:idx_admin_app_banners_display,not null;default:home;comment:显示范围,多选逗号分隔:首页、房间内、充值页或我的页" json:"displayScope"`
Param string `gorm:"size:2048" json:"param"`
Status string `gorm:"size:24;index:idx_admin_app_banners_app_sort;index:idx_admin_app_banner_activity_target,priority:4;not null;default:active" json:"status"`
Status string `gorm:"size:24;index:idx_admin_app_banners_app_sort;index:idx_admin_app_banner_activity_target,priority:4;index:idx_admin_app_banners_region_status_sort,priority:3;not null;default:active" json:"status"`
Platform string `gorm:"size:24;index:idx_admin_app_banners_scope,not null" json:"platform"`
SortOrder int `gorm:"index:idx_admin_app_banners_app_sort,not null;default:0" json:"sortOrder"`
RegionID int64 `gorm:"index:idx_admin_app_banners_scope,not null;default:0" json:"regionId"`
SortOrder int `gorm:"index:idx_admin_app_banners_app_sort;index:idx_admin_app_banners_region_status_sort,priority:4;not null;default:0" json:"sortOrder"`
RegionID int64 `gorm:"index:idx_admin_app_banners_scope;index:idx_admin_app_banners_region_status_sort,priority:2;not null;default:0" json:"regionId"`
CountryCode string `gorm:"size:8;index:idx_admin_app_banners_scope" json:"countryCode"`
Description string `gorm:"size:255" json:"description"`
StartsAtMS int64 `gorm:"column:starts_at_ms;not null;default:0;comment:投放开始时间UTC epoch ms" json:"startsAtMs"`

View File

@ -119,13 +119,21 @@ func (h *Handler) DeleteH5Link(c *gin.Context) {
func (h *Handler) ListBanners(c *gin.Context) {
options := shared.ListOptions(c)
regionID := parseOptionalInt64(c.Query("regionId"), c.Query("region_id"))
country := firstQuery(c, "countryCode", "country_code", "country")
if scope, external := appctx.ExternalScopeFromContext(c.Request.Context()); external {
regionID = scope.RegionID
// Region is server-owned for portal traffic. Country remains an optional,
// region-validated narrowing filter handled by create/update and owner data.
country = strings.TrimSpace(country)
}
items, err := h.service.ListBanners(appctx.FromContext(c.Request.Context()), repository.AppBannerListOptions{
Keyword: options.Keyword,
Status: options.Status,
DisplayScope: firstQuery(c, "displayScope", "display_scope"),
Platform: strings.TrimSpace(c.Query("platform")),
RegionID: parseOptionalInt64(c.Query("regionId"), c.Query("region_id")),
Country: firstQuery(c, "countryCode", "country_code", "country"),
RegionID: regionID,
Country: country,
})
if err != nil {
response.ServerError(c, "获取 BANNER 配置失败")
@ -173,7 +181,7 @@ func (h *Handler) DeleteBanner(c *gin.Context) {
if !ok {
return
}
if err := h.service.DeleteBanner(appctx.FromContext(c.Request.Context()), id); err != nil {
if err := h.service.DeleteBanner(c.Request.Context(), appctx.FromContext(c.Request.Context()), id); err != nil {
response.BadRequest(c, err.Error())
return
}

View File

@ -11,6 +11,7 @@ import (
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/repository"
activityv1 "hyapp.local/api/proto/activity/v1"
@ -40,11 +41,19 @@ type AppConfigService struct {
activity activityclient.Client
landing ActivityLandingURLBuilder
roleScopePolicies RoleScopePolicyClient
regionReader interface {
GetRegion(context.Context, userclient.GetRegionRequest) (*userclient.Region, error)
}
}
func (s *AppConfigService) BindRoleScopePolicyClient(client RoleScopePolicyClient) *AppConfigService {
if s != nil {
s.roleScopePolicies = client
if reader, ok := client.(interface {
GetRegion(context.Context, userclient.GetRegionRequest) (*userclient.Region, error)
}); ok {
s.regionReader = reader
}
}
return s
}
@ -262,7 +271,7 @@ func (s *AppConfigService) ListBanners(appCode string, options repository.AppBan
options.Platform = normalizeBannerPlatform(options.Platform)
options.Country = normalizeCountryCode(options.Country)
options.Keyword = strings.TrimSpace(options.Keyword)
if err := s.store.ExpireAppBanners(options.AppCode, time.Now().UTC().UnixMilli()); err != nil {
if err := s.store.ExpireAppBanners(options.AppCode, options.RegionID, time.Now().UTC().UnixMilli()); err != nil {
return nil, err
}
items, err := s.store.ListAppBanners(options)
@ -277,6 +286,12 @@ func (s *AppConfigService) ListBanners(appCode string, options repository.AppBan
}
func (s *AppConfigService) CreateBanner(ctx context.Context, appCode string, req bannerRequest) (AppBanner, error) {
if scope, external := appctx.ExternalScopeFromContext(ctx); external {
req.RegionID = scope.RegionID
if err := s.validateExternalBannerCountry(ctx, req.CountryCode, scope.RegionID); err != nil {
return AppBanner{}, err
}
}
var err error
req, err = s.resolveBannerTarget(ctx, appctx.Normalize(appCode), req)
if err != nil {
@ -297,6 +312,15 @@ func (s *AppConfigService) UpdateBanner(ctx context.Context, appCode string, id
if err != nil {
return AppBanner{}, err
}
if scope, external := appctx.ExternalScopeFromContext(ctx); external {
if item.RegionID != scope.RegionID {
return AppBanner{}, errors.New("banner does not belong to current region")
}
req.RegionID = scope.RegionID
if err := s.validateExternalBannerCountry(ctx, req.CountryCode, scope.RegionID); err != nil {
return AppBanner{}, err
}
}
req, err = s.resolveBannerTarget(ctx, item.AppCode, req)
if err != nil {
return AppBanner{}, err
@ -396,10 +420,41 @@ func containsInt64(items []int64, target int64) bool {
return false
}
func (s *AppConfigService) DeleteBanner(appCode string, id uint) error {
func (s *AppConfigService) DeleteBanner(ctx context.Context, appCode string, id uint) error {
if scope, external := appctx.ExternalScopeFromContext(ctx); external {
item, err := s.store.GetAppBanner(appctx.Normalize(appCode), id)
if err != nil {
return err
}
if item.RegionID != scope.RegionID {
return errors.New("banner does not belong to current region")
}
}
return s.store.DeleteAppBanner(appctx.Normalize(appCode), id)
}
func (s *AppConfigService) validateExternalBannerCountry(ctx context.Context, countryCode string, regionID int64) error {
countryCode = normalizeCountryCode(countryCode)
if countryCode == "" {
return nil
}
if s.regionReader == nil || regionID <= 0 {
return errors.New("region service is unavailable")
}
region, err := s.regionReader.GetRegion(ctx, userclient.GetRegionRequest{
RequestID: "external-banner-country-check", Caller: "hyapp-admin-external", RegionID: regionID,
})
if err != nil {
return err
}
for _, allowedCountry := range region.Countries {
if normalizeCountryCode(allowedCountry) == countryCode {
return nil
}
}
return errors.New("banner country does not belong to current region")
}
func (s *AppConfigService) ListSplashScreens(appCode string, options repository.AppSplashScreenListOptions) ([]AppSplashScreen, error) {
// 列表入口先把管理后台传入的筛选条件统一成数据库稳定值,再执行过期回收,避免已过期 active 数据继续展示。
options.AppCode = appctx.Normalize(appCode)

View File

@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/userclient"
@ -35,6 +36,16 @@ func New(userClient userclient.Client, activityClient activityclient.Client, adm
}
}
// ExternalTeamClient exposes only the portal-specific owner-service contract to the
// external route assembler; generic App user handlers retain their existing Client.
func (h *Handler) ExternalTeamClient() userclient.ExternalTeamClient {
if h == nil || h.service == nil {
return nil
}
client, _ := h.service.userClient.(userclient.ExternalTeamClient)
return client
}
func (h *Handler) ListUsers(c *gin.Context) {
query := parseListQuery(c)
items, total, err := h.service.ListUsers(c.Request.Context(), query)
@ -103,6 +114,13 @@ func (h *Handler) UpdateUser(c *gin.Context) {
response.BadRequest(c, "用户参数不正确")
return
}
if _, external := appctx.ExternalScopeFromContext(c.Request.Context()); external && req.Country != nil {
// Country is the canonical input for user-service region reassignment. Portal
// accounts may edit profile fields, but must never move a target across their
// server-derived region boundary through the shared main-admin handler.
response.Forbidden(c, "外管不能修改用户所属国家或区域")
return
}
user, err := h.service.UpdateUser(c.Request.Context(), userID, int64(middleware.CurrentUserID(c)), middleware.CurrentRequestID(c), req)
if err != nil {
writeMutationError(c, err, "更新 App 用户失败")

View File

@ -12,6 +12,7 @@ import (
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
@ -66,6 +67,8 @@ type createAccountRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Permissions optionalPermissionSelection `json:"permissions"`
IdentityType string `json:"identityType"`
ParentAccountID *uint64 `json:"parentAccountId"`
}
type statusRequest struct {
@ -76,6 +79,11 @@ type passwordRequest struct {
Password string `json:"password" binding:"required"`
}
type updateParentRequest struct {
ParentAccountID *uint64 `json:"parentAccountId"`
ExpectedRevision uint64 `json:"expectedRevision" binding:"required"`
}
type loginRequest struct {
Account string `json:"account"`
Username string `json:"username"`
@ -148,7 +156,8 @@ func (h *Handler) CreateAccount(c *gin.Context) {
account, err := h.service.CreateAccount(c.Request.Context(), CreateInput{
AppCode: appctx.FromContext(c.Request.Context()), TargetUserID: string(request.TargetUserID),
Username: request.Username, Password: request.Password, Permissions: request.Permissions.Value,
PermissionsSet: request.Permissions.Set, CreatedByAdminID: middleware.CurrentUserID(c),
PermissionsSet: request.Permissions.Set, IdentityType: request.IdentityType,
ParentAccountID: request.ParentAccountID, CreatedByAdminID: middleware.CurrentUserID(c),
})
if errors.Is(err, ErrAccountConflict) {
response.Conflict(c, "外管账户名称已被使用,或当前 App 的绑定用户已存在")
@ -166,6 +175,10 @@ func (h *Handler) CreateAccount(c *gin.Context) {
response.BadRequest(c, permissionValidationMessage(err))
return
}
if errors.Is(err, ErrInvalidHierarchy) {
response.BadRequest(c, "外管身份、上级或当前区域不符合层级规则")
return
}
if errors.Is(err, ErrInvalidInput) || strings.Contains(fmt.Sprint(err), "password length") {
response.BadRequest(c, "账户名称格式不正确,或密码超过 72 字节")
return
@ -174,10 +187,100 @@ func (h *Handler) CreateAccount(c *gin.Context) {
response.ServerError(c, "创建外管用户失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "create-external-admin-user", "external_admin_accounts", strconv.FormatUint(account.ID, 10), "success", fmt.Sprintf("app_code=%s linked_user_id=%d username=%s permission_count=%d", account.AppCode, account.LinkedUser.UserID, account.Username, len(account.Permissions)))
shared.OperationLogWithResourceID(c, h.audit, "create-external-admin-user", "external_admin_accounts", strconv.FormatUint(account.ID, 10), "success", fmt.Sprintf(
"app_code=%s linked_user_id=%d username=%s identity_type=%s parent_account_id=%s permission_count=%d",
account.AppCode, account.LinkedUser.UserID, account.Username, account.IdentityType, optionalAccountID(account.ParentAccountID), len(account.Permissions),
))
response.Created(c, account)
}
func (h *Handler) ListParentCandidates(c *gin.Context) {
childAccountID, _ := strconv.ParseUint(strings.TrimSpace(c.Query("accountId")), 10, 64)
targetUserID := strings.TrimSpace(c.Query("targetUserId"))
if targetUserID == "" {
targetUserID = strings.TrimSpace(c.Query("displayUserId"))
}
result, err := h.service.ListParentCandidates(c.Request.Context(), ParentCandidateInput{
AppCode: appctx.FromContext(c.Request.Context()), IdentityType: c.Query("identityType"),
TargetUserID: targetUserID, ChildAccountID: childAccountID, Keyword: c.Query("keyword"),
})
if errors.Is(err, ErrAccountNotFound) || errors.Is(err, ErrTargetNotFound) {
response.NotFound(c, "外管用户或绑定 App 用户不存在")
return
}
if errors.Is(err, ErrInvalidInput) {
response.BadRequest(c, "请选择有效身份和绑定用户")
return
}
if err != nil {
response.ServerError(c, "获取外管上级候选失败")
return
}
response.OK(c, result)
}
func (h *Handler) UpdateParent(c *gin.Context) {
id, ok := parseUint64Param(c, "id")
if !ok {
return
}
var request updateParentRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "上级绑定参数不正确")
return
}
result, err := h.service.UpdateAccountParent(c.Request.Context(), appctx.FromContext(c.Request.Context()), id, UpdateParentInput{
ParentAccountID: request.ParentAccountID, ExpectedRevision: request.ExpectedRevision,
})
if errors.Is(err, ErrParentConflict) {
response.Conflict(c, "外管用户已被其他管理员修改,请刷新后重试")
return
}
if errors.Is(err, ErrAccountNotFound) {
response.NotFound(c, "外管用户不存在")
return
}
if errors.Is(err, ErrInvalidHierarchy) {
response.BadRequest(c, "上级身份、状态或区域不符合层级规则")
return
}
if errors.Is(err, ErrInvalidInput) {
response.BadRequest(c, "上级绑定参数不正确")
return
}
if err != nil {
response.ServerError(c, "绑定外管用户上级失败")
return
}
shared.OperationLogWithResourceID(
c, h.audit, "update-external-admin-user-parent", "external_admin_accounts", strconv.FormatUint(id, 10), "success",
fmt.Sprintf("parent_account_id=%s revision=%d changed=%t sessions_revoked=%t", optionalAccountID(request.ParentAccountID), result.Account.PermissionRevision, result.Changed, result.SessionsRevoked),
)
response.OK(c, result)
}
func (h *Handler) ListExternalSuperAdmins(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok {
response.Unauthorized(c, "外管会话已失效")
return
}
if normalizeIdentity(principal.IdentityType) == model.ExternalAdminIdentityExternalSuperAdmin {
response.Forbidden(c, "外管SuperAdmin不能查看此菜单")
return
}
result, err := h.service.ListExternalSuperAdmins(c.Request.Context(), principal)
if errors.Is(err, ErrInvalidHierarchy) {
response.Forbidden(c, "当前身份不能查看外管SuperAdmin")
return
}
if err != nil {
response.ServerError(c, "获取外管SuperAdmin失败")
return
}
response.OK(c, result)
}
func (h *Handler) SetStatus(c *gin.Context) {
id, ok := parseUint64Param(c, "id")
if !ok {
@ -512,3 +615,10 @@ func parseUint64Param(c *gin.Context, name string) (uint64, bool) {
}
return id, true
}
func optionalAccountID(value *uint64) string {
if value == nil {
return "none"
}
return strconv.FormatUint(*value, 10)
}

View File

@ -0,0 +1,612 @@
package externaladmin
import (
"context"
"database/sql"
"errors"
"sort"
"strings"
"time"
"hyapp-admin-server/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
maxHierarchyAccounts = 5000
maxParentCandidates = 500
// user-service bounds the trusted owner IN-list at 1000. Enforce the same
// contract before issuing any team request instead of letting an oversized
// hierarchy fail differently across list, invitation and mutation routes.
maxExternalScopeOwners = 1000
)
type portalScope struct {
RegionID int64
OwnerUserIDs []int64
ExternalSuperAdminAccounts []model.ExternalAdminAccount
}
func normalizeIdentity(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "", model.ExternalAdminIdentityCountryManager:
// Empty is accepted only for rolling-compatible create callers and historical
// rows; the persisted/current wire value is always the explicit default.
return model.ExternalAdminIdentityCountryManager
case model.ExternalAdminIdentityLocal:
return model.ExternalAdminIdentityLocal
case model.ExternalAdminIdentityExternalSuperAdmin:
return model.ExternalAdminIdentityExternalSuperAdmin
default:
return ""
}
}
func requiredParentIdentity(identity string) (string, bool) {
switch normalizeIdentity(identity) {
case model.ExternalAdminIdentityExternalSuperAdmin:
return model.ExternalAdminIdentityCountryManager, true
case model.ExternalAdminIdentityCountryManager:
return model.ExternalAdminIdentityLocal, false
default:
return "", false
}
}
// activeUserRegion resolves the current canonical region instead of copying it into
// the portal account. Joining active regions makes deleted/disabled mappings fail
// closed while retaining an indexed users(app_code,user_id) point lookup.
func (s *Service) activeUserRegion(ctx context.Context, appCode string, userID int64) (int64, error) {
if s.userDB == nil || userID <= 0 {
return 0, ErrInvalidInput
}
var regionID int64
err := s.userDB.QueryRowContext(ctx, `
SELECT u.region_id
FROM users u
JOIN regions rg
ON rg.app_code = u.app_code
AND rg.region_id = u.region_id
AND rg.status = 'active'
WHERE u.app_code = ?
AND u.user_id = ?
AND u.status = 'active'
AND COALESCE(u.region_id, 0) > 0
LIMIT 1`, normalizeAppCode(appCode), userID).Scan(&regionID)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return regionID, err
}
func (s *Service) activeUserRegions(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
result := make(map[int64]int64, len(userIDs))
uniqueIDs := uniquePositiveInt64s(userIDs)
if len(uniqueIDs) == 0 {
return result, nil
}
if s.userDB == nil {
return nil, errors.New("user mysql is not configured")
}
placeholders := strings.TrimRight(strings.Repeat("?,", len(uniqueIDs)), ",")
args := make([]any, 0, len(uniqueIDs)+1)
args = append(args, normalizeAppCode(appCode))
for _, userID := range uniqueIDs {
args = append(args, userID)
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT u.user_id, u.region_id
FROM users u
JOIN regions rg
ON rg.app_code = u.app_code
AND rg.region_id = u.region_id
AND rg.status = 'active'
WHERE u.app_code = ?
AND u.status = 'active'
AND COALESCE(u.region_id, 0) > 0
AND u.user_id IN (`+placeholders+`)`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var userID int64
var regionID int64
if err := rows.Scan(&userID, &regionID); err != nil {
return nil, err
}
result[userID] = regionID
}
return result, rows.Err()
}
// resolvePortalScope rebuilds the hierarchy on every authenticated request. Status,
// parent changes and region changes therefore take effect without trusting stale
// browser claims or persisting duplicated owner-service state in the admin database.
func (s *Service) resolvePortalScope(ctx context.Context, account model.ExternalAdminAccount) (portalScope, error) {
scope := portalScope{OwnerUserIDs: []int64{}, ExternalSuperAdminAccounts: []model.ExternalAdminAccount{}}
account.IdentityType = normalizeIdentity(account.IdentityType)
if account.IdentityType == "" {
return scope, ErrInvalidHierarchy
}
regionID, err := s.activeUserRegion(ctx, account.AppCode, account.LinkedAppUserID)
if err != nil {
return scope, err
}
scope.RegionID = regionID
if regionID == 0 {
return scope, nil
}
valid, err := s.accountParentChainIsActive(ctx, account, regionID)
if err != nil {
return scope, err
}
if !valid {
return scope, nil
}
scope.OwnerUserIDs = append(scope.OwnerUserIDs, account.LinkedAppUserID)
descendants, superAdmins, err := s.loadPortalDescendants(ctx, account)
if err != nil {
return scope, err
}
if len(descendants) == 0 {
return scope, nil
}
userIDs := make([]int64, 0, len(descendants))
for _, descendant := range descendants {
userIDs = append(userIDs, descendant.LinkedAppUserID)
}
regions, err := s.activeUserRegions(ctx, account.AppCode, userIDs)
if err != nil {
return scope, err
}
validAccounts := make(map[uint64]struct{}, len(descendants))
switch account.IdentityType {
case model.ExternalAdminIdentityCountryManager:
for _, superAdmin := range superAdmins {
if superAdmin.ParentAccountID == nil || *superAdmin.ParentAccountID != account.ID || regions[superAdmin.LinkedAppUserID] != regionID {
continue
}
validAccounts[superAdmin.ID] = struct{}{}
}
case model.ExternalAdminIdentityLocal:
validManagers := make(map[uint64]struct{}, len(descendants))
for _, descendant := range descendants {
if normalizeIdentity(descendant.IdentityType) != model.ExternalAdminIdentityCountryManager ||
descendant.ParentAccountID == nil || *descendant.ParentAccountID != account.ID ||
regions[descendant.LinkedAppUserID] != regionID {
continue
}
validManagers[descendant.ID] = struct{}{}
validAccounts[descendant.ID] = struct{}{}
}
// A Local must not inherit a SuperAdmin through a Manager that moved to
// another region. Filter by the already validated parent-account set before
// adding either business scope roots or the SuperAdmin menu projection.
for _, superAdmin := range superAdmins {
if superAdmin.ParentAccountID == nil || regions[superAdmin.LinkedAppUserID] != regionID {
continue
}
if _, parentValid := validManagers[*superAdmin.ParentAccountID]; !parentValid {
continue
}
validAccounts[superAdmin.ID] = struct{}{}
}
}
for _, descendant := range descendants {
if _, valid := validAccounts[descendant.ID]; valid {
scope.OwnerUserIDs = append(scope.OwnerUserIDs, descendant.LinkedAppUserID)
}
}
for _, superAdmin := range superAdmins {
if _, valid := validAccounts[superAdmin.ID]; valid {
scope.ExternalSuperAdminAccounts = append(scope.ExternalSuperAdminAccounts, superAdmin)
}
}
scope.OwnerUserIDs = uniquePositiveInt64s(scope.OwnerUserIDs)
if len(scope.OwnerUserIDs) > maxExternalScopeOwners {
return portalScope{OwnerUserIDs: []int64{}, ExternalSuperAdminAccounts: []model.ExternalAdminAccount{}}, ErrInvalidHierarchy
}
return scope, nil
}
func (s *Service) accountParentChainIsActive(ctx context.Context, account model.ExternalAdminAccount, regionID int64) (bool, error) {
return s.accountParentChainIsActiveWithDB(ctx, s.db, account, regionID, false)
}
// accountParentChainIsActiveWithDB validates the complete bounded portal chain
// using the caller's admin transaction when hierarchy rows are being created or
// rebound. The identity order has a fixed maximum depth
// SuperAdmin -> country manager -> Local, so this never becomes an unbounded
// recursive query even if a manually corrupted row points back to a child.
func (s *Service) accountParentChainIsActiveWithDB(
ctx context.Context,
db *gorm.DB,
account model.ExternalAdminAccount,
regionID int64,
lockAncestors bool,
) (bool, error) {
identity := normalizeIdentity(account.IdentityType)
if identity == model.ExternalAdminIdentityLocal {
return account.ParentAccountID == nil, nil
}
if account.ParentAccountID == nil {
return identity == model.ExternalAdminIdentityCountryManager, nil
}
expectedIdentity, _ := requiredParentIdentity(identity)
if expectedIdentity == "" {
return false, nil
}
var parent model.ExternalAdminAccount
query := db.WithContext(ctx)
if lockAncestors {
// Parent mutation and account creation already run in a transaction. Share
// locking every ancestor prevents a concurrent disable/rebind from making a
// child valid only for the instant between validation and commit.
query = query.Clauses(clause.Locking{Strength: "SHARE"})
}
err := query.Where("id = ? AND app_code = ? AND identity_type = ? AND status = ?",
*account.ParentAccountID, account.AppCode, expectedIdentity, model.ExternalAdminStatusActive).
Take(&parent).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
parentRegionID, err := s.activeUserRegion(ctx, account.AppCode, parent.LinkedAppUserID)
if err != nil {
return false, err
}
if parentRegionID != regionID {
return false, nil
}
// A SuperAdmin must not stay authorized through a Manager whose optional
// Local binding has become invalid. Identity transitions strictly decrease
// from SuperAdmin -> Manager -> Local, so this checks the complete bounded
// chain without introducing an unbounded recursive authorization query.
return s.accountParentChainIsActiveWithDB(ctx, db, parent, regionID, lockAncestors)
}
func (s *Service) loadPortalDescendants(ctx context.Context, account model.ExternalAdminAccount) ([]model.ExternalAdminAccount, []model.ExternalAdminAccount, error) {
switch normalizeIdentity(account.IdentityType) {
case model.ExternalAdminIdentityExternalSuperAdmin:
return []model.ExternalAdminAccount{}, []model.ExternalAdminAccount{}, nil
case model.ExternalAdminIdentityCountryManager:
superAdmins, err := s.queryChildren(ctx, account.AppCode, []uint64{account.ID}, model.ExternalAdminIdentityExternalSuperAdmin)
return superAdmins, superAdmins, err
case model.ExternalAdminIdentityLocal:
managers, err := s.queryChildren(ctx, account.AppCode, []uint64{account.ID}, model.ExternalAdminIdentityCountryManager)
if err != nil {
return nil, nil, err
}
managerIDs := make([]uint64, 0, len(managers))
for _, manager := range managers {
managerIDs = append(managerIDs, manager.ID)
}
superAdmins, err := s.queryChildren(ctx, account.AppCode, managerIDs, model.ExternalAdminIdentityExternalSuperAdmin)
if err != nil {
return nil, nil, err
}
descendants := make([]model.ExternalAdminAccount, 0, len(managers)+len(superAdmins))
descendants = append(descendants, managers...)
descendants = append(descendants, superAdmins...)
return descendants, superAdmins, nil
default:
return nil, nil, ErrInvalidHierarchy
}
}
func (s *Service) queryChildren(ctx context.Context, appCode string, parentIDs []uint64, identity string) ([]model.ExternalAdminAccount, error) {
if len(parentIDs) == 0 {
return []model.ExternalAdminAccount{}, nil
}
items := []model.ExternalAdminAccount{}
err := s.db.WithContext(ctx).
Where("app_code = ? AND parent_account_id IN ? AND identity_type = ? AND status = ?",
normalizeAppCode(appCode), parentIDs, identity, model.ExternalAdminStatusActive).
Order("id ASC").
Limit(maxHierarchyAccounts + 1).
Find(&items).Error
if err != nil {
return nil, err
}
// Silent truncation would broaden/lose team authorization unpredictably. Fail the
// request instead, so operators must split an oversized hierarchy deliberately.
if len(items) > maxHierarchyAccounts {
return nil, ErrInvalidHierarchy
}
return items, nil
}
func (s *Service) validateParentForChild(ctx context.Context, tx *gorm.DB, appCode string, identity string, parentID *uint64, childRegionID int64) (*model.ExternalAdminAccount, error) {
identity = normalizeIdentity(identity)
expectedIdentity, parentRequired := requiredParentIdentity(identity)
if identity == "" || childRegionID <= 0 {
return nil, ErrInvalidHierarchy
}
if parentID == nil {
if parentRequired {
return nil, ErrInvalidHierarchy
}
return nil, nil
}
if *parentID == 0 || expectedIdentity == "" {
return nil, ErrInvalidHierarchy
}
var parent model.ExternalAdminAccount
err := tx.Clauses(clause.Locking{Strength: "SHARE"}).
Where("id = ? AND app_code = ? AND identity_type = ? AND status = ?",
*parentID, normalizeAppCode(appCode), expectedIdentity, model.ExternalAdminStatusActive).
Take(&parent).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrInvalidHierarchy
}
if err != nil {
return nil, err
}
parentRegionID, err := s.activeUserRegion(ctx, appCode, parent.LinkedAppUserID)
if err != nil {
return nil, err
}
if parentRegionID == 0 || parentRegionID != childRegionID {
return nil, ErrInvalidHierarchy
}
// A country manager with a stale/disabled/cross-region Local parent is not a
// valid SuperAdmin parent. Likewise, a manually corrupted Local row with its
// own parent cannot authorize a manager. Validate the full chain inside the
// same transaction instead of creating a child that immediately fails closed.
valid, err := s.accountParentChainIsActiveWithDB(ctx, tx, parent, childRegionID, true)
if err != nil {
return nil, err
}
if !valid {
return nil, ErrInvalidHierarchy
}
return &parent, nil
}
func (s *Service) ListParentCandidates(ctx context.Context, input ParentCandidateInput) (ParentCandidateResult, error) {
if s.db == nil {
return ParentCandidateResult{}, errors.New("admin mysql is not configured")
}
input.AppCode = normalizeAppCode(input.AppCode)
identity := normalizeIdentity(input.IdentityType)
if input.AppCode == "" || (strings.TrimSpace(input.IdentityType) != "" && identity == "") {
return ParentCandidateResult{}, ErrInvalidInput
}
var child model.ExternalAdminAccount
var regionID int64
if input.ChildAccountID > 0 {
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", input.ChildAccountID, input.AppCode).Take(&child).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ParentCandidateResult{}, ErrAccountNotFound
}
return ParentCandidateResult{}, err
}
identity = normalizeIdentity(child.IdentityType)
if identity == "" {
return ParentCandidateResult{}, ErrInvalidHierarchy
}
var err error
regionID, err = s.activeUserRegion(ctx, input.AppCode, child.LinkedAppUserID)
if err != nil {
return ParentCandidateResult{}, err
}
} else {
target, err := s.ResolveTarget(ctx, input.AppCode, input.TargetUserID)
if err != nil {
return ParentCandidateResult{}, err
}
regionID, err = s.activeUserRegion(ctx, input.AppCode, target.UserID)
if err != nil {
return ParentCandidateResult{}, err
}
}
expectedIdentity, _ := requiredParentIdentity(identity)
if expectedIdentity == "" || regionID <= 0 {
return ParentCandidateResult{Items: []AccountReference{}}, nil
}
query := s.db.WithContext(ctx).
Where("app_code = ? AND identity_type = ? AND status = ?", input.AppCode, expectedIdentity, model.ExternalAdminStatusActive)
if keyword := strings.TrimSpace(input.Keyword); keyword != "" {
query = query.Where("username LIKE ?", "%"+keyword+"%")
}
candidates := []model.ExternalAdminAccount{}
if err := query.Order("username ASC, id ASC").Limit(maxParentCandidates + 1).Find(&candidates).Error; err != nil {
return ParentCandidateResult{}, err
}
truncated := len(candidates) > maxParentCandidates
if truncated {
candidates = candidates[:maxParentCandidates]
}
userIDs := make([]int64, 0, len(candidates))
for _, candidate := range candidates {
userIDs = append(userIDs, candidate.LinkedAppUserID)
}
regions, err := s.activeUserRegions(ctx, input.AppCode, userIDs)
if err != nil {
return ParentCandidateResult{}, err
}
users, err := s.loadLinkedUsers(ctx, input.AppCode, userIDs)
if err != nil {
return ParentCandidateResult{}, err
}
items := make([]AccountReference, 0, len(candidates))
for _, candidate := range candidates {
if regions[candidate.LinkedAppUserID] != regionID {
continue
}
// Candidate visibility is authorization-sensitive: a country manager whose
// optional Local chain has drifted must not be selectable for a new
// SuperAdmin merely because the manager row itself is still active.
valid, err := s.accountParentChainIsActive(ctx, candidate, regionID)
if err != nil {
return ParentCandidateResult{}, err
}
if !valid {
continue
}
items = append(items, accountReference(candidate, users[candidate.LinkedAppUserID]))
}
return ParentCandidateResult{Items: items, Truncated: truncated}, nil
}
func (s *Service) UpdateAccountParent(ctx context.Context, appCode string, id uint64, input UpdateParentInput) (ParentUpdateResult, error) {
appCode = normalizeAppCode(appCode)
if s.db == nil {
return ParentUpdateResult{}, errors.New("admin mysql is not configured")
}
if appCode == "" || id == 0 || input.ExpectedRevision == 0 {
return ParentUpdateResult{}, ErrInvalidInput
}
result := ParentUpdateResult{}
var account model.ExternalAdminAccount
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND app_code = ?", id, appCode).Take(&account).Error; err != nil {
return err
}
if account.PermissionRevision != input.ExpectedRevision {
return ErrParentConflict
}
regionID, err := s.activeUserRegion(ctx, appCode, account.LinkedAppUserID)
if err != nil {
return err
}
if _, err := s.validateParentForChild(ctx, tx, appCode, account.IdentityType, input.ParentAccountID, regionID); err != nil {
return err
}
if equalUint64Pointers(account.ParentAccountID, input.ParentAccountID) {
return nil
}
if err := tx.Model(&account).Updates(map[string]any{
"parent_account_id": input.ParentAccountID,
"permission_revision": account.PermissionRevision + 1,
}).Error; err != nil {
return err
}
if err := revokeAllSessions(tx, account.ID, "hierarchy_changed", time.Now().UTC().UnixMilli()); err != nil {
return err
}
result.Changed = true
result.SessionsRevoked = true
return nil
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return ParentUpdateResult{}, ErrAccountNotFound
}
if err != nil {
return ParentUpdateResult{}, err
}
account = model.ExternalAdminAccount{}
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", id, appCode).Take(&account).Error; err != nil {
return ParentUpdateResult{}, err
}
dto, err := s.accountDTOWithRelations(ctx, account)
if err != nil {
return ParentUpdateResult{}, err
}
result.Account = dto
return result, nil
}
func (s *Service) ListExternalSuperAdmins(ctx context.Context, principal SessionPrincipal) (ListResult, error) {
if normalizeIdentity(principal.IdentityType) == model.ExternalAdminIdentityExternalSuperAdmin {
return ListResult{}, ErrInvalidHierarchy
}
var account model.ExternalAdminAccount
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", principal.AccountID, principal.AppCode).Take(&account).Error; err != nil {
return ListResult{}, err
}
scope, err := s.resolvePortalScope(ctx, account)
if err != nil {
return ListResult{}, err
}
users, err := s.loadLinkedUsers(ctx, principal.AppCode, linkedUserIDs(scope.ExternalSuperAdminAccounts))
if err != nil {
return ListResult{}, err
}
items := make([]AccountDTO, 0, len(scope.ExternalSuperAdminAccounts))
for _, item := range scope.ExternalSuperAdminAccounts {
items = append(items, accountDTO(item, users[item.LinkedAppUserID]))
}
return ListResult{Items: items, Page: 1, PageSize: len(items), Total: int64(len(items))}, nil
}
func (s *Service) accountDTOWithRelations(ctx context.Context, account model.ExternalAdminAccount) (AccountDTO, error) {
accountUsers, err := s.loadLinkedUsers(ctx, account.AppCode, []int64{account.LinkedAppUserID})
if err != nil {
return AccountDTO{}, err
}
var parent *model.ExternalAdminAccount
parentUsers := map[int64]LinkedUser{}
if account.ParentAccountID != nil {
var loaded model.ExternalAdminAccount
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", *account.ParentAccountID, account.AppCode).Take(&loaded).Error; err == nil {
parent = &loaded
parentUsers, err = s.loadLinkedUsers(ctx, account.AppCode, []int64{loaded.LinkedAppUserID})
if err != nil {
return AccountDTO{}, err
}
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return AccountDTO{}, err
}
}
return accountDTOWithParent(account, accountUsers[account.LinkedAppUserID], parent, parentUsers), nil
}
func accountReference(account model.ExternalAdminAccount, user LinkedUser) AccountReference {
if user.UserID == 0 {
user.UserID = account.LinkedAppUserID
}
return AccountReference{
ID: account.ID, Username: account.Username, IdentityType: normalizeIdentity(account.IdentityType),
Status: account.Status, LinkedUser: user,
}
}
func accountDTOWithParent(account model.ExternalAdminAccount, linkedUser LinkedUser, parent *model.ExternalAdminAccount, users map[int64]LinkedUser) AccountDTO {
dto := accountDTO(account, linkedUser)
if parent != nil {
reference := accountReference(*parent, users[parent.LinkedAppUserID])
dto.Parent = &reference
}
return dto
}
func linkedUserIDs(accounts []model.ExternalAdminAccount) []int64 {
ids := make([]int64, 0, len(accounts))
for _, account := range accounts {
ids = append(ids, account.LinkedAppUserID)
}
return ids
}
func uniquePositiveInt64s(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result
}
func equalUint64Pointers(left *uint64, right *uint64) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}

View File

@ -19,8 +19,17 @@ import (
const (
contextPrincipal = "externalAdminPrincipal"
contextCSRFToken = "externalAdminCSRFToken"
contextScope = "externalAdminScope"
)
type RequestScope struct {
AccountID uint64
LinkedAppUserID int64
IdentityType string
RegionID int64
OwnerUserIDs []int64
}
func (h *Handler) AuthRequired() gin.HandlerFunc {
return func(c *gin.Context) {
cookie, err := c.Request.Cookie(SessionCookieName)
@ -49,12 +58,24 @@ func (h *Handler) AuthRequired() gin.HandlerFunc {
c.Abort()
return
}
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), principal.AppCode))
requestContext := appctx.WithContext(c.Request.Context(), principal.AppCode)
requestContext = appctx.WithExternalScope(requestContext, appctx.ExternalScope{
AccountID: principal.AccountID, LinkedAppUserID: principal.LinkedAppUserID,
IdentityType: principal.IdentityType, RegionID: principal.RegionID,
OwnerUserIDs: append([]int64(nil), principal.ScopeOwnerUserIDs...),
})
c.Request = c.Request.WithContext(requestContext)
c.Header(appctx.HeaderAppCode, principal.AppCode)
c.Set(contextPrincipal, principal)
// Existing business handlers forward these generic actor fields to owner services. Use a
// collision-free synthetic identity there; the real account remains in the external audit log.
c.Set(adminmiddleware.ContextUserID, principal.OperatorID)
c.Set(contextScope, RequestScope{
AccountID: principal.AccountID, LinkedAppUserID: principal.LinkedAppUserID,
IdentityType: principal.IdentityType, RegionID: principal.RegionID,
OwnerUserIDs: append([]int64(nil), principal.ScopeOwnerUserIDs...),
})
// Owner services model operators as App users, not admin credential IDs. The
// isolated external audit log still records AccountID, while downstream facts
// receive the bound App identity required for team ownership and grant history.
c.Set(adminmiddleware.ContextUserID, uint(principal.LinkedAppUserID))
c.Set(adminmiddleware.ContextUsername, operatorUsername(principal.AppCode, principal.Username))
c.Set(adminmiddleware.ContextPermissions, principal.Permissions)
@ -67,6 +88,27 @@ func (h *Handler) AuthRequired() gin.HandlerFunc {
}
}
// RequireRegionScope protects every external business route with server-derived live
// scope. Authentication and password rotation remain available when an App user has no
// active region, but no business handler can treat region 0 or an invalid parent chain
// as global access.
func (h *Handler) RequireRegionScope() gin.HandlerFunc {
return func(c *gin.Context) {
principal, ok := CurrentPrincipal(c)
if !ok {
response.Unauthorized(c, "外管会话已失效")
c.Abort()
return
}
if principal.RegionID <= 0 || len(principal.ScopeOwnerUserIDs) == 0 {
response.Forbidden(c, "当前外管账号没有有效区域或团队范围")
c.Abort()
return
}
c.Next()
}
}
func (h *Handler) RequireCSRF() gin.HandlerFunc {
return func(c *gin.Context) {
if isSafeMethod(c.Request.Method) {
@ -154,6 +196,19 @@ func CurrentCSRFToken(c *gin.Context) string {
return token
}
func CurrentScope(c *gin.Context) (RequestScope, bool) {
value, ok := c.Get(contextScope)
if !ok {
return RequestScope{}, false
}
scope, ok := value.(RequestScope)
if !ok {
return RequestScope{}, false
}
scope.OwnerUserIDs = append([]int64(nil), scope.OwnerUserIDs...)
return scope, true
}
func secureEqual(left string, right string) bool {
if len(left) == 0 || len(left) != len(right) {
return false

View File

@ -117,8 +117,8 @@ func TestAuthRequiredRejectsMismatchedAppHeaderAndBindsOmittedHeaderToSessionApp
if got := c.Writer.Header().Get(appctx.HeaderAppCode); got != "fami" {
t.Fatalf("response app header = %q", got)
}
if got := adminmiddleware.CurrentUserID(c); uint64(got) != externalOperatorNamespace|7 {
t.Fatalf("synthetic operator = %d", got)
if got := adminmiddleware.CurrentUserID(c); got != 9001 {
t.Fatalf("bound App operator = %d", got)
}
}
c.Status(http.StatusNoContent)
@ -226,13 +226,19 @@ func newAuthRequiredFixture(t *testing.T, rawToken string, appState string) (*Ha
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE `external_admin_accounts`.`id` = \\?").
WithArgs(uint64(7)).
WillReturnRows(sqlmock.NewRows([]string{
"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "status",
"id", "app_code", "linked_app_user_id", "username", "password_hash", "permissions_json", "identity_type", "parent_account_id", "status",
"password_change_required", "failed_login_count", "locked_until_ms", "created_by_admin_id", "created_at_ms", "updated_at_ms",
}).AddRow(7, "fami", 9001, "operator", "hash", `["bd:view"]`, "active", false, 0, 0, 1, nowMS, nowMS))
}).AddRow(7, "fami", 9001, "operator", "hash", `["bd:view"]`, "country_manager", nil, "active", false, 0, 0, 1, nowMS, nowMS))
appQuery := userMock.ExpectQuery("SELECT app_code, app_name, logo_url FROM apps WHERE app_code = \\? AND status = 'active' LIMIT 1").WithArgs("fami")
switch appState {
case "active":
appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}).AddRow("fami", "Fami", "logo"))
userMock.ExpectQuery("SELECT u.region_id").
WithArgs("fami", int64(9001)).
WillReturnRows(sqlmock.NewRows([]string{"region_id"}).AddRow(12))
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE app_code = \\? AND parent_account_id IN \\(\\?\\) AND identity_type = \\? AND status = \\?").
WithArgs("fami", uint64(7), "external_super_admin", "active", maxHierarchyAccounts+1).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
case "disabled":
appQuery.WillReturnRows(sqlmock.NewRows([]string{"app_code", "app_name", "logo_url"}))
adminMock.ExpectBegin()

View File

@ -65,13 +65,15 @@ var permissionCatalog = []PermissionCatalogItem{
{Code: "user:ban", Label: "执行封禁", Group: "用户管理", Capabilities: []string{"user:ban"}, DefaultGranted: true},
{Code: "user:unban", Label: "解除封禁", Group: "用户管理", Capabilities: []string{"user:unban"}, DefaultGranted: true},
{Code: "host:list", Label: "主播列表", Group: "组织管理", Capabilities: []string{"host:list"}, DefaultGranted: true},
{Code: "host:invite", Label: "邀请主播", Group: "组织管理", Capabilities: []string{"host:invite"}, DefaultGranted: true},
{Code: "agency:list", Label: "公会列表", Group: "组织管理", Capabilities: []string{"agency:list"}, DefaultGranted: true},
{Code: "agency:invite", Label: "邀请公会", Group: "组织管理", Capabilities: []string{"agency:invite"}, DefaultGranted: true},
{Code: "bd:list", Label: "BD 列表", Group: "组织管理", Capabilities: []string{"bd:list"}, DefaultGranted: true},
{Code: "bd-manager:list", Label: "BD Manager 列表", Group: "组织管理", Capabilities: []string{"bd-manager:list"}, DefaultGranted: true},
{Code: "super-admin:list", Label: "Super Admin 列表", Group: "组织管理", Capabilities: []string{"super-admin:list"}, DefaultGranted: true},
{Code: "team:view", Label: "我的团队", Group: "组织管理", Capabilities: []string{"team:view"}, DefaultGranted: true},
{Code: "bd:invite", Label: "邀请 BD", Group: "组织管理", Capabilities: []string{"bd:invite"}, DefaultGranted: true},
{Code: "external-super-admin:list", Label: "外管SuperAdmin列表", Group: "组织管理", Capabilities: []string{"external-super-admin:list"}, DefaultGranted: true},
{Code: "room:list", Label: "房间管理", Group: "房间管理", Capabilities: []string{"room:list"}, DefaultGranted: true},
{Code: "room:update", Label: "房间编辑", Group: "房间管理", Capabilities: []string{"room:update"}, DefaultGranted: true},
{Code: "room:ban", Label: "房间封禁与解封", Group: "房间管理", Capabilities: []string{"room:ban"}, DefaultGranted: true},
{Code: "privilege:list", Label: "用户特权道具列表", Group: "资源与发放", Capabilities: []string{"privilege:list"}, DefaultGranted: true},
{Code: "privilege:grant", Label: "特权道具发放", Group: "资源与发放", Dependencies: []string{"user-title:grant", "room-background:grant"}, Capabilities: []string{"privilege:grant"}, DefaultGranted: true},
{Code: "pretty-id:grant", Label: "靓号下发", Group: "资源与发放", Capabilities: []string{"pretty-id:grant"}, DefaultGranted: true},
@ -82,6 +84,7 @@ var permissionCatalog = []PermissionCatalogItem{
{Code: "room-background:grant", Label: "房间背景图下发", Group: "资源与发放", Dependencies: []string{"privilege:grant", "user-title:grant"}, Capabilities: []string{"room-background:grant"}, DefaultGranted: true},
{Code: "user-level:grant", Label: "财富/VIP等级下发", Group: "资源与发放", Capabilities: []string{"user-level:grant"}, DefaultGranted: true},
{Code: "banner:create", Label: "Banner创建", Group: "运营管理", Capabilities: []string{"banner:create"}, DefaultGranted: true},
{Code: "banner:update", Label: "Banner编辑与删除", Group: "运营管理", Capabilities: []string{"banner:update"}, DefaultGranted: true},
}
// effectivePermissions upgrades the original 18 route-oriented snapshot into the
@ -110,7 +113,7 @@ func effectivePermissions(stored []string) []string {
"app-user:status": {"user:ban", "user:unban"},
"host:view": {"host:list"},
"agency:view": {"agency:list"},
"bd:view": {"bd:list", "bd-manager:list", "super-admin:list", "team:view"},
"bd:view": {"bd:list"},
"room:view": {"room:list"},
"resource-grant:create": {"privilege:grant", "user-title:grant", "room-background:grant"},
}
@ -304,11 +307,10 @@ func (s *Service) UpdateAccountPermissions(ctx context.Context, appCode string,
if err := s.db.WithContext(ctx).Where("id = ? AND app_code = ?", id, appCode).First(&account).Error; err != nil {
return PermissionUpdateResult{}, err
}
users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID})
result.Account, err = s.accountDTOWithRelations(ctx, account)
if err != nil {
return PermissionUpdateResult{}, err
}
result.Account = accountDTO(account, users[account.LinkedAppUserID])
return result, nil
}

View File

@ -16,13 +16,14 @@ import (
"github.com/gin-gonic/gin"
)
func TestPermissionCatalogMatchesTwentyProductCapabilities(t *testing.T) {
func TestPermissionCatalogMatchesCurrentProductCapabilities(t *testing.T) {
items := PermissionCatalog()
want := []string{
"user:list", "user:update", "user-ban:list", "user:ban", "user:unban",
"host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list", "team:view",
"room:list", "room:update", "privilege:list", "privilege:grant", "pretty-id:grant",
"host:list", "host:invite", "agency:list", "agency:invite", "bd:list", "bd:invite", "external-super-admin:list",
"room:list", "room:update", "room:ban", "privilege:list", "privilege:grant", "pretty-id:grant",
"user-title:grant", "room-background:grant", "user-level:grant", "banner:create",
"banner:update",
}
if len(items) != len(want) {
t.Fatalf("catalog count = %d, want %d", len(items), len(want))
@ -83,8 +84,16 @@ func TestEffectivePermissionsSafelyUpgradesLegacyAndRejectsCorruptSnapshots(t *t
"pretty-id:view", "pretty-id:grant", "app-config:view", "app-config:update",
"vip-config:grant", "upload:create",
}
if got := effectivePermissions(legacy); !reflect.DeepEqual(got, DefaultPermissionSnapshot()) {
t.Fatalf("legacy default did not expand to full 20: got=%v want=%v", got, DefaultPermissionSnapshot())
// Retired hierarchy/menu permissions and newly introduced write powers are not
// inferred from an old snapshot. A platform administrator must delegate them.
wantLegacy := []string{
"agency:list", "banner:create", "bd:list", "host:list", "pretty-id:grant",
"privilege:grant", "privilege:list", "room-background:grant", "room:list", "room:update",
"user-ban:list", "user-level:grant", "user-title:grant", "user:ban", "user:list",
"user:unban", "user:update",
}
if got := effectivePermissions(legacy); !reflect.DeepEqual(got, wantLegacy) {
t.Fatalf("legacy default expansion = %v, want fail-closed %v", got, wantLegacy)
}
got := effectivePermissions([]string{"user:list", "privilege:grant", "unknown:permission", "*", "external-admin:*"})
if !reflect.DeepEqual(got, []string{"user:list"}) {

View File

@ -9,6 +9,7 @@ import (
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
adminmiddleware "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/appuser"
@ -41,10 +42,8 @@ func TestExternalBusinessRoutesKeepIndependentPermissionBoundaries(t *testing.T)
permission string
allowed bool
}{
{name: "BD Manager list accepts its capability", method: http.MethodGet, path: "/admin/managers", permission: "bd-manager:list", allowed: true},
{name: "BD Manager list rejects Super Admin capability", method: http.MethodGet, path: "/admin/managers", permission: "super-admin:list"},
{name: "Super Admin list accepts its capability", method: http.MethodGet, path: "/admin/bd-leaders", permission: "super-admin:list", allowed: true},
{name: "Super Admin list rejects BD Manager capability", method: http.MethodGet, path: "/admin/bd-leaders", permission: "bd-manager:list"},
{name: "external SuperAdmin list accepts its capability", method: http.MethodGet, path: "/admin/external-super-admins", permission: "external-super-admin:list", allowed: true},
{name: "external SuperAdmin list rejects retired capability", method: http.MethodGet, path: "/admin/external-super-admins", permission: "super-admin:list"},
{name: "user update can read support list", method: http.MethodGet, path: "/app/users", permission: "user:update", allowed: true},
{name: "user update cannot ban", method: http.MethodPost, path: "/app/users/9001/ban", permission: "user:update"},
{name: "user unban can read ban support list", method: http.MethodGet, path: "/app/users/bans", permission: "user:unban", allowed: true},
@ -63,27 +62,43 @@ func TestExternalBusinessRoutesKeepIndependentPermissionBoundaries(t *testing.T)
}
})
}
for _, retiredPath := range []string{"/admin/managers", "/admin/bd-leaders", "/admin/my-team/agencies"} {
request := httptest.NewRequest(http.MethodGet, retiredPath, nil)
request.Header.Set("X-Test-Permission", "external-super-admin:list")
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("retired external route %s status=%d body=%s", retiredPath, recorder.Code, recorder.Body.String())
}
}
}
func TestExternalVIPRoutesUseManagerCenterOwnerPolicySource(t *testing.T) {
func TestExternalVIPRoutesExposeOnlyManagerCenterTrialCards(t *testing.T) {
gin.SetMode(gin.TestMode)
wallet := &externalVIPRouteWallet{}
engine := gin.New()
engine.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
ctx := appctx.WithContext(c.Request.Context(), "fami")
ctx = appctx.WithExternalScope(ctx, appctx.ExternalScope{AccountID: 1, LinkedAppUserID: 77, RegionID: 9, OwnerUserIDs: []int64{77}})
c.Request = c.Request.WithContext(ctx)
c.Set(contextScope, RequestScope{AccountID: 1, LinkedAppUserID: 77, RegionID: 9, OwnerUserIDs: []int64{77}})
c.Set(adminmiddleware.ContextPermissions, []string{"user-level:grant"})
c.Set(adminmiddleware.ContextRequestID, "external-vip-route-test")
c.Set(adminmiddleware.ContextUserID, uint(77))
c.Next()
})
registerBusinessWhitelist(engine.Group(""), &Handler{}, BusinessHandlers{VIPConfig: vipconfig.New(wallet, nil)})
teamClient := &externalVIPRouteUserClient{}
registerBusinessWhitelist(engine.Group(""), &Handler{}, BusinessHandlers{
AppUser: appuser.New(teamClient, nil, nil, nil, nil, config.Config{}, nil),
VIPConfig: vipconfig.New(wallet, nil),
})
vipRequest := httptest.NewRequest(http.MethodPost, "/admin/activity/vip-grants", strings.NewReader(`{"targetUserId":"9001","level":5,"reason":"manual"}`))
vipRequest.Header.Set("Content-Type", "application/json")
vipRecorder := httptest.NewRecorder()
engine.ServeHTTP(vipRecorder, vipRequest)
if vipRecorder.Code != http.StatusCreated || wallet.vipRequest == nil || wallet.vipRequest.GetGrantSource() != "manager_center" {
t.Fatalf("external VIP route source: status=%d request=%+v body=%s", vipRecorder.Code, wallet.vipRequest, vipRecorder.Body.String())
if vipRecorder.Code != http.StatusNotFound || wallet.vipRequest != nil {
t.Fatalf("direct external VIP route must be absent: status=%d request=%+v body=%s", vipRecorder.Code, wallet.vipRequest, vipRecorder.Body.String())
}
trialRequest := httptest.NewRequest(http.MethodPost, "/admin/activity/vip-trial-card-grants", strings.NewReader(`{"targetUserId":"9001","level":5,"durationMs":86400000,"resourceId":99,"reason":"manual"}`))
@ -95,6 +110,30 @@ func TestExternalVIPRoutesUseManagerCenterOwnerPolicySource(t *testing.T) {
}
}
type externalVIPRouteUserClient struct {
userclient.Client
}
func (client *externalVIPRouteUserClient) ListExternalTeamUsers(_ context.Context, request userclient.ListExternalTeamUsersRequest) (*userclient.ListExternalTeamUsersResult, error) {
return &userclient.ListExternalTeamUsersResult{
Users: []userclient.ExternalTeamUser{{UserID: 9001, RegionID: request.RegionID, Status: request.Status}},
Total: 1, Page: request.Page, PageSize: request.PageSize,
}, nil
}
func (client *externalVIPRouteUserClient) ExternalInviteBD(context.Context, userclient.ExternalInviteBDRequest) (*userclient.ExternalRoleInvitation, error) {
return nil, nil
}
func (client *externalVIPRouteUserClient) ExternalInviteAgency(context.Context, userclient.ExternalInviteAgencyRequest) (*userclient.ExternalRoleInvitation, error) {
return nil, nil
}
func (client *externalVIPRouteUserClient) ExternalInviteHost(context.Context, userclient.ExternalInviteHostRequest) (*userclient.ExternalRoleInvitation, error) {
return nil, nil
}
func (client *externalVIPRouteUserClient) SearchExternalInvitationCandidate(context.Context, userclient.SearchExternalInvitationCandidateRequest) (*userclient.ExternalTeamUser, error) {
return nil, nil
}
type externalVIPRouteWallet struct {
walletclient.Client
vipRequest *walletv1.GrantVipRequest

View File

@ -1,6 +1,7 @@
package externaladmin
import (
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/appconfig"
"hyapp-admin-server/internal/modules/appuser"
@ -31,10 +32,12 @@ func RegisterAdminRoutes(protected *gin.RouterGroup, h *Handler) {
}
protected.GET("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:view"), h.ListAccounts)
protected.GET("/admin/external-admin-users/permission-catalog", middleware.RequirePermission("external-admin-user:permissions"), h.ListPermissionCatalog)
protected.GET("/admin/external-admin-users/parent-candidates", middleware.RequirePermission("external-admin-user:permissions"), h.ListParentCandidates)
protected.GET("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.GetAccountPermissions)
protected.GET("/admin/external-admin-users/target", middleware.RequirePermission("external-admin-user:create"), h.ResolveTarget)
protected.POST("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:create"), middleware.RequirePermission("external-admin-user:permissions"), h.CreateAccount)
protected.PUT("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.UpdateAccountPermissions)
protected.PATCH("/admin/external-admin-users/:id/parent", middleware.RequirePermission("external-admin-user:permissions"), h.UpdateParent)
protected.PATCH("/admin/external-admin-users/:id/status", middleware.RequirePermission("external-admin-user:status"), h.SetStatus)
protected.POST("/admin/external-admin-users/:id/password", middleware.RequirePermission("external-admin-user:reset-password"), h.ResetPassword)
}
@ -63,7 +66,7 @@ func RegisterExternalRoutes(api *gin.RouterGroup, h *Handler, handlers BusinessH
// business-route gate. Legacy rows may still carry password_change_required=true;
// authorization must therefore depend only on the active session and permissions.
business := external.Group("")
business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF())
business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF(), h.RequireRegionScope())
registerBusinessWhitelist(business, h, handlers)
}
@ -76,62 +79,96 @@ func noStore() gin.HandlerFunc {
}
func registerBusinessWhitelist(group *gin.RouterGroup, h *Handler, handlers BusinessHandlers) {
// "My team" is not a generic manager-list alias. Its handler derives the root
// manager from the external session and never accepts a client-selected user ID.
group.GET("/admin/my-team/agencies", middleware.RequirePermission("team:view"), h.ListMyTeamAgencies)
group.GET("/admin/external-super-admins", middleware.RequirePermission("external-super-admin:list"), h.ListExternalSuperAdmins)
if handlers.AppUser != nil {
group.GET("/app/users", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user-level:grant"), handlers.AppUser.ListUsers)
group.GET("/app/users/bans", middleware.RequireAnyPermission("user-ban:list", "user:unban"), handlers.AppUser.ListBannedUsers)
teamClient := handlers.AppUser.ExternalTeamClient()
group.GET("/admin/team/users", middleware.RequireAnyPermission("user:list", "bd:list", "agency:list", "host:list"), h.ListExternalTeamUsers(teamClient, ""))
group.GET("/admin/team/invitation-candidate", middleware.RequireAnyPermission("bd:invite", "agency:invite", "host:invite"), h.SearchExternalInvitationCandidate(teamClient))
group.POST("/admin/team/bd-invitations", middleware.RequirePermission("bd:invite"), h.CreateExternalBDInvitation(teamClient))
group.POST("/admin/team/agency-invitations", middleware.RequirePermission("agency:invite"), h.CreateExternalAgencyInvitation(teamClient))
group.POST("/admin/team/host-invitations", middleware.RequirePermission("host:invite"), h.CreateExternalHostInvitation(teamClient))
group.GET("/app/users", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user-level:grant"), h.ListExternalTeamUsers(teamClient, ""))
group.GET("/app/users/bans", middleware.RequireAnyPermission("user-ban:list", "user:unban"), func(c *gin.Context) {
query := c.Request.URL.Query()
query.Set("status", "banned")
c.Request.URL.RawQuery = query.Encode()
h.ListExternalTeamUsers(teamClient, "")(c)
})
// Detail is a support read for each user-targeted action, not a second product
// capability. Any independently assigned user action can resolve its exact target.
group.GET("/app/users/:id", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user:unban", "user-level:grant"), handlers.AppUser.GetUser)
group.PATCH("/app/users/:id", middleware.RequirePermission("user:update"), handlers.AppUser.UpdateUser)
group.PUT("/app/users/:id/levels", middleware.RequirePermission("user-level:grant"), handlers.AppUser.AdjustTemporaryLevels)
group.POST("/app/users/:id/ban", middleware.RequirePermission("user:ban"), handlers.AppUser.BanUser)
group.POST("/app/users/:id/unban", middleware.RequirePermission("user:unban"), handlers.AppUser.UnbanUser)
targetGuard := h.RequireExternalTeamTarget(teamClient, "id")
group.GET("/app/users/:id", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user:unban", "user-level:grant"), targetGuard, handlers.AppUser.GetUser)
group.PATCH("/app/users/:id", middleware.RequirePermission("user:update"), targetGuard, handlers.AppUser.UpdateUser)
group.PUT("/app/users/:id/levels", middleware.RequirePermission("user-level:grant"), targetGuard, handlers.AppUser.AdjustTemporaryLevels)
group.POST("/app/users/:id/ban", middleware.RequirePermission("user:ban"), targetGuard, handlers.AppUser.BanUser)
group.POST("/app/users/:id/unban", middleware.RequirePermission("user:unban"), targetGuard, handlers.AppUser.UnbanUser)
}
if handlers.HostOrg != nil {
group.GET("/admin/hosts", middleware.RequirePermission("host:list"), handlers.HostOrg.ListHosts)
group.GET("/admin/agencies", middleware.RequirePermission("agency:list"), handlers.HostOrg.ListAgencies)
group.GET("/admin/bds", middleware.RequirePermission("bd:list"), handlers.HostOrg.ListBDs)
// The shared host-org API names are historical: external BD Manager uses
// /managers, while the Super Admin projection uses /bd-leaders.
group.GET("/admin/bd-leaders", middleware.RequirePermission("super-admin:list"), handlers.HostOrg.ListBDLeaders)
group.GET("/admin/managers", middleware.RequirePermission("bd-manager:list"), handlers.HostOrg.ListManagers)
var teamClient userclient.ExternalTeamClient
if handlers.AppUser != nil {
teamClient = handlers.AppUser.ExternalTeamClient()
}
group.GET("/admin/hosts", middleware.RequirePermission("host:list"), h.ListExternalTeamUsers(teamClient, "host"))
group.GET("/admin/agencies", middleware.RequirePermission("agency:list"), h.ListExternalTeamUsers(teamClient, "agency"))
group.GET("/admin/bds", middleware.RequirePermission("bd:list"), h.ListExternalTeamUsers(teamClient, "bd"))
}
if handlers.RoomAdmin != nil {
group.GET("/admin/rooms", middleware.RequireAnyPermission("room:list", "room:update"), handlers.RoomAdmin.ListRooms)
group.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), handlers.RoomAdmin.UpdateRoom)
var teamClient userclient.ExternalTeamClient
if handlers.AppUser != nil {
teamClient = handlers.AppUser.ExternalTeamClient()
}
roomScope := h.HydrateExternalRoomScope(teamClient)
group.GET("/admin/rooms", middleware.RequireAnyPermission("room:list", "room:update", "room:ban"), roomScope, handlers.RoomAdmin.ListRooms)
group.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), roomScope, handlers.RoomAdmin.UpdateRoom)
group.POST("/admin/rooms/:room_id/ban", middleware.RequirePermission("room:ban"), roomScope, handlers.RoomAdmin.BanRoom)
group.POST("/admin/rooms/:room_id/unban", middleware.RequirePermission("room:ban"), roomScope, handlers.RoomAdmin.UnbanRoom)
}
if handlers.Resource != nil {
var teamClient userclient.ExternalTeamClient
if handlers.AppUser != nil {
teamClient = handlers.AppUser.ExternalTeamClient()
}
teamScope := h.HydrateExternalRoomScope(teamClient)
// Resource owner data cannot yet classify generic entitlements into the three
// product labels below. The assignment validator therefore keeps them atomic,
// and the route repeats all three checks so corrupt DB JSON still fails closed.
group.GET("/admin/resources", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.ListExternalGrantResources)
group.GET("/admin/resource-grants", middleware.RequirePermission("privilege:list"), handlers.Resource.ListResourceGrants)
group.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"), handlers.Resource.LookupResourceGrantTarget)
group.POST("/admin/resource-grants/resource", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.GrantExternalResource)
group.GET("/admin/resource-grants", middleware.RequirePermission("privilege:list"), teamScope, handlers.Resource.ListResourceGrants)
group.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"), teamScope, handlers.Resource.LookupResourceGrantTarget)
group.POST("/admin/resource-grants/resource", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), teamScope, handlers.Resource.GrantExternalResource)
group.POST("/admin/resource-grants/resources", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), teamScope, handlers.Resource.GrantExternalResources)
// External UI never uses group grants. Without an owner-level semantic category,
// a mixed group cannot be authorized safely, so it is intentionally not exposed.
}
if handlers.PrettyID != nil {
var teamClient userclient.ExternalTeamClient
if handlers.AppUser != nil {
teamClient = handlers.AppUser.ExternalTeamClient()
}
teamScope := h.HydrateExternalRoomScope(teamClient)
group.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.ListIDs)
group.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.Grant)
group.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), teamScope, handlers.PrettyID.Grant)
}
if handlers.AppConfig != nil {
group.GET("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.ListBanners)
group.POST("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.CreateBanner)
group.PUT("/admin/app-config/banners/:banner_id", middleware.RequirePermission("banner:update"), handlers.AppConfig.UpdateBanner)
group.DELETE("/admin/app-config/banners/:banner_id", middleware.RequirePermission("banner:update"), handlers.AppConfig.DeleteBanner)
}
if handlers.VIPConfig != nil {
var teamClient userclient.ExternalTeamClient
if handlers.AppUser != nil {
teamClient = handlers.AppUser.ExternalTeamClient()
}
teamScope := h.HydrateExternalRoomScope(teamClient)
// Grant mode and level IDs are owner-service facts. Read them with the same narrowly scoped
// grant permission so the portal can select direct-VIP vs trial-card without config write access.
group.GET("/admin/activity/vip-program", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GetProgram)
group.GET("/admin/activity/vip-levels", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.ListLevels)
group.POST("/admin/activity/vip-trial-card-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIPTrialCard)
group.POST("/admin/activity/vip-grants", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GrantExternalVIP)
group.POST("/admin/activity/vip-trial-card-grants", middleware.RequirePermission("user-level:grant"), teamScope, handlers.VIPConfig.GrantExternalVIPTrialCard)
}
if handlers.Upload != nil {
// Banner/avatar forms need a real upload endpoint; no other file-management route is exposed.
group.POST("/admin/files/image/upload", middleware.RequirePermission("banner:create"), handlers.Upload.UploadImage)
group.POST("/admin/files/image/upload", middleware.RequireAnyPermission("banner:create", "banner:update", "user:update"), handlers.Upload.UploadImage)
}
}

View File

@ -0,0 +1,329 @@
package externaladmin
import (
"errors"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type externalInviteRequest struct {
TargetUserID FlexibleString `json:"targetUserId" binding:"required"`
ParentBDUserID FlexibleString `json:"parentBdUserId"`
AgencyID FlexibleString `json:"agencyId"`
AgencyName string `json:"agencyName"`
}
func (h *Handler) ListExternalTeamUsers(client userclient.ExternalTeamClient, fixedRole string) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
if !ok || client == nil {
response.ServerError(c, "团队服务暂不可用")
return
}
role := strings.ToLower(strings.TrimSpace(fixedRole))
if role == "" {
role = strings.ToLower(strings.TrimSpace(c.Query("role")))
}
if !externalTeamRoleAllowed(c, role) {
response.Forbidden(c, "没有该团队列表权限")
return
}
page := positiveQueryInt(c.Query("page"), 1)
pageSize := positiveQueryInt(c.Query("pageSize"), 20)
if pageSize > 100 {
pageSize = 100
}
result, err := client.ListExternalTeamUsers(c.Request.Context(), userclient.ListExternalTeamUsersRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "hyapp-admin-external",
ScopeOwnerUserIDs: scope.OwnerUserIDs, RegionID: scope.RegionID, Role: role,
Status: strings.ToLower(strings.TrimSpace(c.DefaultQuery("status", "active"))),
Keyword: strings.TrimSpace(c.Query("keyword")), Page: page, PageSize: pageSize,
})
if err != nil {
writeExternalOwnerError(c, err, "获取团队用户失败")
return
}
if result == nil {
response.ServerError(c, "团队服务返回空结果")
return
}
response.OK(c, response.Page{Items: result.Users, Page: result.Page, PageSize: result.PageSize, Total: result.Total})
}
}
func (h *Handler) SearchExternalInvitationCandidate(client userclient.ExternalTeamClient) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
if !ok || client == nil {
response.ServerError(c, "邀请服务暂不可用")
return
}
invitationType := strings.ToLower(strings.TrimSpace(c.Query("invitationType")))
if !externalInvitePermissionAllowed(c, invitationType) {
response.Forbidden(c, "没有该邀请权限")
return
}
item, err := client.SearchExternalInvitationCandidate(c.Request.Context(), userclient.SearchExternalInvitationCandidateRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "hyapp-admin-external",
OperatorUserID: scope.LinkedAppUserID, ExpectedRegionID: scope.RegionID,
InvitationType: invitationType, Keyword: strings.TrimSpace(c.Query("keyword")),
})
if err != nil {
writeExternalOwnerError(c, err, "未找到符合条件的同区域用户")
return
}
response.OK(c, item)
}
}
func (h *Handler) CreateExternalBDInvitation(client userclient.ExternalTeamClient) gin.HandlerFunc {
return h.createExternalInvitation(client, "bd")
}
func (h *Handler) CreateExternalAgencyInvitation(client userclient.ExternalTeamClient) gin.HandlerFunc {
return h.createExternalInvitation(client, "agency")
}
func (h *Handler) CreateExternalHostInvitation(client userclient.ExternalTeamClient) gin.HandlerFunc {
return h.createExternalInvitation(client, "host")
}
func (h *Handler) createExternalInvitation(client userclient.ExternalTeamClient, invitationType string) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
if !ok || client == nil {
response.ServerError(c, "邀请服务暂不可用")
return
}
var request externalInviteRequest
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "邀请参数不正确")
return
}
targetUserID, err := positiveFlexibleID(request.TargetUserID)
if err != nil {
response.BadRequest(c, "目标用户 ID 不正确")
return
}
requestID := middleware.CurrentRequestID(c)
commandID := fmt.Sprintf("external-%s:%s:%d", invitationType, requestID, targetUserID)
var invitation *userclient.ExternalRoleInvitation
switch invitationType {
case "bd":
invitation, err = client.ExternalInviteBD(c.Request.Context(), userclient.ExternalInviteBDRequest{
RequestID: requestID, Caller: "hyapp-admin-external", CommandID: commandID,
OperatorUserID: scope.LinkedAppUserID, TargetUserID: targetUserID, ExpectedRegionID: scope.RegionID,
})
case "agency":
parentBDUserID, parseErr := positiveFlexibleID(request.ParentBDUserID)
if parseErr != nil || strings.TrimSpace(request.AgencyName) == "" {
response.BadRequest(c, "请选择团队 BD 并填写 Agency 名称")
return
}
invitation, err = client.ExternalInviteAgency(c.Request.Context(), userclient.ExternalInviteAgencyRequest{
RequestID: requestID, Caller: "hyapp-admin-external", CommandID: commandID,
OperatorUserID: scope.LinkedAppUserID, ParentBDUserID: parentBDUserID, TargetUserID: targetUserID,
AgencyName: strings.TrimSpace(request.AgencyName), ScopeOwnerUserIDs: scope.OwnerUserIDs, ExpectedRegionID: scope.RegionID,
})
case "host":
agencyID, parseErr := positiveFlexibleID(request.AgencyID)
if parseErr != nil {
response.BadRequest(c, "请选择团队 Agency")
return
}
invitation, err = client.ExternalInviteHost(c.Request.Context(), userclient.ExternalInviteHostRequest{
RequestID: requestID, Caller: "hyapp-admin-external", CommandID: commandID,
OperatorUserID: scope.LinkedAppUserID, AgencyID: agencyID, TargetUserID: targetUserID,
ScopeOwnerUserIDs: scope.OwnerUserIDs, ExpectedRegionID: scope.RegionID,
})
}
if err != nil {
writeExternalOwnerError(c, err, "创建邀请失败")
return
}
response.Created(c, invitation)
}
}
func (h *Handler) RequireExternalTeamTarget(client userclient.ExternalTeamClient, param string) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
targetUserID, err := strconv.ParseInt(strings.TrimSpace(c.Param(param)), 10, 64)
if !ok || client == nil || err != nil || targetUserID <= 0 {
response.Forbidden(c, "目标用户不在当前团队")
c.Abort()
return
}
allowed := false
for _, userStatus := range []string{"active", "banned", "disabled"} {
result, lookupErr := client.ListExternalTeamUsers(c.Request.Context(), userclient.ListExternalTeamUsersRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "hyapp-admin-external-target-check",
ScopeOwnerUserIDs: scope.OwnerUserIDs, RegionID: scope.RegionID, Status: userStatus,
Keyword: strconv.FormatInt(targetUserID, 10), Page: 1, PageSize: 10,
})
if lookupErr != nil {
writeExternalOwnerError(c, lookupErr, "团队范围校验失败")
c.Abort()
return
}
for _, item := range result.Users {
if item.UserID == targetUserID && item.RegionID == scope.RegionID {
allowed = true
break
}
}
if allowed {
break
}
}
if !allowed {
response.Forbidden(c, "目标用户不在当前团队或区域")
c.Abort()
return
}
c.Next()
}
}
// HydrateExternalRoomScope resolves business team members before entering roomadmin.
// Room owners are BD/Agency/Host users, not portal Local/经理/SuperAdmin account roots.
func (h *Handler) HydrateExternalRoomScope(client userclient.ExternalTeamClient) gin.HandlerFunc {
return func(c *gin.Context) {
scope, ok := CurrentScope(c)
if !ok || client == nil {
response.ServerError(c, "团队服务暂不可用")
c.Abort()
return
}
const maxRoomScopeUsers = 5000
seen := make(map[int64]struct{})
teamUserIDs := make([]int64, 0)
for _, userStatus := range []string{"active", "banned", "disabled"} {
for page := 1; ; page++ {
result, err := client.ListExternalTeamUsers(c.Request.Context(), userclient.ListExternalTeamUsersRequest{
RequestID: middleware.CurrentRequestID(c), Caller: "hyapp-admin-external-room-scope",
ScopeOwnerUserIDs: scope.OwnerUserIDs, RegionID: scope.RegionID, Status: userStatus,
Page: page, PageSize: 200,
})
if err != nil {
writeExternalOwnerError(c, err, "房间团队范围校验失败")
c.Abort()
return
}
if result == nil {
response.ServerError(c, "团队服务返回空结果")
c.Abort()
return
}
for _, item := range result.Users {
if item.UserID <= 0 || item.RegionID != scope.RegionID {
continue
}
if _, exists := seen[item.UserID]; exists {
continue
}
seen[item.UserID] = struct{}{}
teamUserIDs = append(teamUserIDs, item.UserID)
if len(teamUserIDs) > maxRoomScopeUsers {
response.Forbidden(c, "当前团队规模超过房间管理上限")
c.Abort()
return
}
}
if len(result.Users) == 0 || result.PageSize <= 0 || int64(page*result.PageSize) >= result.Total {
break
}
}
}
requestScope := appctx.ExternalScope{
AccountID: scope.AccountID, LinkedAppUserID: scope.LinkedAppUserID,
IdentityType: scope.IdentityType, RegionID: scope.RegionID, OwnerUserIDs: teamUserIDs,
}
c.Request = c.Request.WithContext(appctx.WithExternalScope(c.Request.Context(), requestScope))
c.Next()
}
}
func positiveFlexibleID(value FlexibleString) (int64, error) {
parsed, err := strconv.ParseInt(strings.TrimSpace(string(value)), 10, 64)
if err != nil || parsed <= 0 {
return 0, ErrInvalidInput
}
return parsed, nil
}
func positiveQueryInt(raw string, fallback int) int {
value, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || value <= 0 {
return fallback
}
return value
}
func externalTeamRoleAllowed(c *gin.Context, role string) bool {
switch role {
case "":
return principalHasAnyPermission(c, "user:list", "user:update", "user:ban", "user-ban:list", "user:unban", "user-level:grant")
case "bd":
// Agency invitations must select an existing BD even when the account was
// intentionally granted agency:invite without the standalone BD list menu.
return principalHasAnyPermission(c, "bd:list", "bd:invite", "agency:invite")
case "agency":
// Host invitations have the same bounded parent-selector dependency on Agency.
return principalHasAnyPermission(c, "agency:list", "agency:invite", "host:invite")
case "host":
return principalHasAnyPermission(c, "host:list", "host:invite")
default:
return false
}
}
func externalInvitePermissionAllowed(c *gin.Context, invitationType string) bool {
return principalHasAnyPermission(c, invitationType+":invite")
}
func principalHasAnyPermission(c *gin.Context, permissions ...string) bool {
principal, ok := CurrentPrincipal(c)
if !ok {
return false
}
assigned := make(map[string]struct{}, len(principal.Permissions))
for _, permission := range principal.Permissions {
assigned[permission] = struct{}{}
}
for _, permission := range permissions {
if _, exists := assigned[permission]; exists {
return true
}
}
return false
}
func writeExternalOwnerError(c *gin.Context, err error, fallback string) {
if err == nil {
return
}
code := status.Code(err)
switch {
case code == codes.InvalidArgument:
response.BadRequest(c, status.Convert(err).Message())
case code == codes.NotFound:
response.NotFound(c, status.Convert(err).Message())
case code == codes.PermissionDenied || code == codes.FailedPrecondition:
response.Forbidden(c, status.Convert(err).Message())
case errors.Is(err, ErrInvalidInput):
response.BadRequest(c, fallback)
default:
response.ServerError(c, fallback)
}
}

View File

@ -134,9 +134,38 @@ func (s *Service) ListAccounts(ctx context.Context, input ListInput) (ListResult
if err != nil {
return ListResult{}, err
}
parentIDs := make([]uint64, 0, len(accounts))
for _, account := range accounts {
if account.ParentAccountID != nil {
parentIDs = append(parentIDs, *account.ParentAccountID)
}
}
parents := map[uint64]model.ExternalAdminAccount{}
parentUsers := map[int64]LinkedUser{}
if len(parentIDs) > 0 {
parentRows := []model.ExternalAdminAccount{}
if err := s.db.WithContext(ctx).Where("app_code = ? AND id IN ?", input.AppCode, parentIDs).Find(&parentRows).Error; err != nil {
return ListResult{}, err
}
parentUserIDs := make([]int64, 0, len(parentRows))
for _, parent := range parentRows {
parents[parent.ID] = parent
parentUserIDs = append(parentUserIDs, parent.LinkedAppUserID)
}
parentUsers, err = s.loadLinkedUsers(ctx, input.AppCode, parentUserIDs)
if err != nil {
return ListResult{}, err
}
}
items := make([]AccountDTO, 0, len(accounts))
for _, account := range accounts {
items = append(items, accountDTO(account, users[account.LinkedAppUserID]))
var parent *model.ExternalAdminAccount
if account.ParentAccountID != nil {
if resolved, ok := parents[*account.ParentAccountID]; ok {
parent = &resolved
}
}
items = append(items, accountDTOWithParent(account, users[account.LinkedAppUserID], parent, parentUsers))
}
return ListResult{Items: items, Page: input.Page, PageSize: input.PageSize, Total: total}, nil
}
@ -153,7 +182,10 @@ func (s *Service) FindAccountByLinkedUser(ctx context.Context, appCode string, l
if err != nil {
return nil, err
}
dto := accountDTO(account, linkedUser)
dto, err := s.accountDTOWithRelations(ctx, account)
if err != nil {
return nil, err
}
return &dto, nil
}
@ -163,7 +195,8 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
}
input.AppCode = normalizeAppCode(input.AppCode)
input.Username = normalizeUsername(input.Username)
if input.AppCode == "" || !validUsername(input.Username) || input.CreatedByAdminID == 0 {
input.IdentityType = normalizeIdentity(input.IdentityType)
if input.AppCode == "" || !validUsername(input.Username) || input.IdentityType == "" || input.CreatedByAdminID == 0 {
return AccountDTO{}, ErrInvalidInput
}
if err := validatePassword(input.Password); err != nil {
@ -194,6 +227,13 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
if err != nil {
return AccountDTO{}, err
}
linkedRegionID, err := s.activeUserRegion(ctx, input.AppCode, linkedUser.UserID)
if err != nil {
return AccountDTO{}, err
}
if linkedRegionID <= 0 {
return AccountDTO{}, ErrInvalidHierarchy
}
passwordHash, err := security.HashPasswordWithoutMinimum(input.Password)
if err != nil {
return AccountDTO{}, ErrInvalidInput
@ -209,6 +249,8 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
PasswordHash: passwordHash,
PermissionsJSON: permissionsJSON,
PermissionRevision: 1,
IdentityType: input.IdentityType,
ParentAccountID: input.ParentAccountID,
Status: model.ExternalAdminStatusActive,
CreatedByAdminID: input.CreatedByAdminID,
}
@ -217,6 +259,11 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
// the same transaction so a rolling-release request handled by an older instance
// cannot revive the retired first-login gate for a newly created account.
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Parent validation is repeated under an admin-row share lock so a concurrent
// disable/rebind cannot race this credential into an invalid portal hierarchy.
if _, err := s.validateParentForChild(ctx, tx, input.AppCode, input.IdentityType, input.ParentAccountID, linkedRegionID); err != nil {
return err
}
if err := tx.Create(&account).Error; err != nil {
return err
}
@ -232,7 +279,7 @@ func (s *Service) CreateAccount(ctx context.Context, input CreateInput) (Account
}
return AccountDTO{}, err
}
return accountDTO(account, linkedUser), nil
return s.accountDTOWithRelations(ctx, account)
}
func (s *Service) SetStatus(ctx context.Context, appCode string, id uint64, status string) (AccountDTO, error) {
@ -268,8 +315,7 @@ func (s *Service) SetStatus(ctx context.Context, appCode string, id uint64, stat
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil {
return AccountDTO{}, err
}
users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID})
return accountDTO(account, users[account.LinkedAppUserID]), err
return s.accountDTOWithRelations(ctx, account)
}
func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64, password string) (AccountDTO, error) {
@ -310,8 +356,7 @@ func (s *Service) ResetPassword(ctx context.Context, appCode string, id uint64,
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&account).Error; err != nil {
return AccountDTO{}, err
}
users, err := s.loadLinkedUsers(ctx, appCode, []int64{account.LinkedAppUserID})
return accountDTO(account, users[account.LinkedAppUserID]), err
return s.accountDTOWithRelations(ctx, account)
}
func (s *Service) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
@ -551,6 +596,10 @@ func (s *Service) Authenticate(ctx context.Context, rawToken string) (SessionPri
_ = s.RevokeSession(ctx, session.ID, "permission_snapshot_invalid")
return SessionPrincipal{}, ErrInvalidSession
}
scope, err := s.resolvePortalScope(ctx, session.Account)
if err != nil {
return SessionPrincipal{}, err
}
if nowMS-session.LastSeenAtMS >= lastSeenWriteInterval.Milliseconds() {
// last_seen is intentionally throttled so read-heavy external pages do not turn every request into a write.
_ = s.db.WithContext(ctx).Model(&model.ExternalAdminSession{}).Where("id = ? AND revoked_at_ms = 0", session.ID).Update("last_seen_at_ms", nowMS).Error
@ -563,6 +612,8 @@ func (s *Service) Authenticate(ctx context.Context, rawToken string) (SessionPri
return SessionPrincipal{
SessionID: session.ID, AccountID: session.Account.ID, OperatorID: operatorID, AppCode: session.AppCode,
LinkedAppUserID: session.Account.LinkedAppUserID, Username: session.Account.Username,
IdentityType: normalizeIdentity(session.Account.IdentityType), RegionID: scope.RegionID,
ScopeOwnerUserIDs: append([]int64(nil), scope.OwnerUserIDs...),
// The persisted flag is a deprecated compatibility column. Always expose false
// so historical rows marked by older releases cannot recreate the retired gate.
Permissions: permissions, PasswordChangeRequired: false,
@ -657,10 +708,20 @@ func (s *Service) sessionView(ctx context.Context, account model.ExternalAdminAc
if err != nil {
return SessionView{}, err
}
scope, err := s.resolvePortalScope(ctx, account)
if err != nil {
return SessionView{}, err
}
identity := normalizeIdentity(account.IdentityType)
return SessionView{
User: user,
Account: AccountSummary{ID: account.ID, Username: account.Username, Status: account.Status},
Account: AccountSummary{
ID: account.ID, Username: account.Username, Status: account.Status,
IdentityType: identity, ParentAccountID: account.ParentAccountID,
},
App: app, AppCode: account.AppCode, Permissions: permissions, Capabilities: capabilitiesFor(permissions),
IdentityType: identity, RegionID: scope.RegionID,
ExternalSuperAdminCount: int64(len(scope.ExternalSuperAdminAccounts)),
// Keep the wire field during rolling upgrades, but never surface the deprecated
// database marker as an instruction to block navigation or business APIs.
PasswordChangeRequired: false, CSRFToken: csrfToken,
@ -773,7 +834,9 @@ func accountDTO(account model.ExternalAdminAccount, linkedUser LinkedUser) Accou
}
return AccountDTO{
ID: account.ID, AppCode: account.AppCode, Username: account.Username, Status: account.Status,
LinkedUser: linkedUser, Permissions: permissions, PermissionRevision: account.PermissionRevision, CreatedByAdminID: account.CreatedByAdminID,
LinkedUser: linkedUser, Permissions: permissions, PermissionRevision: account.PermissionRevision,
IdentityType: normalizeIdentity(account.IdentityType), ParentAccountID: account.ParentAccountID,
CreatedByAdminID: account.CreatedByAdminID,
CreatedAtMS: account.CreatedAtMS, UpdatedAtMS: account.UpdatedAtMS, LastLoginAtMS: account.LastLoginAtMS,
}
}

View File

@ -134,6 +134,12 @@ func TestLoginWithoutAppCreatesSessionFromResolvedAccountTenant(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{
"user_id", "current_display_user_id", "default_display_user_id", "pretty_display_user_id", "pretty_id", "username", "avatar", "status",
}).AddRow(9001, "123456", "654321", "8888", "8888", "linked-user", "avatar", "active"))
userMock.ExpectQuery("SELECT u.region_id").
WithArgs("fami", int64(9001)).
WillReturnRows(sqlmock.NewRows([]string{"region_id"}).AddRow(12))
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE app_code = \\? AND parent_account_id IN \\(\\?\\) AND identity_type = \\? AND status = \\?").
WithArgs("fami", uint64(7), "external_super_admin", "active", maxHierarchyAccounts+1).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
service := NewService(adminDB, userDB, Config{})
service.limiter = allowFixedWindowLimiter{}
@ -149,6 +155,9 @@ func TestLoginWithoutAppCreatesSessionFromResolvedAccountTenant(t *testing.T) {
if result.View.AppCode != "fami" || result.View.App.AppCode != "fami" || result.View.Account.Username != "operator" {
t.Fatalf("resolved session view = %+v", result.View)
}
if result.View.IdentityType != "country_manager" || result.View.RegionID != 12 || result.View.ExternalSuperAdminCount != 0 {
t.Fatalf("identity scope view = %+v", result.View)
}
if err := adminMock.ExpectationsWereMet(); err != nil {
t.Fatalf("admin sql expectations: %v", err)
}

View File

@ -2,16 +2,19 @@ package externaladmin
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
adminmiddleware "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/model"
"github.com/DATA-DOG/go-sqlmock"
"github.com/gin-gonic/gin"
"google.golang.org/grpc"
"gorm.io/driver/mysql"
"gorm.io/gorm"
userv1 "hyapp.local/api/proto/user/v1"
)
@ -36,59 +39,7 @@ func (client *managerTeamHostClient) ListManagerTeamAgencies(_ context.Context,
}, nil
}
func TestMyTeamRouteUsesOnlySessionLinkedUserAndPaginates(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
engine := gin.New()
group := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) {
// This fixture models fields populated by AuthRequired. Query parameters below
// intentionally carry attacker-selected IDs and must never override the principal.
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"team:view"})
c.Next()
})
registerBusinessWhitelist(group, handler, BusinessHandlers{})
request := httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies?page=2&page_size=2&manager_user_id=999&user_id=888", nil)
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if client.calls != 1 || client.request == nil {
t.Fatalf("owner service calls = %d request=%v", client.calls, client.request)
}
if client.request.GetManagerUserId() != 42 {
t.Fatalf("manager user id = %d, want session linked user 42", client.request.GetManagerUserId())
}
if client.request.GetMeta().GetAppCode() != "fami" || client.request.GetPageSize() != myTeamAgencyRPCPageSize {
t.Fatalf("owner request is not session scoped: %+v", client.request)
}
var body struct {
Data MyTeamAgencyPage `json:"data"`
}
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Data.Page != 2 || body.Data.PageSize != 2 || body.Data.Total != 3 || len(body.Data.Items) != 1 {
t.Fatalf("unexpected page: %+v", body.Data)
}
item := body.Data.Items[0]
if item.AgencyID != 103 || item.OwnerUserID != 1003 || item.ParentBDUserID != 2003 || item.Status != "active" {
t.Fatalf("unexpected agency projection: %+v", item)
}
if body.Data.TotalBDLeaders != 4 || body.Data.TotalBDs != 9 || !body.Data.Truncated {
t.Fatalf("owner totals not preserved: %+v", body.Data)
}
if got := responseRecorder.Body.String(); !containsAll(got, `"agencyId":"103"`, `"ownerUserId":"1003"`, `"parentBdUserId":"2003"`) {
t.Fatalf("int64 ids must be JSON strings: %s", got)
}
}
func TestMyTeamRouteRequiresTeamViewPermissionBeforeOwnerCall(t *testing.T) {
func TestRetiredMyTeamRouteIsNotExposed(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
@ -101,55 +52,68 @@ func TestMyTeamRouteRequiresTeamViewPermissionBeforeOwnerCall(t *testing.T) {
})
registerBusinessWhitelist(group, handler, BusinessHandlers{})
request := httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies?page=2&page_size=2&manager_user_id=999&user_id=888", nil)
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies", nil))
if responseRecorder.Code != http.StatusForbidden {
engine.ServeHTTP(responseRecorder, request)
if responseRecorder.Code != http.StatusNotFound {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
}
if client.calls != 0 {
t.Fatalf("owner service called %d times without team:view", client.calls)
t.Fatalf("retired route called owner service %d times", client.calls)
}
}
func TestMyTeamRouteFiltersScopedIDProjectionBeforeTotalAndPagination(t *testing.T) {
gin.SetMode(gin.TestMode)
client := &managerTeamHostClient{}
handler := New(nil, nil, Config{}, nil, WithUserHostClient(client))
engine := gin.New()
group := engine.Group("/api/v1/external")
group.Use(func(c *gin.Context) {
c.Set(contextPrincipal, SessionPrincipal{AppCode: "fami", LinkedAppUserID: 42})
c.Set(adminmiddleware.ContextPermissions, []string{"team:view"})
c.Next()
})
registerBusinessWhitelist(group, handler, BusinessHandlers{})
func TestValidateParentRejectsManagerWhoseLocalMovedRegion(t *testing.T) {
adminSQL, adminMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new admin sqlmock: %v", err)
}
defer adminSQL.Close()
adminDB, err := gorm.Open(mysql.New(mysql.Config{Conn: adminSQL, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
t.Fatalf("open admin gorm: %v", err)
}
userDB, userMock, err := sqlmock.New()
if err != nil {
t.Fatalf("new user sqlmock: %v", err)
}
defer userDB.Close()
// "002" matches owner 1002 and parent BD 2002. Filtering happens only over
// the already session-scoped RPC projection and must precede total/pagination.
responseRecorder := httptest.NewRecorder()
engine.ServeHTTP(responseRecorder, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies?keyword=002&page=1&page_size=1", nil))
if responseRecorder.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", responseRecorder.Code, responseRecorder.Body.String())
managerID := uint64(11)
localID := uint64(9)
accountColumns := []string{"id", "app_code", "linked_app_user_id", "identity_type", "parent_account_id", "status"}
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\? AND identity_type = \\? AND status = \\?").
WithArgs(managerID, "fami", model.ExternalAdminIdentityCountryManager, model.ExternalAdminStatusActive, 1).
WillReturnRows(sqlmock.NewRows(accountColumns).AddRow(
managerID, "fami", int64(1100), model.ExternalAdminIdentityCountryManager, localID, model.ExternalAdminStatusActive,
))
userMock.ExpectQuery("SELECT u.region_id").
WithArgs("fami", int64(1100)).
WillReturnRows(sqlmock.NewRows([]string{"region_id"}).AddRow(int64(12)))
adminMock.ExpectQuery("SELECT \\* FROM `external_admin_accounts` WHERE id = \\? AND app_code = \\? AND identity_type = \\? AND status = \\?").
WithArgs(localID, "fami", model.ExternalAdminIdentityLocal, model.ExternalAdminStatusActive, 1).
WillReturnRows(sqlmock.NewRows(accountColumns).AddRow(
localID, "fami", int64(900), model.ExternalAdminIdentityLocal, nil, model.ExternalAdminStatusActive,
))
userMock.ExpectQuery("SELECT u.region_id").
WithArgs("fami", int64(900)).
WillReturnRows(sqlmock.NewRows([]string{"region_id"}).AddRow(int64(13)))
service := NewService(adminDB, userDB, Config{})
if _, err := service.validateParentForChild(
t.Context(),
adminDB,
"fami",
model.ExternalAdminIdentityExternalSuperAdmin,
&managerID,
12,
); !errors.Is(err, ErrInvalidHierarchy) {
t.Fatalf("validate parent error = %v, want ErrInvalidHierarchy", err)
}
var body struct {
Data MyTeamAgencyPage `json:"data"`
if err := adminMock.ExpectationsWereMet(); err != nil {
t.Fatalf("admin expectations: %v", err)
}
if err := json.Unmarshal(responseRecorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body.Data.Total != 1 || len(body.Data.Items) != 1 || body.Data.Items[0].AgencyID != 102 {
t.Fatalf("keyword filter/page = %+v", body.Data)
}
if client.request.GetManagerUserId() != 42 {
t.Fatalf("keyword must not change team scope: manager=%d", client.request.GetManagerUserId())
if err := userMock.ExpectationsWereMet(); err != nil {
t.Fatalf("user expectations: %v", err)
}
}
func containsAll(value string, targets ...string) bool {
for _, target := range targets {
if !strings.Contains(value, target) {
return false
}
}
return true
}

View File

@ -29,6 +29,8 @@ var (
ErrInvalidInput = errors.New("invalid external admin input")
ErrInvalidPermissions = errors.New("invalid external admin permissions")
ErrPermissionConflict = errors.New("external admin permission revision conflict")
ErrParentConflict = errors.New("external admin parent revision conflict")
ErrInvalidHierarchy = errors.New("invalid external admin hierarchy")
ErrInvalidSession = errors.New("invalid external admin session")
ErrPasswordReused = errors.New("new external admin password matches current password")
ErrPasswordBlank = errors.New("external admin password cannot be only whitespace")
@ -98,16 +100,31 @@ type AccountDTO struct {
LinkedUser LinkedUser `json:"linkedUser"`
Permissions []string `json:"permissions"`
PermissionRevision uint64 `json:"permissionRevision"`
IdentityType string `json:"identityType"`
ParentAccountID *uint64 `json:"parentAccountId,omitempty"`
Parent *AccountReference `json:"parent,omitempty"`
CreatedByAdminID uint `json:"createdByAdminId"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
LastLoginAtMS *int64 `json:"lastLoginAtMs"`
}
// AccountReference is the bounded parent/descendant projection used by selectors
// and hierarchy lists. It deliberately excludes permissions and credentials.
type AccountReference struct {
ID uint64 `json:"id"`
Username string `json:"username"`
IdentityType string `json:"identityType"`
Status string `json:"status"`
LinkedUser LinkedUser `json:"linkedUser"`
}
type AccountSummary struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Status string `json:"status"`
IdentityType string `json:"identityType"`
ParentAccountID *uint64 `json:"parentAccountId,omitempty"`
}
type SessionView struct {
@ -117,6 +134,9 @@ type SessionView struct {
AppCode string `json:"appCode"`
Permissions []string `json:"permissions"`
Capabilities []string `json:"capabilities"`
IdentityType string `json:"identityType"`
RegionID int64 `json:"regionId"`
ExternalSuperAdminCount int64 `json:"externalSuperAdminCount"`
PasswordChangeRequired bool `json:"passwordChangeRequired"`
CSRFToken string `json:"csrfToken,omitempty"`
}
@ -156,9 +176,35 @@ type CreateInput struct {
Password string
Permissions []string
PermissionsSet bool
IdentityType string
ParentAccountID *uint64
CreatedByAdminID uint
}
type UpdateParentInput struct {
ParentAccountID *uint64
ExpectedRevision uint64
}
type ParentUpdateResult struct {
Account AccountDTO `json:"account"`
Changed bool `json:"changed"`
SessionsRevoked bool `json:"sessionsRevoked"`
}
type ParentCandidateInput struct {
AppCode string
IdentityType string
TargetUserID string
ChildAccountID uint64
Keyword string
}
type ParentCandidateResult struct {
Items []AccountReference `json:"items"`
Truncated bool `json:"truncated"`
}
type ChangePasswordInput struct {
CurrentPassword string
NewPassword string
@ -170,6 +216,9 @@ type SessionPrincipal struct {
OperatorID uint
AppCode string
LinkedAppUserID int64
IdentityType string
RegionID int64
ScopeOwnerUserIDs []int64
Username string
Permissions []string
PasswordChangeRequired bool

View File

@ -7,6 +7,8 @@ import (
"sort"
"strings"
"testing"
"hyapp-admin-server/internal/model"
)
func TestExternalOperatorIdentityUsesStablePositiveNamespace(t *testing.T) {
@ -49,9 +51,9 @@ func TestDefaultPermissionSnapshotProducesCompleteCapabilityContract(t *testing.
permissions := DefaultPermissionSnapshot()
wantPermissions := []string{
"user:list", "user:update", "user-ban:list", "user:ban", "user:unban",
"host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list",
"room:list", "room:update", "privilege:list", "privilege:grant", "banner:create",
"pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant", "team:view",
"host:list", "host:invite", "agency:list", "agency:invite", "bd:list", "bd:invite", "external-super-admin:list",
"room:list", "room:update", "room:ban", "privilege:list", "privilege:grant", "banner:create", "banner:update",
"pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant",
}
for _, permission := range wantPermissions {
if !contains(permissions, permission) {
@ -62,9 +64,9 @@ func TestDefaultPermissionSnapshotProducesCompleteCapabilityContract(t *testing.
capabilities := capabilitiesFor(permissions)
wantCapabilities := []string{
"user:list", "user:update", "user-ban:list", "user:ban", "user:unban",
"host:list", "agency:list", "bd:list", "bd-manager:list", "super-admin:list",
"room:list", "room:update", "privilege:list", "privilege:grant", "banner:create",
"pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant", "team:view",
"host:list", "host:invite", "agency:list", "agency:invite", "bd:list", "bd:invite", "external-super-admin:list",
"room:list", "room:update", "room:ban", "privilege:list", "privilege:grant", "banner:create", "banner:update",
"pretty-id:grant", "user-title:grant", "room-background:grant", "user-level:grant",
}
sort.Strings(wantCapabilities)
if !reflect.DeepEqual(capabilities, wantCapabilities) {
@ -72,6 +74,30 @@ func TestDefaultPermissionSnapshotProducesCompleteCapabilityContract(t *testing.
}
}
func TestExternalIdentityHierarchyUsesExplicitPortalOnlyRoles(t *testing.T) {
tests := []struct {
input string
wantIdentity string
wantParentRole string
parentRequired bool
}{
{input: "", wantIdentity: model.ExternalAdminIdentityCountryManager, wantParentRole: model.ExternalAdminIdentityLocal},
{input: " COUNTRY_MANAGER ", wantIdentity: model.ExternalAdminIdentityCountryManager, wantParentRole: model.ExternalAdminIdentityLocal},
{input: "local", wantIdentity: model.ExternalAdminIdentityLocal},
{input: "external_super_admin", wantIdentity: model.ExternalAdminIdentityExternalSuperAdmin, wantParentRole: model.ExternalAdminIdentityCountryManager, parentRequired: true},
}
for _, testCase := range tests {
identity := normalizeIdentity(testCase.input)
parentRole, required := requiredParentIdentity(identity)
if identity != testCase.wantIdentity || parentRole != testCase.wantParentRole || required != testCase.parentRequired {
t.Fatalf("identity=%q normalized=%q parent=%q required=%t", testCase.input, identity, parentRole, required)
}
}
if got := normalizeIdentity("manager"); got != "" {
t.Fatalf("ambiguous legacy manager identity must be rejected, got %q", got)
}
}
func TestFlexibleStringAcceptsShortIDStringAndInteger(t *testing.T) {
for _, testCase := range []struct {
body string

View File

@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
@ -190,12 +191,18 @@ func (h *Handler) ListIDs(c *gin.Context) {
return
}
// 列表筛选保持轻量透传空字符串表示不过滤assigned_user_id 只在正整数时作为精确条件。
statusFilter := strings.TrimSpace(c.Query("status"))
if _, external := appctx.ExternalScopeFromContext(c.Request.Context()); external {
// 外管入口只提供可发放号码,不能浏览其他区域用户当前占用的靓号。
statusFilter = "available"
assignedUserID = 0
}
items, total, err := h.user.ListPrettyDisplayIDs(c.Request.Context(), userclient.ListPrettyDisplayIDsRequest{
RequestID: middleware.CurrentRequestID(c),
Caller: "admin-server",
PoolID: strings.TrimSpace(c.Query("pool_id")),
Source: strings.TrimSpace(c.Query("source")),
Status: strings.TrimSpace(c.Query("status")),
Status: statusFilter,
Keyword: strings.TrimSpace(c.Query("keyword")),
AssignedUserID: assignedUserID,
Page: page,
@ -265,6 +272,10 @@ func (h *Handler) Grant(c *gin.Context) {
response.BadRequest(c, err.Error())
return
}
if scope, external := appctx.ExternalScopeFromContext(c.Request.Context()); external && !prettyIDTargetAllowed(scope, targetUserID) {
response.Forbidden(c, "目标用户不在当前团队或区域")
return
}
// 后台发放最终仍只把内部 user_id 传给 user-service短号/靓号只作为后台输入别名,避免运营把 6 位短号误当成内部 ID 导致 NotFound。
result, err := h.user.AdminGrantPrettyDisplayID(c.Request.Context(), userclient.AdminGrantPrettyDisplayIDRequest{
RequestID: middleware.CurrentRequestID(c),
@ -283,6 +294,15 @@ func (h *Handler) Grant(c *gin.Context) {
response.Created(c, result)
}
func prettyIDTargetAllowed(scope appctx.ExternalScope, targetUserID int64) bool {
for _, userID := range scope.OwnerUserIDs {
if scope.RegionID > 0 && userID == targetUserID {
return true
}
}
return false
}
func (h *Handler) SetStatus(c *gin.Context) {
var req statusRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -1,6 +1,11 @@
package resource
import walletv1 "hyapp.local/api/proto/wallet/v1"
import (
"encoding/json"
"strings"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
type resourceDTO struct {
AppCode string `json:"appCode"`
@ -120,6 +125,7 @@ type grantItemDTO struct {
GrantID string `json:"grantId"`
ResourceID int64 `json:"resourceId"`
ResourceSnapshotJSON string `json:"resourceSnapshotJson"`
ResourceCoverURL string `json:"resourceCoverUrl,omitempty"`
Quantity int64 `json:"quantity"`
DurationMS int64 `json:"durationMs"`
ResultType string `json:"resultType"`
@ -419,11 +425,18 @@ func grantFromProto(grant *walletv1.ResourceGrant) grantDTO {
}
items := make([]grantItemDTO, 0, len(grant.GetItems()))
for _, item := range grant.GetItems() {
var snapshot resourceNoticeSnapshot
_ = json.Unmarshal([]byte(item.GetResourceSnapshotJson()), &snapshot)
coverURL := strings.TrimSpace(snapshot.PreviewURL)
if coverURL == "" {
coverURL = strings.TrimSpace(snapshot.AssetURL)
}
items = append(items, grantItemDTO{
GrantItemID: item.GetGrantItemId(),
GrantID: item.GetGrantId(),
ResourceID: item.GetResourceId(),
ResourceSnapshotJSON: item.GetResourceSnapshotJson(),
ResourceCoverURL: coverURL,
Quantity: item.GetQuantity(),
DurationMS: item.GetDurationMs(),
ResultType: item.GetResultType(),

View File

@ -729,6 +729,10 @@ func (h *Handler) grantResource(c *gin.Context, external bool) {
response.BadRequest(c, err.Error())
return
}
if external && !externalGrantTargetAllowed(c.Request.Context(), targetUserID) {
response.Forbidden(c, "目标用户不在当前团队或区域")
return
}
if err := h.validateGenericGrantResource(c, req.ResourceID, external); err != nil {
response.BadRequest(c, err.Error())
return
@ -768,28 +772,60 @@ func (h *Handler) grantResource(c *gin.Context, external bool) {
// delivered in a single inbox card. Child wallet commands are deterministic, therefore an HTTP
// retry cannot duplicate entitlements that were already committed before a later item failed.
func (h *Handler) GrantResources(c *gin.Context) {
h.grantResources(c, false)
}
// GrantExternalResources applies the same deterministic batch semantics as main admin
// while forcing manager_center and validating every target/resource before the first write.
func (h *Handler) GrantExternalResources(c *gin.Context) {
h.grantResources(c, true)
}
func (h *Handler) grantResources(c *gin.Context, external bool) {
var req grantResourcesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "资源批量赠送参数不正确")
return
}
if len(req.Resources) == 0 && len(req.ResourceIDs) > 0 {
req.Resources = make([]grantResourcesItemRequest, 0, len(req.ResourceIDs))
for _, resourceID := range req.ResourceIDs {
req.Resources = append(req.Resources, grantResourcesItemRequest{
ResourceID: resourceID, Quantity: req.Quantity, DurationMS: req.DurationMS,
})
}
}
targetUserID, err := parseGrantTargetUserID(req.TargetUserID)
if err != nil || strings.TrimSpace(req.CommandID) == "" || len(strings.TrimSpace(req.CommandID)) > 96 || strings.TrimSpace(req.Reason) == "" || len(req.Resources) == 0 || len(req.Resources) > 100 {
response.BadRequest(c, "资源批量赠送参数不正确")
return
}
if external && !externalGrantTargetAllowed(c.Request.Context(), targetUserID) {
response.Forbidden(c, "目标用户不在当前团队或区域")
return
}
seenResourceIDs := make(map[int64]struct{}, len(req.Resources))
for _, item := range req.Resources {
if item.ResourceID <= 0 || item.Quantity <= 0 || item.DurationMS < 0 {
response.BadRequest(c, "资源批量赠送参数不正确")
return
}
if err := h.validateGenericGrantResource(c, item.ResourceID, false); err != nil {
if _, duplicated := seenResourceIDs[item.ResourceID]; duplicated {
response.BadRequest(c, "同一资源不能重复选择")
return
}
seenResourceIDs[item.ResourceID] = struct{}{}
if err := h.validateGenericGrantResource(c, item.ResourceID, external); err != nil {
response.BadRequest(c, err.Error())
return
}
}
appCode := appctx.FromContext(c.Request.Context())
grantSource := grantSourceAdmin
if external {
grantSource = grantSourceManagerCenter
}
grants := make([]*walletv1.ResourceGrant, 0, len(req.Resources))
grantDTOs := make([]grantDTO, 0, len(req.Resources))
for index, item := range req.Resources {
@ -802,7 +838,7 @@ func (h *Handler) GrantResources(c *gin.Context) {
DurationMs: item.DurationMS,
Reason: strings.TrimSpace(req.Reason),
OperatorUserId: actorID(c),
GrantSource: "admin",
GrantSource: grantSource,
})
if grantErr != nil {
response.BadRequest(c, grantErr.Error())
@ -822,6 +858,30 @@ func (h *Handler) GrantResources(c *gin.Context) {
response.Created(c, map[string]any{"grants": grantDTOs})
}
func externalGrantTargetAllowed(ctx context.Context, targetUserID int64) bool {
scope, external := appctx.ExternalScopeFromContext(ctx)
if !external {
return true
}
if scope.RegionID <= 0 || targetUserID <= 0 {
return false
}
for _, userID := range scope.OwnerUserIDs {
if userID == targetUserID {
return true
}
}
return false
}
func legacyExternalOperatorID(accountID uint64) int64 {
const legacyNamespace uint64 = 1 << 62
if accountID == 0 || accountID >= legacyNamespace {
return 0
}
return int64(legacyNamespace | accountID)
}
func resourceBatchChildCommandID(batchCommandID string, index int, resourceID int64) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", strings.TrimSpace(batchCommandID), index, resourceID)))
return "admin_resource_grant:" + hex.EncodeToString(sum[:])
@ -1001,6 +1061,10 @@ func (h *Handler) LookupResourceGrantTarget(c *gin.Context) {
response.ServerError(c, "查询用户失败")
return
}
if !externalGrantTargetAllowed(c.Request.Context(), user.UserID) {
response.Forbidden(c, "目标用户不在当前团队或区域")
return
}
response.OK(c, user)
}
@ -1035,14 +1099,21 @@ func (h *Handler) ListResourceGrants(c *gin.Context) {
return
}
}
resp, err := h.wallet.ListResourceGrants(c.Request.Context(), &walletv1.ListResourceGrantsRequest{
request := &walletv1.ListResourceGrantsRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appCode,
TargetUserId: targetUserID,
Status: options.Status,
Page: int32(options.Page),
PageSize: int32(options.PageSize),
})
}
if scope, external := appctx.ExternalScopeFromContext(c.Request.Context()); external {
// Current grants use the linked App user. The high-bit synthetic ID is retained
// only in historical rows written before portal operators were corrected.
request.GrantSource = grantSourceManagerCenter
request.OperatorUserIds = []int64{scope.LinkedAppUserID, legacyExternalOperatorID(scope.AccountID)}
}
resp, err := h.wallet.ListResourceGrants(c.Request.Context(), request)
if err != nil {
response.ServerError(c, "获取资源赠送记录失败")
return

View File

@ -253,6 +253,9 @@ type grantResourcesRequest struct {
CommandID string `json:"commandId"`
TargetUserID any `json:"targetUserId"`
Resources []grantResourcesItemRequest `json:"resources"`
ResourceIDs []int64 `json:"resourceIds"`
Quantity int64 `json:"quantity"`
DurationMS int64 `json:"durationMs"`
Reason string `json:"reason"`
}

View File

@ -231,6 +231,44 @@ func (h *Handler) DeleteRoom(c *gin.Context) {
response.OK(c, gin.H{"deleted": true})
}
func (h *Handler) BanRoom(c *gin.Context) {
roomID, ok := parseRoomID(c)
if !ok {
return
}
var req moderateRoomRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Reason) == "" {
response.BadRequest(c, "请输入封禁原因")
return
}
room, err := h.service.BanRoom(c.Request.Context(), roomID, req.Reason, shared.ActorFromContext(c), middleware.CurrentRequestID(c))
if err != nil {
writeMutationError(c, err, "封禁房间失败")
return
}
writeRoomAuditLog(c, h.audit, "ban-room", "rooms", roomID, "success", "ban room")
response.OK(c, room)
}
func (h *Handler) UnbanRoom(c *gin.Context) {
roomID, ok := parseRoomID(c)
if !ok {
return
}
var req moderateRoomRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Reason) == "" {
response.BadRequest(c, "请输入解封原因")
return
}
room, err := h.service.UnbanRoom(c.Request.Context(), roomID, req.Reason, shared.ActorFromContext(c), middleware.CurrentRequestID(c))
if err != nil {
writeMutationError(c, err, "解封房间失败")
return
}
writeRoomAuditLog(c, h.audit, "unban-room", "rooms", roomID, "success", "unban room")
response.OK(c, room)
}
func (h *Handler) setRobotRoomStatus(c *gin.Context, status string, action string, fallback string) {
roomID, ok := parseRoomID(c)
if !ok {

View File

@ -14,6 +14,7 @@ type listQuery struct {
PageSize int
Keyword string
OwnerUserID int64
OwnerUserIDs []int64
OwnerUserKeyword string
OwnerFilter shared.UserIdentityFilter
Status string
@ -134,6 +135,10 @@ type updateRoomRequest struct {
VisibleRegionID *int64 `json:"visibleRegionId"`
}
type moderateRoomRequest struct {
Reason string `json:"reason"`
}
type updateRoomConfigRequest struct {
AllowedSeatCounts []int32 `json:"allowedSeatCounts"`
DefaultSeatCount int32 `json:"defaultSeatCount"`

View File

@ -73,6 +73,16 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
return nil, 0, fmt.Errorf("room service client is not configured")
}
query = normalizeListQuery(query)
if scope, external := appctx.ExternalScopeFromContext(ctx); external {
// 外管查询的区域与 owner 集合均由认证链路生成;空团队必须返回空页,
// 不能把空 IN 条件退化成主后台的全 App 查询。
if scope.RegionID <= 0 || len(scope.OwnerUserIDs) == 0 {
return []Room{}, 0, nil
}
query.RegionID = scope.RegionID
query.OwnerUserID = 0
query.OwnerUserIDs = append([]int64(nil), scope.OwnerUserIDs...)
}
ownerUserID, ownerMatched, err := s.resolveListOwnerUserID(ctx, query)
if err != nil {
return nil, 0, err
@ -98,6 +108,7 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
Status: query.Status,
RegionID: query.RegionID,
OwnerUserID: ownerUserID,
OwnerUserIDs: append([]int64(nil), query.OwnerUserIDs...),
SortBy: query.SortBy,
SortDirection: query.SortDirection,
})
@ -169,6 +180,9 @@ func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
return Room{}, mapRoomClientError(err)
}
items := []Room{roomFromClient(*item)}
if scope, external := appctx.ExternalScopeFromContext(ctx); external && !externalRoomAllowed(scope, item.OwnerUserID, item.VisibleRegionID) {
return Room{}, ErrNotFound
}
if err := s.fillRoomDetails(ctx, items); err != nil {
return Room{}, err
}
@ -182,6 +196,14 @@ func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomR
if _, _, err := buildRoomUpdate(req); err != nil {
return Room{}, err
}
if _, external := appctx.ExternalScopeFromContext(ctx); external {
if req.VisibleRegionID != nil {
return Room{}, fmt.Errorf("%w: 外管不能修改房间区域", ErrInvalidArgument)
}
if _, err := s.GetRoom(ctx, roomID); err != nil {
return Room{}, err
}
}
if !roomUpdateHasChange(req) {
return s.GetRoom(ctx, roomID)
}
@ -219,6 +241,56 @@ func (s *Service) DeleteRoom(ctx context.Context, roomID string, actor shared.Ac
return mapRoomClientError(err)
}
func (s *Service) BanRoom(ctx context.Context, roomID string, reason string, actor shared.Actor, requestID string) (Room, error) {
reason = strings.TrimSpace(reason)
if roomID == "" || reason == "" {
return Room{}, fmt.Errorf("%w: room and reason are required", ErrInvalidArgument)
}
if _, external := appctx.ExternalScopeFromContext(ctx); external {
if _, err := s.GetRoom(ctx, roomID); err != nil {
return Room{}, err
}
}
if _, err := s.roomClient.BanRoom(ctx, roomclient.ModerateRoomRequest{
RequestID: requestID, RoomID: roomID, ActorUserID: int64(actor.UserID), Reason: reason,
AdminID: uint64(actor.UserID), AdminName: actor.Username,
}); err != nil {
return Room{}, mapRoomClientError(err)
}
return s.GetRoom(ctx, roomID)
}
func (s *Service) UnbanRoom(ctx context.Context, roomID string, reason string, actor shared.Actor, requestID string) (Room, error) {
reason = strings.TrimSpace(reason)
if roomID == "" || reason == "" {
return Room{}, fmt.Errorf("%w: room and reason are required", ErrInvalidArgument)
}
if _, external := appctx.ExternalScopeFromContext(ctx); external {
if _, err := s.GetRoom(ctx, roomID); err != nil {
return Room{}, err
}
}
if _, err := s.roomClient.UnbanRoom(ctx, roomclient.ModerateRoomRequest{
RequestID: requestID, RoomID: roomID, ActorUserID: int64(actor.UserID), Reason: reason,
AdminID: uint64(actor.UserID), AdminName: actor.Username,
}); err != nil {
return Room{}, mapRoomClientError(err)
}
return s.GetRoom(ctx, roomID)
}
func externalRoomAllowed(scope appctx.ExternalScope, ownerUserID int64, regionID int64) bool {
if scope.RegionID <= 0 || regionID != scope.RegionID || ownerUserID <= 0 {
return false
}
for _, allowedOwnerUserID := range scope.OwnerUserIDs {
if ownerUserID == allowedOwnerUserID {
return true
}
}
return false
}
func roomUpdateHasChange(req updateRoomRequest) bool {
return req.Title != nil || req.CoverURL != nil || req.Description != nil || req.Mode != nil || req.Status != nil || req.VisibleRegionID != nil
}
@ -233,7 +305,7 @@ func roomsFromClient(items []roomclient.Room) []Room {
func roomFromClient(item roomclient.Room) Room {
status := item.Status
if status != "active" {
if status != "active" && status != "banned" {
status = "closed"
}
ownerUserID := strconv.FormatInt(item.OwnerUserID, 10)

View File

@ -360,6 +360,17 @@ func (h *Handler) grantVIPTrialCard(c *gin.Context, grantSource string, defaultC
response.BadRequest(c, "VIP 体验卡赠送参数不正确")
return
}
if grantSource == "manager_center" {
if req.Level > 6 {
response.BadRequest(c, "外管最多只能赠送 VIP6 体验卡")
return
}
scope, external := appctx.ExternalScopeFromContext(c.Request.Context())
if !external || !containsExternalVIPTarget(scope, targetUserID) {
response.Forbidden(c, "目标用户不在当前团队或区域")
return
}
}
reason := strings.TrimSpace(req.Reason)
if reason == "" {
response.BadRequest(c, "赠送原因不能为空")
@ -393,6 +404,18 @@ func (h *Handler) grantVIPTrialCard(c *gin.Context, grantSource string, defaultC
response.Created(c, grantVipTrialCardFromProto(resp))
}
func containsExternalVIPTarget(scope appctx.ExternalScope, targetUserID int64) bool {
if scope.RegionID <= 0 || targetUserID <= 0 {
return false
}
for _, userID := range scope.OwnerUserIDs {
if userID == targetUserID {
return true
}
}
return false
}
func (h *Handler) UpdateLevels(c *gin.Context) {
var req configRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -168,7 +168,12 @@ func TestExternalVIPGrantsUseManagerCenterOwnerPolicySource(t *testing.T) {
gintest := func(path string, body string, handler gin.HandlerFunc) *httptest.ResponseRecorder {
router := gin.New()
router.Use(func(c *gin.Context) {
c.Request = c.Request.WithContext(appctx.WithContext(c.Request.Context(), "fami"))
ctx := appctx.WithContext(c.Request.Context(), "fami")
ctx = appctx.WithExternalScope(ctx, appctx.ExternalScope{
AccountID: 1, LinkedAppUserID: 77, RegionID: 9,
OwnerUserIDs: []int64{318705991371722752},
})
c.Request = c.Request.WithContext(ctx)
c.Set(middleware.ContextRequestID, "external-vip-test")
c.Set(middleware.ContextUserID, uint(77))
c.Next()
@ -181,10 +186,6 @@ func TestExternalVIPGrantsUseManagerCenterOwnerPolicySource(t *testing.T) {
return recorder
}
handler := New(wallet, nil)
vipRecorder := gintest("/external-vip", `{"targetUserId":"318705991371722752","level":5,"reason":"manual"}`, handler.GrantExternalVIP)
if vipRecorder.Code != http.StatusCreated || wallet.grantVIPRequest == nil || wallet.grantVIPRequest.GetGrantSource() != "manager_center" || wallet.grantVIPRequest.GetCommandId() != "external_vip_grant:external-vip-test" {
t.Fatalf("external VIP source mismatch: status=%d request=%+v body=%s", vipRecorder.Code, wallet.grantVIPRequest, vipRecorder.Body.String())
}
trialRecorder := gintest("/external-trial", `{"targetUserId":"318705991371722752","level":5,"durationMs":86400000,"resourceId":99,"reason":"manual"}`, handler.GrantExternalVIPTrialCard)
if trialRecorder.Code != http.StatusCreated || wallet.grantTrialRequest == nil || wallet.grantTrialRequest.GetGrantSource() != "manager_center" || wallet.grantTrialRequest.GetCommandId() != "external_vip_trial_card_grant:external-vip-test" {
t.Fatalf("external trial source mismatch: status=%d request=%+v body=%s", trialRecorder.Code, wallet.grantTrialRequest, trialRecorder.Body.String())

View File

@ -42,13 +42,20 @@ func (s *Store) ListAppBanners(options AppBannerListOptions) ([]model.AppBanner,
return items, err
}
func (s *Store) ExpireAppBanners(appCode string, nowMs int64) error {
func (s *Store) ExpireAppBanners(appCode string, regionID int64, nowMs int64) error {
appCode = strings.TrimSpace(appCode)
if appCode == "" || nowMs <= 0 {
return nil
}
return s.db.Model(&model.AppBanner{}).
Where("app_code = ? AND status = ? AND ends_at_ms > 0 AND ends_at_ms <= ?", appCode, "active", nowMs).
// Portal reads carry a server-derived positive region. Keep their implicit
// expiry maintenance inside that region; region 0 is reserved for the main
// admin's existing app-wide maintenance behavior.
query := s.db.Model(&model.AppBanner{}).
Where("app_code = ? AND status = ? AND ends_at_ms > 0 AND ends_at_ms <= ?", appCode, "active", nowMs)
if regionID > 0 {
query = query.Where("region_id = ?", regionID)
}
return query.
Updates(map[string]any{
"status": "expired",
"updated_at_ms": nowMs,

View File

@ -342,6 +342,30 @@ func TestExternalAdminPermissionAssignmentMigrationIsIncrementalAndPlatformOnly(
}
}
func TestExternalAdminIdentityHierarchyMigrationIsBoundedAndBackfillsManagerDefault(t *testing.T) {
content, err := os.ReadFile("../../migrations/121_external_admin_identity_hierarchy.sql")
if err != nil {
t.Fatalf("read external admin identity migration: %v", err)
}
sqlText := string(content)
for _, token := range []string{
"ADD COLUMN identity_type", "NOT NULL DEFAULT 'country_manager'",
"ADD COLUMN parent_account_id", "idx_external_admin_accounts_hierarchy",
"chk_external_admin_accounts_identity", "'local'", "'country_manager'", "'external_super_admin'",
"fk_external_admin_accounts_parent", "REFERENCES external_admin_accounts(id)",
} {
if !strings.Contains(sqlText, token) {
t.Fatalf("external admin identity migration missing %s", token)
}
}
upperSQL := strings.ToUpper(sqlText)
for _, forbidden := range []string{"UPDATE USERS", "JOIN USERS", "UPDATE EXTERNAL_ADMIN_ACCOUNTS", "DELETE FROM EXTERNAL_ADMIN_ACCOUNTS"} {
if strings.Contains(upperSQL, forbidden) {
t.Fatalf("identity migration must stay bounded to online account-table DDL: %s", forbidden)
}
}
}
func TestLuckyGiftPoolAdjustmentPermissionMigrationRepairsManagedRoles(t *testing.T) {
content, err := os.ReadFile("../../migrations/098_lucky_gift_pool_adjust_permissions.sql")
if err != nil {

View File

@ -82,13 +82,16 @@ func TestExternalPortalRegistersOnlyExplicitAppUserWhitelist(t *testing.T) {
if allowed.Code != http.StatusUnauthorized {
t.Fatalf("whitelisted route status = %d body=%s", allowed.Code, allowed.Body.String())
}
team := httptest.NewRecorder()
engine.ServeHTTP(team, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/my-team/agencies", nil))
if team.Code != http.StatusUnauthorized {
t.Fatalf("my-team route must exist behind external session auth: status=%d body=%s", team.Code, team.Body.String())
superAdmins := httptest.NewRecorder()
engine.ServeHTTP(superAdmins, httptest.NewRequest(http.MethodGet, "/api/v1/external/admin/external-super-admins", nil))
if superAdmins.Code != http.StatusUnauthorized {
t.Fatalf("external SuperAdmin route must exist behind external session auth: status=%d body=%s", superAdmins.Code, superAdmins.Body.String())
}
for _, path := range []string{
"/api/v1/external/admin/my-team/agencies",
"/api/v1/external/admin/managers",
"/api/v1/external/admin/bd-leaders",
"/api/v1/external/app/users/1/password",
"/api/v1/external/app/users/1/access-token",
"/api/v1/external/exports/app-users",

View File

@ -0,0 +1,15 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- external_admin_accounts is a small credential table. The fixed-width identity and
-- nullable parent columns backfill existing rows as country_manager without reading
-- users, rooms, wallet ledgers or any other business table. The combined index/FK
-- addition validates and may rebuild this table depending on the exact MySQL 8 minor
-- version, so inspect its row count and run this away from account-creation peaks.
ALTER TABLE external_admin_accounts
ADD COLUMN identity_type VARCHAR(32) NOT NULL DEFAULT 'country_manager' COMMENT 'local/country_manager/external_super_admin' AFTER permission_revision,
ADD COLUMN parent_account_id BIGINT UNSIGNED NULL COMMENT '外管层级上级账号 ID' AFTER identity_type,
ADD KEY idx_external_admin_accounts_hierarchy (app_code, parent_account_id, identity_type, status),
ADD CONSTRAINT chk_external_admin_accounts_identity
CHECK (identity_type IN ('local', 'country_manager', 'external_super_admin')),
ADD CONSTRAINT fk_external_admin_accounts_parent
FOREIGN KEY (parent_account_id) REFERENCES external_admin_accounts(id);

View File

@ -0,0 +1,8 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 外管 Banner 列表固定 app_code + region_id并在同一区域内按状态和顺序读取。
-- 将 region 放在 platform 前,避免未指定 platform 的门户列表退化为全 App 扫描。
-- 索引构建会扫描 admin_app_banners该配置表预期规模有限但上线前仍应确认
-- TABLE_ROWS/DATA_LENGTH并避开 Banner 集中发布窗口以降低元数据锁等待。
ALTER TABLE admin_app_banners
ADD INDEX idx_admin_app_banners_region_status_sort (app_code, region_id, status, sort_order, id);

View File

@ -46,6 +46,7 @@ activity_template_runtime:
settlement_lateness: "10m"
message_action_confirm_worker:
enabled: true
app_codes: ["lalu", "fami", "huwaa"]
outbox_poll_interval: "1s"
outbox_batch_size: 100
outbox_lock_ttl: "30s"

View File

@ -46,6 +46,7 @@ activity_template_runtime:
settlement_lateness: "10m"
message_action_confirm_worker:
enabled: true
app_codes: ["lalu", "fami", "huwaa"]
outbox_poll_interval: "1s"
outbox_batch_size: 100
outbox_lock_ttl: "30s"

View File

@ -46,6 +46,7 @@ activity_template_runtime:
settlement_lateness: "10m"
message_action_confirm_worker:
enabled: true
app_codes: ["lalu", "fami", "huwaa"]
outbox_poll_interval: "1s"
outbox_batch_size: 100
outbox_lock_ttl: "30s"

View File

@ -99,8 +99,9 @@ func (a *App) runMessageActionOutboxWorker(ctx context.Context) {
func (a *App) processMessageActionOutboxBatch(ctx context.Context) (int, error) {
options := a.messageActionWorkerOptions
workerCtx := appcode.WithContext(ctx, appcode.Default)
records, err := a.actionConfirm.ClaimPendingOutbox(workerCtx, options.OutboxBatchSize, options.OutboxLockTTL)
// ActionConfirmService 会按配置的 App 分区公平 claim这里不能再固定默认 Lalu context
// 否则 Fami/Huwaa 已落库的确认消息永远不会发布到 notice-service。
records, err := a.actionConfirm.ClaimPendingOutbox(ctx, options.OutboxBatchSize, options.OutboxLockTTL)
if err != nil {
return 0, err
}

View File

@ -61,7 +61,10 @@ func buildServiceBundle(cfg config.Config, repository *mysqlstorage.Repository,
activityTemplateSvc := activitytemplateservice.New(repository, walletClient, regionSource)
messageSvc := messageservice.New(messageservice.Config{NodeID: cfg.NodeID}, repository, messageservice.WithTargetSource(activityclient.NewGRPCUserTargetSource(clients.userConn)))
activityTemplateSvc.BindNotice(messageSvc)
actionConfirmSvc := messageservice.NewActionConfirm(messageservice.Config{NodeID: cfg.NodeID}, repository, activityclient.NewGRPCRoleInvitationClient(clients.userConn))
actionConfirmSvc := messageservice.NewActionConfirm(messageservice.Config{
NodeID: cfg.NodeID,
AppCodes: cfg.MessageActionConfirmWorker.AppCodes,
}, repository, activityclient.NewGRPCRoleInvitationClient(clients.userConn))
taskSvc := taskservice.New(repository, walletClient)
registrationRewardSvc := registrationrewardservice.New(repository, walletClient)
sevenDayCheckInSvc := sevendaycheckinservice.New(repository, walletClient)

View File

@ -75,6 +75,8 @@ type MessageInboxConfig struct {
// MessageActionConfirmWorkerConfig 保存确认消息消费和 MQ 发布策略。
type MessageActionConfirmWorkerConfig struct {
Enabled bool `yaml:"enabled"`
// AppCodes 显式限定确认消息发布分区worker 必须逐 App 使用 (app_code, status, created_at_ms) 前缀索引 claim。
AppCodes []string `yaml:"app_codes"`
OutboxPollInterval time.Duration `yaml:"outbox_poll_interval"`
OutboxBatchSize int `yaml:"outbox_batch_size"`
OutboxLockTTL time.Duration `yaml:"outbox_lock_ttl"`
@ -332,6 +334,7 @@ func Default() Config {
ActivityTemplateRuntime: ActivityTemplateRuntimeConfig{SettlementLateness: 10 * time.Minute},
MessageActionConfirmWorker: MessageActionConfirmWorkerConfig{
Enabled: true,
AppCodes: []string{"lalu", "fami", "huwaa"},
OutboxPollInterval: time.Second,
OutboxBatchSize: 100,
OutboxLockTTL: 30 * time.Second,
@ -587,6 +590,10 @@ func Load(path string) (Config, error) {
if cfg.MessageActionConfirmWorker.Enabled && (!cfg.RocketMQ.UserOutbox.Enabled || !cfg.RocketMQ.MessageActionOutbox.Enabled) {
return Config{}, errors.New("message action confirm worker requires rocketmq.user_outbox.enabled and rocketmq.message_action_outbox.enabled")
}
cfg.MessageActionConfirmWorker.AppCodes = normalizeAppCodes(cfg.MessageActionConfirmWorker.AppCodes)
if cfg.MessageActionConfirmWorker.Enabled && len(cfg.MessageActionConfirmWorker.AppCodes) == 0 {
return Config{}, errors.New("message action confirm worker requires app_codes")
}
if cfg.ActivityTemplateStatsWorker.Enabled && !cfg.RocketMQ.ActivityTemplateOutbox.Enabled {
return Config{}, errors.New("activity template stats worker requires rocketmq.activity_template_outbox.enabled")
}

View File

@ -9,7 +9,9 @@ import (
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/outboxpartition"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
)
@ -60,12 +62,19 @@ type ActionConfirmService struct {
cfg Config
repository ActionConfirmRepository
roleInvitationClient RoleInvitationClient
outboxPartitions *outboxpartition.Partitions
now func() time.Time
}
// NewActionConfirm creates the generic confirm-message service.
func NewActionConfirm(cfg Config, repository ActionConfirmRepository, roleInvitationClient RoleInvitationClient) *ActionConfirmService {
return &ActionConfirmService{cfg: cfg, repository: repository, roleInvitationClient: roleInvitationClient, now: time.Now}
return &ActionConfirmService{
cfg: cfg,
repository: repository,
roleInvitationClient: roleInvitationClient,
outboxPartitions: outboxpartition.New(cfg.AppCodes, outboxpartition.DefaultDiscoveryRefreshInterval),
now: time.Now,
}
}
// SetClock injects deterministic time for tests.
@ -271,7 +280,25 @@ func (s *ActionConfirmService) ClaimPendingOutbox(ctx context.Context, batchSize
lockTTL = 30 * time.Second
}
nowMS := s.now().UnixMilli()
return s.repository.ClaimPendingActionConfirmOutbox(ctx, s.workerID(), nowMS, nowMS+lockTTL.Milliseconds(), batchSize)
appCodes, err := s.outboxPartitions.Resolve(ctx, nil)
if err != nil {
return nil, err
}
if len(appCodes) == 0 {
// 直接构造 service 的本地调用仍沿用当前 context常驻 worker 必须由配置提供完整 App 清单。
appCodes = []string{appcode.FromContext(ctx)}
}
// 每个 App 继续走仓储层现有的 app_code 前缀索引和 SKIP LOCKED 事务;
// 公平配额避免 Lalu 积压占满批次后让 Fami/Huwaa 的邀请 C2C 长期饥饿。
return outboxpartition.ClaimFair(ctx, s.outboxPartitions.Order(appCodes), batchSize, func(claimCtx context.Context, app string, limit int) ([]messagedomain.ActionConfirmOutbox, error) {
return s.repository.ClaimPendingActionConfirmOutbox(
appcode.WithContext(claimCtx, app),
s.workerID(),
nowMS,
nowMS+lockTTL.Milliseconds(),
limit,
)
})
}
func (s *ActionConfirmService) MarkOutboxDelivered(ctx context.Context, eventID string) error {
@ -349,11 +376,13 @@ func roleInvitationMessageText(event messagedomain.RoleInvitationConfirmEvent) s
name := firstNonEmptyText(event.Inviter.Username, event.Inviter.DisplayUserID, strconv.FormatInt(event.InviterUserID, 10))
agencyName := firstNonEmptyText(event.AgencyName, "guild")
switch event.InvitationType {
case "host":
case "host", "external_host":
// 外管邀请使用独立 subtype 隔离 Lalu 现有角色链路,但目标用户
// 看到并处理的仍是同一种 Host 入会确认。
return fmt.Sprintf("%s has invited you to join the %s guild", name, agencyName)
case "agency":
case "agency", "external_agency":
return fmt.Sprintf("%s has invited you to create the %s guild", name, agencyName)
case "bd":
case "bd", "external_bd":
return fmt.Sprintf("%s has invited you to become BD", name)
default:
return fmt.Sprintf("%s has sent you a confirmation request", name)

View File

@ -1,8 +1,11 @@
package message
import (
"context"
"encoding/json"
"strconv"
"testing"
"time"
messagedomain "hyapp/services/activity-service/internal/domain/message"
)
@ -39,4 +42,189 @@ func TestConfirmIMPayloadActionsExposeAppButtonLabels(t *testing.T) {
if payload.Actions[1].Action != "accept" || payload.Actions[1].Label != "Accept" || payload.Actions[1].Text != "Accept" || payload.Actions[1].Style != "primary" {
t.Fatalf("accept action mismatch: %+v", payload.Actions[1])
}
// 外管邀请使用独立 subtype 保护 Lalu 原有邀请状态机,但通知文案仍须
// 呈现实际业务动作,不能退化为无上下文的通用确认提示。
externalText := roleInvitationMessageText(messagedomain.RoleInvitationConfirmEvent{
InvitationType: "external_host",
InviterUserID: 1001,
AgencyName: "ABC",
Inviter: messagedomain.RoleInvitationUserSnapshot{Username: "Alice"},
})
if externalText != "Alice has invited you to join the ABC guild" {
t.Fatalf("external host invitation text mismatch: %q", externalText)
}
}
func TestExternalRoleInvitationCreatesC2CConfirmAndProcessesAppAction(t *testing.T) {
tests := []struct {
invitationType string
agencyName string
wantText string
action string
wantStatus string
}{
{
invitationType: "external_bd",
wantText: "Alice has invited you to become BD",
action: messagedomain.ActionConfirmActionAccept,
wantStatus: messagedomain.ActionConfirmStatusAccepted,
},
{
invitationType: "external_agency",
agencyName: "Team Alpha",
wantText: "Alice has invited you to create the Team Alpha guild",
action: messagedomain.ActionConfirmActionAccept,
wantStatus: messagedomain.ActionConfirmStatusAccepted,
},
{
invitationType: "external_host",
agencyName: "Team Alpha",
wantText: "Alice has invited you to join the Team Alpha guild",
action: messagedomain.ActionConfirmActionReject,
wantStatus: messagedomain.ActionConfirmStatusRejected,
},
}
for index, test := range tests {
t.Run(test.invitationType, func(t *testing.T) {
repository := &actionConfirmRepositoryFake{}
roleClient := &roleInvitationClientFake{invitationType: test.invitationType}
service := NewActionConfirm(Config{NodeID: "activity-test"}, repository, roleClient)
service.SetClock(func() time.Time { return time.UnixMilli(1_760_000_000_000) })
invitationID := int64(901 + index)
event := messagedomain.RoleInvitationConfirmEvent{
SourceEventID: "uout-external-" + test.invitationType,
InvitationID: invitationID,
InvitationType: test.invitationType,
InviterUserID: 1001,
TargetUserID: 2002,
AgencyName: test.agencyName,
Inviter: messagedomain.RoleInvitationUserSnapshot{Username: "Alice"},
CreatedAtMS: 1_760_000_000_000,
RawPayloadJSON: `{"source":"user_outbox"}`,
}
confirm, created, err := service.ConsumeRoleInvitationCreated(context.Background(), event)
if err != nil {
t.Fatalf("ConsumeRoleInvitationCreated failed: %v", err)
}
if !created || confirm.BusinessSubtype != test.invitationType ||
confirm.BusinessID != strconv.FormatInt(invitationID, 10) ||
confirm.SenderUserID != 1001 || confirm.ReceiverUserID != 2002 ||
confirm.MessageText != test.wantText {
t.Fatalf("external invitation confirm projection mismatch: %+v", confirm)
}
var payload confirmIMPayload
if err := json.Unmarshal([]byte(repository.savedOutbox.PayloadJSON), &payload); err != nil {
t.Fatalf("decode C2C confirm payload failed: %v", err)
}
if payload.Type != "im_confirm" ||
payload.BusinessType != messagedomain.ActionConfirmBusinessRoleInvitation ||
payload.BusinessSubtype != test.invitationType ||
payload.SenderUserID != "1001" || payload.ReceiverUserID != "2002" ||
payload.Msg != test.wantText || len(payload.Actions) != 2 {
t.Fatalf("external invitation C2C payload mismatch: %+v", payload)
}
var processed messagedomain.ActionConfirm
if test.action == messagedomain.ActionConfirmActionAccept {
processed, err = service.AcceptActionConfirm(context.Background(), 2002, confirm.ConfirmID, "app-accept-"+test.invitationType)
} else {
processed, err = service.RejectActionConfirm(context.Background(), 2002, confirm.ConfirmID, "app-reject-"+test.invitationType, "not available")
}
if err != nil {
t.Fatalf("process App confirmation failed: %v", err)
}
if processed.Status != test.wantStatus || processed.Action != test.action {
t.Fatalf("App confirmation terminal state mismatch: %+v", processed)
}
if roleClient.lastInput.ActorUserID != 2002 ||
roleClient.lastInput.InvitationID != invitationID ||
roleClient.lastInput.Action != test.action {
t.Fatalf("ProcessRoleInvitation command mismatch: %+v", roleClient.lastInput)
}
})
}
}
type actionConfirmRepositoryFake struct {
savedConfirm messagedomain.ActionConfirm
savedOutbox messagedomain.ActionConfirmOutbox
}
func (r *actionConfirmRepositoryFake) SaveActionConfirmWithOutbox(_ context.Context, confirm messagedomain.ActionConfirm, outbox messagedomain.ActionConfirmOutbox) (bool, messagedomain.ActionConfirm, error) {
r.savedConfirm = confirm
r.savedOutbox = outbox
return true, confirm, nil
}
func (r *actionConfirmRepositoryFake) ClaimActionConfirm(_ context.Context, confirmID string, actorUserID int64, action string, commandID string, _ string, _ int64, _ int64) (messagedomain.ActionConfirm, bool, error) {
confirm := r.savedConfirm
if confirm.ConfirmID != confirmID || confirm.ReceiverUserID != actorUserID {
return messagedomain.ActionConfirm{}, false, nil
}
confirm.Status = messagedomain.ActionConfirmStatusProcessing
confirm.Action = action
confirm.CommandID = commandID
r.savedConfirm = confirm
return confirm, true, nil
}
func (r *actionConfirmRepositoryFake) CompleteActionConfirm(_ context.Context, confirmID string, commandID string, status string, action string, processedAtMS int64) (messagedomain.ActionConfirm, error) {
confirm := r.savedConfirm
confirm.ConfirmID = confirmID
confirm.CommandID = commandID
confirm.Status = status
confirm.Action = action
confirm.ProcessedAtMS = processedAtMS
r.savedConfirm = confirm
return confirm, nil
}
func (r *actionConfirmRepositoryFake) ReleaseActionConfirm(context.Context, string, string, string, int64) error {
return nil
}
func (r *actionConfirmRepositoryFake) BatchGetActionConfirms(context.Context, int64, []string) ([]messagedomain.ActionConfirm, error) {
return nil, nil
}
func (r *actionConfirmRepositoryFake) ListConversationActionConfirms(context.Context, int64, int64, int, int64, string) ([]messagedomain.ActionConfirm, error) {
return nil, nil
}
func (r *actionConfirmRepositoryFake) ClaimPendingActionConfirmOutbox(context.Context, string, int64, int64, int) ([]messagedomain.ActionConfirmOutbox, error) {
return nil, nil
}
func (r *actionConfirmRepositoryFake) MarkActionConfirmOutboxDelivered(context.Context, string, int64) error {
return nil
}
func (r *actionConfirmRepositoryFake) MarkActionConfirmOutboxRetryable(context.Context, string, string, int64, int64) error {
return nil
}
type roleInvitationClientFake struct {
invitationType string
lastInput ProcessRoleInvitationInput
}
func (c *roleInvitationClientFake) ProcessRoleInvitation(_ context.Context, input ProcessRoleInvitationInput) (RoleInvitationState, error) {
c.lastInput = input
status := messagedomain.ActionConfirmStatusAccepted
if input.Action == messagedomain.ActionConfirmActionReject {
status = messagedomain.ActionConfirmStatusRejected
}
return RoleInvitationState{
InvitationID: input.InvitationID,
InvitationType: c.invitationType,
Status: status,
}, nil
}
func (c *roleInvitationClientFake) GetRoleInvitation(_ context.Context, actorUserID int64, invitationID int64) (RoleInvitationState, error) {
return RoleInvitationState{
InvitationID: invitationID,
InvitationType: c.invitationType,
Status: messagedomain.ActionConfirmStatusPending,
}, nil
}

View File

@ -40,6 +40,8 @@ type Repository interface {
// Config keeps service-level knobs that do not belong in transport code.
type Config struct {
NodeID string
// AppCodes 只供通用确认消息 outbox 使用;普通 inbox API 始终以请求 context 的 app_code 隔离。
AppCodes []string
}
// TargetQuery describes the user-service cursor query needed by fanout worker scopes.

View File

@ -3679,6 +3679,8 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
batchEquippedResp: &walletv1.BatchGetUserEquippedResourcesResponse{
Users: []*walletv1.UserEquippedResources{{
UserId: 42,
EffectiveVipLevel: 9,
AllowedVipBenefitCodes: []string{"colored_nickname", "colored_id", "colored_room_name"},
Resources: []*walletv1.UserResourceEntitlement{
{
UserId: 42,
@ -3782,6 +3784,9 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
hasRoomDisplayResourceQuery := false
for _, req := range walletClient.batchEquippedRequests {
if strings.Join(req.GetResourceTypes(), ",") == "avatar_frame,profile_card,vehicle,chat_bubble,badge,mic_seat_icon,mic_seat_animation" {
if strings.Join(req.GetVipBenefitCodes(), ",") != "colored_nickname,colored_id,colored_room_name" {
t.Fatalf("room display query must request server-authorized colorful identity benefits: %+v", req)
}
hasRoomDisplayResourceQuery = true
break
}
@ -3807,6 +3812,13 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
if !profileCardOK || profileCard["resource_id"] != float64(8104) || !strings.Contains(metadataJSON, `"content_top":143`) {
t.Fatalf("join profile must include profile_card metadata_json: %+v", currentProfile)
}
vip, vipOK := currentProfile["vip"].(map[string]any)
vipBenefitCodes, vipBenefitCodesOK := vip["benefit_codes"].([]any)
if !vipOK || vip["level"] != float64(9) || vip["active"] != true ||
!vipBenefitCodesOK || len(vipBenefitCodes) != 3 ||
vipBenefitCodes[0] != "colored_nickname" || vipBenefitCodes[1] != "colored_id" || vipBenefitCodes[2] != "colored_room_name" {
t.Fatalf("join profile must include wallet-confirmed paid/trial VIP identity: %+v", currentProfile)
}
micSeatIcon, micSeatIconOK := currentProfile["mic_seat_icon"].(map[string]any)
if !micSeatIconOK || micSeatIcon["resource_id"] != float64(8107) || micSeatIcon["asset_url"] != "https://cdn.example/mic-seat-icon.png" {
t.Fatalf("join profile must include mic_seat_icon independently from Voice Wave: %+v", currentProfile)
@ -3821,9 +3833,9 @@ func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T)
}
badge := badges[0].(map[string]any)
badgeResource := badge["resource"].(map[string]any)
vipBenefitCodes, vipBenefitCodesOK := badgeResource["vip_benefit_codes"].([]any)
vipBadgeBenefitCodes, vipBadgeBenefitCodesOK := badgeResource["vip_benefit_codes"].([]any)
if badge["badge_form"] != "tile" || badgeResource["preview_url"] != "https://cdn.example/badge-tile.png" ||
!vipBenefitCodesOK || len(vipBenefitCodes) != 1 || vipBenefitCodes[0] != "vip_badge_identity" {
!vipBadgeBenefitCodesOK || len(vipBadgeBenefitCodes) != 1 || vipBadgeBenefitCodes[0] != "vip_badge_identity" {
t.Fatalf("join profile must include equipped tile badge resource: %+v", badge)
}
}

View File

@ -2553,7 +2553,7 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
CountryName: strings.TrimSpace(user.GetCountryName()),
CountryDisplayName: strings.TrimSpace(user.GetCountryDisplayName()),
CountryFlag: roomProfileCountryFlag(user.GetCountry()),
VIP: map[string]any{},
VIP: appearance.VIP,
Level: levels.Levels,
LevelBadges: levels.Badges,
Badges: roomMergeBadgeItems(badgeMap[userID], appearance.Badges),
@ -2571,6 +2571,11 @@ func (h *Handler) roomDisplayProfileMap(request *http.Request, userIDs []int64)
profile.Badges = []map[string]any{}
profiles[userID] = profile
}
if profiles[userID].VIP == nil {
profile := profiles[userID]
profile.VIP = map[string]any{}
profiles[userID] = profile
}
if profiles[userID].AvatarFrame == nil {
profile := profiles[userID]
profile.AvatarFrame = map[string]any{}
@ -2625,6 +2630,7 @@ type roomAppearanceResources struct {
MicSeatIcon map[string]any
MicSeatAnimation map[string]any
Badges []map[string]any
VIP map[string]any
}
func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int64) map[int64]roomAppearanceResources {
@ -2632,12 +2638,14 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int
if h.walletClient == nil || len(userIDs) == 0 {
return result
}
// wallet 在该 RPC 内做过期佩戴过滤,所以 roomDisplayProfileMap 不需要重复判断 expires_at_ms。
// wallet 在该 RPC 内做过期佩戴过滤,并在同一个有界批次中计算付费/体验卡合并后的
// VIP 等级与彩色身份权益;房间链路不能再逐用户查询或从体验卡资源反推等级。
resp, err := h.walletClient.BatchGetUserEquippedResources(request.Context(), &walletv1.BatchGetUserEquippedResourcesRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()),
AppCode: appcode.FromContext(request.Context()),
UserIds: userIDs,
ResourceTypes: []string{roomAppearanceResourceAvatarFrame, roomAppearanceResourceProfileCard, roomAppearanceResourceVehicle, roomAppearanceResourceChatBubble, roomAppearanceResourceBadge, roomAppearanceResourceMicSeatIcon, roomAppearanceResourceMicSeatAnimation},
VipBenefitCodes: []string{"colored_nickname", "colored_id", "colored_room_name"},
})
if err != nil {
// 装扮是展示增强,失败不影响房间主数据返回。
@ -2648,6 +2656,11 @@ func (h *Handler) roomAppearanceResourceMap(request *http.Request, userIDs []int
continue
}
current := result[user.GetUserId()]
current.VIP = map[string]any{
"level": user.GetEffectiveVipLevel(),
"active": user.GetEffectiveVipLevel() > 0,
"benefit_codes": append([]string(nil), user.GetAllowedVipBenefitCodes()...),
}
for _, item := range user.GetResources() {
// 同一资源类型只保留当前佩戴的一个权益wallet equipment 主键保证唯一性。
resource := roomAppearanceResourceFromEntitlement(item)

View File

@ -11,7 +11,7 @@ import (
)
func TestProcessActionOutboxMessagePublishesConfirmC2C(t *testing.T) {
payloadJSON := `{"type":"im_confirm","confirm_id":"cfm_1","sender_user_id":"1001","receiver_user_id":"2002"}`
payloadJSON := `{"type":"im_confirm","confirm_id":"cfm_1","business_type":"role_invitation","business_subtype":"external_bd","sender_user_id":"1001","receiver_user_id":"2002","actions":[{"action":"reject","label":"Reject"},{"action":"accept","label":"Accept"}]}`
repository := &confirmRepositoryFake{claimOK: true}
publisher := &confirmPublisherFake{}
service := New(Config{NodeID: "notice-node-1"}, repository, publisher)

View File

@ -235,6 +235,26 @@ type AdminUpdateRoom struct {
func (AdminUpdateRoom) Type() string { return "admin_update_room" }
// AdminBanRoom 定义持久封禁命令;它与普通 closed 分离,防止房主通过进房自动恢复。
type AdminBanRoom struct {
Base
Reason string `json:"reason"`
AdminID uint64 `json:"admin_id,omitempty"`
AdminName string `json:"admin_name,omitempty"`
}
func (AdminBanRoom) Type() string { return "admin_ban_room" }
// AdminUnbanRoom 定义人工解封命令;解封只回到 closed不直接制造在线房间。
type AdminUnbanRoom struct {
Base
Reason string `json:"reason,omitempty"`
AdminID uint64 `json:"admin_id,omitempty"`
AdminName string `json:"admin_name,omitempty"`
}
func (AdminUnbanRoom) Type() string { return "admin_unban_room" }
// AdminDeleteRoom 定义后台删除房间请求;命令先经过 Room Cell 串行确认,再由 repository 清理恢复来源。
type AdminDeleteRoom struct {
Base
@ -875,6 +895,10 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
cmd = &CloseRoom{}
case AdminUpdateRoom{}.Type():
cmd = &AdminUpdateRoom{}
case AdminBanRoom{}.Type():
cmd = &AdminBanRoom{}
case AdminUnbanRoom{}.Type():
cmd = &AdminUnbanRoom{}
case AdminDeleteRoom{}.Type():
cmd = &AdminDeleteRoom{}
case MicUp{}.Type():

View File

@ -27,6 +27,7 @@ func (s *Service) AdminListRooms(ctx context.Context, req *roomv1.AdminListRooms
Status: strings.TrimSpace(req.GetStatus()),
RegionID: req.GetVisibleRegionId(),
OwnerUserID: req.GetOwnerUserId(),
OwnerUserIDs: positiveUniqueRoomOwnerIDs(req.GetOwnerUserIds()),
SortBy: strings.TrimSpace(req.GetSortBy()),
SortDirection: strings.TrimSpace(req.GetSortDirection()),
NowMS: s.clock.Now().UnixMilli(),
@ -69,6 +70,161 @@ func (s *Service) AdminUpdateRoom(ctx context.Context, req *roomv1.AdminUpdateRo
}, nil
}
// AdminBanRoom 通过 Room Cell 原子持久化 banned 状态、清场事实和命令日志。
func (s *Service) AdminBanRoom(ctx context.Context, req *roomv1.AdminBanRoomRequest) (*roomv1.AdminBanRoomResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
if req == nil || req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetRoomId()) == "" {
return nil, xerr.New(xerr.InvalidArgument, "room_id is required")
}
reason := strings.TrimSpace(req.GetReason())
if reason == "" {
return nil, xerr.New(xerr.InvalidArgument, "ban reason is required")
}
cmd := command.AdminBanRoom{
Base: baseFromMeta(req.GetMeta()),
Reason: reason,
AdminID: req.GetAdminId(),
AdminName: strings.TrimSpace(req.GetAdminName()),
}
rtcKickTargets := make([]int64, 0)
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.RoomStatusBanned {
// command_id 之外再提供状态幂等,重复人工操作不能重复发送踢人事实。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
if current.Status == state.RoomStatusDeleted {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
}
if err := s.ensureNoRecoverableGiftOperations(ctx, current.RoomID); err != nil {
return mutationResult{}, nil, err
}
kickTargets := closeRoomKickTargets(current)
rtcKickTargets = append(rtcKickTargets[:0], kickTargets...)
type occupiedMicSeat struct {
userID int64
seatNo int32
micSessionID string
}
occupiedSeats := make([]occupiedMicSeat, 0, len(current.MicSeats))
for _, seat := range current.MicSeats {
if seat.UserID != 0 && seat.MicSessionID != "" {
occupiedSeats = append(occupiedSeats, occupiedMicSeat{userID: seat.UserID, seatNo: seat.SeatNo, micSessionID: seat.MicSessionID})
}
}
current.Status = state.RoomStatusBanned
current.OnlineUsers = map[int64]*state.RoomUserState{}
for index := range current.MicSeats {
current.ClearMicSession(index)
}
current.Version++
records := make([]outbox.Record, 0, len(kickTargets)+len(occupiedSeats)+1)
bannedEvent, err := outbox.Build(current.RoomID, "RoomBanned", current.Version, now, &roomeventsv1.RoomBanned{
ActorUserId: cmd.ActorUserID(),
Reason: cmd.Reason,
})
if err != nil {
return mutationResult{}, nil, err
}
records = append(records, bannedEvent)
for _, targetUserID := range kickTargets {
kickEvent, buildErr := outbox.Build(current.RoomID, "RoomUserKicked", current.Version, now, &roomeventsv1.RoomUserKicked{
ActorUserId: cmd.ActorUserID(),
TargetUserId: targetUserID,
})
if buildErr != nil {
return mutationResult{}, nil, buildErr
}
records = append(records, kickEvent)
}
for _, seat := range occupiedSeats {
micEvent, buildErr := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), seat.userID, seat.seatNo, seat.micSessionID, "room_banned")
if buildErr != nil {
return mutationResult{}, nil, buildErr
}
records = append(records, micEvent)
}
return mutationResult{
snapshot: current.ToProto(),
roomStatus: state.RoomStatusBanned,
closeInfo: &RoomCloseInfo{Reason: cmd.Reason, AdminID: cmd.AdminID, AdminName: cmd.AdminName, ClosedAtMS: now.UnixMilli()},
syncEvent: &tencentim.RoomEvent{
EventID: bannedEvent.EventID,
RoomID: current.RoomID,
EventType: "room_banned",
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
RoomVersion: current.Version,
Attributes: map[string]string{"reason": cmd.Reason},
},
}, records, nil
})
if err != nil {
return nil, err
}
if result.applied && s.rtcUserRemover != nil {
for _, targetUserID := range rtcKickTargets {
// RTC 属于连接态;房间封禁事实提交成功后才执行外部移除,失败不回滚 Room Cell。
_ = s.rtcUserRemover.RemoveUserByStrRoomID(ctx, cmd.RoomID(), targetUserID)
}
}
return &roomv1.AdminBanRoomResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
Room: result.snapshot,
}, nil
}
// AdminUnbanRoom 只执行 banned -> closed房主后续按普通进房路径恢复 active。
func (s *Service) AdminUnbanRoom(ctx context.Context, req *roomv1.AdminUnbanRoomRequest) (*roomv1.AdminUnbanRoomResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
if req == nil || req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetRoomId()) == "" {
return nil, xerr.New(xerr.InvalidArgument, "room_id is required")
}
reason := strings.TrimSpace(req.GetReason())
if reason == "" {
return nil, xerr.New(xerr.InvalidArgument, "unban reason is required")
}
cmd := command.AdminUnbanRoom{
Base: baseFromMeta(req.GetMeta()),
Reason: reason,
AdminID: req.GetAdminId(),
AdminName: strings.TrimSpace(req.GetAdminName()),
}
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.RoomStatusBanned {
return mutationResult{}, nil, xerr.New(xerr.Conflict, "room is not banned")
}
current.Status = state.RoomStatusClosed
current.Version++
unbannedEvent, buildErr := outbox.Build(current.RoomID, "RoomUnbanned", current.Version, now, &roomeventsv1.RoomUnbanned{
ActorUserId: cmd.ActorUserID(),
Reason: cmd.Reason,
})
if buildErr != nil {
return mutationResult{}, nil, buildErr
}
return mutationResult{
snapshot: current.ToProto(),
roomStatus: state.RoomStatusClosed,
closeInfo: &RoomCloseInfo{ClearOnOpen: true},
}, []outbox.Record{unbannedEvent}, nil
})
if err != nil {
return nil, err
}
return &roomv1.AdminUnbanRoomResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
Room: result.snapshot,
}, nil
}
func (s *Service) AdminDeleteRoom(ctx context.Context, req *roomv1.AdminDeleteRoomRequest) (*roomv1.AdminDeleteRoomResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())
if req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetRoomId()) == "" {
@ -111,6 +267,22 @@ func (s *Service) AdminDeleteRoom(ctx context.Context, req *roomv1.AdminDeleteRo
}, nil
}
func positiveUniqueRoomOwnerIDs(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func (s *Service) adminUpdateCommand(req *roomv1.AdminUpdateRoomRequest) (command.AdminUpdateRoom, error) {
if req == nil || req.GetMeta() == nil || strings.TrimSpace(req.GetMeta().GetRoomId()) == "" {
return command.AdminUpdateRoom{}, xerr.New(xerr.InvalidArgument, "room_id is required")
@ -190,6 +362,10 @@ func (s *Service) applyAdminRoomMutation(ctx context.Context, logCommand command
if current == nil {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
}
if current.Status == state.RoomStatusBanned && cmd.Status != nil {
// banned 只能通过 AdminUnbanRoom 解除,通用资料修改不得伪造 active/closed 跳过审计。
return mutationResult{}, nil, xerr.New(xerr.Conflict, "banned room status requires dedicated unban command")
}
if cmd.Status != nil && (*cmd.Status == state.RoomStatusClosed || *cmd.Status == state.RoomStatusDeleted) {
if err := s.ensureNoRecoverableGiftOperations(ctx, current.RoomID); err != nil {
return mutationResult{}, nil, err

View File

@ -348,6 +348,95 @@ func TestCloseRoomByAdminEvictsCurrentUsersThroughOutboxAndRTC(t *testing.T) {
}
}
func TestAdminBanRoomPersistsAcrossRecoveryAndRequiresDedicatedUnban(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
rtc := &fakeRTCUserRemover{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-ban-room-test", LeaseTTL: 10 * time.Second, RankLimit: 20,
SnapshotEveryN: 100, RTCUserRemover: rtc,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-admin-banned"
ownerID := int64(8501)
viewerID := int64(8502)
adminID := int64(8503)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID), SeatCount: 10, Mode: "voice",
VisibleRegionId: 8101, RoomName: "Admin Banned", RoomAvatar: testRoomCoverURL, RoomShortId: "admin-banned",
}); err != nil {
t.Fatalf("create banned room fixture failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, viewerID), Role: "audience"}); err != nil {
t.Fatalf("join banned room fixture failed: %v", err)
}
banResp, err := svc.AdminBanRoom(ctx, &roomv1.AdminBanRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-admin-ban", CommandId: "cmd-admin-ban", ActorUserId: adminID,
RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(),
},
Reason: "policy violation", AdminId: uint64(adminID), AdminName: "external-manager",
})
if err != nil {
t.Fatalf("admin ban room failed: %v", err)
}
if banResp.GetRoom().GetStatus() != "banned" || len(banResp.GetRoom().GetOnlineUsers()) != 0 {
t.Fatalf("ban must persist banned state and clear presence: %+v", banResp.GetRoom())
}
if !hasRTCRemoveCall(rtc.calls, roomID, ownerID) || !hasRTCRemoveCall(rtc.calls, roomID, viewerID) {
t.Fatalf("ban must remove every current RTC user: %+v", rtc.calls)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, ownerID), Role: "owner"}); !xerr.IsCode(err, xerr.RoomClosed) {
t.Fatalf("owner cannot reactivate a banned room: %v", err)
}
closed := "closed"
if _, err := svc.AdminUpdateRoom(ctx, &roomv1.AdminUpdateRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-generic-unban", CommandId: "cmd-generic-unban", ActorUserId: adminID,
RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(),
},
Status: &closed, CloseReason: "bypass attempt", AdminId: uint64(adminID),
}); !xerr.IsCode(err, xerr.Conflict) {
t.Fatalf("generic room update must not bypass dedicated unban: %v", err)
}
// A fresh Service must replay admin_ban_room from the durable command log before
// accepting the dedicated unban; this covers restart recovery, not just memory state.
recovered := roomservice.New(roomservice.Config{
NodeID: "node-ban-room-recovery-test", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 100,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
if _, err := recovered.AdminUnbanRoom(ctx, &roomv1.AdminUnbanRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-admin-unban-empty", CommandId: "cmd-admin-unban-empty", ActorUserId: adminID,
RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(),
},
AdminId: uint64(adminID), AdminName: "external-manager",
}); !xerr.IsCode(err, xerr.InvalidArgument) {
t.Fatalf("dedicated unban must require an audit reason: %v", err)
}
unbanResp, err := recovered.AdminUnbanRoom(ctx, &roomv1.AdminUnbanRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-admin-unban", CommandId: "cmd-admin-unban", ActorUserId: adminID,
RoomId: roomID, AppCode: appcode.Default, SentAtMs: time.Now().UnixMilli(),
},
Reason: "appeal approved", AdminId: uint64(adminID), AdminName: "external-manager",
})
if err != nil {
t.Fatalf("dedicated unban after recovery failed: %v", err)
}
if unbanResp.GetRoom().GetStatus() != "closed" {
t.Fatalf("unban must return room to closed, not active: %+v", unbanResp.GetRoom())
}
ownerJoin, err := recovered.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, ownerID), Role: "owner"})
if err != nil || ownerJoin.GetRoom().GetStatus() != "active" {
t.Fatalf("owner should reactivate only after manual unban: room=%+v err=%v", ownerJoin.GetRoom(), err)
}
records, err := repository.ListPendingOutbox(ctx, 100)
if err != nil || !hasOutboxEvent(records, "RoomBanned", 0) || !hasOutboxEvent(records, "RoomUnbanned", 0) {
t.Fatalf("ban lifecycle must publish durable facts: records=%+v err=%v", outboxSummary(records), err)
}
}
func TestAdminDeleteRoomHardDeletesRoomStateAndAllowsRecreate(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)

View File

@ -29,6 +29,9 @@ func (s *Service) roomEntryPolicy(ctx context.Context, roomID string, actorUserI
switch strings.TrimSpace(roomMeta.Status) {
case "", state.RoomStatusActive:
return false, nil
case state.RoomStatusBanned:
// banned 是人工封禁终态;房主与普通用户都不能通过 JoinRoom 自动恢复。
return false, xerr.New(xerr.RoomClosed, "room banned")
case state.RoomStatusClosed:
if roomMeta.OwnerUserID == actorUserID && actorUserID > 0 {
// 只有房主本人进入 closed 房间时才允许在 JoinRoom 命令内重新激活。

View File

@ -271,6 +271,19 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
}
}
current.Version++
case *command.AdminBanRoom:
current.Status = state.RoomStatusBanned
current.OnlineUsers = map[int64]*state.RoomUserState{}
for index := range current.MicSeats {
current.ClearMicSession(index)
}
current.Version++
case *command.AdminUnbanRoom:
if current.Status != state.RoomStatusBanned {
return fmt.Errorf("admin_unban_room replay requires banned state, got %s", current.Status)
}
current.Status = state.RoomStatusClosed
current.Version++
case *command.AdminDeleteRoom:
current.Status = state.RoomStatusDeleted
current.OnlineUsers = map[int64]*state.RoomUserState{}

View File

@ -407,6 +407,8 @@ type AdminRoomListQuery struct {
Status string
RegionID int64
OwnerUserID int64
// OwnerUserIDs 是服务端认证后的团队 owner 集合repository 必须在 count 和数据页上使用同一条件。
OwnerUserIDs []int64
SortBy string
SortDirection string
// NowMS 用于把后台房间贡献收敛到当前 UTC 周;调用方传服务时钟,测试可以固定边界。

View File

@ -18,6 +18,8 @@ const (
RoomStatusClosing = "closing"
// RoomStatusClosed 表示房间已经关闭guard 和进房都必须拒绝。
RoomStatusClosed = "closed"
// RoomStatusBanned 表示后台人工封禁;只有专用解封命令能将其恢复为 closed。
RoomStatusBanned = "banned"
// RoomStatusDeleted 只兼容历史后台软删除数据;新的后台删除会物理清理房间恢复来源。
RoomStatusDeleted = "deleted"

View File

@ -255,6 +255,14 @@ func adminRoomWhere(ctx context.Context, query roomservice.AdminRoomListQuery) (
where = append(where, "rle.owner_user_id = ?")
args = append(args, query.OwnerUserID)
}
if ownerUserIDs := positiveUniqueAdminRoomOwnerIDs(query.OwnerUserIDs); len(ownerUserIDs) > 0 {
placeholders := make([]string, 0, len(ownerUserIDs))
for _, ownerUserID := range ownerUserIDs {
placeholders = append(placeholders, "?")
args = append(args, ownerUserID)
}
where = append(where, "rle.owner_user_id IN ("+strings.Join(placeholders, ",")+")")
}
if strings.TrimSpace(query.Keyword) != "" {
like := "%" + strings.ReplaceAll(strings.TrimSpace(query.Keyword), "%", "\\%") + "%"
where = append(where, "(rle.room_id LIKE ? ESCAPE '\\\\' OR rle.room_short_id LIKE ? ESCAPE '\\\\' OR rle.title LIKE ? ESCAPE '\\\\' OR CAST(rle.owner_user_id AS CHAR) LIKE ? ESCAPE '\\\\')")
@ -263,6 +271,22 @@ func adminRoomWhere(ctx context.Context, query roomservice.AdminRoomListQuery) (
return " WHERE " + strings.Join(where, " AND "), args
}
func positiveUniqueAdminRoomOwnerIDs(values []int64) []int64 {
seen := make(map[int64]struct{}, len(values))
result := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func adminRoomOrderBy(query roomservice.AdminRoomListQuery, contributionExpr string) string {
direction := "DESC"
if strings.EqualFold(strings.TrimSpace(query.SortDirection), "asc") {

View File

@ -188,6 +188,26 @@ func (s *Server) AdminUpdateRoom(ctx context.Context, req *roomv1.AdminUpdateRoo
return mapServiceResult(s.svc.AdminUpdateRoom(ctx, req))
}
func (s *Server) AdminBanRoom(ctx context.Context, req *roomv1.AdminBanRoomRequest) (*roomv1.AdminBanRoomResponse, error) {
ctx = contextWithMetaApp(ctx, req.GetMeta())
if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.AdminBanRoomResponse, error) {
return client.AdminBanRoom(callCtx, req)
}); forwarded {
return resp, err
}
return mapServiceResult(s.svc.AdminBanRoom(ctx, req))
}
func (s *Server) AdminUnbanRoom(ctx context.Context, req *roomv1.AdminUnbanRoomRequest) (*roomv1.AdminUnbanRoomResponse, error) {
ctx = contextWithMetaApp(ctx, req.GetMeta())
if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.AdminUnbanRoomResponse, error) {
return client.AdminUnbanRoom(callCtx, req)
}); forwarded {
return resp, err
}
return mapServiceResult(s.svc.AdminUnbanRoom(ctx, req))
}
func (s *Server) AdminDeleteRoom(ctx context.Context, req *roomv1.AdminDeleteRoomRequest) (*roomv1.AdminDeleteRoomResponse, error) {
ctx = contextWithMetaApp(ctx, req.GetMeta())
if resp, forwarded, err := forwardCommand(s, ctx, req.GetMeta(), func(callCtx context.Context, client roomv1.RoomCommandServiceClient) (*roomv1.AdminDeleteRoomResponse, error) {

View File

@ -132,6 +132,80 @@ func TestSocialRetentionUserDayProjectionStaysNarrow(t *testing.T) {
}
}
func TestNewUserRoomJoinRatesRequireFinalJoinOutcome(t *testing.T) {
rows := []socialRequirementUserDay{
{Subject: "u:1", Registered: 1, RoomJoinNG: 1},
{Subject: "u:2", Registered: 1, RoomJoinNG: 1, RoomJoinOK: 1},
{Subject: "u:3", Registered: 1, RoomJoinOK: 1},
}
aggregate := aggregateSocialRows(rows, SocialSectionNewUsers, "all", "all", "all")
metrics := socialSectionMetrics(SocialSectionNewUsers, aggregate, nil)
// 同一用户先失败再成功时以最终成功为准;失败人数只保留全天没有成功事实的用户。
if socialMetricInt(t, SocialRequirementRow{Metrics: metrics}, "room_join_success_users") != 2 ||
socialMetricInt(t, SocialRequirementRow{Metrics: metrics}, "room_join_fail_users") != 1 ||
math.Abs(socialMetricFloat(t, SocialRequirementRow{Metrics: metrics}, "room_join_success_rate")-2.0/3.0) > 0.0001 ||
math.Abs(socialMetricFloat(t, SocialRequirementRow{Metrics: metrics}, "room_join_fail_rate")-1.0/3.0) > 0.0001 {
t.Fatalf("new-user room join outcome metrics mismatch: %+v", metrics)
}
columns := socialRequirementColumns(SocialSectionNewUsers)
byKey := make(map[string]SocialRequirementColumn, len(columns))
for _, column := range columns {
byKey[column.Key] = column
}
if byKey["room_join_success_users"].Label != "新用户进房人数" ||
byKey["room_join_fail_users"].Label != "新用户进房失败人数" ||
byKey["room_join_success_rate"].Tooltip != "新用户进房人数 / 新增用户" ||
byKey["room_join_fail_rate"].Tooltip != "新用户进房失败人数 / 新增用户" {
t.Fatalf("new-user room join column contract mismatch: %+v", byKey)
}
}
func TestNewUserMicGiftAndRechargeRatesRequireSuccessfulFacts(t *testing.T) {
rows := []socialRequirementUserDay{
// 普通充值、确认上麦和普通礼物均为成功事实。
{Subject: "u:1", Registered: 1, RoomJoinOK: 1, MicUp: 1, Gift: 1, Recharge: 1},
// finance verified 币商充值只写金额但必须进入新用户充值人数Lucky 同样属于成功送礼。
{Subject: "u:2", Registered: 1, RoomJoinOK: 1, Gift: 1, LuckyGift: 1, CoinSellerRechargeUSDMinor: 500},
{Subject: "u:3", Registered: 1, RoomJoinOK: 1},
// 未成功进房的送礼标记不能进入“进房后送礼”转化分子。
{Subject: "u:4", Registered: 1, Gift: 1},
}
aggregate := aggregateSocialRows(rows, SocialSectionNewUsers, "all", "all", "all")
metrics := socialSectionMetrics(SocialSectionNewUsers, aggregate, nil)
row := SocialRequirementRow{Metrics: metrics}
if socialMetricInt(t, row, "mic_up_users") != 1 ||
math.Abs(socialMetricFloat(t, row, "mic_up_rate")-1.0/3.0) > 0.0001 ||
socialMetricInt(t, row, "gift_users") != 2 ||
math.Abs(socialMetricFloat(t, row, "gift_rate")-2.0/3.0) > 0.0001 ||
socialMetricInt(t, row, "recharge_users") != 2 ||
math.Abs(socialMetricFloat(t, row, "recharge_rate")-0.5) > 0.0001 {
t.Fatalf("new-user mic/gift/recharge metrics mismatch: %+v", metrics)
}
// 即使失败埋点没有 error_code只要 success=false 就不能把一次点击误算成成功上麦。
scope := statDayScope{tz: StatTZUTC, day: "2026-07-01"}
if _, ok := socialDeltaForAppTrackingEvent(AppTrackingEvent{EventName: "mic_up", Success: false}, scope, 1); ok {
t.Fatal("failed mic-up attempt must not produce a successful social delta")
}
successDelta, ok := socialDeltaForAppTrackingEvent(AppTrackingEvent{EventName: "mic_up", Success: true}, scope, 1)
if !ok || successDelta.MicUp != 1 || successDelta.RoomJoinOK != 1 {
t.Fatalf("confirmed mic-up must imply successful room entry: %+v, ok=%v", successDelta, ok)
}
columns := socialRequirementColumns(SocialSectionNewUsers)
byKey := make(map[string]SocialRequirementColumn, len(columns))
for _, column := range columns {
byKey[column.Key] = column
}
if byKey["mic_up_rate"].Tooltip != "新用户上麦人数 / 新用户进房人数" ||
byKey["gift_rate"].Tooltip != "新用户送礼人数 / 新用户进房人数" ||
byKey["recharge_rate"].Tooltip != "新用户充值人数 / 新增用户" {
t.Fatalf("new-user conversion column contract mismatch: %+v", byKey)
}
}
func TestSocialRetentionMetricsExcludeUnobservableCohorts(t *testing.T) {
day := "2026-07-01"
host := socialRequirementUserDay{
@ -561,6 +635,14 @@ func TestProfileSetupViewMetricCanonicalizesAndDeduplicatesDailyExposure(t *test
if metrics["profile_setup_view_users"] != int64(1) {
t.Fatalf("profile setup exposure metric mismatch: %+v", metrics)
}
rateMetrics := socialSectionMetrics(
SocialSectionNewUsers,
socialRequirementAggregate{ProfileSetupView: 4, Registered: 3},
nil,
)
if math.Abs(rateMetrics["registration_success_rate"].(float64)-0.75) > 0.0001 {
t.Fatalf("registration success rate must use profile setup exposure: %+v", rateMetrics)
}
}
func TestSocialIdentityMarksConcurrentAccountSwitchAmbiguous(t *testing.T) {
@ -782,6 +864,9 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
{AppCode: "lalu", EventID: "social:chat:1001", EventName: "send_message", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(6 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:follow-user:1001", EventName: "follow_host", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(7 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:follow-room:1001", EventName: "follow_room", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(8 * time.Minute).UnixMilli()},
// user1 先失败后由服务端确认成功不能同时进入失败人数user2 只有失败事实,应计入失败人数。
{AppCode: "lalu", EventID: "social:room-fail:1001", EventName: "room_join_fail", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, ErrorCode: "network_error", OccurredAtMS: start.Add(8*time.Minute + 15*time.Second).UnixMilli()},
{AppCode: "lalu", EventID: "social:room-fail:1002", EventName: "room_join_fail", UserID: user2, DeviceID: "dev-1002", CountryID: 86, RegionID: 210, ErrorCode: "load_error", OccurredAtMS: start.Add(8*time.Minute + 30*time.Second).UnixMilli()},
{AppCode: "lalu", EventID: "social:d1-active:1001", EventName: "home_view", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(24*time.Hour + time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:d1-room:1001", EventName: "room_join_success", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(24*time.Hour + 2*time.Minute).UnixMilli()},
} {
@ -866,7 +951,18 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
socialMetricInt(t, newUsers.Total, "registered_users") != 2 ||
socialMetricInt(t, newUsers.Total, "new_host_users") != 1 ||
socialMetricInt(t, newUsers.Total, "new_normal_users") != 1 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "registration_success_rate")-1) > 0.0001 ||
socialMetricInt(t, newUsers.Total, "room_join_success_users") != 1 ||
socialMetricInt(t, newUsers.Total, "room_join_fail_users") != 1 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "room_join_success_rate")-0.5) > 0.0001 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "room_join_fail_rate")-0.5) > 0.0001 ||
socialMetricInt(t, newUsers.Total, "mic_up_users") != 1 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "mic_up_rate")-1) > 0.0001 ||
socialMetricInt(t, newUsers.Total, "gift_users") != 1 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "gift_rate")-1) > 0.0001 ||
// user1 为 Google/第三方成功充值user2 为 finance verified 币商充值,两者都属于新用户充值成功。
socialMetricInt(t, newUsers.Total, "recharge_users") != 2 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "recharge_rate")-1) > 0.0001 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "new_host_d1_retention_rate")-1) > 0.0001 ||
// 新用户平均进房/上麦时长只统计当天注册用户room_stay 300s/1人=5minmic 120s/1人=2min。
math.Abs(socialMetricFloat(t, newUsers.Total, "avg_room_stay_min")-5) > 0.0001 ||
@ -884,6 +980,11 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
if !profileSetupColumnFound {
t.Fatalf("P0 profile setup view column contract is missing: %+v", newUsers.Columns)
}
for _, column := range newUsers.Columns {
if column.Key == "app_download_users" {
t.Fatalf("P0 download count must remain hidden from table columns: %+v", newUsers.Columns)
}
}
revenue := socialTestSection(t, result, SocialSectionRevenue)
// 文档口径:总充值 2100 = Google 500 + 三方(MiFaPay) 700 + 币商(finance verified) 900。

View File

@ -1052,8 +1052,9 @@ func (r *Repository) ConsumeFinanceCoinSellerRechargeOrder(ctx context.Context,
}
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
for _, scope := range statDayScopesFromContext(ctx, occurredAtMS) {
// finance 工作台 verified 币商订单只进入总充值和币商充值列(文档:币商充值=财务-代理充值),
// 不再重复写第三方列;也不置 recharge_user/first/repeat 标记,避免财务补单把付费率和 ARPPU 分母放大。
// finance 工作台 verified 币商订单只进入总充值和币商充值金额列,不重复写第三方列;
// 仍不置全局 recharge_user/first/repeat避免财务补单放大付费率和 ARPPU 分母。
// 新用户充值转化单独以该已审核金额识别成功币商充值,不改变全局付费用户口径。
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,

View File

@ -804,7 +804,9 @@ func aggregateSocialRows(rows []socialRequirementUserDay, section string, role s
if row.Registered > 0 && row.RoomJoinOK > 0 {
agg.RegisteredRoomJoinOK++
}
if row.Registered > 0 && row.RoomJoinNG > 0 {
// 失败人数表示“尝试过但当天始终没有拿到房间信息”的新用户;同一用户先失败后成功时,
// 成功事实优先,不能让一个人同时进入成功、失败两个转化分子。
if row.Registered > 0 && row.RoomJoinNG > 0 && row.RoomJoinOK == 0 {
agg.RegisteredRoomJoinNG++
}
if row.Registered > 0 && row.RoomJoinOK > 0 && row.MicUp > 0 {
@ -813,7 +815,9 @@ func aggregateSocialRows(rows []socialRequirementUserDay, section string, role s
if row.Registered > 0 && row.RoomJoinOK > 0 && row.Gift > 0 {
agg.RegisteredRoomGift++
}
if row.Registered > 0 && row.Recharge > 0 {
// 新用户充值转化覆盖普通充值事实和 finance verified 币商充值。后者不进入全局
// paid/ARPPU 分母,但已审核到账金额足以证明该新用户当天完成过一次充值。
if row.Registered > 0 && (row.Recharge > 0 || row.CoinSellerRechargeUSDMinor > 0) {
agg.RegisteredRecharge++
}
if row.Registered > 0 && row.GameBet > 0 {
@ -1128,7 +1132,7 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent
"registered_users": agg.Registered,
"new_host_users": agg.NewHostUsers,
"new_normal_users": agg.NewNormalUsers,
"registration_success_rate": ratio(agg.Registered, agg.AppFirstOpen),
"registration_success_rate": ratio(agg.Registered, agg.ProfileSetupView),
"room_join_success_users": agg.RegisteredRoomJoinOK,
"room_join_fail_users": agg.RegisteredRoomJoinNG,
"room_join_success_rate": ratio(agg.RegisteredRoomJoinOK, agg.Registered),
@ -1318,10 +1322,20 @@ func socialRequirementMetricDefinitions(section string) []MetricDefinition {
}
default:
return []MetricDefinition{
{Metric: "app_download_users", Definition: "客户端 app_first_open 去重人数;不代表应用商店真实下载量。"},
{Metric: "profile_setup_view_users", Definition: "进入填写资料页的新用户人数:客户端 profile_start 事件按 canonical 用户或设备主体、统计自然日去重。"},
// registered_users 保留历史字段兼容,业务展示统一为“新增用户”;资格和幂等边界由 UserRegistered 生产、消费链路保证。
{Metric: "registered_users", Definition: "注册成功的新用户人数:仅统计资料完成后由 user-service 发布的 UserRegistered 服务端事实按用户去重未完成资料、quick_account、game_robot 不计MQ 重投不重复。"},
{Metric: "registration_success_rate", Definition: "新增用户 / 填写资料页曝光人数;填写资料页曝光人数为 0 时返回 0。"},
{Metric: "room_join_success_users", Definition: "新用户进房人数:当天新增用户中,客户端 JoinRoom 成功返回完整房间信息或服务端 RoomUserJoined 确认成功的去重用户数。"},
{Metric: "room_join_fail_users", Definition: "新用户进房失败人数:当天新增用户中发起进房后因网络、加载或业务错误失败,且当天没有任何成功进房事实的去重用户数。"},
{Metric: "room_join_success_rate", Definition: "新用户进房人数 / 新增用户;新增用户为 0 时返回 0。"},
{Metric: "room_join_fail_rate", Definition: "新用户进房失败人数 / 新增用户;新增用户为 0 时返回 0。"},
{Metric: "mic_up_users", Definition: "新用户上麦人数:当天已成功进房的新用户中,客户端 ConfirmMicPublishing 确认麦位为 publishing或服务端产生正麦上时长事实的去重人数。"},
{Metric: "mic_up_rate", Definition: "新用户上麦人数 / 新用户进房人数;新用户进房人数为 0 时返回 0。"},
{Metric: "gift_users", Definition: "新用户送礼人数:当天已成功进房的新用户中,产生 RoomGiftSent 成功事实的去重人数;普通、幸运及 Super Lucky 礼物均计入。"},
{Metric: "gift_rate", Definition: "新用户送礼人数 / 新用户进房人数;新用户进房人数为 0 时返回 0。"},
{Metric: "recharge_users", Definition: "新用户充值人数:当天新增用户中,成功完成 Google、第三方或币商充值的去重人数充值失败、待审核币商订单不计。"},
{Metric: "recharge_rate", Definition: "新用户充值人数 / 新增用户;新增用户为 0 时返回 0。"},
{Metric: "new_user_d1_retention_rate", Definition: "新普通用户次日活跃留存。"},
}
}
@ -1429,22 +1443,21 @@ func socialRequirementColumns(section string) []SocialRequirementColumn {
}
default:
return []SocialRequirementColumn{
{Key: "app_download_users", Label: "下载APP人数", Type: "count", Tooltip: "客户端 app_first_open 去重人数"},
{Key: "profile_setup_view_users", Label: "填写资料页曝光人数", Type: "count", Tooltip: "进入填写资料页的新用户人数;同一用户或设备同一天重复进入只计一次"},
{Key: "registered_users", Label: "新增用户", Type: "count", Tooltip: "注册成功的新用户人数:资料完成后产生 UserRegistered 服务端事实并按用户去重未完成资料、quick_account、game_robot 不计MQ 重投不重复"},
{Key: "new_host_users", Label: "新主播用户", Type: "count"},
{Key: "new_normal_users", Label: "新普通用户", Type: "count"},
{Key: "registration_success_rate", Label: "注册成功率", Type: "ratio"},
{Key: "room_join_success_users", Label: "新用户进房成功", Type: "count"},
{Key: "room_join_fail_users", Label: "新用户进房失败", Type: "count"},
{Key: "room_join_success_rate", Label: "新用户进房成功率", Type: "ratio"},
{Key: "room_join_fail_rate", Label: "新用户进房失败率", Type: "ratio"},
{Key: "mic_up_users", Label: "新用户上麦", Type: "count"},
{Key: "mic_up_rate", Label: "新用户上麦率", Type: "ratio"},
{Key: "gift_users", Label: "新用户送礼", Type: "count"},
{Key: "gift_rate", Label: "新用户送礼率", Type: "ratio"},
{Key: "recharge_users", Label: "新用户充值", Type: "count"},
{Key: "recharge_rate", Label: "新用户充值率", Type: "ratio"},
{Key: "registration_success_rate", Label: "注册成功率", Type: "ratio", Tooltip: "新增用户 / 填写资料页曝光人数"},
{Key: "room_join_success_users", Label: "新用户进房人数", Type: "count", Tooltip: "当天新增用户中,成功拿到房间信息的去重人数"},
{Key: "room_join_fail_users", Label: "新用户进房失败人数", Type: "count", Tooltip: "当天新增用户中,进房报错且当天始终未成功拿到房间信息的去重人数"},
{Key: "room_join_success_rate", Label: "新用户进房成功率", Type: "ratio", Tooltip: "新用户进房人数 / 新增用户"},
{Key: "room_join_fail_rate", Label: "新用户进房失败率", Type: "ratio", Tooltip: "新用户进房失败人数 / 新增用户"},
{Key: "mic_up_users", Label: "新用户上麦人数", Type: "count", Tooltip: "已成功进房且实际处于麦上 publishing 状态的新用户去重人数"},
{Key: "mic_up_rate", Label: "新用户上麦率", Type: "ratio", Tooltip: "新用户上麦人数 / 新用户进房人数"},
{Key: "gift_users", Label: "新用户送礼人数", Type: "count", Tooltip: "已成功进房且成功送出普通、幸运或 Super Lucky 礼物的新用户去重人数"},
{Key: "gift_rate", Label: "新用户送礼率", Type: "ratio", Tooltip: "新用户送礼人数 / 新用户进房人数"},
{Key: "recharge_users", Label: "新用户充值人数", Type: "count", Tooltip: "成功完成 Google、第三方或币商充值的新用户去重人数"},
{Key: "recharge_rate", Label: "新用户充值率", Type: "ratio", Tooltip: "新用户充值人数 / 新增用户"},
{Key: "game_users", Label: "新用户游戏", Type: "count"},
{Key: "game_conversion_rate", Label: "新用户游戏转化率", Type: "ratio"},
{Key: "avg_app_stay_min", Label: "新用户平均停留(分钟)", Type: "number", Tooltip: "当天注册的新用户 App 停留总时长 / 新用户注册成功人数"},

View File

@ -238,7 +238,9 @@ func socialDeltaForAppTrackingEvent(event AppTrackingEvent, scope statDayScope,
delta.RoomJoinOK = 1
delta.RoomStayMS = event.DurationMS
case "mic_up":
if !event.Success && strings.TrimSpace(event.ErrorCode) != "" {
// 只有 ConfirmMicPublishing 已确认麦位进入 publishing 后Flutter 才会上报 success=true
// 权限拒绝或无错误码的异常同样不能因为事件名是 mic_up 就被当成成功。
if !event.Success {
return socialUserDayDelta{}, false
}
delta.Active = 1

View File

@ -981,11 +981,13 @@ CREATE TABLE IF NOT EXISTS bd_profiles (
user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID',
role VARCHAR(32) NOT NULL COMMENT '角色',
parent_leader_user_id BIGINT NULL COMMENT '父级负责人用户 ID',
parent_owner_user_id BIGINT NULL COMMENT '外管团队直接 owner 用户 ID普通 App BD 始终为空',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
KEY idx_bd_profiles_parent_leader (app_code, parent_leader_user_id, status)
KEY idx_bd_profiles_parent_leader (app_code, parent_leader_user_id, status),
KEY idx_bd_profiles_parent_owner (app_code, parent_owner_user_id, status, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD资料表';
CREATE TABLE IF NOT EXISTS bd_leader_profiles (
@ -1149,9 +1151,12 @@ CREATE TABLE IF NOT EXISTS role_invitations (
inviter_user_id BIGINT NOT NULL COMMENT '邀请人用户 ID',
inviter_bd_user_id BIGINT NOT NULL COMMENT '邀请人BD用户 ID',
target_user_id BIGINT NOT NULL COMMENT '目标用户 ID',
external_operator_user_id BIGINT NULL COMMENT '外管邀请操作者的 App 用户 ID普通 App 邀请为空',
agency_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '公会名称',
parent_bd_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '父级BD用户 ID',
parent_leader_user_id BIGINT NULL COMMENT '父级负责人用户 ID',
parent_owner_user_id BIGINT NULL COMMENT '邀请创建时固化的外管团队 owner 用户 ID',
parent_agency_id BIGINT NULL COMMENT 'Host 邀请创建时固化的 Agency ID',
processed_by_user_id BIGINT NULL COMMENT '处理用户 ID',
process_reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '处理原因',
processed_at_ms BIGINT NULL COMMENT '处理时间UTC epoch ms',
@ -1163,7 +1168,8 @@ CREATE TABLE IF NOT EXISTS role_invitations (
UNIQUE KEY uk_role_invitations_command (app_code, command_id),
UNIQUE KEY uk_role_invitations_pending_target (app_code, pending_target_key),
KEY idx_role_invitations_target_status (app_code, target_user_id, status, created_at_ms),
KEY idx_role_invitations_inviter_status (app_code, inviter_user_id, status, created_at_ms)
KEY idx_role_invitations_inviter_status (app_code, inviter_user_id, status, created_at_ms),
KEY idx_role_invitations_parent_owner (app_code, parent_owner_user_id, status, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色邀请表';
CREATE TABLE IF NOT EXISTS host_command_results (

View File

@ -0,0 +1,65 @@
-- 外管团队 owner 和邀请父级必须由 user-service 持久化,不能由 admin-server 在列表时临时推导。
-- 所有新增列均为可空列,可在 MySQL 8 使用 INSTANT 添加;索引仍应在低峰期以 INPLACE 构建。
-- 启动兼容迁移也能先补列,因此离线脚本逐项探测,保证该发布步骤可安全重试。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_profiles' AND COLUMN_NAME = 'parent_owner_user_id') = 0,
'ALTER TABLE bd_profiles ADD COLUMN parent_owner_user_id BIGINT NULL COMMENT ''外管团队直接 owner 用户 ID普通 App BD 始终为空'' AFTER parent_leader_user_id, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- 历史和普通 App BD 必须保持 parent_owner_user_id=NULL。
-- 仅 ExternalInviteBD 的接受事务写该列,外管查询因此不会把 Lalu 的 BD Leader 树误纳入团队。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'bd_profiles' AND INDEX_NAME = 'idx_bd_profiles_parent_owner') = 0,
'ALTER TABLE bd_profiles ADD INDEX idx_bd_profiles_parent_owner (app_code, parent_owner_user_id, status, user_id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'role_invitations' AND COLUMN_NAME = 'external_operator_user_id') = 0,
'ALTER TABLE role_invitations ADD COLUMN external_operator_user_id BIGINT NULL COMMENT ''外管邀请操作者的 App 用户 ID普通 App 邀请为空'' AFTER target_user_id, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'role_invitations' AND COLUMN_NAME = 'parent_owner_user_id') = 0,
'ALTER TABLE role_invitations ADD COLUMN parent_owner_user_id BIGINT NULL COMMENT ''邀请创建时固化的外管团队 owner 用户 ID'' AFTER parent_leader_user_id, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'role_invitations' AND COLUMN_NAME = 'parent_agency_id') = 0,
'ALTER TABLE role_invitations ADD COLUMN parent_agency_id BIGINT NULL COMMENT ''Host 邀请创建时固化的 Agency ID'' AFTER parent_owner_user_id, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'role_invitations' AND INDEX_NAME = 'idx_role_invitations_parent_owner') = 0,
'ALTER TABLE role_invitations ADD INDEX idx_role_invitations_parent_owner (app_code, parent_owner_user_id, status, created_at_ms), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -4,7 +4,7 @@ package host
const (
// HostStatusActive 表示用户已经拥有长期主播身份。
HostStatusActive = "active"
// HostStatusDisabled 表示主播身份已停用Agency owner 移除普通 Host 后也进入该状态。
// HostStatusDisabled 表示主播身份被独立的身份管理流程停用;解除 Agency 成员关系不会改变该状态。
HostStatusDisabled = "disabled"
// HostSourceApplication 表示主播身份来自 Agency 申请审核通过。
@ -83,6 +83,13 @@ const (
InvitationTypeBD = "bd"
// InvitationTypeHost 表示 Agency owner 邀请用户成为本 Agency 的 Host 成员。
InvitationTypeHost = "host"
// InvitationTypeExternalAgency 是外管平台专用的 Agency 邀请类型。
// 它不能复用普通 Agency 类型,否则旧 App 邀请的 pending 唯一键和接受分支会被外管元数据改变。
InvitationTypeExternalAgency = "external_agency"
// InvitationTypeExternalBD 是外管平台专用的 BD 邀请类型。
InvitationTypeExternalBD = "external_bd"
// InvitationTypeExternalHost 是外管平台专用的 Host 邀请类型。
InvitationTypeExternalHost = "external_host"
// InvitationStatusPending 表示等待目标用户处理。
InvitationStatusPending = "pending"
@ -114,6 +121,12 @@ const (
CommandTypeInviteBD = "invite_bd"
// CommandTypeInviteHost 记录 Agency owner 邀请 Host 加入团队的命令。
CommandTypeInviteHost = "invite_host"
// CommandTypeExternalInviteAgency 记录外管操作者为指定 BD 创建 Agency 邀请的命令。
CommandTypeExternalInviteAgency = "external_invite_agency"
// CommandTypeExternalInviteBD 记录外管操作者直接发展 BD 的命令。
CommandTypeExternalInviteBD = "external_invite_bd"
// CommandTypeExternalInviteHost 记录外管操作者为指定 Agency 创建 Host 邀请的命令。
CommandTypeExternalInviteHost = "external_invite_host"
// CommandTypeProcessRoleInvitation 记录邀请处理命令。
CommandTypeProcessRoleInvitation = "process_role_invitation"
// CommandTypeCreateBDLeader 记录后台创建 BD Leader 命令。
@ -222,6 +235,9 @@ type BDProfile struct {
Role string
RegionID int64
ParentLeaderUserID int64
// ParentOwnerUserID 只记录外管团队的直接 owner。
// 普通 App BD 始终保持 0 并继续只使用 ParentLeaderUserID避免改变既有 Manager/BD Leader 链路。
ParentOwnerUserID int64
Status string
CreatedByUserID int64
CreatedAtMs int64
@ -399,10 +415,16 @@ type RoleInvitation struct {
InviterUserID int64
InviterBDUserID int64
TargetUserID int64
// ExternalOperatorUserID 非 0 时表示邀请由外管专用入口创建;普通 App 邀请保持 0。
ExternalOperatorUserID int64
RegionID int64
AgencyName string
ParentBDUserID int64
ParentLeaderUserID int64
// ParentOwnerUserID 固化选中 BD 的外管 owner接受时用于识别父级归属漂移。
ParentOwnerUserID int64
// ParentAgencyID 固化 Host 邀请选择的 Agency不能在接受时按操作者身份重新推导。
ParentAgencyID int64
ProcessedByUserID int64
ProcessReason string
ProcessedAtMs int64
@ -410,6 +432,30 @@ type RoleInvitation struct {
UpdatedAtMs int64
}
// ExternalTeamUser 是外管团队列表和邀请候选共同使用的用户投影。
// Roles 只表达 App 业务关系,不包含 portal Local/国家外管经理/外管SuperAdmin 身份。
type ExternalTeamUser struct {
UserID int64
DefaultDisplayUserID string
CurrentDisplayUserID string
Username string
Avatar string
Status string
RegionID int64
Roles []string
ParentOwnerUserID int64
ParentBDUserID int64
AgencyID int64
}
// ExternalTeamUsers 是外管团队用户的真实数据库分页结果。
type ExternalTeamUsers struct {
Users []ExternalTeamUser
Total int64
Page int
PageSize int
}
// CommandResult 是 host_command_results 的轻量投影,用于命令幂等返回已有事实。
type CommandResult struct {
CommandID string

View File

@ -118,6 +118,93 @@ type InviteHostCommand struct {
NowMs int64
}
// ExternalInviteBDInput 是外管账号直接邀请 BD 的参数。
// ExpectedRegionID 来自 admin-server 的实时外管 scope但 user-service 必须再次读取 operator/target 用户事实核对。
type ExternalInviteBDInput struct {
CommandID string
OperatorUserID int64
TargetUserID int64
ExpectedRegionID int64
}
// ExternalInviteBDCommand 是外管 BD 邀请事务需要的完整命令。
type ExternalInviteBDCommand struct {
CommandID string
InvitationID int64
OperatorUserID int64
TargetUserID int64
ExpectedRegionID int64
NowMs int64
}
// ExternalInviteAgencyInput 是外管操作者为团队内指定 BD 创建 Agency 邀请的参数。
type ExternalInviteAgencyInput struct {
CommandID string
OperatorUserID int64
ParentBDUserID int64
TargetUserID int64
AgencyName string
ScopeOwnerUserIDs []int64
ExpectedRegionID int64
}
// ExternalInviteAgencyCommand 是外管 Agency 邀请事务需要的完整命令。
type ExternalInviteAgencyCommand struct {
CommandID string
InvitationID int64
OperatorUserID int64
ParentBDUserID int64
TargetUserID int64
AgencyName string
ScopeOwnerUserIDs []int64
ExpectedRegionID int64
NowMs int64
}
// ExternalInviteHostInput 是外管操作者为团队内指定 Agency 创建 Host 邀请的参数。
type ExternalInviteHostInput struct {
CommandID string
OperatorUserID int64
AgencyID int64
TargetUserID int64
ScopeOwnerUserIDs []int64
ExpectedRegionID int64
}
// ExternalInviteHostCommand 是外管 Host 邀请事务需要的完整命令。
type ExternalInviteHostCommand struct {
CommandID string
InvitationID int64
OperatorUserID int64
AgencyID int64
TargetUserID int64
ScopeOwnerUserIDs []int64
ExpectedRegionID int64
NowMs int64
}
// ListExternalTeamUsersCommand 描述外管团队的受限分页查询。
// ScopeOwnerUserIDs 由 admin-server 根据 portal 账号树生成;空集合必须返回空结果,不能表示全量。
type ListExternalTeamUsersCommand struct {
ScopeOwnerUserIDs []int64
RegionID int64
Role string
Status string
Keyword string
Page int
PageSize int
}
// SearchExternalInvitationCandidateCommand 只允许按 ID 精确查找当前区域候选人。
type SearchExternalInvitationCandidateCommand struct {
OperatorUserID int64
ExpectedRegionID int64
InvitationType string
Keyword string
// NowMs 只由 service 层写入,保证仓储判断靓号租约有效期时不信任外部请求时间。
NowMs int64
}
// ListRoleInvitationsCommand 描述当前用户收件箱读取条件。
type ListRoleInvitationsCommand struct {
TargetUserID int64

View File

@ -21,6 +21,11 @@ type Repository interface {
InviteAgency(ctx context.Context, command InviteAgencyCommand) (hostdomain.RoleInvitation, error)
InviteBD(ctx context.Context, command InviteBDCommand) (hostdomain.RoleInvitation, error)
InviteHost(ctx context.Context, command InviteHostCommand) (hostdomain.RoleInvitation, error)
ExternalInviteAgency(ctx context.Context, command ExternalInviteAgencyCommand) (hostdomain.RoleInvitation, error)
ExternalInviteBD(ctx context.Context, command ExternalInviteBDCommand) (hostdomain.RoleInvitation, error)
ExternalInviteHost(ctx context.Context, command ExternalInviteHostCommand) (hostdomain.RoleInvitation, error)
ListExternalTeamUsers(ctx context.Context, command ListExternalTeamUsersCommand) (hostdomain.ExternalTeamUsers, error)
SearchExternalInvitationCandidate(ctx context.Context, command SearchExternalInvitationCandidateCommand) (hostdomain.ExternalTeamUser, error)
ListRoleInvitations(ctx context.Context, command ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error)
GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (hostdomain.RoleInvitation, error)
ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error)
@ -193,7 +198,7 @@ func (s *Service) ReviewAgencyApplication(ctx context.Context, command ReviewAge
})
}
// KickAgencyHost 结束普通成员关系,并在目标没有 active Agency owner 身份时停用 Host 身份
// KickAgencyHost 只结束普通成员关系Host 是长期身份,不能随 Agency 归属一起停用
func (s *Service) KickAgencyHost(ctx context.Context, command KickAgencyHostInput) (hostdomain.KickAgencyHostResult, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.KickAgencyHostResult{}, err
@ -210,7 +215,7 @@ func (s *Service) KickAgencyHost(ctx context.Context, command KickAgencyHostInpu
AgencyID: command.AgencyID,
HostUserID: command.HostUserID,
Reason: strings.TrimSpace(command.Reason),
EventID: s.nextEventIDRange(2),
EventID: s.nextEventIDRange(1),
NowMs: nowMs,
})
}
@ -328,6 +333,166 @@ func (s *Service) InviteHost(ctx context.Context, command InviteHostInput) (host
})
}
// ExternalInviteBD 允许外管 Local、国家外管经理或外管SuperAdmin 直接发展 BD。
// Portal 身份权限由 admin-server 判断user-service 只接受 App 用户 ID并在事务内强制 operator/target 同区。
func (s *Service) ExternalInviteBD(ctx context.Context, command ExternalInviteBDInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.OperatorUserID <= 0 || command.TargetUserID <= 0 || command.ExpectedRegionID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "command_id, operator_user_id, target_user_id and expected_region_id are required")
}
return s.repository.ExternalInviteBD(ctx, ExternalInviteBDCommand{
CommandID: strings.TrimSpace(command.CommandID),
InvitationID: s.idGenerator.NewInt64(),
OperatorUserID: command.OperatorUserID,
TargetUserID: command.TargetUserID,
ExpectedRegionID: command.ExpectedRegionID,
NowMs: s.now().UnixMilli(),
})
}
// ExternalInviteAgency 为团队中显式选择的 BD 创建 Agency 邀请。
// scope owner 为空时直接拒绝,避免“没有下级”被错误解释为“允许全 App”。
func (s *Service) ExternalInviteAgency(ctx context.Context, command ExternalInviteAgencyInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err
}
scopeOwnerUserIDs, err := normalizeExternalScopeOwnerIDs(command.ScopeOwnerUserIDs)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.OperatorUserID <= 0 || command.ParentBDUserID <= 0 || command.TargetUserID <= 0 || command.ExpectedRegionID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "command_id, operator_user_id, parent_bd_user_id, target_user_id and expected_region_id are required")
}
return s.repository.ExternalInviteAgency(ctx, ExternalInviteAgencyCommand{
CommandID: strings.TrimSpace(command.CommandID),
InvitationID: s.idGenerator.NewInt64(),
OperatorUserID: command.OperatorUserID,
ParentBDUserID: command.ParentBDUserID,
TargetUserID: command.TargetUserID,
AgencyName: strings.TrimSpace(command.AgencyName),
ScopeOwnerUserIDs: scopeOwnerUserIDs,
ExpectedRegionID: command.ExpectedRegionID,
NowMs: s.now().UnixMilli(),
})
}
// ExternalInviteHost 为团队中显式选择的 Agency 创建 Host 邀请。
func (s *Service) ExternalInviteHost(ctx context.Context, command ExternalInviteHostInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err
}
scopeOwnerUserIDs, err := normalizeExternalScopeOwnerIDs(command.ScopeOwnerUserIDs)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if strings.TrimSpace(command.CommandID) == "" || command.OperatorUserID <= 0 || command.AgencyID <= 0 || command.TargetUserID <= 0 || command.ExpectedRegionID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "command_id, operator_user_id, agency_id, target_user_id and expected_region_id are required")
}
return s.repository.ExternalInviteHost(ctx, ExternalInviteHostCommand{
CommandID: strings.TrimSpace(command.CommandID),
InvitationID: s.idGenerator.NewInt64(),
OperatorUserID: command.OperatorUserID,
AgencyID: command.AgencyID,
TargetUserID: command.TargetUserID,
ScopeOwnerUserIDs: scopeOwnerUserIDs,
ExpectedRegionID: command.ExpectedRegionID,
NowMs: s.now().UnixMilli(),
})
}
// ListExternalTeamUsers 返回数据库真实分页的团队用户region 和 scope 缺失都 fail-closed。
func (s *Service) ListExternalTeamUsers(ctx context.Context, command ListExternalTeamUsersCommand) (hostdomain.ExternalTeamUsers, error) {
if s.repository == nil {
return hostdomain.ExternalTeamUsers{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
scopeOwnerUserIDs, err := normalizeExternalScopeOwnerIDs(command.ScopeOwnerUserIDs)
if err != nil {
return hostdomain.ExternalTeamUsers{}, err
}
if command.RegionID <= 0 {
return hostdomain.ExternalTeamUsers{}, xerr.New(xerr.InvalidArgument, "region_id is required")
}
role := strings.ToLower(strings.TrimSpace(command.Role))
if role != "" && role != hostdomain.BDRoleBD && role != "agency" && role != "host" {
return hostdomain.ExternalTeamUsers{}, xerr.New(xerr.InvalidArgument, "external team role is invalid")
}
status := normalizeStatus(command.Status)
if status == "" {
status = "active"
}
if status != "active" && status != "disabled" && status != "banned" {
return hostdomain.ExternalTeamUsers{}, xerr.New(xerr.InvalidArgument, "user status is invalid")
}
page := command.Page
if page <= 0 {
page = 1
}
pageSize := command.PageSize
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 200 {
pageSize = 200
}
return s.repository.ListExternalTeamUsers(ctx, ListExternalTeamUsersCommand{
ScopeOwnerUserIDs: scopeOwnerUserIDs,
RegionID: command.RegionID,
Role: role,
Status: status,
Keyword: strings.TrimSpace(command.Keyword),
Page: page,
PageSize: pageSize,
})
}
// SearchExternalInvitationCandidate 精确查找当前区域候选人,禁止 username 模糊搜索扩散用户目录。
func (s *Service) SearchExternalInvitationCandidate(ctx context.Context, command SearchExternalInvitationCandidateCommand) (hostdomain.ExternalTeamUser, error) {
if s.repository == nil {
return hostdomain.ExternalTeamUser{}, xerr.New(xerr.Unavailable, "host repository is not configured")
}
invitationType := strings.ToLower(strings.TrimSpace(command.InvitationType))
if command.OperatorUserID <= 0 || command.ExpectedRegionID <= 0 || strings.TrimSpace(command.Keyword) == "" {
return hostdomain.ExternalTeamUser{}, xerr.New(xerr.InvalidArgument, "operator_user_id, expected_region_id and keyword are required")
}
if invitationType != hostdomain.InvitationTypeBD && invitationType != hostdomain.InvitationTypeAgency && invitationType != hostdomain.InvitationTypeHost {
return hostdomain.ExternalTeamUser{}, xerr.New(xerr.InvalidArgument, "invitation_type is invalid")
}
return s.repository.SearchExternalInvitationCandidate(ctx, SearchExternalInvitationCandidateCommand{
OperatorUserID: command.OperatorUserID,
ExpectedRegionID: command.ExpectedRegionID,
InvitationType: invitationType,
Keyword: strings.TrimSpace(command.Keyword),
NowMs: s.now().UnixMilli(),
})
}
func normalizeExternalScopeOwnerIDs(userIDs []int64) ([]int64, error) {
if len(userIDs) == 0 {
return nil, xerr.New(xerr.PermissionDenied, "external team scope is empty")
}
if len(userIDs) > 1000 {
return nil, xerr.New(xerr.InvalidArgument, "external team scope exceeds 1000 owners")
}
seen := make(map[int64]struct{}, len(userIDs))
result := make([]int64, 0, len(userIDs))
for _, userID := range userIDs {
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "scope_owner_user_ids contains invalid user id")
}
if _, ok := seen[userID]; ok {
continue
}
seen[userID] = struct{}{}
result = append(result, userID)
}
if len(result) == 0 {
return nil, xerr.New(xerr.PermissionDenied, "external team scope is empty")
}
return result, nil
}
// ListRoleInvitations 返回当前用户收到的角色/团队邀请收件箱。
func (s *Service) ListRoleInvitations(ctx context.Context, command ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error) {
if s.repository == nil {

View File

@ -2,6 +2,7 @@ package host_test
import (
"context"
"encoding/json"
"fmt"
"testing"
"time"
@ -57,6 +58,46 @@ func seedActiveUserInAppAndCountry(t *testing.T, repository *mysqltest.Repositor
})
}
// assertRoleInvitationCreatedOutbox verifies the reliable boundary consumed by
// activity-service. A pending invitation without this row would exist in MySQL
// but never reach the target user's Tencent IM conversation.
func assertRoleInvitationCreatedOutbox(t *testing.T, repository *mysqltest.Repository, invitation hostdomain.RoleInvitation) {
t.Helper()
var payloadJSON string
if err := repository.RawDB().QueryRow(`
SELECT CAST(payload_json AS CHAR)
FROM user_outbox
WHERE app_code = ? AND event_type = 'RoleInvitationCreated'
AND aggregate_type = 'role_invitation' AND aggregate_id = ?`,
appcode.Default, invitation.InvitationID,
).Scan(&payloadJSON); err != nil {
t.Fatalf("query RoleInvitationCreated outbox for %d failed: %v", invitation.InvitationID, err)
}
var payload struct {
InvitationID int64 `json:"invitation_id"`
InvitationType string `json:"invitation_type"`
Status string `json:"status"`
InviterUserID int64 `json:"inviter_user_id"`
TargetUserID int64 `json:"target_user_id"`
ExternalOperatorUserID int64 `json:"external_operator_user_id"`
ParentOwnerUserID int64 `json:"parent_owner_user_id"`
ParentAgencyID int64 `json:"parent_agency_id"`
}
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
t.Fatalf("decode RoleInvitationCreated outbox for %d failed: %v", invitation.InvitationID, err)
}
if payload.InvitationID != invitation.InvitationID ||
payload.InvitationType != invitation.InvitationType ||
payload.Status != hostdomain.InvitationStatusPending ||
payload.InviterUserID != invitation.InviterUserID ||
payload.TargetUserID != invitation.TargetUserID ||
payload.ExternalOperatorUserID != invitation.ExternalOperatorUserID ||
payload.ParentOwnerUserID != invitation.ParentOwnerUserID ||
payload.ParentAgencyID != invitation.ParentAgencyID {
t.Fatalf("RoleInvitationCreated outbox lost external invitation identity: payload=%+v invitation=%+v", payload, invitation)
}
}
func seedActiveMembership(t *testing.T, repository *mysqltest.Repository, membershipID int64, agencyID int64, hostUserID int64, membershipType string) {
t.Helper()
if membershipType == "" {
@ -158,7 +199,7 @@ func TestCheckBusinessCapabilityRequiresActiveManagerProfile(t *testing.T) {
if err != nil {
t.Fatalf("CheckBusinessCapability for agency owner failed: %v", err)
}
if allowed || reason != "active_manager_required" {
if allowed || reason != "manager_capability_required" {
t.Fatalf("agency owner without manager profile should be denied: allowed=%v reason=%q", allowed, reason)
}
@ -330,7 +371,11 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndPreservesExistingHostBinding(t
if err != nil {
t.Fatalf("InviteAgency failed: %v", err)
}
if invitation.Status != hostdomain.InvitationStatusPending {
if invitation.Status != hostdomain.InvitationStatusPending ||
invitation.InvitationType != hostdomain.InvitationTypeAgency ||
invitation.ExternalOperatorUserID != 0 ||
invitation.ParentOwnerUserID != 0 ||
invitation.ParentAgencyID != 0 {
t.Fatalf("agency invitation should wait for target accept: %+v", invitation)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
@ -460,6 +505,12 @@ func TestAcceptHostInvitationDoesNotGrantAgencyIdentity(t *testing.T) {
if err != nil {
t.Fatalf("InviteHost failed: %v", err)
}
if invitation.InvitationType != hostdomain.InvitationTypeHost ||
invitation.ExternalOperatorUserID != 0 ||
invitation.ParentOwnerUserID != 0 ||
invitation.ParentAgencyID != 0 {
t.Fatalf("ordinary Host invitation must not write external ownership metadata: %+v", invitation)
}
result, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "accept-host-612",
ActorUserID: 612,
@ -505,7 +556,7 @@ func TestAcceptHostInvitationDoesNotGrantAgencyIdentity(t *testing.T) {
}
}
func TestKickAgencyHostDisablesOrdinaryHostIdentity(t *testing.T) {
func TestKickAgencyHostOnlyEndsMembershipAndKeepsHostIdentity(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedActiveUser(t, repository, 621, 10)
@ -541,15 +592,15 @@ func TestKickAgencyHostDisablesOrdinaryHostIdentity(t *testing.T) {
if result.Membership.Status != hostdomain.MembershipStatusEnded || result.Membership.EndedAtMs != 640 || result.Membership.EndedByUserID != 621 || result.Membership.EndedReason != "agency_remove" {
t.Fatalf("membership should be ended by agency owner: %+v", result.Membership)
}
if result.HostProfile.Status != hostdomain.HostStatusDisabled || result.HostProfile.CurrentAgencyID != 0 || result.HostProfile.CurrentMembershipID != 0 {
t.Fatalf("removed ordinary host should lose host identity: %+v", result.HostProfile)
if result.HostProfile.Status != hostdomain.HostStatusActive || result.HostProfile.CurrentAgencyID != 0 || result.HostProfile.CurrentMembershipID != 0 {
t.Fatalf("removed host should keep identity and only lose agency binding: %+v", result.HostProfile)
}
summary, err := svc.GetUserRoleSummary(ctx, 622)
if err != nil {
t.Fatalf("GetUserRoleSummary after kick failed: %v", err)
}
if summary.IsHost || summary.HostStatus != hostdomain.HostStatusDisabled || summary.IsAgency {
t.Fatalf("removed host should no longer expose host entry: %+v", summary)
if !summary.IsHost || summary.HostStatus != hostdomain.HostStatusActive || summary.IsAgency {
t.Fatalf("removed host should keep host entry without agency identity: %+v", summary)
}
replayed, err := svc.KickAgencyHost(ctx, hostservice.KickAgencyHostInput{
CommandID: "kick-host-622",
@ -561,8 +612,8 @@ func TestKickAgencyHostDisablesOrdinaryHostIdentity(t *testing.T) {
if err != nil {
t.Fatalf("KickAgencyHost idempotent replay failed: %v", err)
}
if replayed.HostProfile.Status != hostdomain.HostStatusDisabled || replayed.HostProfile.CurrentMembershipID != 0 || replayed.Membership.Status != hostdomain.MembershipStatusEnded {
t.Fatalf("idempotent replay should return disabled host fact: %+v", replayed)
if replayed.HostProfile.Status != hostdomain.HostStatusActive || replayed.HostProfile.CurrentAgencyID != 0 || replayed.HostProfile.CurrentMembershipID != 0 || replayed.Membership.Status != hostdomain.MembershipStatusEnded {
t.Fatalf("idempotent replay should return unbound active host fact: %+v", replayed)
}
}
@ -639,7 +690,11 @@ func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) {
if err != nil {
t.Fatalf("InviteBD failed: %v", err)
}
if invitation.Status != hostdomain.InvitationStatusPending {
if invitation.Status != hostdomain.InvitationStatusPending ||
invitation.InvitationType != hostdomain.InvitationTypeBD ||
invitation.ExternalOperatorUserID != 0 ||
invitation.ParentOwnerUserID != 0 ||
invitation.ParentAgencyID != 0 {
t.Fatalf("bd invitation should wait for target accept: %+v", invitation)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
@ -654,7 +709,8 @@ func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) {
if err != nil {
t.Fatalf("GetBDProfile failed: %v", err)
}
if bdProfile.UserID != 702 || bdProfile.Role != hostdomain.BDRoleBD || bdProfile.ParentLeaderUserID != 701 {
if bdProfile.UserID != 702 || bdProfile.Role != hostdomain.BDRoleBD ||
bdProfile.ParentLeaderUserID != 701 || bdProfile.ParentOwnerUserID != 0 {
t.Fatalf("bd profile mismatch: %+v", bdProfile)
}
if _, err := svc.GetHostProfile(ctx, 702); xerr.CodeOf(err) != xerr.NotFound {
@ -2168,3 +2224,345 @@ func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
t.Fatalf("direct bd role summary mismatch: %+v", directBD)
}
}
func TestExternalTeamInvitationFlowKeepsOwnerScopeAndReturnsShortIDs(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
for _, userID := range []int64{1101, 1102, 1103, 1104, 1105} {
seedActiveUser(t, repository, userID, 91)
}
seedActiveUser(t, repository, 1199, 92)
svc := newHostService(repository, 30_000, 40_000)
bdInvitation, err := svc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
CommandID: "external-invite-bd-1102",
OperatorUserID: 1101,
TargetUserID: 1102,
ExpectedRegionID: 91,
})
if err != nil {
t.Fatalf("ExternalInviteBD failed: %v", err)
}
if bdInvitation.InvitationType != hostdomain.InvitationTypeExternalBD ||
bdInvitation.ExternalOperatorUserID != 1101 ||
bdInvitation.ParentOwnerUserID != 1101 ||
bdInvitation.ParentLeaderUserID != 0 {
t.Fatalf("external BD invitation must stay outside the App leader invitation chain: %+v", bdInvitation)
}
assertRoleInvitationCreatedOutbox(t, repository, bdInvitation)
bdInbox, err := svc.ListRoleInvitations(ctx, hostservice.ListRoleInvitationsCommand{
TargetUserID: 1102,
Status: hostdomain.InvitationStatusPending,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListRoleInvitations for external BD failed: %v", err)
}
if len(bdInbox) != 1 || bdInbox[0].InvitationID != bdInvitation.InvitationID {
t.Fatalf("external BD must remain pending and visible before App confirmation: %+v", bdInbox)
}
var bdRowsBeforeAccept int
if err := repository.RawDB().QueryRow(`
SELECT COUNT(*) FROM bd_profiles
WHERE app_code = ? AND user_id = ? AND status = 'active'`,
appcode.Default, 1102,
).Scan(&bdRowsBeforeAccept); err != nil {
t.Fatalf("count external BD before accept failed: %v", err)
}
if bdRowsBeforeAccept != 0 {
t.Fatalf("external BD relationship must not exist before target confirmation: count=%d", bdRowsBeforeAccept)
}
bdResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-bd-1102",
ActorUserID: 1102,
InvitationID: bdInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if err != nil {
t.Fatalf("accept external BD failed: %v", err)
}
if bdResult.BDProfile.ParentOwnerUserID != 1101 || bdResult.BDProfile.ParentLeaderUserID != 0 {
t.Fatalf("external BD must attach directly to operator owner: %+v", bdResult.BDProfile)
}
agencyInvitation, err := svc.ExternalInviteAgency(ctx, hostservice.ExternalInviteAgencyInput{
CommandID: "external-invite-agency-1103",
OperatorUserID: 1101,
ParentBDUserID: 1102,
TargetUserID: 1103,
AgencyName: "External Team Agency",
ScopeOwnerUserIDs: []int64{1101},
ExpectedRegionID: 91,
})
if err != nil {
t.Fatalf("ExternalInviteAgency failed: %v", err)
}
if agencyInvitation.InvitationType != hostdomain.InvitationTypeExternalAgency ||
agencyInvitation.ExternalOperatorUserID != 1101 ||
agencyInvitation.ParentOwnerUserID != 1101 ||
agencyInvitation.ParentBDUserID != 1102 {
t.Fatalf("external Agency invitation must persist the selected external parent chain: %+v", agencyInvitation)
}
assertRoleInvitationCreatedOutbox(t, repository, agencyInvitation)
var agencyRowsBeforeAccept int
if err := repository.RawDB().QueryRow(`
SELECT COUNT(*) FROM agencies
WHERE app_code = ? AND owner_user_id = ? AND status = 'active'`,
appcode.Default, 1103,
).Scan(&agencyRowsBeforeAccept); err != nil {
t.Fatalf("count external Agency before accept failed: %v", err)
}
if agencyRowsBeforeAccept != 0 {
t.Fatalf("external Agency relationship must not exist before target confirmation: count=%d", agencyRowsBeforeAccept)
}
agencyResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-agency-1103",
ActorUserID: 1103,
InvitationID: agencyInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
})
if err != nil {
t.Fatalf("accept external Agency failed: %v", err)
}
if agencyResult.Agency.ParentBDUserID != 1102 || agencyResult.Agency.OwnerUserID != 1103 {
t.Fatalf("external Agency parent mismatch: %+v", agencyResult.Agency)
}
hostInvitation, err := svc.ExternalInviteHost(ctx, hostservice.ExternalInviteHostInput{
CommandID: "external-invite-host-1104",
OperatorUserID: 1101,
AgencyID: agencyResult.Agency.AgencyID,
TargetUserID: 1104,
ScopeOwnerUserIDs: []int64{1101},
ExpectedRegionID: 91,
})
if err != nil {
t.Fatalf("ExternalInviteHost failed: %v", err)
}
if hostInvitation.InvitationType != hostdomain.InvitationTypeExternalHost ||
hostInvitation.ExternalOperatorUserID != 1101 ||
hostInvitation.ParentAgencyID != agencyResult.Agency.AgencyID {
t.Fatalf("external Host invitation must persist the selected Agency independently: %+v", hostInvitation)
}
assertRoleInvitationCreatedOutbox(t, repository, hostInvitation)
var hostRowsBeforeAccept int
if err := repository.RawDB().QueryRow(`
SELECT COUNT(*) FROM agency_memberships
WHERE app_code = ? AND host_user_id = ? AND status = 'active'`,
appcode.Default, 1104,
).Scan(&hostRowsBeforeAccept); err != nil {
t.Fatalf("count external Host before accept failed: %v", err)
}
if hostRowsBeforeAccept != 0 {
t.Fatalf("external Host relationship must not exist before target confirmation: count=%d", hostRowsBeforeAccept)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-host-1104",
ActorUserID: 1104,
InvitationID: hostInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("accept external Host failed: %v", err)
}
// 拒绝分支走同一个 App 确认状态机,但绝不能落 Host 关系;目标用户
// 仍能从 C2C 消息点击拒绝,并让邀请进入可审计终态。
rejectedHostInvitation, err := svc.ExternalInviteHost(ctx, hostservice.ExternalInviteHostInput{
CommandID: "external-invite-host-1105",
OperatorUserID: 1101,
AgencyID: agencyResult.Agency.AgencyID,
TargetUserID: 1105,
ScopeOwnerUserIDs: []int64{1101},
ExpectedRegionID: 91,
})
if err != nil {
t.Fatalf("ExternalInviteHost for reject failed: %v", err)
}
assertRoleInvitationCreatedOutbox(t, repository, rejectedHostInvitation)
rejectedHostResult, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-reject-host-1105",
ActorUserID: 1105,
InvitationID: rejectedHostInvitation.InvitationID,
Action: hostdomain.InvitationActionReject,
Reason: "not available",
})
if err != nil {
t.Fatalf("reject external Host failed: %v", err)
}
if rejectedHostResult.Invitation.Status != hostdomain.InvitationStatusRejected ||
rejectedHostResult.Invitation.ProcessedByUserID != 1105 {
t.Fatalf("external Host reject terminal state mismatch: %+v", rejectedHostResult.Invitation)
}
var rejectedHostMemberships int
if err := repository.RawDB().QueryRow(`
SELECT COUNT(*) FROM agency_memberships
WHERE app_code = ? AND host_user_id = ? AND status = 'active'`,
appcode.Default, 1105,
).Scan(&rejectedHostMemberships); err != nil {
t.Fatalf("count rejected external Host membership failed: %v", err)
}
if rejectedHostMemberships != 0 {
t.Fatalf("rejected external Host invitation must not create membership: count=%d", rejectedHostMemberships)
}
team, err := svc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
ScopeOwnerUserIDs: []int64{1101},
RegionID: 91,
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListExternalTeamUsers failed: %v", err)
}
if team.Total != 3 || len(team.Users) != 3 {
t.Fatalf("external team should contain BD, Agency owner and Host only: %+v", team)
}
rolesByUser := make(map[int64][]string, len(team.Users))
for _, user := range team.Users {
if user.CurrentDisplayUserID == "" || user.DefaultDisplayUserID == "" || user.RegionID != 91 {
t.Fatalf("external team projection must include both short IDs and canonical region: %+v", user)
}
rolesByUser[user.UserID] = user.Roles
}
if fmt.Sprint(rolesByUser[1102]) != "[bd]" || fmt.Sprint(rolesByUser[1103]) != "[agency]" || fmt.Sprint(rolesByUser[1104]) != "[host]" {
t.Fatalf("unexpected external team roles: %+v", rolesByUser)
}
if _, err := repository.RawDB().Exec(`
UPDATE users SET status = 'banned'
WHERE app_code = ? AND user_id = ?`, appcode.Default, 1102); err != nil {
t.Fatalf("ban external BD fixture failed: %v", err)
}
activeAfterBDBan, err := svc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
ScopeOwnerUserIDs: []int64{1101},
RegionID: 91,
Status: "active",
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListExternalTeamUsers after BD ban failed: %v", err)
}
if activeAfterBDBan.Total != 2 || len(activeAfterBDBan.Users) != 2 {
t.Fatalf("banned BD must not cut its active Agency/Host descendants: %+v", activeAfterBDBan)
}
bannedTeam, err := svc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
ScopeOwnerUserIDs: []int64{1101},
RegionID: 91,
Status: "banned",
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListExternalTeamUsers banned target lookup failed: %v", err)
}
if bannedTeam.Total != 1 || len(bannedTeam.Users) != 1 || bannedTeam.Users[0].UserID != 1102 {
t.Fatalf("original external owner must retain scope over banned BD: %+v", bannedTeam)
}
candidate, err := svc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
OperatorUserID: 1101,
ExpectedRegionID: 91,
InvitationType: hostdomain.InvitationTypeHost,
Keyword: displayID(1105),
})
if err != nil || candidate.UserID != 1105 || candidate.CurrentDisplayUserID != displayID(1105) {
t.Fatalf("same-region exact candidate lookup failed: candidate=%+v err=%v", candidate, err)
}
// active lease 是靓号当前归属事实:即使 users.current_display_user_id 快照尚未同步,
// 外管邀请也必须能精确命中,但不能因此开放区域用户模糊浏览。
if _, err := repository.RawDB().Exec(`
INSERT INTO pretty_display_user_id_leases (
app_code, lease_id, display_user_id, user_id, status,
starts_at_ms, expires_at_ms, source, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 'active', ?, 0, 'admin_grant', ?, ?)`,
appcode.Default, "external-candidate-pretty-1105", "pretty-1105", int64(1105),
int64(1), int64(1), int64(1),
); err != nil {
t.Fatalf("seed active pretty lease failed: %v", err)
}
prettyCandidate, err := svc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
OperatorUserID: 1101,
ExpectedRegionID: 91,
InvitationType: hostdomain.InvitationTypeHost,
Keyword: "pretty-1105",
})
if err != nil || prettyCandidate.UserID != 1105 {
t.Fatalf("active pretty lease exact candidate lookup failed: candidate=%+v err=%v", prettyCandidate, err)
}
if _, err := svc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
OperatorUserID: 1101,
ExpectedRegionID: 91,
InvitationType: hostdomain.InvitationTypeHost,
Keyword: displayID(1199),
}); xerr.CodeOf(err) != xerr.NotFound {
t.Fatalf("cross-region candidate must be hidden as not found: %v", err)
}
}
func TestExternalInvitationAcceptFailsAfterRegionOrParentOwnershipDrift(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
for _, userID := range []int64{1201, 1202, 1203, 1204} {
seedActiveUser(t, repository, userID, 93)
}
svc := newHostService(repository, 31_000, 41_000)
bdInvitation, err := svc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
CommandID: "external-invite-bd-region-drift",
OperatorUserID: 1201,
TargetUserID: 1202,
ExpectedRegionID: 93,
})
if err != nil {
t.Fatalf("ExternalInviteBD setup failed: %v", err)
}
seedActiveUser(t, repository, 1202, 94)
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-bd-region-drift",
ActorUserID: 1202,
InvitationID: bdInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); xerr.CodeOf(err) != xerr.RegionMismatch {
t.Fatalf("region drift must block external BD acceptance: %v", err)
}
bdInvitation, err = svc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
CommandID: "external-invite-bd-parent-drift",
OperatorUserID: 1201,
TargetUserID: 1203,
ExpectedRegionID: 93,
})
if err != nil {
t.Fatalf("ExternalInviteBD parent drift setup failed: %v", err)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-bd-parent-drift",
ActorUserID: 1203,
InvitationID: bdInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); err != nil {
t.Fatalf("accept BD parent drift setup failed: %v", err)
}
agencyInvitation, err := svc.ExternalInviteAgency(ctx, hostservice.ExternalInviteAgencyInput{
CommandID: "external-invite-agency-parent-drift",
OperatorUserID: 1201,
ParentBDUserID: 1203,
TargetUserID: 1204,
ScopeOwnerUserIDs: []int64{1201},
ExpectedRegionID: 93,
})
if err != nil {
t.Fatalf("ExternalInviteAgency parent drift setup failed: %v", err)
}
if _, err := repository.RawDB().Exec(`UPDATE bd_profiles SET parent_owner_user_id = 1202 WHERE user_id = 1203`); err != nil {
t.Fatalf("drift parent owner failed: %v", err)
}
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
CommandID: "external-accept-agency-parent-drift",
ActorUserID: 1204,
InvitationID: agencyInvitation.InvitationID,
Action: hostdomain.InvitationActionAccept,
}); xerr.CodeOf(err) != xerr.Conflict {
t.Fatalf("parent ownership drift must block external Agency acceptance: %v", err)
}
}

View File

@ -59,6 +59,31 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureColumn(ctx, "login_audit", "build_number", "build_number VARCHAR(64) NOT NULL DEFAULT '' COMMENT '本次认证客户端构建号' AFTER app_version"); err != nil {
return err
}
// 外管团队字段全部是可空、无默认值的元数据列,滚动升级时允许 INSTANT 补列;
// 历史 App 关系不做回填,索引构建仍由 024 离线 migration 完成,启动路径绝不能扫描 BD/邀请业务表。
externalTeamColumns := []struct {
table string
name string
ddl string
}{
{table: "bd_profiles", name: "parent_owner_user_id", ddl: "parent_owner_user_id BIGINT NULL COMMENT '外管团队直接 owner 用户 ID' AFTER parent_leader_user_id"},
{table: "role_invitations", name: "external_operator_user_id", ddl: "external_operator_user_id BIGINT NULL COMMENT '外管邀请操作者 App 用户 ID' AFTER target_user_id"},
{table: "role_invitations", name: "parent_owner_user_id", ddl: "parent_owner_user_id BIGINT NULL COMMENT '邀请创建时固化的外管团队 owner 用户 ID' AFTER parent_leader_user_id"},
{table: "role_invitations", name: "parent_agency_id", ddl: "parent_agency_id BIGINT NULL COMMENT 'Host 邀请创建时固化的 Agency ID' AFTER parent_owner_user_id"},
}
for _, column := range externalTeamColumns {
if err := r.ensureInstantColumn(ctx, column.table, column.name, column.ddl); err != nil {
return err
}
}
// 团队列表和邀请父级校验都要求先按 owner 缩小结果集;索引缺失时宁可拒绝启动,
// 也不能在外管页面流量下退化成租户级全表扫描。
if err := r.requireIndexDefinition(ctx, "bd_profiles", "idx_bd_profiles_parent_owner", []string{"app_code", "parent_owner_user_id", "status", "user_id"}, false); err != nil {
return fmt.Errorf("%w; apply migration 024 before startup", err)
}
if err := r.requireIndexDefinition(ctx, "role_invitations", "idx_role_invitations_parent_owner", []string{"app_code", "parent_owner_user_id", "status", "created_at_ms"}, false); err != nil {
return fmt.Errorf("%w; apply migration 024 before startup", err)
}
// auth_sessions 是热表;只允许 INSTANT 常量默认列在启动迁移中执行,绝不在进程启动时隐式 COPY 大表或构建 family 索引。
instantSessionColumns := []struct {
name string

View File

@ -27,12 +27,39 @@ func TestMigrateAddsLoginAuditClientVersionColumnsAndIndex(t *testing.T) {
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec(regexp.QuoteMeta("ALTER TABLE login_audit ADD COLUMN build_number VARCHAR(64) NOT NULL DEFAULT '' COMMENT '本次认证客户端构建号' AFTER app_version")).
WillReturnResult(sqlmock.NewResult(0, 0))
for _, column := range []struct {
table string
name string
}{
{table: "bd_profiles", name: "parent_owner_user_id"},
{table: "role_invitations", name: "external_operator_user_id"},
{table: "role_invitations", name: "parent_owner_user_id"},
{table: "role_invitations", name: "parent_agency_id"},
} {
mock.ExpectQuery(columnProbe).
WithArgs(column.table, column.name).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
}
requiredIndexProbe := `(?s)SELECT COLUMN_NAME, NON_UNIQUE.*FROM information_schema\.STATISTICS.*TABLE_SCHEMA = DATABASE\(\).*TABLE_NAME = \?.*INDEX_NAME = \?.*ORDER BY SEQ_IN_INDEX`
mock.ExpectQuery(requiredIndexProbe).
WithArgs("bd_profiles", "idx_bd_profiles_parent_owner").
WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME", "NON_UNIQUE"}).
AddRow("app_code", 1).
AddRow("parent_owner_user_id", 1).
AddRow("status", 1).
AddRow("user_id", 1))
mock.ExpectQuery(requiredIndexProbe).
WithArgs("role_invitations", "idx_role_invitations_parent_owner").
WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME", "NON_UNIQUE"}).
AddRow("app_code", 1).
AddRow("parent_owner_user_id", 1).
AddRow("status", 1).
AddRow("created_at_ms", 1))
for _, column := range []string{"token_family_id", "generation", "parent_session_id", "rotated_to_session_id", "rotation_at_ms", "rotation_request_id"} {
mock.ExpectQuery(columnProbe).
WithArgs("auth_sessions", column).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
}
requiredIndexProbe := `(?s)SELECT COLUMN_NAME, NON_UNIQUE.*FROM information_schema\.STATISTICS.*TABLE_SCHEMA = DATABASE\(\).*TABLE_NAME = \?.*INDEX_NAME = \?.*ORDER BY SEQ_IN_INDEX`
mock.ExpectQuery(requiredIndexProbe).
WithArgs("auth_sessions", "idx_auth_sessions_token_family").
WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME", "NON_UNIQUE"}).

View File

@ -232,7 +232,7 @@ func (r *Repository) ReviewAgencyApplication(ctx context.Context, command hostse
return result, nil
}
// KickAgencyHost 结束普通成员关系;普通成员会同步停用 Host 身份Agency owner 例外
// KickAgencyHost 只结束普通成员关系并清空当前 Agency 指针,长期 Host 身份保持不变
func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.KickAgencyHostCommand) (hostdomain.KickAgencyHostResult, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
@ -263,14 +263,8 @@ func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.Kic
if agency.OwnerUserID == command.HostUserID {
return hostdomain.KickAgencyHostResult{}, xerr.New(xerr.Conflict, "agency owner cannot be kicked")
}
// 如果目标用户同时是其他 active Agency owner协议要求 owner 自动拥有 Host 身份;
// 这种身份不能因为另一家 Agency 移除普通成员关系而被误停用。
_, ownsActiveAgency, err := queryActiveAgencyByOwner(ctx, tx, command.HostUserID, true)
if err != nil {
return hostdomain.KickAgencyHostResult{}, err
}
// host_profile 是 Host 身份事实;踢出时必须和 membership 同锁同事务更新,
// 否则我的页、Host Center 和送礼结算会在短时间内看到互相矛盾的身份。
// host_profile 是长期主播身份Agency membership 只是当前组织归属;这里锁住身份行,
// 只为在同一事务内清空指向被结束关系的冗余指针,不改变 status也不触碰 wallet-service 的钻石事实。
hostProfile, err := queryHostProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.HostUserID)
if err != nil {
return hostdomain.KickAgencyHostResult{}, err
@ -293,11 +287,9 @@ func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.Kic
if err := endAgencyMembership(ctx, tx, membership); err != nil {
return hostdomain.KickAgencyHostResult{}, err
}
hostProfileStatusChanged := false
if ownsActiveAgency {
// Agency owner 本身仍是 Host这里只摘掉被结束的普通成员归属
// 不把 owner 的 Host 入口关掉避免破坏“owner 自动拥有 Host 身份”的协议边界。
if hostProfile.CurrentMembershipID == membership.MembershipID {
// 只清理仍指向本次 ended membership 的当前归属,避免并发重绑后旧命令误清新关系。
// Host status、历史钻石和结算进度都不属于 Agency 归属,因此全部保持原值。
hostProfile.CurrentAgencyID = 0
hostProfile.CurrentMembershipID = 0
hostProfile.UpdatedAtMs = command.NowMs
@ -305,18 +297,6 @@ func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.Kic
return hostdomain.KickAgencyHostResult{}, err
}
}
} else {
// 普通成员被 Agency 移除后不再拥有 Host 身份;保留历史行但状态置 disabled
// 后续重新申请或被邀请时会复用该行并重新激活。
hostProfileStatusChanged = hostProfile.Status != hostdomain.HostStatusDisabled
hostProfile.Status = hostdomain.HostStatusDisabled
hostProfile.CurrentAgencyID = 0
hostProfile.CurrentMembershipID = 0
hostProfile.UpdatedAtMs = command.NowMs
if err := disableHostProfileAfterAgencyKick(ctx, tx, hostProfile); err != nil {
return hostdomain.KickAgencyHostResult{}, err
}
}
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
CommandID: command.CommandID,
CommandType: hostdomain.CommandTypeKickAgencyHost,
@ -329,11 +309,6 @@ func (r *Repository) KickAgencyHost(ctx context.Context, command hostservice.Kic
if err := insertHostOutbox(ctx, tx, command.EventID, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil {
return hostdomain.KickAgencyHostResult{}, err
}
if hostProfileStatusChanged {
if err := insertHostOutbox(ctx, tx, command.EventID+1, "HostStatusChanged", "host_profile", hostProfile.UserID, command.NowMs); err != nil {
return hostdomain.KickAgencyHostResult{}, err
}
}
// 系统消息消费的是和成员关系结束同事务提交的 user_outbox 快照。HTTP 返回后再同步调用
// activity-service 会让“关系已移除、通知调用失败”变成不可重试的半成功状态,也无法覆盖 MQ 重投。
if err := insertAgencyHostRemovedOutbox(ctx, tx, membership, command.OperatorUserID, command.NowMs); err != nil {

View File

@ -72,10 +72,11 @@ var (
AND active_member.status = '%s'
), 0)`, userRegionProjection("agencies", "owner_user_id"), hostdomain.MembershipStatusActive)
bdProfileColumns = fmt.Sprintf(`
user_id, role, %s, COALESCE(parent_leader_user_id, 0), status,
user_id, role, %s, COALESCE(parent_leader_user_id, 0),
COALESCE(parent_owner_user_id, 0), status,
created_by_user_id, created_at_ms, updated_at_ms`, userRegionProjection("bd_profiles", "user_id"))
bdLeaderProfileColumns = fmt.Sprintf(`
user_id, 'bd_leader', %s, 0, status,
user_id, 'bd_leader', %s, 0, 0, status,
created_by_user_id, created_at_ms, updated_at_ms`, userRegionProjection("bd_leader_profiles", "user_id"))
managerProfileColumns = fmt.Sprintf(`
user_id, %s, status, contact_info, created_by_admin_id, created_at_ms, updated_at_ms`, userRegionProjection("manager_profiles", "user_id"))
@ -89,8 +90,10 @@ var (
created_at_ms, updated_at_ms`, userRegionProjection("agency_applications", "applicant_user_id"))
roleInvitationColumns = fmt.Sprintf(`
invitation_id, command_id, invitation_type, status, inviter_user_id,
inviter_bd_user_id, target_user_id, %s, agency_name, parent_bd_user_id,
COALESCE(parent_leader_user_id, 0), COALESCE(processed_by_user_id, 0),
inviter_bd_user_id, target_user_id, COALESCE(external_operator_user_id, 0),
%s, agency_name, parent_bd_user_id,
COALESCE(parent_leader_user_id, 0), COALESCE(parent_owner_user_id, 0),
COALESCE(parent_agency_id, 0), COALESCE(processed_by_user_id, 0),
process_reason, COALESCE(processed_at_ms, 0), created_at_ms, updated_at_ms`, userRegionProjection("role_invitations", "target_user_id"))
)

View File

@ -0,0 +1,673 @@
package host
import (
"context"
"database/sql"
"fmt"
"strconv"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
hostdomain "hyapp/services/user-service/internal/domain/host"
hostservice "hyapp/services/user-service/internal/service/host"
)
// ExternalInviteBD 创建“外管操作者 -> BD”的待确认邀请。
// Portal 身份不属于 user-service但操作者和目标用户的 active/region 事实必须在本事务内锁定重验。
func (r *Repository) ExternalInviteBD(ctx context.Context, command hostservice.ExternalInviteBDCommand) (hostdomain.RoleInvitation, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
defer tx.Rollback()
if replayed, ok, err := replayExternalInvitation(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteBD); err != nil || ok {
return replayed, err
}
if err := requireExternalOperatorAndTargetRegion(ctx, tx, command.OperatorUserID, command.TargetUserID, command.ExpectedRegionID); err != nil {
return hostdomain.RoleInvitation{}, err
}
if _, ok, err := queryBDProfileMaybe(ctx, tx, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has active bd profile")
}
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeExternalBD, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
if pending.ExternalOperatorUserID != command.OperatorUserID || pending.ParentOwnerUserID != command.OperatorUserID {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has pending bd invitation")
}
if err := bindExternalCommandReplay(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteBD, pending.InvitationID, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
return pending, nil
}
invitation := hostdomain.RoleInvitation{
InvitationID: command.InvitationID,
CommandID: command.CommandID,
InvitationType: hostdomain.InvitationTypeExternalBD,
Status: hostdomain.InvitationStatusPending,
InviterUserID: command.OperatorUserID,
TargetUserID: command.TargetUserID,
ExternalOperatorUserID: command.OperatorUserID,
RegionID: command.ExpectedRegionID,
ParentOwnerUserID: command.OperatorUserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := persistExternalInvitation(ctx, tx, invitation, hostdomain.CommandTypeExternalInviteBD, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
return invitation, nil
}
// ExternalInviteAgency 为 scope 内明确选择的 BD 创建 Agency 邀请。
// BD 的 parent_owner_user_id 是授权索引;不能用 created_by_user_id 或 parent_leader_user_id 猜外管归属。
func (r *Repository) ExternalInviteAgency(ctx context.Context, command hostservice.ExternalInviteAgencyCommand) (hostdomain.RoleInvitation, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
defer tx.Rollback()
if replayed, ok, err := replayExternalInvitation(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteAgency); err != nil || ok {
return replayed, err
}
if err := requireExternalOperatorAndTargetRegion(ctx, tx, command.OperatorUserID, command.TargetUserID, command.ExpectedRegionID); err != nil {
return hostdomain.RoleInvitation{}, err
}
bd, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentBDUserID)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if bd.Status != hostdomain.BDStatusActive || bd.Role != hostdomain.BDRoleBD || bd.RegionID != command.ExpectedRegionID ||
bd.ParentLeaderUserID != 0 || bd.ParentOwnerUserID <= 0 ||
!containsExternalScopeOwner(command.ScopeOwnerUserIDs, bd.ParentOwnerUserID) {
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "parent bd is outside external team scope")
}
if _, err := requireExternalScopeOwnerRegion(ctx, tx, bd.ParentOwnerUserID, command.ExpectedRegionID); err != nil {
return hostdomain.RoleInvitation{}, err
}
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already owns active agency")
}
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeExternalAgency, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
if pending.ExternalOperatorUserID != command.OperatorUserID || pending.ParentBDUserID != bd.UserID || pending.ParentOwnerUserID != bd.ParentOwnerUserID {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has pending agency invitation")
}
if err := bindExternalCommandReplay(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteAgency, pending.InvitationID, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
return pending, nil
}
invitation := hostdomain.RoleInvitation{
InvitationID: command.InvitationID,
CommandID: command.CommandID,
InvitationType: hostdomain.InvitationTypeExternalAgency,
Status: hostdomain.InvitationStatusPending,
InviterUserID: command.OperatorUserID,
InviterBDUserID: bd.UserID,
TargetUserID: command.TargetUserID,
ExternalOperatorUserID: command.OperatorUserID,
RegionID: command.ExpectedRegionID,
AgencyName: command.AgencyName,
ParentBDUserID: bd.UserID,
ParentLeaderUserID: bd.ParentLeaderUserID,
ParentOwnerUserID: bd.ParentOwnerUserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := persistExternalInvitation(ctx, tx, invitation, hostdomain.CommandTypeExternalInviteAgency, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
return invitation, nil
}
// ExternalInviteHost 为 scope 内明确选择的 Agency 创建 Host 邀请。
func (r *Repository) ExternalInviteHost(ctx context.Context, command hostservice.ExternalInviteHostCommand) (hostdomain.RoleInvitation, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
defer tx.Rollback()
if replayed, ok, err := replayExternalInvitation(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteHost); err != nil || ok {
return replayed, err
}
if err := requireExternalOperatorAndTargetRegion(ctx, tx, command.OperatorUserID, command.TargetUserID, command.ExpectedRegionID); err != nil {
return hostdomain.RoleInvitation{}, err
}
agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", command.AgencyID)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if agency.Status != hostdomain.AgencyStatusActive || !agency.JoinEnabled || agency.RegionID != command.ExpectedRegionID {
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "agency is not active in external region")
}
bd, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", agency.ParentBDUserID)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if bd.Status != hostdomain.BDStatusActive || bd.ParentLeaderUserID != 0 || bd.ParentOwnerUserID <= 0 ||
!containsExternalScopeOwner(command.ScopeOwnerUserIDs, bd.ParentOwnerUserID) {
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "agency is outside external team scope")
}
if _, err := requireExternalScopeOwnerRegion(ctx, tx, bd.ParentOwnerUserID, command.ExpectedRegionID); err != nil {
return hostdomain.RoleInvitation{}, err
}
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has active agency membership")
}
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already owns active agency")
}
if pending, ok, err := queryPendingRoleInvitationByTarget(ctx, tx, hostdomain.InvitationTypeExternalHost, command.TargetUserID, true); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
if pending.ExternalOperatorUserID != command.OperatorUserID || pending.ParentAgencyID != agency.AgencyID || pending.ParentOwnerUserID != bd.ParentOwnerUserID {
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has pending host invitation")
}
if err := bindExternalCommandReplay(ctx, tx, command.CommandID, hostdomain.CommandTypeExternalInviteHost, pending.InvitationID, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
return pending, nil
}
invitation := hostdomain.RoleInvitation{
InvitationID: command.InvitationID,
CommandID: command.CommandID,
InvitationType: hostdomain.InvitationTypeExternalHost,
Status: hostdomain.InvitationStatusPending,
InviterUserID: command.OperatorUserID,
InviterBDUserID: bd.UserID,
TargetUserID: command.TargetUserID,
ExternalOperatorUserID: command.OperatorUserID,
RegionID: command.ExpectedRegionID,
AgencyName: agency.Name,
ParentBDUserID: bd.UserID,
ParentLeaderUserID: bd.ParentLeaderUserID,
ParentOwnerUserID: bd.ParentOwnerUserID,
ParentAgencyID: agency.AgencyID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := persistExternalInvitation(ctx, tx, invitation, hostdomain.CommandTypeExternalInviteHost, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
return invitation, nil
}
func replayExternalInvitation(ctx context.Context, tx *sql.Tx, commandID string, commandType string) (hostdomain.RoleInvitation, bool, error) {
existing, ok, err := commandResult(ctx, tx, commandID)
if err != nil || !ok {
return hostdomain.RoleInvitation{}, false, err
}
if err := requireCommandResult(existing, commandType, hostdomain.ResultTypeInvitation); err != nil {
return hostdomain.RoleInvitation{}, true, err
}
invitation, err := queryRoleInvitation(ctx, tx, "WHERE invitation_id = ?", existing.ResultID)
return invitation, true, err
}
func bindExternalCommandReplay(ctx context.Context, tx *sql.Tx, commandID string, commandType string, invitationID int64, nowMs int64) error {
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
CommandID: commandID,
CommandType: commandType,
ResultType: hostdomain.ResultTypeInvitation,
ResultID: invitationID,
CreatedAtMs: nowMs,
}); err != nil {
return mapHostDuplicateError(err)
}
return nil
}
func persistExternalInvitation(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, commandType string, nowMs int64) error {
if err := insertExternalRoleInvitation(ctx, tx, invitation); err != nil {
return mapHostDuplicateError(err)
}
if err := bindExternalCommandReplay(ctx, tx, invitation.CommandID, commandType, invitation.InvitationID, nowMs); err != nil {
return err
}
return insertRoleInvitationCreatedOutbox(ctx, tx, invitation, nowMs)
}
func requireExternalOperatorAndTargetRegion(ctx context.Context, tx *sql.Tx, operatorUserID int64, targetUserID int64, expectedRegionID int64) error {
operatorRegionID, err := currentUserRegion(ctx, tx, operatorUserID, "FOR UPDATE")
if err != nil {
return err
}
if operatorRegionID <= 0 || operatorRegionID != expectedRegionID {
return xerr.New(xerr.RegionMismatch, "external operator region has changed")
}
targetRegionID, err := currentUserRegion(ctx, tx, targetUserID, "FOR UPDATE")
if err != nil {
return err
}
if targetRegionID != expectedRegionID {
return xerr.New(xerr.RegionMismatch, "target region does not match external operator region")
}
return nil
}
func requireExternalScopeOwnerRegion(ctx context.Context, tx *sql.Tx, ownerUserID int64, expectedRegionID int64) (int64, error) {
regionID, err := currentUserRegion(ctx, tx, ownerUserID, "FOR UPDATE")
if err != nil {
return 0, err
}
if regionID != expectedRegionID {
return 0, xerr.New(xerr.RegionMismatch, "external team owner region has changed")
}
return regionID, nil
}
func containsExternalScopeOwner(ownerUserIDs []int64, ownerUserID int64) bool {
for _, candidate := range ownerUserIDs {
if candidate == ownerUserID {
return true
}
}
return false
}
func validateExternalBDInvitationAcceptance(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, targetRegionID int64) error {
if invitation.InvitationType != hostdomain.InvitationTypeExternalBD ||
invitation.ExternalOperatorUserID <= 0 ||
invitation.InviterBDUserID != 0 ||
invitation.ParentLeaderUserID != 0 {
return xerr.New(xerr.InvalidArgument, "external bd invitation metadata is invalid")
}
operatorRegionID, err := currentUserRegion(ctx, tx, invitation.ExternalOperatorUserID, "FOR UPDATE")
if err != nil {
return err
}
if invitation.ParentOwnerUserID != invitation.ExternalOperatorUserID ||
invitation.InviterUserID != invitation.ExternalOperatorUserID ||
invitation.RegionID <= 0 ||
operatorRegionID != invitation.RegionID ||
targetRegionID != invitation.RegionID {
return xerr.New(xerr.RegionMismatch, "external bd invitation scope has changed")
}
return nil
}
func validateExternalAgencyInvitationAcceptance(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, targetRegionID int64) error {
if invitation.InvitationType != hostdomain.InvitationTypeExternalAgency ||
invitation.ExternalOperatorUserID <= 0 ||
invitation.InviterUserID != invitation.ExternalOperatorUserID ||
invitation.InviterBDUserID != invitation.ParentBDUserID {
return xerr.New(xerr.InvalidArgument, "external agency invitation metadata is invalid")
}
operatorRegionID, err := currentUserRegion(ctx, tx, invitation.ExternalOperatorUserID, "FOR UPDATE")
if err != nil {
return err
}
if operatorRegionID != invitation.RegionID || targetRegionID != invitation.RegionID || invitation.ParentOwnerUserID <= 0 {
return xerr.New(xerr.RegionMismatch, "external agency invitation region has changed")
}
bd, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", invitation.ParentBDUserID)
if err != nil {
return err
}
if bd.Status != hostdomain.BDStatusActive ||
bd.RegionID != invitation.RegionID ||
bd.ParentLeaderUserID != 0 ||
bd.ParentOwnerUserID != invitation.ParentOwnerUserID {
return xerr.New(xerr.Conflict, "external agency parent bd ownership has changed")
}
_, err = requireExternalScopeOwnerRegion(ctx, tx, bd.ParentOwnerUserID, invitation.RegionID)
return err
}
func validateExternalHostInvitationAcceptance(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation, targetRegionID int64) (hostdomain.Agency, error) {
if invitation.InvitationType != hostdomain.InvitationTypeExternalHost ||
invitation.ExternalOperatorUserID <= 0 ||
invitation.InviterUserID != invitation.ExternalOperatorUserID ||
invitation.InviterBDUserID != invitation.ParentBDUserID {
return hostdomain.Agency{}, xerr.New(xerr.InvalidArgument, "external host invitation metadata is invalid")
}
operatorRegionID, err := currentUserRegion(ctx, tx, invitation.ExternalOperatorUserID, "FOR UPDATE")
if err != nil {
return hostdomain.Agency{}, err
}
if operatorRegionID != invitation.RegionID || targetRegionID != invitation.RegionID || invitation.ParentAgencyID <= 0 || invitation.ParentOwnerUserID <= 0 {
return hostdomain.Agency{}, xerr.New(xerr.RegionMismatch, "external host invitation region has changed")
}
agency, err := queryAgency(ctx, tx, "WHERE agency_id = ? FOR UPDATE", invitation.ParentAgencyID)
if err != nil {
return hostdomain.Agency{}, err
}
if agency.Status != hostdomain.AgencyStatusActive || !agency.JoinEnabled ||
agency.RegionID != invitation.RegionID || agency.ParentBDUserID != invitation.ParentBDUserID {
return hostdomain.Agency{}, xerr.New(xerr.Conflict, "external host parent agency has changed")
}
bd, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", invitation.ParentBDUserID)
if err != nil {
return hostdomain.Agency{}, err
}
if bd.Status != hostdomain.BDStatusActive || bd.ParentLeaderUserID != 0 ||
bd.ParentOwnerUserID != invitation.ParentOwnerUserID || bd.RegionID != invitation.RegionID {
return hostdomain.Agency{}, xerr.New(xerr.Conflict, "external host parent bd ownership has changed")
}
if _, err := requireExternalScopeOwnerRegion(ctx, tx, bd.ParentOwnerUserID, invitation.RegionID); err != nil {
return hostdomain.Agency{}, err
}
return agency, nil
}
// ListExternalTeamUsers 通过 parent_owner 索引先收敛 BD再向下连接 Agency/Host。
// region/status 条件在 users 权威表上执行scope 绝不允许为空。
func (r *Repository) ListExternalTeamUsers(ctx context.Context, command hostservice.ListExternalTeamUsersCommand) (hostdomain.ExternalTeamUsers, error) {
if len(command.ScopeOwnerUserIDs) == 0 || command.RegionID <= 0 {
return hostdomain.ExternalTeamUsers{}, xerr.New(xerr.PermissionDenied, "external team scope is empty")
}
cte, cteArgs := externalTeamCTE(ctx, command.ScopeOwnerUserIDs, command.RegionID)
filter, filterArgs := externalTeamFilter(command)
outerArgs := []any{appcode.FromContext(ctx), command.RegionID, command.Status}
outerArgs = append(outerArgs, filterArgs...)
countArgs := append(append([]any{}, cteArgs...), outerArgs...)
var total int64
if err := r.db.QueryRowContext(ctx, cte+`
SELECT COUNT(DISTINCT tr.user_id)
FROM team_relations tr
INNER JOIN users u ON u.app_code = ? AND u.user_id = tr.user_id
WHERE u.region_id = ? AND u.status = ?`+filter,
countArgs...,
).Scan(&total); err != nil {
return hostdomain.ExternalTeamUsers{}, err
}
offset := (command.Page - 1) * command.PageSize
listArgs := append(append([]any{}, cteArgs...), outerArgs...)
listArgs = append(listArgs, command.PageSize, offset)
rows, err := r.db.QueryContext(ctx, cte+`
SELECT
u.user_id,
COALESCE(u.default_display_user_id, ''),
COALESCE(u.current_display_user_id, ''),
COALESCE(u.username, ''),
COALESCE(u.avatar, ''),
u.status,
COALESCE(u.region_id, 0),
GROUP_CONCAT(DISTINCT tr.role ORDER BY FIELD(tr.role, 'bd', 'agency', 'host') SEPARATOR ','),
MAX(tr.parent_owner_user_id),
MAX(tr.parent_bd_user_id),
MAX(tr.agency_id)
FROM team_relations tr
INNER JOIN users u ON u.app_code = ? AND u.user_id = tr.user_id
WHERE u.region_id = ? AND u.status = ?`+filter+`
GROUP BY u.user_id, u.default_display_user_id, u.current_display_user_id, u.username, u.avatar, u.status, u.region_id
ORDER BY MAX(u.updated_at_ms) DESC, u.user_id DESC
LIMIT ? OFFSET ?`,
listArgs...,
)
if err != nil {
return hostdomain.ExternalTeamUsers{}, err
}
defer rows.Close()
users := make([]hostdomain.ExternalTeamUser, 0, command.PageSize)
for rows.Next() {
var user hostdomain.ExternalTeamUser
var roles string
if err := rows.Scan(
&user.UserID,
&user.DefaultDisplayUserID,
&user.CurrentDisplayUserID,
&user.Username,
&user.Avatar,
&user.Status,
&user.RegionID,
&roles,
&user.ParentOwnerUserID,
&user.ParentBDUserID,
&user.AgencyID,
); err != nil {
return hostdomain.ExternalTeamUsers{}, err
}
if roles != "" {
user.Roles = strings.Split(roles, ",")
}
users = append(users, user)
}
if err := rows.Err(); err != nil {
return hostdomain.ExternalTeamUsers{}, err
}
return hostdomain.ExternalTeamUsers{Users: users, Total: total, Page: command.Page, PageSize: command.PageSize}, nil
}
func externalTeamCTE(ctx context.Context, ownerUserIDs []int64, regionID int64) (string, []any) {
args := make([]any, 0, len(ownerUserIDs)+9)
args = append(args, regionID, regionID, appcode.FromContext(ctx))
for _, userID := range ownerUserIDs {
args = append(args, userID)
}
args = append(args,
hostdomain.BDStatusActive,
appcode.FromContext(ctx),
hostdomain.AgencyStatusActive,
appcode.FromContext(ctx),
hostdomain.MembershipStatusActive,
)
query := `
WITH scoped_bd AS (
SELECT b.user_id AS bd_user_id, b.parent_owner_user_id
FROM bd_profiles b
INNER JOIN users scope_owner
ON scope_owner.app_code = b.app_code AND scope_owner.user_id = b.parent_owner_user_id
AND scope_owner.status = 'active' AND scope_owner.region_id = ?
INNER JOIN users bd_user
ON bd_user.app_code = b.app_code AND bd_user.user_id = b.user_id
AND bd_user.region_id = ?
WHERE b.app_code = ?
AND b.parent_owner_user_id IN (` + placeholderList(len(ownerUserIDs)) + `)
AND b.parent_leader_user_id IS NULL
AND b.status = ?
),
scoped_agencies AS (
SELECT a.agency_id, a.owner_user_id, a.parent_bd_user_id, sb.parent_owner_user_id
FROM scoped_bd sb
INNER JOIN agencies a
ON a.app_code = ? AND a.parent_bd_user_id = sb.bd_user_id AND a.status = ?
),
team_relations AS (
SELECT bd_user_id AS user_id, 'bd' AS role, parent_owner_user_id, bd_user_id AS parent_bd_user_id, 0 AS agency_id
FROM scoped_bd
UNION ALL
SELECT owner_user_id, 'agency', parent_owner_user_id, parent_bd_user_id, agency_id
FROM scoped_agencies
UNION ALL
SELECT m.host_user_id, 'host', sa.parent_owner_user_id, sa.parent_bd_user_id, sa.agency_id
FROM scoped_agencies sa
INNER JOIN agency_memberships m
ON m.app_code = ? AND m.agency_id = sa.agency_id
AND m.status = ? AND m.membership_type = 'member'
)`
return query, args
}
func externalTeamFilter(command hostservice.ListExternalTeamUsersCommand) (string, []any) {
filter := ""
args := make([]any, 0, 5)
if command.Role != "" {
filter += " AND tr.role = ?"
args = append(args, command.Role)
}
if command.Keyword != "" {
filter += " AND (u.user_id = ? OR u.current_display_user_id = ? OR u.default_display_user_id = ? OR u.username LIKE ?)"
args = append(args, int64FromKeyword(command.Keyword), command.Keyword, command.Keyword, "%"+command.Keyword+"%")
}
return filter, args
}
// SearchExternalInvitationCandidate 只执行最多四个有唯一索引支撑的精确查询;
// 不对 username 做 LIKE避免候选接口演变成区域用户目录。
func (r *Repository) SearchExternalInvitationCandidate(ctx context.Context, command hostservice.SearchExternalInvitationCandidateCommand) (hostdomain.ExternalTeamUser, error) {
operatorRegionID, err := currentUserRegion(ctx, r.db, command.OperatorUserID, "")
if err != nil {
return hostdomain.ExternalTeamUser{}, err
}
if operatorRegionID <= 0 || operatorRegionID != command.ExpectedRegionID {
return hostdomain.ExternalTeamUser{}, xerr.New(xerr.RegionMismatch, "external operator region has changed")
}
user, err := r.findExactExternalCandidate(ctx, command.ExpectedRegionID, command.Keyword, command.NowMs)
if err != nil {
return hostdomain.ExternalTeamUser{}, err
}
if err := r.requireCandidateRoleAvailable(ctx, command.InvitationType, user.UserID); err != nil {
return hostdomain.ExternalTeamUser{}, err
}
return user, nil
}
func (r *Repository) findExactExternalCandidate(ctx context.Context, regionID int64, keyword string, nowMs int64) (hostdomain.ExternalTeamUser, error) {
type lookup struct {
clause string
args []any
}
lookups := make([]lookup, 0, 3)
if userID, err := strconv.ParseInt(keyword, 10, 64); err == nil && userID > 0 {
lookups = append(lookups, lookup{clause: "user_id = ?", args: []any{userID}})
}
lookups = append(lookups,
lookup{
// users.current_display_user_id 是读取快照;过期 pretty 快照不能继续命中候选人。
clause: `current_display_user_id = ?
AND (current_display_user_id_kind <> 'pretty'
OR current_display_user_id_expires_at_ms IS NULL
OR current_display_user_id_expires_at_ms = 0
OR current_display_user_id_expires_at_ms > ?)`,
args: []any{keyword, nowMs},
},
lookup{clause: "default_display_user_id = ?", args: []any{keyword}},
)
for _, candidate := range lookups {
var user hostdomain.ExternalTeamUser
args := []any{appcode.FromContext(ctx), regionID}
args = append(args, candidate.args...)
err := r.db.QueryRowContext(ctx, `
SELECT user_id, COALESCE(default_display_user_id, ''), COALESCE(current_display_user_id, ''),
COALESCE(username, ''), COALESCE(avatar, ''), status, COALESCE(region_id, 0)
FROM users
WHERE app_code = ? AND region_id = ? AND status = 'active' AND `+candidate.clause+`
LIMIT 1`,
args...,
).Scan(
&user.UserID,
&user.DefaultDisplayUserID,
&user.CurrentDisplayUserID,
&user.Username,
&user.Avatar,
&user.Status,
&user.RegionID,
)
if err == nil {
return user, nil
}
if err != sql.ErrNoRows {
return hostdomain.ExternalTeamUser{}, err
}
}
// active lease 是靓号归属事实,不能只依赖 users.current_display_user_id 快照;
// active_display_user_id 生成列使该精确查询命中租约唯一索引,不扫描区域用户。
var user hostdomain.ExternalTeamUser
err := r.db.QueryRowContext(ctx, `
SELECT u.user_id, COALESCE(u.default_display_user_id, ''), COALESCE(u.current_display_user_id, ''),
COALESCE(u.username, ''), COALESCE(u.avatar, ''), u.status, COALESCE(u.region_id, 0)
FROM pretty_display_user_id_leases lease FORCE INDEX (uk_pretty_display_user_id_active)
INNER JOIN users u
ON u.user_id = lease.user_id AND u.app_code = lease.app_code
WHERE lease.app_code = ? AND lease.active_display_user_id = ?
AND (lease.expires_at_ms = 0 OR lease.expires_at_ms > ?)
AND u.region_id = ? AND u.status = 'active'
LIMIT 1`,
appcode.FromContext(ctx), keyword, nowMs, regionID,
).Scan(
&user.UserID,
&user.DefaultDisplayUserID,
&user.CurrentDisplayUserID,
&user.Username,
&user.Avatar,
&user.Status,
&user.RegionID,
)
if err == nil {
return user, nil
}
if err != sql.ErrNoRows {
return hostdomain.ExternalTeamUser{}, err
}
return hostdomain.ExternalTeamUser{}, xerr.New(xerr.NotFound, "external invitation candidate not found")
}
func (r *Repository) requireCandidateRoleAvailable(ctx context.Context, invitationType string, userID int64) error {
var query string
switch invitationType {
case hostdomain.InvitationTypeBD:
query = `SELECT (
EXISTS(SELECT 1 FROM bd_profiles WHERE app_code = ? AND user_id = ? AND status = 'active')
OR EXISTS(SELECT 1 FROM role_invitations
WHERE app_code = ? AND target_user_id = ? AND status = 'pending'
AND invitation_type IN ('bd', 'external_bd'))
)`
case hostdomain.InvitationTypeAgency:
query = `SELECT (
EXISTS(SELECT 1 FROM agencies WHERE app_code = ? AND owner_user_id = ? AND status = 'active')
OR EXISTS(SELECT 1 FROM role_invitations
WHERE app_code = ? AND target_user_id = ? AND status = 'pending'
AND invitation_type IN ('agency', 'external_agency'))
)`
case hostdomain.InvitationTypeHost:
query = `SELECT (
EXISTS(SELECT 1 FROM agencies WHERE app_code = ? AND owner_user_id = ? AND status = 'active')
OR EXISTS(SELECT 1 FROM agency_memberships WHERE app_code = ? AND host_user_id = ? AND status = 'active')
OR EXISTS(SELECT 1 FROM role_invitations
WHERE app_code = ? AND target_user_id = ? AND status = 'pending'
AND invitation_type IN ('host', 'external_host'))
)`
default:
return xerr.New(xerr.InvalidArgument, "invitation_type is invalid")
}
var exists bool
args := []any{appcode.FromContext(ctx), userID, appcode.FromContext(ctx), userID}
if invitationType == hostdomain.InvitationTypeHost {
args = append(args, appcode.FromContext(ctx), userID)
}
if err := r.db.QueryRowContext(ctx, query, args...).Scan(&exists); err != nil {
return err
}
if exists {
return xerr.New(xerr.Conflict, fmt.Sprintf("target already has active %s identity", invitationType))
}
return nil
}

View File

@ -315,10 +315,13 @@ type roleInvitationCreatedPayload struct {
InviterUserID int64 `json:"inviter_user_id"`
InviterBDUserID int64 `json:"inviter_bd_user_id"`
TargetUserID int64 `json:"target_user_id"`
ExternalOperatorUserID int64 `json:"external_operator_user_id,omitempty"`
RegionID int64 `json:"region_id"`
AgencyName string `json:"agency_name"`
ParentBDUserID int64 `json:"parent_bd_user_id"`
ParentLeaderUserID int64 `json:"parent_leader_user_id"`
ParentOwnerUserID int64 `json:"parent_owner_user_id,omitempty"`
ParentAgencyID int64 `json:"parent_agency_id,omitempty"`
Inviter roleInvitationUserSnapshot `json:"inviter"`
Target roleInvitationUserSnapshot `json:"target"`
CreatedAtMS int64 `json:"created_at_ms"`
@ -340,10 +343,13 @@ func insertRoleInvitationCreatedOutbox(ctx context.Context, tx *sql.Tx, invitati
InviterUserID: invitation.InviterUserID,
InviterBDUserID: invitation.InviterBDUserID,
TargetUserID: invitation.TargetUserID,
ExternalOperatorUserID: invitation.ExternalOperatorUserID,
RegionID: invitation.RegionID,
AgencyName: invitation.AgencyName,
ParentBDUserID: invitation.ParentBDUserID,
ParentLeaderUserID: invitation.ParentLeaderUserID,
ParentOwnerUserID: invitation.ParentOwnerUserID,
ParentAgencyID: invitation.ParentAgencyID,
Inviter: inviter,
Target: target,
CreatedAtMS: invitation.CreatedAtMs,
@ -471,6 +477,25 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
return hostdomain.ProcessRoleInvitationResult{}, err
}
}
} else if invitation.InvitationType == hostdomain.InvitationTypeExternalAgency {
// 外管 Agency 邀请使用独立类型和授权校验;旧 agency 分支不读取 external_* 元数据。
hostProfileChanged, membershipCreated, err := r.acceptExternalAgencyInvitation(ctx, tx, command, &invitation, &result)
if err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
if hostProfileChanged {
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", invitation.TargetUserID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
}
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", command.AgencyID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
if membershipCreated {
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
}
} else if invitation.InvitationType == hostdomain.InvitationTypeBD {
// BD 邀请的接受只创建 bd_profileBD 不需要 host_profile。
if err := r.acceptBDInvitation(ctx, tx, command, &invitation, &result, regionMatchRequired); err != nil {
@ -479,6 +504,14 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", invitation.TargetUserID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
} else if invitation.InvitationType == hostdomain.InvitationTypeExternalBD {
// 外管 BD 只写 parent_owner_user_id不进入 App 的 parent_leader_user_id 树。
if err := r.acceptExternalBDInvitation(ctx, tx, command, &invitation, &result); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", invitation.TargetUserID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
} else if invitation.InvitationType == hostdomain.InvitationTypeHost {
// Host 邀请只创建或复用 Host 身份,并把当前成员关系挂到发起 Agency。
hostProfileCreated, err := r.acceptHostInvitation(ctx, tx, command, &invitation, &result)
@ -493,6 +526,20 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
} else if invitation.InvitationType == hostdomain.InvitationTypeExternalHost {
// 外管 Host 先验证固化的 Agency再走和普通 Host 相同的身份及 membership 原子写入。
hostProfileCreated, err := r.acceptExternalHostInvitation(ctx, tx, command, &invitation, &result)
if err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
if hostProfileCreated {
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", invitation.TargetUserID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
}
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err
}
} else {
return hostdomain.ProcessRoleInvitationResult{}, xerr.New(xerr.InvalidArgument, "invitation type is invalid")
}
@ -704,3 +751,75 @@ func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command
result.BDProfile = bd
return nil
}
func (r *Repository) acceptExternalAgencyInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, bool, error) {
// 外管邀请先按固化的 operator/owner/BD/region 快照重验授权,再复用 Agency 事实的原子落库流程。
// 普通 invitation_type=agency 的接受函数不读取任何 external_* 字段,确保旧 App 链路保持原语义。
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
if err != nil {
return false, false, err
}
if err := validateExternalAgencyInvitationAcceptance(ctx, tx, *invitation, targetRegionID); err != nil {
return false, false, err
}
return r.acceptAgencyInvitation(ctx, tx, command, invitation, result, true)
}
func (r *Repository) acceptExternalHostInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) {
// 外管 Host 邀请固化的是明确 agency_id接受时先确认 Agency 及其 BD owner 都未漂移。
// 旧 Host 落库函数只认 Agency owner因此仅在局部副本中换成已验证的 owner不修改邀请审计事实。
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
if err != nil {
return false, err
}
agency, err := validateExternalHostInvitationAcceptance(ctx, tx, *invitation, targetRegionID)
if err != nil {
return false, err
}
materializationInvitation := *invitation
materializationInvitation.InviterUserID = agency.OwnerUserID
return r.acceptHostInvitation(ctx, tx, command, &materializationInvitation, result)
}
func (r *Repository) acceptExternalBDInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) error {
// 外管 BD 不属于 App 的 BD Leader 树parent_leader_user_id 必须为空,
// 只有 parent_owner_user_id 记录外管团队归属,避免 Manager/BD Leader 查询被新关系污染。
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
if err != nil {
return err
}
if err := validateExternalBDInvitationAcceptance(ctx, tx, *invitation, targetRegionID); err != nil {
return err
}
if existing, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil {
return err
} else if ok {
if existing.Status == hostdomain.BDStatusActive {
return xerr.New(xerr.Conflict, "target already has bd profile")
}
existing.ParentLeaderUserID = 0
existing.ParentOwnerUserID = invitation.ParentOwnerUserID
existing.Status = hostdomain.BDStatusActive
existing.UpdatedAtMs = command.NowMs
if err := updateExternalBDProfileForActivation(ctx, tx, existing); err != nil {
return err
}
result.BDProfile = existing
return nil
}
bd := hostdomain.BDProfile{
UserID: invitation.TargetUserID,
Role: hostdomain.BDRoleBD,
RegionID: targetRegionID,
ParentOwnerUserID: invitation.ParentOwnerUserID,
Status: hostdomain.BDStatusActive,
CreatedByUserID: invitation.ExternalOperatorUserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := insertExternalBDProfile(ctx, tx, bd); err != nil {
return mapHostDuplicateError(err)
}
result.BDProfile = bd
return nil
}

View File

@ -923,6 +923,7 @@ func scanBDProfile(scanner rowScanner) (hostdomain.BDProfile, error) {
&profile.Role,
&profile.RegionID,
&profile.ParentLeaderUserID,
&profile.ParentOwnerUserID,
&profile.Status,
&profile.CreatedByUserID,
&profile.CreatedAtMs,
@ -1112,10 +1113,13 @@ func scanRoleInvitation(scanner rowScanner) (hostdomain.RoleInvitation, error) {
&invitation.InviterUserID,
&invitation.InviterBDUserID,
&invitation.TargetUserID,
&invitation.ExternalOperatorUserID,
&invitation.RegionID,
&invitation.AgencyName,
&invitation.ParentBDUserID,
&invitation.ParentLeaderUserID,
&invitation.ParentOwnerUserID,
&invitation.ParentAgencyID,
&invitation.ProcessedByUserID,
&invitation.ProcessReason,
&invitation.ProcessedAtMs,
@ -1170,7 +1174,7 @@ func processResultByInvitationID(ctx context.Context, q sqlQueryer, invitationID
// 只有接受动作会创建后续身份事实;按邀请类型回填前端需要立即刷新的最新关系。
switch invitation.InvitationType {
case hostdomain.InvitationTypeAgency:
case hostdomain.InvitationTypeAgency, hostdomain.InvitationTypeExternalAgency:
if profile, err := queryHostProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil {
result.HostProfile = profile
if profile.CurrentMembershipID > 0 {
@ -1182,11 +1186,11 @@ func processResultByInvitationID(ctx context.Context, q sqlQueryer, invitationID
} else if ok {
result.Agency = agency
}
case hostdomain.InvitationTypeBD:
case hostdomain.InvitationTypeBD, hostdomain.InvitationTypeExternalBD:
if bd, err := queryBDProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil {
result.BDProfile = bd
}
case hostdomain.InvitationTypeHost:
case hostdomain.InvitationTypeHost, hostdomain.InvitationTypeExternalHost:
if profile, err := queryHostProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil {
result.HostProfile = profile
if profile.CurrentMembershipID > 0 {

View File

@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
"reflect"
"strings"
"testing"
@ -80,3 +81,59 @@ func TestRoleScopePolicyMigrationOnlyMaterializesLegacyGlobalTenant(t *testing.T
}
}
}
func TestExternalTeamCTEEnforcesOwnerAndBDCurrentRegionWithoutDroppingBannedMembers(t *testing.T) {
ctx := appcode.WithContext(context.Background(), "tenant-external")
query, args := externalTeamCTE(ctx, []int64{101, 202}, 77)
wantArgs := []any{
int64(77),
int64(77),
"tenant-external",
int64(101),
int64(202),
"active",
"tenant-external",
"active",
"tenant-external",
"active",
}
if !reflect.DeepEqual(args, wantArgs) {
t.Fatalf("external team CTE args mismatch: got=%#v want=%#v", args, wantArgs)
}
for _, snippet := range []string{
"parent_owner_user_id in (?,?)",
"parent_leader_user_id is null",
"scope_owner.status = 'active' and scope_owner.region_id = ?",
"bd_user.region_id = ?",
"m.membership_type = 'member'",
} {
if !strings.Contains(strings.ToLower(query), snippet) {
t.Fatalf("external team CTE missing fail-closed boundary %q", snippet)
}
}
if strings.Contains(strings.ToLower(query), "bd_user.status = 'active'") {
t.Fatal("team traversal must retain banned/disabled BD so its owner can still manage the BD and descendants")
}
}
func TestExternalTeamMigrationLeavesLegacyOwnerEmptyAndDeclaresQueryIndexes(t *testing.T) {
body, err := os.ReadFile("../../../../deploy/mysql/migrations/024_external_admin_team_scope.sql")
if err != nil {
t.Fatalf("read external team migration: %v", err)
}
sqlText := strings.ToLower(string(body))
for _, snippet := range []string{
"idx_bd_profiles_parent_owner (app_code, parent_owner_user_id, status, user_id)",
"idx_role_invitations_parent_owner (app_code, parent_owner_user_id, status, created_at_ms)",
"algorithm=instant",
"algorithm=inplace",
"lock=none",
} {
if !strings.Contains(sqlText, snippet) {
t.Fatalf("external team migration missing %q", snippet)
}
}
if strings.Contains(sqlText, "set parent_owner_user_id = parent_leader_user_id") {
t.Fatal("external migration must not backfill Lalu BD Leader ownership into the external owner column")
}
}

View File

@ -157,17 +157,6 @@ func updateHostCurrentMembership(ctx context.Context, tx *sql.Tx, profile hostdo
return err
}
func disableHostProfileAfterAgencyKick(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
// Agency 移除普通 Host 的产品语义是收回主播身份;历史行保留给审计和后续重新开通复用。
// 当前归属必须同步清空,否则 App 入口和送礼结算会继续把 ended membership 当作有效关系。
_, err := tx.ExecContext(ctx, `
UPDATE host_profiles
SET status = ?, current_agency_id = NULL, current_membership_id = NULL, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func updateHostProfileForMembership(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
// 恢复 disabled Host 或修复历史指针时,需要同时写 status 和当前归属;
// source/first_became_host_at_ms 仍然保留首次成为 Host 的历史语义。
@ -230,6 +219,18 @@ func insertBDProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfi
return err
}
func insertExternalBDProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// 外管 BD 与普通 App BD 共用身份事实,但仅专用入口写 parent_owner_user_id
// parent_leader_user_id 固定为空,确保 Manager/BD Leader 的既有树查询不会纳入外管关系。
_, err := tx.ExecContext(ctx, `
INSERT INTO bd_profiles (
app_code, user_id, role, parent_leader_user_id, parent_owner_user_id, status,
created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), profile.UserID, profile.Role, profile.ParentOwnerUserID, profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
return err
}
func insertBDLeaderProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// BD Leader 使用独立表承载负责人身份,避免和普通 BD 工资、状态、团队归属混成同一条事实。
_, err := tx.ExecContext(ctx, `
@ -262,6 +263,17 @@ func updateBDProfileForActivation(ctx context.Context, tx *sql.Tx, profile hostd
return err
}
func updateExternalBDProfileForActivation(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// 只有 external_bd 接受分支能把 disabled BD 重新绑定为外管关系。
// 同一语句清空旧 parent_leader避免一条 active BD 同时出现在 App Leader 树和外管 owner 树。
_, err := tx.ExecContext(ctx, `
UPDATE bd_profiles
SET parent_leader_user_id = NULL, parent_owner_user_id = ?, status = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND role = 'bd'
`, profile.ParentOwnerUserID, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
return err
}
func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
// Leader 状态只影响负责人权限和 ADMIN_SALARY_USD 钱包入口,不影响同用户可能存在的普通 BD 身份。
_, err := tx.ExecContext(ctx, `
@ -428,6 +440,21 @@ func insertRoleInvitation(ctx context.Context, tx *sql.Tx, invitation hostdomain
return err
}
func insertExternalRoleInvitation(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation) error {
// 外管邀请使用独立 invitation_type 并把 operator/owner/Agency 快照一次性写入。
// 普通 InviteBD/Agency/Host 继续调用原 insertRoleInvitation完全不接触这些扩展列。
_, err := tx.ExecContext(ctx, `
INSERT INTO role_invitations (
app_code, invitation_id, command_id, invitation_type, status, inviter_user_id,
inviter_bd_user_id, target_user_id, external_operator_user_id, agency_name, parent_bd_user_id,
parent_leader_user_id, parent_owner_user_id, parent_agency_id,
processed_by_user_id, process_reason, processed_at_ms,
created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.FromContext(ctx), invitation.InvitationID, invitation.CommandID, invitation.InvitationType, invitation.Status, invitation.InviterUserID, invitation.InviterBDUserID, invitation.TargetUserID, invitation.ExternalOperatorUserID, invitation.AgencyName, invitation.ParentBDUserID, nullableRegionID(invitation.ParentLeaderUserID), invitation.ParentOwnerUserID, nullableRegionID(invitation.ParentAgencyID), nullableRegionID(invitation.ProcessedByUserID), invitation.ProcessReason, nullableRegionID(invitation.ProcessedAtMs), invitation.CreatedAtMs, invitation.UpdatedAtMs)
return err
}
func updateRoleInvitationProcessed(ctx context.Context, tx *sql.Tx, invitation hostdomain.RoleInvitation) error {
// 处理邀请时统一记录处理人、原因和时间,覆盖接受、拒绝和取消。
_, err := tx.ExecContext(ctx, `

View File

@ -595,7 +595,7 @@ func (r *Repository) PutUser(user userdomain.User) {
onboarding_status = VALUES(onboarding_status),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)
`, user.AppCode, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableInt64(user.CurrentDisplayUserIDExpiresAtMs), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableInt64(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.ProfileBgImg), strings.TrimSpace(user.ContactInfo), strings.TrimSpace(user.WithdrawUSDTTRC20Address), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, string(user.OnboardingStatus), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
`, user.AppCode, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableInt64(user.CurrentDisplayUserIDExpiresAtMs), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableInt64(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), strings.TrimSpace(user.ProfileBgImg), strings.TrimSpace(user.ContactInfo), strings.TrimSpace(user.WithdrawUSDTTRC20Address), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, string(user.OnboardingStatus), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
if err != nil {
r.t.Fatalf("seed user %d failed: %v", user.UserID, err)
}
@ -848,15 +848,16 @@ func (r *Repository) PutBDProfile(profile hostdomain.BDProfile) {
_, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO bd_profiles (
user_id, role, parent_leader_user_id, status,
user_id, role, parent_leader_user_id, parent_owner_user_id, status,
created_by_user_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
role = VALUES(role),
parent_leader_user_id = VALUES(parent_leader_user_id),
parent_owner_user_id = VALUES(parent_owner_user_id),
status = VALUES(status),
updated_at_ms = VALUES(updated_at_ms)
`, profile.UserID, profile.Role, nullableInt64(profile.ParentLeaderUserID), profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
`, profile.UserID, profile.Role, nullableInt64(profile.ParentLeaderUserID), nullableInt64(profile.ParentOwnerUserID), profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs)
if err != nil {
r.t.Fatalf("seed bd profile %d failed: %v", profile.UserID, err)
}

View File

@ -514,6 +514,7 @@ func toProtoBDProfile(profile hostdomain.BDProfile) *userv1.BDProfile {
Role: profile.Role,
RegionId: profile.RegionID,
ParentLeaderUserId: profile.ParentLeaderUserID,
ParentOwnerUserId: profile.ParentOwnerUserID,
Status: profile.Status,
CreatedByUserId: profile.CreatedByUserID,
CreatedAtMs: profile.CreatedAtMs,
@ -698,10 +699,13 @@ func toProtoRoleInvitation(invitation hostdomain.RoleInvitation) *userv1.RoleInv
InviterUserId: invitation.InviterUserID,
InviterBdUserId: invitation.InviterBDUserID,
TargetUserId: invitation.TargetUserID,
ExternalOperatorUserId: invitation.ExternalOperatorUserID,
RegionId: invitation.RegionID,
AgencyName: invitation.AgencyName,
ParentBdUserId: invitation.ParentBDUserID,
ParentLeaderUserId: invitation.ParentLeaderUserID,
ParentOwnerUserId: invitation.ParentOwnerUserID,
ParentAgencyId: invitation.ParentAgencyID,
ProcessedByUserId: invitation.ProcessedByUserID,
ProcessReason: invitation.ProcessReason,
ProcessedAtMs: invitation.ProcessedAtMs,
@ -709,3 +713,23 @@ func toProtoRoleInvitation(invitation hostdomain.RoleInvitation) *userv1.RoleInv
UpdatedAtMs: invitation.UpdatedAtMs,
}
}
// toProtoExternalTeamUser 保留长 ID、默认短号和当前短号避免外管列表再回查或把靓号误当内部 ID。
func toProtoExternalTeamUser(user hostdomain.ExternalTeamUser) *userv1.ExternalTeamUser {
if user.UserID == 0 {
return nil
}
return &userv1.ExternalTeamUser{
UserId: user.UserID,
DefaultDisplayUserId: user.DefaultDisplayUserID,
CurrentDisplayUserId: user.CurrentDisplayUserID,
Username: user.Username,
Avatar: user.Avatar,
Status: user.Status,
RegionId: user.RegionID,
Roles: append([]string(nil), user.Roles...),
ParentOwnerUserId: user.ParentOwnerUserID,
ParentBdUserId: user.ParentBDUserID,
AgencyId: user.AgencyID,
}
}

View File

@ -261,3 +261,110 @@ func (s *Server) SetAgencyJoinEnabled(ctx context.Context, req *userv1.SetAgency
}
return &userv1.SetAgencyJoinEnabledResponse{Agency: toProtoAgency(agency)}, nil
}
// ExternalInviteBD 创建外管操作者直属 BD 的待确认邀请。
func (s *Server) ExternalInviteBD(ctx context.Context, req *userv1.ExternalInviteBDRequest) (*userv1.ExternalInviteBDResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
invitation, err := s.hostSvc.ExternalInviteBD(ctx, hostservice.ExternalInviteBDInput{
CommandID: req.GetCommandId(),
OperatorUserID: req.GetOperatorUserId(),
TargetUserID: req.GetTargetUserId(),
ExpectedRegionID: req.GetExpectedRegionId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.ExternalInviteBDResponse{Invitation: toProtoRoleInvitation(invitation)}, nil
}
// ExternalInviteAgency 为外管团队内明确选择的 BD 创建 Agency 邀请。
func (s *Server) ExternalInviteAgency(ctx context.Context, req *userv1.ExternalInviteAgencyRequest) (*userv1.ExternalInviteAgencyResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
invitation, err := s.hostSvc.ExternalInviteAgency(ctx, hostservice.ExternalInviteAgencyInput{
CommandID: req.GetCommandId(),
OperatorUserID: req.GetOperatorUserId(),
ParentBDUserID: req.GetParentBdUserId(),
TargetUserID: req.GetTargetUserId(),
AgencyName: req.GetAgencyName(),
ScopeOwnerUserIDs: req.GetScopeOwnerUserIds(),
ExpectedRegionID: req.GetExpectedRegionId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.ExternalInviteAgencyResponse{Invitation: toProtoRoleInvitation(invitation)}, nil
}
// ExternalInviteHost 为外管团队内明确选择的 Agency 创建 Host 邀请。
func (s *Server) ExternalInviteHost(ctx context.Context, req *userv1.ExternalInviteHostRequest) (*userv1.ExternalInviteHostResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
invitation, err := s.hostSvc.ExternalInviteHost(ctx, hostservice.ExternalInviteHostInput{
CommandID: req.GetCommandId(),
OperatorUserID: req.GetOperatorUserId(),
AgencyID: req.GetAgencyId(),
TargetUserID: req.GetTargetUserId(),
ScopeOwnerUserIDs: req.GetScopeOwnerUserIds(),
ExpectedRegionID: req.GetExpectedRegionId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.ExternalInviteHostResponse{Invitation: toProtoRoleInvitation(invitation)}, nil
}
// ListExternalTeamUsers 返回外管 scope 和当前区域交集内的 BD/Agency/Host。
func (s *Server) ListExternalTeamUsers(ctx context.Context, req *userv1.ListExternalTeamUsersRequest) (*userv1.ListExternalTeamUsersResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
result, err := s.hostSvc.ListExternalTeamUsers(ctx, hostservice.ListExternalTeamUsersCommand{
ScopeOwnerUserIDs: req.GetScopeOwnerUserIds(),
RegionID: req.GetRegionId(),
Role: req.GetRole(),
Status: req.GetStatus(),
Keyword: req.GetKeyword(),
Page: int(req.GetPage()),
PageSize: int(req.GetPageSize()),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &userv1.ListExternalTeamUsersResponse{
Users: make([]*userv1.ExternalTeamUser, 0, len(result.Users)),
Total: result.Total,
Page: int32(result.Page),
PageSize: int32(result.PageSize),
}
for _, user := range result.Users {
resp.Users = append(resp.Users, toProtoExternalTeamUser(user))
}
return resp, nil
}
// SearchExternalInvitationCandidate 精确解析当前区域邀请候选,不暴露模糊用户目录。
func (s *Server) SearchExternalInvitationCandidate(ctx context.Context, req *userv1.SearchExternalInvitationCandidateRequest) (*userv1.SearchExternalInvitationCandidateResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
user, err := s.hostSvc.SearchExternalInvitationCandidate(ctx, hostservice.SearchExternalInvitationCandidateCommand{
OperatorUserID: req.GetOperatorUserId(),
ExpectedRegionID: req.GetExpectedRegionId(),
InvitationType: req.GetInvitationType(),
Keyword: req.GetKeyword(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.SearchExternalInvitationCandidateResponse{User: toProtoExternalTeamUser(user)}, nil
}

View File

@ -1658,7 +1658,8 @@ CREATE TABLE IF NOT EXISTS resource_grants (
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, grant_id),
UNIQUE KEY uk_resource_grants_command (app_code, command_id),
KEY idx_resource_grants_target_time (app_code, target_user_id, created_at_ms)
KEY idx_resource_grants_target_time (app_code, target_user_id, created_at_ms),
KEY idx_resource_grants_operator_source_time (app_code, grant_source, operator_user_id, created_at_ms DESC, grant_id DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资源发放表';
CREATE TABLE IF NOT EXISTS resource_grant_items (
@ -2211,7 +2212,8 @@ CREATE TABLE IF NOT EXISTS vip_benefit_resource_binding_history (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='VIP 权益资源绑定不可变历史';
-- user_vip_trial_cards 只保存体验卡的 VIP 元数据;卡片库存与当前佩戴仍分别复用
-- user_resource_entitlements 和 user_resource_equipment切换佩戴不会重置或作废其它卡片。
-- user_resource_entitlements 和 user_resource_equipment。普通切换不改卡片时钟购买等级
-- 大于等于当前体验等级时wallet 会在购买事务内回收当前卡片及其体验资源。
CREATE TABLE IF NOT EXISTS user_vip_trial_cards (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
trial_card_id VARCHAR(96) NOT NULL COMMENT '体验卡实例 ID',

View File

@ -0,0 +1,6 @@
-- 外管赠送历史固定按 app/source/operator 过滤,再按时间和 grant_id 稳定倒序。
-- 新索引避免分页 count/list 扫描整个 resource_grants执行前必须用真实外管 operator
-- 参数 EXPLAIN确认选择 idx_resource_grants_operator_source_time。
ALTER TABLE resource_grants
ADD INDEX idx_resource_grants_operator_source_time
(app_code, grant_source, operator_user_id, created_at_ms DESC, grant_id DESC);

View File

@ -85,4 +85,6 @@ type UserEquippedResources struct {
UserID int64
Resources []UserResourceEntitlement
AllowedVIPBenefitCodes []string
// EffectiveVIPLevel 仅在请求展示权益时返回,且与权益集合共享同一付费/体验卡只读快照。
EffectiveVIPLevel int32
}

View File

@ -77,6 +77,8 @@ type ListResourceGrantsQuery struct {
AppCode string
TargetUserID int64
Status string
OperatorUserIDs []int64
GrantSource string
Page int32
PageSize int32
}

View File

@ -7321,6 +7321,38 @@ func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) {
if grant.GrantSource != resourcedomain.GrantSourceManagerCenter || len(grant.Items) != 1 {
t.Fatalf("manager grant response mismatch: %+v", grant)
}
if _, err := svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-admin-grant-same-resource",
TargetUserID: 43002,
ResourceID: enabled.ResourceID,
Quantity: 1,
Reason: "main admin",
OperatorUserID: 92001,
GrantSource: resourcedomain.GrantSourceAdmin,
}); err != nil {
t.Fatalf("admin comparison grant failed: %v", err)
}
ownHistory, ownTotal, err := svc.ListResourceGrants(ctx, resourcedomain.ListResourceGrantsQuery{
GrantSource: resourcedomain.GrantSourceManagerCenter,
OperatorUserIDs: []int64{91001, 91001, 0},
Page: 1,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListResourceGrants own manager history failed: %v", err)
}
if ownTotal != 1 || len(ownHistory) != 1 || ownHistory[0].GrantID != grant.GrantID {
t.Fatalf("manager history must filter before pagination: total=%d items=%+v", ownTotal, ownHistory)
}
allHistory, allTotal, err := svc.ListResourceGrants(ctx, resourcedomain.ListResourceGrantsQuery{
Page: 1, PageSize: 20,
})
if err != nil {
t.Fatalf("ListResourceGrants unfiltered history failed: %v", err)
}
if allTotal != 2 || len(allHistory) != 2 {
t.Fatalf("omitted grant source must retain main-admin all-source history: total=%d items=%+v", allTotal, allHistory)
}
_, err = svc.GrantResource(ctx, resourcedomain.GrantResourceCommand{
CommandID: "cmd-manager-grant-disabled",
@ -7336,6 +7368,24 @@ func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) {
}
}
// TestManagerCenterVipTrialCardRejectsAboveVipSix locks the wallet-owner ceiling so
// future HTTP routes cannot bypass the external portal's VIP6 UI validation.
func TestManagerCenterVipTrialCardRejectsAboveVipSix(t *testing.T) {
svc := walletservice.New(mysqltest.NewRepository(t))
_, err := svc.GrantVipTrialCard(context.Background(), ledger.GrantVipTrialCardCommand{
CommandID: "manager-center-vip7-denied",
TargetUserID: 43003,
Level: 7,
DurationMS: int64(24 * time.Hour / time.Millisecond),
GrantSource: ledger.VipGrantSourceManagerCenter,
OperatorUserID: 91001,
Reason: "external trial",
})
if !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("manager center VIP7 must be rejected by wallet owner, got %v", err)
}
}
// TestGameRobotInitCanGrantActiveNonGrantableResource 锁定机器人初始化发放不跟随人工发放开关;
// 只要资源仍处于 active就允许机器人账号获得装扮避免运营库没有打开 grantable 时生成失败。
func TestGameRobotInitCanGrantActiveNonGrantableResource(t *testing.T) {
@ -8052,7 +8102,7 @@ func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) {
t.Fatalf("both vip grants should enqueue activation IM events, got %d", got)
}
managerGrant, err := svc.GrantVip(context.Background(), ledger.GrantVipCommand{
_, err = svc.GrantVip(context.Background(), ledger.GrantVipCommand{
CommandID: "cmd-manager-vip2",
TargetUserID: 51004,
Level: 2,
@ -8060,11 +8110,8 @@ func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) {
OperatorUserID: 9002,
Reason: "manager center vip grant",
})
if err != nil {
t.Fatalf("GrantVip manager center failed: %v", err)
}
if gotDuration := managerGrant.Vip.ExpiresAtMS - managerGrant.Vip.StartedAtMS; gotDuration != int64(30*24*time.Hour/time.Millisecond) {
t.Fatalf("manager center VIP grant duration mismatch: got %d receipt=%+v", gotDuration, managerGrant)
if !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("manager center direct VIP grant must be rejected in favor of trial cards: %v", err)
}
}

View File

@ -110,8 +110,13 @@ func (s *Service) GrantVip(ctx context.Context, command ledger.GrantVipCommand)
return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
}
command.GrantSource = ledger.NormalizeVipGrantSource(command.GrantSource)
if command.GrantSource == ledger.VipGrantSourceManagerCenter {
// 外管赠送只能生成可自主佩戴的体验卡,禁止内部调用方绕过 Portal
// 直接改变用户当前 VIP 会员状态。
return ledger.GrantVipReceipt{}, xerr.New(xerr.PermissionDenied, "manager center must grant a vip trial card")
}
switch command.GrantSource {
case ledger.VipGrantSourceAdmin, ledger.VipGrantSourceManagerCenter:
case ledger.VipGrantSourceAdmin:
if command.OperatorUserID <= 0 {
return ledger.GrantVipReceipt{}, xerr.New(xerr.InvalidArgument, "operator_user_id is required")
}
@ -160,6 +165,11 @@ func (s *Service) GrantVipTrialCard(ctx context.Context, command ledger.GrantVip
default:
return ledger.GrantVipTrialCardReceipt{}, xerr.New(xerr.InvalidArgument, "vip grant_source is invalid")
}
if command.GrantSource == ledger.VipGrantSourceManagerCenter && command.Level > 6 {
// 外管只能发 VIP1..VIP6 体验卡。限制放在账务 owner 内再次执行,
// 防止未来新增入口绕过 admin-server 的页面和 handler 校验。
return ledger.GrantVipTrialCardReceipt{}, xerr.New(xerr.PermissionDenied, "manager center can grant at most VIP6 trial card")
}
if command.Reason == "" {
command.Reason = command.GrantSource
}

View File

@ -52,6 +52,31 @@ func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
if _, _, err := service.UpdateAdminVipLevels(ctx, commands, 9001); err != nil {
t.Fatalf("enable Fami VIP levels failed: %v", err)
}
vipResources, _, err := service.ListResources(ctx, resourcedomain.ListResourcesQuery{
AppCode: "fami", ResourceType: resourcedomain.TypeVIPTrialCard,
ActiveOnly: true, ManagerGrantOnly: true, Page: 1, PageSize: 20,
})
if err != nil {
t.Fatalf("list Fami VIP trial resources failed: %v", err)
}
var vip7ResourceID int64
for _, resource := range vipResources {
if resource.ResourceCode == "vip_trial_card_7" {
vip7ResourceID = resource.ResourceID
break
}
}
if vip7ResourceID <= 0 {
t.Fatal("Fami VIP7 trial resource is missing")
}
if _, err := service.GrantResource(ctx, resourcedomain.GrantResourceCommand{
AppCode: "fami", CommandID: "manager-center-generic-vip7-denied",
TargetUserID: 880002, ResourceID: vip7ResourceID, Quantity: 1,
DurationMS: int64(24 * time.Hour / time.Millisecond), Reason: "external trial",
OperatorUserID: 9001, GrantSource: resourcedomain.GrantSourceManagerCenter,
}); !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("generic manager-center VIP7 must be rejected by wallet owner, got %v", err)
}
const userID int64 = 880001
repository.SetBalanceForApp("fami", userID, 10_000)
@ -287,6 +312,264 @@ func TestFamiVIPReplaceAndTrialCardSwitch(t *testing.T) {
}
}
// TestFamiVIPPurchaseReplacesCoveredTrialCard 锁定购买与体验卡的三条产品边界:
// 购买等级高于或等于体验等级时回收卡片及全部体验资源;低于体验等级时保留体验态,
// 付费会员独立计时并在体验卡自然失效后作为回落身份。
func TestFamiVIPPurchaseReplacesCoveredTrialCard(t *testing.T) {
repository := mysqltest.NewRepository(t)
service := walletservice.New(repository)
ctx := appcode.WithContext(context.Background(), "fami")
createBadge := func(code string) resourcedomain.Resource {
t.Helper()
resource, err := service.CreateResource(ctx, resourcedomain.ResourceCommand{
AppCode: "fami",
ResourceCode: code,
ResourceType: resourcedomain.TypeBadge,
Name: code,
Status: resourcedomain.StatusActive,
Grantable: true,
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
UsageScopes: []string{"profile"},
AssetURL: "https://cdn.example/" + code + ".png",
MetadataJSON: `{"badge_form":"strip","badge_kind":"normal"}`,
OperatorUserID: 9001,
})
if err != nil {
t.Fatalf("create %s failed: %v", code, err)
}
return resource
}
vip2Badge := createBadge("vip2_trial_replace_badge")
vip3Badge := createBadge("vip3_paid_replace_badge")
vip5Badge := createBadge("vip5_trial_keep_badge")
for _, binding := range []struct {
level int32
resource resourcedomain.Resource
}{
{level: 2, resource: vip2Badge},
{level: 3, resource: vip3Badge},
{level: 5, resource: vip5Badge},
} {
repository.SetVIPLevelStatusForApp("fami", binding.level, ledger.VipStatusActive, 100)
repository.BindVIPBenefitResourceForApp(
"fami",
binding.level,
ledger.VipBenefitCodeMedal,
binding.resource.ResourceID,
resourcedomain.TypeBadge,
true,
)
if got := repository.CountRows(
"vip_level_benefits",
"app_code = ? AND level = ? AND benefit_code = ? AND resource_id = ? AND resource_type = ?",
"fami", binding.level, ledger.VipBenefitCodeMedal, binding.resource.ResourceID, resourcedomain.TypeBadge,
); got != 1 {
t.Fatalf("VIP%d badge binding was not persisted: resource=%+v rows=%d", binding.level, binding.resource, got)
}
if got := repository.CountRows(
"resources",
"app_code = ? AND resource_id = ? AND resource_type = ? AND status = ?",
"fami", binding.resource.ResourceID, resourcedomain.TypeBadge, resourcedomain.StatusActive,
); got != 1 {
t.Fatalf("VIP%d badge resource was not active: resource=%+v rows=%d", binding.level, binding.resource, got)
}
}
_, levels, err := service.ListAdminVipLevels(ctx)
if err != nil {
t.Fatalf("list Fami VIP levels failed: %v", err)
}
var vip3DurationMS int64
for _, level := range levels {
if level.Level == 3 {
vip3DurationMS = level.DurationMS
break
}
}
if vip3DurationMS <= 0 {
t.Fatalf("VIP3 duration is not configured: %+v", levels)
}
equippedBadges := func(userID int64) []resourcedomain.UserResourceEntitlement {
t.Helper()
items, err := service.BatchGetUserEquippedResources(ctx, resourcedomain.BatchGetUserEquippedResourcesQuery{
AppCode: "fami", UserIDs: []int64{userID}, ResourceTypes: []string{resourcedomain.TypeBadge},
})
if err != nil {
t.Fatalf("list user %d equipped badges failed: %v", userID, err)
}
if len(items) != 1 {
t.Fatalf("user %d equipped badge projection mismatch: %+v", userID, items)
}
return items[0].Resources
}
const upgradedUserID int64 = 880011
repository.SetBalanceForApp("fami", upgradedUserID, 1_000)
vip2Card, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
AppCode: "fami", CommandID: "replace-trial-vip2", TargetUserID: upgradedUserID, Level: 2,
DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
OperatorUserID: 9001, Reason: "purchase replacement test",
})
if err != nil {
t.Fatalf("grant VIP2 trial card failed: %v", err)
}
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-replaced-vip2", UserID: upgradedUserID,
EntitlementID: vip2Card.TrialCard.EntitlementID,
}); err != nil {
t.Fatalf("equip VIP2 trial card failed: %v", err)
}
beforeReplace := equippedBadges(upgradedUserID)
if len(beforeReplace) != 1 || beforeReplace[0].ResourceID != vip2Badge.ResourceID {
t.Fatalf(
"VIP2 trial resource was not equipped before purchase: equipped=%+v trial_grants=%d active_entitlements=%d equipment=%d",
beforeReplace,
repository.CountRows("resource_grants", "app_code = ? AND target_user_id = ? AND grant_source = ?", "fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial),
repository.CountRows("user_resource_entitlements", "app_code = ? AND user_id = ? AND status = 'active'", "fami", upgradedUserID),
repository.CountRows("user_resource_equipment", "app_code = ? AND user_id = ?", "fami", upgradedUserID),
)
}
if got := repository.CountRows(
"resource_grants",
"app_code = ? AND target_user_id = ? AND grant_source = ? AND status = ?",
"fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial, resourcedomain.GrantStatusDone,
); got == 0 {
t.Fatal("equipped VIP2 trial card did not create a trial benefit grant")
}
purchasedAt := time.Now().UnixMilli()
upgrade, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
AppCode: "fami", CommandID: "buy-vip3-over-trial-vip2", UserID: upgradedUserID, Level: 3,
})
if err != nil {
t.Fatalf("purchase VIP3 over VIP2 trial failed: %v", err)
}
if upgrade.State.PaidVip.Level != 3 || upgrade.State.EffectiveSource != ledger.VipEffectiveSourcePaid ||
upgrade.State.EffectiveVip.Level != 3 || upgrade.State.EquippedTrialCard != nil {
t.Fatalf("VIP3 purchase must atomically replace VIP2 trial identity: %+v", upgrade.State)
}
if upgrade.Vip.StartedAtMS < purchasedAt-2_000 ||
upgrade.Vip.ExpiresAtMS-upgrade.Vip.StartedAtMS != vip3DurationMS {
t.Fatalf("paid VIP3 must start a fresh configured window without trial remainder: vip=%+v duration=%d", upgrade.Vip, vip3DurationMS)
}
if got := repository.CountRows(
"user_vip_trial_cards",
"app_code = ? AND trial_card_id = ? AND status = ?",
"fami", vip2Card.TrialCard.TrialCardID, ledger.VipTrialCardStatusRevoked,
); got != 1 {
t.Fatalf("replaced VIP2 trial card must be revoked, got %d", got)
}
if got := repository.CountRows(
"user_resource_entitlements",
"app_code = ? AND entitlement_id = ? AND status = ? AND remaining_quantity = 0",
"fami", vip2Card.TrialCard.EntitlementID, resourcedomain.GrantStatusRevoked,
); got != 1 {
t.Fatalf("replaced VIP2 card entitlement must be recovered, got %d", got)
}
if got := repository.CountRows(
"user_resource_entitlements",
`app_code = ? AND user_id = ? AND status = 'active' AND source_grant_id IN (
SELECT grant_id FROM resource_grants
WHERE app_code = ? AND target_user_id = ? AND grant_source = ?
)`,
"fami", upgradedUserID, "fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial,
); got != 0 {
t.Fatalf("replaced VIP2 trial resources must not remain active, got %d", got)
}
if got := repository.CountRows(
"user_resource_equipment",
`app_code = ? AND user_id = ? AND entitlement_id IN (
SELECT entitlement_id FROM user_resource_entitlements
WHERE app_code = ? AND user_id = ? AND source_grant_id IN (
SELECT grant_id FROM resource_grants
WHERE app_code = ? AND target_user_id = ? AND grant_source = ?
)
)`,
"fami", upgradedUserID, "fami", upgradedUserID,
"fami", upgradedUserID, resourcedomain.GrantSourceVIPTrial,
); got != 0 {
t.Fatalf("replaced VIP2 trial equipment must be removed, got %d", got)
}
afterReplace := equippedBadges(upgradedUserID)
if len(afterReplace) != 1 || afterReplace[0].ResourceID != vip3Badge.ResourceID {
t.Fatalf("paid VIP3 resource must replace the worn VIP2 trial resource: %+v", afterReplace)
}
const equalUserID int64 = 880012
repository.SetBalanceForApp("fami", equalUserID, 1_000)
vip3Card, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
AppCode: "fami", CommandID: "replace-trial-vip3", TargetUserID: equalUserID, Level: 3,
DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
OperatorUserID: 9001, Reason: "equal purchase replacement test",
})
if err != nil {
t.Fatalf("grant equal VIP3 trial card failed: %v", err)
}
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-replaced-vip3", UserID: equalUserID,
EntitlementID: vip3Card.TrialCard.EntitlementID,
}); err != nil {
t.Fatalf("equip equal VIP3 trial card failed: %v", err)
}
equalPurchase, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
AppCode: "fami", CommandID: "buy-vip3-over-trial-vip3", UserID: equalUserID, Level: 3,
})
if err != nil {
t.Fatalf("purchase VIP3 over equal trial failed: %v", err)
}
if equalPurchase.State.EffectiveSource != ledger.VipEffectiveSourcePaid ||
equalPurchase.State.EquippedTrialCard != nil ||
repository.CountRows(
"user_vip_trial_cards",
"app_code = ? AND trial_card_id = ? AND status = ?",
"fami", vip3Card.TrialCard.TrialCardID, ledger.VipTrialCardStatusRevoked,
) != 1 {
t.Fatalf("equal paid level must replace and recover the trial card: %+v", equalPurchase.State)
}
const lowerPaidUserID int64 = 880013
repository.SetBalanceForApp("fami", lowerPaidUserID, 1_000)
vip5Card, err := service.GrantVipTrialCard(ctx, ledger.GrantVipTrialCardCommand{
AppCode: "fami", CommandID: "keep-trial-vip5", TargetUserID: lowerPaidUserID, Level: 5,
DurationMS: int64(14 * 24 * time.Hour / time.Millisecond), GrantSource: ledger.VipGrantSourceAdmin,
OperatorUserID: 9001, Reason: "lower paid purchase test",
})
if err != nil {
t.Fatalf("grant VIP5 trial card failed: %v", err)
}
if _, _, err := service.EquipVipTrialCard(ctx, ledger.EquipVipTrialCardCommand{
AppCode: "fami", RequestID: "equip-kept-vip5", UserID: lowerPaidUserID,
EntitlementID: vip5Card.TrialCard.EntitlementID,
}); err != nil {
t.Fatalf("equip VIP5 trial card failed: %v", err)
}
lowerPurchase, err := service.PurchaseVip(ctx, ledger.PurchaseVipCommand{
AppCode: "fami", CommandID: "buy-vip3-under-trial-vip5", UserID: lowerPaidUserID, Level: 3,
})
if err != nil {
t.Fatalf("purchase VIP3 under VIP5 trial failed: %v", err)
}
if lowerPurchase.State.PaidVip.Level != 3 ||
lowerPurchase.State.EffectiveSource != ledger.VipEffectiveSourceTrial ||
lowerPurchase.State.EffectiveVip.Level != 5 ||
lowerPurchase.State.EquippedTrialCard == nil ||
lowerPurchase.State.EquippedTrialCard.EntitlementID != vip5Card.TrialCard.EntitlementID {
t.Fatalf("lower paid VIP must remain behind the higher trial identity: %+v", lowerPurchase.State)
}
if got := repository.CountRows(
"user_vip_trial_cards",
"app_code = ? AND trial_card_id = ? AND status = ?",
"fami", vip5Card.TrialCard.TrialCardID, ledger.VipTrialCardStatusActive,
); got != 1 {
t.Fatalf("higher VIP5 trial card must remain active, got %d", got)
}
keptTrialBadges := equippedBadges(lowerPaidUserID)
if len(keptTrialBadges) != 1 || keptTrialBadges[0].ResourceID != vip5Badge.ResourceID {
t.Fatalf("higher VIP5 trial resource must remain equipped: %+v", keptTrialBadges)
}
}
// TestVipPurchaseConcurrentReplayWithExactBalance 验证幂等守卫发生在账户/会员锁之前:
// 两个并发首发即使余额只够一单,也都返回同一成功回执,而不是让后到请求误报余额不足。
func TestVipPurchaseConcurrentReplayWithExactBalance(t *testing.T) {

Some files were not shown because too many files have changed in this diff Show More