用户邀请
This commit is contained in:
parent
c52bc9d026
commit
e71194ad25
File diff suppressed because it is too large
Load Diff
@ -872,6 +872,29 @@ message CompleteOnboardingResponse {
|
|||||||
InviteBinding invite = 6;
|
InviteBinding invite = 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchInviteReferrerRequest 用当前用户国家约束可绑定邀请码的搜索范围。
|
||||||
|
message SearchInviteReferrerRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
int64 user_id = 2;
|
||||||
|
string invite_code = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message SearchInviteReferrerResponse {
|
||||||
|
User inviter = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindInviteReferrerRequest 只接受用户输入的邀请码,归属由 user-service 事务内读取。
|
||||||
|
message BindInviteReferrerRequest {
|
||||||
|
RequestMeta meta = 1;
|
||||||
|
int64 user_id = 2;
|
||||||
|
string invite_code = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message BindInviteReferrerResponse {
|
||||||
|
InviteBinding invite = 1;
|
||||||
|
User inviter = 2;
|
||||||
|
}
|
||||||
|
|
||||||
// BindPushTokenRequest 绑定当前登录用户的系统推送 token。
|
// BindPushTokenRequest 绑定当前登录用户的系统推送 token。
|
||||||
// push token 只服务离线触达,不作为登录凭证或设备身份凭证。
|
// push token 只服务离线触达,不作为登录凭证或设备身份凭证。
|
||||||
message BindPushTokenRequest {
|
message BindPushTokenRequest {
|
||||||
@ -1293,6 +1316,8 @@ service UserService {
|
|||||||
rpc ListManagerUserBlocks(ListManagerUserBlocksRequest) returns (ListManagerUserBlocksResponse);
|
rpc ListManagerUserBlocks(ListManagerUserBlocksRequest) returns (ListManagerUserBlocksResponse);
|
||||||
rpc UnblockManagerUser(UnblockManagerUserRequest) returns (UnblockManagerUserResponse);
|
rpc UnblockManagerUser(UnblockManagerUserRequest) returns (UnblockManagerUserResponse);
|
||||||
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
|
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
|
||||||
|
rpc SearchInviteReferrer(SearchInviteReferrerRequest) returns (SearchInviteReferrerResponse);
|
||||||
|
rpc BindInviteReferrer(BindInviteReferrerRequest) returns (BindInviteReferrerResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
// UserSocialService 承载用户访问、关注和好友关系。
|
// UserSocialService 承载用户访问、关注和好友关系。
|
||||||
|
|||||||
@ -36,6 +36,8 @@ const (
|
|||||||
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
|
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
|
||||||
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
|
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
|
||||||
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
|
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
|
||||||
|
UserService_SearchInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/SearchInviteReferrer"
|
||||||
|
UserService_BindInviteReferrer_FullMethodName = "/hyapp.user.v1.UserService/BindInviteReferrer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserServiceClient is the client API for UserService service.
|
// UserServiceClient is the client API for UserService service.
|
||||||
@ -61,6 +63,8 @@ type UserServiceClient interface {
|
|||||||
ListManagerUserBlocks(ctx context.Context, in *ListManagerUserBlocksRequest, opts ...grpc.CallOption) (*ListManagerUserBlocksResponse, error)
|
ListManagerUserBlocks(ctx context.Context, in *ListManagerUserBlocksRequest, opts ...grpc.CallOption) (*ListManagerUserBlocksResponse, error)
|
||||||
UnblockManagerUser(ctx context.Context, in *UnblockManagerUserRequest, opts ...grpc.CallOption) (*UnblockManagerUserResponse, error)
|
UnblockManagerUser(ctx context.Context, in *UnblockManagerUserRequest, opts ...grpc.CallOption) (*UnblockManagerUserResponse, error)
|
||||||
CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error)
|
CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error)
|
||||||
|
SearchInviteReferrer(ctx context.Context, in *SearchInviteReferrerRequest, opts ...grpc.CallOption) (*SearchInviteReferrerResponse, error)
|
||||||
|
BindInviteReferrer(ctx context.Context, in *BindInviteReferrerRequest, opts ...grpc.CallOption) (*BindInviteReferrerResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type userServiceClient struct {
|
type userServiceClient struct {
|
||||||
@ -241,6 +245,26 @@ func (c *userServiceClient) CompleteOnboarding(ctx context.Context, in *Complete
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *userServiceClient) SearchInviteReferrer(ctx context.Context, in *SearchInviteReferrerRequest, opts ...grpc.CallOption) (*SearchInviteReferrerResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(SearchInviteReferrerResponse)
|
||||||
|
err := c.cc.Invoke(ctx, UserService_SearchInviteReferrer_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *userServiceClient) BindInviteReferrer(ctx context.Context, in *BindInviteReferrerRequest, opts ...grpc.CallOption) (*BindInviteReferrerResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(BindInviteReferrerResponse)
|
||||||
|
err := c.cc.Invoke(ctx, UserService_BindInviteReferrer_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UserServiceServer is the server API for UserService service.
|
// UserServiceServer is the server API for UserService service.
|
||||||
// All implementations must embed UnimplementedUserServiceServer
|
// All implementations must embed UnimplementedUserServiceServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@ -264,6 +288,8 @@ type UserServiceServer interface {
|
|||||||
ListManagerUserBlocks(context.Context, *ListManagerUserBlocksRequest) (*ListManagerUserBlocksResponse, error)
|
ListManagerUserBlocks(context.Context, *ListManagerUserBlocksRequest) (*ListManagerUserBlocksResponse, error)
|
||||||
UnblockManagerUser(context.Context, *UnblockManagerUserRequest) (*UnblockManagerUserResponse, error)
|
UnblockManagerUser(context.Context, *UnblockManagerUserRequest) (*UnblockManagerUserResponse, error)
|
||||||
CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error)
|
CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error)
|
||||||
|
SearchInviteReferrer(context.Context, *SearchInviteReferrerRequest) (*SearchInviteReferrerResponse, error)
|
||||||
|
BindInviteReferrer(context.Context, *BindInviteReferrerRequest) (*BindInviteReferrerResponse, error)
|
||||||
mustEmbedUnimplementedUserServiceServer()
|
mustEmbedUnimplementedUserServiceServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -325,6 +351,12 @@ func (UnimplementedUserServiceServer) UnblockManagerUser(context.Context, *Unblo
|
|||||||
func (UnimplementedUserServiceServer) CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error) {
|
func (UnimplementedUserServiceServer) CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CompleteOnboarding not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method CompleteOnboarding not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedUserServiceServer) SearchInviteReferrer(context.Context, *SearchInviteReferrerRequest) (*SearchInviteReferrerResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method SearchInviteReferrer not implemented")
|
||||||
|
}
|
||||||
|
func (UnimplementedUserServiceServer) BindInviteReferrer(context.Context, *BindInviteReferrerRequest) (*BindInviteReferrerResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method BindInviteReferrer not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedUserServiceServer) mustEmbedUnimplementedUserServiceServer() {}
|
func (UnimplementedUserServiceServer) mustEmbedUnimplementedUserServiceServer() {}
|
||||||
func (UnimplementedUserServiceServer) testEmbeddedByValue() {}
|
func (UnimplementedUserServiceServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@ -652,6 +684,42 @@ func _UserService_CompleteOnboarding_Handler(srv interface{}, ctx context.Contex
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _UserService_SearchInviteReferrer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(SearchInviteReferrerRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(UserServiceServer).SearchInviteReferrer(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: UserService_SearchInviteReferrer_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(UserServiceServer).SearchInviteReferrer(ctx, req.(*SearchInviteReferrerRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func _UserService_BindInviteReferrer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(BindInviteReferrerRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(UserServiceServer).BindInviteReferrer(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: UserService_BindInviteReferrer_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(UserServiceServer).BindInviteReferrer(ctx, req.(*BindInviteReferrerRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// UserService_ServiceDesc is the grpc.ServiceDesc for UserService service.
|
// UserService_ServiceDesc is the grpc.ServiceDesc for UserService service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@ -727,6 +795,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "CompleteOnboarding",
|
MethodName: "CompleteOnboarding",
|
||||||
Handler: _UserService_CompleteOnboarding_Handler,
|
Handler: _UserService_CompleteOnboarding_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "SearchInviteReferrer",
|
||||||
|
Handler: _UserService_SearchInviteReferrer_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "BindInviteReferrer",
|
||||||
|
Handler: _UserService_BindInviteReferrer_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{},
|
Streams: []grpc.StreamDesc{},
|
||||||
Metadata: "proto/user/v1/user.proto",
|
Metadata: "proto/user/v1/user.proto",
|
||||||
|
|||||||
@ -86,6 +86,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
var userClient client.UserAuthClient = client.NewGRPCUserAuthClient(userConn)
|
var userClient client.UserAuthClient = client.NewGRPCUserAuthClient(userConn)
|
||||||
var userIdentityClient client.UserIdentityClient = client.NewGRPCUserIdentityClient(userConn)
|
var userIdentityClient client.UserIdentityClient = client.NewGRPCUserIdentityClient(userConn)
|
||||||
var userProfileClient client.UserProfileClient = client.NewGRPCUserProfileClient(userConn)
|
var userProfileClient client.UserProfileClient = client.NewGRPCUserProfileClient(userConn)
|
||||||
|
var userInviteClient client.UserInviteClient = client.NewGRPCUserInviteClient(userConn)
|
||||||
var userDeviceClient client.UserDeviceClient = client.NewGRPCUserDeviceClient(userConn)
|
var userDeviceClient client.UserDeviceClient = client.NewGRPCUserDeviceClient(userConn)
|
||||||
var userCountryQueryClient client.UserCountryQueryClient = client.NewGRPCUserCountryQueryClient(userConn)
|
var userCountryQueryClient client.UserCountryQueryClient = client.NewGRPCUserCountryQueryClient(userConn)
|
||||||
var userRegionClient client.UserRegionClient = client.NewGRPCUserRegionClient(userConn)
|
var userRegionClient client.UserRegionClient = client.NewGRPCUserRegionClient(userConn)
|
||||||
@ -149,6 +150,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
handler.SetRoomQueryClient(roomQueryClient)
|
handler.SetRoomQueryClient(roomQueryClient)
|
||||||
handler.SetUserDeviceClient(userDeviceClient)
|
handler.SetUserDeviceClient(userDeviceClient)
|
||||||
|
handler.SetUserInviteClient(userInviteClient)
|
||||||
handler.SetUserCountryQueryClient(userCountryQueryClient)
|
handler.SetUserCountryQueryClient(userCountryQueryClient)
|
||||||
handler.SetUserRegionClient(userRegionClient)
|
handler.SetUserRegionClient(userRegionClient)
|
||||||
handler.SetUserHostClient(userHostClient)
|
handler.SetUserHostClient(userHostClient)
|
||||||
|
|||||||
@ -50,6 +50,12 @@ type UserProfileClient interface {
|
|||||||
UnblockManagerUser(ctx context.Context, req *userv1.UnblockManagerUserRequest) (*userv1.UnblockManagerUserResponse, error)
|
UnblockManagerUser(ctx context.Context, req *userv1.UnblockManagerUserRequest) (*userv1.UnblockManagerUserResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UserInviteClient 抽象 H5 邀请页对 user-service 邀请人搜索和绑定能力的依赖。
|
||||||
|
type UserInviteClient interface {
|
||||||
|
SearchInviteReferrer(ctx context.Context, req *userv1.SearchInviteReferrerRequest) (*userv1.SearchInviteReferrerResponse, error)
|
||||||
|
BindInviteReferrer(ctx context.Context, req *userv1.BindInviteReferrerRequest) (*userv1.BindInviteReferrerResponse, error)
|
||||||
|
}
|
||||||
|
|
||||||
// UserSocialClient 抽象 gateway 对 user-service 访问、关注和好友关系能力的依赖。
|
// UserSocialClient 抽象 gateway 对 user-service 访问、关注和好友关系能力的依赖。
|
||||||
type UserSocialClient interface {
|
type UserSocialClient interface {
|
||||||
RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error)
|
RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error)
|
||||||
@ -142,6 +148,10 @@ type grpcUserProfileClient struct {
|
|||||||
client userv1.UserServiceClient
|
client userv1.UserServiceClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type grpcUserInviteClient struct {
|
||||||
|
client userv1.UserServiceClient
|
||||||
|
}
|
||||||
|
|
||||||
type grpcUserSocialClient struct {
|
type grpcUserSocialClient struct {
|
||||||
client userv1.UserSocialServiceClient
|
client userv1.UserSocialServiceClient
|
||||||
}
|
}
|
||||||
@ -195,6 +205,13 @@ func NewGRPCUserProfileClient(conn *grpc.ClientConn) UserProfileClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewGRPCUserInviteClient 用 gRPC 连接构造 H5 邀请页专用 client。
|
||||||
|
func NewGRPCUserInviteClient(conn *grpc.ClientConn) UserInviteClient {
|
||||||
|
return &grpcUserInviteClient{
|
||||||
|
client: userv1.NewUserServiceClient(conn),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewGRPCUserSocialClient 用 gRPC 连接构造 user-service social client。
|
// NewGRPCUserSocialClient 用 gRPC 连接构造 user-service social client。
|
||||||
func NewGRPCUserSocialClient(conn *grpc.ClientConn) UserSocialClient {
|
func NewGRPCUserSocialClient(conn *grpc.ClientConn) UserSocialClient {
|
||||||
return &grpcUserSocialClient{
|
return &grpcUserSocialClient{
|
||||||
@ -371,6 +388,14 @@ func (c *grpcUserProfileClient) UnblockManagerUser(ctx context.Context, req *use
|
|||||||
return c.client.UnblockManagerUser(ctx, req)
|
return c.client.UnblockManagerUser(ctx, req)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *grpcUserInviteClient) SearchInviteReferrer(ctx context.Context, req *userv1.SearchInviteReferrerRequest) (*userv1.SearchInviteReferrerResponse, error) {
|
||||||
|
return c.client.SearchInviteReferrer(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *grpcUserInviteClient) BindInviteReferrer(ctx context.Context, req *userv1.BindInviteReferrerRequest) (*userv1.BindInviteReferrerResponse, error) {
|
||||||
|
return c.client.BindInviteReferrer(ctx, req)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *grpcUserSocialClient) RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
|
func (c *grpcUserSocialClient) RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
|
||||||
return c.client.RecordProfileVisit(ctx, req)
|
return c.client.RecordProfileVisit(ctx, req)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -137,6 +137,10 @@ func (f *fakeInviteActivityUserProfileClient) UpdateUserProfileBackground(contex
|
|||||||
return &userv1.UpdateUserProfileBackgroundResponse{}, nil
|
return &userv1.UpdateUserProfileBackgroundResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeInviteActivityUserProfileClient) UpdateUserContactInfo(context.Context, *userv1.UpdateUserContactInfoRequest) (*userv1.UpdateUserContactInfoResponse, error) {
|
||||||
|
return &userv1.UpdateUserContactInfoResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeInviteActivityUserProfileClient) ChangeUserCountry(context.Context, *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
|
func (f *fakeInviteActivityUserProfileClient) ChangeUserCountry(context.Context, *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
|
||||||
return &userv1.ChangeUserCountryResponse{}, nil
|
return &userv1.ChangeUserCountryResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -28,6 +28,7 @@ type Handler struct {
|
|||||||
userClient client.UserAuthClient
|
userClient client.UserAuthClient
|
||||||
userIdentityClient client.UserIdentityClient
|
userIdentityClient client.UserIdentityClient
|
||||||
userProfileClient client.UserProfileClient
|
userProfileClient client.UserProfileClient
|
||||||
|
userInviteClient client.UserInviteClient
|
||||||
userSocialClient client.UserSocialClient
|
userSocialClient client.UserSocialClient
|
||||||
userCPClient client.UserCPClient
|
userCPClient client.UserCPClient
|
||||||
userDeviceClient client.UserDeviceClient
|
userDeviceClient client.UserDeviceClient
|
||||||
@ -190,6 +191,11 @@ func (h *Handler) SetUserDeviceClient(userDeviceClient client.UserDeviceClient)
|
|||||||
h.userDeviceClient = userDeviceClient
|
h.userDeviceClient = userDeviceClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUserInviteClient 注入 H5 邀请页搜索和绑定邀请人 client。
|
||||||
|
func (h *Handler) SetUserInviteClient(userInviteClient client.UserInviteClient) {
|
||||||
|
h.userInviteClient = userInviteClient
|
||||||
|
}
|
||||||
|
|
||||||
// SetUserHostClient 注入 user-service host domain 身份查询 client。
|
// SetUserHostClient 注入 user-service host domain 身份查询 client。
|
||||||
func (h *Handler) SetUserHostClient(userHostClient client.UserHostClient) {
|
func (h *Handler) SetUserHostClient(userHostClient client.UserHostClient) {
|
||||||
h.userHostClient = userHostClient
|
h.userHostClient = userHostClient
|
||||||
|
|||||||
@ -73,6 +73,8 @@ type UserHandlers struct {
|
|||||||
BatchRoomDisplayProfiles http.HandlerFunc
|
BatchRoomDisplayProfiles http.HandlerFunc
|
||||||
GetMyOverview http.HandlerFunc
|
GetMyOverview http.HandlerFunc
|
||||||
GetMyInviteOverview http.HandlerFunc
|
GetMyInviteOverview http.HandlerFunc
|
||||||
|
SearchInviteReferrer http.HandlerFunc
|
||||||
|
BindInviteReferrer http.HandlerFunc
|
||||||
GetMyGiftWall http.HandlerFunc
|
GetMyGiftWall http.HandlerFunc
|
||||||
GetMyIdentity http.HandlerFunc
|
GetMyIdentity http.HandlerFunc
|
||||||
GetMyHostIdentity http.HandlerFunc
|
GetMyHostIdentity http.HandlerFunc
|
||||||
@ -420,6 +422,8 @@ func (r routes) registerUserRoutes() {
|
|||||||
r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles)
|
r.profile("/users/room-display-profiles:batch", http.MethodGet, h.BatchRoomDisplayProfiles)
|
||||||
r.profile("/users/me/overview", "", h.GetMyOverview)
|
r.profile("/users/me/overview", "", h.GetMyOverview)
|
||||||
r.profile("/users/me/invite-overview", http.MethodGet, h.GetMyInviteOverview)
|
r.profile("/users/me/invite-overview", http.MethodGet, h.GetMyInviteOverview)
|
||||||
|
r.profile("/users/me/invite-referrer/search", http.MethodGet, h.SearchInviteReferrer)
|
||||||
|
r.profile("/users/me/invite-referrer/bind", http.MethodPost, h.BindInviteReferrer)
|
||||||
r.profile("/users/me/gift-wall", http.MethodGet, h.GetMyGiftWall)
|
r.profile("/users/me/gift-wall", http.MethodGet, h.GetMyGiftWall)
|
||||||
r.auth("/users/me/identity", "", h.GetMyIdentity)
|
r.auth("/users/me/identity", "", h.GetMyIdentity)
|
||||||
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
|
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
|
||||||
|
|||||||
@ -160,6 +160,29 @@ func TestGetMyOverviewProjectsOrdinaryUserEntriesAndZeroWallet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetMyOverviewHidesAgencyCenterForOrdinaryHost(t *testing.T) {
|
||||||
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
|
handler.SetWalletClient(&fakeWalletClient{})
|
||||||
|
handler.SetUserHostClient(&fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{
|
||||||
|
UserId: 42,
|
||||||
|
IsHost: true,
|
||||||
|
IsAgency: false,
|
||||||
|
HostStatus: "active",
|
||||||
|
}})
|
||||||
|
handler.SetMessageInboxClient(&fakeMessageInboxClient{})
|
||||||
|
|
||||||
|
data := performOverviewRequest(t, handler, "req-overview-host-only")
|
||||||
|
|
||||||
|
roles := data["roles"].(map[string]any)
|
||||||
|
if roles["is_host"] != true || roles["is_agency"] != false {
|
||||||
|
t.Fatalf("ordinary host roles mismatch: %+v", roles)
|
||||||
|
}
|
||||||
|
entries := data["entries"].([]any)
|
||||||
|
if !overviewEntryVisible(entries, "host_center") || overviewEntryVisible(entries, "agency_center") || overviewEntryVisible(entries, "apply_host") {
|
||||||
|
t.Fatalf("ordinary host must only expose host center: %+v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetMyOverviewDegradesOptionalUpstreamFailures(t *testing.T) {
|
func TestGetMyOverviewDegradesOptionalUpstreamFailures(t *testing.T) {
|
||||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{statsErr: errors.New("stats down")})
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{statsErr: errors.New("stats down")})
|
||||||
handler.SetWalletClient(&fakeWalletClient{err: errors.New("wallet down")})
|
handler.SetWalletClient(&fakeWalletClient{err: errors.New("wallet down")})
|
||||||
|
|||||||
@ -4688,6 +4688,15 @@ func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
|
|||||||
ContactInfo: "+65 1234 5678",
|
ContactInfo: "+65 1234 5678",
|
||||||
UpdatedAtMs: 1700000001000,
|
UpdatedAtMs: 1700000001000,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
UserId: 802,
|
||||||
|
DisplayUserId: "900802",
|
||||||
|
Username: "Seller Without Contact",
|
||||||
|
RegionId: 30,
|
||||||
|
Status: "active",
|
||||||
|
MerchantAssetType: "COIN_SELLER_COIN",
|
||||||
|
UpdatedAtMs: 1700000002000,
|
||||||
|
},
|
||||||
}}}
|
}}}
|
||||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||||
handler.SetUserHostClient(hostClient)
|
handler.SetUserHostClient(hostClient)
|
||||||
@ -4712,9 +4721,13 @@ func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
|
|||||||
data := response.Data.(map[string]any)
|
data := response.Data.(map[string]any)
|
||||||
items := data["items"].([]any)
|
items := data["items"].([]any)
|
||||||
first := items[0].(map[string]any)
|
first := items[0].(map[string]any)
|
||||||
if data["total"] != float64(1) || first["user_id"] != "801" || first["display_user_id"] != "900801" || first["username"] != "Seller One" || first["country_id"] != float64(86) || first["status"] != "active" || first["whatsapp_phone"] != "+65 1234 5678" || first["whatsapp_url"] != "https://wa.me/6512345678" {
|
if data["total"] != float64(2) || first["user_id"] != "801" || first["display_user_id"] != "900801" || first["username"] != "Seller One" || first["country_id"] != float64(86) || first["status"] != "active" || first["whatsapp_phone"] != "+65 1234 5678" || first["whatsapp_url"] != "https://wa.me/6512345678" {
|
||||||
t.Fatalf("coin seller list response mismatch: %+v", response)
|
t.Fatalf("coin seller list response mismatch: %+v", response)
|
||||||
}
|
}
|
||||||
|
second := items[1].(map[string]any)
|
||||||
|
if second["whatsapp_phone"] != "" || second["whatsapp_url"] != "" {
|
||||||
|
t.Fatalf("empty contact whatsapp fields must be stable empty strings: %+v", second)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSalaryWalletSearchAllowsSelfCoinSeller(t *testing.T) {
|
func TestSalaryWalletSearchAllowsSelfCoinSeller(t *testing.T) {
|
||||||
|
|||||||
@ -59,6 +59,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
|||||||
userAPI := userapi.New(userapi.Config{
|
userAPI := userapi.New(userapi.Config{
|
||||||
UserIdentityClient: h.userIdentityClient,
|
UserIdentityClient: h.userIdentityClient,
|
||||||
UserProfileClient: h.userProfileClient,
|
UserProfileClient: h.userProfileClient,
|
||||||
|
UserInviteClient: h.userInviteClient,
|
||||||
UserSocialClient: h.userSocialClient,
|
UserSocialClient: h.userSocialClient,
|
||||||
UserCPClient: h.userCPClient,
|
UserCPClient: h.userCPClient,
|
||||||
UserCountryClient: h.userCountryClient,
|
UserCountryClient: h.userCountryClient,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import (
|
|||||||
type Handler struct {
|
type Handler struct {
|
||||||
userIdentityClient client.UserIdentityClient
|
userIdentityClient client.UserIdentityClient
|
||||||
userProfileClient client.UserProfileClient
|
userProfileClient client.UserProfileClient
|
||||||
|
userInviteClient client.UserInviteClient
|
||||||
userSocialClient client.UserSocialClient
|
userSocialClient client.UserSocialClient
|
||||||
userCPClient client.UserCPClient
|
userCPClient client.UserCPClient
|
||||||
userCountryClient client.UserCountryQueryClient
|
userCountryClient client.UserCountryQueryClient
|
||||||
@ -25,6 +26,7 @@ type Handler struct {
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
UserIdentityClient client.UserIdentityClient
|
UserIdentityClient client.UserIdentityClient
|
||||||
UserProfileClient client.UserProfileClient
|
UserProfileClient client.UserProfileClient
|
||||||
|
UserInviteClient client.UserInviteClient
|
||||||
UserSocialClient client.UserSocialClient
|
UserSocialClient client.UserSocialClient
|
||||||
UserCPClient client.UserCPClient
|
UserCPClient client.UserCPClient
|
||||||
UserCountryClient client.UserCountryQueryClient
|
UserCountryClient client.UserCountryQueryClient
|
||||||
@ -39,6 +41,7 @@ func New(config Config) *Handler {
|
|||||||
return &Handler{
|
return &Handler{
|
||||||
userIdentityClient: config.UserIdentityClient,
|
userIdentityClient: config.UserIdentityClient,
|
||||||
userProfileClient: config.UserProfileClient,
|
userProfileClient: config.UserProfileClient,
|
||||||
|
userInviteClient: config.UserInviteClient,
|
||||||
userSocialClient: config.UserSocialClient,
|
userSocialClient: config.UserSocialClient,
|
||||||
userCPClient: config.UserCPClient,
|
userCPClient: config.UserCPClient,
|
||||||
userCountryClient: config.UserCountryClient,
|
userCountryClient: config.UserCountryClient,
|
||||||
@ -55,6 +58,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
|||||||
ResolveDisplayUserID: h.resolveDisplayUserID,
|
ResolveDisplayUserID: h.resolveDisplayUserID,
|
||||||
GetMyOverview: h.getMyOverview,
|
GetMyOverview: h.getMyOverview,
|
||||||
GetMyInviteOverview: h.getMyInviteOverview,
|
GetMyInviteOverview: h.getMyInviteOverview,
|
||||||
|
SearchInviteReferrer: h.searchInviteReferrer,
|
||||||
|
BindInviteReferrer: h.bindInviteReferrer,
|
||||||
GetMyIdentity: h.getMyIdentity,
|
GetMyIdentity: h.getMyIdentity,
|
||||||
GetMyHostIdentity: h.getMyHostIdentity,
|
GetMyHostIdentity: h.getMyHostIdentity,
|
||||||
GetMyRoleSummary: h.getMyRoleSummary,
|
GetMyRoleSummary: h.getMyRoleSummary,
|
||||||
|
|||||||
@ -0,0 +1,77 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bindInviteReferrerData struct {
|
||||||
|
Invite *inviteBindingData `json:"invite,omitempty"`
|
||||||
|
Inviter userProfileData `json:"inviter"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchInviteReferrer 只按邀请码暴露当前用户同国家、可绑定的邀请人资料。
|
||||||
|
func (h *Handler) searchInviteReferrer(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.userInviteClient == nil {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inviteCode := strings.TrimSpace(request.URL.Query().Get("invite_code"))
|
||||||
|
if inviteCode == "" {
|
||||||
|
inviteCode = strings.TrimSpace(request.URL.Query().Get("keyword"))
|
||||||
|
}
|
||||||
|
if inviteCode == "" {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.userInviteClient.SearchInviteReferrer(request.Context(), &userv1.SearchInviteReferrerRequest{
|
||||||
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
|
UserId: auth.UserIDFromContext(request.Context()),
|
||||||
|
InviteCode: inviteCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httpkit.WriteOK(writer, request, map[string]any{"inviter": profileData(resp.GetInviter(), 0)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// bindInviteReferrer 在资料完成后按邀请码绑定邀请归因;跨国家、自邀和重复改绑由 user-service 事务内拒绝。
|
||||||
|
func (h *Handler) bindInviteReferrer(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if h.userInviteClient == nil {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
InviteCode string `json:"invite_code"`
|
||||||
|
}
|
||||||
|
if !httpkit.Decode(writer, request, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body.InviteCode = strings.TrimSpace(body.InviteCode)
|
||||||
|
if body.InviteCode == "" {
|
||||||
|
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.userInviteClient.BindInviteReferrer(request.Context(), &userv1.BindInviteReferrerRequest{
|
||||||
|
Meta: httpkit.UserMeta(request, ""),
|
||||||
|
UserId: auth.UserIDFromContext(request.Context()),
|
||||||
|
InviteCode: body.InviteCode,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
httpkit.WriteRPCError(writer, request, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
httpkit.WriteOK(writer, request, bindInviteReferrerData{
|
||||||
|
Invite: inviteBindingDataFromProto(resp.GetInvite()),
|
||||||
|
Inviter: profileData(resp.GetInviter(), 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
package userapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
userv1 "hyapp.local/api/proto/user/v1"
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/services/gateway-service/internal/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeInviteReferrerClient struct {
|
||||||
|
lastSearch *userv1.SearchInviteReferrerRequest
|
||||||
|
lastBind *userv1.BindInviteReferrerRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeInviteReferrerClient) SearchInviteReferrer(_ context.Context, req *userv1.SearchInviteReferrerRequest) (*userv1.SearchInviteReferrerResponse, error) {
|
||||||
|
f.lastSearch = req
|
||||||
|
return &userv1.SearchInviteReferrerResponse{
|
||||||
|
Inviter: &userv1.User{
|
||||||
|
UserId: 9001,
|
||||||
|
DisplayUserId: "163900",
|
||||||
|
Username: "Alice",
|
||||||
|
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeInviteReferrerClient) BindInviteReferrer(_ context.Context, req *userv1.BindInviteReferrerRequest) (*userv1.BindInviteReferrerResponse, error) {
|
||||||
|
f.lastBind = req
|
||||||
|
return &userv1.BindInviteReferrerResponse{
|
||||||
|
Invite: &userv1.InviteBinding{
|
||||||
|
Bound: true,
|
||||||
|
InviteCode: req.GetInviteCode(),
|
||||||
|
InviterUserId: 9001,
|
||||||
|
},
|
||||||
|
Inviter: &userv1.User{
|
||||||
|
UserId: 9001,
|
||||||
|
DisplayUserId: "163900",
|
||||||
|
Username: "Alice",
|
||||||
|
Status: userv1.UserStatus_USER_STATUS_ACTIVE,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteReferrerSearchUsesInviteCode(t *testing.T) {
|
||||||
|
client := &fakeInviteReferrerClient{}
|
||||||
|
handler := New(Config{UserInviteClient: client})
|
||||||
|
request := newInviteReferrerRequest(http.MethodGet, "/api/v1/users/me/invite-referrer/search?invite_code=A1B2C3", "")
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.searchInviteReferrer(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("search status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if client.lastSearch == nil || client.lastSearch.GetUserId() != 10001 || client.lastSearch.GetInviteCode() != "A1B2C3" {
|
||||||
|
t.Fatalf("search request mismatch: %+v", client.lastSearch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteReferrerBindUsesInviteCode(t *testing.T) {
|
||||||
|
client := &fakeInviteReferrerClient{}
|
||||||
|
handler := New(Config{UserInviteClient: client})
|
||||||
|
request := newInviteReferrerRequest(http.MethodPost, "/api/v1/users/me/invite-referrer/bind", `{"invite_code":"A1B2C3"}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.bindInviteReferrer(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bind status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if client.lastBind == nil || client.lastBind.GetUserId() != 10001 || client.lastBind.GetInviteCode() != "A1B2C3" {
|
||||||
|
t.Fatalf("bind request mismatch: %+v", client.lastBind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInviteReferrerBindRejectsInviterUserID(t *testing.T) {
|
||||||
|
client := &fakeInviteReferrerClient{}
|
||||||
|
handler := New(Config{UserInviteClient: client})
|
||||||
|
request := newInviteReferrerRequest(http.MethodPost, "/api/v1/users/me/invite-referrer/bind", `{"inviter_user_id":"9001"}`)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.bindInviteReferrer(recorder, request)
|
||||||
|
|
||||||
|
if recorder.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("bind status = %d body=%s", recorder.Code, recorder.Body.String())
|
||||||
|
}
|
||||||
|
if client.lastBind != nil {
|
||||||
|
t.Fatalf("inviter_user_id body must not be forwarded: %+v", client.lastBind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInviteReferrerRequest(method string, target string, body string) *http.Request {
|
||||||
|
var reader *strings.Reader
|
||||||
|
if body == "" {
|
||||||
|
reader = strings.NewReader("")
|
||||||
|
} else {
|
||||||
|
reader = strings.NewReader(body)
|
||||||
|
}
|
||||||
|
request := httptest.NewRequest(method, target, reader)
|
||||||
|
request.Header.Set("Content-Type", "application/json")
|
||||||
|
ctx := appcode.WithContext(request.Context(), "lalu")
|
||||||
|
ctx = auth.WithUserID(ctx, 10001)
|
||||||
|
return request.WithContext(ctx)
|
||||||
|
}
|
||||||
@ -24,9 +24,10 @@ type coinSellerData struct {
|
|||||||
RegionName string `json:"region_name,omitempty"`
|
RegionName string `json:"region_name,omitempty"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
MerchantAssetType string `json:"merchant_asset_type"`
|
MerchantAssetType string `json:"merchant_asset_type"`
|
||||||
WhatsappPhone string `json:"whatsapp_phone,omitempty"`
|
// WhatsApp 字段固定输出,空联系方式也保留空字符串,避免充值页逐项读取时出现 schema 漂移。
|
||||||
WhatsappURL string `json:"whatsapp_url,omitempty"`
|
WhatsappPhone string `json:"whatsapp_phone"`
|
||||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
WhatsappURL string `json:"whatsapp_url"`
|
||||||
|
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// listCoinSellers 返回当前用户所在区域内所有启用币商。
|
// listCoinSellers 返回当前用户所在区域内所有启用币商。
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
package walletapi
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCoinSellerWhatsAppURL(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "phone number", raw: "+65 1234 5678", want: "https://wa.me/6512345678"},
|
||||||
|
{name: "wa me link", raw: "https://wa.me/6512345678", want: "https://wa.me/6512345678"},
|
||||||
|
{name: "whatsapp scheme", raw: "whatsapp://send?phone=6512345678", want: "whatsapp://send?phone=6512345678"},
|
||||||
|
{name: "blank contact", raw: " ", want: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := coinSellerWhatsAppURL(tt.raw); got != tt.want {
|
||||||
|
t.Fatalf("coinSellerWhatsAppURL(%q)=%q, want %q", tt.raw, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -185,7 +185,7 @@ type AdminUpdateRoom struct {
|
|||||||
|
|
||||||
func (AdminUpdateRoom) Type() string { return "admin_update_room" }
|
func (AdminUpdateRoom) Type() string { return "admin_update_room" }
|
||||||
|
|
||||||
// AdminDeleteRoom 定义后台删除房间请求;删除是 Room Cell 终态,不直接删物理表。
|
// AdminDeleteRoom 定义后台删除房间请求;命令先经过 Room Cell 串行确认,再由 repository 清理恢复来源。
|
||||||
type AdminDeleteRoom struct {
|
type AdminDeleteRoom struct {
|
||||||
Base
|
Base
|
||||||
AdminID uint64 `json:"admin_id,omitempty"`
|
AdminID uint64 `json:"admin_id,omitempty"`
|
||||||
|
|||||||
@ -79,17 +79,29 @@ func (s *Service) AdminDeleteRoom(ctx context.Context, req *roomv1.AdminDeleteRo
|
|||||||
AdminID: req.GetAdminId(),
|
AdminID: req.GetAdminId(),
|
||||||
AdminName: strings.TrimSpace(req.GetAdminName()),
|
AdminName: strings.TrimSpace(req.GetAdminName()),
|
||||||
}
|
}
|
||||||
patch := command.AdminUpdateRoom{
|
result, err := s.mutateRoom(ctx, cmd, false, func(_ time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||||
Base: cmd.Base,
|
if current == nil {
|
||||||
Status: stringPtr(state.RoomStatusDeleted),
|
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
|
||||||
CloseReason: "admin_deleted",
|
}
|
||||||
AdminID: cmd.AdminID,
|
// 删除不是“关闭”的另一种展示状态,而是后台确认移除房间恢复来源;状态只服务本次响应和内存 Cell 收尾。
|
||||||
AdminName: cmd.AdminName,
|
current.Status = state.RoomStatusDeleted
|
||||||
}
|
current.OnlineUsers = map[int64]*state.RoomUserState{}
|
||||||
result, err := s.applyAdminRoomMutation(ctx, cmd, patch)
|
for index := range current.MicSeats {
|
||||||
|
current.ClearMicSession(index)
|
||||||
|
}
|
||||||
|
current.Version++
|
||||||
|
return mutationResult{
|
||||||
|
snapshot: current.ToProto(),
|
||||||
|
deleteRoom: true,
|
||||||
|
}, nil, nil
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if result.applied {
|
||||||
|
s.deleteRoomListCacheBestEffort(ctx, cmd.RoomID())
|
||||||
|
s.forgetLoadedRoom(ctx, cmd.RoomID())
|
||||||
|
}
|
||||||
return &roomv1.AdminDeleteRoomResponse{
|
return &roomv1.AdminDeleteRoomResponse{
|
||||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||||
Room: result.snapshot,
|
Room: result.snapshot,
|
||||||
@ -143,7 +155,7 @@ func (s *Service) adminUpdateCommand(req *roomv1.AdminUpdateRoomRequest) (comman
|
|||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
value := strings.TrimSpace(req.GetStatus())
|
value := strings.TrimSpace(req.GetStatus())
|
||||||
switch value {
|
switch value {
|
||||||
case state.RoomStatusActive, state.RoomStatusClosed, state.RoomStatusDeleted:
|
case state.RoomStatusActive, state.RoomStatusClosed:
|
||||||
cmd.Status = &value
|
cmd.Status = &value
|
||||||
default:
|
default:
|
||||||
return command.AdminUpdateRoom{}, xerr.New(xerr.InvalidArgument, "room status is invalid")
|
return command.AdminUpdateRoom{}, xerr.New(xerr.InvalidArgument, "room status is invalid")
|
||||||
@ -294,7 +306,3 @@ func adminRoomListItemProto(item AdminRoomListEntry) *roomv1.AdminRoomListItem {
|
|||||||
UpdatedAtMs: item.UpdatedAtMS,
|
UpdatedAtMs: item.UpdatedAtMS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func stringPtr(value string) *string {
|
|
||||||
return &value
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,12 +2,14 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/idgen"
|
"hyapp/pkg/idgen"
|
||||||
|
"hyapp/pkg/logx"
|
||||||
"hyapp/services/room-service/internal/room/cell"
|
"hyapp/services/room-service/internal/room/cell"
|
||||||
"hyapp/services/room-service/internal/room/command"
|
"hyapp/services/room-service/internal/room/command"
|
||||||
"hyapp/services/room-service/internal/room/state"
|
"hyapp/services/room-service/internal/room/state"
|
||||||
@ -37,6 +39,29 @@ func (s *Service) loadCell(ctx context.Context, roomID string) *cell.RoomCell {
|
|||||||
return s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)]
|
return s.cells[runtimeRoomKey(appcode.FromContext(ctx), roomID)]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) forgetLoadedRoom(ctx context.Context, roomID string) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app := appcode.FromContext(ctx)
|
||||||
|
key := runtimeRoomKey(app, roomID)
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.cells, key)
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if s.directory != nil {
|
||||||
|
if _, err := s.directory.ReleaseOwner(ctx, key, s.nodeID); err != nil {
|
||||||
|
// 删除事实已在 MySQL 提交,释放 lease 失败只影响其他节点接管等待 TTL,不能回滚删除结果。
|
||||||
|
logx.Warn(ctx, "room_delete_lease_release_failed",
|
||||||
|
slog.String("node_id", s.nodeID),
|
||||||
|
slog.String("app_code", app),
|
||||||
|
slog.String("room_id", roomID),
|
||||||
|
slog.String("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) loadedRoomRefs() []loadedRoomRef {
|
func (s *Service) loadedRoomRefs() []loadedRoomRef {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|||||||
@ -348,6 +348,159 @@ func TestCloseRoomByAdminEvictsCurrentUsersThroughOutboxAndRTC(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminDeleteRoomHardDeletesRoomStateAndAllowsRecreate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
svc := roomservice.New(roomservice.Config{
|
||||||
|
NodeID: "node-admin-delete-test",
|
||||||
|
LeaseTTL: 10 * time.Second,
|
||||||
|
RankLimit: 20,
|
||||||
|
SnapshotEveryN: 1,
|
||||||
|
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||||
|
|
||||||
|
roomID := "room-admin-delete-flow"
|
||||||
|
ownerID := int64(8301)
|
||||||
|
viewerID := int64(8302)
|
||||||
|
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||||
|
SeatCount: 10,
|
||||||
|
Mode: "voice",
|
||||||
|
VisibleRegionId: 8101,
|
||||||
|
RoomName: "Delete Flow",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
|
RoomShortId: "delete-flow",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create delete room fixture failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta(roomID, viewerID),
|
||||||
|
Role: "audience",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("join delete fixture viewer failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := svc.AdminDeleteRoom(ctx, &roomv1.AdminDeleteRoomRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{
|
||||||
|
RequestId: "req-admin-delete-room",
|
||||||
|
CommandId: "cmd-admin-delete-room",
|
||||||
|
ActorUserId: 1,
|
||||||
|
RoomId: roomID,
|
||||||
|
AppCode: appcode.Default,
|
||||||
|
SentAtMs: time.Now().UnixMilli(),
|
||||||
|
},
|
||||||
|
AdminId: 1,
|
||||||
|
AdminName: "admin",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("admin delete room failed: %v", err)
|
||||||
|
}
|
||||||
|
if !resp.GetResult().GetApplied() || resp.GetRoom().GetStatus() != "deleted" {
|
||||||
|
t.Fatalf("delete response must report one applied delete mutation: %+v", resp)
|
||||||
|
}
|
||||||
|
if _, exists, err := repository.GetRoomMeta(ctx, roomID); err != nil || exists {
|
||||||
|
t.Fatalf("rooms metadata must be physically deleted, exists=%v err=%v", exists, err)
|
||||||
|
}
|
||||||
|
for _, table := range []string{"room_list_entries", "room_snapshots", "room_command_log", "room_user_presence", "room_user_feed_entries"} {
|
||||||
|
if count := repository.CountRowsByRoom(table, roomID); count != 0 {
|
||||||
|
t.Fatalf("%s rows must be deleted, count=%d", table, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
listResp, err := svc.AdminListRooms(ctx, &roomv1.AdminListRoomsRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{AppCode: appcode.Default},
|
||||||
|
OwnerUserId: ownerID,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list rooms after delete failed: %v", err)
|
||||||
|
}
|
||||||
|
if listResp.GetTotal() != 0 || len(listResp.GetRooms()) != 0 {
|
||||||
|
t.Fatalf("deleted room must disappear from admin list: %+v", listResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||||
|
SeatCount: 10,
|
||||||
|
Mode: "voice",
|
||||||
|
VisibleRegionId: 8101,
|
||||||
|
RoomName: "Recreated Flow",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
|
RoomShortId: "delete-flow",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("owner must be able to recreate room after hard delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClosedRoomOwnerJoinReactivatesButAudienceCannot(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
svc := roomservice.New(roomservice.Config{
|
||||||
|
NodeID: "node-owner-reactivate-test",
|
||||||
|
LeaseTTL: 10 * time.Second,
|
||||||
|
RankLimit: 20,
|
||||||
|
SnapshotEveryN: 1,
|
||||||
|
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||||
|
|
||||||
|
roomID := "room-owner-reactivate"
|
||||||
|
ownerID := int64(8401)
|
||||||
|
viewerID := int64(8402)
|
||||||
|
adminID := int64(1)
|
||||||
|
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||||
|
SeatCount: 10,
|
||||||
|
Mode: "voice",
|
||||||
|
VisibleRegionId: 8101,
|
||||||
|
RoomName: "Reactivate Flow",
|
||||||
|
RoomAvatar: testRoomCoverURL,
|
||||||
|
RoomShortId: "reactivate-flow",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create reactivation room fixture failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
|
||||||
|
Meta: &roomv1.RequestMeta{
|
||||||
|
RequestId: "req-close-reactivation-room",
|
||||||
|
CommandId: "cmd-close-reactivation-room",
|
||||||
|
ActorUserId: adminID,
|
||||||
|
RoomId: roomID,
|
||||||
|
AppCode: appcode.Default,
|
||||||
|
SentAtMs: time.Now().UnixMilli(),
|
||||||
|
},
|
||||||
|
Reason: "admin_closed",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("close room fixture failed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta(roomID, viewerID),
|
||||||
|
Role: "audience",
|
||||||
|
}); !xerr.IsCode(err, xerr.RoomClosed) {
|
||||||
|
t.Fatalf("audience join closed room must be rejected as ROOM_CLOSED: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerJoin, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||||
|
Role: "audience",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("owner join closed room must reactivate it: %v", err)
|
||||||
|
}
|
||||||
|
if ownerJoin.GetRoom().GetStatus() != "active" {
|
||||||
|
t.Fatalf("owner join must return active room: %+v", ownerJoin.GetRoom())
|
||||||
|
}
|
||||||
|
if user := findRoomUser(ownerJoin.GetRoom(), ownerID); user == nil || user.GetRole() != "owner" {
|
||||||
|
t.Fatalf("reactivated owner must be in room with owner role: %+v", ownerJoin.GetRoom().GetOnlineUsers())
|
||||||
|
}
|
||||||
|
meta, exists, err := repository.GetRoomMeta(ctx, roomID)
|
||||||
|
if err != nil || !exists || meta.Status != "active" {
|
||||||
|
t.Fatalf("owner reactivation must persist active status, exists=%v meta=%+v err=%v", exists, meta, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||||
|
Meta: roomservice.NewRequestMeta(roomID, viewerID),
|
||||||
|
Role: "audience",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("audience must join after owner reactivates room: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
|
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
|
||||||
for _, user := range snapshot.GetOnlineUsers() {
|
for _, user := range snapshot.GetOnlineUsers() {
|
||||||
if user.GetUserId() == userID {
|
if user.GetUserId() == userID {
|
||||||
|
|||||||
@ -16,13 +16,31 @@ import (
|
|||||||
"hyapp/services/room-service/internal/room/state"
|
"hyapp/services/room-service/internal/room/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *Service) requireRoomOpenForEntry(ctx context.Context, roomID string) error {
|
func (s *Service) roomEntryPolicy(ctx context.Context, roomID string, actorUserID int64) (bool, error) {
|
||||||
closed, err := s.isRoomClosed(ctx, roomID)
|
// 进房前先读 rooms 元数据,避免为了拒绝关闭/删除房间而恢复完整 Room Cell。
|
||||||
if err != nil || !closed {
|
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
|
||||||
return err
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !exists {
|
||||||
|
// 硬删除后的房间必须表现为不存在,不能因为旧内存 Cell 或旧快照被再次加入。
|
||||||
|
return false, xerr.New(xerr.NotFound, "room not found")
|
||||||
|
}
|
||||||
|
switch strings.TrimSpace(roomMeta.Status) {
|
||||||
|
case "", state.RoomStatusActive:
|
||||||
|
return false, nil
|
||||||
|
case state.RoomStatusClosed:
|
||||||
|
if roomMeta.OwnerUserID == actorUserID && actorUserID > 0 {
|
||||||
|
// 只有房主本人进入 closed 房间时才允许在 JoinRoom 命令内重新激活。
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, xerr.New(xerr.RoomClosed, "room closed")
|
||||||
|
case state.RoomStatusDeleted:
|
||||||
|
// deleted 只兼容历史软删除数据;新删除会物理清理,旧 deleted 也不能被房主重新激活。
|
||||||
|
return false, xerr.New(xerr.NotFound, "room not found")
|
||||||
|
default:
|
||||||
|
return false, xerr.New(xerr.RoomClosed, "room closed")
|
||||||
}
|
}
|
||||||
// 进房入口使用专门错误码,gateway 可以稳定映射成客户端房间关闭态。
|
|
||||||
return xerr.New(xerr.RoomClosed, "room closed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) isRoomClosed(ctx context.Context, roomID string) (bool, error) {
|
func (s *Service) isRoomClosed(ctx context.Context, roomID string) (bool, error) {
|
||||||
|
|||||||
@ -303,6 +303,16 @@ func (s *Service) projectRoomListCacheBestEffort(ctx context.Context, entry Room
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) deleteRoomListCacheBestEffort(ctx context.Context, roomID string) {
|
||||||
|
if s == nil || s.roomListCache == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.roomListCache.DeleteRoomListEntry(ctx, appcode.FromContext(ctx), roomID); err != nil {
|
||||||
|
// MySQL 删除已经是权威事实;缓存删除失败只能记录,后续全量重建会收敛。
|
||||||
|
logx.Warn(ctx, "room_list_cache_delete_failed", slog.String("room_id", roomID), slog.String("error", err.Error()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// roomListEntryFromSnapshot 把完整房间快照压缩成列表卡片读模型。
|
// roomListEntryFromSnapshot 把完整房间快照压缩成列表卡片读模型。
|
||||||
// 这里刻意只提取列表需要的字段,避免列表表结构反向绑定 RoomState 全量细节。
|
// 这里刻意只提取列表需要的字段,避免列表表结构反向绑定 RoomState 全量细节。
|
||||||
func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID int64, nowMS int64) RoomListEntry {
|
func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID int64, nowMS int64) RoomListEntry {
|
||||||
|
|||||||
@ -98,6 +98,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
CreatedAtMS: now.UnixMilli(),
|
CreatedAtMS: now.UnixMilli(),
|
||||||
},
|
},
|
||||||
OutboxRecords: durableOutboxRecords,
|
OutboxRecords: durableOutboxRecords,
|
||||||
|
DeleteRoom: result.deleteRoom,
|
||||||
RoomStatus: result.roomStatus,
|
RoomStatus: result.roomStatus,
|
||||||
RoomSeatCount: result.roomSeatCount,
|
RoomSeatCount: result.roomSeatCount,
|
||||||
RoomPasswordHash: result.roomPasswordHash,
|
RoomPasswordHash: result.roomPasswordHash,
|
||||||
@ -164,7 +165,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
s.roomRocketProgressCoalescer.enqueue(*result.roomRocketProgress)
|
s.roomRocketProgressCoalescer.enqueue(*result.roomRocketProgress)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.applied || result.forceSnapshot) && result.snapshot != nil && (result.forceSnapshot || shouldSnapshot(result.snapshot, s.snapshotEveryN)) {
|
if !result.deleteRoom && (result.applied || result.forceSnapshot) && result.snapshot != nil && (result.forceSnapshot || shouldSnapshot(result.snapshot, s.snapshotEveryN)) {
|
||||||
// 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。
|
// 快照失败时返回错误,因为没有快照会增加恢复成本并可能影响 guard 查询。
|
||||||
snapshotStartedAt := time.Now()
|
snapshotStartedAt := time.Now()
|
||||||
if err := s.persistSnapshot(ctx, result.snapshot); err != nil {
|
if err := s.persistSnapshot(ctx, result.snapshot); err != nil {
|
||||||
@ -172,7 +173,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
}
|
}
|
||||||
snapshotMS = elapsedMS(snapshotStartedAt)
|
snapshotMS = elapsedMS(snapshotStartedAt)
|
||||||
}
|
}
|
||||||
if result.applied {
|
if result.applied && !result.deleteRoom {
|
||||||
projectionStartedAt := time.Now()
|
projectionStartedAt := time.Now()
|
||||||
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
|
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
|
||||||
s.projectRoomListBestEffort(ctx, result.snapshot)
|
s.projectRoomListBestEffort(ctx, result.snapshot)
|
||||||
|
|||||||
@ -39,11 +39,25 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
|||||||
// 客户端不传角色时按普通观众进入房间。
|
// 客户端不传角色时按普通观众进入房间。
|
||||||
cmd.Role = "audience"
|
cmd.Role = "audience"
|
||||||
}
|
}
|
||||||
if err := s.requireRoomOpenForEntry(ctx, cmd.RoomID()); err != nil {
|
reactivateClosedRoom, err := s.roomEntryPolicy(ctx, cmd.RoomID(), cmd.ActorUserID())
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||||
|
result := mutationResult{}
|
||||||
|
if current.Status == state.RoomStatusClosed {
|
||||||
|
if !reactivateClosedRoom || current.OwnerUserID != cmd.ActorUserID() {
|
||||||
|
// 元数据和 Room Cell 任一层发现不是房主恢复 closed 房间,都必须继续拒绝进房。
|
||||||
|
return mutationResult{}, nil, xerr.New(xerr.RoomClosed, "room closed")
|
||||||
|
}
|
||||||
|
current.Status = state.RoomStatusActive
|
||||||
|
result.roomStatus = state.RoomStatusActive
|
||||||
|
result.closeInfo = &RoomCloseInfo{ClearOnOpen: true}
|
||||||
|
}
|
||||||
|
if current.Status != state.RoomStatusActive {
|
||||||
|
return mutationResult{}, nil, xerr.New(xerr.RoomClosed, "room closed")
|
||||||
|
}
|
||||||
if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists {
|
if ban, exists := current.BanUsers[cmd.ActorUserID()]; exists {
|
||||||
if ban.ActiveAt(now.UnixMilli()) {
|
if ban.ActiveAt(now.UnixMilli()) {
|
||||||
// 被踢或封禁用户在 ban 未过期前不能重新进入业务 presence。
|
// 被踢或封禁用户在 ban 未过期前不能重新进入业务 presence。
|
||||||
@ -58,6 +72,11 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
|||||||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "room password is invalid")
|
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "room password is invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
joinRole := cmd.Role
|
||||||
|
if current.OwnerUserID == cmd.ActorUserID() {
|
||||||
|
// 房主恢复 closed 房间时通常没有现存 presence,角色必须按房间 owner 事实收敛为 owner。
|
||||||
|
joinRole = "owner"
|
||||||
|
}
|
||||||
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
|
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
|
||||||
// 重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新 last_seen,
|
// 重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新 last_seen,
|
||||||
// 但房间 UI 仍需要一条入房飘窗。这里把事件放进 directIMRecords,不进入 durable outbox,
|
// 但房间 UI 仍需要一条入房飘窗。这里把事件放进 directIMRecords,不进入 durable outbox,
|
||||||
@ -70,43 +89,41 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
|||||||
return mutationResult{}, nil, err
|
return mutationResult{}, nil, err
|
||||||
}
|
}
|
||||||
snapshot := current.ToProto()
|
snapshot := current.ToProto()
|
||||||
return mutationResult{
|
result.snapshot = snapshot
|
||||||
snapshot: snapshot,
|
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||||||
user: findProtoUser(snapshot, cmd.ActorUserID()),
|
result.directIMRecords = []outbox.Record{joinedDisplayEvent}
|
||||||
directIMRecords: []outbox.Record{joinedDisplayEvent},
|
return result, nil, nil
|
||||||
}, nil, nil
|
|
||||||
}
|
}
|
||||||
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
|
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
|
||||||
UserID: cmd.ActorUserID(),
|
UserID: cmd.ActorUserID(),
|
||||||
Role: cmd.Role,
|
Role: joinRole,
|
||||||
JoinedAtMS: now.UnixMilli(),
|
JoinedAtMS: now.UnixMilli(),
|
||||||
LastSeenAtMS: now.UnixMilli(),
|
LastSeenAtMS: now.UnixMilli(),
|
||||||
}
|
}
|
||||||
current.Version++
|
current.Version++
|
||||||
|
|
||||||
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。
|
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。
|
||||||
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, cmd.Role, current.VisibleRegionID))
|
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, joinRole, current.VisibleRegionID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return mutationResult{}, nil, err
|
return mutationResult{}, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot := current.ToProto()
|
snapshot := current.ToProto()
|
||||||
|
|
||||||
return mutationResult{
|
result.snapshot = snapshot
|
||||||
snapshot: snapshot,
|
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||||||
user: findProtoUser(snapshot, cmd.ActorUserID()),
|
result.syncEvent = &tencentim.RoomEvent{
|
||||||
syncEvent: &tencentim.RoomEvent{
|
EventID: joinedEvent.EventID,
|
||||||
EventID: joinedEvent.EventID,
|
RoomID: current.RoomID,
|
||||||
RoomID: current.RoomID,
|
EventType: "room_user_joined",
|
||||||
EventType: "room_user_joined",
|
ActorUserID: cmd.ActorUserID(),
|
||||||
ActorUserID: cmd.ActorUserID(),
|
TargetUserID: cmd.ActorUserID(),
|
||||||
TargetUserID: cmd.ActorUserID(),
|
RoomVersion: current.Version,
|
||||||
RoomVersion: current.Version,
|
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
|
||||||
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
|
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
|
||||||
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
|
Attributes: roomUserJoinedIMAttributes(joinRole, cmd.ActorDisplayProfile),
|
||||||
Attributes: roomUserJoinedIMAttributes(cmd.Role, cmd.ActorDisplayProfile),
|
}
|
||||||
},
|
return result, []outbox.Record{joinedEvent}, nil
|
||||||
}, []outbox.Record{joinedEvent}, nil
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@ -147,6 +147,14 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
|
|||||||
current.RoomExt[roomExtBackgroundKey] = typed.BackgroundURL
|
current.RoomExt[roomExtBackgroundKey] = typed.BackgroundURL
|
||||||
current.Version++
|
current.Version++
|
||||||
case *command.JoinRoom:
|
case *command.JoinRoom:
|
||||||
|
if current.Status == state.RoomStatusClosed && typed.ActorUserID() == current.OwnerUserID {
|
||||||
|
// 房主进 closed 房会在 JoinRoom 提交里恢复 active;回放必须重建同一个生命周期状态。
|
||||||
|
current.Status = state.RoomStatusActive
|
||||||
|
}
|
||||||
|
role := typed.Role
|
||||||
|
if typed.ActorUserID() == current.OwnerUserID {
|
||||||
|
role = "owner"
|
||||||
|
}
|
||||||
if existing, exists := current.OnlineUsers[typed.ActorUserID()]; exists {
|
if existing, exists := current.OnlineUsers[typed.ActorUserID()]; exists {
|
||||||
// 重复 JoinRoom 回放只刷新 last_seen,不能重置首次进房时间或降级 owner 角色。
|
// 重复 JoinRoom 回放只刷新 last_seen,不能重置首次进房时间或降级 owner 角色。
|
||||||
existing.LastSeenAtMS = typed.SentAtMS
|
existing.LastSeenAtMS = typed.SentAtMS
|
||||||
@ -154,7 +162,7 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
|
|||||||
// presence 使用命令发送时间恢复,真实 IM 连接由客户端重新登录腾讯云 IM。
|
// presence 使用命令发送时间恢复,真实 IM 连接由客户端重新登录腾讯云 IM。
|
||||||
current.OnlineUsers[typed.ActorUserID()] = &state.RoomUserState{
|
current.OnlineUsers[typed.ActorUserID()] = &state.RoomUserState{
|
||||||
UserID: typed.ActorUserID(),
|
UserID: typed.ActorUserID(),
|
||||||
Role: typed.Role,
|
Role: role,
|
||||||
JoinedAtMS: typed.SentAtMS,
|
JoinedAtMS: typed.SentAtMS,
|
||||||
LastSeenAtMS: typed.SentAtMS,
|
LastSeenAtMS: typed.SentAtMS,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,6 +59,9 @@ type MutationCommit struct {
|
|||||||
Command CommandRecord
|
Command CommandRecord
|
||||||
// OutboxRecords 是本次命令产生的房间外事件。
|
// OutboxRecords 是本次命令产生的房间外事件。
|
||||||
OutboxRecords []outbox.Record
|
OutboxRecords []outbox.Record
|
||||||
|
// DeleteRoom 表示后台确认删除房间后清理该房间的持久恢复来源和列表投影。
|
||||||
|
// 该标记只用于 AdminDeleteRoom;关闭房间必须继续走 RoomStatus=closed,保留可恢复的房间事实。
|
||||||
|
DeleteRoom bool
|
||||||
// RoomStatus 非空时同步更新 rooms 和 room_list_entries 的生命周期状态。
|
// RoomStatus 非空时同步更新 rooms 和 room_list_entries 的生命周期状态。
|
||||||
RoomStatus string
|
RoomStatus string
|
||||||
// RoomSeatCount 非 nil 时同步更新 rooms 元数据中的麦位总数。
|
// RoomSeatCount 非 nil 时同步更新 rooms 元数据中的麦位总数。
|
||||||
|
|||||||
@ -184,6 +184,8 @@ type mutationResult struct {
|
|||||||
roomRocketProgress *roomRocketProgressPending
|
roomRocketProgress *roomRocketProgressPending
|
||||||
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
|
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
|
||||||
roomStatus string
|
roomStatus string
|
||||||
|
// deleteRoom 表示本命令成功后房间应从持久恢复来源、列表投影和当前节点 Cell 注册表中移除。
|
||||||
|
deleteRoom bool
|
||||||
// roomSeatCount 非 nil 时代表命令提交时需要同步更新 rooms 元数据座位数。
|
// roomSeatCount 非 nil 时代表命令提交时需要同步更新 rooms 元数据座位数。
|
||||||
roomSeatCount *int32
|
roomSeatCount *int32
|
||||||
// roomPasswordHash 非 nil 时代表命令提交时需要同步更新 rooms 当前密码哈希。
|
// roomPasswordHash 非 nil 时代表命令提交时需要同步更新 rooms 当前密码哈希。
|
||||||
|
|||||||
@ -17,7 +17,7 @@ const (
|
|||||||
RoomStatusClosing = "closing"
|
RoomStatusClosing = "closing"
|
||||||
// RoomStatusClosed 表示房间已经关闭,guard 和进房都必须拒绝。
|
// RoomStatusClosed 表示房间已经关闭,guard 和进房都必须拒绝。
|
||||||
RoomStatusClosed = "closed"
|
RoomStatusClosed = "closed"
|
||||||
// RoomStatusDeleted 表示后台删除后的运营终态;保留 command log/snapshot 作为恢复和审计来源。
|
// RoomStatusDeleted 只兼容历史后台软删除数据;新的后台删除会物理清理房间恢复来源。
|
||||||
RoomStatusDeleted = "deleted"
|
RoomStatusDeleted = "deleted"
|
||||||
|
|
||||||
// RocketStatusCharging 表示当前等级火箭还在积攒燃料。
|
// RocketStatusCharging 表示当前等级火箭还在积攒燃料。
|
||||||
|
|||||||
@ -95,6 +95,14 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if commit.DeleteRoom {
|
||||||
|
if err := r.deleteRoomRows(ctx, tx, appCode, commit.Command.RoomID); err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(commit.RoomStatus) != "" {
|
if strings.TrimSpace(commit.RoomStatus) != "" {
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`UPDATE rooms
|
`UPDATE rooms
|
||||||
@ -276,6 +284,31 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
|||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error {
|
||||||
|
// 删除覆盖所有能让房间重新出现在列表、恢复链路或 owner 唯一约束里的本服务表。
|
||||||
|
// room_outbox 也一起清理,避免已删除房间继续补偿投递旧的展示事件。
|
||||||
|
statements := []string{
|
||||||
|
`DELETE FROM room_outbox WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_robot_rooms WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_region_pins WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_follows WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_user_feed_entries WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_user_presence WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_user_gift_stats WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_background_images WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_snapshots WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_command_log WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM room_list_entries WHERE app_code = ? AND room_id = ?`,
|
||||||
|
`DELETE FROM rooms WHERE app_code = ? AND room_id = ?`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if _, err := tx.ExecContext(ctx, statement, appCode, roomID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) insertRoomOutboxRecords(ctx context.Context, tx *sql.Tx, records []outbox.Record) error {
|
func (r *Repository) insertRoomOutboxRecords(ctx context.Context, tx *sql.Tx, records []outbox.Record) error {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -72,6 +72,17 @@ func (r *Repository) DeleteRoomListEntry(roomID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountRowsByRoom returns how many rows a room still owns in a test table.
|
||||||
|
func (r *Repository) CountRowsByRoom(table string, roomID string) int64 {
|
||||||
|
r.t.Helper()
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := r.schema.DB.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM `+table+` WHERE app_code = ? AND room_id = ?`, appcode.Default, roomID).Scan(&count); err != nil {
|
||||||
|
r.t.Fatalf("count %s rows failed: %v", table, err)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
// SetRoomSortScore rewrites discovery sort_score for deterministic list-order tests.
|
// SetRoomSortScore rewrites discovery sort_score for deterministic list-order tests.
|
||||||
func (r *Repository) SetRoomSortScore(roomID string, sortScore int64) {
|
func (r *Repository) SetRoomSortScore(roomID string, sortScore int64) {
|
||||||
r.t.Helper()
|
r.t.Helper()
|
||||||
|
|||||||
@ -11,6 +11,8 @@ const (
|
|||||||
HostSourceApplication = "agency_application"
|
HostSourceApplication = "agency_application"
|
||||||
// HostSourceAgencyInvitation 表示主播身份来自接受 Agency 邀请并成为 Agency owner。
|
// HostSourceAgencyInvitation 表示主播身份来自接受 Agency 邀请并成为 Agency owner。
|
||||||
HostSourceAgencyInvitation = "agency_invitation"
|
HostSourceAgencyInvitation = "agency_invitation"
|
||||||
|
// HostSourceHostInvitation 表示主播身份来自 Agency owner 邀请加入,本身不具备 Agency owner 身份。
|
||||||
|
HostSourceHostInvitation = "host_invitation"
|
||||||
// HostSourceAdminCreateAgency 表示主播身份来自后台直接创建 Agency。
|
// HostSourceAdminCreateAgency 表示主播身份来自后台直接创建 Agency。
|
||||||
HostSourceAdminCreateAgency = "admin_create_agency"
|
HostSourceAdminCreateAgency = "admin_create_agency"
|
||||||
|
|
||||||
|
|||||||
@ -680,6 +680,20 @@ type CompleteOnboardingCommand struct {
|
|||||||
CompletedAtMs int64
|
CompletedAtMs int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BindInviteReferrerCommand 描述资料完成后的邀请码绑定事务。
|
||||||
|
type BindInviteReferrerCommand struct {
|
||||||
|
// AppCode 是邀请关系所属 App,必须和双方用户及邀请码保持一致。
|
||||||
|
AppCode string
|
||||||
|
// InvitedUserID 是当前登录用户,只能绑定一次邀请人。
|
||||||
|
InvitedUserID int64
|
||||||
|
// InviteCode 是用户输入的邀请码,服务端在事务内解析归属并绑定。
|
||||||
|
InviteCode string
|
||||||
|
// RequestID 是入口请求 ID,写入邀请关系和 outbox 便于追踪。
|
||||||
|
RequestID string
|
||||||
|
// BoundAtMs 是绑定事务时间,同时作为用户快照更新时间。
|
||||||
|
BoundAtMs int64
|
||||||
|
}
|
||||||
|
|
||||||
// CountryChangeCommand 描述用户国家修改事务。
|
// CountryChangeCommand 描述用户国家修改事务。
|
||||||
type CountryChangeCommand struct {
|
type CountryChangeCommand struct {
|
||||||
// AppCode 是国家修改所属 App。
|
// AppCode 是国家修改所属 App。
|
||||||
|
|||||||
@ -389,7 +389,7 @@ func (s *Service) CreateBDLeader(ctx context.Context, command CreateBDLeaderInpu
|
|||||||
TargetUserID: command.TargetUserID,
|
TargetUserID: command.TargetUserID,
|
||||||
Reason: strings.TrimSpace(command.Reason),
|
Reason: strings.TrimSpace(command.Reason),
|
||||||
RequestID: strings.TrimSpace(command.RequestID),
|
RequestID: strings.TrimSpace(command.RequestID),
|
||||||
EventID: s.idGenerator.NewInt64(),
|
EventID: s.nextEventIDRange(2),
|
||||||
NowMs: nowMs,
|
NowMs: nowMs,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,22 @@ func seedActiveUserInCountry(t *testing.T, repository *mysqltest.Repository, use
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedActiveMembership(t *testing.T, repository *mysqltest.Repository, membershipID int64, agencyID int64, hostUserID int64, membershipType string) {
|
||||||
|
t.Helper()
|
||||||
|
if membershipType == "" {
|
||||||
|
membershipType = hostdomain.MembershipTypeMember
|
||||||
|
}
|
||||||
|
_, err := repository.RawDB().Exec(`
|
||||||
|
INSERT INTO agency_memberships (
|
||||||
|
membership_id, agency_id, host_user_id, membership_type, status,
|
||||||
|
joined_at_ms, created_at_ms, updated_at_ms
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 1, 1, 1)
|
||||||
|
`, membershipID, agencyID, hostUserID, membershipType, hostdomain.MembershipStatusActive)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("seed active membership %d failed: %v", membershipID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func displayID(userID int64) string {
|
func displayID(userID int64) string {
|
||||||
return fmt.Sprintf("%06d", 900000+userID%100000)
|
return fmt.Sprintf("%06d", 900000+userID%100000)
|
||||||
}
|
}
|
||||||
@ -216,18 +232,27 @@ func TestApplyReviewApproveCreatesHostMembershipIdempotently(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.T) {
|
func TestAcceptAgencyInvitationCreatesOwnerHostAndPreservesExistingHostBinding(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
seedActiveUser(t, repository, 501, 10)
|
seedActiveUser(t, repository, 501, 10)
|
||||||
seedActiveUser(t, repository, 601, 10)
|
seedActiveUser(t, repository, 601, 10)
|
||||||
seedActiveUser(t, repository, 602, 10)
|
seedActiveUser(t, repository, 602, 10)
|
||||||
|
seedActiveUser(t, repository, 603, 10)
|
||||||
|
seedActiveUser(t, repository, 604, 10)
|
||||||
repository.PutBDProfile(hostdomain.BDProfile{
|
repository.PutBDProfile(hostdomain.BDProfile{
|
||||||
UserID: 501,
|
UserID: 501,
|
||||||
Role: hostdomain.BDRoleBD,
|
Role: hostdomain.BDRoleBD,
|
||||||
RegionID: 10,
|
RegionID: 10,
|
||||||
Status: hostdomain.BDStatusActive,
|
Status: hostdomain.BDStatusActive,
|
||||||
})
|
})
|
||||||
|
repository.PutAgency(hostdomain.Agency{
|
||||||
|
AgencyID: 650,
|
||||||
|
OwnerUserID: 604,
|
||||||
|
RegionID: 10,
|
||||||
|
Status: hostdomain.AgencyStatusActive,
|
||||||
|
JoinEnabled: true,
|
||||||
|
})
|
||||||
svc := newHostService(repository, 2000, 3000)
|
svc := newHostService(repository, 2000, 3000)
|
||||||
|
|
||||||
invitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
invitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
||||||
@ -239,8 +264,16 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("InviteAgency failed: %v", err)
|
t.Fatalf("InviteAgency failed: %v", err)
|
||||||
}
|
}
|
||||||
if invitation.Status != hostdomain.InvitationStatusAccepted {
|
if invitation.Status != hostdomain.InvitationStatusPending {
|
||||||
t.Fatalf("agency invitation should be accepted immediately: %+v", invitation)
|
t.Fatalf("agency invitation should wait for target accept: %+v", invitation)
|
||||||
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-agency-601",
|
||||||
|
ActorUserID: 601,
|
||||||
|
InvitationID: invitation.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ProcessRoleInvitation accept agency failed: %v", err)
|
||||||
}
|
}
|
||||||
summary, err := svc.GetUserRoleSummary(ctx, 601)
|
summary, err := svc.GetUserRoleSummary(ctx, 601)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -272,6 +305,14 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("detached active host should become agency owner: %v", err)
|
t.Fatalf("detached active host should become agency owner: %v", err)
|
||||||
}
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-agency-602",
|
||||||
|
ActorUserID: 602,
|
||||||
|
InvitationID: secondInvitation.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ProcessRoleInvitation accept detached host agency failed: %v", err)
|
||||||
|
}
|
||||||
secondSummary, err := svc.GetUserRoleSummary(ctx, 602)
|
secondSummary, err := svc.GetUserRoleSummary(ctx, 602)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetUserRoleSummary for reused host failed: %v", err)
|
t.Fatalf("GetUserRoleSummary for reused host failed: %v", err)
|
||||||
@ -280,17 +321,121 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetHostProfile for reused host failed: %v", err)
|
t.Fatalf("GetHostProfile for reused host failed: %v", err)
|
||||||
}
|
}
|
||||||
if !secondSummary.IsAgency || !secondSummary.IsHost || secondHostProfile.CurrentAgencyID <= 0 || secondHostProfile.Source != "test" || secondInvitation.Status != hostdomain.InvitationStatusAccepted {
|
if !secondSummary.IsAgency || !secondSummary.IsHost || secondHostProfile.CurrentAgencyID <= 0 || secondHostProfile.Source != "test" || secondInvitation.Status != hostdomain.InvitationStatusPending {
|
||||||
t.Fatalf("detached host reuse mismatch: summary=%+v host=%+v invitation=%+v", secondSummary, secondHostProfile, secondInvitation)
|
t.Fatalf("detached host reuse mismatch: summary=%+v host=%+v invitation=%+v", secondSummary, secondHostProfile, secondInvitation)
|
||||||
}
|
}
|
||||||
_, err = svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
repository.PutHostProfile(hostdomain.HostProfile{
|
||||||
CommandID: "invite-agency-602-again",
|
UserID: 603,
|
||||||
InviterUserID: 501,
|
Status: hostdomain.HostStatusActive,
|
||||||
TargetUserID: 602,
|
RegionID: 10,
|
||||||
AgencyName: "Blocked Agency",
|
CurrentAgencyID: 650,
|
||||||
|
CurrentMembershipID: 660,
|
||||||
|
Source: "existing-membership",
|
||||||
})
|
})
|
||||||
if got := xerr.CodeOf(err); got != xerr.Conflict {
|
seedActiveMembership(t, repository, 660, 650, 603, hostdomain.MembershipTypeMember)
|
||||||
t.Fatalf("host with active membership must not become another agency owner: got %s err=%v", got, err)
|
boundHostInvitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
||||||
|
CommandID: "invite-agency-603-bound-host",
|
||||||
|
InviterUserID: 501,
|
||||||
|
TargetUserID: 603,
|
||||||
|
AgencyName: "Bound Host Agency",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("active bound host should still be invitable as agency owner: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-agency-603-bound-host",
|
||||||
|
ActorUserID: 603,
|
||||||
|
InvitationID: boundHostInvitation.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ProcessRoleInvitation accept bound host agency failed: %v", err)
|
||||||
|
}
|
||||||
|
boundHostProfile, err := svc.GetHostProfile(ctx, 603)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHostProfile for bound host failed: %v", err)
|
||||||
|
}
|
||||||
|
boundSummary, err := svc.GetUserRoleSummary(ctx, 603)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserRoleSummary for bound host failed: %v", err)
|
||||||
|
}
|
||||||
|
if !boundSummary.IsAgency || !boundSummary.IsHost || boundHostProfile.CurrentAgencyID != 650 || boundHostProfile.CurrentMembershipID != 660 {
|
||||||
|
t.Fatalf("agency owner must not overwrite existing host binding: summary=%+v host=%+v", boundSummary, boundHostProfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptHostInvitationDoesNotGrantAgencyIdentity(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
repository := mysqltest.NewRepository(t)
|
||||||
|
seedActiveUser(t, repository, 611, 10)
|
||||||
|
seedActiveUser(t, repository, 612, 10)
|
||||||
|
seedActiveUser(t, repository, 613, 10)
|
||||||
|
repository.PutAgency(hostdomain.Agency{
|
||||||
|
AgencyID: 620,
|
||||||
|
OwnerUserID: 611,
|
||||||
|
RegionID: 10,
|
||||||
|
Status: hostdomain.AgencyStatusActive,
|
||||||
|
JoinEnabled: true,
|
||||||
|
})
|
||||||
|
repository.PutHostProfile(hostdomain.HostProfile{
|
||||||
|
UserID: 611,
|
||||||
|
Status: hostdomain.HostStatusActive,
|
||||||
|
RegionID: 10,
|
||||||
|
CurrentAgencyID: 620,
|
||||||
|
CurrentMembershipID: 621,
|
||||||
|
Source: hostdomain.HostSourceAgencyInvitation,
|
||||||
|
})
|
||||||
|
svc := newHostService(repository, 630, 640)
|
||||||
|
|
||||||
|
invitation, err := svc.InviteHost(ctx, hostservice.InviteHostInput{
|
||||||
|
CommandID: "invite-host-612",
|
||||||
|
InviterUserID: 611,
|
||||||
|
TargetUserID: 612,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("InviteHost failed: %v", err)
|
||||||
|
}
|
||||||
|
result, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-host-612",
|
||||||
|
ActorUserID: 612,
|
||||||
|
InvitationID: invitation.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProcessRoleInvitation accept host failed: %v", err)
|
||||||
|
}
|
||||||
|
if result.Membership.MembershipType != hostdomain.MembershipTypeMember || result.HostProfile.Source != hostdomain.HostSourceHostInvitation {
|
||||||
|
t.Fatalf("host invitation should create member-only host facts: result=%+v", result)
|
||||||
|
}
|
||||||
|
summary, err := svc.GetUserRoleSummary(ctx, 612)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserRoleSummary host member failed: %v", err)
|
||||||
|
}
|
||||||
|
if !summary.IsHost || summary.IsAgency || summary.AgencyID != 0 {
|
||||||
|
t.Fatalf("ordinary host member must not expose agency identity: %+v", summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
repository.PutHostProfile(hostdomain.HostProfile{
|
||||||
|
UserID: 613,
|
||||||
|
Status: hostdomain.HostStatusActive,
|
||||||
|
RegionID: 10,
|
||||||
|
CurrentAgencyID: 620,
|
||||||
|
CurrentMembershipID: 622,
|
||||||
|
Source: hostdomain.HostSourceAgencyInvitation,
|
||||||
|
})
|
||||||
|
legacySummary, err := svc.GetUserRoleSummary(ctx, 613)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserRoleSummary legacy host member failed: %v", err)
|
||||||
|
}
|
||||||
|
if !legacySummary.IsHost || legacySummary.IsAgency || legacySummary.AgencyID != 0 {
|
||||||
|
t.Fatalf("legacy host member source must not imply agency identity: %+v", legacySummary)
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerSummary, err := svc.GetUserRoleSummary(ctx, 611)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserRoleSummary owner failed: %v", err)
|
||||||
|
}
|
||||||
|
if !ownerSummary.IsHost || !ownerSummary.IsAgency || ownerSummary.AgencyID != 620 {
|
||||||
|
t.Fatalf("agency owner should keep agency identity: %+v", ownerSummary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,8 +460,16 @@ func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("InviteBD failed: %v", err)
|
t.Fatalf("InviteBD failed: %v", err)
|
||||||
}
|
}
|
||||||
if invitation.Status != hostdomain.InvitationStatusAccepted {
|
if invitation.Status != hostdomain.InvitationStatusPending {
|
||||||
t.Fatalf("bd invitation should be accepted immediately: %+v", invitation)
|
t.Fatalf("bd invitation should wait for target accept: %+v", invitation)
|
||||||
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-bd-702",
|
||||||
|
ActorUserID: 702,
|
||||||
|
InvitationID: invitation.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("ProcessRoleInvitation accept bd failed: %v", err)
|
||||||
}
|
}
|
||||||
bdProfile, err := svc.GetBDProfile(ctx, 702, hostdomain.BDRoleBD)
|
bdProfile, err := svc.GetBDProfile(ctx, 702, hostdomain.BDRoleBD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -387,11 +540,19 @@ func TestInviteBDAcceptsSelfInviteForLeader(t *testing.T) {
|
|||||||
TargetUserID: 703,
|
TargetUserID: 703,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("bd leader self invite must be accepted: %v", err)
|
t.Fatalf("bd leader self invite must be created: %v", err)
|
||||||
}
|
}
|
||||||
if invitation.Status != hostdomain.InvitationStatusAccepted || invitation.InviterUserID != 703 || invitation.TargetUserID != 703 || invitation.ParentLeaderUserID != 703 {
|
if invitation.Status != hostdomain.InvitationStatusPending || invitation.InviterUserID != 703 || invitation.TargetUserID != 703 || invitation.ParentLeaderUserID != 703 {
|
||||||
t.Fatalf("self invite invitation mismatch: %+v", invitation)
|
t.Fatalf("self invite invitation mismatch: %+v", invitation)
|
||||||
}
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-bd-self-703",
|
||||||
|
ActorUserID: 703,
|
||||||
|
InvitationID: invitation.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("self invite accept must create ordinary bd profile: %v", err)
|
||||||
|
}
|
||||||
ordinaryBD, err := svc.GetBDProfile(ctx, 703, hostdomain.BDRoleBD)
|
ordinaryBD, err := svc.GetBDProfile(ctx, 703, hostdomain.BDRoleBD)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("self invite must create ordinary bd profile: %v", err)
|
t.Fatalf("self invite must create ordinary bd profile: %v", err)
|
||||||
@ -458,19 +619,23 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) {
|
|||||||
t.Fatalf("leader retry must replay existing result: %+v", retriedLeader)
|
t.Fatalf("leader retry must replay existing result: %+v", retriedLeader)
|
||||||
}
|
}
|
||||||
|
|
||||||
sameUserBD, err := svc.CreateBD(ctx, hostservice.CreateBDInput{
|
sameUserBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleBD)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateBDLeader must create self ordinary bd: %v", err)
|
||||||
|
}
|
||||||
|
if sameUserBD.Role != hostdomain.BDRoleBD || sameUserBD.ParentLeaderUserID != 801 {
|
||||||
|
t.Fatalf("self ordinary bd mismatch: %+v", sameUserBD)
|
||||||
|
}
|
||||||
|
_, err = svc.CreateBD(ctx, hostservice.CreateBDInput{
|
||||||
CommandID: "admin-create-bd-801-same-user",
|
CommandID: "admin-create-bd-801-same-user",
|
||||||
AdminUserID: 1,
|
AdminUserID: 1,
|
||||||
TargetUserID: 801,
|
TargetUserID: 801,
|
||||||
ParentLeaderUserID: 801,
|
ParentLeaderUserID: 801,
|
||||||
Reason: "same user can also be bd",
|
Reason: "same user already has bd",
|
||||||
RequestID: "req-bd-same-user-1",
|
RequestID: "req-bd-same-user-1",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if got := xerr.CodeOf(err); got != xerr.Conflict {
|
||||||
t.Fatalf("CreateBD for existing leader user failed: %v", err)
|
t.Fatalf("CreateBDLeader already creates ordinary bd: got %s err=%v", got, err)
|
||||||
}
|
|
||||||
if sameUserBD.Role != hostdomain.BDRoleBD || sameUserBD.ParentLeaderUserID != 801 {
|
|
||||||
t.Fatalf("same user bd mismatch: %+v", sameUserBD)
|
|
||||||
}
|
}
|
||||||
leaderAfterBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader)
|
leaderAfterBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader)
|
||||||
if err != nil || leaderAfterBD.Role != hostdomain.BDRoleLeader || leaderAfterBD.Status != hostdomain.BDStatusActive {
|
if err != nil || leaderAfterBD.Role != hostdomain.BDRoleLeader || leaderAfterBD.Status != hostdomain.BDStatusActive {
|
||||||
@ -525,13 +690,52 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) {
|
|||||||
if disabled.Status != hostdomain.BDStatusDisabled {
|
if disabled.Status != hostdomain.BDStatusDisabled {
|
||||||
t.Fatalf("disabled bd mismatch: %+v", disabled)
|
t.Fatalf("disabled bd mismatch: %+v", disabled)
|
||||||
}
|
}
|
||||||
_, err = svc.InviteBD(ctx, hostservice.InviteBDInput{
|
disabledInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
||||||
CommandID: "invite-disabled-bd-802-again",
|
CommandID: "invite-disabled-bd-802-again",
|
||||||
InviterUserID: 801,
|
InviterUserID: 801,
|
||||||
TargetUserID: 802,
|
TargetUserID: 802,
|
||||||
})
|
})
|
||||||
if got := xerr.CodeOf(err); got != xerr.Conflict {
|
if err != nil {
|
||||||
t.Fatalf("disabled ordinary bd must not be invited again: got %s err=%v", got, err)
|
t.Fatalf("disabled ordinary bd should be invitable again: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-disabled-bd-802-again",
|
||||||
|
ActorUserID: 802,
|
||||||
|
InvitationID: disabledInvite.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("accept disabled bd invite should restore profile: %v", err)
|
||||||
|
}
|
||||||
|
restoredBD, err := svc.GetBDProfile(ctx, 802, hostdomain.BDRoleBD)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBDProfile restored disabled bd failed: %v", err)
|
||||||
|
}
|
||||||
|
if restoredBD.Status != hostdomain.BDStatusActive || restoredBD.ParentLeaderUserID != 801 {
|
||||||
|
t.Fatalf("disabled bd invite should restore active under inviter leader: %+v", restoredBD)
|
||||||
|
}
|
||||||
|
seedActiveUser(t, repository, 805, 20)
|
||||||
|
repository.PutBDProfile(hostdomain.BDProfile{
|
||||||
|
UserID: 805,
|
||||||
|
Role: hostdomain.BDRoleBD,
|
||||||
|
RegionID: 20,
|
||||||
|
ParentLeaderUserID: 801,
|
||||||
|
Status: hostdomain.BDStatusActive,
|
||||||
|
})
|
||||||
|
if _, err := svc.CreateBDLeader(ctx, hostservice.CreateBDLeaderInput{
|
||||||
|
CommandID: "admin-create-leader-existing-bd-805",
|
||||||
|
AdminUserID: 1,
|
||||||
|
TargetUserID: 805,
|
||||||
|
Reason: "promote existing bd",
|
||||||
|
RequestID: "req-leader-existing-bd-1",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateBDLeader for existing bd failed: %v", err)
|
||||||
|
}
|
||||||
|
existingBD, err := svc.GetBDProfile(ctx, 805, hostdomain.BDRoleBD)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetBDProfile existing bd after leader create failed: %v", err)
|
||||||
|
}
|
||||||
|
if existingBD.ParentLeaderUserID != 801 {
|
||||||
|
t.Fatalf("CreateBDLeader must not overwrite active bd parent: %+v", existingBD)
|
||||||
}
|
}
|
||||||
disabledSameUserBD, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{
|
disabledSameUserBD, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{
|
||||||
CommandID: "admin-disable-bd-801",
|
CommandID: "admin-disable-bd-801",
|
||||||
@ -799,6 +1003,8 @@ func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
|
|||||||
repository := mysqltest.NewRepository(t)
|
repository := mysqltest.NewRepository(t)
|
||||||
seedActiveUser(t, repository, 911, 40)
|
seedActiveUser(t, repository, 911, 40)
|
||||||
seedActiveUser(t, repository, 910, 40)
|
seedActiveUser(t, repository, 910, 40)
|
||||||
|
seedActiveUser(t, repository, 912, 40)
|
||||||
|
seedActiveUser(t, repository, 913, 40)
|
||||||
repository.PutBDProfile(hostdomain.BDProfile{
|
repository.PutBDProfile(hostdomain.BDProfile{
|
||||||
UserID: 910,
|
UserID: 910,
|
||||||
Role: hostdomain.BDRoleBD,
|
Role: hostdomain.BDRoleBD,
|
||||||
@ -811,6 +1017,22 @@ func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
|
|||||||
RegionID: 40,
|
RegionID: 40,
|
||||||
Source: "test",
|
Source: "test",
|
||||||
})
|
})
|
||||||
|
repository.PutAgency(hostdomain.Agency{
|
||||||
|
AgencyID: 914,
|
||||||
|
OwnerUserID: 913,
|
||||||
|
RegionID: 40,
|
||||||
|
Status: hostdomain.AgencyStatusActive,
|
||||||
|
JoinEnabled: true,
|
||||||
|
})
|
||||||
|
repository.PutHostProfile(hostdomain.HostProfile{
|
||||||
|
UserID: 912,
|
||||||
|
Status: hostdomain.HostStatusActive,
|
||||||
|
RegionID: 40,
|
||||||
|
CurrentAgencyID: 914,
|
||||||
|
CurrentMembershipID: 915,
|
||||||
|
Source: "existing-membership",
|
||||||
|
})
|
||||||
|
seedActiveMembership(t, repository, 915, 914, 912, hostdomain.MembershipTypeMember)
|
||||||
svc := newHostService(repository, 6000, 7000)
|
svc := newHostService(repository, 6000, 7000)
|
||||||
|
|
||||||
created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
|
created, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
|
||||||
@ -827,6 +1049,27 @@ func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
|
|||||||
if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.HostProfile.Source != "test" {
|
if created.HostProfile.CurrentAgencyID != created.Agency.AgencyID || created.HostProfile.Source != "test" {
|
||||||
t.Fatalf("existing host identity should be reused without rewriting source: %+v", created.HostProfile)
|
t.Fatalf("existing host identity should be reused without rewriting source: %+v", created.HostProfile)
|
||||||
}
|
}
|
||||||
|
boundOwner, err := svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
|
||||||
|
CommandID: "admin-create-agency-bound-host",
|
||||||
|
AdminUserID: 1,
|
||||||
|
OwnerUserID: 912,
|
||||||
|
ParentBDUserID: 910,
|
||||||
|
Name: "Bound Host Agency",
|
||||||
|
JoinEnabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bound host should become agency owner without rebinding host: %v", err)
|
||||||
|
}
|
||||||
|
if boundOwner.HostProfile.CurrentAgencyID != 914 || boundOwner.HostProfile.CurrentMembershipID != 915 || boundOwner.Membership.AgencyID != 914 {
|
||||||
|
t.Fatalf("admin agency create must preserve existing host binding: %+v", boundOwner)
|
||||||
|
}
|
||||||
|
boundSummary, err := svc.GetUserRoleSummary(ctx, 912)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetUserRoleSummary for bound agency owner failed: %v", err)
|
||||||
|
}
|
||||||
|
if !boundSummary.IsAgency || !boundSummary.IsHost || boundSummary.AgencyID != boundOwner.Agency.AgencyID {
|
||||||
|
t.Fatalf("bound host agency owner summary mismatch: %+v agency=%+v", boundSummary, boundOwner.Agency)
|
||||||
|
}
|
||||||
_, err = svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
|
_, err = svc.CreateAgency(ctx, hostservice.CreateAgencyInput{
|
||||||
CommandID: "admin-create-agency-existing-host-again",
|
CommandID: "admin-create-agency-existing-host-again",
|
||||||
AdminUserID: 1,
|
AdminUserID: 1,
|
||||||
@ -1036,13 +1279,22 @@ func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
|
|||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("CreateCoinSeller failed: %v", err)
|
t.Fatalf("CreateCoinSeller failed: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
invite932, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
||||||
CommandID: "invite-bd-932",
|
CommandID: "invite-bd-932",
|
||||||
InviterUserID: 931,
|
InviterUserID: 931,
|
||||||
TargetUserID: 932,
|
TargetUserID: 932,
|
||||||
}); err != nil {
|
})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("InviteBD failed: %v", err)
|
t.Fatalf("InviteBD failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-bd-932",
|
||||||
|
ActorUserID: 932,
|
||||||
|
InvitationID: invite932.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("accept InviteBD for summary failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
summary, err := svc.GetUserRoleSummary(ctx, 931)
|
summary, err := svc.GetUserRoleSummary(ctx, 931)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1051,13 +1303,22 @@ func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
|
|||||||
if !summary.IsHost || !summary.IsAgency || !summary.IsManager || summary.IsBD || !summary.IsBDLeader || !summary.IsCoinSeller || summary.AgencyID != 933 || summary.BDID != 931 {
|
if !summary.IsHost || !summary.IsAgency || !summary.IsManager || summary.IsBD || !summary.IsBDLeader || !summary.IsCoinSeller || summary.AgencyID != 933 || summary.BDID != 931 {
|
||||||
t.Fatalf("leader-only role summary mismatch: %+v", summary)
|
t.Fatalf("leader-only role summary mismatch: %+v", summary)
|
||||||
}
|
}
|
||||||
if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
selfInvite, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
||||||
CommandID: "invite-bd-self-931",
|
CommandID: "invite-bd-self-931",
|
||||||
InviterUserID: 931,
|
InviterUserID: 931,
|
||||||
TargetUserID: 931,
|
TargetUserID: 931,
|
||||||
}); err != nil {
|
})
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("self InviteBD for summary failed: %v", err)
|
t.Fatalf("self InviteBD for summary failed: %v", err)
|
||||||
}
|
}
|
||||||
|
if _, err := svc.ProcessRoleInvitation(ctx, hostservice.ProcessRoleInvitationInput{
|
||||||
|
CommandID: "accept-bd-self-931-summary",
|
||||||
|
ActorUserID: 931,
|
||||||
|
InvitationID: selfInvite.InvitationID,
|
||||||
|
Action: hostdomain.InvitationActionAccept,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("accept self InviteBD for summary failed: %v", err)
|
||||||
|
}
|
||||||
combinedSummary, err := svc.GetUserRoleSummary(ctx, 931)
|
combinedSummary, err := svc.GetUserRoleSummary(ctx, 931)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GetUserRoleSummary combined failed: %v", err)
|
t.Fatalf("GetUserRoleSummary combined failed: %v", err)
|
||||||
|
|||||||
@ -0,0 +1,90 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||||
|
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SearchInviteReferrer 通过邀请码搜索可绑定邀请人。
|
||||||
|
// 搜索结果只允许同 App、同国家、已完成资料且 active 的普通用户,避免 H5 枚举跨国家用户资料。
|
||||||
|
func (s *Service) SearchInviteReferrer(ctx context.Context, userID int64, inviteCode string) (userdomain.User, error) {
|
||||||
|
inviteCode = invitedomain.NormalizeCode(inviteCode)
|
||||||
|
if userID <= 0 {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||||
|
}
|
||||||
|
if !invitedomain.ValidCode(inviteCode) {
|
||||||
|
// 对外搜索入口只接受合法邀请码;非法输入直接拒绝,不落入 NotFound 分支。
|
||||||
|
return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||||
|
}
|
||||||
|
if s.userRepository == nil {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
current, err := s.userRepository.GetUser(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
if !current.ProfileCompleted {
|
||||||
|
// 当前账号未完成资料时没有稳定国家边界,不能用这个入口搜索邀请人。
|
||||||
|
return userdomain.User{}, xerr.New(xerr.ProfileRequired, "profile required")
|
||||||
|
}
|
||||||
|
if current.Country == "" {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "current user country is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
inviter, err := s.userRepository.FindInviteReferrerByCode(ctx, appcode.FromContext(ctx), inviteCode)
|
||||||
|
if err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
if err := validateInviteReferrerPair(current, inviter); err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.refreshExpiredUser(ctx, inviter, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindInviteReferrer 原子绑定当前用户的邀请人。
|
||||||
|
// H5 只能传用户输入的邀请码;归属用户由 repository 在事务内重新解析并复核。
|
||||||
|
func (s *Service) BindInviteReferrer(ctx context.Context, userID int64, inviteCode string, requestID string) (userdomain.User, error) {
|
||||||
|
inviteCode = invitedomain.NormalizeCode(inviteCode)
|
||||||
|
if userID <= 0 {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||||
|
}
|
||||||
|
if !invitedomain.ValidCode(inviteCode) {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||||
|
}
|
||||||
|
if s.userRepository == nil {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.userRepository.BindInviteReferrer(ctx, userdomain.BindInviteReferrerCommand{
|
||||||
|
AppCode: appcode.FromContext(ctx),
|
||||||
|
InvitedUserID: userID,
|
||||||
|
InviteCode: inviteCode,
|
||||||
|
RequestID: strings.TrimSpace(requestID),
|
||||||
|
BoundAtMs: s.now().UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInviteReferrerPair(invited userdomain.User, inviter userdomain.User) error {
|
||||||
|
if invited.UserID <= 0 || inviter.UserID <= 0 {
|
||||||
|
return xerr.New(xerr.NotFound, "inviter not found")
|
||||||
|
}
|
||||||
|
if invited.UserID == inviter.UserID {
|
||||||
|
// 自邀不进入邀请码解析,避免把当前用户自己的邀请码可用性暴露出去。
|
||||||
|
return xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||||
|
}
|
||||||
|
if !inviter.ProfileCompleted || !inviter.CanLogin() {
|
||||||
|
return xerr.New(xerr.NotFound, "inviter not found")
|
||||||
|
}
|
||||||
|
if invited.Country == "" || inviter.Country == "" || invited.Country != inviter.Country {
|
||||||
|
// 产品要求只能搜索和绑定本国家邀请人;跨国家统一返回 not found,避免泄漏目标用户是否存在。
|
||||||
|
return xerr.New(xerr.NotFound, "inviter not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -211,6 +211,10 @@ func (r *fakeModerationRepository) BatchGetUsers(context.Context, []int64) (map[
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *fakeModerationRepository) FindInviteReferrerByCode(context.Context, string, string) (userdomain.User, error) {
|
||||||
|
return r.user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *fakeModerationRepository) ListUserIDs(context.Context, userdomain.UserIDPageFilter) ([]int64, error) {
|
func (r *fakeModerationRepository) ListUserIDs(context.Context, userdomain.UserIDPageFilter) ([]int64, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@ -238,6 +242,10 @@ func (r *fakeModerationRepository) CompleteOnboarding(context.Context, userdomai
|
|||||||
return r.user, nil
|
return r.user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *fakeModerationRepository) BindInviteReferrer(context.Context, userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
|
||||||
|
return r.user, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *fakeModerationRepository) ChangeUserCountry(_ context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error) {
|
func (r *fakeModerationRepository) ChangeUserCountry(_ context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error) {
|
||||||
r.lastCountryCommand = command
|
r.lastCountryCommand = command
|
||||||
r.countryChangeCalls++
|
r.countryChangeCalls++
|
||||||
|
|||||||
@ -47,6 +47,8 @@ type UserRepository interface {
|
|||||||
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
|
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
|
||||||
// GetInviteAttribution 按被邀请用户读取邀请归因快照;无关系时返回 Found=false。
|
// GetInviteAttribution 按被邀请用户读取邀请归因快照;无关系时返回 Found=false。
|
||||||
GetInviteAttribution(ctx context.Context, invitedUserID int64) (invitedomain.Attribution, error)
|
GetInviteAttribution(ctx context.Context, invitedUserID int64) (invitedomain.Attribution, error)
|
||||||
|
// FindInviteReferrerByCode 按 active 邀请码读取归属用户,供 H5 绑定页搜索同国家邀请人。
|
||||||
|
FindInviteReferrerByCode(ctx context.Context, appCode string, inviteCode string) (userdomain.User, error)
|
||||||
// ListUserIDs 按稳定 user_id 游标读取低频后台任务目标用户。
|
// ListUserIDs 按稳定 user_id 游标读取低频后台任务目标用户。
|
||||||
ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error)
|
ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error)
|
||||||
// CreateUserWithIdentity 原子创建用户和默认短号。
|
// CreateUserWithIdentity 原子创建用户和默认短号。
|
||||||
@ -59,6 +61,8 @@ type UserRepository interface {
|
|||||||
UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error)
|
UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error)
|
||||||
// CompleteOnboarding 原子写注册页必填资料并标记 profile_completed。
|
// CompleteOnboarding 原子写注册页必填资料并标记 profile_completed。
|
||||||
CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error)
|
CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error)
|
||||||
|
// BindInviteReferrer 原子绑定当前用户的邀请人,并在事务内复核双方国家一致。
|
||||||
|
BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error)
|
||||||
// ChangeUserCountry 原子修改用户国家、写变更日志,并返回需要立即失效的 auth session。
|
// ChangeUserCountry 原子修改用户国家、写变更日志,并返回需要立即失效的 auth session。
|
||||||
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error)
|
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,29 +34,26 @@ func (r *Repository) CreateBDLeader(ctx context.Context, command hostservice.Cre
|
|||||||
if err := requireActiveRegion(ctx, tx, regionID); err != nil {
|
if err := requireActiveRegion(ctx, tx, regionID); err != nil {
|
||||||
return hostdomain.BDProfile{}, err
|
return hostdomain.BDProfile{}, err
|
||||||
}
|
}
|
||||||
if _, ok, err := queryBDLeaderProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil {
|
// admin 和经理入口都是强制开通:已有 active Leader 直接复用,disabled Leader 恢复 active。
|
||||||
|
// 开通 Leader 后再补齐普通 BD 身份;已有 active BD 时绝不覆盖原 parent_leader_user_id。
|
||||||
|
profile, leaderEvent, err := ensureActiveBDLeaderProfile(ctx, tx, command.TargetUserID, regionID, command.AdminUserID, command.NowMs)
|
||||||
|
if err != nil {
|
||||||
return hostdomain.BDProfile{}, err
|
return hostdomain.BDProfile{}, err
|
||||||
} else if ok {
|
|
||||||
return hostdomain.BDProfile{}, xerr.New(xerr.Conflict, "target already has bd leader profile")
|
|
||||||
}
|
}
|
||||||
|
if _, bdEvent, err := ensureSelfBDProfileForLeader(ctx, tx, command.TargetUserID, regionID, command.AdminUserID, command.NowMs); err != nil {
|
||||||
profile := hostdomain.BDProfile{
|
return hostdomain.BDProfile{}, err
|
||||||
UserID: command.TargetUserID,
|
} else if bdEvent != "" {
|
||||||
Role: hostdomain.BDRoleLeader,
|
if err := insertHostOutbox(ctx, tx, command.EventID+1, bdEvent, "bd_profile", command.TargetUserID, command.NowMs); err != nil {
|
||||||
RegionID: regionID,
|
return hostdomain.BDProfile{}, err
|
||||||
Status: hostdomain.BDStatusActive,
|
}
|
||||||
CreatedByUserID: command.AdminUserID,
|
|
||||||
CreatedAtMs: command.NowMs,
|
|
||||||
UpdatedAtMs: command.NowMs,
|
|
||||||
}
|
|
||||||
if err := insertBDLeaderProfile(ctx, tx, profile); err != nil {
|
|
||||||
return hostdomain.BDProfile{}, mapHostDuplicateError(err)
|
|
||||||
}
|
}
|
||||||
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeCreateBDLeader, ResultType: hostdomain.ResultTypeBDProfile, ResultID: profile.UserID, CreatedAtMs: command.NowMs}); err != nil {
|
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeCreateBDLeader, ResultType: hostdomain.ResultTypeBDProfile, ResultID: profile.UserID, CreatedAtMs: command.NowMs}); err != nil {
|
||||||
return hostdomain.BDProfile{}, mapHostDuplicateError(err)
|
return hostdomain.BDProfile{}, mapHostDuplicateError(err)
|
||||||
}
|
}
|
||||||
if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", profile.UserID, command.NowMs); err != nil {
|
if leaderEvent != "" {
|
||||||
return hostdomain.BDProfile{}, err
|
if err := insertHostOutbox(ctx, tx, command.EventID, leaderEvent, "bd_profile", profile.UserID, command.NowMs); err != nil {
|
||||||
|
return hostdomain.BDProfile{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return hostdomain.BDProfile{}, err
|
return hostdomain.BDProfile{}, err
|
||||||
@ -313,10 +310,9 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
|
|||||||
} else if ok {
|
} else if ok {
|
||||||
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.Conflict, "owner already has active agency")
|
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.Conflict, "owner already has active agency")
|
||||||
}
|
}
|
||||||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.OwnerUserID, true); err != nil {
|
activeMembership, hasActiveMembership, err := queryActiveMembershipByHost(ctx, tx, command.OwnerUserID, true)
|
||||||
|
if err != nil {
|
||||||
return hostdomain.CreateAgencyResult{}, err
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
} else if ok {
|
|
||||||
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.Conflict, "owner already has active agency membership")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// max_hosts 是历史兼容字段,当前业务不再限制主播数量,新建 Agency 固化为 0。
|
// max_hosts 是历史兼容字段,当前业务不再限制主播数量,新建 Agency 固化为 0。
|
||||||
@ -324,18 +320,33 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
|
|||||||
if err := insertAgency(ctx, tx, agency); err != nil {
|
if err := insertAgency(ctx, tx, agency); err != nil {
|
||||||
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
|
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
|
||||||
}
|
}
|
||||||
hostProfile, created, err := ensureHostProfileForMembership(ctx, tx, command.OwnerUserID, regionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAdminCreateAgency, command.NowMs)
|
var hostProfile hostdomain.HostProfile
|
||||||
if err != nil {
|
var membership hostdomain.AgencyMembership
|
||||||
return hostdomain.CreateAgencyResult{}, err
|
var hostProfileChanged bool
|
||||||
}
|
var membershipCreated bool
|
||||||
membership := hostdomain.AgencyMembership{MembershipID: command.MembershipID, AgencyID: agency.AgencyID, HostUserID: command.OwnerUserID, RegionID: regionID, MembershipType: hostdomain.MembershipTypeOwner, Status: hostdomain.MembershipStatusActive, JoinedAtMs: command.NowMs, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs}
|
if hasActiveMembership {
|
||||||
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
|
// owner 已经是其他 Agency 的 active Host 时,只开启 Agency owner 身份;
|
||||||
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
|
// Host 当前归属继续指向原 membership,避免覆盖既有 Agency 关系。
|
||||||
|
hostProfile, hostProfileChanged, err = ensureHostProfileForExistingMembership(ctx, tx, command.OwnerUserID, regionID, activeMembership, hostdomain.HostSourceAdminCreateAgency, command.NowMs)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
|
}
|
||||||
|
membership = activeMembership
|
||||||
|
} else {
|
||||||
|
hostProfile, hostProfileChanged, err = ensureHostProfileForMembership(ctx, tx, command.OwnerUserID, regionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAdminCreateAgency, command.NowMs)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
|
}
|
||||||
|
membership = hostdomain.AgencyMembership{MembershipID: command.MembershipID, AgencyID: agency.AgencyID, HostUserID: command.OwnerUserID, RegionID: regionID, MembershipType: hostdomain.MembershipTypeOwner, Status: hostdomain.MembershipStatusActive, JoinedAtMs: command.NowMs, CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs}
|
||||||
|
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
|
||||||
|
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
|
||||||
|
}
|
||||||
|
membershipCreated = true
|
||||||
}
|
}
|
||||||
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeCreateAgency, ResultType: hostdomain.ResultTypeAgency, ResultID: agency.AgencyID, CreatedAtMs: command.NowMs}); err != nil {
|
if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeCreateAgency, ResultType: hostdomain.ResultTypeAgency, ResultID: agency.AgencyID, CreatedAtMs: command.NowMs}); err != nil {
|
||||||
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
|
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
|
||||||
}
|
}
|
||||||
if created {
|
if hostProfileChanged {
|
||||||
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", hostProfile.UserID, command.NowMs); err != nil {
|
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", hostProfile.UserID, command.NowMs); err != nil {
|
||||||
return hostdomain.CreateAgencyResult{}, err
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
}
|
}
|
||||||
@ -343,8 +354,10 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
|
|||||||
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", agency.AgencyID, command.NowMs); err != nil {
|
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", agency.AgencyID, command.NowMs); err != nil {
|
||||||
return hostdomain.CreateAgencyResult{}, err
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
}
|
}
|
||||||
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil {
|
if membershipCreated {
|
||||||
return hostdomain.CreateAgencyResult{}, err
|
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", membership.MembershipID, command.NowMs); err != nil {
|
||||||
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return hostdomain.CreateAgencyResult{}, err
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
@ -542,9 +555,11 @@ func createAgencyResultByAgencyID(ctx context.Context, q sqlQueryer, agencyID in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return hostdomain.CreateAgencyResult{}, err
|
return hostdomain.CreateAgencyResult{}, err
|
||||||
}
|
}
|
||||||
membership, err := queryAgencyMembership(ctx, q, "WHERE agency_id = ? AND host_user_id = ? AND membership_type = ? AND status = ?", agency.AgencyID, agency.OwnerUserID, hostdomain.MembershipTypeOwner, hostdomain.MembershipStatusActive)
|
var membership hostdomain.AgencyMembership
|
||||||
if err != nil {
|
if hostProfile.CurrentMembershipID > 0 {
|
||||||
return hostdomain.CreateAgencyResult{}, err
|
// 新规则允许 Agency owner 已经是其他 Agency 的 Host 成员;
|
||||||
|
// 幂等回放返回用户当前 Host 归属,而不是强行要求新 Agency 存在 owner membership。
|
||||||
|
membership, _ = queryAgencyMembership(ctx, q, "WHERE membership_id = ?", hostProfile.CurrentMembershipID)
|
||||||
}
|
}
|
||||||
return hostdomain.CreateAgencyResult{Agency: agency, HostProfile: hostProfile, Membership: membership}, nil
|
return hostdomain.CreateAgencyResult{Agency: agency, HostProfile: hostProfile, Membership: membership}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,13 +47,8 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
|
|||||||
if targetRegionID != inviter.RegionID {
|
if targetRegionID != inviter.RegionID {
|
||||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match inviter region")
|
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match inviter region")
|
||||||
}
|
}
|
||||||
// Agency 拥有者必须没有当前有效 Agency 归属;已有 Host 身份但未归属 Agency 时可以复用身份。
|
// Agency owner 和 Host 成员归属是两套身份;目标已有 active Host 绑定时仍允许成为 Agency owner,
|
||||||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.TargetUserID, true); err != nil {
|
// 但接受邀请时不会覆盖原 Host 当前归属。一个用户只能拥有一个有效 Agency,这里仍先行拦截。
|
||||||
return hostdomain.RoleInvitation{}, err
|
|
||||||
} else if ok {
|
|
||||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has active agency membership")
|
|
||||||
}
|
|
||||||
// 一个用户只能拥有一个有效 Agency;这里先拦截,最终仍依赖唯一索引兜底并发。
|
|
||||||
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.TargetUserID, true); err != nil {
|
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.TargetUserID, true); err != nil {
|
||||||
return hostdomain.RoleInvitation{}, err
|
return hostdomain.RoleInvitation{}, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
@ -138,9 +133,9 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
|
|||||||
if targetRegionID != inviter.RegionID {
|
if targetRegionID != inviter.RegionID {
|
||||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region")
|
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region")
|
||||||
}
|
}
|
||||||
// 目标已有普通 BD 身份行时不再创建新邀请,即使已停用也不能再插入第二条普通 BD 事实。
|
// 只有 active 普通 BD 才算已有权限;disabled 行可以重新发邀请,
|
||||||
// BD Leader 自邀也必须新增普通 BD 行,后续才能获得 BD_SALARY_USD 钱包鉴权。
|
// 接受时会复用原主键行恢复 active,避免历史停用记录阻断重新授权。
|
||||||
if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil {
|
if _, ok, err := queryBDProfileMaybe(ctx, tx, command.TargetUserID, true); err != nil {
|
||||||
return hostdomain.RoleInvitation{}, err
|
return hostdomain.RoleInvitation{}, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has bd profile")
|
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has bd profile")
|
||||||
@ -422,13 +417,13 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
|
|||||||
invitation.Status = hostdomain.InvitationStatusAccepted
|
invitation.Status = hostdomain.InvitationStatusAccepted
|
||||||
if invitation.InvitationType == hostdomain.InvitationTypeAgency {
|
if invitation.InvitationType == hostdomain.InvitationTypeAgency {
|
||||||
// Agency 邀请的接受会一次性创建 Agency 拥有者身份、Agency 和拥有者成员关系。
|
// Agency 邀请的接受会一次性创建 Agency 拥有者身份、Agency 和拥有者成员关系。
|
||||||
hostProfileCreated, err := r.acceptAgencyInvitation(ctx, tx, command, &invitation, &result)
|
hostProfileChanged, membershipCreated, err := r.acceptAgencyInvitation(ctx, tx, command, &invitation, &result)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return hostdomain.ProcessRoleInvitationResult{}, err
|
return hostdomain.ProcessRoleInvitationResult{}, err
|
||||||
}
|
}
|
||||||
// 事件 ID 在业务层预分配;这里按固定偏移写多条事件箱记录,
|
// 事件 ID 在业务层预分配;这里按固定偏移写多条事件箱记录,
|
||||||
// 让同一个接受事务中的派生事件保持可追踪顺序。
|
// 让同一个接受事务中的派生事件保持可追踪顺序。
|
||||||
if hostProfileCreated {
|
if hostProfileChanged {
|
||||||
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", invitation.TargetUserID, command.NowMs); err != nil {
|
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", invitation.TargetUserID, command.NowMs); err != nil {
|
||||||
return hostdomain.ProcessRoleInvitationResult{}, err
|
return hostdomain.ProcessRoleInvitationResult{}, err
|
||||||
}
|
}
|
||||||
@ -436,8 +431,10 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
|
|||||||
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", command.AgencyID, command.NowMs); err != nil {
|
if err := insertHostOutbox(ctx, tx, command.EventID+1, "AgencyCreated", "agency", command.AgencyID, command.NowMs); err != nil {
|
||||||
return hostdomain.ProcessRoleInvitationResult{}, err
|
return hostdomain.ProcessRoleInvitationResult{}, err
|
||||||
}
|
}
|
||||||
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil {
|
if membershipCreated {
|
||||||
return hostdomain.ProcessRoleInvitationResult{}, err
|
if err := insertHostOutbox(ctx, tx, command.EventID+2, "AgencyMembershipChanged", "agency_membership", command.MembershipID, command.NowMs); err != nil {
|
||||||
|
return hostdomain.ProcessRoleInvitationResult{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if invitation.InvitationType == hostdomain.InvitationTypeBD {
|
} else if invitation.InvitationType == hostdomain.InvitationTypeBD {
|
||||||
// BD 邀请的接受只创建 bd_profile;BD 不需要 host_profile。
|
// BD 邀请的接受只创建 bd_profile;BD 不需要 host_profile。
|
||||||
@ -486,31 +483,31 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) {
|
func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, bool, error) {
|
||||||
// 邀请可能放置一段时间后才被接受,所以目标用户和邀请 BD 都用 users 当前区域重新对齐。
|
// 邀请可能放置一段时间后才被接受,所以目标用户和邀请 BD 都用 users 当前区域重新对齐。
|
||||||
// 邀请表不再保存区域快照,旧库残留的 region_id 也不能参与是否可接受的业务判断。
|
// 邀请表不再保存区域快照,旧库残留的 region_id 也不能参与是否可接受的业务判断。
|
||||||
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
|
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
inviterRegionID, err := userRegionValue(ctx, tx, invitation.InviterBDUserID, "")
|
inviterRegionID, err := userRegionValue(ctx, tx, invitation.InviterBDUserID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
if targetRegionID != inviterRegionID {
|
if targetRegionID != inviterRegionID {
|
||||||
return false, xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
|
return false, false, xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
|
||||||
}
|
}
|
||||||
// 目标用户在邀请创建后可能已经加入其他 Agency;接受前必须再次阻断有效归属。
|
// 目标已有 Host 绑定时保留绑定,不再把 Host 当前归属切到自己新建的 Agency。
|
||||||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true); err != nil {
|
// 这里仍锁住 active membership,避免接受邀请期间另一条入会流程改写当前关系。
|
||||||
return false, err
|
activeMembership, hasActiveMembership, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true)
|
||||||
} else if ok {
|
if err != nil {
|
||||||
return false, xerr.New(xerr.Conflict, "target already has active agency membership")
|
return false, false, err
|
||||||
}
|
}
|
||||||
// 同一用户不能拥有多个有效 Agency;这里和唯一索引共同处理并发接受。
|
// 同一用户不能拥有多个有效 Agency;这里和唯一索引共同处理并发接受。
|
||||||
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, invitation.TargetUserID, true); err != nil {
|
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, invitation.TargetUserID, true); err != nil {
|
||||||
return false, err
|
return false, false, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return false, xerr.New(xerr.Conflict, "target already owns active agency")
|
return false, false, xerr.New(xerr.Conflict, "target already owns active agency")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agency、拥有者 host_profile、拥有者成员关系是同一业务事实的三张表投影,
|
// Agency、拥有者 host_profile、拥有者成员关系是同一业务事实的三张表投影,
|
||||||
@ -530,11 +527,23 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
|
|||||||
UpdatedAtMs: command.NowMs,
|
UpdatedAtMs: command.NowMs,
|
||||||
}
|
}
|
||||||
if err := insertAgency(ctx, tx, agency); err != nil {
|
if err := insertAgency(ctx, tx, agency); err != nil {
|
||||||
return false, mapHostDuplicateError(err)
|
return false, false, mapHostDuplicateError(err)
|
||||||
|
}
|
||||||
|
if hasActiveMembership {
|
||||||
|
// 已有 active Host 绑定时只恢复/修正 host_profile 指向原 membership,不插入 owner membership,
|
||||||
|
// 防止同一 Host 同时拥有两条 active agency_memberships。
|
||||||
|
hostProfile, hostProfileChanged, err := ensureHostProfileForExistingMembership(ctx, tx, invitation.TargetUserID, targetRegionID, activeMembership, hostdomain.HostSourceAgencyInvitation, command.NowMs)
|
||||||
|
if err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
result.HostProfile = hostProfile
|
||||||
|
result.Agency = agency
|
||||||
|
result.Membership = activeMembership
|
||||||
|
return hostProfileChanged, false, nil
|
||||||
}
|
}
|
||||||
hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAgencyInvitation, command.NowMs)
|
hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceAgencyInvitation, command.NowMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, false, err
|
||||||
}
|
}
|
||||||
membership := hostdomain.AgencyMembership{
|
membership := hostdomain.AgencyMembership{
|
||||||
MembershipID: command.MembershipID,
|
MembershipID: command.MembershipID,
|
||||||
@ -548,12 +557,12 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
|
|||||||
UpdatedAtMs: command.NowMs,
|
UpdatedAtMs: command.NowMs,
|
||||||
}
|
}
|
||||||
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
|
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
|
||||||
return false, mapHostDuplicateError(err)
|
return false, false, mapHostDuplicateError(err)
|
||||||
}
|
}
|
||||||
result.HostProfile = hostProfile
|
result.HostProfile = hostProfile
|
||||||
result.Agency = agency
|
result.Agency = agency
|
||||||
result.Membership = membership
|
result.Membership = membership
|
||||||
return hostProfileCreated, nil
|
return hostProfileCreated, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) {
|
func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, command hostservice.ProcessRoleInvitationCommand, invitation *hostdomain.RoleInvitation, result *hostdomain.ProcessRoleInvitationResult) (bool, error) {
|
||||||
@ -585,7 +594,7 @@ func (r *Repository) acceptHostInvitation(ctx context.Context, tx *sql.Tx, comma
|
|||||||
return false, xerr.New(xerr.Conflict, "target already owns active agency")
|
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)
|
hostProfile, hostProfileCreated, err := ensureHostProfileForMembership(ctx, tx, invitation.TargetUserID, targetRegionID, command.MembershipID, agency.AgencyID, hostdomain.HostSourceHostInvitation, command.NowMs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@ -622,11 +631,22 @@ func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command
|
|||||||
if targetRegionID != leaderRegionID {
|
if targetRegionID != leaderRegionID {
|
||||||
return xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
|
return xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
|
||||||
}
|
}
|
||||||
// BD 身份是独立角色事实,不依赖 host_profile;任何已有普通 BD 行都不能重复插入。
|
// BD 身份是独立角色事实,不依赖 host_profile;active BD 不能被覆盖,
|
||||||
if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil {
|
// disabled BD 不算当前权限,接受邀请时恢复并绑定到本次 Leader。
|
||||||
|
if existing, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil {
|
||||||
return err
|
return err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
return xerr.New(xerr.Conflict, "target already has bd profile")
|
if existing.Status == hostdomain.BDStatusActive {
|
||||||
|
return xerr.New(xerr.Conflict, "target already has bd profile")
|
||||||
|
}
|
||||||
|
existing.ParentLeaderUserID = invitation.ParentLeaderUserID
|
||||||
|
existing.Status = hostdomain.BDStatusActive
|
||||||
|
existing.UpdatedAtMs = command.NowMs
|
||||||
|
if err := updateBDProfileForActivation(ctx, tx, existing); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.BDProfile = existing
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
bd := hostdomain.BDProfile{
|
bd := hostdomain.BDProfile{
|
||||||
UserID: invitation.TargetUserID,
|
UserID: invitation.TargetUserID,
|
||||||
|
|||||||
@ -237,8 +237,6 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
|
|||||||
app := appcode.FromContext(ctx)
|
app := appcode.FromContext(ctx)
|
||||||
summary := hostdomain.UserRoleSummary{UserID: userID}
|
summary := hostdomain.UserRoleSummary{UserID: userID}
|
||||||
var hostStatus string
|
var hostStatus string
|
||||||
var hostSource string
|
|
||||||
var hostAgencyID int64
|
|
||||||
var ownerAgencyID int64
|
var ownerAgencyID int64
|
||||||
var bdUserID int64
|
var bdUserID int64
|
||||||
var bdStatus string
|
var bdStatus string
|
||||||
@ -251,8 +249,6 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
|
|||||||
err := r.db.QueryRowContext(ctx, `
|
err := r.db.QueryRowContext(ctx, `
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(h.status, ''),
|
COALESCE(h.status, ''),
|
||||||
COALESCE(h.source, ''),
|
|
||||||
COALESCE(h.current_agency_id, 0),
|
|
||||||
COALESCE(a.agency_id, 0),
|
COALESCE(a.agency_id, 0),
|
||||||
COALESCE(b.user_id, 0),
|
COALESCE(b.user_id, 0),
|
||||||
COALESCE(b.status, ''),
|
COALESCE(b.status, ''),
|
||||||
@ -281,21 +277,20 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
|
|||||||
app,
|
app,
|
||||||
app, hostdomain.ManagerStatusActive,
|
app, hostdomain.ManagerStatusActive,
|
||||||
app,
|
app,
|
||||||
).Scan(&hostStatus, &hostSource, &hostAgencyID, &ownerAgencyID, &bdUserID, &bdStatus, &leaderUserID, &leaderStatus, &managerUserID, &coinSellerStatus, &merchantAssetType)
|
).Scan(&hostStatus, &ownerAgencyID, &bdUserID, &bdStatus, &leaderUserID, &leaderStatus, &managerUserID, &coinSellerStatus, &merchantAssetType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return hostdomain.UserRoleSummary{}, err
|
return hostdomain.UserRoleSummary{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
summary.HostStatus = hostStatus
|
summary.HostStatus = hostStatus
|
||||||
summary.IsHost = hostStatus == hostdomain.HostStatusActive
|
summary.IsHost = hostStatus == hostdomain.HostStatusActive
|
||||||
if ownerAgencyID > 0 {
|
// Agency 身份只来自 active Agency owner 行;普通 Host 即使有 current_agency_id,也只是成员归属,不能暴露 Agency Center。
|
||||||
|
summary.IsAgency = ownerAgencyID > 0
|
||||||
|
if summary.IsAgency {
|
||||||
summary.AgencyID = ownerAgencyID
|
summary.AgencyID = ownerAgencyID
|
||||||
} else {
|
|
||||||
summary.AgencyID = hostAgencyID
|
|
||||||
}
|
}
|
||||||
// 经理身份来自独立 manager_profiles 表;Agency owner、Host、BD 或 BD Leader 身份都不会隐式获得经理中心入口。
|
// 经理身份来自独立 manager_profiles 表;Agency owner、Host、BD 或 BD Leader 身份都不会隐式获得经理中心入口。
|
||||||
summary.IsManager = managerUserID > 0
|
summary.IsManager = managerUserID > 0
|
||||||
summary.IsAgency = ownerAgencyID > 0 || (summary.IsHost && (hostSource == hostdomain.HostSourceAgencyInvitation || hostSource == hostdomain.HostSourceAdminCreateAgency))
|
|
||||||
summary.BDStatus = bdStatus
|
summary.BDStatus = bdStatus
|
||||||
if bdUserID > 0 && bdStatus == hostdomain.BDStatusActive {
|
if bdUserID > 0 && bdStatus == hostdomain.BDStatusActive {
|
||||||
summary.BDID = bdUserID
|
summary.BDID = bdUserID
|
||||||
@ -325,7 +320,7 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HasActiveAgencyOwner 精确判断用户是否为 active Agency owner。
|
// HasActiveAgencyOwner 精确判断用户是否为 active Agency owner。
|
||||||
// 普通 Agency 成员会在 UserRoleSummary 中有 Agency 入口,但不能获得经理中心资源赠送权限。
|
// 普通 Agency 成员只保留 host_profile.current_agency_id 归属,不会在 UserRoleSummary 中获得 Agency 入口。
|
||||||
func (r *Repository) HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error) {
|
func (r *Repository) HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error) {
|
||||||
agency, ok, err := queryActiveAgencyByOwner(ctx, r.db, userID, false)
|
agency, ok, err := queryActiveAgencyByOwner(ctx, r.db, userID, false)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
|
|||||||
@ -81,7 +81,14 @@ func ensureHostProfileForMembership(ctx context.Context, tx *sql.Tx, userID int6
|
|||||||
return hostdomain.HostProfile{}, false, err
|
return hostdomain.HostProfile{}, false, err
|
||||||
}
|
}
|
||||||
if profile.Status != hostdomain.HostStatusActive {
|
if profile.Status != hostdomain.HostStatusActive {
|
||||||
return hostdomain.HostProfile{}, false, xerr.New(xerr.Conflict, "host profile is not active")
|
// disabled 不再算有效 Host 权限;当用户通过邀请或后台开通重新获得 Host 身份时,
|
||||||
|
// 复用原主键行并挂到本次新 membership,避免因为历史停用记录导致身份补齐失败。
|
||||||
|
profile.Status = hostdomain.HostStatusActive
|
||||||
|
profile.RegionID = regionID
|
||||||
|
profile.CurrentAgencyID = agencyID
|
||||||
|
profile.CurrentMembershipID = membershipID
|
||||||
|
profile.UpdatedAtMs = nowMs
|
||||||
|
return profile, true, updateHostProfileForMembership(ctx, tx, profile)
|
||||||
}
|
}
|
||||||
if profile.CurrentMembershipID > 0 {
|
if profile.CurrentMembershipID > 0 {
|
||||||
// current_membership 是为“我的 Agency”快速读取做的冗余;非 0 表示已经挂载。
|
// current_membership 是为“我的 Agency”快速读取做的冗余;非 0 表示已经挂载。
|
||||||
@ -95,6 +102,39 @@ func ensureHostProfileForMembership(ctx context.Context, tx *sql.Tx, userID int6
|
|||||||
return profile, false, updateHostCurrentMembership(ctx, tx, profile)
|
return profile, false, updateHostCurrentMembership(ctx, tx, profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureHostProfileForExistingMembership(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, membership hostdomain.AgencyMembership, source string, nowMs int64) (hostdomain.HostProfile, bool, error) {
|
||||||
|
profile, ok, err := queryHostProfileByUserMaybe(ctx, tx, userID, true)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.HostProfile{}, false, err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// 极端历史数据可能只有 active membership 没有 host_profile;这里补回 Host 身份,
|
||||||
|
// 但指针仍指向已有 membership,绝不把用户重新绑定到新创建的 Agency。
|
||||||
|
profile = hostdomain.HostProfile{
|
||||||
|
UserID: userID,
|
||||||
|
Status: hostdomain.HostStatusActive,
|
||||||
|
RegionID: regionID,
|
||||||
|
CurrentAgencyID: membership.AgencyID,
|
||||||
|
CurrentMembershipID: membership.MembershipID,
|
||||||
|
Source: source,
|
||||||
|
FirstBecameHostAtMs: nowMs,
|
||||||
|
CreatedAtMs: nowMs,
|
||||||
|
UpdatedAtMs: nowMs,
|
||||||
|
}
|
||||||
|
return profile, true, insertHostProfile(ctx, tx, profile)
|
||||||
|
}
|
||||||
|
changed := profile.Status != hostdomain.HostStatusActive || profile.CurrentAgencyID != membership.AgencyID || profile.CurrentMembershipID != membership.MembershipID
|
||||||
|
profile.Status = hostdomain.HostStatusActive
|
||||||
|
profile.RegionID = regionID
|
||||||
|
profile.CurrentAgencyID = membership.AgencyID
|
||||||
|
profile.CurrentMembershipID = membership.MembershipID
|
||||||
|
profile.UpdatedAtMs = nowMs
|
||||||
|
if !changed {
|
||||||
|
return profile, false, nil
|
||||||
|
}
|
||||||
|
return profile, true, updateHostProfileForMembership(ctx, tx, profile)
|
||||||
|
}
|
||||||
|
|
||||||
func insertHostProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
|
func insertHostProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
|
||||||
// 这里只用于首次创建身份;成员关系变化走 updateHostCurrentMembership。
|
// 这里只用于首次创建身份;成员关系变化走 updateHostCurrentMembership。
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
@ -117,6 +157,17 @@ func updateHostCurrentMembership(ctx context.Context, tx *sql.Tx, profile hostdo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateHostProfileForMembership(ctx context.Context, tx *sql.Tx, profile hostdomain.HostProfile) error {
|
||||||
|
// 恢复 disabled Host 或修复历史指针时,需要同时写 status 和当前归属;
|
||||||
|
// source/first_became_host_at_ms 仍然保留首次成为 Host 的历史语义。
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE host_profiles
|
||||||
|
SET status = ?, current_agency_id = ?, current_membership_id = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND user_id = ?
|
||||||
|
`, profile.Status, nullableRegionID(profile.CurrentAgencyID), nullableRegionID(profile.CurrentMembershipID), profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func updateUserRegion(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, nowMs int64) error {
|
func updateUserRegion(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, nowMs int64) error {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
UPDATE users
|
UPDATE users
|
||||||
@ -179,6 +230,17 @@ func updateBDProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.B
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func updateBDProfileForActivation(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
|
||||||
|
// disabled 普通 BD 被重新授予权限时才允许重写父级 Leader;
|
||||||
|
// active BD 的 parent 代表既有业务关系,任何补齐逻辑都不能覆盖它。
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE bd_profiles
|
||||||
|
SET parent_leader_user_id = ?, status = ?, updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND user_id = ? AND role = 'bd'
|
||||||
|
`, nullableRegionID(profile.ParentLeaderUserID), profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
|
func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error {
|
||||||
// Leader 状态只影响负责人权限和 ADMIN_SALARY_USD 钱包入口,不影响同用户可能存在的普通 BD 身份。
|
// Leader 状态只影响负责人权限和 ADMIN_SALARY_USD 钱包入口,不影响同用户可能存在的普通 BD 身份。
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
@ -189,6 +251,75 @@ func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureActiveBDLeaderProfile(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, createdByUserID int64, nowMs int64) (hostdomain.BDProfile, string, error) {
|
||||||
|
profile, ok, err := queryBDLeaderProfileByUserMaybe(ctx, tx, userID, true)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.BDProfile{}, "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// 直开 BD Leader 时,负责人身份先成为 active 事实;普通 BD 钱包/工资身份由后续 helper 补齐。
|
||||||
|
profile = hostdomain.BDProfile{
|
||||||
|
UserID: userID,
|
||||||
|
Role: hostdomain.BDRoleLeader,
|
||||||
|
RegionID: regionID,
|
||||||
|
Status: hostdomain.BDStatusActive,
|
||||||
|
CreatedByUserID: createdByUserID,
|
||||||
|
CreatedAtMs: nowMs,
|
||||||
|
UpdatedAtMs: nowMs,
|
||||||
|
}
|
||||||
|
if err := insertBDLeaderProfile(ctx, tx, profile); err != nil {
|
||||||
|
return hostdomain.BDProfile{}, "", mapHostDuplicateError(err)
|
||||||
|
}
|
||||||
|
return profile, "BDCreated", nil
|
||||||
|
}
|
||||||
|
if profile.Status == hostdomain.BDStatusActive {
|
||||||
|
// 已经是 active Leader 时只回放当前事实,不重写 created_by,也不重复发状态事件。
|
||||||
|
return profile, "", nil
|
||||||
|
}
|
||||||
|
profile.Status = hostdomain.BDStatusActive
|
||||||
|
profile.UpdatedAtMs = nowMs
|
||||||
|
if err := updateBDLeaderProfileStatus(ctx, tx, profile); err != nil {
|
||||||
|
return hostdomain.BDProfile{}, "", err
|
||||||
|
}
|
||||||
|
return profile, "BDStatusChanged", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSelfBDProfileForLeader(ctx context.Context, tx *sql.Tx, userID int64, regionID int64, createdByUserID int64, nowMs int64) (hostdomain.BDProfile, string, error) {
|
||||||
|
profile, ok, err := queryBDProfileByUserMaybe(ctx, tx, userID, true)
|
||||||
|
if err != nil {
|
||||||
|
return hostdomain.BDProfile{}, "", err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
// BD Leader 需要普通 BD 身份承载下级 Agency/工资等 BD 体系能力;
|
||||||
|
// 首次补齐时把 parent_leader_user_id 指向自己,形成自有 BD Leader 体系。
|
||||||
|
profile = hostdomain.BDProfile{
|
||||||
|
UserID: userID,
|
||||||
|
Role: hostdomain.BDRoleBD,
|
||||||
|
RegionID: regionID,
|
||||||
|
ParentLeaderUserID: userID,
|
||||||
|
Status: hostdomain.BDStatusActive,
|
||||||
|
CreatedByUserID: createdByUserID,
|
||||||
|
CreatedAtMs: nowMs,
|
||||||
|
UpdatedAtMs: nowMs,
|
||||||
|
}
|
||||||
|
if err := insertBDProfile(ctx, tx, profile); err != nil {
|
||||||
|
return hostdomain.BDProfile{}, "", mapHostDuplicateError(err)
|
||||||
|
}
|
||||||
|
return profile, "BDCreated", nil
|
||||||
|
}
|
||||||
|
if profile.Status == hostdomain.BDStatusActive {
|
||||||
|
// 已有 active 普通 BD 说明用户已经在某个 BD 关系里;开通 Leader 不能覆盖原 parent。
|
||||||
|
return profile, "", nil
|
||||||
|
}
|
||||||
|
profile.ParentLeaderUserID = userID
|
||||||
|
profile.Status = hostdomain.BDStatusActive
|
||||||
|
profile.UpdatedAtMs = nowMs
|
||||||
|
if err := updateBDProfileForActivation(ctx, tx, profile); err != nil {
|
||||||
|
return hostdomain.BDProfile{}, "", err
|
||||||
|
}
|
||||||
|
return profile, "BDStatusChanged", nil
|
||||||
|
}
|
||||||
|
|
||||||
func insertCoinSellerProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.CoinSellerProfile) error {
|
func insertCoinSellerProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.CoinSellerProfile) error {
|
||||||
// 币商身份是独立角色事实;账户余额属于 wallet-service,不在此表冗余。
|
// 币商身份是独立角色事实;账户余额属于 wallet-service,不在此表冗余。
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
|||||||
@ -0,0 +1,225 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hyapp/pkg/appcode"
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
|
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||||
|
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||||
|
invitestorage "hyapp/services/user-service/internal/storage/mysql/invite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FindInviteReferrerByCode 按 active 邀请码读取归属用户,搜索阶段只做只读解析。
|
||||||
|
func (r *Repository) FindInviteReferrerByCode(ctx context.Context, appCode string, inviteCode string) (userdomain.User, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
appCode = appcode.Normalize(appCode)
|
||||||
|
inviteCode = invitedomain.NormalizeCode(inviteCode)
|
||||||
|
if !invitedomain.ValidCode(inviteCode) {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||||
|
}
|
||||||
|
|
||||||
|
row := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT `+userSelectColumns+`
|
||||||
|
FROM users
|
||||||
|
INNER JOIN user_invite_codes codes
|
||||||
|
ON codes.app_code = users.app_code
|
||||||
|
AND codes.owner_user_id = users.user_id
|
||||||
|
WHERE users.app_code = ?
|
||||||
|
AND codes.code = ?
|
||||||
|
AND codes.status = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, appCode, inviteCode, invitedomain.CodeStatusActive)
|
||||||
|
inviter, err := ScanUser(row)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.NotFound, "inviter not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
return inviter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindInviteReferrer 在资料完成后绑定当前用户的邀请人。
|
||||||
|
// 这里必须锁双方 users 行并在同一事务内复核国家,不能只信 H5 先前搜索结果。
|
||||||
|
func (r *Repository) BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||||
|
}
|
||||||
|
command.AppCode = appcode.Normalize(command.AppCode)
|
||||||
|
command.InviteCode = invitedomain.NormalizeCode(command.InviteCode)
|
||||||
|
command.RequestID = strings.TrimSpace(command.RequestID)
|
||||||
|
if command.InvitedUserID <= 0 || !invitedomain.ValidCode(command.InviteCode) {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := r.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
inviterUserID, err := inviteReferrerOwnerUserID(ctx, tx, command.AppCode, command.InviteCode)
|
||||||
|
if err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
if command.InvitedUserID == inviterUserID {
|
||||||
|
return userdomain.User{}, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||||
|
}
|
||||||
|
|
||||||
|
invited, inviter, err := lockInviteReferrerUsers(ctx, tx, command.AppCode, command.InvitedUserID, inviterUserID)
|
||||||
|
if err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
if err := validateLockedInviteReferrerPair(invited, inviter); err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if existing, exists, err := inviteRelationForInvited(ctx, tx, command.AppCode, command.InvitedUserID); err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
} else if exists {
|
||||||
|
if invitedomain.NormalizeCode(existing.InviteCode) != command.InviteCode {
|
||||||
|
// 邀请归因是一次性事实;已绑定其他邀请人时不能被 H5 改写。
|
||||||
|
return userdomain.User{}, xerr.New(xerr.Conflict, "invite referrer already bound")
|
||||||
|
}
|
||||||
|
if err := fillUserInviteCodeSnapshot(ctx, tx, command, existing.InviteCode); err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
updated, err := r.GetUser(ctx, command.InvitedUserID)
|
||||||
|
updated.InviteBinding = invitedomain.Binding{InviteCode: existing.InviteCode, InviterUserID: existing.InviterUserID}
|
||||||
|
return updated, err
|
||||||
|
}
|
||||||
|
|
||||||
|
binding, err := invitestorage.BindRelation(ctx, tx, invitedomain.BindCommand{
|
||||||
|
AppCode: command.AppCode,
|
||||||
|
InvitedUserID: command.InvitedUserID,
|
||||||
|
InvitedRegionID: invited.RegionID,
|
||||||
|
InviteCode: command.InviteCode,
|
||||||
|
Source: "bind_invite_referrer",
|
||||||
|
RequestID: command.RequestID,
|
||||||
|
BoundAtMs: command.BoundAtMs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
if binding.InviterUserID != inviter.UserID || invitedomain.NormalizeCode(binding.InviteCode) != command.InviteCode {
|
||||||
|
// 并发下如果另一个事务先绑定了不同邀请人,保持一次性归因语义并让调用方刷新状态。
|
||||||
|
return userdomain.User{}, xerr.New(xerr.Conflict, "invite referrer already bound")
|
||||||
|
}
|
||||||
|
if err := fillUserInviteCodeSnapshot(ctx, tx, command, binding.InviteCode); err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return userdomain.User{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := r.GetUser(ctx, command.InvitedUserID)
|
||||||
|
updated.InviteBinding = binding
|
||||||
|
return updated, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func inviteReferrerOwnerUserID(ctx context.Context, tx *sql.Tx, appCode string, inviteCode string) (int64, error) {
|
||||||
|
var ownerUserID int64
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT owner_user_id
|
||||||
|
FROM user_invite_codes
|
||||||
|
WHERE app_code = ? AND code = ? AND status = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, appCode, inviteCode, invitedomain.CodeStatusActive).Scan(&ownerUserID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return 0, xerr.New(xerr.InvalidInviteCode, "invalid invite code")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return ownerUserID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockInviteReferrerUsers(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64, inviterUserID int64) (userdomain.User, userdomain.User, error) {
|
||||||
|
rows, err := tx.QueryContext(ctx, fmt.Sprintf(`
|
||||||
|
SELECT %s
|
||||||
|
FROM users
|
||||||
|
WHERE app_code = ? AND user_id IN (?, ?)
|
||||||
|
ORDER BY user_id ASC
|
||||||
|
FOR UPDATE
|
||||||
|
`, userSelectColumns), appCode, invitedUserID, inviterUserID)
|
||||||
|
if err != nil {
|
||||||
|
return userdomain.User{}, userdomain.User{}, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
users := make(map[int64]userdomain.User, 2)
|
||||||
|
for rows.Next() {
|
||||||
|
user, err := ScanUser(rows)
|
||||||
|
if err != nil {
|
||||||
|
return userdomain.User{}, userdomain.User{}, err
|
||||||
|
}
|
||||||
|
users[user.UserID] = user
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return userdomain.User{}, userdomain.User{}, err
|
||||||
|
}
|
||||||
|
invited, invitedOK := users[invitedUserID]
|
||||||
|
inviter, inviterOK := users[inviterUserID]
|
||||||
|
if !invitedOK || !inviterOK {
|
||||||
|
return userdomain.User{}, userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
|
||||||
|
}
|
||||||
|
return invited, inviter, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateLockedInviteReferrerPair(invited userdomain.User, inviter userdomain.User) error {
|
||||||
|
if !invited.ProfileCompleted {
|
||||||
|
return xerr.New(xerr.ProfileRequired, "profile required")
|
||||||
|
}
|
||||||
|
if !invited.CanLogin() {
|
||||||
|
return xerr.New(xerr.PermissionDenied, "permission denied")
|
||||||
|
}
|
||||||
|
if !inviter.ProfileCompleted || !inviter.CanLogin() {
|
||||||
|
return xerr.New(xerr.NotFound, "inviter not found")
|
||||||
|
}
|
||||||
|
if invited.Country == "" || inviter.Country == "" || invited.Country != inviter.Country {
|
||||||
|
// 绑定入口和搜索入口都必须执行同国家限制,避免直接 POST 邀请码绕过搜索态校验。
|
||||||
|
return xerr.New(xerr.NotFound, "inviter not found")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inviteRelationForInvited(ctx context.Context, tx *sql.Tx, appCode string, invitedUserID int64) (invitedomain.Binding, bool, error) {
|
||||||
|
var binding invitedomain.Binding
|
||||||
|
err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT inviter_user_id, invite_code
|
||||||
|
FROM user_invite_relations
|
||||||
|
WHERE app_code = ? AND invited_user_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
FOR UPDATE
|
||||||
|
`, appCode, invitedUserID).Scan(&binding.InviterUserID, &binding.InviteCode)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return invitedomain.Binding{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return invitedomain.Binding{}, false, err
|
||||||
|
}
|
||||||
|
return binding, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fillUserInviteCodeSnapshot(ctx context.Context, tx *sql.Tx, command userdomain.BindInviteReferrerCommand, inviteCode string) error {
|
||||||
|
inviteCode = invitedomain.NormalizeCode(inviteCode)
|
||||||
|
if inviteCode == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE users
|
||||||
|
SET invite_code = CASE WHEN COALESCE(invite_code, '') = '' THEN ? ELSE invite_code END,
|
||||||
|
updated_at_ms = GREATEST(updated_at_ms, ?)
|
||||||
|
WHERE app_code = ? AND user_id = ?
|
||||||
|
`, inviteCode, command.BoundAtMs, command.AppCode, command.InvitedUserID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@ -159,6 +159,16 @@ func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain.
|
|||||||
return r.Repository.UserRepository().CompleteOnboarding(ctx, command)
|
return r.Repository.UserRepository().CompleteOnboarding(ctx, command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BindInviteReferrer 让测试 wrapper 继续满足 H5 邀请人绑定接口。
|
||||||
|
func (r *Repository) BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
|
||||||
|
return r.Repository.UserRepository().BindInviteReferrer(ctx, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindInviteReferrerByCode 让测试 wrapper 继续满足 H5 邀请人搜索接口。
|
||||||
|
func (r *Repository) FindInviteReferrerByCode(ctx context.Context, appCode string, inviteCode string) (userdomain.User, error) {
|
||||||
|
return r.Repository.UserRepository().FindInviteReferrerByCode(ctx, appCode, inviteCode)
|
||||||
|
}
|
||||||
|
|
||||||
// ChangeUserCountry 让测试 wrapper 继续满足业务层国家修改接口。
|
// ChangeUserCountry 让测试 wrapper 继续满足业务层国家修改接口。
|
||||||
func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error) {
|
func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error) {
|
||||||
return r.Repository.UserRepository().ChangeUserCountry(ctx, command)
|
return r.Repository.UserRepository().ChangeUserCountry(ctx, command)
|
||||||
|
|||||||
@ -318,6 +318,36 @@ func (s *Server) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*user
|
|||||||
return &userv1.GetUserResponse{User: toProtoUser(user)}, nil
|
return &userv1.GetUserResponse{User: toProtoUser(user)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SearchInviteReferrer 用当前用户国家边界搜索 H5 邀请页可绑定邀请人。
|
||||||
|
func (s *Server) SearchInviteReferrer(ctx context.Context, req *userv1.SearchInviteReferrerRequest) (*userv1.SearchInviteReferrerResponse, error) {
|
||||||
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
|
inviter, err := s.userSvc.SearchInviteReferrer(ctx, req.GetUserId(), req.GetInviteCode())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &userv1.SearchInviteReferrerResponse{Inviter: toProtoUser(inviter)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindInviteReferrer 绑定当前用户的邀请人;邀请码归属由 user-service 事务内确认。
|
||||||
|
func (s *Server) BindInviteReferrer(ctx context.Context, req *userv1.BindInviteReferrerRequest) (*userv1.BindInviteReferrerResponse, error) {
|
||||||
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
|
updated, err := s.userSvc.BindInviteReferrer(ctx, req.GetUserId(), req.GetInviteCode(), req.GetMeta().GetRequestId())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inviter, err := s.userSvc.GetUser(ctx, updated.InviteBinding.InviterUserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &userv1.BindInviteReferrerResponse{
|
||||||
|
Invite: toProtoInviteBinding(updated.InviteBinding),
|
||||||
|
Inviter: toProtoUser(inviter),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetInviteAttribution 返回被邀请用户的邀请人归因,供活动服务按充值事实累计邀请活动奖励。
|
// GetInviteAttribution 返回被邀请用户的邀请人归因,供活动服务按充值事实累计邀请活动奖励。
|
||||||
func (s *Server) GetInviteAttribution(ctx context.Context, req *userv1.GetInviteAttributionRequest) (*userv1.GetInviteAttributionResponse, error) {
|
func (s *Server) GetInviteAttribution(ctx context.Context, req *userv1.GetInviteAttributionRequest) (*userv1.GetInviteAttributionResponse, error) {
|
||||||
ctx = contextWithApp(ctx, req.GetMeta())
|
ctx = contextWithApp(ctx, req.GetMeta())
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user