2026-04-27 21:10:27 +08:00

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
}