fix: use region for invite code binding

This commit is contained in:
zhx 2026-07-01 14:22:15 +08:00
parent bea31229ce
commit 4b02562864
9 changed files with 237 additions and 25 deletions

View File

@ -6,7 +6,7 @@
- 每个用户有一个可展示、可复制的永久邀请码。
- 新用户注册或完成资料时可以选填邀请码。
- 非空邀请码必须能解析到同一 `app_code` 下的有效邀请人。
- 非空邀请码必须能解析到同一 `app_code`、同一 `region_id` 下的有效邀请人。
- 邀请关系一旦绑定不可被客户端修改。
- 邀请关系必须落 MySQLRedis 只能做短 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` |
| 已绑定关系后再次提交邀请码 | 原关系不变 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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