package service import ( "chatapp3-golang/internal/config" "chatapp3-golang/internal/integration" "chatapp3-golang/internal/model" "chatapp3-golang/internal/repo" "chatapp3-golang/internal/util" "context" "encoding/json" "errors" "fmt" "net/url" "sort" "strconv" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( defaultTimezone = "Asia/Riyadh" defaultShareTitle = "Invite Friends, Earn Big Rewards!" defaultShareDesc = "Invite friends to join Party App and unlock exclusive rewards." defaultValidRechargeCoins = int64(80000) rewardLogStatusPending = "PENDING" rewardLogStatusSuccess = "SUCCESS" rewardLogStatusFailed = "FAILED" relationStatusEligible = "ELIGIBLE" relationStatusBlocked = "BLOCKED" blockReasonDuplicateIP = "DUPLICATE_IP" blockReasonDuplicateDevice = "DUPLICATE_DEVICE" ruleTypeInviterBind = "INVITER_BIND" ruleTypeInviteeBind = "INVITEE_BIND" ruleTypeInviteeRecharge = "INVITEE_RECHARGE" ruleTypeInviterValidCount = "INVITER_VALID_COUNT" ruleTypeInviterTotalRecharge = "INVITER_TOTAL_RECHARGE" ) var errAlreadyProcessed = errors.New("invite recharge already processed") type AppError struct { Status int `json:"-"` Code string `json:"code"` Message string `json:"message"` } func (e *AppError) Error() string { return e.Message } func NewAppError(status int, code, message string) *AppError { return &AppError{Status: status, Code: code, Message: message} } type AuthUser struct { UserID int64 SysOrigin string Token string Authorization string } type RewardView struct { RuleID int64 `json:"ruleId,omitempty"` RuleType string `json:"ruleType,omitempty"` Threshold int64 `json:"threshold,omitempty"` GoldAmount int64 `json:"goldAmount"` RewardGroupID *int64 `json:"rewardGroupId,omitempty"` ValidThreshold int64 `json:"validThreshold,omitempty"` } type TaskView struct { RuleID int64 `json:"ruleId"` RuleType string `json:"ruleType"` Threshold int64 `json:"threshold"` Progress int64 `json:"progress"` GoldAmount int64 `json:"goldAmount"` RewardGroupID *int64 `json:"rewardGroupId,omitempty"` Achieved bool `json:"achieved"` Claimed bool `json:"claimed"` } type InviteStats struct { TotalInvitedUsers int64 `json:"totalInvitedUsers"` TotalValidUsers int64 `json:"totalValidUsers"` TotalRechargeCoins int64 `json:"totalRechargeCoins"` RewardGoldCoins int64 `json:"rewardGoldCoins"` } type HomeResponse struct { CampaignEnabled bool `json:"campaignEnabled"` SysOrigin string `json:"sysOrigin"` InviteCode string `json:"inviteCode"` InviteLink string `json:"inviteLink"` ShareTitle string `json:"shareTitle"` ShareDesc string `json:"shareDesc"` DownloadURL string `json:"downloadUrl"` ServiceContact string `json:"serviceContact"` Timezone string `json:"timezone"` MonthKey string `json:"monthKey"` ValidUserThreshold int64 `json:"validUserThreshold"` InviterBindReward RewardView `json:"inviterBindReward"` InviteeBindReward RewardView `json:"inviteeBindReward"` InviteeRecharge []RewardView `json:"inviteeRechargeRules"` ValidCountTasks []TaskView `json:"inviterValidCountTasks"` TotalRechargeTasks []TaskView `json:"inviterTotalRechargeTasks"` MonthlyProgress struct { InviteCount int64 `json:"inviteCount"` ValidUserCount int64 `json:"validUserCount"` TotalRecharge int64 `json:"totalRechargeCoins"` RewardGoldCoins int64 `json:"rewardGoldCoins"` } `json:"monthlyProgress"` Stats InviteStats `json:"stats"` } type LandingResponse struct { CampaignEnabled bool `json:"campaignEnabled"` SysOrigin string `json:"sysOrigin"` InviterUserID int64 `json:"inviterUserId"` InviterNickname string `json:"inviterNickname"` InviterAvatar string `json:"inviterAvatar"` InviteCode string `json:"inviteCode"` DownloadURL string `json:"downloadUrl"` ShareTitle string `json:"shareTitle"` ShareDesc string `json:"shareDesc"` InviteeReward RewardView `json:"inviteeReward"` } type BindCodeRequest struct { InviteCode string `json:"inviteCode"` } type BindCodeResponse struct { RelationID int64 `json:"relationId"` Eligible bool `json:"eligible"` BlockReason string `json:"blockReason,omitempty"` InviterUserID int64 `json:"inviterUserId"` InviteCode string `json:"inviteCode"` InviterReward RewardView `json:"inviterReward"` InviteeReward RewardView `json:"inviteeReward"` } type TaskClaimRequest struct { RuleID int64 `json:"ruleId"` } type TaskClaimResponse struct { Claimed bool `json:"claimed"` AlreadyClaimed bool `json:"alreadyClaimed"` RuleID int64 `json:"ruleId"` RuleType string `json:"ruleType"` Progress int64 `json:"progress"` Threshold int64 `json:"threshold"` Reward RewardView `json:"reward"` } type RechargeSuccessRequest struct { OrderID string `json:"orderId"` UserID int64 `json:"userId"` RechargeCoins int64 `json:"rechargeCoins"` PayTime any `json:"payTime"` SysOrigin string `json:"sysOrigin"` } type RechargeSuccessResponse struct { Processed bool `json:"processed"` AlreadyProcessed bool `json:"alreadyProcessed"` HasInviteRelation bool `json:"hasInviteRelation"` Eligible bool `json:"eligible"` RelationID int64 `json:"relationId,omitempty"` InviterUserID int64 `json:"inviterUserId,omitempty"` TotalRechargeCoins int64 `json:"totalRechargeCoins,omitempty"` ValidUser bool `json:"validUser"` NewlyRewardedRuleIDs []int64 `json:"newlyRewardedRuleIds,omitempty"` } type rewardDispatchInput struct { SysOrigin string MonthKey string RelationID *int64 InviterUserID *int64 InviteeUserID *int64 TargetUserID int64 Reward RewardView Scene string RuleID *int64 BusinessKey string GoldOrigin string PropsOrigin string Remark string } type rewardDispatchResult struct { Success bool } type campaignBundle struct { Config model.InviteCampaignConfig `json:"config"` Rules []model.InviteCampaignRewardRule `json:"rules"` } type InviteService struct { cfg config.Config repo *repo.Repository java *integration.Client } func NewInviteService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *InviteService { return &InviteService{ cfg: cfg, repo: repository, java: javaClient, } } func (s *InviteService) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) { bundle, err := s.loadBundle(ctx, user.SysOrigin) if err != nil { return nil, err } inviteCode, err := s.ensureInviteCode(ctx, user) if err != nil { return nil, err } now := time.Now() timezone := normalizeTimezone(bundle.Config.Timezone) monthKey := monthKeyAt(now, timezone) monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) if err != nil { return nil, err } stats, err := s.loadStats(ctx, user.SysOrigin, user.UserID) if err != nil { return nil, err } claims, err := s.loadClaimedRuleIDs(ctx, user.SysOrigin, user.UserID, monthKey) if err != nil { return nil, err } inviterBindRule := firstRule(bundle.Rules, ruleTypeInviterBind) inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) validCountRules := rulesByType(bundle.Rules, ruleTypeInviterValidCount) totalRechargeRules := rulesByType(bundle.Rules, ruleTypeInviterTotalRecharge) resp := &HomeResponse{ CampaignEnabled: bundle.Config.Enabled, SysOrigin: user.SysOrigin, InviteCode: inviteCode, InviteLink: s.cfg.PublicBaseURL + "/public/h5/invite-campaign/landing?code=" + url.QueryEscape(inviteCode), ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle), ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc), DownloadURL: bundle.Config.DownloadURL, ServiceContact: bundle.Config.ServiceContact, Timezone: timezone, MonthKey: monthKey, ValidUserThreshold: effectiveValidThreshold(inviteeRechargeRules), InviterBindReward: rewardFromRule(inviterBindRule), InviteeBindReward: rewardFromRule(inviteeBindRule), InviteeRecharge: rewardsFromRules(inviteeRechargeRules), Stats: stats, } resp.MonthlyProgress.InviteCount = monthly.InviteCount resp.MonthlyProgress.ValidUserCount = monthly.ValidUserCount resp.MonthlyProgress.TotalRecharge = monthly.TotalRecharge resp.MonthlyProgress.RewardGoldCoins = monthly.RewardGoldCoins resp.ValidCountTasks = buildTaskViews(validCountRules, monthly.ValidUserCount, claims) resp.TotalRechargeTasks = buildTaskViews(totalRechargeRules, monthly.TotalRecharge, claims) return resp, nil } func (s *InviteService) GetLanding(ctx context.Context, inviteCode string) (*LandingResponse, error) { inviteCode = strings.TrimSpace(inviteCode) if inviteCode == "" { return nil, NewAppError(400, "invalid_invite_code", "inviteCode is required") } 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 } inviter, err := s.findUserBaseInfo(ctx, mapping.UserID) if err != nil { return nil, err } bundle, err := s.loadBundle(ctx, inviter.OriginSys) if err != nil { return nil, err } inviteeBindRule := firstRule(bundle.Rules, ruleTypeInviteeBind) return &LandingResponse{ CampaignEnabled: bundle.Config.Enabled, SysOrigin: inviter.OriginSys, InviterUserID: inviter.ID, InviterNickname: inviter.UserNickname, InviterAvatar: inviter.UserAvatar, InviteCode: mapping.InviteCode, DownloadURL: bundle.Config.DownloadURL, ShareTitle: defaultIfBlank(bundle.Config.ShareTitle, defaultShareTitle), ShareDesc: defaultIfBlank(bundle.Config.ShareDesc, defaultShareDesc), InviteeReward: rewardFromRule(inviteeBindRule), }, nil } func (s *InviteService) BindCode(ctx context.Context, user AuthUser, clientIP string, req BindCodeRequest) (*BindCodeResponse, error) { inviteCode := strings.TrimSpace(req.InviteCode) 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") } 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 := "" if value, fpErr := s.java.GetDeviceFingerprint(ctx, user.UserID); fpErr == nil { fingerprint = strings.TrimSpace(value) } 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 monthKey string ) err = s.withBindLocks(ctx, lockKeys, func() error { if _, lockErr := s.findRelationByInvitee(ctx, user.SysOrigin, user.UserID); lockErr == nil { return NewAppError(400, "already_bound", "invite code already bound") } else if !errors.Is(lockErr, gorm.ErrRecordNotFound) { return lockErr } bindTime := time.Now() monthKey = monthKeyAt(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 := util.NextID() if idErr != nil { return idErr } progressID, idErr := util.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, monthKey, 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 { inviterUserID := mapping.UserID inviteeUserID := user.UserID _, err = s.dispatchReward(ctx, rewardDispatchInput{ SysOrigin: user.SysOrigin, MonthKey: monthKey, 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, BlockReason: blockReason, InviterUserID: mapping.UserID, InviteCode: inviteCode, InviterReward: rewardFromRule(inviterBindRule), InviteeReward: rewardFromRule(inviteeBindRule), }, nil } func (s *InviteService) ClaimTask(ctx context.Context, user AuthUser, req TaskClaimRequest) (*TaskClaimResponse, error) { if req.RuleID == 0 { return nil, NewAppError(400, "invalid_rule_id", "ruleId is required") } bundle, err := s.loadBundle(ctx, user.SysOrigin) if err != nil { return nil, err } rule := findRuleByID(bundle.Rules, req.RuleID) if rule == nil || !rule.Enabled { return nil, NewAppError(404, "rule_not_found", "rule not found") } if rule.RuleType != ruleTypeInviterValidCount && rule.RuleType != ruleTypeInviterTotalRecharge { return nil, NewAppError(400, "invalid_rule_type", "rule cannot be claimed manually") } monthKey := monthKeyAt(time.Now(), normalizeTimezone(bundle.Config.Timezone)) claimed, err := s.hasTaskClaim(ctx, user.SysOrigin, user.UserID, monthKey, rule.ID) if err != nil { return nil, err } monthly, err := s.findMonthlyProgress(ctx, user.SysOrigin, user.UserID, monthKey) if err != nil { return nil, err } progressValue := monthly.TotalRecharge if rule.RuleType == ruleTypeInviterValidCount { progressValue = monthly.ValidUserCount } if claimed { return &TaskClaimResponse{ Claimed: true, AlreadyClaimed: true, RuleID: rule.ID, RuleType: rule.RuleType, Progress: progressValue, Threshold: rule.ThresholdValue, Reward: rewardFromRule(rule), }, nil } if progressValue < rule.ThresholdValue { return nil, NewAppError(400, "task_not_reached", "task threshold not reached") } _, err = s.dispatchReward(ctx, rewardDispatchInput{ SysOrigin: user.SysOrigin, MonthKey: monthKey, InviterUserID: refInt64(user.UserID), TargetUserID: user.UserID, Reward: rewardFromRule(rule), Scene: rule.RuleType, RuleID: refInt64(rule.ID), BusinessKey: fmt.Sprintf("claim:%d:%s:%d", user.UserID, monthKey, rule.ID), GoldOrigin: mapGoldOrigin(rule.RuleType), PropsOrigin: mapPropsOrigin(rule.RuleType), Remark: "resident invite monthly task reward", }) if err != nil { return nil, err } now := time.Now() err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := s.createTaskClaimIfAbsentTx(tx, user.SysOrigin, user.UserID, monthKey, rule.ID, now); err != nil { return err } return nil }) if err != nil { return nil, err } return &TaskClaimResponse{ Claimed: true, AlreadyClaimed: false, RuleID: rule.ID, RuleType: rule.RuleType, Progress: progressValue, Threshold: rule.ThresholdValue, Reward: rewardFromRule(rule), }, nil } func (s *InviteService) HandleRechargeSuccess(ctx context.Context, req RechargeSuccessRequest) (*RechargeSuccessResponse, error) { req.OrderID = strings.TrimSpace(req.OrderID) req.SysOrigin = strings.TrimSpace(req.SysOrigin) if req.OrderID == "" { return nil, NewAppError(400, "invalid_order_id", "orderId is required") } if req.UserID == 0 { return nil, NewAppError(400, "invalid_user_id", "userId is required") } if req.RechargeCoins <= 0 { return nil, NewAppError(400, "invalid_recharge", "rechargeCoins must be greater than 0") } if req.SysOrigin == "" { return nil, NewAppError(400, "invalid_sys_origin", "sysOrigin is required") } bundle, err := s.loadBundle(ctx, req.SysOrigin) if err != nil { return nil, err } payTime, err := parseFlexibleTime(req.PayTime) if err != nil { return nil, NewAppError(400, "invalid_pay_time", err.Error()) } monthKey := monthKeyAt(payTime, normalizeTimezone(bundle.Config.Timezone)) inviteeRechargeRules := rulesByType(bundle.Rules, ruleTypeInviteeRecharge) validThreshold := effectiveValidThreshold(inviteeRechargeRules) var ( relation model.InviteCampaignRelation progress model.InviteCampaignInviteeProgress crossedRules []model.InviteCampaignRewardRule oldRewardedIDs map[int64]struct{} hasRelation bool eligible bool becameValid bool inviterUserID int64 relationID int64 newTotal int64 ) err = s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var existing model.InviteCampaignRechargeEvent err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("order_id = ?", req.OrderID). First(&existing).Error if err == nil { return errAlreadyProcessed } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("sys_origin = ? AND invitee_user_id = ?", req.SysOrigin, req.UserID). First(&relation).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } hasRelation = err == nil eligible = hasRelation && relation.EligibleStatus == relationStatusEligible eventID, idErr := util.NextID() if idErr != nil { return idErr } rechargeEvent := model.InviteCampaignRechargeEvent{ ID: eventID, SysOrigin: req.SysOrigin, OrderID: req.OrderID, InviteeUserID: req.UserID, RechargeCoins: req.RechargeCoins, PayTime: payTime, CreateTime: time.Now(), UpdateTime: time.Now(), } if hasRelation { rechargeEvent.InviterUserID = refInt64(relation.InviterUserID) } if err := tx.Create(&rechargeEvent).Error; err != nil { return err } if !hasRelation { return nil } err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("relation_id = ?", relation.ID). First(&progress).Error if err != nil { return err } oldTotal := progress.TotalRecharge newTotal = oldTotal + req.RechargeCoins oldRewardedIDs = parseRewardedRuleIDs(progress.RewardedRuleIDs) crossedRules = crossedInviteeRechargeRules(inviteeRechargeRules, oldTotal, newTotal, oldRewardedIDs) progress.TotalRecharge = newTotal progress.UpdateTime = time.Now() if eligible && !progress.ValidUser && newTotal >= validThreshold { progress.ValidUser = true progress.ValidTime = refTime(payTime) becameValid = true } if err := tx.Save(&progress).Error; err != nil { return err } if eligible { if err := s.incrementMonthlyProgressTx(tx, req.SysOrigin, relation.InviterUserID, monthKey, func(item *model.InviteCampaignMonthlyProgress) { item.TotalRecharge += req.RechargeCoins if becameValid { item.ValidUserCount += 1 } }); err != nil { return err } } inviterUserID = relation.InviterUserID relationID = relation.ID return nil }) if err != nil { if errors.Is(err, errAlreadyProcessed) { return &RechargeSuccessResponse{ Processed: true, AlreadyProcessed: true, }, nil } return nil, err } if !hasRelation { return &RechargeSuccessResponse{ Processed: true, HasInviteRelation: false, }, nil } successfulRuleIDs := make([]int64, 0, len(crossedRules)) if eligible { for _, rule := range crossedRules { _, err := s.dispatchReward(ctx, rewardDispatchInput{ SysOrigin: req.SysOrigin, MonthKey: monthKey, RelationID: refInt64(relationID), InviterUserID: refInt64(inviterUserID), InviteeUserID: refInt64(req.UserID), TargetUserID: inviterUserID, Reward: rewardFromRule(&rule), Scene: ruleTypeInviteeRecharge, RuleID: refInt64(rule.ID), BusinessKey: fmt.Sprintf("recharge:%d:%d", relationID, rule.ID), GoldOrigin: mapGoldOrigin(ruleTypeInviteeRecharge), PropsOrigin: mapPropsOrigin(ruleTypeInviteeRecharge), Remark: "resident invite recharge reward", }) if err != nil { return nil, err } successfulRuleIDs = append(successfulRuleIDs, rule.ID) } if len(successfulRuleIDs) > 0 { if err := s.mergeRewardedRuleIDs(ctx, relationID, successfulRuleIDs); err != nil { return nil, err } } } return &RechargeSuccessResponse{ Processed: true, AlreadyProcessed: false, HasInviteRelation: true, Eligible: eligible, RelationID: relationID, InviterUserID: inviterUserID, TotalRechargeCoins: newTotal, ValidUser: progress.ValidUser, NewlyRewardedRuleIDs: successfulRuleIDs, }, nil } func (s *InviteService) loadBundle(ctx context.Context, sysOrigin string) (*campaignBundle, error) { sysOrigin = normalizeSysOrigin(sysOrigin) cacheKey := "invite:campaign:config:" + sysOrigin if raw, err := s.repo.Redis.Get(ctx, cacheKey).Result(); err == nil && raw != "" { var bundle campaignBundle if jsonErr := json.Unmarshal([]byte(raw), &bundle); jsonErr == nil { return &bundle, nil } } bundle := &campaignBundle{ Config: model.InviteCampaignConfig{ SysOrigin: sysOrigin, Enabled: false, Timezone: defaultTimezone, AntiCheatSameIPOnce: true, AntiCheatSameDeviceOnce: true, }, } var configEntity model.InviteCampaignConfig err := s.repo.DB.WithContext(ctx). Where("sys_origin = ?", sysOrigin). First(&configEntity).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } if err == nil { bundle.Config = configEntity if strings.TrimSpace(bundle.Config.Timezone) == "" { bundle.Config.Timezone = defaultTimezone } } if err := s.repo.DB.WithContext(ctx). Where("sys_origin = ?", sysOrigin). Order("sort asc, threshold_value asc, id asc"). Find(&bundle.Rules).Error; err != nil { return nil, err } payload, _ := json.Marshal(bundle) s.repo.Redis.Set(ctx, cacheKey, payload, 0) return bundle, nil } func (s *InviteService) ensureInviteCode(ctx context.Context, user AuthUser) (string, error) { if mapping, err := s.findInviteCodeMappingByUserID(ctx, user.UserID); err == nil { return mapping.InviteCode, nil } else if !errors.Is(err, gorm.ErrRecordNotFound) { return "", err } if user.Authorization == "" { return "", NewAppError(401, "missing_authorization", "authorization is required to generate invite code") } resp, err := s.java.EnsureInviteCode(ctx, user.Authorization) if err != nil { return "", err } info, infoErr := s.findUserBaseInfo(ctx, user.UserID) if infoErr != nil { return "", infoErr } mapping := model.InviteCodeMapping{ UserID: user.UserID, InviteCode: resp.InviteCode, Account: info.Account, } if err := s.repo.DB.WithContext(ctx). Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"invite_code", "account"}), }). Create(&mapping).Error; err != nil { return "", err } return resp.InviteCode, nil } func (s *InviteService) findInviteCodeMappingByUserID(ctx context.Context, userID int64) (*model.InviteCodeMapping, error) { var mapping model.InviteCodeMapping err := s.repo.DB.WithContext(ctx). Where("user_id = ?", userID). First(&mapping).Error if err != nil { return nil, err } return &mapping, nil } func (s *InviteService) findInviteCodeMappingByCode(ctx context.Context, inviteCode string) (*model.InviteCodeMapping, error) { var mapping model.InviteCodeMapping err := s.repo.DB.WithContext(ctx). Where("invite_code = ?", inviteCode). First(&mapping).Error if err != nil { return nil, err } return &mapping, nil } func (s *InviteService) findUserBaseInfo(ctx context.Context, userID int64) (*model.UserBaseInfo, error) { var info model.UserBaseInfo err := s.repo.DB.WithContext(ctx). Where("id = ?", userID). First(&info).Error if err != nil { return nil, err } return &info, nil } func (s *InviteService) findRelationByInvitee(ctx context.Context, sysOrigin string, inviteeUserID int64) (*model.InviteCampaignRelation, error) { var relation model.InviteCampaignRelation err := s.repo.DB.WithContext(ctx). Where("sys_origin = ? AND invitee_user_id = ?", sysOrigin, inviteeUserID). First(&relation).Error if err != nil { return nil, err } return &relation, nil } 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() } 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): } } } func (s *InviteService) releaseBindLocks(ctx context.Context, keys []string) { if len(keys) == 0 { return } _ = s.repo.Redis.Del(ctx, keys...).Err() } 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 } 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") } 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 } 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 } func (s *InviteService) findMonthlyProgress(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (*model.InviteCampaignMonthlyProgress, error) { var progress model.InviteCampaignMonthlyProgress err := s.repo.DB.WithContext(ctx). Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). First(&progress).Error if errors.Is(err, gorm.ErrRecordNotFound) { return &model.InviteCampaignMonthlyProgress{ SysOrigin: sysOrigin, InviterUserID: inviterUserID, MonthKey: monthKey, }, nil } if err != nil { return nil, err } return &progress, nil } func (s *InviteService) loadStats(ctx context.Context, sysOrigin string, inviterUserID int64) (InviteStats, error) { stats := InviteStats{} if err := s.repo.DB.WithContext(ctx). Model(&model.InviteCampaignRelation{}). Where("sys_origin = ? AND inviter_user_id = ? AND eligible_status = ?", sysOrigin, inviterUserID, relationStatusEligible). Count(&stats.TotalInvitedUsers).Error; err != nil { return stats, err } if err := s.repo.DB.WithContext(ctx).Raw(` SELECT COALESCE(COUNT(1), 0) FROM invite_campaign_invitee_progress ip INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ? AND ip.valid_user = 1 `, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalValidUsers).Error; err != nil { return stats, err } if err := s.repo.DB.WithContext(ctx).Raw(` SELECT COALESCE(SUM(ip.total_recharge_coins), 0) FROM invite_campaign_invitee_progress ip INNER JOIN invite_campaign_relation r ON r.id = ip.relation_id WHERE r.sys_origin = ? AND r.inviter_user_id = ? AND r.eligible_status = ? `, sysOrigin, inviterUserID, relationStatusEligible).Scan(&stats.TotalRechargeCoins).Error; err != nil { return stats, err } if err := s.repo.DB.WithContext(ctx).Raw(` SELECT COALESCE(SUM(gold_amount), 0) FROM invite_campaign_reward_log WHERE sys_origin = ? AND inviter_user_id = ? AND target_user_id = ? AND status = ? `, sysOrigin, inviterUserID, inviterUserID, rewardLogStatusSuccess).Scan(&stats.RewardGoldCoins).Error; err != nil { return stats, err } return stats, nil } func (s *InviteService) loadClaimedRuleIDs(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string) (map[int64]bool, error) { var claims []model.InviteCampaignTaskClaim if err := s.repo.DB.WithContext(ctx). Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). Find(&claims).Error; err != nil { return nil, err } result := make(map[int64]bool, len(claims)) for _, item := range claims { result[item.RuleID] = true } return result, nil } func (s *InviteService) hasTaskClaim(ctx context.Context, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64) (bool, error) { var count int64 err := s.repo.DB.WithContext(ctx). Model(&model.InviteCampaignTaskClaim{}). Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID). Count(&count).Error return count > 0, err } func (s *InviteService) incrementMonthlyProgressTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, mutate func(item *model.InviteCampaignMonthlyProgress)) error { var progress model.InviteCampaignMonthlyProgress err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ?", sysOrigin, inviterUserID, monthKey). First(&progress).Error if errors.Is(err, gorm.ErrRecordNotFound) { id, idErr := util.NextID() if idErr != nil { return idErr } now := time.Now() progress = model.InviteCampaignMonthlyProgress{ ID: id, SysOrigin: sysOrigin, InviterUserID: inviterUserID, MonthKey: monthKey, CreateTime: now, UpdateTime: now, } mutate(&progress) return tx.Create(&progress).Error } if err != nil { return err } mutate(&progress) progress.UpdateTime = time.Now() return tx.Save(&progress).Error } func (s *InviteService) createTaskClaimIfAbsentTx(tx *gorm.DB, sysOrigin string, inviterUserID int64, monthKey string, ruleID int64, claimTime time.Time) error { var existing model.InviteCampaignTaskClaim err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("sys_origin = ? AND inviter_user_id = ? AND month_key = ? AND rule_id = ?", sysOrigin, inviterUserID, monthKey, ruleID). First(&existing).Error if err == nil { return nil } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return err } id, idErr := util.NextID() if idErr != nil { return idErr } return tx.Create(&model.InviteCampaignTaskClaim{ ID: id, SysOrigin: sysOrigin, InviterUserID: inviterUserID, MonthKey: monthKey, RuleID: ruleID, ClaimTime: claimTime, CreateTime: claimTime, UpdateTime: claimTime, }).Error } func (s *InviteService) dispatchReward(ctx context.Context, input rewardDispatchInput) (*rewardDispatchResult, error) { if input.Reward.GoldAmount <= 0 && input.Reward.RewardGroupID == nil { return &rewardDispatchResult{Success: true}, nil } if input.Reward.GoldAmount > 0 { if err := s.dispatchGold(ctx, input); err != nil { return &rewardDispatchResult{Success: false}, err } } if input.Reward.RewardGroupID != nil { if err := s.dispatchProps(ctx, input); err != nil { return &rewardDispatchResult{Success: false}, err } } return &rewardDispatchResult{Success: true}, nil } func (s *InviteService) dispatchGold(ctx context.Context, input rewardDispatchInput) error { businessKey := input.BusinessKey + ":gold" logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, input.Reward.GoldAmount, nil) if err != nil { return err } if logRecord.Status == rewardLogStatusSuccess { return nil } err = s.java.GrantGold(ctx, integration.GrantGoldRequest{ UserID: input.TargetUserID, SysOrigin: input.SysOrigin, EventID: businessKey, Origin: input.GoldOrigin, GoldAmount: input.Reward.GoldAmount, Remark: input.Remark, }) if err != nil { return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) } if err := s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, ""); err != nil { return err } if input.MonthKey != "" && input.InviterUserID != nil && *input.InviterUserID == input.TargetUserID { return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { return s.incrementMonthlyProgressTx(tx, input.SysOrigin, input.TargetUserID, input.MonthKey, func(item *model.InviteCampaignMonthlyProgress) { item.RewardGoldCoins += input.Reward.GoldAmount }) }) } return nil } func (s *InviteService) dispatchProps(ctx context.Context, input rewardDispatchInput) error { if input.Reward.RewardGroupID == nil { return nil } businessKey := input.BusinessKey + ":props" logRecord, err := s.getOrCreateRewardLog(ctx, businessKey, input, 0, input.Reward.RewardGroupID) if err != nil { return err } if logRecord.Status == rewardLogStatusSuccess { return nil } err = s.java.GrantProps(ctx, integration.GrantPropsRequest{ TrackID: logRecord.ID, AcceptUserID: input.TargetUserID, SysOrigin: input.SysOrigin, Origin: input.PropsOrigin, SourceGroupID: *input.Reward.RewardGroupID, }) if err != nil { return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusFailed, err.Error()) } return s.updateRewardLogStatus(ctx, logRecord.ID, rewardLogStatusSuccess, "") } func (s *InviteService) getOrCreateRewardLog(ctx context.Context, businessKey string, input rewardDispatchInput, goldAmount int64, rewardGroupID *int64) (*model.InviteCampaignRewardLog, error) { var record model.InviteCampaignRewardLog err := s.repo.DB.WithContext(ctx). Where("business_key = ?", businessKey). First(&record).Error if err == nil { return &record, nil } if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return nil, err } id, idErr := util.NextID() if idErr != nil { return nil, idErr } now := time.Now() record = model.InviteCampaignRewardLog{ ID: id, SysOrigin: input.SysOrigin, RelationID: input.RelationID, InviterUserID: input.InviterUserID, InviteeUserID: input.InviteeUserID, TargetUserID: input.TargetUserID, RuleID: input.RuleID, RewardScene: input.Scene, BusinessKey: businessKey, GoldAmount: goldAmount, RewardGroupID: rewardGroupID, Status: rewardLogStatusPending, CreateTime: now, UpdateTime: now, } if err := s.repo.DB.WithContext(ctx).Create(&record).Error; err != nil { if !strings.Contains(strings.ToLower(err.Error()), "duplicate") { return nil, err } if err := s.repo.DB.WithContext(ctx).Where("business_key = ?", businessKey).First(&record).Error; err != nil { return nil, err } } return &record, nil } func (s *InviteService) updateRewardLogStatus(ctx context.Context, id int64, status, message string) error { return s.repo.DB.WithContext(ctx). Model(&model.InviteCampaignRewardLog{}). Where("id = ?", id). Updates(map[string]any{ "status": status, "error_message": truncate(message, 255), "update_time": time.Now(), }).Error } func (s *InviteService) mergeRewardedRuleIDs(ctx context.Context, relationID int64, ruleIDs []int64) error { return s.repo.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var progress model.InviteCampaignInviteeProgress if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). Where("relation_id = ?", relationID). First(&progress).Error; err != nil { return err } current := parseRewardedRuleIDs(progress.RewardedRuleIDs) for _, ruleID := range ruleIDs { current[ruleID] = struct{}{} } progress.RewardedRuleIDs = formatRewardedRuleIDs(current) progress.UpdateTime = time.Now() return tx.Save(&progress).Error }) } func buildTaskViews(rules []model.InviteCampaignRewardRule, progress int64, claimed map[int64]bool) []TaskView { result := make([]TaskView, 0, len(rules)) for _, rule := range rules { if !rule.Enabled { continue } result = append(result, TaskView{ RuleID: rule.ID, RuleType: rule.RuleType, Threshold: rule.ThresholdValue, Progress: progress, GoldAmount: rule.GoldAmount, RewardGroupID: rule.RewardGroupID, Achieved: progress >= rule.ThresholdValue, Claimed: claimed[rule.ID], }) } return result } func firstRule(rules []model.InviteCampaignRewardRule, ruleType string) *model.InviteCampaignRewardRule { for _, item := range rules { if item.RuleType == ruleType && item.Enabled { rule := item return &rule } } return nil } func rulesByType(rules []model.InviteCampaignRewardRule, ruleType string) []model.InviteCampaignRewardRule { result := make([]model.InviteCampaignRewardRule, 0) for _, item := range rules { if item.RuleType == ruleType && item.Enabled { result = append(result, item) } } sort.SliceStable(result, func(i, j int) bool { if result[i].Sort == result[j].Sort { if result[i].ThresholdValue == result[j].ThresholdValue { return result[i].ID < result[j].ID } return result[i].ThresholdValue < result[j].ThresholdValue } return result[i].Sort < result[j].Sort }) return result } func findRuleByID(rules []model.InviteCampaignRewardRule, ruleID int64) *model.InviteCampaignRewardRule { for _, item := range rules { if item.ID == ruleID { rule := item return &rule } } return nil } func crossedInviteeRechargeRules(rules []model.InviteCampaignRewardRule, oldTotal, newTotal int64, rewarded map[int64]struct{}) []model.InviteCampaignRewardRule { result := make([]model.InviteCampaignRewardRule, 0) for _, rule := range rules { if !rule.Enabled { continue } if _, exists := rewarded[rule.ID]; exists { continue } if oldTotal < rule.ThresholdValue && newTotal >= rule.ThresholdValue { result = append(result, rule) } } return result } func rewardFromRule(rule *model.InviteCampaignRewardRule) RewardView { if rule == nil { return RewardView{} } return RewardView{ RuleID: rule.ID, RuleType: rule.RuleType, Threshold: rule.ThresholdValue, GoldAmount: rule.GoldAmount, RewardGroupID: rule.RewardGroupID, } } func rewardsFromRules(rules []model.InviteCampaignRewardRule) []RewardView { result := make([]RewardView, 0, len(rules)) for _, item := range rules { result = append(result, rewardFromRule(&item)) } return result } func effectiveValidThreshold(rules []model.InviteCampaignRewardRule) int64 { var threshold int64 for _, rule := range rules { if !rule.Enabled || rule.ThresholdValue <= 0 { continue } if threshold == 0 || rule.ThresholdValue < threshold { threshold = rule.ThresholdValue } } if threshold == 0 { return defaultValidRechargeCoins } return threshold } func parseRewardedRuleIDs(raw string) map[int64]struct{} { result := make(map[int64]struct{}) for _, item := range strings.Split(raw, ",") { item = strings.TrimSpace(item) if item == "" { continue } value, err := strconv.ParseInt(item, 10, 64) if err == nil && value > 0 { result[value] = struct{}{} } } return result } func formatRewardedRuleIDs(values map[int64]struct{}) string { items := make([]int64, 0, len(values)) for value := range values { items = append(items, value) } sort.Slice(items, func(i, j int) bool { return items[i] < items[j] }) parts := make([]string, 0, len(items)) for _, value := range items { parts = append(parts, strconv.FormatInt(value, 10)) } return strings.Join(parts, ",") } func parseFlexibleTime(value any) (time.Time, error) { switch v := value.(type) { case nil: return time.Now(), nil case float64: return fromUnixNumber(int64(v)), nil case int64: return fromUnixNumber(v), nil case int: return fromUnixNumber(int64(v)), nil case json.Number: num, err := v.Int64() if err != nil { return time.Time{}, err } return fromUnixNumber(num), nil case string: v = strings.TrimSpace(v) if v == "" { return time.Now(), nil } if digitsOnly(v) { num, err := strconv.ParseInt(v, 10, 64) if err != nil { return time.Time{}, err } return fromUnixNumber(num), nil } layouts := []string{ time.RFC3339, "2006-01-02 15:04:05", "2006-01-02T15:04:05", "2006-01-02", } for _, layout := range layouts { if parsed, err := time.Parse(layout, v); err == nil { return parsed, nil } } return time.Time{}, fmt.Errorf("unsupported payTime format") default: return time.Time{}, fmt.Errorf("unsupported payTime type") } } func fromUnixNumber(value int64) time.Time { if value > 1_000_000_000_000 { return time.UnixMilli(value) } return time.Unix(value, 0) } func normalizeSysOrigin(value string) string { return strings.TrimSpace(value) } func normalizeTimezone(value string) string { value = strings.TrimSpace(value) if value == "" { return defaultTimezone } return value } func monthKeyAt(t time.Time, timezone string) string { location, err := time.LoadLocation(normalizeTimezone(timezone)) if err != nil { location = time.FixedZone(defaultTimezone, 3*3600) } return t.In(location).Format("200601") } func digitsOnly(value string) bool { for _, ch := range value { if ch < '0' || ch > '9' { return false } } return true } func truncate(value string, size int) string { if len(value) <= size { return value } return value[:size] } func optionalRuleID(rule *model.InviteCampaignRewardRule) *int64 { if rule == nil || rule.ID == 0 { return nil } return refInt64(rule.ID) } func refInt64(value int64) *int64 { return &value } func refTime(value time.Time) *time.Time { return &value } func mapGoldOrigin(ruleType string) string { switch ruleType { case ruleTypeInviterBind: return "INVITED_NEW_USER_REWARD" case ruleTypeInviteeRecharge: return "INVITED_USER_FIRST_RECHARGE_REWARD" case ruleTypeInviterTotalRecharge: return "CUMULATIVE_RECHARGE_REWARDS" default: return "INVITE_USER_REWARDS" } } func mapPropsOrigin(ruleType string) string { switch ruleType { case ruleTypeInviterTotalRecharge: return "CUMULATIVE_RECHARGE_REWARDS" default: return "INVITE_USER_REWARDS" } }