feat: add role invitation confirmation flow

This commit is contained in:
zhx 2026-06-12 18:08:53 +08:00
parent 4b5c2931ea
commit b810bae232
17 changed files with 1333 additions and 377 deletions

File diff suppressed because it is too large Load Diff

View File

@ -222,6 +222,17 @@ message InviteBDResponse {
RoleInvitation invitation = 1;
}
message InviteHostRequest {
RequestMeta meta = 1;
string command_id = 2;
int64 inviter_user_id = 3;
int64 target_user_id = 4;
}
message InviteHostResponse {
RoleInvitation invitation = 1;
}
message ListBDLeaderBDsRequest {
RequestMeta meta = 1;
int64 leader_user_id = 2;
@ -255,6 +266,17 @@ message ListBDAgenciesResponse {
repeated Agency agencies = 1;
}
message ListRoleInvitationsRequest {
RequestMeta meta = 1;
int64 target_user_id = 2;
string status = 3;
int32 page_size = 4;
}
message ListRoleInvitationsResponse {
repeated RoleInvitation invitations = 1;
}
message ProcessRoleInvitationRequest {
RequestMeta meta = 1;
string command_id = 2;
@ -486,9 +508,11 @@ service UserHostService {
rpc KickAgencyHost(KickAgencyHostRequest) returns (KickAgencyHostResponse);
rpc InviteAgency(InviteAgencyRequest) returns (InviteAgencyResponse);
rpc InviteBD(InviteBDRequest) returns (InviteBDResponse);
rpc InviteHost(InviteHostRequest) returns (InviteHostResponse);
rpc ListBDLeaderBDs(ListBDLeaderBDsRequest) returns (ListBDLeaderBDsResponse);
rpc ListBDLeaderAgencies(ListBDLeaderAgenciesRequest) returns (ListBDLeaderAgenciesResponse);
rpc ListBDAgencies(ListBDAgenciesRequest) returns (ListBDAgenciesResponse);
rpc ListRoleInvitations(ListRoleInvitationsRequest) returns (ListRoleInvitationsResponse);
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);

View File

@ -25,9 +25,11 @@ const (
UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost"
UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency"
UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD"
UserHostService_InviteHost_FullMethodName = "/hyapp.user.v1.UserHostService/InviteHost"
UserHostService_ListBDLeaderBDs_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderBDs"
UserHostService_ListBDLeaderAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderAgencies"
UserHostService_ListBDAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDAgencies"
UserHostService_ListRoleInvitations_FullMethodName = "/hyapp.user.v1.UserHostService/ListRoleInvitations"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
@ -52,9 +54,11 @@ type UserHostServiceClient interface {
KickAgencyHost(ctx context.Context, in *KickAgencyHostRequest, opts ...grpc.CallOption) (*KickAgencyHostResponse, error)
InviteAgency(ctx context.Context, in *InviteAgencyRequest, opts ...grpc.CallOption) (*InviteAgencyResponse, error)
InviteBD(ctx context.Context, in *InviteBDRequest, opts ...grpc.CallOption) (*InviteBDResponse, error)
InviteHost(ctx context.Context, in *InviteHostRequest, opts ...grpc.CallOption) (*InviteHostResponse, error)
ListBDLeaderBDs(ctx context.Context, in *ListBDLeaderBDsRequest, opts ...grpc.CallOption) (*ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(ctx context.Context, in *ListBDLeaderAgenciesRequest, opts ...grpc.CallOption) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(ctx context.Context, in *ListBDAgenciesRequest, opts ...grpc.CallOption) (*ListBDAgenciesResponse, error)
ListRoleInvitations(ctx context.Context, in *ListRoleInvitationsRequest, opts ...grpc.CallOption) (*ListRoleInvitationsResponse, error)
ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
@ -135,6 +139,16 @@ func (c *userHostServiceClient) InviteBD(ctx context.Context, in *InviteBDReques
return out, nil
}
func (c *userHostServiceClient) InviteHost(ctx context.Context, in *InviteHostRequest, opts ...grpc.CallOption) (*InviteHostResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(InviteHostResponse)
err := c.cc.Invoke(ctx, UserHostService_InviteHost_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) ListBDLeaderBDs(ctx context.Context, in *ListBDLeaderBDsRequest, opts ...grpc.CallOption) (*ListBDLeaderBDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListBDLeaderBDsResponse)
@ -165,6 +179,16 @@ func (c *userHostServiceClient) ListBDAgencies(ctx context.Context, in *ListBDAg
return out, nil
}
func (c *userHostServiceClient) ListRoleInvitations(ctx context.Context, in *ListRoleInvitationsRequest, opts ...grpc.CallOption) (*ListRoleInvitationsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListRoleInvitationsResponse)
err := c.cc.Invoke(ctx, UserHostService_ListRoleInvitations_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userHostServiceClient) ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ProcessRoleInvitationResponse)
@ -277,9 +301,11 @@ type UserHostServiceServer interface {
KickAgencyHost(context.Context, *KickAgencyHostRequest) (*KickAgencyHostResponse, error)
InviteAgency(context.Context, *InviteAgencyRequest) (*InviteAgencyResponse, error)
InviteBD(context.Context, *InviteBDRequest) (*InviteBDResponse, error)
InviteHost(context.Context, *InviteHostRequest) (*InviteHostResponse, error)
ListBDLeaderBDs(context.Context, *ListBDLeaderBDsRequest) (*ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error)
ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error)
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
@ -318,6 +344,9 @@ func (UnimplementedUserHostServiceServer) InviteAgency(context.Context, *InviteA
func (UnimplementedUserHostServiceServer) InviteBD(context.Context, *InviteBDRequest) (*InviteBDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method InviteBD not implemented")
}
func (UnimplementedUserHostServiceServer) InviteHost(context.Context, *InviteHostRequest) (*InviteHostResponse, error) {
return nil, status.Error(codes.Unimplemented, "method InviteHost not implemented")
}
func (UnimplementedUserHostServiceServer) ListBDLeaderBDs(context.Context, *ListBDLeaderBDsRequest) (*ListBDLeaderBDsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBDLeaderBDs not implemented")
}
@ -327,6 +356,9 @@ func (UnimplementedUserHostServiceServer) ListBDLeaderAgencies(context.Context,
func (UnimplementedUserHostServiceServer) ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBDAgencies not implemented")
}
func (UnimplementedUserHostServiceServer) ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListRoleInvitations not implemented")
}
func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessRoleInvitation not implemented")
}
@ -486,6 +518,24 @@ func _UserHostService_InviteBD_Handler(srv interface{}, ctx context.Context, dec
return interceptor(ctx, in, info, handler)
}
func _UserHostService_InviteHost_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(InviteHostRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).InviteHost(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_InviteHost_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).InviteHost(ctx, req.(*InviteHostRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ListBDLeaderBDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListBDLeaderBDsRequest)
if err := dec(in); err != nil {
@ -540,6 +590,24 @@ func _UserHostService_ListBDAgencies_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ListRoleInvitations_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListRoleInvitationsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserHostServiceServer).ListRoleInvitations(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserHostService_ListRoleInvitations_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserHostServiceServer).ListRoleInvitations(ctx, req.(*ListRoleInvitationsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserHostService_ProcessRoleInvitation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ProcessRoleInvitationRequest)
if err := dec(in); err != nil {
@ -751,6 +819,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "InviteBD",
Handler: _UserHostService_InviteBD_Handler,
},
{
MethodName: "InviteHost",
Handler: _UserHostService_InviteHost_Handler,
},
{
MethodName: "ListBDLeaderBDs",
Handler: _UserHostService_ListBDLeaderBDs_Handler,
@ -763,6 +835,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListBDAgencies",
Handler: _UserHostService_ListBDAgencies_Handler,
},
{
MethodName: "ListRoleInvitations",
Handler: _UserHostService_ListRoleInvitations_Handler,
},
{
MethodName: "ProcessRoleInvitation",
Handler: _UserHostService_ProcessRoleInvitation_Handler,

View File

@ -94,9 +94,12 @@ type UserHostClient interface {
ApplyToAgency(ctx context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error)
InviteAgency(ctx context.Context, req *userv1.InviteAgencyRequest) (*userv1.InviteAgencyResponse, error)
InviteBD(ctx context.Context, req *userv1.InviteBDRequest) (*userv1.InviteBDResponse, error)
InviteHost(ctx context.Context, req *userv1.InviteHostRequest) (*userv1.InviteHostResponse, error)
ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(ctx context.Context, req *userv1.ListBDLeaderAgenciesRequest) (*userv1.ListBDLeaderAgenciesResponse, error)
ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, error)
ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error)
ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
@ -433,6 +436,10 @@ func (c *grpcUserHostClient) InviteBD(ctx context.Context, req *userv1.InviteBDR
return c.client.InviteBD(ctx, req)
}
func (c *grpcUserHostClient) InviteHost(ctx context.Context, req *userv1.InviteHostRequest) (*userv1.InviteHostResponse, error) {
return c.client.InviteHost(ctx, req)
}
func (c *grpcUserHostClient) ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) {
return c.client.ListBDLeaderBDs(ctx, req)
}
@ -445,6 +452,14 @@ func (c *grpcUserHostClient) ListBDAgencies(ctx context.Context, req *userv1.Lis
return c.client.ListBDAgencies(ctx, req)
}
func (c *grpcUserHostClient) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) {
return c.client.ListRoleInvitations(ctx, req)
}
func (c *grpcUserHostClient) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
return c.client.ProcessRoleInvitation(ctx, req)
}
func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
return c.client.GetHostProfile(ctx, req)
}

View File

@ -84,6 +84,7 @@ type UserHandlers struct {
ListAgencyCenterApplications http.HandlerFunc
ReviewAgencyCenterApplication http.HandlerFunc
RemoveAgencyCenterHost http.HandlerFunc
InviteAgencyCenterHost http.HandlerFunc
GetBDLeaderCenterOverview http.HandlerFunc
ListBDLeaderCenterBDs http.HandlerFunc
ListBDLeaderCenterAgencies http.HandlerFunc
@ -92,6 +93,8 @@ type UserHandlers struct {
GetBDCenterOverview http.HandlerFunc
ListBDCenterAgencies http.HandlerFunc
InviteBDAgency http.HandlerFunc
ListMyRoleInvitations http.HandlerFunc
ProcessMyRoleInvitation http.HandlerFunc
CompleteMyOnboarding http.HandlerFunc
UpdateMyProfile http.HandlerFunc
ChangeMyCountry http.HandlerFunc
@ -397,6 +400,7 @@ func (r routes) registerUserRoutes() {
r.profile("/agency-center/applications", http.MethodGet, h.ListAgencyCenterApplications)
r.profile("/agency-center/applications/{application_id}/review", http.MethodPost, h.ReviewAgencyCenterApplication)
r.profile("/agency-center/hosts/{host_user_id}/remove", http.MethodPost, h.RemoveAgencyCenterHost)
r.profile("/agency-center/invitations/host", http.MethodPost, h.InviteAgencyCenterHost)
r.profile("/bd-leader-center/overview", http.MethodGet, h.GetBDLeaderCenterOverview)
r.profile("/bd-leader-center/bds", http.MethodGet, h.ListBDLeaderCenterBDs)
r.profile("/bd-leader-center/agencies", http.MethodGet, h.ListBDLeaderCenterAgencies)
@ -405,6 +409,8 @@ func (r routes) registerUserRoutes() {
r.profile("/bd-center/overview", http.MethodGet, h.GetBDCenterOverview)
r.profile("/bd-center/agencies", http.MethodGet, h.ListBDCenterAgencies)
r.profile("/bd/invitations/agency", http.MethodPost, h.InviteBDAgency)
r.profile("/role-invitations", http.MethodGet, h.ListMyRoleInvitations)
r.profile("/role-invitations/{invitation_id}/process", http.MethodPost, h.ProcessMyRoleInvitation)
r.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
r.profile("/users/me/country/change", "", h.ChangeMyCountry)

View File

@ -415,6 +415,9 @@ type fakeUserHostClient struct {
lastInviteBD *userv1.InviteBDRequest
inviteBDResp *userv1.InviteBDResponse
inviteBDErr error
lastInviteHost *userv1.InviteHostRequest
inviteHostResp *userv1.InviteHostResponse
inviteHostErr error
lastLeaderBDs *userv1.ListBDLeaderBDsRequest
leaderBDsResp *userv1.ListBDLeaderBDsResponse
leaderBDsErr error
@ -424,6 +427,12 @@ type fakeUserHostClient struct {
lastBDAgencies *userv1.ListBDAgenciesRequest
bdAgenciesResp *userv1.ListBDAgenciesResponse
bdAgenciesErr error
lastRoleInvitations *userv1.ListRoleInvitationsRequest
roleInvitationsResp *userv1.ListRoleInvitationsResponse
roleInvitationsErr error
lastProcessInvite *userv1.ProcessRoleInvitationRequest
processInviteResp *userv1.ProcessRoleInvitationResponse
processInviteErr error
last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile
err error
@ -1297,6 +1306,17 @@ func (f *fakeUserHostClient) InviteBD(_ context.Context, req *userv1.InviteBDReq
return &userv1.InviteBDResponse{}, nil
}
func (f *fakeUserHostClient) InviteHost(_ context.Context, req *userv1.InviteHostRequest) (*userv1.InviteHostResponse, error) {
f.lastInviteHost = req
if f.inviteHostErr != nil {
return nil, f.inviteHostErr
}
if f.inviteHostResp != nil {
return f.inviteHostResp, nil
}
return &userv1.InviteHostResponse{}, nil
}
func (f *fakeUserHostClient) ListBDLeaderBDs(_ context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) {
f.lastLeaderBDs = req
if f.leaderBDsErr != nil {
@ -1330,6 +1350,28 @@ func (f *fakeUserHostClient) ListBDAgencies(_ context.Context, req *userv1.ListB
return &userv1.ListBDAgenciesResponse{}, nil
}
func (f *fakeUserHostClient) ListRoleInvitations(_ context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) {
f.lastRoleInvitations = req
if f.roleInvitationsErr != nil {
return nil, f.roleInvitationsErr
}
if f.roleInvitationsResp != nil {
return f.roleInvitationsResp, nil
}
return &userv1.ListRoleInvitationsResponse{}, nil
}
func (f *fakeUserHostClient) ProcessRoleInvitation(_ context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
f.lastProcessInvite = req
if f.processInviteErr != nil {
return nil, f.processInviteErr
}
if f.processInviteResp != nil {
return f.processInviteResp, nil
}
return &userv1.ProcessRoleInvitationResponse{}, nil
}
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req
if f.hostErr != nil {
@ -4392,14 +4434,14 @@ func TestBDCenterOverviewUsesBDSalaryAndDirectAgencies(t *testing.T) {
}
}
func TestBDCenterInviteAgencyDirectlyForwardsInviteAgency(t *testing.T) {
func TestBDCenterInviteAgencyCreatesPendingInvitation(t *testing.T) {
hostClient := &fakeUserHostClient{
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
inviteAgencyResp: &userv1.InviteAgencyResponse{Invitation: &userv1.RoleInvitation{
InvitationId: 9001,
CommandId: "cmd-bd-invite-agency",
InvitationType: "agency",
Status: "accepted",
Status: "pending",
InviterUserId: 42,
TargetUserId: 99,
AgencyName: "Target Agency",
@ -4429,7 +4471,7 @@ func TestBDCenterInviteAgencyDirectlyForwardsInviteAgency(t *testing.T) {
}
data := response.Data.(map[string]any)
invitation := data["invitation"].(map[string]any)
if invitation["status"] != "accepted" || invitation["target_user_id"] != "99" {
if invitation["status"] != "pending" || invitation["target_user_id"] != "99" {
t.Fatalf("bd invite response mismatch: %+v", data)
}
}
@ -4441,7 +4483,7 @@ func TestBDLeaderInviteBDForwardsSelfInvite(t *testing.T) {
InvitationId: 9002,
CommandId: "cmd-bd-self",
InvitationType: "bd",
Status: "accepted",
Status: "pending",
InviterUserId: 42,
InviterBdUserId: 42,
TargetUserId: 42,
@ -4470,11 +4512,103 @@ func TestBDLeaderInviteBDForwardsSelfInvite(t *testing.T) {
}
data := response.Data.(map[string]any)
invitation := data["invitation"].(map[string]any)
if invitation["status"] != "accepted" || invitation["target_user_id"] != "42" || invitation["parent_leader_user_id"] != "42" {
if invitation["status"] != "pending" || invitation["target_user_id"] != "42" || invitation["parent_leader_user_id"] != "42" {
t.Fatalf("bd self invite response mismatch: %+v", data)
}
}
func TestAgencyCenterInviteHostCreatesPendingInvitation(t *testing.T) {
hostClient := &fakeUserHostClient{
roleSummary: &userv1.UserRoleSummary{UserId: 42, IsManager: true, AgencyId: 7001},
getAgencyResp: &userv1.GetAgencyResponse{Agency: &userv1.Agency{AgencyId: 7001, OwnerUserId: 42, Status: "active"}},
inviteHostResp: &userv1.InviteHostResponse{Invitation: &userv1.RoleInvitation{
InvitationId: 9010,
CommandId: "cmd-host-invite",
InvitationType: "host",
Status: "pending",
InviterUserId: 42,
TargetUserId: 100,
AgencyName: "Voice Agency",
}},
}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/agency-center/invitations/host", bytes.NewReader([]byte(`{"command_id":"cmd-host-invite","target_user_id":"100"}`)))
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if hostClient.lastInviteHost == nil || hostClient.lastInviteHost.GetInviterUserId() != 42 || hostClient.lastInviteHost.GetTargetUserId() != 100 {
t.Fatalf("host invite request mismatch: %+v", hostClient.lastInviteHost)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
invitation := response.Data.(map[string]any)["invitation"].(map[string]any)
if invitation["status"] != "pending" || invitation["invitation_type"] != "host" {
t.Fatalf("host invite response mismatch: %+v", response.Data)
}
}
func TestRoleInvitationsListAndProcessForwardCurrentUser(t *testing.T) {
hostClient := &fakeUserHostClient{
roleInvitationsResp: &userv1.ListRoleInvitationsResponse{Invitations: []*userv1.RoleInvitation{{
InvitationId: 9011,
InvitationType: "agency",
Status: "pending",
InviterUserId: 77,
TargetUserId: 42,
AgencyName: "Target Agency",
CreatedAtMs: 1700000000000,
}}},
processInviteResp: &userv1.ProcessRoleInvitationResponse{
Invitation: &userv1.RoleInvitation{
InvitationId: 9011,
InvitationType: "agency",
Status: "accepted",
InviterUserId: 77,
TargetUserId: 42,
},
Agency: &userv1.Agency{AgencyId: 7008, OwnerUserId: 42, Status: "active", Name: "Target Agency"},
Membership: &userv1.AgencyMembership{MembershipId: 3008, AgencyId: 7008, HostUserId: 42, MembershipType: "owner", Status: "active"},
},
}
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
77: {UserId: 77, DisplayUserId: "163077", Username: "Leader Rose"},
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
handler.SetUserHostClient(hostClient)
router := handler.Routes(auth.NewVerifier("secret"))
listRequest := httptest.NewRequest(http.MethodGet, "/api/v1/role-invitations?status=pending", nil)
listRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, listRequest)
if listRecorder.Code != http.StatusOK {
t.Fatalf("list status mismatch: got %d body=%s", listRecorder.Code, listRecorder.Body.String())
}
if hostClient.lastRoleInvitations == nil || hostClient.lastRoleInvitations.GetTargetUserId() != 42 || hostClient.lastRoleInvitations.GetStatus() != "pending" {
t.Fatalf("list invitations request mismatch: %+v", hostClient.lastRoleInvitations)
}
processRequest := httptest.NewRequest(http.MethodPost, "/api/v1/role-invitations/9011/process", bytes.NewReader([]byte(`{"command_id":"cmd-accept-9011","action":"accept"}`)))
processRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
processRecorder := httptest.NewRecorder()
router.ServeHTTP(processRecorder, processRequest)
if processRecorder.Code != http.StatusOK {
t.Fatalf("process status mismatch: got %d body=%s", processRecorder.Code, processRecorder.Body.String())
}
if hostClient.lastProcessInvite == nil || hostClient.lastProcessInvite.GetActorUserId() != 42 || hostClient.lastProcessInvite.GetInvitationId() != 9011 || hostClient.lastProcessInvite.GetAction() != "accept" {
t.Fatalf("process invitation request mismatch: %+v", hostClient.lastProcessInvite)
}
}
func TestAgencyCenterOverviewRejectsNonOwner(t *testing.T) {
hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 42, IsManager: false, AgencyId: 7001}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
@ -4645,6 +4779,7 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-auth-third")
request.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1")
request.Header.Set("X-Real-IP", "129.226.91.24")
request.Header.Set("User-Agent", "hyapp-test/1.0")
request.Header.Set("CF-IPCountry", "sg")
recorder := httptest.NewRecorder()
@ -4684,6 +4819,17 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
}
}
func TestAuthClientIPPrefersForwardedPublicIPOverProxyRealIP(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", nil)
request.RemoteAddr = "10.0.0.9:54123"
request.Header.Set("X-Forwarded-For", "114.8.228.33, 10.0.0.1")
request.Header.Set("X-Real-IP", "129.226.91.24")
if got := clientIP(request); got != "114.8.228.33" {
t.Fatalf("clientIP must use the first public X-Forwarded-For address before proxy X-Real-IP, got %q", got)
}
}
func TestThirdPartyLoginRateLimitByProviderAndIP(t *testing.T) {
userClient := &fakeUserAuthClient{}
handler := NewHandler(&fakeRoomClient{}, userClient)

View File

@ -332,6 +332,37 @@ func (h *Handler) removeAgencyCenterHost(writer http.ResponseWriter, request *ht
httpkit.WriteOK(writer, request, map[string]any{"membership": agencyMembershipData(resp.GetMembership())})
}
func (h *Handler) inviteAgencyCenterHost(writer http.ResponseWriter, request *http.Request) {
if _, ok := h.resolveCurrentOwnerAgency(writer, request); !ok {
return
}
var body struct {
CommandID string `json:"command_id"`
TargetUserID string `json:"target_user_id"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
targetUserID := int64JSONValue(body.TargetUserID)
if strings.TrimSpace(body.CommandID) == "" || targetUserID <= 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
// Agency owner 只负责发送邀请;目标用户接受后 user-service 再原子创建 Host 身份和成员关系。
resp, err := h.userHostClient.InviteHost(request.Context(), &userv1.InviteHostRequest{
Meta: httpkit.UserMeta(request, ""),
CommandId: strings.TrimSpace(body.CommandID),
InviterUserId: auth.UserIDFromContext(request.Context()),
TargetUserId: targetUserID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{"invitation": roleInvitationData(resp.GetInvitation())})
}
func (h *Handler) resolveCurrentOwnerAgency(writer http.ResponseWriter, request *http.Request) (*userv1.Agency, bool) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")

View File

@ -372,7 +372,7 @@ func (h *Handler) inviteBDAgency(writer http.ResponseWriter, request *http.Reque
target := users[targetUserID]
agencyName = firstBDLeaderNonBlank(target.Username, target.DisplayUserID, int64String(targetUserID))
}
// InviteAgency is already a direct-accept transaction in user-service, so this HTTP endpoint does not create a pending approval step.
// InviteAgency 只发送 pending 邀请Agency 身份必须由目标用户在消息中心接受后才创建。
resp, err := h.userHostClient.InviteAgency(request.Context(), &userv1.InviteAgencyRequest{
Meta: httpkit.UserMeta(request, ""),
CommandId: commandID,
@ -457,6 +457,7 @@ func roleInvitationData(invitation *userv1.RoleInvitation) map[string]any {
"invitation_type": invitation.GetInvitationType(),
"status": invitation.GetStatus(),
"inviter_user_id": int64String(invitation.GetInviterUserId()),
"inviter_bd_user_id": int64String(invitation.GetInviterBdUserId()),
"target_user_id": int64String(invitation.GetTargetUserId()),
"region_id": invitation.GetRegionId(),
"agency_name": invitation.GetAgencyName(),

View File

@ -68,6 +68,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
ListAgencyCenterApplications: h.listAgencyCenterApplications,
ReviewAgencyCenterApplication: h.reviewAgencyCenterApplication,
RemoveAgencyCenterHost: h.removeAgencyCenterHost,
InviteAgencyCenterHost: h.inviteAgencyCenterHost,
GetBDLeaderCenterOverview: h.getBDLeaderCenterOverview,
ListBDLeaderCenterBDs: h.listBDLeaderCenterBDs,
ListBDLeaderCenterAgencies: h.listBDLeaderCenterAgencies,
@ -76,6 +77,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
GetBDCenterOverview: h.getBDCenterOverview,
ListBDCenterAgencies: h.listBDCenterAgencies,
InviteBDAgency: h.inviteBDAgency,
ListMyRoleInvitations: h.listMyRoleInvitations,
ProcessMyRoleInvitation: h.processMyRoleInvitation,
CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile,
ChangeMyCountry: h.changeMyCountry,

View File

@ -0,0 +1,126 @@
package userapi
import (
"net/http"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
func (h *Handler) listMyRoleInvitations(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
userID := auth.UserIDFromContext(request.Context())
status := strings.TrimSpace(request.URL.Query().Get("status"))
if status == "" {
status = "pending"
}
// 收件箱只按当前登录用户读取 target_user_id前端不能传入任意用户 ID 枚举邀请消息。
resp, err := h.userHostClient.ListRoleInvitations(request.Context(), &userv1.ListRoleInvitationsRequest{
Meta: httpkit.UserMeta(request, ""),
TargetUserId: userID,
Status: status,
PageSize: int32(queryPageSize(request, 50)),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
userIDs := make([]int64, 0, len(resp.GetInvitations())*2)
for _, invitation := range resp.GetInvitations() {
if invitation.GetInviterUserId() > 0 {
userIDs = append(userIDs, invitation.GetInviterUserId())
}
if invitation.GetTargetUserId() > 0 {
userIDs = append(userIDs, invitation.GetTargetUserId())
}
}
users, err := h.userProfiles(request, userIDs)
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
items := make([]map[string]any, 0, len(resp.GetInvitations()))
for _, invitation := range resp.GetInvitations() {
items = append(items, map[string]any{
"invitation": roleInvitationData(invitation),
"inviter": users[invitation.GetInviterUserId()],
"target": users[invitation.GetTargetUserId()],
})
}
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
}
func (h *Handler) processMyRoleInvitation(writer http.ResponseWriter, request *http.Request) {
if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
invitationID, ok := httpkit.PositiveInt64PathValue(request, "invitation_id")
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid invitation_id")
return
}
var body map[string]any
if !httpkit.Decode(writer, request, &body) {
return
}
commandID := stringJSONValue(body["command_id"])
action := strings.ToLower(strings.TrimSpace(stringJSONValue(body["action"])))
if action == "agree" {
action = "accept"
} else if action == "refuse" {
action = "reject"
}
if commandID == "" || (action != "accept" && action != "reject") {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
// 接受/拒绝入口只使用当前登录用户作为 actor_user_iduser-service 会再次校验他是否是邀请目标。
resp, err := h.userHostClient.ProcessRoleInvitation(request.Context(), &userv1.ProcessRoleInvitationRequest{
Meta: httpkit.UserMeta(request, ""),
CommandId: commandID,
ActorUserId: auth.UserIDFromContext(request.Context()),
InvitationId: invitationID,
Action: action,
Reason: strings.TrimSpace(stringJSONValue(body["reason"])),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, map[string]any{
"invitation": roleInvitationData(resp.GetInvitation()),
"host_profile": hostProfileData(resp.GetHostProfile()),
"agency": hostAgencyFromProto(resp.GetAgency()),
"membership": agencyMembershipData(resp.GetMembership()),
"bd_profile": bdLeaderProfileFromProto(resp.GetBdProfile()),
})
}
func hostProfileData(profile *userv1.HostProfile) map[string]any {
if profile == nil {
return map[string]any{}
}
return map[string]any{
"user_id": int64String(profile.GetUserId()),
"status": profile.GetStatus(),
"region_id": profile.GetRegionId(),
"current_agency_id": int64String(profile.GetCurrentAgencyId()),
"current_membership_id": int64String(profile.GetCurrentMembershipId()),
"source": profile.GetSource(),
"first_became_host_at_ms": profile.GetFirstBecameHostAtMs(),
"created_at_ms": profile.GetCreatedAtMs(),
"updated_at_ms": profile.GetUpdatedAtMs(),
"current_agency_owner_user_id": int64String(profile.GetCurrentAgencyOwnerUserId()),
}
}

View File

@ -68,6 +68,8 @@ const (
InvitationTypeAgency = "agency"
// InvitationTypeBD 表示邀请用户成为 BD。
InvitationTypeBD = "bd"
// InvitationTypeHost 表示 Agency owner 邀请用户成为本 Agency 的 Host 成员。
InvitationTypeHost = "host"
// InvitationStatusPending 表示等待目标用户处理。
InvitationStatusPending = "pending"
@ -97,6 +99,8 @@ const (
CommandTypeInviteAgency = "invite_agency"
// CommandTypeInviteBD 记录 BD 邀请命令。
CommandTypeInviteBD = "invite_bd"
// CommandTypeInviteHost 记录 Agency owner 邀请 Host 加入团队的命令。
CommandTypeInviteHost = "invite_host"
// CommandTypeProcessRoleInvitation 记录邀请处理命令。
CommandTypeProcessRoleInvitation = "process_role_invitation"
// CommandTypeCreateBDLeader 记录后台创建 BD Leader 命令。

View File

@ -102,6 +102,29 @@ type InviteBDCommand struct {
NowMs int64
}
// InviteHostInput 是 Agency owner 邀请用户成为 Host 成员的外部输入。
type InviteHostInput struct {
CommandID string
InviterUserID int64
TargetUserID int64
}
// InviteHostCommand 是 Agency 邀请 Host 事务所需的完整命令。
type InviteHostCommand struct {
CommandID string
InvitationID int64
InviterUserID int64
TargetUserID int64
NowMs int64
}
// ListRoleInvitationsCommand 描述当前用户收件箱读取条件。
type ListRoleInvitationsCommand struct {
TargetUserID int64
Status string
PageSize int
}
// ListBDLeaderBDsCommand 描述 BD Leader 查询直属 BD 列表的条件。
type ListBDLeaderBDsCommand struct {
LeaderUserID int64

View File

@ -20,6 +20,8 @@ type Repository interface {
KickAgencyHost(ctx context.Context, command KickAgencyHostCommand) (hostdomain.KickAgencyHostResult, error)
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)
ListRoleInvitations(ctx context.Context, command ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error)
ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error)
ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error)
ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]hostdomain.Agency, error)
@ -200,12 +202,12 @@ func (s *Service) GetAgency(ctx context.Context, agencyID int64) (hostdomain.Age
return s.repository.GetAgency(ctx, agencyID)
}
// InviteAgency 由 BD 直接创建用户的 Agency owner 身份,不再等待目标用户确认
// InviteAgency 由 BD 或 BD Leader 创建 Agency owner 邀请;目标用户接受前不创建 Agency 身份
func (s *Service) InviteAgency(ctx context.Context, command InviteAgencyInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err
}
// Agency 名称在邀请动作里固化;持久化层会在同一事务中写 accepted invitation、Agency、owner Host 和 owner membership
// Agency 名称在邀请动作里固化,接受时按这份快照创建 Agency避免目标确认后前端再传入不同名称
agencyName := strings.TrimSpace(command.AgencyName)
if strings.TrimSpace(command.CommandID) == "" || command.InviterUserID <= 0 || command.TargetUserID <= 0 || agencyName == "" {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "command_id, inviter_user_id, target_user_id and agency_name are required")
@ -218,19 +220,16 @@ func (s *Service) InviteAgency(ctx context.Context, command InviteAgencyInput) (
InviterUserID: command.InviterUserID,
TargetUserID: command.TargetUserID,
AgencyName: agencyName,
AgencyID: s.idGenerator.NewInt64(),
MembershipID: s.idGenerator.NewInt64(),
EventID: s.nextEventIDRange(3),
NowMs: nowMs,
})
}
// InviteBD 由 BD Leader 直接创建用户的 BD 身份,不再等待目标用户确认
// InviteBD 由 BD Leader 创建 BD 邀请;目标用户接受前不创建普通 BD 身份
func (s *Service) InviteBD(ctx context.Context, command InviteBDInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err
}
// 负责人身份、区域、目标是否已有 BD 都交给持久化层在同一事务中校验,并和 accepted invitation 一起提交
// 负责人身份、区域、目标是否已有 BD 都交给持久化层在同一事务中校验,避免发出不可处理的待确认消息
if strings.TrimSpace(command.CommandID) == "" || command.InviterUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "command_id, inviter_user_id and target_user_id are required")
}
@ -241,11 +240,45 @@ func (s *Service) InviteBD(ctx context.Context, command InviteBDInput) (hostdoma
InvitationID: s.idGenerator.NewInt64(),
InviterUserID: command.InviterUserID,
TargetUserID: command.TargetUserID,
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs,
})
}
// InviteHost 由 active Agency owner 邀请用户加入自己的 Agency接受前不创建 Host 或成员关系。
func (s *Service) InviteHost(ctx context.Context, command InviteHostInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err
}
// command_id 仍是发送动作的幂等边界;目标处理时会使用新的 command_id 推进邀请终态。
if strings.TrimSpace(command.CommandID) == "" || command.InviterUserID <= 0 || command.TargetUserID <= 0 {
return hostdomain.RoleInvitation{}, xerr.New(xerr.InvalidArgument, "command_id, inviter_user_id and target_user_id are required")
}
nowMs := s.now().UnixMilli()
return s.repository.InviteHost(ctx, InviteHostCommand{
CommandID: strings.TrimSpace(command.CommandID),
InvitationID: s.idGenerator.NewInt64(),
InviterUserID: command.InviterUserID,
TargetUserID: command.TargetUserID,
NowMs: nowMs,
})
}
// ListRoleInvitations 返回当前用户收到的角色/团队邀请收件箱。
func (s *Service) ListRoleInvitations(ctx context.Context, command ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
}
if command.TargetUserID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
}
return s.repository.ListRoleInvitations(ctx, ListRoleInvitationsCommand{
TargetUserID: command.TargetUserID,
Status: normalizeStatus(command.Status),
PageSize: normalizePageSize(command.PageSize),
})
}
// ListBDLeaderBDs 返回当前 BD Leader 直属的有效 BD 列表。
func (s *Service) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) {
if s.repository == nil {

View File

@ -65,7 +65,7 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
InvitationID: command.InvitationID,
CommandID: command.CommandID,
InvitationType: hostdomain.InvitationTypeAgency,
Status: hostdomain.InvitationStatusAccepted,
Status: hostdomain.InvitationStatusPending,
InviterUserID: command.InviterUserID,
InviterBDUserID: inviter.UserID,
TargetUserID: command.TargetUserID,
@ -73,42 +73,13 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
AgencyName: command.AgencyName,
ParentBDUserID: inviter.UserID,
ParentLeaderUserID: parentLeaderUserID,
ProcessedByUserID: command.InviterUserID,
ProcessReason: "direct_invite",
ProcessedAtMs: command.NowMs,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
// 新邀请不再等待目标用户确认accepted invitation、Agency、owner Host 和 owner membership 必须同事务提交
// 邀请创建只落 pending 消息Agency、owner Host 和 owner membership 必须等目标用户接受时再同事务创建
if err := insertRoleInvitation(ctx, tx, invitation); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
}
result := hostdomain.ProcessRoleInvitationResult{Invitation: invitation}
hostProfileCreated, err := r.acceptAgencyInvitation(ctx, tx, hostservice.ProcessRoleInvitationCommand{
CommandID: command.CommandID,
ActorUserID: command.InviterUserID,
InvitationID: command.InvitationID,
Action: hostdomain.InvitationActionAccept,
Reason: "direct_invite",
AgencyID: command.AgencyID,
MembershipID: command.MembershipID,
EventID: command.EventID,
NowMs: command.NowMs,
}, &invitation, &result)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if hostProfileCreated {
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", invitation.TargetUserID, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
}
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", command.AgencyID, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
CommandID: command.CommandID,
CommandType: hostdomain.CommandTypeInviteAgency,
@ -171,47 +142,105 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
InvitationID: command.InvitationID,
CommandID: command.CommandID,
InvitationType: hostdomain.InvitationTypeBD,
Status: hostdomain.InvitationStatusAccepted,
Status: hostdomain.InvitationStatusPending,
InviterUserID: command.InviterUserID,
InviterBDUserID: inviter.UserID,
TargetUserID: command.TargetUserID,
RegionID: targetRegionID,
ParentLeaderUserID: inviter.UserID,
ProcessedByUserID: command.InviterUserID,
ProcessReason: "direct_invite",
ProcessedAtMs: command.NowMs,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
// 新 BD 邀请直接创建 BD 身份accepted invitation 只作为审计事实和幂等回放结果
// 新 BD 邀请只产生待确认消息BD 身份要等目标用户接受后才创建
if err := insertRoleInvitation(ctx, tx, invitation); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
}
recordCommandResult := func() error {
return insertCommandResult(ctx, tx, hostdomain.CommandResult{
CommandID: command.CommandID,
CommandType: hostdomain.CommandTypeInviteBD,
ResultType: hostdomain.ResultTypeInvitation,
ResultID: invitation.InvitationID,
CreatedAtMs: command.NowMs,
})
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
CommandID: command.CommandID,
CommandType: hostdomain.CommandTypeInviteBD,
ResultType: hostdomain.ResultTypeInvitation,
ResultID: invitation.InvitationID,
CreatedAtMs: command.NowMs,
}); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
}
result := hostdomain.ProcessRoleInvitationResult{Invitation: invitation}
if err := r.acceptBDInvitation(ctx, tx, hostservice.ProcessRoleInvitationCommand{
CommandID: command.CommandID,
ActorUserID: command.InviterUserID,
InvitationID: command.InvitationID,
Action: hostdomain.InvitationActionAccept,
Reason: "direct_invite",
EventID: command.EventID,
NowMs: command.NowMs,
}, &invitation, &result); err != nil {
if err := tx.Commit(); err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", invitation.TargetUserID, command.NowMs); err != nil {
return invitation, nil
}
// InviteHost 创建 Agency owner 到目标用户的 Host 入会邀请。
func (r *Repository) InviteHost(ctx context.Context, command hostservice.InviteHostCommand) (hostdomain.RoleInvitation, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if err := recordCommandResult(); err != nil {
defer tx.Rollback()
// 发送邀请是独立写命令;重试只回放同一条 pending/终态邀请,不重新读取 owner 当前权限。
if existing, ok, err := commandResult(ctx, tx, command.CommandID); err != nil {
return hostdomain.RoleInvitation{}, err
} else if ok {
if err := requireCommandResult(existing, hostdomain.CommandTypeInviteHost, hostdomain.ResultTypeInvitation); err != nil {
return hostdomain.RoleInvitation{}, err
}
return queryRoleInvitation(ctx, tx, "WHERE invitation_id = ?", existing.ResultID)
}
// Agency owner 是邀请 Host 的唯一授权身份;锁 active agency 行可以让关闭、删除、禁入和邀请创建串行。
agency, ok, err := queryActiveAgencyByOwner(ctx, tx, command.InviterUserID, true)
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if !ok || !agency.JoinEnabled {
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "inviter agency is not active or joinable")
}
// 目标用户可能还没有 host_profile所以用 users 行作为目标身份变化的串行化锁。
targetRegionID, err := r.userRegion(ctx, tx, command.TargetUserID, "FOR UPDATE")
if err != nil {
return hostdomain.RoleInvitation{}, err
}
if targetRegionID != agency.RegionID {
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match agency region")
}
// Host 入会邀请不能绕过“一人一个有效 Agency 归属”的硬约束,接受时还会再次校验当前状态。
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")
}
invitation := hostdomain.RoleInvitation{
InvitationID: command.InvitationID,
CommandID: command.CommandID,
InvitationType: hostdomain.InvitationTypeHost,
Status: hostdomain.InvitationStatusPending,
InviterUserID: command.InviterUserID,
InviterBDUserID: agency.ParentBDUserID,
TargetUserID: command.TargetUserID,
RegionID: targetRegionID,
AgencyName: agency.Name,
ParentBDUserID: agency.ParentBDUserID,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := insertRoleInvitation(ctx, tx, invitation); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
}
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
CommandID: command.CommandID,
CommandType: hostdomain.CommandTypeInviteHost,
ResultType: hostdomain.ResultTypeInvitation,
ResultID: invitation.InvitationID,
CreatedAtMs: command.NowMs,
}); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
}
if err := tx.Commit(); err != nil {
@ -231,7 +260,7 @@ func queryActiveAgencyInviterProfile(ctx context.Context, tx *sql.Tx, userID int
return hostdomain.BDProfile{}, xerr.New(xerr.PermissionDenied, "inviter bd is not active")
}
// ProcessRoleInvitation 处理角色邀请,接受路径在同一事务内创建 Agency/BD 角色事实。
// ProcessRoleInvitation 处理角色邀请,接受路径在同一事务内创建 Host/Agency/BD 角色事实。
func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostservice.ProcessRoleInvitationCommand) (hostdomain.ProcessRoleInvitationResult, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
@ -309,6 +338,20 @@ 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.InvitationTypeHost {
// Host 邀请只创建或复用 Host 身份,并把当前成员关系挂到发起 Agency。
hostProfileCreated, err := r.acceptHostInvitation(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")
}
@ -404,6 +447,59 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
return hostProfileCreated, nil
}
func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) {
// Host 邀请绑定的是发起人当前 active Agency而不是客户端传入的 agency_id
// 这样邀请消息无法被目标用户篡改成加入其他团队。
agency, ok, err := queryActiveAgencyByOwner(ctx, tx, invitation.InviterUserID, true)
if err != nil {
return false, err
}
if !ok || !agency.JoinEnabled {
return false, xerr.New(xerr.Conflict, "inviter agency is no longer active or joinable")
}
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
if err != nil {
return false, err
}
if targetRegionID != agency.RegionID {
return false, xerr.New(xerr.PermissionDenied, "target region no longer matches agency")
}
// 接受时再次检查有效归属和 Agency owner 身份,覆盖邀请发出后用户已经加入或创建团队的情况。
if _, ok, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true); err != nil {
return false, err
} else if ok {
return false, xerr.New(xerr.Conflict, "target already has active agency membership")
}
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, invitation.TargetUserID, true); err != nil {
return false, err
} else if ok {
return false, xerr.New(xerr.Conflict, "target already owns active agency")
}
hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAgencyInvitation, command.NowMs)
if err != nil {
return false, err
}
membership := hostdomain.AgencyMembership{
MembershipID: command.MembershipID,
AgencyID: agency.AgencyID,
HostUserID: invitation.TargetUserID,
RegionID: targetRegionID,
MembershipType: hostdomain.MembershipTypeMember,
Status: hostdomain.MembershipStatusActive,
JoinedAtMs: command.NowMs,
CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs,
}
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
return false, mapHostDuplicateError(err)
}
result.HostProfile = hostProfile
result.Agency = agency
result.Membership = membership
return hostProfileCreated, nil
}
func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) error {
// BD 邀请同样以目标用户和发起 Leader 的 users 当前区域为准;旧邀请快照不能继续授权跨区接受。
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")

View File

@ -353,6 +353,20 @@ func (r *Repository) ListBDAgencies(ctx context.Context, command hostservice.Lis
return queryAgencies(ctx, r.db, clause, args...)
}
// ListRoleInvitations 读取目标用户收到的角色邀请消息;默认由业务层传入 pending 状态用于消息中心。
func (r *Repository) ListRoleInvitations(ctx context.Context, command hostservice.ListRoleInvitationsCommand) ([]hostdomain.RoleInvitation, error) {
query := fmt.Sprintf(`SELECT %s FROM role_invitations WHERE app_code = ? AND target_user_id = ?`, roleInvitationColumns)
args := []any{appcode.FromContext(ctx), command.TargetUserID}
if command.Status != "" {
query += " AND status = ?"
args = append(args, command.Status)
}
// 消息中心优先展示最新邀请;同毫秒内用 invitation_id 保持稳定排序。
query += " ORDER BY created_at_ms DESC, invitation_id DESC LIMIT ?"
args = append(args, command.PageSize)
return queryRoleInvitations(ctx, r.db, query, args...)
}
// ListAgencyMembers 读取 Agency 成员关系列表。
func (r *Repository) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) {
query := fmt.Sprintf(`SELECT %s FROM agency_memberships WHERE app_code = ? AND agency_id = ?`, agencyMembershipColumns)
@ -812,6 +826,23 @@ func queryRoleInvitation(ctx context.Context, q sqlQueryer, clause string, args
return invitation, err
}
func queryRoleInvitations(ctx context.Context, q sqlQueryer, query string, args ...any) ([]hostdomain.RoleInvitation, error) {
rows, err := q.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
invitations := make([]hostdomain.RoleInvitation, 0)
for rows.Next() {
invitation, err := scanRoleInvitation(rows)
if err != nil {
return nil, err
}
invitations = append(invitations, invitation)
}
return invitations, rows.Err()
}
func scanRoleInvitation(scanner rowScanner) (hostdomain.RoleInvitation, error) {
var invitation hostdomain.RoleInvitation
err := scanner.Scan(
@ -877,7 +908,10 @@ func processResultByInvitationID(ctx context.Context, q sqlQueryer, invitationID
if invitation.Status != hostdomain.InvitationStatusAccepted {
return result, nil
}
if invitation.InvitationType == hostdomain.InvitationTypeAgency {
// 只有接受动作会创建后续身份事实;按邀请类型回填前端需要立即刷新的最新关系。
switch invitation.InvitationType {
case hostdomain.InvitationTypeAgency:
if profile, err := queryHostProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil {
result.HostProfile = profile
if profile.CurrentMembershipID > 0 {
@ -889,10 +923,20 @@ func processResultByInvitationID(ctx context.Context, q sqlQueryer, invitationID
} else if ok {
result.Agency = agency
}
} else if invitation.InvitationType == hostdomain.InvitationTypeBD {
case hostdomain.InvitationTypeBD:
if bd, err := queryBDProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil {
result.BDProfile = bd
}
case hostdomain.InvitationTypeHost:
if profile, err := queryHostProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil {
result.HostProfile = profile
if profile.CurrentMembershipID > 0 {
result.Membership, _ = queryAgencyMembership(ctx, q, "WHERE membership_id = ?", profile.CurrentMembershipID)
}
if profile.CurrentAgencyID > 0 {
result.Agency, _ = queryAgency(ctx, q, "WHERE agency_id = ?", profile.CurrentAgencyID)
}
}
}
return result, nil
}

View File

@ -66,7 +66,7 @@ func TestBDLeaderSelfInviteFlowThroughGRPCUsesSplitIdentityTables(t *testing.T)
t.Fatalf("InviteBD self RPC failed: %v", err)
}
invitation := inviteResp.GetInvitation()
if invitation.GetStatus() != hostdomain.InvitationStatusAccepted || invitation.GetTargetUserId() != 10001 || invitation.GetParentLeaderUserId() != 10001 {
if invitation.GetStatus() != hostdomain.InvitationStatusPending || invitation.GetTargetUserId() != 10001 || invitation.GetParentLeaderUserId() != 10001 {
t.Fatalf("self invite response mismatch: %+v", invitation)
}
retryInviteResp, err := server.InviteBD(ctx, &userv1.InviteBDRequest{
@ -82,9 +82,39 @@ func TestBDLeaderSelfInviteFlowThroughGRPCUsesSplitIdentityTables(t *testing.T)
t.Fatalf("self invite retry must replay same invitation: first=%+v retry=%+v", invitation, retryInviteResp.GetInvitation())
}
if _, err := server.GetBDProfile(ctx, &userv1.GetBDProfileRequest{Meta: meta, UserId: 10001, Role: hostdomain.BDRoleBD}); xerr.ReasonFromGRPC(err) != xerr.NotFound {
t.Fatalf("pending self invite must not create ordinary bd before accept: err=%v", err)
}
listResp, err := server.ListRoleInvitations(ctx, &userv1.ListRoleInvitationsRequest{
Meta: meta,
TargetUserId: 10001,
Status: hostdomain.InvitationStatusPending,
PageSize: 20,
})
if err != nil {
t.Fatalf("ListRoleInvitations RPC failed: %v", err)
}
if len(listResp.GetInvitations()) != 1 || listResp.GetInvitations()[0].GetInvitationId() != invitation.GetInvitationId() {
t.Fatalf("pending invitation list mismatch: %+v", listResp.GetInvitations())
}
processResp, err := server.ProcessRoleInvitation(ctx, &userv1.ProcessRoleInvitationRequest{
Meta: meta,
CommandId: "rpc-accept-self-bd-10001",
ActorUserId: 10001,
InvitationId: invitation.GetInvitationId(),
Action: hostdomain.InvitationActionAccept,
Reason: "accept self bd",
})
if err != nil {
t.Fatalf("ProcessRoleInvitation accept RPC failed: %v", err)
}
if processResp.GetInvitation().GetStatus() != hostdomain.InvitationStatusAccepted || processResp.GetBdProfile().GetRole() != hostdomain.BDRoleBD {
t.Fatalf("accepted self invite response mismatch: %+v bd=%+v", processResp.GetInvitation(), processResp.GetBdProfile())
}
bdResp, err := server.GetBDProfile(ctx, &userv1.GetBDProfileRequest{Meta: meta, UserId: 10001, Role: hostdomain.BDRoleBD})
if err != nil {
t.Fatalf("GetBDProfile role=bd RPC failed after self invite: %v", err)
t.Fatalf("GetBDProfile role=bd RPC failed after accepting self invite: %v", err)
}
if bdResp.GetBdProfile().GetRole() != hostdomain.BDRoleBD || bdResp.GetBdProfile().GetParentLeaderUserId() != 10001 {
t.Fatalf("ordinary bd profile mismatch: %+v", bdResp.GetBdProfile())

View File

@ -98,7 +98,7 @@ func (s *Server) KickAgencyHost(ctx context.Context, req *userv1.KickAgencyHostR
}, nil
}
// InviteAgency 直接创建用户成为 Agency owner 的关系事实,不再等待目标用户确认
// InviteAgency 创建用户成为 Agency owner 的待确认邀请,目标接受后才创建关系事实。
func (s *Server) InviteAgency(ctx context.Context, req *userv1.InviteAgencyRequest) (*userv1.InviteAgencyResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
@ -118,7 +118,7 @@ func (s *Server) InviteAgency(ctx context.Context, req *userv1.InviteAgencyReque
return &userv1.InviteAgencyResponse{Invitation: toProtoRoleInvitation(invitation)}, nil
}
// InviteBD 直接创建用户成为 BD 的关系事实,只有 BD 负责人可调用。
// InviteBD 创建用户成为普通 BD 的待确认邀请,只有 BD 负责人可调用。
func (s *Server) InviteBD(ctx context.Context, req *userv1.InviteBDRequest) (*userv1.InviteBDResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
@ -137,6 +137,25 @@ func (s *Server) InviteBD(ctx context.Context, req *userv1.InviteBDRequest) (*us
return &userv1.InviteBDResponse{Invitation: toProtoRoleInvitation(invitation)}, nil
}
// InviteHost 创建用户加入 Agency 的待确认邀请,只有 active Agency owner 可调用。
func (s *Server) InviteHost(ctx context.Context, req *userv1.InviteHostRequest) (*userv1.InviteHostResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
// 发起人是否拥有 active Agency、目标是否同区和是否已有归属都在持久化事务内锁定判断。
invitation, err := s.hostSvc.InviteHost(ctx, hostservice.InviteHostInput{
CommandID: req.GetCommandId(),
InviterUserID: req.GetInviterUserId(),
TargetUserID: req.GetTargetUserId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.InviteHostResponse{Invitation: toProtoRoleInvitation(invitation)}, nil
}
// ListBDLeaderBDs 返回当前 BD Leader 直属 BD 列表。
func (s *Server) ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) {
if s.hostSvc == nil {
@ -203,7 +222,30 @@ func (s *Server) ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesR
return resp, nil
}
// ProcessRoleInvitation 处理 Agency 或 BD 邀请。
// ListRoleInvitations 返回目标用户收到的角色邀请消息。
func (s *Server) ListRoleInvitations(ctx context.Context, req *userv1.ListRoleInvitationsRequest) (*userv1.ListRoleInvitationsResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
}
ctx = contextWithApp(ctx, req.GetMeta())
// 查询只接受 target_user_id不允许调用方按 inviter 任意枚举别人的消息。
invitations, err := s.hostSvc.ListRoleInvitations(ctx, hostservice.ListRoleInvitationsCommand{
TargetUserID: req.GetTargetUserId(),
Status: req.GetStatus(),
PageSize: int(req.GetPageSize()),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
resp := &userv1.ListRoleInvitationsResponse{Invitations: make([]*userv1.RoleInvitation, 0, len(invitations))}
for _, invitation := range invitations {
resp.Invitations = append(resp.Invitations, toProtoRoleInvitation(invitation))
}
return resp, nil
}
// ProcessRoleInvitation 处理 Host、Agency 或 BD 邀请。
func (s *Server) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))