387 lines
13 KiB
Go
387 lines
13 KiB
Go
package invite
|
|
|
|
import (
|
|
"chatapp3-golang/internal/model"
|
|
"chatapp3-golang/internal/utils"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
// BindCode 处理邀请码绑定,并写入邀请关系、风控状态和双方即时奖励。
|
|
func (s *InviteService) BindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest) (*BindCodeResponse, error) {
|
|
return s.bindCode(ctx, user, clientIP, req, "")
|
|
}
|
|
|
|
// BindRegisteredCode 处理 Java 注册链路传入的邀请码,复用 H5 绑码口径。
|
|
func (s *InviteService) BindRegisteredCode(ctx context.Context, req RegisterBindCodeRequest) (*BindCodeResponse, error) {
|
|
sysOrigin := normalizeSysOrigin(req.SysOrigin)
|
|
if sysOrigin == "" {
|
|
return nil, NewAppError(400, "invalid_sys_origin", "sysOrigin is required")
|
|
}
|
|
if req.InviteeUserID <= 0 {
|
|
return nil, NewAppError(400, "invalid_invitee_user_id", "inviteeUserId is required")
|
|
}
|
|
return s.bindCode(ctx, AuthUser{
|
|
UserID: req.InviteeUserID,
|
|
SysOrigin: sysOrigin,
|
|
}, req.ClientIP, BindCodeRequest{InviteCode: req.NormalizedInviteCode()}, req.DeviceFingerprint)
|
|
}
|
|
|
|
// NormalizedInviteCode 返回兼容 camelCase、snake_case 和 Java invitationCode 的邀请码字段。
|
|
func (req BindCodeRequest) NormalizedInviteCode() string {
|
|
for _, value := range []string{req.InviteCode, req.InviteCodeSnake, req.InvitationCode, req.InvitationCodeSnake} {
|
|
if normalized := strings.TrimSpace(value); normalized != "" {
|
|
return normalized
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// NormalizedInviteCode 返回注册链路里兼容多种命名的邀请码字段。
|
|
func (req RegisterBindCodeRequest) NormalizedInviteCode() string {
|
|
for _, value := range []string{req.InviteCode, req.InviteCodeSnake, req.InvitationCode, req.InvitationCodeSnake} {
|
|
if normalized := strings.TrimSpace(value); normalized != "" {
|
|
return normalized
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (s *InviteService) bindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest, deviceFingerprint string) (*BindCodeResponse, error) {
|
|
inviteCode := req.NormalizedInviteCode()
|
|
if inviteCode == "" {
|
|
return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required")
|
|
}
|
|
bundle, err := s.loadBundle(ctx, user.SysOrigin)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !bundle.Config.Enabled {
|
|
return nil, NewAppError(400, "campaign_disabled", "invite campaign is disabled")
|
|
}
|
|
|
|
if existing, existingErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); existingErr == nil {
|
|
if existing.InviteCode == inviteCode {
|
|
return s.buildAlreadyBoundResponse(bundle, existing, inviteCode), nil
|
|
}
|
|
return nil, NewAppError(400, "already_bound", "invite code already bound")
|
|
} else if !errors.Is(existingErr, gorm.ErrRecordNotFound) {
|
|
return nil, existingErr
|
|
}
|
|
|
|
mapping, err := s.findInviteCodeMappingByCode(ctx, inviteCode)
|
|
if err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, NewAppError(404, "invite_code_not_found", "invite code not found")
|
|
}
|
|
return nil, err
|
|
}
|
|
if mapping.UserID == user.UserID {
|
|
return nil, NewAppError(400, "self_bind_not_allowed", "cannot bind your own invite code")
|
|
}
|
|
|
|
inviterProfile, err := s.findUserBaseInfo(ctx, mapping.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if inviterProfile.OriginSys != "" && inviterProfile.OriginSys != user.SysOrigin {
|
|
return nil, NewAppError(400, "sys_origin_mismatch", "invite code does not belong to the current system")
|
|
}
|
|
|
|
fingerprint := strings.TrimSpace(deviceFingerprint)
|
|
if fingerprint == "" {
|
|
if value, fpErr := s.java.GetDeviceFingerprint(ctx, user.UserID); fpErr == nil {
|
|
fingerprint = strings.TrimSpace(value)
|
|
}
|
|
}
|
|
// 绑码链路同时锁住用户以及可选的 IP/设备维度,避免并发穿透风控。
|
|
lockKeys := []string{
|
|
fmt.Sprintf("invite:bind:lock:user:%s:%d", user.SysOrigin, user.UserID),
|
|
}
|
|
if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" {
|
|
lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:ip:%s:%s", user.SysOrigin, strings.TrimSpace(clientIP)))
|
|
}
|
|
if bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" {
|
|
lockKeys = append(lockKeys, fmt.Sprintf("invite:bind:lock:device:%s:%s", user.SysOrigin, fingerprint))
|
|
}
|
|
|
|
var (
|
|
relationID int64
|
|
eligibleStatus = relationStatusEligible
|
|
blockReason string
|
|
periodKey string
|
|
alreadyBound bool
|
|
)
|
|
err = s.withBindLocks(ctx, lockKeys, func() error {
|
|
if existing, lockErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); lockErr == nil {
|
|
if existing.InviteCode == inviteCode {
|
|
relationID = existing.ID
|
|
eligibleStatus = existing.EligibleStatus
|
|
blockReason = existing.BlockReason
|
|
alreadyBound = true
|
|
return nil
|
|
}
|
|
return NewAppError(400, "already_bound", "invite code already bound")
|
|
} else if !errors.Is(lockErr, gorm.ErrRecordNotFound) {
|
|
return lockErr
|
|
}
|
|
|
|
// 在锁内判定风控资格并写入关系、进度两张表。
|
|
bindTime := time.Now()
|
|
periodKey, _, _, _ = calendarMonthPeriodInfoAt(bindTime, normalizeTimezone(bundle.Config.Timezone))
|
|
if bundle.Config.AntiCheatSameIPOnce && strings.TrimSpace(clientIP) != "" {
|
|
exists, existsErr := s.existsEligibleRelationByIP(ctx, user.SysOrigin, clientIP)
|
|
if existsErr != nil {
|
|
return existsErr
|
|
}
|
|
if exists {
|
|
eligibleStatus = relationStatusBlocked
|
|
blockReason = blockReasonDuplicateIP
|
|
}
|
|
}
|
|
if eligibleStatus == relationStatusEligible && bundle.Config.AntiCheatSameDeviceOnce && fingerprint != "" {
|
|
exists, existsErr := s.existsEligibleRelationByDevice(ctx, user.SysOrigin, fingerprint)
|
|
if existsErr != nil {
|
|
return existsErr
|
|
}
|
|
if exists {
|
|
eligibleStatus = relationStatusBlocked
|
|
blockReason = blockReasonDuplicateDevice
|
|
}
|
|
}
|
|
|
|
nextRelationID, idErr := utils.NextID()
|
|
if idErr != nil {
|
|
return idErr
|
|
}
|
|
progressID, idErr := utils.NextID()
|
|
if idErr != nil {
|
|
return idErr
|
|
}
|
|
relationID = nextRelationID
|
|
relation := model.InviteCampaignRelation{
|
|
ID: relationID,
|
|
SysOrigin: user.SysOrigin,
|
|
InviterUserID: mapping.UserID,
|
|
InviteeUserID: user.UserID,
|
|
InviteCode: inviteCode,
|
|
BindIP: strings.TrimSpace(clientIP),
|
|
DeviceFingerprint: fingerprint,
|
|
EligibleStatus: eligibleStatus,
|
|
BlockReason: blockReason,
|
|
BindTime: bindTime,
|
|
CreateTime: bindTime,
|
|
UpdateTime: bindTime,
|
|
}
|
|
progress := model.InviteCampaignInviteeProgress{
|
|
ID: progressID,
|
|
RelationID: relationID,
|
|
SysOrigin: user.SysOrigin,
|
|
InviterUserID: mapping.UserID,
|
|
InviteeUserID: user.UserID,
|
|
TotalRecharge: 0,
|
|
ValidUser: false,
|
|
RewardedRuleIDs: "",
|
|
CreateTime: bindTime,
|
|
UpdateTime: bindTime,
|
|
}
|
|
|
|
return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if err := tx.Create(&relation).Error; err != nil {
|
|
if isInviteRelationDuplicateErr(err) {
|
|
return NewAppError(400, "already_bound", "invite code already bound")
|
|
}
|
|
return err
|
|
}
|
|
if err := tx.Create(&progress).Error; err != nil {
|
|
return err
|
|
}
|
|
if eligibleStatus == relationStatusEligible {
|
|
if err := s.incrementMonthlyProgressTx(tx, user.SysOrigin, mapping.UserID, periodKey, func(item *model.InviteCampaignMonthlyProgress) {
|
|
item.InviteCount += 1
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind)
|
|
inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind)
|
|
// 只有资格正常的绑定关系才会触发双方即时奖励。
|
|
if eligibleStatus == relationStatusEligible && !alreadyBound {
|
|
inviterUserID := mapping.UserID
|
|
inviteeUserID := user.UserID
|
|
_, err = s.dispatchReward(ctx, rewardDispatchInput{
|
|
SysOrigin: user.SysOrigin,
|
|
MonthKey: periodKey,
|
|
RelationID: refInt64(relationID),
|
|
InviterUserID: &inviterUserID,
|
|
InviteeUserID: &inviteeUserID,
|
|
TargetUserID: mapping.UserID,
|
|
Reward: rewardFromRule(inviterBindRule),
|
|
Scene: ruleTypeInviterBind,
|
|
RuleID: optionalRuleID(inviterBindRule),
|
|
BusinessKey: fmt.Sprintf("bind:inviter:%d", relationID),
|
|
GoldOrigin: "INVITED_NEW_USER_REWARD",
|
|
PropsOrigin: "INVITE_USER_REWARDS",
|
|
Remark: "resident invite inviter bind reward",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = s.dispatchReward(ctx, rewardDispatchInput{
|
|
SysOrigin: user.SysOrigin,
|
|
RelationID: refInt64(relationID),
|
|
InviterUserID: &inviterUserID,
|
|
InviteeUserID: &inviteeUserID,
|
|
TargetUserID: user.UserID,
|
|
Reward: rewardFromRule(inviteeBindRule),
|
|
Scene: ruleTypeInviteeBind,
|
|
RuleID: optionalRuleID(inviteeBindRule),
|
|
BusinessKey: fmt.Sprintf("bind:invitee:%d", relationID),
|
|
GoldOrigin: "INVITE_USER_REWARDS",
|
|
PropsOrigin: "INVITE_USER_REWARDS",
|
|
Remark: "resident invite invitee bind reward",
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return &BindCodeResponse{
|
|
RelationID: relationID,
|
|
Eligible: eligibleStatus == relationStatusEligible,
|
|
AlreadyBound: alreadyBound,
|
|
BlockReason: blockReason,
|
|
InviterUserID: mapping.UserID,
|
|
InviteCode: inviteCode,
|
|
InviterReward: rewardFromRule(inviterBindRule),
|
|
InviteeReward: rewardFromRule(inviteeBindRule),
|
|
}, nil
|
|
}
|
|
|
|
func (s *InviteService) buildAlreadyBoundResponse(bundle *campaignBundle, relation *model.InviteCampaignRelation, inviteCode string) *BindCodeResponse {
|
|
return &BindCodeResponse{
|
|
RelationID: relation.ID,
|
|
Eligible: relation.EligibleStatus == relationStatusEligible,
|
|
AlreadyBound: true,
|
|
BlockReason: relation.BlockReason,
|
|
InviterUserID: relation.InviterUserID,
|
|
InviteCode: inviteCode,
|
|
InviterReward: rewardFromRule(firstRule(bundle.Rules, ruleTypeInviterBind)),
|
|
InviteeReward: rewardFromRule(firstRule(bundle.Rules, ruleTypeInviteeBind)),
|
|
}
|
|
}
|
|
|
|
// withBindLocks 为绑码流程批量加锁,确保同用户/同邀请码并发安全。
|
|
func (s *InviteService) withBindLocks(ctx context.Context, keys []string, fn func() error) error {
|
|
keys = uniqueSortedStrings(keys)
|
|
released := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
acquired, err := s.acquireBindLock(ctx, key, 3*time.Second)
|
|
if err != nil {
|
|
s.releaseBindLocks(ctx, released)
|
|
return err
|
|
}
|
|
if !acquired {
|
|
s.releaseBindLocks(ctx, released)
|
|
return NewAppError(409, "bind_in_progress", "invite bind is in progress")
|
|
}
|
|
released = append(released, key)
|
|
}
|
|
defer s.releaseBindLocks(ctx, released)
|
|
return fn()
|
|
}
|
|
|
|
// acquireBindLock 获取单个绑码锁。
|
|
func (s *InviteService) acquireBindLock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for {
|
|
acquired, err := s.repo.Redis.SetNX(ctx, key, "1", ttl).Result()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if acquired {
|
|
return true, nil
|
|
}
|
|
if time.Now().After(deadline) {
|
|
return false, nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return false, ctx.Err()
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|
|
|
|
// releaseBindLocks 释放绑码流程持有的锁。
|
|
func (s *InviteService) releaseBindLocks(ctx context.Context, keys []string) {
|
|
if len(keys) == 0 {
|
|
return
|
|
}
|
|
_ = s.repo.Redis.Del(ctx, keys...).Err()
|
|
}
|
|
|
|
// uniqueSortedStrings 去重并排序字符串切片。
|
|
func uniqueSortedStrings(values []string) []string {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
seen := make(map[string]struct{}, len(values))
|
|
result := make([]string, 0, len(values))
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[value]; ok {
|
|
continue
|
|
}
|
|
seen[value] = struct{}{}
|
|
result = append(result, value)
|
|
}
|
|
sort.Strings(result)
|
|
return result
|
|
}
|
|
|
|
// isInviteRelationDuplicateErr 判断是否是邀请关系唯一键冲突。
|
|
func isInviteRelationDuplicateErr(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
|
return strings.Contains(message, "duplicate entry") && strings.Contains(message, "invite_campaign_relation")
|
|
}
|
|
|
|
// existsEligibleRelationByIP 判断同 IP 是否已经存在有效邀请关系。
|
|
func (s *InviteService) existsEligibleRelationByIP(ctx context.Context, sysOrigin, bindIP string) (bool, error) {
|
|
var count int64
|
|
err := s.repo.DB.WithContext(ctx).
|
|
Model(&model.InviteCampaignRelation{}).
|
|
Where("sys_origin = ? AND bind_ip = ? AND eligible_status = ?", sysOrigin, bindIP, relationStatusEligible).
|
|
Count(&count).Error
|
|
return count > 0, err
|
|
}
|
|
|
|
// existsEligibleRelationByDevice 判断同设备是否已经存在有效邀请关系。
|
|
func (s *InviteService) existsEligibleRelationByDevice(ctx context.Context, sysOrigin, fingerprint string) (bool, error) {
|
|
var count int64
|
|
err := s.repo.DB.WithContext(ctx).
|
|
Model(&model.InviteCampaignRelation{}).
|
|
Where("sys_origin = ? AND device_fingerprint = ? AND eligible_status = ?", sysOrigin, fingerprint, relationStatusEligible).
|
|
Count(&count).Error
|
|
return count > 0, err
|
|
}
|