package invite import ( "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "context" "encoding/json" "errors" "fmt" "sort" "strconv" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" ) // HandleRechargeSuccess 处理被邀请人的充值成功事件,并更新进度与触发奖励。 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()) } periodKey, _, _ := thirtyDayPeriodInfoAt(payTime, normalizeTimezone(bundle.Config.Timezone), bundle.Config.CreateTime) 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 := utils.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, periodKey, 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: periodKey, 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 } // crossedInviteeRechargeRules 找出本次充值跨过的被邀请人充值奖励规则。 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 } // parseRewardedRuleIDs 解析已奖励规则 ID 字符串。 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 } // formatRewardedRuleIDs 把规则 ID 集合格式化成稳定字符串。 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, ",") } // parseFlexibleTime 宽松解析回调里的时间字段。 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") } } // fromUnixNumber 把秒或毫秒级时间戳转换成时间。 func fromUnixNumber(value int64) time.Time { if value > 1_000_000_000_000 { return time.UnixMilli(value) } return time.Unix(value, 0) }