From 2a7fff405db825660f43b9f4bc23ddb8c5fed92b Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 1 Jul 2026 18:55:22 +0800 Subject: [PATCH] fix: show region mismatch for role invitations --- pkg/xerr/catalog.go | 1 + pkg/xerr/errors.go | 2 + pkg/xerr/grpc_test.go | 8 + .../internal/transport/http/response_test.go | 78 ++++++++++ .../internal/service/host/service_test.go | 141 ++++++++++++++++++ .../storage/mysql/host/invitations.go | 12 +- 6 files changed, 236 insertions(+), 6 deletions(-) diff --git a/pkg/xerr/catalog.go b/pkg/xerr/catalog.go index 78b6d78a..85c3dc00 100644 --- a/pkg/xerr/catalog.go +++ b/pkg/xerr/catalog.go @@ -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, diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index 240feb17..db2db022 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -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 表示同一注册设备的账号数量已经达到注册上限,不能继续创建新账号。 diff --git a/pkg/xerr/grpc_test.go b/pkg/xerr/grpc_test.go index 7fb14fc5..8f48e6ec 100644 --- a/pkg/xerr/grpc_test.go +++ b/pkg/xerr/grpc_test.go @@ -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 { diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index f5f7d0e0..06980463 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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{{ diff --git a/services/user-service/internal/service/host/service_test.go b/services/user-service/internal/service/host/service_test.go index 17cf9e79..e7da1141 100644 --- a/services/user-service/internal/service/host/service_test.go +++ b/services/user-service/internal/service/host/service_test.go @@ -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) diff --git a/services/user-service/internal/storage/mysql/host/invitations.go b/services/user-service/internal/storage/mysql/host/invitations.go index 11bda917..4f0674ec 100644 --- a/services/user-service/internal/storage/mysql/host/invitations.go +++ b/services/user-service/internal/storage/mysql/host/invitations.go @@ -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_profile;active BD 不能被覆盖, // disabled BD 不算当前权限,接受邀请时恢复并绑定到本次 Leader。