230 lines
8.0 KiB
Go

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
}