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, _, _ = thirtyDayPeriodInfoAt(bindTime, normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime) 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 }