用户邀请

This commit is contained in:
zhx 2026-06-30 10:27:29 +08:00
parent c52bc9d026
commit e71194ad25
44 changed files with 3365 additions and 1597 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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 访

View File

@ -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",

View File

@ -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)

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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)

View File

@ -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")})

View File

@ -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) {

View File

@ -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,

View File

@ -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,

View File

@ -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),
})
}

View File

@ -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)
}

View File

@ -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 返回当前用户所在区域内所有启用币商。

View File

@ -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)
}
})
}
}

View File

@ -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"`

View File

@ -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
}

View File

@ -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()

View File

@ -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 {

View File

@ -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) {

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -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,
}

View File

@ -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 元数据中的麦位总数。

View File

@ -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 当前密码哈希。

View File

@ -17,7 +17,7 @@ const (
RoomStatusClosing = "closing"
// RoomStatusClosed 表示房间已经关闭guard 和进房都必须拒绝。
RoomStatusClosed = "closed"
// RoomStatusDeleted 表示后台删除后的运营终态;保留 command log/snapshot 作为恢复和审计来源。
// RoomStatusDeleted 只兼容历史后台软删除数据;新的后台删除会物理清理房间恢复来源。
RoomStatusDeleted = "deleted"
// RocketStatusCharging 表示当前等级火箭还在积攒燃料。

View File

@ -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

View File

@ -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()

View File

@ -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"

View File

@ -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。

View File

@ -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,
})
}

View File

@ -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)

View File

@ -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
}

View File

@ -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++

View File

@ -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)
}

View File

@ -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
}

View File

@ -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_profileBD 不需要 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_profileactive 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,

View File

@ -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 {

View File

@ -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, `

View File

@ -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
}

View File

@ -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)

View File

@ -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())