diff --git a/docs/注册邀请码架构.md b/docs/注册邀请码架构.md index 272b881f..f364168c 100644 --- a/docs/注册邀请码架构.md +++ b/docs/注册邀请码架构.md @@ -6,7 +6,7 @@ - 每个用户有一个可展示、可复制的永久邀请码。 - 新用户注册或完成资料时可以选填邀请码。 -- 非空邀请码必须能解析到同一 `app_code` 下的有效邀请人。 +- 非空邀请码必须能解析到同一 `app_code`、同一 `region_id` 下的有效邀请人。 - 邀请关系一旦绑定不可被客户端修改。 - 邀请关系必须落 MySQL;Redis 只能做短 TTL 查询缓存。 - 邀请统计必须拆成 `invite_count` 和 `valid_invite_count`:前者表示成功填码绑定关系的人数,后者表示被邀请人累计充值达到后台配置门槛的人数。 @@ -39,7 +39,7 @@ ### Code Ownership - 邀请码属于用户,不属于设备、session、三方账号或 Agency。 -- 邀请码按 `app_code` 隔离;`lalu` 的邀请码不能邀请 `popoo` 用户。 +- 邀请码按 `app_code + region_id` 隔离;`lalu` 的邀请码不能邀请 `popoo` 用户,不同区域用户也不能互相绑定邀请关系。 - 用户被禁用或封禁后,默认不允许继续作为新邀请人的有效 code owner;历史邀请关系不删除。 - 用户自己的邀请码创建后长期稳定。后续如果允许换码,必须保留旧码和历史关系快照。 @@ -49,7 +49,7 @@ | --- | --- | | 注册不填邀请码 | 创建用户,不创建邀请关系 | | 注册填写有效邀请码 | 创建用户,并绑定 `inviter_user_id` | -| 注册填写不存在、停用、跨 App 或 owner 非 active 的邀请码 | 返回 `INVALID_INVITE_CODE`,不静默忽略 | +| 注册填写不存在、停用、跨 App、跨区域或 owner 非 active 的邀请码 | 返回 `INVALID_INVITE_CODE`,不静默忽略 | | 用户填写自己的邀请码 | 返回 `INVALID_INVITE_CODE` | | 已有邀请关系的用户再次提交邀请码 | 不修改原关系 | | 已完成注册用户提交邀请码 | 不修改原关系;如果通过 onboarding 入口提交,按已完成资料逻辑不处理邀请码 | @@ -359,7 +359,7 @@ CREATE TABLE user_invite_counters ( 3. 创建 `users`。 4. 创建用户自己的 `user_invite_codes`。 5. 如果请求带 `invite_code`,解析并锁定 code owner 的 `users` 行。 -6. 校验 owner active、非自己、同 `app_code`。 +6. 校验 owner active、非自己、同 `app_code`、同 `region_id`。 7. 插入 `user_invite_relations`。 8. 创建 `third_party_identities` 和 `auth_sessions`。 9. 写 `UserRegistered` / `UserInvited` outbox 事件。 @@ -521,6 +521,7 @@ Redis miss 或异常时必须回 MySQL 或 fail-closed,不能把不存在当 | 新用户填有效邀请码 | 注册成功,关系表写入 inviter | | 新用户填不存在邀请码 | 返回 `INVALID_INVITE_CODE`,不创建完成关系 | | 邀请码跨 App | 返回 `INVALID_INVITE_CODE` | +| 邀请码跨区域 | 返回 `INVALID_INVITE_CODE` | | 邀请人 disabled/banned | 返回 `INVALID_INVITE_CODE` | | 用户尝试填自己的邀请码 | 返回 `INVALID_INVITE_CODE` | | 已绑定关系后再次提交邀请码 | 原关系不变 | diff --git a/services/user-service/internal/service/user/invite_referrer.go b/services/user-service/internal/service/user/invite_referrer.go index cb51a47e..e9dbf9be 100644 --- a/services/user-service/internal/service/user/invite_referrer.go +++ b/services/user-service/internal/service/user/invite_referrer.go @@ -11,7 +11,7 @@ import ( ) // SearchInviteReferrer 通过邀请码搜索可绑定邀请人。 -// 搜索结果只允许同 App、同国家、已完成资料且 active 的普通用户,避免 H5 枚举跨国家用户资料。 +// 邀请归因按 app 和区域隔离;国家只是用户资料快照,不能把同区域的跨国家邀请误判为不存在。 func (s *Service) SearchInviteReferrer(ctx context.Context, userID int64, inviteCode string) (userdomain.User, error) { inviteCode = invitedomain.NormalizeCode(inviteCode) if userID <= 0 { @@ -30,12 +30,9 @@ func (s *Service) SearchInviteReferrer(ctx context.Context, userID int64, invite return userdomain.User{}, err } if !current.ProfileCompleted { - // 当前账号未完成资料时没有稳定国家边界,不能用这个入口搜索邀请人。 + // H5 绑定入口只服务已完成资料用户;注册资料页绑定走 CompleteOnboarding 的原子写链路。 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 { @@ -82,8 +79,8 @@ func validateInviteReferrerPair(invited userdomain.User, inviter userdomain.User if !inviter.ProfileCompleted || !inviter.CanLogin() { return xerr.New(xerr.NotFound, "inviter not found") } - if invited.Country == "" || inviter.Country == "" || invited.Country != inviter.Country { - // 产品要求只能搜索和绑定本国家邀请人;跨国家统一返回 not found,避免泄漏目标用户是否存在。 + if invited.RegionID != inviter.RegionID { + // 产品边界是同区域邀请;跨区域统一按未找到处理,避免 H5 用搜索接口枚举其他区域用户。 return xerr.New(xerr.NotFound, "inviter not found") } return nil diff --git a/services/user-service/internal/service/user/invite_referrer_test.go b/services/user-service/internal/service/user/invite_referrer_test.go new file mode 100644 index 00000000..0688e281 --- /dev/null +++ b/services/user-service/internal/service/user/invite_referrer_test.go @@ -0,0 +1,70 @@ +package user + +import ( + "context" + "testing" + + "hyapp/pkg/appcode" + "hyapp/pkg/xerr" + userdomain "hyapp/services/user-service/internal/domain/user" +) + +func TestSearchInviteReferrerAllowsDifferentCountryInSameRegion(t *testing.T) { + repository := &fakeModerationRepository{ + user: userdomain.User{ + AppCode: "lalu", + UserID: 330307556184428544, + Country: "SG", + RegionID: 3, + ProfileCompleted: true, + Status: userdomain.StatusActive, + }, + inviteReferrer: userdomain.User{ + AppCode: "lalu", + UserID: 312900014781243392, + Country: "AT", + RegionID: 3, + ProfileCompleted: true, + Status: userdomain.StatusActive, + }, + } + svc := New(repository) + + found, err := svc.SearchInviteReferrer(appcode.WithContext(context.Background(), "lalu"), 330307556184428544, " a5uzc4mt ") + if err != nil { + t.Fatalf("same-region invite referrer search failed: %v", err) + } + if found.UserID != 312900014781243392 { + t.Fatalf("same-region invite referrer mismatch: %+v", found) + } + if repository.lastFindAppCode != "lalu" || repository.lastFindInviteCode != "A5UZC4MT" { + t.Fatalf("invite code lookup should use normalized app/code: app=%q code=%q", repository.lastFindAppCode, repository.lastFindInviteCode) + } +} + +func TestSearchInviteReferrerRejectsDifferentRegion(t *testing.T) { + repository := &fakeModerationRepository{ + user: userdomain.User{ + AppCode: "lalu", + UserID: 10001, + Country: "SG", + RegionID: 3, + ProfileCompleted: true, + Status: userdomain.StatusActive, + }, + inviteReferrer: userdomain.User{ + AppCode: "lalu", + UserID: 10002, + Country: "US", + RegionID: 4, + ProfileCompleted: true, + Status: userdomain.StatusActive, + }, + } + svc := New(repository) + + _, err := svc.SearchInviteReferrer(appcode.WithContext(context.Background(), "lalu"), 10001, "A5UZC4MT") + if xerr.CodeOf(err) != xerr.NotFound { + t.Fatalf("different-region invite referrer must be hidden as not found, err=%v", err) + } +} diff --git a/services/user-service/internal/service/user/moderation_test.go b/services/user-service/internal/service/user/moderation_test.go index 6df521ee..e10437f6 100644 --- a/services/user-service/internal/service/user/moderation_test.go +++ b/services/user-service/internal/service/user/moderation_test.go @@ -104,11 +104,15 @@ func TestSetUserStatusSurfacesAccessDenylistError(t *testing.T) { type fakeModerationRepository struct { user userdomain.User + inviteReferrer userdomain.User sessionIDs []string countrySessionIDs []string lastCommand UserStatusCommand lastProfileCommand userdomain.ProfileUpdateCommand lastCountryCommand userdomain.CountryChangeCommand + lastFindAppCode string + lastFindInviteCode string + lastBindInvite userdomain.BindInviteReferrerCommand countryChangeCalls int lastReport userdomain.Report } @@ -211,7 +215,12 @@ func (r *fakeModerationRepository) BatchGetUsers(context.Context, []int64) (map[ return nil, nil } -func (r *fakeModerationRepository) FindInviteReferrerByCode(context.Context, string, string) (userdomain.User, error) { +func (r *fakeModerationRepository) FindInviteReferrerByCode(_ context.Context, appCode string, inviteCode string) (userdomain.User, error) { + r.lastFindAppCode = appCode + r.lastFindInviteCode = inviteCode + if r.inviteReferrer.UserID > 0 { + return r.inviteReferrer, nil + } return r.user, nil } @@ -242,7 +251,8 @@ func (r *fakeModerationRepository) CompleteOnboarding(context.Context, userdomai return r.user, nil } -func (r *fakeModerationRepository) BindInviteReferrer(context.Context, userdomain.BindInviteReferrerCommand) (userdomain.User, error) { +func (r *fakeModerationRepository) BindInviteReferrer(_ context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error) { + r.lastBindInvite = command return r.user, nil } diff --git a/services/user-service/internal/service/user/service.go b/services/user-service/internal/service/user/service.go index 3563935e..065e4712 100644 --- a/services/user-service/internal/service/user/service.go +++ b/services/user-service/internal/service/user/service.go @@ -47,7 +47,7 @@ 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 按 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) @@ -61,7 +61,7 @@ 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 原子绑定当前用户的邀请人,并在事务内复核双方同区域和账号可用性。 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) diff --git a/services/user-service/internal/storage/mysql/invite/repository.go b/services/user-service/internal/storage/mysql/invite/repository.go index b5ea5a78..b382b40d 100644 --- a/services/user-service/internal/storage/mysql/invite/repository.go +++ b/services/user-service/internal/storage/mysql/invite/repository.go @@ -90,6 +90,10 @@ func BindRelation(ctx context.Context, tx *sql.Tx, command invitedomain.BindComm if code.OwnerUserID == command.InvitedUserID || code.OwnerStatus != "active" { return invitedomain.Binding{}, invalidInviteCode() } + if code.OwnerRegionID != command.InvitedRegionID { + // 邀请归因按区域隔离;注册、资料补全和 H5 后补绑定都必须共享同一条写入边界。 + return invitedomain.Binding{}, invalidInviteCode() + } existing, exists, err := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID) if err != nil { @@ -331,10 +335,11 @@ func (r *Repository) ApplyRechargeEvent(ctx context.Context, event invitedomain. } type inviteCodeRow struct { - InviteCodeID int64 - Code string - OwnerUserID int64 - OwnerStatus string + InviteCodeID int64 + Code string + OwnerUserID int64 + OwnerRegionID int64 + OwnerStatus string } type relationRow struct { @@ -381,13 +386,13 @@ func primaryCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, owner func resolveActiveCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, code string) (inviteCodeRow, error) { var row inviteCodeRow err := tx.QueryRowContext(ctx, ` - SELECT c.invite_code_id, c.code, c.owner_user_id, u.status + SELECT c.invite_code_id, c.code, c.owner_user_id, COALESCE(u.region_id, 0), u.status FROM user_invite_codes c INNER JOIN users u ON u.app_code = c.app_code AND u.user_id = c.owner_user_id WHERE c.app_code = ? AND c.code = ? AND c.status = ? LIMIT 1 FOR UPDATE - `, appCode, code, invitedomain.CodeStatusActive).Scan(&row.InviteCodeID, &row.Code, &row.OwnerUserID, &row.OwnerStatus) + `, appCode, code, invitedomain.CodeStatusActive).Scan(&row.InviteCodeID, &row.Code, &row.OwnerUserID, &row.OwnerRegionID, &row.OwnerStatus) if err == sql.ErrNoRows { return inviteCodeRow{}, invalidInviteCode() } diff --git a/services/user-service/internal/storage/mysql/invite/repository_test.go b/services/user-service/internal/storage/mysql/invite/repository_test.go index e9d79b37..4c330be1 100644 --- a/services/user-service/internal/storage/mysql/invite/repository_test.go +++ b/services/user-service/internal/storage/mysql/invite/repository_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "hyapp/pkg/xerr" invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" "hyapp/services/user-service/internal/testutil/mysqltest" @@ -111,6 +112,65 @@ func TestInviteRelationCountersAndRechargeValidity(t *testing.T) { } } +func TestCompleteOnboardingAllowsDifferentCountryInSameRegion(t *testing.T) { + repo := mysqltest.NewRepository(t) + ctx := context.Background() + + createCompletedUserWithRegion(t, repo, 10011, "163011", "AT", 3, 1000) + createUser(t, repo, 10012, "163012", 1001) + inviter, err := repo.GetUser(ctx, 10011) + if err != nil { + t.Fatalf("get inviter failed: %v", err) + } + + invited, err := repo.CompleteOnboarding(ctx, userdomain.CompleteOnboardingCommand{ + AppCode: "lalu", + UserID: 10012, + Username: "same-region", + Avatar: "https://cdn.example/same-region.png", + Gender: "female", + Country: "SG", + RegionID: 3, + InviteCode: inviter.InviteOverview.MyInviteCode, + RequestID: "req-same-region-onboarding", + CompletedAtMs: 2000, + }) + if err != nil { + t.Fatalf("complete onboarding with same-region invite failed: %v", err) + } + if !invited.InviteBinding.Bound || invited.InviteBinding.InviterUserID != 10011 { + t.Fatalf("same-region onboarding invite binding mismatch: %+v", invited.InviteBinding) + } +} + +func TestCompleteOnboardingRejectsDifferentRegionInvite(t *testing.T) { + repo := mysqltest.NewRepository(t) + ctx := context.Background() + + createCompletedUserWithRegion(t, repo, 10021, "163021", "AT", 3, 1000) + createUser(t, repo, 10022, "163022", 1001) + inviter, err := repo.GetUser(ctx, 10021) + if err != nil { + t.Fatalf("get inviter failed: %v", err) + } + + _, err = repo.CompleteOnboarding(ctx, userdomain.CompleteOnboardingCommand{ + AppCode: "lalu", + UserID: 10022, + Username: "different-region", + Avatar: "https://cdn.example/different-region.png", + Gender: "female", + Country: "US", + RegionID: 4, + InviteCode: inviter.InviteOverview.MyInviteCode, + RequestID: "req-different-region-onboarding", + CompletedAtMs: 2000, + }) + if xerr.CodeOf(err) != xerr.InvalidInviteCode { + t.Fatalf("different-region invite must be invalid invite code, err=%v", err) + } +} + func TestInviteRechargeValidityAcceptsAllRechargeSources(t *testing.T) { repo := mysqltest.NewRepository(t) ctx := context.Background() @@ -170,6 +230,35 @@ func TestInviteRechargeValidityAcceptsAllRechargeSources(t *testing.T) { } } +func createCompletedUserWithRegion(t *testing.T, repo *mysqltest.Repository, userID int64, displayUserID string, country string, regionID int64, nowMs int64) { + t.Helper() + if err := repo.CreateUserWithIdentity(context.Background(), userdomain.User{ + AppCode: "lalu", + UserID: userID, + DefaultDisplayUserID: displayUserID, + CurrentDisplayUserID: displayUserID, + CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault, + Username: "completed-user", + Country: country, + RegionID: regionID, + ProfileCompleted: true, + ProfileCompletedAtMs: nowMs, + Status: userdomain.StatusActive, + OnboardingStatus: userdomain.OnboardingStatusCompleted, + CreatedAtMs: nowMs, + UpdatedAtMs: nowMs, + }, userdomain.Identity{ + AppCode: "lalu", + UserID: userID, + DisplayUserID: displayUserID, + DefaultDisplayUserID: displayUserID, + DisplayUserIDKind: userdomain.DisplayUserIDKindDefault, + Status: userdomain.DisplayUserIDStatusActive, + }); err != nil { + t.Fatalf("create completed user %d failed: %v", userID, err) + } +} + func createUser(t *testing.T, repo *mysqltest.Repository, userID int64, displayUserID string, nowMs int64) { t.Helper() if err := repo.CreateUserWithIdentity(context.Background(), userdomain.User{ diff --git a/services/user-service/internal/storage/mysql/user/invite_referrer.go b/services/user-service/internal/storage/mysql/user/invite_referrer.go index 84dbef38..2877cbfb 100644 --- a/services/user-service/internal/storage/mysql/user/invite_referrer.go +++ b/services/user-service/internal/storage/mysql/user/invite_referrer.go @@ -50,7 +50,7 @@ func (r *Repository) FindInviteReferrerByCode(ctx context.Context, appCode strin } // BindInviteReferrer 在资料完成后绑定当前用户的邀请人。 -// 这里必须锁双方 users 行并在同一事务内复核国家,不能只信 H5 先前搜索结果。 +// 这里必须锁双方 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") @@ -189,8 +189,8 @@ func validateLockedInviteReferrerPair(invited userdomain.User, inviter userdomai if !inviter.ProfileCompleted || !inviter.CanLogin() { return xerr.New(xerr.NotFound, "inviter not found") } - if invited.Country == "" || inviter.Country == "" || invited.Country != inviter.Country { - // 绑定入口和搜索入口都必须执行同国家限制,避免直接 POST 邀请码绕过搜索态校验。 + if invited.RegionID != inviter.RegionID { + // 邀请绑定按区域隔离;跨区域统一隐藏邀请人存在性,避免直接 POST 邀请码绕过搜索态校验。 return xerr.New(xerr.NotFound, "inviter not found") } return nil diff --git a/services/user-service/internal/storage/mysql/user/invite_referrer_test.go b/services/user-service/internal/storage/mysql/user/invite_referrer_test.go index 58322797..80c91dc3 100644 --- a/services/user-service/internal/storage/mysql/user/invite_referrer_test.go +++ b/services/user-service/internal/storage/mysql/user/invite_referrer_test.go @@ -30,7 +30,46 @@ func TestFindInviteReferrerByCodeDoesNotAmbiguouslySelectUserProjection(t *testi } } +func TestBindInviteReferrerAllowsDifferentCountryInSameRegion(t *testing.T) { + repo := mysqltest.NewRepository(t) + ctx := context.Background() + + createInviteReferrerUserInRegion(t, repo, 12011, "166011", "AT", 3, 1000) + createInviteReferrerUserInRegion(t, repo, 12012, "166012", "SG", 3, 1001) + inviter, err := repo.GetUser(ctx, 12011) + if err != nil { + t.Fatalf("get inviter failed: %v", err) + } + + updated, err := repo.BindInviteReferrer(ctx, userdomain.BindInviteReferrerCommand{ + AppCode: "lalu", + InvitedUserID: 12012, + InviteCode: inviter.InviteOverview.MyInviteCode, + RequestID: "req-same-region-bind", + BoundAtMs: 2000, + }) + if err != nil { + t.Fatalf("bind invite referrer in same region failed: %v", err) + } + if !updated.InviteBinding.Bound || updated.InviteBinding.InviterUserID != 12011 { + t.Fatalf("same-region invite binding mismatch: %+v", updated.InviteBinding) + } + + attribution, err := repo.GetInviteAttribution(ctx, 12012) + if err != nil { + t.Fatalf("get invite attribution failed: %v", err) + } + if !attribution.Found || attribution.InviterUserID != 12011 || attribution.InviteCode != inviter.InviteOverview.MyInviteCode { + t.Fatalf("same-region attribution mismatch: %+v", attribution) + } +} + func createInviteReferrerUser(t *testing.T, repo *mysqltest.Repository, userID int64, displayUserID string, nowMs int64) { + t.Helper() + createInviteReferrerUserInRegion(t, repo, userID, displayUserID, "SG", 3, nowMs) +} + +func createInviteReferrerUserInRegion(t *testing.T, repo *mysqltest.Repository, userID int64, displayUserID string, country string, regionID int64, nowMs int64) { t.Helper() err := repo.CreateUserWithIdentity(context.Background(), userdomain.User{ AppCode: "lalu", @@ -40,7 +79,8 @@ func createInviteReferrerUser(t *testing.T, repo *mysqltest.Repository, userID i CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault, Username: "invite-referrer", Gender: "male", - Country: "SG", + Country: country, + RegionID: regionID, ProfileCompleted: true, ProfileCompletedAtMs: nowMs, Status: userdomain.StatusActive,