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 WHERE users.app_code = ? AND EXISTS ( SELECT 1 FROM user_invite_codes codes WHERE codes.app_code = users.app_code AND codes.owner_user_id = users.user_id AND codes.code = ? AND codes.status = ? LIMIT 1 ) 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.RegionID != inviter.RegionID { // 邀请绑定按区域隔离;跨区域统一隐藏邀请人存在性,避免直接 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 }