用户邀请
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;
|
||||
}
|
||||
|
||||
// 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。
|
||||
// push token 只服务离线触达,不作为登录凭证或设备身份凭证。
|
||||
message BindPushTokenRequest {
|
||||
@ -1293,6 +1316,8 @@ service UserService {
|
||||
rpc ListManagerUserBlocks(ListManagerUserBlocksRequest) returns (ListManagerUserBlocksResponse);
|
||||
rpc UnblockManagerUser(UnblockManagerUserRequest) returns (UnblockManagerUserResponse);
|
||||
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
|
||||
rpc SearchInviteReferrer(SearchInviteReferrerRequest) returns (SearchInviteReferrerResponse);
|
||||
rpc BindInviteReferrer(BindInviteReferrerRequest) returns (BindInviteReferrerResponse);
|
||||
}
|
||||
|
||||
// UserSocialService 承载用户访问、关注和好友关系。
|
||||
|
||||
@ -36,6 +36,8 @@ const (
|
||||
UserService_ListManagerUserBlocks_FullMethodName = "/hyapp.user.v1.UserService/ListManagerUserBlocks"
|
||||
UserService_UnblockManagerUser_FullMethodName = "/hyapp.user.v1.UserService/UnblockManagerUser"
|
||||
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.
|
||||
@ -61,6 +63,8 @@ type UserServiceClient interface {
|
||||
ListManagerUserBlocks(ctx context.Context, in *ListManagerUserBlocksRequest, opts ...grpc.CallOption) (*ListManagerUserBlocksResponse, error)
|
||||
UnblockManagerUser(ctx context.Context, in *UnblockManagerUserRequest, opts ...grpc.CallOption) (*UnblockManagerUserResponse, 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 {
|
||||
@ -241,6 +245,26 @@ func (c *userServiceClient) CompleteOnboarding(ctx context.Context, in *Complete
|
||||
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.
|
||||
// All implementations must embed UnimplementedUserServiceServer
|
||||
// for forward compatibility.
|
||||
@ -264,6 +288,8 @@ type UserServiceServer interface {
|
||||
ListManagerUserBlocks(context.Context, *ListManagerUserBlocksRequest) (*ListManagerUserBlocksResponse, error)
|
||||
UnblockManagerUser(context.Context, *UnblockManagerUserRequest) (*UnblockManagerUserResponse, error)
|
||||
CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error)
|
||||
SearchInviteReferrer(context.Context, *SearchInviteReferrerRequest) (*SearchInviteReferrerResponse, error)
|
||||
BindInviteReferrer(context.Context, *BindInviteReferrerRequest) (*BindInviteReferrerResponse, error)
|
||||
mustEmbedUnimplementedUserServiceServer()
|
||||
}
|
||||
|
||||
@ -325,6 +351,12 @@ func (UnimplementedUserServiceServer) UnblockManagerUser(context.Context, *Unblo
|
||||
func (UnimplementedUserServiceServer) CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error) {
|
||||
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) testEmbeddedByValue() {}
|
||||
|
||||
@ -652,6 +684,42 @@ func _UserService_CompleteOnboarding_Handler(srv interface{}, ctx context.Contex
|
||||
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.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -727,6 +795,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "CompleteOnboarding",
|
||||
Handler: _UserService_CompleteOnboarding_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SearchInviteReferrer",
|
||||
Handler: _UserService_SearchInviteReferrer_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BindInviteReferrer",
|
||||
Handler: _UserService_BindInviteReferrer_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
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 userIdentityClient client.UserIdentityClient = client.NewGRPCUserIdentityClient(userConn)
|
||||
var userProfileClient client.UserProfileClient = client.NewGRPCUserProfileClient(userConn)
|
||||
var userInviteClient client.UserInviteClient = client.NewGRPCUserInviteClient(userConn)
|
||||
var userDeviceClient client.UserDeviceClient = client.NewGRPCUserDeviceClient(userConn)
|
||||
var userCountryQueryClient client.UserCountryQueryClient = client.NewGRPCUserCountryQueryClient(userConn)
|
||||
var userRegionClient client.UserRegionClient = client.NewGRPCUserRegionClient(userConn)
|
||||
@ -149,6 +150,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
}
|
||||
handler.SetRoomQueryClient(roomQueryClient)
|
||||
handler.SetUserDeviceClient(userDeviceClient)
|
||||
handler.SetUserInviteClient(userInviteClient)
|
||||
handler.SetUserCountryQueryClient(userCountryQueryClient)
|
||||
handler.SetUserRegionClient(userRegionClient)
|
||||
handler.SetUserHostClient(userHostClient)
|
||||
|
||||
@ -50,6 +50,12 @@ type UserProfileClient interface {
|
||||
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 访问、关注和好友关系能力的依赖。
|
||||
type UserSocialClient interface {
|
||||
RecordProfileVisit(ctx context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error)
|
||||
@ -142,6 +148,10 @@ type grpcUserProfileClient struct {
|
||||
client userv1.UserServiceClient
|
||||
}
|
||||
|
||||
type grpcUserInviteClient struct {
|
||||
client userv1.UserServiceClient
|
||||
}
|
||||
|
||||
type grpcUserSocialClient struct {
|
||||
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。
|
||||
func NewGRPCUserSocialClient(conn *grpc.ClientConn) UserSocialClient {
|
||||
return &grpcUserSocialClient{
|
||||
@ -371,6 +388,14 @@ func (c *grpcUserProfileClient) UnblockManagerUser(ctx context.Context, req *use
|
||||
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) {
|
||||
return c.client.RecordProfileVisit(ctx, req)
|
||||
}
|
||||
|
||||
@ -137,6 +137,10 @@ func (f *fakeInviteActivityUserProfileClient) UpdateUserProfileBackground(contex
|
||||
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) {
|
||||
return &userv1.ChangeUserCountryResponse{}, nil
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ type Handler struct {
|
||||
userClient client.UserAuthClient
|
||||
userIdentityClient client.UserIdentityClient
|
||||
userProfileClient client.UserProfileClient
|
||||
userInviteClient client.UserInviteClient
|
||||
userSocialClient client.UserSocialClient
|
||||
userCPClient client.UserCPClient
|
||||
userDeviceClient client.UserDeviceClient
|
||||
@ -190,6 +191,11 @@ func (h *Handler) SetUserDeviceClient(userDeviceClient client.UserDeviceClient)
|
||||
h.userDeviceClient = userDeviceClient
|
||||
}
|
||||
|
||||
// SetUserInviteClient 注入 H5 邀请页搜索和绑定邀请人 client。
|
||||
func (h *Handler) SetUserInviteClient(userInviteClient client.UserInviteClient) {
|
||||
h.userInviteClient = userInviteClient
|
||||
}
|
||||
|
||||
// SetUserHostClient 注入 user-service host domain 身份查询 client。
|
||||
func (h *Handler) SetUserHostClient(userHostClient client.UserHostClient) {
|
||||
h.userHostClient = userHostClient
|
||||
|
||||
@ -73,6 +73,8 @@ type UserHandlers struct {
|
||||
BatchRoomDisplayProfiles http.HandlerFunc
|
||||
GetMyOverview http.HandlerFunc
|
||||
GetMyInviteOverview http.HandlerFunc
|
||||
SearchInviteReferrer http.HandlerFunc
|
||||
BindInviteReferrer http.HandlerFunc
|
||||
GetMyGiftWall http.HandlerFunc
|
||||
GetMyIdentity 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/me/overview", "", h.GetMyOverview)
|
||||
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.auth("/users/me/identity", "", h.GetMyIdentity)
|
||||
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) {
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{statsErr: errors.New("stats down")})
|
||||
handler.SetWalletClient(&fakeWalletClient{err: errors.New("wallet down")})
|
||||
|
||||
@ -4688,6 +4688,15 @@ func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
|
||||
ContactInfo: "+65 1234 5678",
|
||||
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.SetUserHostClient(hostClient)
|
||||
@ -4712,9 +4721,13 @@ func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
|
||||
data := response.Data.(map[string]any)
|
||||
items := data["items"].([]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)
|
||||
}
|
||||
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) {
|
||||
|
||||
@ -59,6 +59,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
userAPI := userapi.New(userapi.Config{
|
||||
UserIdentityClient: h.userIdentityClient,
|
||||
UserProfileClient: h.userProfileClient,
|
||||
UserInviteClient: h.userInviteClient,
|
||||
UserSocialClient: h.userSocialClient,
|
||||
UserCPClient: h.userCPClient,
|
||||
UserCountryClient: h.userCountryClient,
|
||||
|
||||
@ -12,6 +12,7 @@ import (
|
||||
type Handler struct {
|
||||
userIdentityClient client.UserIdentityClient
|
||||
userProfileClient client.UserProfileClient
|
||||
userInviteClient client.UserInviteClient
|
||||
userSocialClient client.UserSocialClient
|
||||
userCPClient client.UserCPClient
|
||||
userCountryClient client.UserCountryQueryClient
|
||||
@ -25,6 +26,7 @@ type Handler struct {
|
||||
type Config struct {
|
||||
UserIdentityClient client.UserIdentityClient
|
||||
UserProfileClient client.UserProfileClient
|
||||
UserInviteClient client.UserInviteClient
|
||||
UserSocialClient client.UserSocialClient
|
||||
UserCPClient client.UserCPClient
|
||||
UserCountryClient client.UserCountryQueryClient
|
||||
@ -39,6 +41,7 @@ func New(config Config) *Handler {
|
||||
return &Handler{
|
||||
userIdentityClient: config.UserIdentityClient,
|
||||
userProfileClient: config.UserProfileClient,
|
||||
userInviteClient: config.UserInviteClient,
|
||||
userSocialClient: config.UserSocialClient,
|
||||
userCPClient: config.UserCPClient,
|
||||
userCountryClient: config.UserCountryClient,
|
||||
@ -55,6 +58,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
||||
ResolveDisplayUserID: h.resolveDisplayUserID,
|
||||
GetMyOverview: h.getMyOverview,
|
||||
GetMyInviteOverview: h.getMyInviteOverview,
|
||||
SearchInviteReferrer: h.searchInviteReferrer,
|
||||
BindInviteReferrer: h.bindInviteReferrer,
|
||||
GetMyIdentity: h.getMyIdentity,
|
||||
GetMyHostIdentity: h.getMyHostIdentity,
|
||||
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"`
|
||||
Status string `json:"status"`
|
||||
MerchantAssetType string `json:"merchant_asset_type"`
|
||||
WhatsappPhone string `json:"whatsapp_phone,omitempty"`
|
||||
WhatsappURL string `json:"whatsapp_url,omitempty"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
// WhatsApp 字段固定输出,空联系方式也保留空字符串,避免充值页逐项读取时出现 schema 漂移。
|
||||
WhatsappPhone string `json:"whatsapp_phone"`
|
||||
WhatsappURL string `json:"whatsapp_url"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
// 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" }
|
||||
|
||||
// AdminDeleteRoom 定义后台删除房间请求;删除是 Room Cell 终态,不直接删物理表。
|
||||
// AdminDeleteRoom 定义后台删除房间请求;命令先经过 Room Cell 串行确认,再由 repository 清理恢复来源。
|
||||
type AdminDeleteRoom struct {
|
||||
Base
|
||||
AdminID uint64 `json:"admin_id,omitempty"`
|
||||
|
||||
@ -79,17 +79,29 @@ func (s *Service) AdminDeleteRoom(ctx context.Context, req *roomv1.AdminDeleteRo
|
||||
AdminID: req.GetAdminId(),
|
||||
AdminName: strings.TrimSpace(req.GetAdminName()),
|
||||
}
|
||||
patch := command.AdminUpdateRoom{
|
||||
Base: cmd.Base,
|
||||
Status: stringPtr(state.RoomStatusDeleted),
|
||||
CloseReason: "admin_deleted",
|
||||
AdminID: cmd.AdminID,
|
||||
AdminName: cmd.AdminName,
|
||||
}
|
||||
result, err := s.applyAdminRoomMutation(ctx, cmd, patch)
|
||||
result, err := s.mutateRoom(ctx, cmd, false, func(_ time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if current == nil {
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
|
||||
}
|
||||
// 删除不是“关闭”的另一种展示状态,而是后台确认移除房间恢复来源;状态只服务本次响应和内存 Cell 收尾。
|
||||
current.Status = state.RoomStatusDeleted
|
||||
current.OnlineUsers = map[int64]*state.RoomUserState{}
|
||||
for index := range current.MicSeats {
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
current.Version++
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
deleteRoom: true,
|
||||
}, nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.applied {
|
||||
s.deleteRoomListCacheBestEffort(ctx, cmd.RoomID())
|
||||
s.forgetLoadedRoom(ctx, cmd.RoomID())
|
||||
}
|
||||
return &roomv1.AdminDeleteRoomResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
Room: result.snapshot,
|
||||
@ -143,7 +155,7 @@ func (s *Service) adminUpdateCommand(req *roomv1.AdminUpdateRoomRequest) (comman
|
||||
if req.Status != nil {
|
||||
value := strings.TrimSpace(req.GetStatus())
|
||||
switch value {
|
||||
case state.RoomStatusActive, state.RoomStatusClosed, state.RoomStatusDeleted:
|
||||
case state.RoomStatusActive, state.RoomStatusClosed:
|
||||
cmd.Status = &value
|
||||
default:
|
||||
return command.AdminUpdateRoom{}, xerr.New(xerr.InvalidArgument, "room status is invalid")
|
||||
@ -294,7 +306,3 @@ func adminRoomListItemProto(item AdminRoomListEntry) *roomv1.AdminRoomListItem {
|
||||
UpdatedAtMs: item.UpdatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func stringPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
@ -2,12 +2,14 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/room-service/internal/room/cell"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"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)]
|
||||
}
|
||||
|
||||
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 {
|
||||
s.mu.Lock()
|
||||
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 {
|
||||
for _, user := range snapshot.GetOnlineUsers() {
|
||||
if user.GetUserId() == userID {
|
||||
|
||||
@ -16,13 +16,31 @@ import (
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
func (s *Service) requireRoomOpenForEntry(ctx context.Context, roomID string) error {
|
||||
closed, err := s.isRoomClosed(ctx, roomID)
|
||||
if err != nil || !closed {
|
||||
return err
|
||||
func (s *Service) roomEntryPolicy(ctx context.Context, roomID string, actorUserID int64) (bool, error) {
|
||||
// 进房前先读 rooms 元数据,避免为了拒绝关闭/删除房间而恢复完整 Room Cell。
|
||||
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
|
||||
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) {
|
||||
|
||||
@ -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 把完整房间快照压缩成列表卡片读模型。
|
||||
// 这里刻意只提取列表需要的字段,避免列表表结构反向绑定 RoomState 全量细节。
|
||||
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(),
|
||||
},
|
||||
OutboxRecords: durableOutboxRecords,
|
||||
DeleteRoom: result.deleteRoom,
|
||||
RoomStatus: result.roomStatus,
|
||||
RoomSeatCount: result.roomSeatCount,
|
||||
RoomPasswordHash: result.roomPasswordHash,
|
||||
@ -164,7 +165,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
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 查询。
|
||||
snapshotStartedAt := time.Now()
|
||||
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)
|
||||
}
|
||||
if result.applied {
|
||||
if result.applied && !result.deleteRoom {
|
||||
projectionStartedAt := time.Now()
|
||||
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
|
||||
s.projectRoomListBestEffort(ctx, result.snapshot)
|
||||
|
||||
@ -39,11 +39,25 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
// 客户端不传角色时按普通观众进入房间。
|
||||
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
|
||||
}
|
||||
|
||||
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.ActiveAt(now.UnixMilli()) {
|
||||
// 被踢或封禁用户在 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")
|
||||
}
|
||||
|
||||
joinRole := cmd.Role
|
||||
if current.OwnerUserID == cmd.ActorUserID() {
|
||||
// 房主恢复 closed 房间时通常没有现存 presence,角色必须按房间 owner 事实收敛为 owner。
|
||||
joinRole = "owner"
|
||||
}
|
||||
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
|
||||
// 重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新 last_seen,
|
||||
// 但房间 UI 仍需要一条入房飘窗。这里把事件放进 directIMRecords,不进入 durable outbox,
|
||||
@ -70,43 +89,41 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
snapshot := current.ToProto()
|
||||
return mutationResult{
|
||||
snapshot: snapshot,
|
||||
user: findProtoUser(snapshot, cmd.ActorUserID()),
|
||||
directIMRecords: []outbox.Record{joinedDisplayEvent},
|
||||
}, nil, nil
|
||||
result.snapshot = snapshot
|
||||
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||||
result.directIMRecords = []outbox.Record{joinedDisplayEvent}
|
||||
return result, nil, nil
|
||||
}
|
||||
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
|
||||
UserID: cmd.ActorUserID(),
|
||||
Role: cmd.Role,
|
||||
Role: joinRole,
|
||||
JoinedAtMS: now.UnixMilli(),
|
||||
LastSeenAtMS: now.UnixMilli(),
|
||||
}
|
||||
current.Version++
|
||||
|
||||
// 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 {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
snapshot := current.ToProto()
|
||||
|
||||
return mutationResult{
|
||||
snapshot: snapshot,
|
||||
user: findProtoUser(snapshot, cmd.ActorUserID()),
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: joinedEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_user_joined",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
TargetUserID: cmd.ActorUserID(),
|
||||
RoomVersion: current.Version,
|
||||
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
|
||||
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
|
||||
Attributes: roomUserJoinedIMAttributes(cmd.Role, cmd.ActorDisplayProfile),
|
||||
},
|
||||
}, []outbox.Record{joinedEvent}, nil
|
||||
result.snapshot = snapshot
|
||||
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||||
result.syncEvent = &tencentim.RoomEvent{
|
||||
EventID: joinedEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_user_joined",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
TargetUserID: cmd.ActorUserID(),
|
||||
RoomVersion: current.Version,
|
||||
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
|
||||
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
|
||||
Attributes: roomUserJoinedIMAttributes(joinRole, cmd.ActorDisplayProfile),
|
||||
}
|
||||
return result, []outbox.Record{joinedEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -147,6 +147,14 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
|
||||
current.RoomExt[roomExtBackgroundKey] = typed.BackgroundURL
|
||||
current.Version++
|
||||
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 {
|
||||
// 重复 JoinRoom 回放只刷新 last_seen,不能重置首次进房时间或降级 owner 角色。
|
||||
existing.LastSeenAtMS = typed.SentAtMS
|
||||
@ -154,7 +162,7 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
|
||||
// presence 使用命令发送时间恢复,真实 IM 连接由客户端重新登录腾讯云 IM。
|
||||
current.OnlineUsers[typed.ActorUserID()] = &state.RoomUserState{
|
||||
UserID: typed.ActorUserID(),
|
||||
Role: typed.Role,
|
||||
Role: role,
|
||||
JoinedAtMS: typed.SentAtMS,
|
||||
LastSeenAtMS: typed.SentAtMS,
|
||||
}
|
||||
|
||||
@ -59,6 +59,9 @@ type MutationCommit struct {
|
||||
Command CommandRecord
|
||||
// OutboxRecords 是本次命令产生的房间外事件。
|
||||
OutboxRecords []outbox.Record
|
||||
// DeleteRoom 表示后台确认删除房间后清理该房间的持久恢复来源和列表投影。
|
||||
// 该标记只用于 AdminDeleteRoom;关闭房间必须继续走 RoomStatus=closed,保留可恢复的房间事实。
|
||||
DeleteRoom bool
|
||||
// RoomStatus 非空时同步更新 rooms 和 room_list_entries 的生命周期状态。
|
||||
RoomStatus string
|
||||
// RoomSeatCount 非 nil 时同步更新 rooms 元数据中的麦位总数。
|
||||
|
||||
@ -184,6 +184,8 @@ type mutationResult struct {
|
||||
roomRocketProgress *roomRocketProgressPending
|
||||
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
|
||||
roomStatus string
|
||||
// deleteRoom 表示本命令成功后房间应从持久恢复来源、列表投影和当前节点 Cell 注册表中移除。
|
||||
deleteRoom bool
|
||||
// roomSeatCount 非 nil 时代表命令提交时需要同步更新 rooms 元数据座位数。
|
||||
roomSeatCount *int32
|
||||
// roomPasswordHash 非 nil 时代表命令提交时需要同步更新 rooms 当前密码哈希。
|
||||
|
||||
@ -17,7 +17,7 @@ const (
|
||||
RoomStatusClosing = "closing"
|
||||
// RoomStatusClosed 表示房间已经关闭,guard 和进房都必须拒绝。
|
||||
RoomStatusClosed = "closed"
|
||||
// RoomStatusDeleted 表示后台删除后的运营终态;保留 command log/snapshot 作为恢复和审计来源。
|
||||
// RoomStatusDeleted 只兼容历史后台软删除数据;新的后台删除会物理清理房间恢复来源。
|
||||
RoomStatusDeleted = "deleted"
|
||||
|
||||
// RocketStatusCharging 表示当前等级火箭还在积攒燃料。
|
||||
|
||||
@ -95,6 +95,14 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
||||
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 _, err := tx.ExecContext(ctx,
|
||||
`UPDATE rooms
|
||||
@ -276,6 +284,31 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati
|
||||
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 {
|
||||
if len(records) == 0 {
|
||||
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.
|
||||
func (r *Repository) SetRoomSortScore(roomID string, sortScore int64) {
|
||||
r.t.Helper()
|
||||
|
||||
@ -11,6 +11,8 @@ const (
|
||||
HostSourceApplication = "agency_application"
|
||||
// HostSourceAgencyInvitation 表示主播身份来自接受 Agency 邀请并成为 Agency owner。
|
||||
HostSourceAgencyInvitation = "agency_invitation"
|
||||
// HostSourceHostInvitation 表示主播身份来自 Agency owner 邀请加入,本身不具备 Agency owner 身份。
|
||||
HostSourceHostInvitation = "host_invitation"
|
||||
// HostSourceAdminCreateAgency 表示主播身份来自后台直接创建 Agency。
|
||||
HostSourceAdminCreateAgency = "admin_create_agency"
|
||||
|
||||
|
||||
@ -680,6 +680,20 @@ type CompleteOnboardingCommand struct {
|
||||
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 描述用户国家修改事务。
|
||||
type CountryChangeCommand struct {
|
||||
// AppCode 是国家修改所属 App。
|
||||
|
||||
@ -389,7 +389,7 @@ func (s *Service) CreateBDLeader(ctx context.Context, command CreateBDLeaderInpu
|
||||
TargetUserID: command.TargetUserID,
|
||||
Reason: strings.TrimSpace(command.Reason),
|
||||
RequestID: strings.TrimSpace(command.RequestID),
|
||||
EventID: s.idGenerator.NewInt64(),
|
||||
EventID: s.nextEventIDRange(2),
|
||||
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 {
|
||||
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()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedActiveUser(t, repository, 501, 10)
|
||||
seedActiveUser(t, repository, 601, 10)
|
||||
seedActiveUser(t, repository, 602, 10)
|
||||
seedActiveUser(t, repository, 603, 10)
|
||||
seedActiveUser(t, repository, 604, 10)
|
||||
repository.PutBDProfile(hostdomain.BDProfile{
|
||||
UserID: 501,
|
||||
Role: hostdomain.BDRoleBD,
|
||||
RegionID: 10,
|
||||
Status: hostdomain.BDStatusActive,
|
||||
})
|
||||
repository.PutAgency(hostdomain.Agency{
|
||||
AgencyID: 650,
|
||||
OwnerUserID: 604,
|
||||
RegionID: 10,
|
||||
Status: hostdomain.AgencyStatusActive,
|
||||
JoinEnabled: true,
|
||||
})
|
||||
svc := newHostService(repository, 2000, 3000)
|
||||
|
||||
invitation, err := svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
||||
@ -239,8 +264,16 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.
|
||||
if err != nil {
|
||||
t.Fatalf("InviteAgency failed: %v", err)
|
||||
}
|
||||
if invitation.Status != hostdomain.InvitationStatusAccepted {
|
||||
t.Fatalf("agency invitation should be accepted immediately: %+v", invitation)
|
||||
if invitation.Status != hostdomain.InvitationStatusPending {
|
||||
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)
|
||||
if err != nil {
|
||||
@ -272,6 +305,14 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserRoleSummary for reused host failed: %v", err)
|
||||
@ -280,17 +321,121 @@ func TestAcceptAgencyInvitationCreatesOwnerHostAndReusesDetachedHost(t *testing.
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
_, err = svc.InviteAgency(ctx, hostservice.InviteAgencyInput{
|
||||
CommandID: "invite-agency-602-again",
|
||||
InviterUserID: 501,
|
||||
TargetUserID: 602,
|
||||
AgencyName: "Blocked Agency",
|
||||
repository.PutHostProfile(hostdomain.HostProfile{
|
||||
UserID: 603,
|
||||
Status: hostdomain.HostStatusActive,
|
||||
RegionID: 10,
|
||||
CurrentAgencyID: 650,
|
||||
CurrentMembershipID: 660,
|
||||
Source: "existing-membership",
|
||||
})
|
||||
if got := xerr.CodeOf(err); got != xerr.Conflict {
|
||||
t.Fatalf("host with active membership must not become another agency owner: got %s err=%v", got, err)
|
||||
seedActiveMembership(t, repository, 660, 650, 603, hostdomain.MembershipTypeMember)
|
||||
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 {
|
||||
t.Fatalf("InviteBD failed: %v", err)
|
||||
}
|
||||
if invitation.Status != hostdomain.InvitationStatusAccepted {
|
||||
t.Fatalf("bd invitation should be accepted immediately: %+v", invitation)
|
||||
if invitation.Status != hostdomain.InvitationStatusPending {
|
||||
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)
|
||||
if err != nil {
|
||||
@ -387,11 +540,19 @@ func TestInviteBDAcceptsSelfInviteForLeader(t *testing.T) {
|
||||
TargetUserID: 703,
|
||||
})
|
||||
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)
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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",
|
||||
AdminUserID: 1,
|
||||
TargetUserID: 801,
|
||||
ParentLeaderUserID: 801,
|
||||
Reason: "same user can also be bd",
|
||||
Reason: "same user already has bd",
|
||||
RequestID: "req-bd-same-user-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBD for existing leader user failed: %v", err)
|
||||
}
|
||||
if sameUserBD.Role != hostdomain.BDRoleBD || sameUserBD.ParentLeaderUserID != 801 {
|
||||
t.Fatalf("same user bd mismatch: %+v", sameUserBD)
|
||||
if got := xerr.CodeOf(err); got != xerr.Conflict {
|
||||
t.Fatalf("CreateBDLeader already creates ordinary bd: got %s err=%v", got, err)
|
||||
}
|
||||
leaderAfterBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader)
|
||||
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 {
|
||||
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",
|
||||
InviterUserID: 801,
|
||||
TargetUserID: 802,
|
||||
})
|
||||
if got := xerr.CodeOf(err); got != xerr.Conflict {
|
||||
t.Fatalf("disabled ordinary bd must not be invited again: got %s err=%v", got, err)
|
||||
if err != nil {
|
||||
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{
|
||||
CommandID: "admin-disable-bd-801",
|
||||
@ -799,6 +1003,8 @@ func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedActiveUser(t, repository, 911, 40)
|
||||
seedActiveUser(t, repository, 910, 40)
|
||||
seedActiveUser(t, repository, 912, 40)
|
||||
seedActiveUser(t, repository, 913, 40)
|
||||
repository.PutBDProfile(hostdomain.BDProfile{
|
||||
UserID: 910,
|
||||
Role: hostdomain.BDRoleBD,
|
||||
@ -811,6 +1017,22 @@ func TestAdminCreateAgencyReusesDetachedHost(t *testing.T) {
|
||||
RegionID: 40,
|
||||
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)
|
||||
|
||||
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" {
|
||||
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{
|
||||
CommandID: "admin-create-agency-existing-host-again",
|
||||
AdminUserID: 1,
|
||||
@ -1036,13 +1279,22 @@ func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCoinSeller failed: %v", err)
|
||||
}
|
||||
if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
||||
invite932, err := svc.InviteBD(ctx, hostservice.InviteBDInput{
|
||||
CommandID: "invite-bd-932",
|
||||
InviterUserID: 931,
|
||||
TargetUserID: 932,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
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 {
|
||||
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",
|
||||
InviterUserID: 931,
|
||||
TargetUserID: 931,
|
||||
}); err != nil {
|
||||
})
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
return nil, nil
|
||||
}
|
||||
@ -238,6 +242,10 @@ func (r *fakeModerationRepository) CompleteOnboarding(context.Context, userdomai
|
||||
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) {
|
||||
r.lastCountryCommand = command
|
||||
r.countryChangeCalls++
|
||||
|
||||
@ -47,6 +47,8 @@ type UserRepository interface {
|
||||
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
|
||||
// GetInviteAttribution 按被邀请用户读取邀请归因快照;无关系时返回 Found=false。
|
||||
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(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error)
|
||||
// CreateUserWithIdentity 原子创建用户和默认短号。
|
||||
@ -59,6 +61,8 @@ type UserRepository interface {
|
||||
UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error)
|
||||
// CompleteOnboarding 原子写注册页必填资料并标记 profile_completed。
|
||||
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(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 {
|
||||
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
|
||||
} else if ok {
|
||||
return hostdomain.BDProfile{}, xerr.New(xerr.Conflict, "target already has bd leader profile")
|
||||
}
|
||||
|
||||
profile := hostdomain.BDProfile{
|
||||
UserID: command.TargetUserID,
|
||||
Role: hostdomain.BDRoleLeader,
|
||||
RegionID: regionID,
|
||||
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 _, bdEvent, err := ensureSelfBDProfileForLeader(ctx, tx, command.TargetUserID, regionID, command.AdminUserID, command.NowMs); err != nil {
|
||||
return hostdomain.BDProfile{}, err
|
||||
} else if bdEvent != "" {
|
||||
if err := insertHostOutbox(ctx, tx, command.EventID+1, bdEvent, "bd_profile", command.TargetUserID, command.NowMs); err != nil {
|
||||
return hostdomain.BDProfile{}, 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 {
|
||||
return hostdomain.BDProfile{}, mapHostDuplicateError(err)
|
||||
}
|
||||
if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", profile.UserID, command.NowMs); err != nil {
|
||||
return hostdomain.BDProfile{}, err
|
||||
if leaderEvent != "" {
|
||||
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 {
|
||||
return hostdomain.BDProfile{}, err
|
||||
@ -313,10 +310,9 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat
|
||||
} else if ok {
|
||||
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
|
||||
} else if ok {
|
||||
return hostdomain.CreateAgencyResult{}, xerr.New(xerr.Conflict, "owner already has active agency membership")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return hostdomain.CreateAgencyResult{}, mapHostDuplicateError(err)
|
||||
}
|
||||
hostProfile, created, 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)
|
||||
var hostProfile hostdomain.HostProfile
|
||||
var membership hostdomain.AgencyMembership
|
||||
var hostProfileChanged bool
|
||||
var membershipCreated bool
|
||||
if hasActiveMembership {
|
||||
// owner 已经是其他 Agency 的 active Host 时,只开启 Agency owner 身份;
|
||||
// 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 {
|
||||
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 {
|
||||
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 {
|
||||
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 membershipCreated {
|
||||
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 {
|
||||
return hostdomain.CreateAgencyResult{}, err
|
||||
@ -542,9 +555,11 @@ func createAgencyResultByAgencyID(ctx context.Context, q sqlQueryer, agencyID in
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
return hostdomain.CreateAgencyResult{}, err
|
||||
var membership hostdomain.AgencyMembership
|
||||
if hostProfile.CurrentMembershipID > 0 {
|
||||
// 新规则允许 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
|
||||
}
|
||||
|
||||
@ -47,13 +47,8 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit
|
||||
if targetRegionID != inviter.RegionID {
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match inviter region")
|
||||
}
|
||||
// Agency 拥有者必须没有当前有效 Agency 归属;已有 Host 身份但未归属 Agency 时可以复用身份。
|
||||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, command.TargetUserID, true); err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
} else if ok {
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has active agency membership")
|
||||
}
|
||||
// 一个用户只能拥有一个有效 Agency;这里先拦截,最终仍依赖唯一索引兜底并发。
|
||||
// Agency owner 和 Host 成员归属是两套身份;目标已有 active Host 绑定时仍允许成为 Agency owner,
|
||||
// 但接受邀请时不会覆盖原 Host 当前归属。一个用户只能拥有一个有效 Agency,这里仍先行拦截。
|
||||
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, command.TargetUserID, true); err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
} else if ok {
|
||||
@ -138,9 +133,9 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC
|
||||
if targetRegionID != inviter.RegionID {
|
||||
return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region")
|
||||
}
|
||||
// 目标已有普通 BD 身份行时不再创建新邀请,即使已停用也不能再插入第二条普通 BD 事实。
|
||||
// BD Leader 自邀也必须新增普通 BD 行,后续才能获得 BD_SALARY_USD 钱包鉴权。
|
||||
if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil {
|
||||
// 只有 active 普通 BD 才算已有权限;disabled 行可以重新发邀请,
|
||||
// 接受时会复用原主键行恢复 active,避免历史停用记录阻断重新授权。
|
||||
if _, ok, err := queryBDProfileMaybe(ctx, tx, command.TargetUserID, true); err != nil {
|
||||
return hostdomain.RoleInvitation{}, err
|
||||
} else if ok {
|
||||
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
|
||||
if invitation.InvitationType == hostdomain.InvitationTypeAgency {
|
||||
// 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 {
|
||||
return hostdomain.ProcessRoleInvitationResult{}, err
|
||||
}
|
||||
// 事件 ID 在业务层预分配;这里按固定偏移写多条事件箱记录,
|
||||
// 让同一个接受事务中的派生事件保持可追踪顺序。
|
||||
if hostProfileCreated {
|
||||
if hostProfileChanged {
|
||||
if err := insertHostOutbox(ctx, tx, command.EventID, "HostCreated", "host_profile", invitation.TargetUserID, command.NowMs); err != nil {
|
||||
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 {
|
||||
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
|
||||
if membershipCreated {
|
||||
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 {
|
||||
// BD 邀请的接受只创建 bd_profile;BD 不需要 host_profile。
|
||||
@ -486,31 +483,31 @@ func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostserv
|
||||
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 当前区域重新对齐。
|
||||
// 邀请表不再保存区域快照,旧库残留的 region_id 也不能参与是否可接受的业务判断。
|
||||
targetRegionID, err := r.userRegion(ctx, tx, invitation.TargetUserID, "FOR UPDATE")
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, false, err
|
||||
}
|
||||
inviterRegionID, err := userRegionValue(ctx, tx, invitation.InviterBDUserID, "")
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, false, err
|
||||
}
|
||||
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;接受前必须再次阻断有效归属。
|
||||
if _, ok, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true); err != nil {
|
||||
return false, err
|
||||
} else if ok {
|
||||
return false, xerr.New(xerr.Conflict, "target already has active agency membership")
|
||||
// 目标已有 Host 绑定时保留绑定,不再把 Host 当前归属切到自己新建的 Agency。
|
||||
// 这里仍锁住 active membership,避免接受邀请期间另一条入会流程改写当前关系。
|
||||
activeMembership, hasActiveMembership, err := queryActiveMembershipByHost(ctx, tx, invitation.TargetUserID, true)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
// 同一用户不能拥有多个有效 Agency;这里和唯一索引共同处理并发接受。
|
||||
if _, ok, err := queryActiveAgencyByOwner(ctx, tx, invitation.TargetUserID, true); err != nil {
|
||||
return false, err
|
||||
return false, false, err
|
||||
} 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、拥有者成员关系是同一业务事实的三张表投影,
|
||||
@ -530,11 +527,23 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
|
||||
UpdatedAtMs: command.NowMs,
|
||||
}
|
||||
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)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return false, false, err
|
||||
}
|
||||
membership := hostdomain.AgencyMembership{
|
||||
MembershipID: command.MembershipID,
|
||||
@ -548,12 +557,12 @@ func (r *Repository) acceptAgencyInvitation(ctx context.Context, tx *sql.Tx, com
|
||||
UpdatedAtMs: command.NowMs,
|
||||
}
|
||||
if err := insertAgencyMembership(ctx, tx, membership); err != nil {
|
||||
return false, mapHostDuplicateError(err)
|
||||
return false, false, mapHostDuplicateError(err)
|
||||
}
|
||||
result.HostProfile = hostProfile
|
||||
result.Agency = agency
|
||||
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) {
|
||||
@ -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")
|
||||
}
|
||||
|
||||
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 {
|
||||
return false, err
|
||||
}
|
||||
@ -622,11 +631,22 @@ func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command
|
||||
if targetRegionID != leaderRegionID {
|
||||
return xerr.New(xerr.PermissionDenied, "target region no longer matches invitation")
|
||||
}
|
||||
// BD 身份是独立角色事实,不依赖 host_profile;任何已有普通 BD 行都不能重复插入。
|
||||
if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil {
|
||||
// BD 身份是独立角色事实,不依赖 host_profile;active BD 不能被覆盖,
|
||||
// disabled BD 不算当前权限,接受邀请时恢复并绑定到本次 Leader。
|
||||
if existing, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil {
|
||||
return err
|
||||
} 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{
|
||||
UserID: invitation.TargetUserID,
|
||||
|
||||
@ -237,8 +237,6 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
|
||||
app := appcode.FromContext(ctx)
|
||||
summary := hostdomain.UserRoleSummary{UserID: userID}
|
||||
var hostStatus string
|
||||
var hostSource string
|
||||
var hostAgencyID int64
|
||||
var ownerAgencyID int64
|
||||
var bdUserID int64
|
||||
var bdStatus string
|
||||
@ -251,8 +249,6 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT
|
||||
COALESCE(h.status, ''),
|
||||
COALESCE(h.source, ''),
|
||||
COALESCE(h.current_agency_id, 0),
|
||||
COALESCE(a.agency_id, 0),
|
||||
COALESCE(b.user_id, 0),
|
||||
COALESCE(b.status, ''),
|
||||
@ -281,21 +277,20 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
|
||||
app,
|
||||
app, hostdomain.ManagerStatusActive,
|
||||
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 {
|
||||
return hostdomain.UserRoleSummary{}, err
|
||||
}
|
||||
|
||||
summary.HostStatus = hostStatus
|
||||
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
|
||||
} else {
|
||||
summary.AgencyID = hostAgencyID
|
||||
}
|
||||
// 经理身份来自独立 manager_profiles 表;Agency owner、Host、BD 或 BD Leader 身份都不会隐式获得经理中心入口。
|
||||
summary.IsManager = managerUserID > 0
|
||||
summary.IsAgency = ownerAgencyID > 0 || (summary.IsHost && (hostSource == hostdomain.HostSourceAgencyInvitation || hostSource == hostdomain.HostSourceAdminCreateAgency))
|
||||
summary.BDStatus = bdStatus
|
||||
if bdUserID > 0 && bdStatus == hostdomain.BDStatusActive {
|
||||
summary.BDID = bdUserID
|
||||
@ -325,7 +320,7 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host
|
||||
}
|
||||
|
||||
// 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) {
|
||||
agency, ok, err := queryActiveAgencyByOwner(ctx, r.db, userID, false)
|
||||
if err != nil || !ok {
|
||||
|
||||
@ -81,7 +81,14 @@ func ensureHostProfileForMembership(ctx context.Context, tx *sql.Tx, userID int6
|
||||
return hostdomain.HostProfile{}, false, err
|
||||
}
|
||||
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 {
|
||||
// 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)
|
||||
}
|
||||
|
||||
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 {
|
||||
// 这里只用于首次创建身份;成员关系变化走 updateHostCurrentMembership。
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
@ -117,6 +157,17 @@ func updateHostCurrentMembership(ctx context.Context, tx *sql.Tx, profile hostdo
|
||||
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 {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
@ -179,6 +230,17 @@ func updateBDProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.B
|
||||
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 {
|
||||
// Leader 状态只影响负责人权限和 ADMIN_SALARY_USD 钱包入口,不影响同用户可能存在的普通 BD 身份。
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
@ -189,6 +251,75 @@ func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdo
|
||||
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 {
|
||||
// 币商身份是独立角色事实;账户余额属于 wallet-service,不在此表冗余。
|
||||
_, 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)
|
||||
}
|
||||
|
||||
// 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 继续满足业务层国家修改接口。
|
||||
func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 返回被邀请用户的邀请人归因,供活动服务按充值事实累计邀请活动奖励。
|
||||
func (s *Server) GetInviteAttribution(ctx context.Context, req *userv1.GetInviteAttributionRequest) (*userv1.GetInviteAttributionResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user