fix: use region for invite code binding
This commit is contained in:
parent
bea31229ce
commit
4b02562864
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
- 每个用户有一个可展示、可复制的永久邀请码。
|
- 每个用户有一个可展示、可复制的永久邀请码。
|
||||||
- 新用户注册或完成资料时可以选填邀请码。
|
- 新用户注册或完成资料时可以选填邀请码。
|
||||||
- 非空邀请码必须能解析到同一 `app_code` 下的有效邀请人。
|
- 非空邀请码必须能解析到同一 `app_code`、同一 `region_id` 下的有效邀请人。
|
||||||
- 邀请关系一旦绑定不可被客户端修改。
|
- 邀请关系一旦绑定不可被客户端修改。
|
||||||
- 邀请关系必须落 MySQL;Redis 只能做短 TTL 查询缓存。
|
- 邀请关系必须落 MySQL;Redis 只能做短 TTL 查询缓存。
|
||||||
- 邀请统计必须拆成 `invite_count` 和 `valid_invite_count`:前者表示成功填码绑定关系的人数,后者表示被邀请人累计充值达到后台配置门槛的人数。
|
- 邀请统计必须拆成 `invite_count` 和 `valid_invite_count`:前者表示成功填码绑定关系的人数,后者表示被邀请人累计充值达到后台配置门槛的人数。
|
||||||
@ -39,7 +39,7 @@
|
|||||||
### Code Ownership
|
### Code Ownership
|
||||||
|
|
||||||
- 邀请码属于用户,不属于设备、session、三方账号或 Agency。
|
- 邀请码属于用户,不属于设备、session、三方账号或 Agency。
|
||||||
- 邀请码按 `app_code` 隔离;`lalu` 的邀请码不能邀请 `popoo` 用户。
|
- 邀请码按 `app_code + region_id` 隔离;`lalu` 的邀请码不能邀请 `popoo` 用户,不同区域用户也不能互相绑定邀请关系。
|
||||||
- 用户被禁用或封禁后,默认不允许继续作为新邀请人的有效 code owner;历史邀请关系不删除。
|
- 用户被禁用或封禁后,默认不允许继续作为新邀请人的有效 code owner;历史邀请关系不删除。
|
||||||
- 用户自己的邀请码创建后长期稳定。后续如果允许换码,必须保留旧码和历史关系快照。
|
- 用户自己的邀请码创建后长期稳定。后续如果允许换码,必须保留旧码和历史关系快照。
|
||||||
|
|
||||||
@ -49,7 +49,7 @@
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 注册不填邀请码 | 创建用户,不创建邀请关系 |
|
| 注册不填邀请码 | 创建用户,不创建邀请关系 |
|
||||||
| 注册填写有效邀请码 | 创建用户,并绑定 `inviter_user_id` |
|
| 注册填写有效邀请码 | 创建用户,并绑定 `inviter_user_id` |
|
||||||
| 注册填写不存在、停用、跨 App 或 owner 非 active 的邀请码 | 返回 `INVALID_INVITE_CODE`,不静默忽略 |
|
| 注册填写不存在、停用、跨 App、跨区域或 owner 非 active 的邀请码 | 返回 `INVALID_INVITE_CODE`,不静默忽略 |
|
||||||
| 用户填写自己的邀请码 | 返回 `INVALID_INVITE_CODE` |
|
| 用户填写自己的邀请码 | 返回 `INVALID_INVITE_CODE` |
|
||||||
| 已有邀请关系的用户再次提交邀请码 | 不修改原关系 |
|
| 已有邀请关系的用户再次提交邀请码 | 不修改原关系 |
|
||||||
| 已完成注册用户提交邀请码 | 不修改原关系;如果通过 onboarding 入口提交,按已完成资料逻辑不处理邀请码 |
|
| 已完成注册用户提交邀请码 | 不修改原关系;如果通过 onboarding 入口提交,按已完成资料逻辑不处理邀请码 |
|
||||||
@ -359,7 +359,7 @@ CREATE TABLE user_invite_counters (
|
|||||||
3. 创建 `users`。
|
3. 创建 `users`。
|
||||||
4. 创建用户自己的 `user_invite_codes`。
|
4. 创建用户自己的 `user_invite_codes`。
|
||||||
5. 如果请求带 `invite_code`,解析并锁定 code owner 的 `users` 行。
|
5. 如果请求带 `invite_code`,解析并锁定 code owner 的 `users` 行。
|
||||||
6. 校验 owner active、非自己、同 `app_code`。
|
6. 校验 owner active、非自己、同 `app_code`、同 `region_id`。
|
||||||
7. 插入 `user_invite_relations`。
|
7. 插入 `user_invite_relations`。
|
||||||
8. 创建 `third_party_identities` 和 `auth_sessions`。
|
8. 创建 `third_party_identities` 和 `auth_sessions`。
|
||||||
9. 写 `UserRegistered` / `UserInvited` outbox 事件。
|
9. 写 `UserRegistered` / `UserInvited` outbox 事件。
|
||||||
@ -521,6 +521,7 @@ Redis miss 或异常时必须回 MySQL 或 fail-closed,不能把不存在当
|
|||||||
| 新用户填有效邀请码 | 注册成功,关系表写入 inviter |
|
| 新用户填有效邀请码 | 注册成功,关系表写入 inviter |
|
||||||
| 新用户填不存在邀请码 | 返回 `INVALID_INVITE_CODE`,不创建完成关系 |
|
| 新用户填不存在邀请码 | 返回 `INVALID_INVITE_CODE`,不创建完成关系 |
|
||||||
| 邀请码跨 App | 返回 `INVALID_INVITE_CODE` |
|
| 邀请码跨 App | 返回 `INVALID_INVITE_CODE` |
|
||||||
|
| 邀请码跨区域 | 返回 `INVALID_INVITE_CODE` |
|
||||||
| 邀请人 disabled/banned | 返回 `INVALID_INVITE_CODE` |
|
| 邀请人 disabled/banned | 返回 `INVALID_INVITE_CODE` |
|
||||||
| 用户尝试填自己的邀请码 | 返回 `INVALID_INVITE_CODE` |
|
| 用户尝试填自己的邀请码 | 返回 `INVALID_INVITE_CODE` |
|
||||||
| 已绑定关系后再次提交邀请码 | 原关系不变 |
|
| 已绑定关系后再次提交邀请码 | 原关系不变 |
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// SearchInviteReferrer 通过邀请码搜索可绑定邀请人。
|
// SearchInviteReferrer 通过邀请码搜索可绑定邀请人。
|
||||||
// 搜索结果只允许同 App、同国家、已完成资料且 active 的普通用户,避免 H5 枚举跨国家用户资料。
|
// 邀请归因按 app 和区域隔离;国家只是用户资料快照,不能把同区域的跨国家邀请误判为不存在。
|
||||||
func (s *Service) SearchInviteReferrer(ctx context.Context, userID int64, inviteCode string) (userdomain.User, error) {
|
func (s *Service) SearchInviteReferrer(ctx context.Context, userID int64, inviteCode string) (userdomain.User, error) {
|
||||||
inviteCode = invitedomain.NormalizeCode(inviteCode)
|
inviteCode = invitedomain.NormalizeCode(inviteCode)
|
||||||
if userID <= 0 {
|
if userID <= 0 {
|
||||||
@ -30,12 +30,9 @@ func (s *Service) SearchInviteReferrer(ctx context.Context, userID int64, invite
|
|||||||
return userdomain.User{}, err
|
return userdomain.User{}, err
|
||||||
}
|
}
|
||||||
if !current.ProfileCompleted {
|
if !current.ProfileCompleted {
|
||||||
// 当前账号未完成资料时没有稳定国家边界,不能用这个入口搜索邀请人。
|
// H5 绑定入口只服务已完成资料用户;注册资料页绑定走 CompleteOnboarding 的原子写链路。
|
||||||
return userdomain.User{}, xerr.New(xerr.ProfileRequired, "profile required")
|
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)
|
inviter, err := s.userRepository.FindInviteReferrerByCode(ctx, appcode.FromContext(ctx), inviteCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -82,8 +79,8 @@ func validateInviteReferrerPair(invited userdomain.User, inviter userdomain.User
|
|||||||
if !inviter.ProfileCompleted || !inviter.CanLogin() {
|
if !inviter.ProfileCompleted || !inviter.CanLogin() {
|
||||||
return xerr.New(xerr.NotFound, "inviter not found")
|
return xerr.New(xerr.NotFound, "inviter not found")
|
||||||
}
|
}
|
||||||
if invited.Country == "" || inviter.Country == "" || invited.Country != inviter.Country {
|
if invited.RegionID != inviter.RegionID {
|
||||||
// 产品要求只能搜索和绑定本国家邀请人;跨国家统一返回 not found,避免泄漏目标用户是否存在。
|
// 产品边界是同区域邀请;跨区域统一按未找到处理,避免 H5 用搜索接口枚举其他区域用户。
|
||||||
return xerr.New(xerr.NotFound, "inviter not found")
|
return xerr.New(xerr.NotFound, "inviter not found")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -104,11 +104,15 @@ func TestSetUserStatusSurfacesAccessDenylistError(t *testing.T) {
|
|||||||
|
|
||||||
type fakeModerationRepository struct {
|
type fakeModerationRepository struct {
|
||||||
user userdomain.User
|
user userdomain.User
|
||||||
|
inviteReferrer userdomain.User
|
||||||
sessionIDs []string
|
sessionIDs []string
|
||||||
countrySessionIDs []string
|
countrySessionIDs []string
|
||||||
lastCommand UserStatusCommand
|
lastCommand UserStatusCommand
|
||||||
lastProfileCommand userdomain.ProfileUpdateCommand
|
lastProfileCommand userdomain.ProfileUpdateCommand
|
||||||
lastCountryCommand userdomain.CountryChangeCommand
|
lastCountryCommand userdomain.CountryChangeCommand
|
||||||
|
lastFindAppCode string
|
||||||
|
lastFindInviteCode string
|
||||||
|
lastBindInvite userdomain.BindInviteReferrerCommand
|
||||||
countryChangeCalls int
|
countryChangeCalls int
|
||||||
lastReport userdomain.Report
|
lastReport userdomain.Report
|
||||||
}
|
}
|
||||||
@ -211,7 +215,12 @@ func (r *fakeModerationRepository) BatchGetUsers(context.Context, []int64) (map[
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeModerationRepository) FindInviteReferrerByCode(context.Context, string, string) (userdomain.User, error) {
|
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
|
return r.user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -242,7 +251,8 @@ func (r *fakeModerationRepository) CompleteOnboarding(context.Context, userdomai
|
|||||||
return r.user, nil
|
return r.user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeModerationRepository) BindInviteReferrer(context.Context, userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
|
func (r *fakeModerationRepository) BindInviteReferrer(_ context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
|
||||||
|
r.lastBindInvite = command
|
||||||
return r.user, nil
|
return r.user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -47,7 +47,7 @@ type UserRepository interface {
|
|||||||
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
|
BatchGetUsers(ctx context.Context, userIDs []int64) (map[int64]userdomain.User, error)
|
||||||
// GetInviteAttribution 按被邀请用户读取邀请归因快照;无关系时返回 Found=false。
|
// GetInviteAttribution 按被邀请用户读取邀请归因快照;无关系时返回 Found=false。
|
||||||
GetInviteAttribution(ctx context.Context, invitedUserID int64) (invitedomain.Attribution, error)
|
GetInviteAttribution(ctx context.Context, invitedUserID int64) (invitedomain.Attribution, error)
|
||||||
// FindInviteReferrerByCode 按 active 邀请码读取归属用户,供 H5 绑定页搜索同国家邀请人。
|
// FindInviteReferrerByCode 按 active 邀请码读取归属用户,供 H5 绑定页搜索同区域邀请人。
|
||||||
FindInviteReferrerByCode(ctx context.Context, appCode string, inviteCode string) (userdomain.User, error)
|
FindInviteReferrerByCode(ctx context.Context, appCode string, inviteCode string) (userdomain.User, error)
|
||||||
// ListUserIDs 按稳定 user_id 游标读取低频后台任务目标用户。
|
// ListUserIDs 按稳定 user_id 游标读取低频后台任务目标用户。
|
||||||
ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error)
|
ListUserIDs(ctx context.Context, filter userdomain.UserIDPageFilter) ([]int64, error)
|
||||||
@ -61,7 +61,7 @@ type UserRepository interface {
|
|||||||
UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error)
|
UpdateUserContactInfo(ctx context.Context, command userdomain.ProfileContactUpdateCommand) (userdomain.User, error)
|
||||||
// CompleteOnboarding 原子写注册页必填资料并标记 profile_completed。
|
// CompleteOnboarding 原子写注册页必填资料并标记 profile_completed。
|
||||||
CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error)
|
CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error)
|
||||||
// BindInviteReferrer 原子绑定当前用户的邀请人,并在事务内复核双方国家一致。
|
// BindInviteReferrer 原子绑定当前用户的邀请人,并在事务内复核双方同区域和账号可用性。
|
||||||
BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error)
|
BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error)
|
||||||
// ChangeUserCountry 原子修改用户国家、写变更日志,并返回需要立即失效的 auth session。
|
// ChangeUserCountry 原子修改用户国家、写变更日志,并返回需要立即失效的 auth session。
|
||||||
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error)
|
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, []string, error)
|
||||||
|
|||||||
@ -90,6 +90,10 @@ func BindRelation(ctx context.Context, tx *sql.Tx, command invitedomain.BindComm
|
|||||||
if code.OwnerUserID == command.InvitedUserID || code.OwnerStatus != "active" {
|
if code.OwnerUserID == command.InvitedUserID || code.OwnerStatus != "active" {
|
||||||
return invitedomain.Binding{}, invalidInviteCode()
|
return invitedomain.Binding{}, invalidInviteCode()
|
||||||
}
|
}
|
||||||
|
if code.OwnerRegionID != command.InvitedRegionID {
|
||||||
|
// 邀请归因按区域隔离;注册、资料补全和 H5 后补绑定都必须共享同一条写入边界。
|
||||||
|
return invitedomain.Binding{}, invalidInviteCode()
|
||||||
|
}
|
||||||
|
|
||||||
existing, exists, err := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID)
|
existing, exists, err := relationForInvitedForUpdate(ctx, tx, command.AppCode, command.InvitedUserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -331,10 +335,11 @@ func (r *Repository) ApplyRechargeEvent(ctx context.Context, event invitedomain.
|
|||||||
}
|
}
|
||||||
|
|
||||||
type inviteCodeRow struct {
|
type inviteCodeRow struct {
|
||||||
InviteCodeID int64
|
InviteCodeID int64
|
||||||
Code string
|
Code string
|
||||||
OwnerUserID int64
|
OwnerUserID int64
|
||||||
OwnerStatus string
|
OwnerRegionID int64
|
||||||
|
OwnerStatus string
|
||||||
}
|
}
|
||||||
|
|
||||||
type relationRow struct {
|
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) {
|
func resolveActiveCodeForUpdate(ctx context.Context, tx *sql.Tx, appCode string, code string) (inviteCodeRow, error) {
|
||||||
var row inviteCodeRow
|
var row inviteCodeRow
|
||||||
err := tx.QueryRowContext(ctx, `
|
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
|
FROM user_invite_codes c
|
||||||
INNER JOIN users u ON u.app_code = c.app_code AND u.user_id = c.owner_user_id
|
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 = ?
|
WHERE c.app_code = ? AND c.code = ? AND c.status = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return inviteCodeRow{}, invalidInviteCode()
|
return inviteCodeRow{}, invalidInviteCode()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"hyapp/pkg/xerr"
|
||||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||||
"hyapp/services/user-service/internal/testutil/mysqltest"
|
"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) {
|
func TestInviteRechargeValidityAcceptsAllRechargeSources(t *testing.T) {
|
||||||
repo := mysqltest.NewRepository(t)
|
repo := mysqltest.NewRepository(t)
|
||||||
ctx := context.Background()
|
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) {
|
func createUser(t *testing.T, repo *mysqltest.Repository, userID int64, displayUserID string, nowMs int64) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := repo.CreateUserWithIdentity(context.Background(), userdomain.User{
|
if err := repo.CreateUserWithIdentity(context.Background(), userdomain.User{
|
||||||
|
|||||||
@ -50,7 +50,7 @@ func (r *Repository) FindInviteReferrerByCode(ctx context.Context, appCode strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BindInviteReferrer 在资料完成后绑定当前用户的邀请人。
|
// BindInviteReferrer 在资料完成后绑定当前用户的邀请人。
|
||||||
// 这里必须锁双方 users 行并在同一事务内复核国家,不能只信 H5 先前搜索结果。
|
// 这里必须锁双方 users 行并在同一事务内复核区域和账号可用性,不能只信 H5 先前搜索结果。
|
||||||
func (r *Repository) BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
|
func (r *Repository) BindInviteReferrer(ctx context.Context, command userdomain.BindInviteReferrerCommand) (userdomain.User, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return userdomain.User{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
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() {
|
if !inviter.ProfileCompleted || !inviter.CanLogin() {
|
||||||
return xerr.New(xerr.NotFound, "inviter not found")
|
return xerr.New(xerr.NotFound, "inviter not found")
|
||||||
}
|
}
|
||||||
if invited.Country == "" || inviter.Country == "" || invited.Country != inviter.Country {
|
if invited.RegionID != inviter.RegionID {
|
||||||
// 绑定入口和搜索入口都必须执行同国家限制,避免直接 POST 邀请码绕过搜索态校验。
|
// 邀请绑定按区域隔离;跨区域统一隐藏邀请人存在性,避免直接 POST 邀请码绕过搜索态校验。
|
||||||
return xerr.New(xerr.NotFound, "inviter not found")
|
return xerr.New(xerr.NotFound, "inviter not found")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -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) {
|
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()
|
t.Helper()
|
||||||
err := repo.CreateUserWithIdentity(context.Background(), userdomain.User{
|
err := repo.CreateUserWithIdentity(context.Background(), userdomain.User{
|
||||||
AppCode: "lalu",
|
AppCode: "lalu",
|
||||||
@ -40,7 +79,8 @@ func createInviteReferrerUser(t *testing.T, repo *mysqltest.Repository, userID i
|
|||||||
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||||
Username: "invite-referrer",
|
Username: "invite-referrer",
|
||||||
Gender: "male",
|
Gender: "male",
|
||||||
Country: "SG",
|
Country: country,
|
||||||
|
RegionID: regionID,
|
||||||
ProfileCompleted: true,
|
ProfileCompleted: true,
|
||||||
ProfileCompletedAtMs: nowMs,
|
ProfileCompletedAtMs: nowMs,
|
||||||
Status: userdomain.StatusActive,
|
Status: userdomain.StatusActive,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user