fix: show region mismatch for role invitations
This commit is contained in:
parent
7d8ef54ca7
commit
2a7fff405d
@ -62,6 +62,7 @@ var catalog = map[Code]Spec{
|
|||||||
RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"),
|
RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"),
|
||||||
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "country already belongs to another region"),
|
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "country already belongs to another region"),
|
||||||
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "region is disabled"),
|
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "region is disabled"),
|
||||||
|
RegionMismatch: spec(codes.PermissionDenied, httpStatusForbidden, RegionMismatch, "不是同一个地区"),
|
||||||
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid invite code"),
|
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid invite code"),
|
||||||
DeviceAlreadyRegistered: spec(
|
DeviceAlreadyRegistered: spec(
|
||||||
codes.FailedPrecondition,
|
codes.FailedPrecondition,
|
||||||
|
|||||||
@ -69,6 +69,8 @@ const (
|
|||||||
RegionCountryConflict Code = "REGION_COUNTRY_CONFLICT"
|
RegionCountryConflict Code = "REGION_COUNTRY_CONFLICT"
|
||||||
// RegionDisabled 表示区域已经停用,不能继续配置国家。
|
// RegionDisabled 表示区域已经停用,不能继续配置国家。
|
||||||
RegionDisabled Code = "REGION_DISABLED"
|
RegionDisabled Code = "REGION_DISABLED"
|
||||||
|
// RegionMismatch 表示当前动作要求双方同一区域,但用户、组织或上级身份的实时区域不一致。
|
||||||
|
RegionMismatch Code = "REGION_MISMATCH"
|
||||||
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
|
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
|
||||||
InvalidInviteCode Code = "INVALID_INVITE_CODE"
|
InvalidInviteCode Code = "INVALID_INVITE_CODE"
|
||||||
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。
|
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。
|
||||||
|
|||||||
@ -94,6 +94,14 @@ func TestCatalogMappings(t *testing.T) {
|
|||||||
publicCode: string(DeviceAlreadyRegistered),
|
publicCode: string(DeviceAlreadyRegistered),
|
||||||
publicMessage: "device already registered",
|
publicMessage: "device already registered",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "region mismatch keeps forbidden transport but exposes concrete app prompt",
|
||||||
|
code: RegionMismatch,
|
||||||
|
grpcCode: codes.PermissionDenied,
|
||||||
|
httpStatus: httpStatusForbidden,
|
||||||
|
publicCode: string(RegionMismatch),
|
||||||
|
publicMessage: "不是同一个地区",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
|
|||||||
@ -5474,6 +5474,84 @@ func TestAgencyCenterInviteHostCreatesPendingInvitation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationRegionMismatchEnvelopeUsesConcreteMessage(t *testing.T) {
|
||||||
|
regionErr := xerr.ToGRPCError(xerr.New(xerr.RegionMismatch, "target region does not match inviter region"))
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
body string
|
||||||
|
hostSetup func() *fakeUserHostClient
|
||||||
|
reached func(*fakeUserHostClient) bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "agency_owner_invite_host",
|
||||||
|
path: "/api/v1/agency-center/invitations/host",
|
||||||
|
body: `{"command_id":"cmd-host-region","target_user_id":"100"}`,
|
||||||
|
hostSetup: func() *fakeUserHostClient {
|
||||||
|
return &fakeUserHostClient{
|
||||||
|
roleSummary: &userv1.UserRoleSummary{UserId: 42, IsAgency: true, AgencyId: 7001},
|
||||||
|
getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}},
|
||||||
|
inviteHostErr: regionErr,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reached: func(client *fakeUserHostClient) bool { return client.lastInviteHost != nil },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bd_invite_agency",
|
||||||
|
path: "/api/v1/bd/invitations/agency",
|
||||||
|
body: `{"command_id":"cmd-agency-region","target_user_id":"100","agency_name":"Target Agency"}`,
|
||||||
|
hostSetup: func() *fakeUserHostClient {
|
||||||
|
return &fakeUserHostClient{
|
||||||
|
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
|
||||||
|
inviteAgencyErr: regionErr,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reached: func(client *fakeUserHostClient) bool { return client.lastInviteAgency != nil },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bd_leader_invite_bd",
|
||||||
|
path: "/api/v1/bd-leader/invitations/bd",
|
||||||
|
body: `{"command_id":"cmd-bd-region","target_user_id":"100"}`,
|
||||||
|
hostSetup: func() *fakeUserHostClient {
|
||||||
|
return &fakeUserHostClient{
|
||||||
|
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 1001},
|
||||||
|
inviteBDErr: regionErr,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reached: func(client *fakeUserHostClient) bool { return client.lastInviteBD != nil },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "target_accept_invitation_after_region_changed",
|
||||||
|
path: "/api/v1/role-invitations/9011/process",
|
||||||
|
body: `{"command_id":"cmd-accept-region","action":"accept"}`,
|
||||||
|
hostSetup: func() *fakeUserHostClient {
|
||||||
|
return &fakeUserHostClient{processInviteErr: regionErr}
|
||||||
|
},
|
||||||
|
reached: func(client *fakeUserHostClient) bool { return client.lastProcessInvite != nil },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
hostClient := test.hostSetup()
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
|
handler.SetUserHostClient(hostClient)
|
||||||
|
router := handler.Routes(auth.NewVerifier("secret"))
|
||||||
|
request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body)))
|
||||||
|
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||||
|
request.Header.Set("X-Request-ID", "req-"+test.name)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
router.ServeHTTP(recorder, request)
|
||||||
|
|
||||||
|
assertEnvelopeMessage(t, recorder, http.StatusForbidden, string(xerr.RegionMismatch), "不是同一个地区", "req-"+test.name)
|
||||||
|
if !test.reached(hostClient) {
|
||||||
|
t.Fatalf("route did not reach role-invitation RPC for %s", test.name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRoleInvitationsListAndProcessForwardCurrentUser(t *testing.T) {
|
func TestRoleInvitationsListAndProcessForwardCurrentUser(t *testing.T) {
|
||||||
hostClient := &fakeUserHostClient{
|
hostClient := &fakeUserHostClient{
|
||||||
roleInvitationsResp: &userv1.ListRoleInvitationsResponse{Invitations: []*userv1.RoleInvitation{{
|
roleInvitationsResp: &userv1.ListRoleInvitationsResponse{Invitations: []*userv1.RoleInvitation{{
|
||||||
|
|||||||
@ -596,6 +596,147 @@ func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationCreateRejectsCrossRegionWithRegionMismatch(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
seedActiveUser(t, repository, 711, 10)
|
||||||
|
seedActiveUser(t, repository, 712, 20)
|
||||||
|
seedActiveUser(t, repository, 721, 10)
|
||||||
|
seedActiveUser(t, repository, 722, 20)
|
||||||
|
seedActiveUser(t, repository, 731, 10)
|
||||||
|
seedActiveUser(t, repository, 732, 20)
|
||||||
|
repository.PutBDProfile(hostdomain.BDProfile{
|
||||||
|
UserID: 711,
|
||||||
|
Role: hostdomain.BDRoleBD,
|
||||||
|
RegionID: 10,
|
||||||
|
Status: hostdomain.BDStatusActive,
|
||||||
|
})
|
||||||
|
repository.PutBDProfile(hostdomain.BDProfile{
|
||||||
|
UserID: 721,
|
||||||
|
Role: hostdomain.BDRoleLeader,
|
||||||
|
RegionID: 10,
|
||||||
|
Status: hostdomain.BDStatusActive,
|
||||||
|
})
|
||||||
|
repository.PutAgency(hostdomain.Agency{
|
||||||
|
AgencyID: 733,
|
||||||
|
OwnerUserID: 731,
|
||||||
|
RegionID: 10,
|
||||||
|
Status: hostdomain.AgencyStatusActive,
|
||||||
|
JoinEnabled: true,
|
||||||
|
})
|
||||||
|
svc := newHostService(repository, 5000, 6000)
|
||||||
|
|
||||||
|
// 三类 H5 邀请共用 RoleInvitation 事实;跨区属于明确业务边界,不应该退化成泛化权限拒绝。
|
||||||
|
if _, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
||||||
|
CommandID: "invite-agency-cross-region",
|
||||||
|
InviterUserID: 711,
|
||||||
|
TargetUserID: 712,
|
||||||
|
AgencyName: "Cross Region Agency",
|
||||||
|
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||||
|
t.Fatalf("cross-region agency invite should return REGION_MISMATCH: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
||||||
|
CommandID: "invite-bd-cross-region",
|
||||||
|
InviterUserID: 721,
|
||||||
|
TargetUserID: 722,
|
||||||
|
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||||
|
t.Fatalf("cross-region bd invite should return REGION_MISMATCH: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
|
||||||
|
CommandID: "invite-host-cross-region",
|
||||||
|
InviterUserID: 731,
|
||||||
|
TargetUserID: 732,
|
||||||
|
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||||
|
t.Fatalf("cross-region host invite should return REGION_MISMATCH: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoleInvitationAcceptRejectsRegionDriftWithRegionMismatch(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
seedActiveUser(t, repository, 741, 10)
|
||||||
|
seedActiveUser(t, repository, 742, 10)
|
||||||
|
seedActiveUser(t, repository, 751, 10)
|
||||||
|
seedActiveUser(t, repository, 752, 10)
|
||||||
|
seedActiveUser(t, repository, 761, 10)
|
||||||
|
seedActiveUser(t, repository, 762, 10)
|
||||||
|
repository.PutBDProfile(hostdomain.BDProfile{
|
||||||
|
UserID: 741,
|
||||||
|
Role: hostdomain.BDRoleBD,
|
||||||
|
RegionID: 10,
|
||||||
|
Status: hostdomain.BDStatusActive,
|
||||||
|
})
|
||||||
|
repository.PutBDProfile(hostdomain.BDProfile{
|
||||||
|
UserID: 751,
|
||||||
|
Role: hostdomain.BDRoleLeader,
|
||||||
|
RegionID: 10,
|
||||||
|
Status: hostdomain.BDStatusActive,
|
||||||
|
})
|
||||||
|
repository.PutAgency(hostdomain.Agency{
|
||||||
|
AgencyID: 763,
|
||||||
|
OwnerUserID: 761,
|
||||||
|
RegionID: 10,
|
||||||
|
Status: hostdomain.AgencyStatusActive,
|
||||||
|
JoinEnabled: true,
|
||||||
|
})
|
||||||
|
svc := newHostService(repository, 5100, 6100)
|
||||||
|
|
||||||
|
agencyInvite, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
||||||
|
CommandID: "invite-agency-before-drift",
|
||||||
|
InviterUserID: 741,
|
||||||
|
TargetUserID: 742,
|
||||||
|
AgencyName: "Drift Agency",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InviteAgency before region drift failed: %v", err)
|
||||||
|
}
|
||||||
|
bdInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
||||||
|
CommandID: "invite-bd-before-drift",
|
||||||
|
InviterUserID: 751,
|
||||||
|
TargetUserID: 752,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InviteBD before region drift failed: %v", err)
|
||||||
|
}
|
||||||
|
hostInvite, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
|
||||||
|
CommandID: "invite-host-before-drift",
|
||||||
|
InviterUserID: 761,
|
||||||
|
TargetUserID: 762,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InviteHost before region drift failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 邀请发出后用户可能被后台迁区;接受时必须重新读 users 当前区域,并给 H5 可解释的跨区错误。
|
||||||
|
seedActiveUser(t, repository, 742, 20)
|
||||||
|
seedActiveUser(t, repository, 752, 20)
|
||||||
|
seedActiveUser(t, repository, 762, 20)
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-agency-after-drift",
|
||||||
|
ActorUserID: 742,
|
||||||
|
InvitationID: agencyInvite.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||||
|
t.Fatalf("agency accept after region drift should return REGION_MISMATCH: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-bd-after-drift",
|
||||||
|
ActorUserID: 752,
|
||||||
|
InvitationID: bdInvite.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||||
|
t.Fatalf("bd accept after region drift should return REGION_MISMATCH: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-host-after-drift",
|
||||||
|
ActorUserID: 762,
|
||||||
|
InvitationID: hostInvite.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); xerr.CodeOf(err) != xerr.RegionMismatch {
|
||||||
|
t.Fatalf("host accept after region drift should return REGION_MISMATCH: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBatchGetHostProfilesReturnsExistingProfilesOnly(t *testing.T) {
|
func TestBatchGetHostProfilesReturnsExistingProfilesOnly(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
|
|||||||
@ -45,7 +45,7 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
|
|||||||
return hostdomain.RoleInvitation{}, err
|
return hostdomain.RoleInvitation{}, err
|
||||||
}
|
}
|
||||||
if targetRegionID != inviter.RegionID {
|
if targetRegionID != inviter.RegionID {
|
||||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match inviter region")
|
return hostdomain.RoleInvitation{}, xerr.New(xerr.RegionMismatch, "target region does not match inviter region")
|
||||||
}
|
}
|
||||||
// Agency owner 和 Host 成员归属是两套身份;目标已有 active Host 绑定时仍允许成为 Agency owner,
|
// Agency owner 和 Host 成员归属是两套身份;目标已有 active Host 绑定时仍允许成为 Agency owner,
|
||||||
// 但接受邀请时不会覆盖原 Host 当前归属。一个用户只能拥有一个有效 Agency,这里仍先行拦截。
|
// 但接受邀请时不会覆盖原 Host 当前归属。一个用户只能拥有一个有效 Agency,这里仍先行拦截。
|
||||||
@ -131,7 +131,7 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
|
|||||||
return hostdomain.RoleInvitation{}, err
|
return hostdomain.RoleInvitation{}, err
|
||||||
}
|
}
|
||||||
if targetRegionID != inviter.RegionID {
|
if targetRegionID != inviter.RegionID {
|
||||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region")
|
return hostdomain.RoleInvitation{}, xerr.New(xerr.RegionMismatch, "target region does not match leader region")
|
||||||
}
|
}
|
||||||
// 只有 active 普通 BD 才算已有权限;disabled 行可以重新发邀请,
|
// 只有 active 普通 BD 才算已有权限;disabled 行可以重新发邀请,
|
||||||
// 接受时会复用原主键行恢复 active,避免历史停用记录阻断重新授权。
|
// 接受时会复用原主键行恢复 active,避免历史停用记录阻断重新授权。
|
||||||
@ -209,7 +209,7 @@ func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteH
|
|||||||
return hostdomain.RoleInvitation{}, err
|
return hostdomain.RoleInvitation{}, err
|
||||||
}
|
}
|
||||||
if targetRegionID != agency.RegionID {
|
if targetRegionID != agency.RegionID {
|
||||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match agency region")
|
return hostdomain.RoleInvitation{}, xerr.New(xerr.RegionMismatch, "target region does not match agency region")
|
||||||
}
|
}
|
||||||
// Host 入会邀请不能绕过“一人一个有效 Agency 归属”的硬约束,接受时还会再次校验当前状态。
|
// Host 入会邀请不能绕过“一人一个有效 Agency 归属”的硬约束,接受时还会再次校验当前状态。
|
||||||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.TargetUserID, true); err != nil {
|
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.TargetUserID, true); err != nil {
|
||||||
@ -495,7 +495,7 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
|
|||||||
return false, false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
if targetRegionID != inviterRegionID {
|
if targetRegionID != inviterRegionID {
|
||||||
return false, false, xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
|
return false, false, xerr.New(xerr.RegionMismatch, "target region no longer matches invitation")
|
||||||
}
|
}
|
||||||
// 目标已有 Host 绑定时保留绑定,不再把 Host 当前归属切到自己新建的 Agency。
|
// 目标已有 Host 绑定时保留绑定,不再把 Host 当前归属切到自己新建的 Agency。
|
||||||
// 这里仍锁住 active membership,避免接受邀请期间另一条入会流程改写当前关系。
|
// 这里仍锁住 active membership,避免接受邀请期间另一条入会流程改写当前关系。
|
||||||
@ -580,7 +580,7 @@ func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, comma
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if targetRegionID != agency.RegionID {
|
if targetRegionID != agency.RegionID {
|
||||||
return false, xerr.New(xerr.PermissionDenied, "target region no longer matches agency")
|
return false, xerr.New(xerr.RegionMismatch, "target region no longer matches agency")
|
||||||
}
|
}
|
||||||
// 接受时再次检查有效归属和 Agency owner 身份,覆盖邀请发出后用户已经加入或创建团队的情况。
|
// 接受时再次检查有效归属和 Agency owner 身份,覆盖邀请发出后用户已经加入或创建团队的情况。
|
||||||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true); err != nil {
|
if _, ok, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true); err != nil {
|
||||||
@ -629,7 +629,7 @@ func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if targetRegionID != leaderRegionID {
|
if targetRegionID != leaderRegionID {
|
||||||
return xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
|
return xerr.New(xerr.RegionMismatch, "target region no longer matches invitation")
|
||||||
}
|
}
|
||||||
// BD 身份是独立角色事实,不依赖 host_profile;active BD 不能被覆盖,
|
// BD 身份是独立角色事实,不依赖 host_profile;active BD 不能被覆盖,
|
||||||
// disabled BD 不算当前权限,接受邀请时恢复并绑定到本次 Leader。
|
// disabled BD 不算当前权限,接受邀请时恢复并绑定到本次 Leader。
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user