230 lines
8.0 KiB
Go
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
|
|
}
|