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; 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 { message ListBDLeaderBDsRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
int64 leader_user_id = 2; int64 leader_user_id = 2;
@ -255,6 +266,17 @@ message ListBDAgenciesResponse {
repeated Agency agencies = 1; 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 { message ProcessRoleInvitationRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
string command_id = 2; string command_id = 2;
@ -486,9 +508,11 @@ service UserHostService {
rpc KickAgencyHost(KickAgencyHostRequest) returns (KickAgencyHostResponse); rpc KickAgencyHost(KickAgencyHostRequest) returns (KickAgencyHostResponse);
rpc InviteAgency(InviteAgencyRequest) returns (InviteAgencyResponse); rpc InviteAgency(InviteAgencyRequest) returns (InviteAgencyResponse);
rpc InviteBD(InviteBDRequest) returns (InviteBDResponse); rpc InviteBD(InviteBDRequest) returns (InviteBDResponse);
rpc InviteHost(InviteHostRequest) returns (InviteHostResponse);
rpc ListBDLeaderBDs(ListBDLeaderBDsRequest) returns (ListBDLeaderBDsResponse); rpc ListBDLeaderBDs(ListBDLeaderBDsRequest) returns (ListBDLeaderBDsResponse);
rpc ListBDLeaderAgencies(ListBDLeaderAgenciesRequest) returns (ListBDLeaderAgenciesResponse); rpc ListBDLeaderAgencies(ListBDLeaderAgenciesRequest) returns (ListBDLeaderAgenciesResponse);
rpc ListBDAgencies(ListBDAgenciesRequest) returns (ListBDAgenciesResponse); rpc ListBDAgencies(ListBDAgenciesRequest) returns (ListBDAgenciesResponse);
rpc ListRoleInvitations(ListRoleInvitationsRequest) returns (ListRoleInvitationsResponse);
rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse); rpc ProcessRoleInvitation(ProcessRoleInvitationRequest) returns (ProcessRoleInvitationResponse);
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse); rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse); rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);

View File

@ -25,9 +25,11 @@ const (
UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost" UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost"
UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency" UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency"
UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD" 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_ListBDLeaderBDs_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderBDs"
UserHostService_ListBDLeaderAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderAgencies" UserHostService_ListBDLeaderAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDLeaderAgencies"
UserHostService_ListBDAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/ListBDAgencies" 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_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile" UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile" 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) KickAgencyHost(ctx context.Context, in *KickAgencyHostRequest, opts ...grpc.CallOption) (*KickAgencyHostResponse, error)
InviteAgency(ctx context.Context, in *InviteAgencyRequest, opts ...grpc.CallOption) (*InviteAgencyResponse, error) InviteAgency(ctx context.Context, in *InviteAgencyRequest, opts ...grpc.CallOption) (*InviteAgencyResponse, error)
InviteBD(ctx context.Context, in *InviteBDRequest, opts ...grpc.CallOption) (*InviteBDResponse, 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) ListBDLeaderBDs(ctx context.Context, in *ListBDLeaderBDsRequest, opts ...grpc.CallOption) (*ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(ctx context.Context, in *ListBDLeaderAgenciesRequest, opts ...grpc.CallOption) (*ListBDLeaderAgenciesResponse, error) ListBDLeaderAgencies(ctx context.Context, in *ListBDLeaderAgenciesRequest, opts ...grpc.CallOption) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(ctx context.Context, in *ListBDAgenciesRequest, opts ...grpc.CallOption) (*ListBDAgenciesResponse, 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) ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error)
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error) GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, 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 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) { func (c *userHostServiceClient) ListBDLeaderBDs(ctx context.Context, in *ListBDLeaderBDsRequest, opts ...grpc.CallOption) (*ListBDLeaderBDsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListBDLeaderBDsResponse) out := new(ListBDLeaderBDsResponse)
@ -165,6 +179,16 @@ func (c *userHostServiceClient) ListBDAgencies(ctx context.Context, in *ListBDAg
return out, nil 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) { func (c *userHostServiceClient) ProcessRoleInvitation(ctx context.Context, in *ProcessRoleInvitationRequest, opts ...grpc.CallOption) (*ProcessRoleInvitationResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ProcessRoleInvitationResponse) out := new(ProcessRoleInvitationResponse)
@ -277,9 +301,11 @@ type UserHostServiceServer interface {
KickAgencyHost(context.Context, *KickAgencyHostRequest) (*KickAgencyHostResponse, error) KickAgencyHost(context.Context, *KickAgencyHostRequest) (*KickAgencyHostResponse, error)
InviteAgency(context.Context, *InviteAgencyRequest) (*InviteAgencyResponse, error) InviteAgency(context.Context, *InviteAgencyRequest) (*InviteAgencyResponse, error)
InviteBD(context.Context, *InviteBDRequest) (*InviteBDResponse, error) InviteBD(context.Context, *InviteBDRequest) (*InviteBDResponse, error)
InviteHost(context.Context, *InviteHostRequest) (*InviteHostResponse, error)
ListBDLeaderBDs(context.Context, *ListBDLeaderBDsRequest) (*ListBDLeaderBDsResponse, error) ListBDLeaderBDs(context.Context, *ListBDLeaderBDsRequest) (*ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error) ListBDLeaderAgencies(context.Context, *ListBDLeaderAgenciesRequest) (*ListBDLeaderAgenciesResponse, error)
ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error) ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error)
ListRoleInvitations(context.Context, *ListRoleInvitationsRequest) (*ListRoleInvitationsResponse, error)
ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error) ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error)
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error) GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, 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) { func (UnimplementedUserHostServiceServer) InviteBD(context.Context, *InviteBDRequest) (*InviteBDResponse, error) {
return nil, status.Error(codes.Unimplemented, "method InviteBD not implemented") 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) { func (UnimplementedUserHostServiceServer) ListBDLeaderBDs(context.Context, *ListBDLeaderBDsRequest) (*ListBDLeaderBDsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBDLeaderBDs not implemented") 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) { func (UnimplementedUserHostServiceServer) ListBDAgencies(context.Context, *ListBDAgenciesRequest) (*ListBDAgenciesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListBDAgencies not implemented") 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) { func (UnimplementedUserHostServiceServer) ProcessRoleInvitation(context.Context, *ProcessRoleInvitationRequest) (*ProcessRoleInvitationResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ProcessRoleInvitation not implemented") 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) 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) { func _UserHostService_ListBDLeaderBDs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListBDLeaderBDsRequest) in := new(ListBDLeaderBDsRequest)
if err := dec(in); err != nil { 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) 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) { func _UserHostService_ProcessRoleInvitation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ProcessRoleInvitationRequest) in := new(ProcessRoleInvitationRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -751,6 +819,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "InviteBD", MethodName: "InviteBD",
Handler: _UserHostService_InviteBD_Handler, Handler: _UserHostService_InviteBD_Handler,
}, },
{
MethodName: "InviteHost",
Handler: _UserHostService_InviteHost_Handler,
},
{ {
MethodName: "ListBDLeaderBDs", MethodName: "ListBDLeaderBDs",
Handler: _UserHostService_ListBDLeaderBDs_Handler, Handler: _UserHostService_ListBDLeaderBDs_Handler,
@ -763,6 +835,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListBDAgencies", MethodName: "ListBDAgencies",
Handler: _UserHostService_ListBDAgencies_Handler, Handler: _UserHostService_ListBDAgencies_Handler,
}, },
{
MethodName: "ListRoleInvitations",
Handler: _UserHostService_ListRoleInvitations_Handler,
},
{ {
MethodName: "ProcessRoleInvitation", MethodName: "ProcessRoleInvitation",
Handler: _UserHostService_ProcessRoleInvitation_Handler, Handler: _UserHostService_ProcessRoleInvitation_Handler,

View File

@ -94,9 +94,12 @@ type UserHostClient interface {
ApplyToAgency(ctx context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error) ApplyToAgency(ctx context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error)
InviteAgency(ctx context.Context, req *userv1.InviteAgencyRequest) (*userv1.InviteAgencyResponse, error) InviteAgency(ctx context.Context, req *userv1.InviteAgencyRequest) (*userv1.InviteAgencyResponse, error)
InviteBD(ctx context.Context, req *userv1.InviteBDRequest) (*userv1.InviteBDResponse, 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) ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error)
ListBDLeaderAgencies(ctx context.Context, req *userv1.ListBDLeaderAgenciesRequest) (*userv1.ListBDLeaderAgenciesResponse, error) ListBDLeaderAgencies(ctx context.Context, req *userv1.ListBDLeaderAgenciesRequest) (*userv1.ListBDLeaderAgenciesResponse, error)
ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesRequest) (*userv1.ListBDAgenciesResponse, 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) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, 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) 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) { func (c *grpcUserHostClient) ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) {
return c.client.ListBDLeaderBDs(ctx, req) 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) 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) { func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
return c.client.GetHostProfile(ctx, req) return c.client.GetHostProfile(ctx, req)
} }

View File

@ -84,6 +84,7 @@ type UserHandlers struct {
ListAgencyCenterApplications http.HandlerFunc ListAgencyCenterApplications http.HandlerFunc
ReviewAgencyCenterApplication http.HandlerFunc ReviewAgencyCenterApplication http.HandlerFunc
RemoveAgencyCenterHost http.HandlerFunc RemoveAgencyCenterHost http.HandlerFunc
InviteAgencyCenterHost http.HandlerFunc
GetBDLeaderCenterOverview http.HandlerFunc GetBDLeaderCenterOverview http.HandlerFunc
ListBDLeaderCenterBDs http.HandlerFunc ListBDLeaderCenterBDs http.HandlerFunc
ListBDLeaderCenterAgencies http.HandlerFunc ListBDLeaderCenterAgencies http.HandlerFunc
@ -92,6 +93,8 @@ type UserHandlers struct {
GetBDCenterOverview http.HandlerFunc GetBDCenterOverview http.HandlerFunc
ListBDCenterAgencies http.HandlerFunc ListBDCenterAgencies http.HandlerFunc
InviteBDAgency http.HandlerFunc InviteBDAgency http.HandlerFunc
ListMyRoleInvitations http.HandlerFunc
ProcessMyRoleInvitation http.HandlerFunc
CompleteMyOnboarding http.HandlerFunc CompleteMyOnboarding http.HandlerFunc
UpdateMyProfile http.HandlerFunc UpdateMyProfile http.HandlerFunc
ChangeMyCountry 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", http.MethodGet, h.ListAgencyCenterApplications)
r.profile("/agency-center/applications/{application_id}/review", http.MethodPost, h.ReviewAgencyCenterApplication) 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/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/overview", http.MethodGet, h.GetBDLeaderCenterOverview)
r.profile("/bd-leader-center/bds", http.MethodGet, h.ListBDLeaderCenterBDs) r.profile("/bd-leader-center/bds", http.MethodGet, h.ListBDLeaderCenterBDs)
r.profile("/bd-leader-center/agencies", http.MethodGet, h.ListBDLeaderCenterAgencies) 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/overview", http.MethodGet, h.GetBDCenterOverview)
r.profile("/bd-center/agencies", http.MethodGet, h.ListBDCenterAgencies) r.profile("/bd-center/agencies", http.MethodGet, h.ListBDCenterAgencies)
r.profile("/bd/invitations/agency", http.MethodPost, h.InviteBDAgency) 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.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
r.profile("/users/me/profile/update", "", h.UpdateMyProfile) r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
r.profile("/users/me/country/change", "", h.ChangeMyCountry) r.profile("/users/me/country/change", "", h.ChangeMyCountry)

View File

@ -415,6 +415,9 @@ type fakeUserHostClient struct {
lastInviteBD *userv1.InviteBDRequest lastInviteBD *userv1.InviteBDRequest
inviteBDResp *userv1.InviteBDResponse inviteBDResp *userv1.InviteBDResponse
inviteBDErr error inviteBDErr error
lastInviteHost *userv1.InviteHostRequest
inviteHostResp *userv1.InviteHostResponse
inviteHostErr error
lastLeaderBDs *userv1.ListBDLeaderBDsRequest lastLeaderBDs *userv1.ListBDLeaderBDsRequest
leaderBDsResp *userv1.ListBDLeaderBDsResponse leaderBDsResp *userv1.ListBDLeaderBDsResponse
leaderBDsErr error leaderBDsErr error
@ -424,6 +427,12 @@ type fakeUserHostClient struct {
lastBDAgencies *userv1.ListBDAgenciesRequest lastBDAgencies *userv1.ListBDAgenciesRequest
bdAgenciesResp *userv1.ListBDAgenciesResponse bdAgenciesResp *userv1.ListBDAgenciesResponse
bdAgenciesErr error bdAgenciesErr error
lastRoleInvitations *userv1.ListRoleInvitationsRequest
roleInvitationsResp *userv1.ListRoleInvitationsResponse
roleInvitationsErr error
lastProcessInvite *userv1.ProcessRoleInvitationRequest
processInviteResp *userv1.ProcessRoleInvitationResponse
processInviteErr error
last *userv1.GetCoinSellerProfileRequest last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile profile *userv1.CoinSellerProfile
err error err error
@ -1297,6 +1306,17 @@ func (f *fakeUserHostClient) InviteBD(_ context.Context, req *userv1.InviteBDReq
return &userv1.InviteBDResponse{}, nil 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) { func (f *fakeUserHostClient) ListBDLeaderBDs(_ context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) {
f.lastLeaderBDs = req f.lastLeaderBDs = req
if f.leaderBDsErr != nil { if f.leaderBDsErr != nil {
@ -1330,6 +1350,28 @@ func (f *fakeUserHostClient) ListBDAgencies(_ context.Context, req *userv1.ListB
return &userv1.ListBDAgenciesResponse{}, nil 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) { func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
f.lastHost = req f.lastHost = req
if f.hostErr != nil { 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{ hostClient := &fakeUserHostClient{
bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001}, bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd", Status: "active", RegionId: 1001},
inviteAgencyResp: &userv1.InviteAgencyResponse{Invitation: &userv1.RoleInvitation{ inviteAgencyResp: &userv1.InviteAgencyResponse{Invitation: &userv1.RoleInvitation{
InvitationId: 9001, InvitationId: 9001,
CommandId: "cmd-bd-invite-agency", CommandId: "cmd-bd-invite-agency",
InvitationType: "agency", InvitationType: "agency",
Status: "accepted", Status: "pending",
InviterUserId: 42, InviterUserId: 42,
TargetUserId: 99, TargetUserId: 99,
AgencyName: "Target Agency", AgencyName: "Target Agency",
@ -4429,7 +4471,7 @@ func TestBDCenterInviteAgencyDirectlyForwardsInviteAgency(t *testing.T) {
} }
data := response.Data.(map[string]any) data := response.Data.(map[string]any)
invitation := data["invitation"].(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) t.Fatalf("bd invite response mismatch: %+v", data)
} }
} }
@ -4441,7 +4483,7 @@ func TestBDLeaderInviteBDForwardsSelfInvite(t *testing.T) {
InvitationId: 9002, InvitationId: 9002,
CommandId: "cmd-bd-self", CommandId: "cmd-bd-self",
InvitationType: "bd", InvitationType: "bd",
Status: "accepted", Status: "pending",
InviterUserId: 42, InviterUserId: 42,
InviterBdUserId: 42, InviterBdUserId: 42,
TargetUserId: 42, TargetUserId: 42,
@ -4470,11 +4512,103 @@ func TestBDLeaderInviteBDForwardsSelfInvite(t *testing.T) {
} }
data := response.Data.(map[string]any) data := response.Data.(map[string]any)
invitation := data["invitation"].(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) 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) { func TestAgencyCenterOverviewRejectsNonOwner(t *testing.T) {
hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 42, IsManager: false, AgencyId: 7001}} hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 42, IsManager: false, AgencyId: 7001}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) 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 := 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-Request-ID", "req-auth-third")
request.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1") 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("User-Agent", "hyapp-test/1.0")
request.Header.Set("CF-IPCountry", "sg") request.Header.Set("CF-IPCountry", "sg")
recorder := httptest.NewRecorder() 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) { func TestThirdPartyLoginRateLimitByProviderAndIP(t *testing.T) {
userClient := &fakeUserAuthClient{} userClient := &fakeUserAuthClient{}
handler := NewHandler(&fakeRoomClient{}, userClient) 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())}) 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) { func (h *Handler) resolveCurrentOwnerAgency(writer http.ResponseWriter, request *http.Request) (*userv1.Agency, bool) {
if h.userHostClient == nil { if h.userHostClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") 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] target := users[targetUserID]
agencyName = firstBDLeaderNonBlank(target.Username, target.DisplayUserID, int64String(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{ resp, err := h.userHostClient.InviteAgency(request.Context(), &userv1.InviteAgencyRequest{
Meta: httpkit.UserMeta(request, ""), Meta: httpkit.UserMeta(request, ""),
CommandId: commandID, CommandId: commandID,
@ -457,6 +457,7 @@ func roleInvitationData(invitation *userv1.RoleInvitation) map[string]any {
"invitation_type": invitation.GetInvitationType(), "invitation_type": invitation.GetInvitationType(),
"status": invitation.GetStatus(), "status": invitation.GetStatus(),
"inviter_user_id": int64String(invitation.GetInviterUserId()), "inviter_user_id": int64String(invitation.GetInviterUserId()),
"inviter_bd_user_id": int64String(invitation.GetInviterBdUserId()),
"target_user_id": int64String(invitation.GetTargetUserId()), "target_user_id": int64String(invitation.GetTargetUserId()),
"region_id": invitation.GetRegionId(), "region_id": invitation.GetRegionId(),
"agency_name": invitation.GetAgencyName(), "agency_name": invitation.GetAgencyName(),

View File

@ -68,6 +68,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
ListAgencyCenterApplications: h.listAgencyCenterApplications, ListAgencyCenterApplications: h.listAgencyCenterApplications,
ReviewAgencyCenterApplication: h.reviewAgencyCenterApplication, ReviewAgencyCenterApplication: h.reviewAgencyCenterApplication,
RemoveAgencyCenterHost: h.removeAgencyCenterHost, RemoveAgencyCenterHost: h.removeAgencyCenterHost,
InviteAgencyCenterHost: h.inviteAgencyCenterHost,
GetBDLeaderCenterOverview: h.getBDLeaderCenterOverview, GetBDLeaderCenterOverview: h.getBDLeaderCenterOverview,
ListBDLeaderCenterBDs: h.listBDLeaderCenterBDs, ListBDLeaderCenterBDs: h.listBDLeaderCenterBDs,
ListBDLeaderCenterAgencies: h.listBDLeaderCenterAgencies, ListBDLeaderCenterAgencies: h.listBDLeaderCenterAgencies,
@ -76,6 +77,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
GetBDCenterOverview: h.getBDCenterOverview, GetBDCenterOverview: h.getBDCenterOverview,
ListBDCenterAgencies: h.listBDCenterAgencies, ListBDCenterAgencies: h.listBDCenterAgencies,
InviteBDAgency: h.inviteBDAgency, InviteBDAgency: h.inviteBDAgency,
ListMyRoleInvitations: h.listMyRoleInvitations,
ProcessMyRoleInvitation: h.processMyRoleInvitation,
CompleteMyOnboarding: h.completeMyOnboarding, CompleteMyOnboarding: h.completeMyOnboarding,
UpdateMyProfile: h.updateMyProfile, UpdateMyProfile: h.updateMyProfile,
ChangeMyCountry: h.changeMyCountry, 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" InvitationTypeAgency = "agency"
// InvitationTypeBD 表示邀请用户成为 BD。 // InvitationTypeBD 表示邀请用户成为 BD。
InvitationTypeBD = "bd" InvitationTypeBD = "bd"
// InvitationTypeHost 表示 Agency owner 邀请用户成为本 Agency 的 Host 成员。
InvitationTypeHost = "host"
// InvitationStatusPending 表示等待目标用户处理。 // InvitationStatusPending 表示等待目标用户处理。
InvitationStatusPending = "pending" InvitationStatusPending = "pending"
@ -97,6 +99,8 @@ const (
CommandTypeInviteAgency = "invite_agency" CommandTypeInviteAgency = "invite_agency"
// CommandTypeInviteBD 记录 BD 邀请命令。 // CommandTypeInviteBD 记录 BD 邀请命令。
CommandTypeInviteBD = "invite_bd" CommandTypeInviteBD = "invite_bd"
// CommandTypeInviteHost 记录 Agency owner 邀请 Host 加入团队的命令。
CommandTypeInviteHost = "invite_host"
// CommandTypeProcessRoleInvitation 记录邀请处理命令。 // CommandTypeProcessRoleInvitation 记录邀请处理命令。
CommandTypeProcessRoleInvitation = "process_role_invitation" CommandTypeProcessRoleInvitation = "process_role_invitation"
// CommandTypeCreateBDLeader 记录后台创建 BD Leader 命令。 // CommandTypeCreateBDLeader 记录后台创建 BD Leader 命令。

View File

@ -102,6 +102,29 @@ type InviteBDCommand struct {
NowMs int64 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 列表的条件。 // ListBDLeaderBDsCommand 描述 BD Leader 查询直属 BD 列表的条件。
type ListBDLeaderBDsCommand struct { type ListBDLeaderBDsCommand struct {
LeaderUserID int64 LeaderUserID int64

View File

@ -20,6 +20,8 @@ type Repository interface {
KickAgencyHost(ctx context.Context, command KickAgencyHostCommand) (hostdomain.KickAgencyHostResult, error) KickAgencyHost(ctx context.Context, command KickAgencyHostCommand) (hostdomain.KickAgencyHostResult, error)
InviteAgency(ctx context.Context, command InviteAgencyCommand) (hostdomain.RoleInvitation, error) InviteAgency(ctx context.Context, command InviteAgencyCommand) (hostdomain.RoleInvitation, error)
InviteBD(ctx context.Context, command InviteBDCommand) (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) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error)
ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error) ListBDLeaderAgencies(ctx context.Context, command ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error)
ListBDAgencies(ctx context.Context, command ListBDAgenciesCommand) ([]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) 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) { func (s *Service) InviteAgency(ctx context.Context, command InviteAgencyInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil { if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err return hostdomain.RoleInvitation{}, err
} }
// Agency 名称在邀请动作里固化;持久化层会在同一事务中写 accepted invitation、Agency、owner Host 和 owner membership // Agency 名称在邀请动作里固化,接受时按这份快照创建 Agency避免目标确认后前端再传入不同名称
agencyName := strings.TrimSpace(command.AgencyName) agencyName := strings.TrimSpace(command.AgencyName)
if strings.TrimSpace(command.CommandID) == "" || command.InviterUserID <= 0 || command.TargetUserID <= 0 || 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") 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, InviterUserID: command.InviterUserID,
TargetUserID: command.TargetUserID, TargetUserID: command.TargetUserID,
AgencyName: agencyName, AgencyName: agencyName,
AgencyID: s.idGenerator.NewInt64(),
MembershipID: s.idGenerator.NewInt64(),
EventID: s.nextEventIDRange(3),
NowMs: nowMs, NowMs: nowMs,
}) })
} }
// InviteBD 由 BD Leader 直接创建用户的 BD 身份,不再等待目标用户确认 // InviteBD 由 BD Leader 创建 BD 邀请;目标用户接受前不创建普通 BD 身份
func (s *Service) InviteBD(ctx context.Context, command InviteBDInput) (hostdomain.RoleInvitation, error) { func (s *Service) InviteBD(ctx context.Context, command InviteBDInput) (hostdomain.RoleInvitation, error) {
if err := s.requireWriteDependencies(); err != nil { if err := s.requireWriteDependencies(); err != nil {
return hostdomain.RoleInvitation{}, err return hostdomain.RoleInvitation{}, err
} }
// 负责人身份、区域、目标是否已有 BD 都交给持久化层在同一事务中校验,并和 accepted invitation 一起提交 // 负责人身份、区域、目标是否已有 BD 都交给持久化层在同一事务中校验,避免发出不可处理的待确认消息
if strings.TrimSpace(command.CommandID) == "" || command.InviterUserID <= 0 || command.TargetUserID <= 0 { 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") 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(), InvitationID: s.idGenerator.NewInt64(),
InviterUserID: command.InviterUserID, InviterUserID: command.InviterUserID,
TargetUserID: command.TargetUserID, TargetUserID: command.TargetUserID,
EventID: s.idGenerator.NewInt64(),
NowMs: nowMs, 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 列表。 // ListBDLeaderBDs 返回当前 BD Leader 直属的有效 BD 列表。
func (s *Service) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) { func (s *Service) ListBDLeaderBDs(ctx context.Context, command ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) {
if s.repository == nil { if s.repository == nil {

View File

@ -65,7 +65,7 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
InvitationID: command.InvitationID, InvitationID: command.InvitationID,
CommandID: command.CommandID, CommandID: command.CommandID,
InvitationType: hostdomain.InvitationTypeAgency, InvitationType: hostdomain.InvitationTypeAgency,
Status: hostdomain.InvitationStatusAccepted, Status: hostdomain.InvitationStatusPending,
InviterUserID: command.InviterUserID, InviterUserID: command.InviterUserID,
InviterBDUserID: inviter.UserID, InviterBDUserID: inviter.UserID,
TargetUserID: command.TargetUserID, TargetUserID: command.TargetUserID,
@ -73,42 +73,13 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
AgencyName: command.AgencyName, AgencyName: command.AgencyName,
ParentBDUserID: inviter.UserID, ParentBDUserID: inviter.UserID,
ParentLeaderUserID: parentLeaderUserID, ParentLeaderUserID: parentLeaderUserID,
ProcessedByUserID: command.InviterUserID,
ProcessReason: "direct_invite",
ProcessedAtMs: command.NowMs,
CreatedAtMs: command.NowMs, CreatedAtMs: command.NowMs,
UpdatedAtMs: 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 { if err := insertRoleInvitation(ctx, tx, invitation); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) 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{ if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
CommandID: command.CommandID, CommandID: command.CommandID,
CommandType: hostdomain.CommandTypeInviteAgency, CommandType: hostdomain.CommandTypeInviteAgency,
@ -171,47 +142,105 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
InvitationID: command.InvitationID, InvitationID: command.InvitationID,
CommandID: command.CommandID, CommandID: command.CommandID,
InvitationType: hostdomain.InvitationTypeBD, InvitationType: hostdomain.InvitationTypeBD,
Status: hostdomain.InvitationStatusAccepted, Status: hostdomain.InvitationStatusPending,
InviterUserID: command.InviterUserID, InviterUserID: command.InviterUserID,
InviterBDUserID: inviter.UserID, InviterBDUserID: inviter.UserID,
TargetUserID: command.TargetUserID, TargetUserID: command.TargetUserID,
RegionID: targetRegionID, RegionID: targetRegionID,
ParentLeaderUserID: inviter.UserID, ParentLeaderUserID: inviter.UserID,
ProcessedByUserID: command.InviterUserID,
ProcessReason: "direct_invite",
ProcessedAtMs: command.NowMs,
CreatedAtMs: command.NowMs, CreatedAtMs: command.NowMs,
UpdatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs,
} }
// 新 BD 邀请直接创建 BD 身份accepted invitation 只作为审计事实和幂等回放结果 // 新 BD 邀请只产生待确认消息BD 身份要等目标用户接受后才创建
if err := insertRoleInvitation(ctx, tx, invitation); err != nil { if err := insertRoleInvitation(ctx, tx, invitation); err != nil {
return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
} }
recordCommandResult := func() error { if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{
return insertCommandResult(ctx, tx, hostdomain.CommandResult{ CommandID: command.CommandID,
CommandID: command.CommandID, CommandType: hostdomain.CommandTypeInviteBD,
CommandType: hostdomain.CommandTypeInviteBD, ResultType: hostdomain.ResultTypeInvitation,
ResultType: hostdomain.ResultTypeInvitation, ResultID: invitation.InvitationID,
ResultID: invitation.InvitationID, CreatedAtMs: command.NowMs,
CreatedAtMs: command.NowMs, }); err != nil {
}) return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
} }
result := hostdomain.ProcessRoleInvitationResult{Invitation: invitation} if err := tx.Commit(); err != nil {
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 {
return hostdomain.RoleInvitation{}, err 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 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) return hostdomain.RoleInvitation{}, mapHostDuplicateError(err)
} }
if err := tx.Commit(); err != nil { 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") 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) { func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostservice.ProcessRoleInvitationCommand) (hostdomain.ProcessRoleInvitationResult, error) {
tx, err := r.db.BeginTx(ctx, nil) tx, err := r.db.BeginTx(ctx, nil)
if err != 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 { if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", invitation.TargetUserID, command.NowMs); err != nil {
return hostdomain.ProcessRoleInvitationResult{}, err 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 { } else {
return hostdomain.ProcessRoleInvitationResult{}, xerr.New(xerr.InvalidArgument, "invitation type is invalid") 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 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 { func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) error {
// BD 邀请同样以目标用户和发起 Leader 的 users 当前区域为准;旧邀请快照不能继续授权跨区接受。 // BD 邀请同样以目标用户和发起 Leader 的 users 当前区域为准;旧邀请快照不能继续授权跨区接受。
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE") 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...) 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 成员关系列表。 // ListAgencyMembers 读取 Agency 成员关系列表。
func (r *Repository) ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error) { 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) 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 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) { func scanRoleInvitation(scanner rowScanner) (hostdomain.RoleInvitation, error) {
var invitation hostdomain.RoleInvitation var invitation hostdomain.RoleInvitation
err := scanner.Scan( err := scanner.Scan(
@ -877,7 +908,10 @@ func processResultByInvitationID(ctx context.Context, q sqlQueryer, invitationID
if invitation.Status != hostdomain.InvitationStatusAccepted { if invitation.Status != hostdomain.InvitationStatusAccepted {
return result, nil 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 { if profile, err := queryHostProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil {
result.HostProfile = profile result.HostProfile = profile
if profile.CurrentMembershipID > 0 { if profile.CurrentMembershipID > 0 {
@ -889,10 +923,20 @@ func processResultByInvitationID(ctx context.Context, q sqlQueryer, invitationID
} else if ok { } else if ok {
result.Agency = agency 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 { if bd, err := queryBDProfile(ctx, q, "WHERE user_id = ?", invitation.TargetUserID); err == nil {
result.BDProfile = bd 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 return result, nil
} }

View File

@ -66,7 +66,7 @@ func TestBDLeaderSelfInviteFlowThroughGRPCUsesSplitIdentityTables(t *testing.T)
t.Fatalf("InviteBD self RPC failed: %v", err) t.Fatalf("InviteBD self RPC failed: %v", err)
} }
invitation := inviteResp.GetInvitation() 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) t.Fatalf("self invite response mismatch: %+v", invitation)
} }
retryInviteResp, err := server.InviteBD(ctx, &userv1.InviteBDRequest{ 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()) 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}) bdResp, err := server.GetBDProfile(ctx, &userv1.GetBDProfileRequest{Meta: meta, UserId: 10001, Role: hostdomain.BDRoleBD})
if err != nil { 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 { if bdResp.GetBdProfile().GetRole() != hostdomain.BDRoleBD || bdResp.GetBdProfile().GetParentLeaderUserId() != 10001 {
t.Fatalf("ordinary bd profile mismatch: %+v", bdResp.GetBdProfile()) 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 }, nil
} }
// InviteAgency 直接创建用户成为 Agency owner 的关系事实,不再等待目标用户确认 // InviteAgency 创建用户成为 Agency owner 的待确认邀请,目标接受后才创建关系事实。
func (s *Server) InviteAgency(ctx context.Context, req *userv1.InviteAgencyRequest) (*userv1.InviteAgencyResponse, error) { func (s *Server) InviteAgency(ctx context.Context, req *userv1.InviteAgencyRequest) (*userv1.InviteAgencyResponse, error) {
if s.hostSvc == nil { if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) 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 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) { func (s *Server) InviteBD(ctx context.Context, req *userv1.InviteBDRequest) (*userv1.InviteBDResponse, error) {
if s.hostSvc == nil { if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) 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 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 列表。 // ListBDLeaderBDs 返回当前 BD Leader 直属 BD 列表。
func (s *Server) ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) { func (s *Server) ListBDLeaderBDs(ctx context.Context, req *userv1.ListBDLeaderBDsRequest) (*userv1.ListBDLeaderBDsResponse, error) {
if s.hostSvc == nil { if s.hostSvc == nil {
@ -203,7 +222,30 @@ func (s *Server) ListBDAgencies(ctx context.Context, req *userv1.ListBDAgenciesR
return resp, nil 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) { func (s *Server) ProcessRoleInvitation(ctx context.Context, req *userv1.ProcessRoleInvitationRequest) (*userv1.ProcessRoleInvitationResponse, error) {
if s.hostSvc == nil { if s.hostSvc == nil {
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))