fix: show region mismatch for role invitations

This commit is contained in:
zhx 2026-07-01 18:55:22 +08:00
parent 7d8ef54ca7
commit 2a7fff405d
6 changed files with 236 additions and 6 deletions

View File

@ -62,6 +62,7 @@ var catalog = map[Code]Spec{
RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"),
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "country already belongs to another region"),
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "region is disabled"),
RegionMismatch: spec(codes.PermissionDenied, httpStatusForbidden, RegionMismatch, "不是同一个地区"),
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid invite code"),
DeviceAlreadyRegistered: spec(
codes.FailedPrecondition,

View File

@ -69,6 +69,8 @@ const (
RegionCountryConflict Code = "REGION_COUNTRY_CONFLICT"
// RegionDisabled 表示区域已经停用,不能继续配置国家。
RegionDisabled Code = "REGION_DISABLED"
// RegionMismatch 表示当前动作要求双方同一区域,但用户、组织或上级身份的实时区域不一致。
RegionMismatch Code = "REGION_MISMATCH"
// InvalidInviteCode 表示邀请码不存在、停用、跨 App、邀请人不可用或用户尝试自邀。
InvalidInviteCode Code = "INVALID_INVITE_CODE"
// DeviceAlreadyRegistered 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。

View File

@ -94,6 +94,14 @@ func TestCatalogMappings(t *testing.T) {
publicCode: string(DeviceAlreadyRegistered),
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 {

View File

@ -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) {
hostClient := &fakeUserHostClient{
roleInvitationsResp: &userv1.ListRoleInvitationsResponse{Invitations: []*userv1.RoleInvitation{{

View File

@ -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) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)

View File

@ -45,7 +45,7 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
return hostdomain.RoleInvitation{}, err
}
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
// 但接受邀请时不会覆盖原 Host 当前归属。一个用户只能拥有一个有效 Agency这里仍先行拦截。
@ -131,7 +131,7 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
return hostdomain.RoleInvitation{}, err
}
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避免历史停用记录阻断重新授权。
@ -209,7 +209,7 @@ func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteH
return hostdomain.RoleInvitation{}, err
}
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 归属”的硬约束,接受时还会再次校验当前状态。
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
}
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。
// 这里仍锁住 active membership避免接受邀请期间另一条入会流程改写当前关系。
@ -580,7 +580,7 @@ func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, comma
return false, err
}
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 身份,覆盖邀请发出后用户已经加入或创建团队的情况。
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
}
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_profileactive BD 不能被覆盖,
// disabled BD 不算当前权限,接受邀请时恢复并绑定到本次 Leader。