package service import ( "context" "fmt" "log" "sort" "strconv" "strings" "time" "chatapp-cron/internal/config" "chatapp-cron/internal/integration" "chatapp-cron/internal/model" "chatapp-cron/internal/repo" "chatapp-cron/internal/util" "gorm.io/gorm/clause" ) const ( rechargeRewardStatusPending = "PENDING" rechargeRewardStatusSuccess = "SUCCESS" rechargeRewardStatusFailed = "FAILED" rechargeRewardStatusSkipped = "SKIPPED" rechargeRewardOrigin = "CUMULATIVE_RECHARGE_REWARDS" ) var rechargeRewardOrderPlatforms = []string{ "GOOGLE", "PAYER_MAX", "AIRWALLEX", "PAYNICORN", "STRIPE", "PAY_PAL", "CLIPSPAY", } type rechargeRewardLevel struct { ID int64 Level int RechargeAmountCents int64 RewardGold int64 RewardGroupID *int64 RewardGroupName string } type rechargeRewardUserAmount struct { UserID int64 `gorm:"column:user_id"` RechargeAmountCents int64 `gorm:"column:recharge_amount_cents"` } type rechargeRewardPeriod struct { CycleKey string RechargeDate int StartAt time.Time EndAt time.Time } // RechargeRewardSettlementService 扫描充值奖励配置,按周期累计充值并自动发奖。 type RechargeRewardSettlementService struct { cfg config.Config repo *repo.Repository java *integration.Client location *time.Location storageLocation *time.Location } func NewRechargeRewardSettlementService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *RechargeRewardSettlementService { location, err := time.LoadLocation(strings.TrimSpace(cfg.RechargeRewardTimezone)) if err != nil { location = time.UTC } storageLocation, err := time.LoadLocation(strings.TrimSpace(cfg.RechargeRewardStorageTimezone)) if err != nil { storageLocation = location } return &RechargeRewardSettlementService{ cfg: cfg, repo: repository, java: javaClient, location: location, storageLocation: storageLocation, } } func (s *RechargeRewardSettlementService) Start(ctx context.Context) { interval := s.cfg.RechargeRewardSettleInterval if interval <= 0 { interval = time.Minute } go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { if ctx.Err() != nil { return } if err := s.SettleDueCycles(ctx); err != nil && ctx.Err() == nil { log.Printf("recharge reward settlement scan failed: %v", err) } select { case <-ctx.Done(): return case <-ticker.C: } } }() } func (s *RechargeRewardSettlementService) SettleDueCycles(ctx context.Context) error { var configs []model.RechargeRewardConfig if err := s.repo.DB.WithContext(ctx). Where("enabled = ?", true). Order("id desc"). Find(&configs).Error; err != nil { return err } if len(configs) == 0 { return nil } now := time.Now() var lastErr error for index := range configs { if err := s.settleConfig(ctx, &configs[index], now); err != nil { lastErr = err log.Printf("recharge reward settle config failed. configId=%d err=%v", configs[index].ID, err) } } return lastErr } func (s *RechargeRewardSettlementService) settleConfig(ctx context.Context, configRow *model.RechargeRewardConfig, now time.Time) error { if configRow == nil { return nil } location := rechargeRewardLocation(configRow.Timezone, s.location) startAt := rechargeRewardStartAt(s.cfg.RechargeRewardStartDate, location) period, ok := rechargeRewardCurrentPeriod(now.In(location), startAt, location) if !ok { return nil } lockKey := fmt.Sprintf("recharge-reward:settle-lock:%d:%s", configRow.ID, period.CycleKey) acquired, err := s.repo.Redis.SetNX(ctx, lockKey, 1, 2*time.Minute).Result() if err != nil { return err } if !acquired { return nil } sysOrigin := normalizeRechargeRewardSysOrigin(configRow.SysOrigin, s.cfg.RechargeRewardDefaultSysOrigin) var lastErr error if err := s.settleCurrentPeriod(ctx, configRow, sysOrigin, period); err != nil { lastErr = err } if err := s.dispatchUnfinishedRecords(ctx, configRow.ID); err != nil { lastErr = err } return lastErr } func (s *RechargeRewardSettlementService) settleCurrentPeriod(ctx context.Context, configRow *model.RechargeRewardConfig, sysOrigin string, period rechargeRewardPeriod) error { levels, err := s.loadLevels(ctx, configRow.ID) if err != nil { return err } if len(levels) == 0 { return nil } minThreshold := levels[0].RechargeAmountCents amounts, err := s.loadRechargeAmounts(ctx, sysOrigin, period, minThreshold) if err != nil { return err } if len(amounts) == 0 { return nil } profiles, err := s.lookupUserProfiles(ctx, rechargeRewardAmountUserIDs(amounts)) if err != nil { return err } now := time.Now() var lastErr error for _, amount := range amounts { for _, level := range levels { if amount.RechargeAmountCents < level.RechargeAmountCents { continue } record, err := s.ensureGrantRecord(ctx, configRow.ID, sysOrigin, period, amount, level, profiles[amount.UserID], now) if err != nil { lastErr = err continue } if record.Status == rechargeRewardStatusSuccess { continue } if err := s.dispatchRewardRecord(ctx, &record); err != nil { lastErr = err } } } return lastErr } func (s *RechargeRewardSettlementService) loadLevels(ctx context.Context, configID int64) ([]rechargeRewardLevel, error) { var rows []model.RechargeRewardLevel if err := s.repo.DB.WithContext(ctx). Where("config_id = ? AND enabled = ?", configID, true). Order("recharge_amount_cents asc"). Order("level asc"). Find(&rows).Error; err != nil { return nil, err } levels := make([]rechargeRewardLevel, 0, len(rows)) for _, row := range rows { if row.RechargeAmountCents <= 0 || (row.RewardGold <= 0 && (row.RewardGroupID == nil || *row.RewardGroupID <= 0)) { continue } levels = append(levels, rechargeRewardLevel{ ID: row.ID, Level: row.Level, RechargeAmountCents: row.RechargeAmountCents, RewardGold: row.RewardGold, RewardGroupID: normalizeRechargeRewardGroupID(row.RewardGroupID), RewardGroupName: strings.TrimSpace(row.RewardGroupName), }) } sort.Slice(levels, func(i, j int) bool { if levels[i].RechargeAmountCents == levels[j].RechargeAmountCents { return levels[i].Level < levels[j].Level } return levels[i].RechargeAmountCents < levels[j].RechargeAmountCents }) return levels, nil } func (s *RechargeRewardSettlementService) loadRechargeAmounts(ctx context.Context, sysOrigin string, period rechargeRewardPeriod, minThresholdCents int64) ([]rechargeRewardUserAmount, error) { startAt := rechargeRewardStorageTime(period.StartAt, s.storageLocation) endAt := rechargeRewardStorageTime(period.EndAt, s.storageLocation) coinSellerGoldPerUSD := s.cfg.RechargeRewardCoinSellerGoldPerUSD if coinSellerGoldPerUSD <= 0 { coinSellerGoldPerUSD = 100000 } const query = ` SELECT source.user_id, CAST(SUM(source.amount_cents) AS SIGNED) AS recharge_amount_cents FROM ( SELECT t.user_id, CAST(ROUND(SUM(IFNULL(t.unit_price, 0)) * 100, 0) AS SIGNED) AS amount_cents FROM order_purchase_history AS t INNER JOIN user_base_info AS u ON u.id = t.user_id WHERE t.evn = 'PROD' AND t.is_trial_period = 0 AND t.status = 'COMPLETE' AND t.pay_platform IN ? AND (u.is_del = 0 OR u.is_del IS NULL) AND UPPER(TRIM(u.origin_sys)) = ? AND t.create_time >= ? AND t.create_time < ? GROUP BY t.user_id UNION ALL SELECT t.user_id, CAST(ROUND(SUM(IFNULL(t.compute_usd_amount, 0)) * 100, 0) AS SIGNED) AS amount_cents FROM order_user_purchase_pay AS t INNER JOIN user_base_info AS u ON u.id = t.user_id WHERE t.evn = 'PROD' AND t.factory_code = 'MIFA_PAY' AND t.pay_status = 'SUCCESSFUL' AND t.receipt_type = 'PAYMENT' AND t.refund_status = 'NONE' AND (u.is_del = 0 OR u.is_del IS NULL) AND UPPER(TRIM(u.origin_sys)) = ? AND IFNULL(t.update_time, t.create_time) >= ? AND IFNULL(t.update_time, t.create_time) < ? GROUP BY t.user_id UNION ALL SELECT t.accept_user_id AS user_id, CAST(ROUND(SUM(IFNULL(t.quantity, 0) * 100 / ?), 0) AS SIGNED) AS amount_cents FROM likei_wallet.user_freight_balance_running_water AS t INNER JOIN user_base_info AS u ON u.id = t.accept_user_id WHERE t.origin = 'SHIPMENT' AND t.type = 1 AND t.accept_user_id IS NOT NULL AND IFNULL(t.quantity, 0) > 0 AND (u.is_del = 0 OR u.is_del IS NULL) AND UPPER(TRIM(u.origin_sys)) = ? AND t.create_time >= ? AND t.create_time < ? GROUP BY t.accept_user_id ) AS source WHERE source.user_id > 0 GROUP BY source.user_id HAVING SUM(source.amount_cents) >= ? ORDER BY SUM(source.amount_cents) DESC` var rows []rechargeRewardUserAmount if err := s.repo.DB.WithContext(ctx).Raw( query, rechargeRewardOrderPlatforms, sysOrigin, startAt, endAt, sysOrigin, startAt, endAt, coinSellerGoldPerUSD, sysOrigin, startAt, endAt, minThresholdCents, ).Scan(&rows).Error; err != nil { return nil, err } return rows, nil } func (s *RechargeRewardSettlementService) ensureGrantRecord( ctx context.Context, configID int64, sysOrigin string, period rechargeRewardPeriod, amount rechargeRewardUserAmount, level rechargeRewardLevel, profile rechargeRewardUserProfile, now time.Time, ) (model.RechargeRewardGrantRecord, error) { recordID, err := util.NextID() if err != nil { return model.RechargeRewardGrantRecord{}, err } rewardGroupStatus := rechargeRewardStatusSkipped if level.RewardGroupID != nil && *level.RewardGroupID > 0 { rewardGroupStatus = rechargeRewardStatusPending } rewardGoldStatus := rechargeRewardStatusSkipped goldEventID := "" if level.RewardGold > 0 { rewardGoldStatus = rechargeRewardStatusPending goldEventID = rechargeRewardGoldEventID(sysOrigin, period.CycleKey, amount.UserID, level.Level) } record := model.RechargeRewardGrantRecord{ ID: recordID, ConfigID: configID, LevelID: level.ID, SysOrigin: sysOrigin, CycleKey: period.CycleKey, RechargeDate: period.RechargeDate, PeriodStartAt: period.StartAt, PeriodEndAt: period.EndAt, UserID: amount.UserID, Account: profile.Account, UserAvatar: profile.UserAvatar, UserNickname: profile.UserNickname, CountryCode: profile.CountryCode, CountryName: profile.CountryName, RechargeAmountCents: amount.RechargeAmountCents, Level: level.Level, RechargeThresholdCents: level.RechargeAmountCents, RewardGold: level.RewardGold, RewardGroupID: level.RewardGroupID, RewardGroupName: level.RewardGroupName, RewardGroupTrackID: recordID, GoldEventID: goldEventID, Status: rechargeRewardStatusPending, RewardGroupStatus: rewardGroupStatus, RewardGoldStatus: rewardGoldStatus, CreateTime: now, UpdateTime: now, } if err := s.repo.DB.WithContext(ctx). Clauses(clause.OnConflict{DoNothing: true}). Create(&record).Error; err != nil { return model.RechargeRewardGrantRecord{}, err } var saved model.RechargeRewardGrantRecord if err := s.repo.DB.WithContext(ctx). Where("sys_origin = ? AND cycle_key = ? AND user_id = ? AND level = ?", sysOrigin, period.CycleKey, amount.UserID, level.Level). First(&saved).Error; err != nil { return model.RechargeRewardGrantRecord{}, err } if saved.Status != rechargeRewardStatusSuccess && amount.RechargeAmountCents > saved.RechargeAmountCents { if err := s.repo.DB.WithContext(ctx). Model(&model.RechargeRewardGrantRecord{}). Where("id = ?", saved.ID). Updates(map[string]any{ "recharge_amount_cents": amount.RechargeAmountCents, "update_time": now, }).Error; err != nil { return model.RechargeRewardGrantRecord{}, err } saved.RechargeAmountCents = amount.RechargeAmountCents } return saved, nil } func (s *RechargeRewardSettlementService) dispatchUnfinishedRecords(ctx context.Context, configID int64) error { var rows []model.RechargeRewardGrantRecord if err := s.repo.DB.WithContext(ctx). Where("config_id = ? AND status IN ?", configID, []string{rechargeRewardStatusPending, rechargeRewardStatusFailed}). Order("create_time asc"). Limit(200). Find(&rows).Error; err != nil { return err } var lastErr error for index := range rows { if err := s.dispatchRewardRecord(ctx, &rows[index]); err != nil { lastErr = err } } return lastErr } func (s *RechargeRewardSettlementService) dispatchRewardRecord(ctx context.Context, record *model.RechargeRewardGrantRecord) error { if record == nil || record.Status == rechargeRewardStatusSuccess { return nil } now := time.Now() retryCount := record.RetryCount if record.Status == rechargeRewardStatusFailed { retryCount++ } groupStatus := normalizeRechargeRewardPartStatus(record.RewardGroupStatus, record.RewardGroupID != nil && *record.RewardGroupID > 0) if groupStatus != rechargeRewardStatusSuccess && groupStatus != rechargeRewardStatusSkipped { if err := s.java.SendActivityReward(ctx, integration.SendActivityRewardRequest{ TrackID: record.RewardGroupTrackID, Origin: rechargeRewardOrigin, SysOrigin: record.SysOrigin, SourceGroupID: *record.RewardGroupID, AcceptUserID: record.UserID, }); err != nil { return s.markRechargeRewardFailed(ctx, record.ID, retryCount, "reward_group_status", err, now) } groupStatus = rechargeRewardStatusSuccess record.RewardGroupStatus = groupStatus if err := s.repo.DB.WithContext(ctx). Model(&model.RechargeRewardGrantRecord{}). Where("id = ?", record.ID). Updates(map[string]any{ "reward_group_status": groupStatus, "retry_count": retryCount, "update_time": now, }).Error; err != nil { return err } } goldStatus := normalizeRechargeRewardPartStatus(record.RewardGoldStatus, record.RewardGold > 0) if goldStatus != rechargeRewardStatusSuccess && goldStatus != rechargeRewardStatusSkipped { if err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: "INCOME", UserID: record.UserID, SysOrigin: record.SysOrigin, EventID: record.GoldEventID, Origin: rechargeRewardOrigin, Remark: fmt.Sprintf("recharge reward %s level %d", record.CycleKey, record.Level), Amount: integration.NewPennyAmountPayloadFromDollar(record.RewardGold), CloseDelayAsset: false, OpUserType: "BACK", }); err != nil { return s.markRechargeRewardFailed(ctx, record.ID, retryCount, "reward_gold_status", err, now) } goldStatus = rechargeRewardStatusSuccess record.RewardGoldStatus = goldStatus if err := s.repo.DB.WithContext(ctx). Model(&model.RechargeRewardGrantRecord{}). Where("id = ?", record.ID). Updates(map[string]any{ "reward_gold_status": goldStatus, "retry_count": retryCount, "update_time": now, }).Error; err != nil { return err } } if err := s.repo.DB.WithContext(ctx). Model(&model.RechargeRewardGrantRecord{}). Where("id = ?", record.ID). Updates(map[string]any{ "status": rechargeRewardStatusSuccess, "reward_group_status": groupStatus, "reward_gold_status": goldStatus, "retry_count": retryCount, "last_error": "", "sent_at": now, "update_time": now, }).Error; err != nil { return err } return nil } func (s *RechargeRewardSettlementService) markRechargeRewardFailed(ctx context.Context, recordID int64, retryCount int, failedColumn string, err error, now time.Time) error { updates := map[string]any{ "status": rechargeRewardStatusFailed, "retry_count": retryCount, "last_error": truncateString(err.Error(), 1024), "update_time": now, } if strings.TrimSpace(failedColumn) != "" { updates[failedColumn] = rechargeRewardStatusFailed } if updateErr := s.repo.DB.WithContext(context.Background()). Model(&model.RechargeRewardGrantRecord{}). Where("id = ?", recordID). Updates(updates).Error; updateErr != nil { return updateErr } return err } type rechargeRewardUserProfile struct { UserID int64 Account string UserAvatar string UserNickname string CountryCode string CountryName string } func (s *RechargeRewardSettlementService) lookupUserProfiles(ctx context.Context, userIDs []int64) (map[int64]rechargeRewardUserProfile, error) { result := make(map[int64]rechargeRewardUserProfile, len(userIDs)) if len(userIDs) == 0 { return result, nil } var rows []model.UserBaseInfo if err := s.repo.DB.WithContext(ctx).Where("id IN ?", userIDs).Find(&rows).Error; err != nil { return nil, err } for _, row := range rows { result[row.ID] = rechargeRewardUserProfile{ UserID: row.ID, Account: strings.TrimSpace(row.Account), UserAvatar: strings.TrimSpace(row.UserAvatar), UserNickname: strings.TrimSpace(row.UserNickname), CountryCode: strings.TrimSpace(row.CountryCode), CountryName: strings.TrimSpace(row.CountryName), } } for _, userID := range userIDs { if _, ok := result[userID]; !ok { result[userID] = rechargeRewardUserProfile{UserID: userID} } } return result, nil } func rechargeRewardCurrentPeriod(now time.Time, startAt time.Time, location *time.Location) (rechargeRewardPeriod, bool) { local := now.In(location) start := startAt.In(location) if local.Before(start) { return rechargeRewardPeriod{}, false } monthStart := time.Date(local.Year(), local.Month(), 1, 0, 0, 0, 0, location) periodStart := monthStart if periodStart.Before(start) { periodStart = start } periodEnd := monthStart.AddDate(0, 1, 0) if !local.Before(periodEnd) { return rechargeRewardPeriod{}, false } return rechargeRewardPeriod{ CycleKey: periodStart.Format("20060102"), RechargeDate: periodStart.Year()*100 + int(periodStart.Month()), StartAt: periodStart, EndAt: periodEnd, }, true } func rechargeRewardStartAt(raw string, location *time.Location) time.Time { value := strings.TrimSpace(raw) if value == "" { value = "2026-05-21" } parsed, err := time.ParseInLocation("2006-01-02", value, location) if err != nil { parsed, _ = time.ParseInLocation("2006-01-02", "2026-05-21", location) } return time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, location) } func rechargeRewardLocation(timezone string, fallback *time.Location) *time.Location { location, err := time.LoadLocation(strings.TrimSpace(timezone)) if err == nil { return location } if fallback != nil { return fallback } return time.UTC } func rechargeRewardStorageTime(t time.Time, storageLocation *time.Location) string { if storageLocation == nil { storageLocation = t.Location() } return t.In(storageLocation).Format("2006-01-02 15:04:05") } func rechargeRewardAmountUserIDs(rows []rechargeRewardUserAmount) []int64 { result := make([]int64, 0, len(rows)) seen := make(map[int64]struct{}, len(rows)) for _, row := range rows { if row.UserID <= 0 { continue } if _, exists := seen[row.UserID]; exists { continue } seen[row.UserID] = struct{}{} result = append(result, row.UserID) } return result } func normalizeRechargeRewardSysOrigin(values ...string) string { for _, value := range values { normalized := strings.ToUpper(strings.TrimSpace(value)) if normalized != "" { return normalized } } return "LIKEI" } func normalizeRechargeRewardGroupID(id *int64) *int64 { if id == nil || *id <= 0 { return nil } return id } func normalizeRechargeRewardPartStatus(status string, required bool) string { normalized := strings.ToUpper(strings.TrimSpace(status)) if !required { return rechargeRewardStatusSkipped } switch normalized { case rechargeRewardStatusSuccess, rechargeRewardStatusFailed, rechargeRewardStatusPending: return normalized default: return rechargeRewardStatusPending } } func rechargeRewardGoldEventID(sysOrigin string, cycleKey string, userID int64, level int) string { return "recharge-reward:" + strings.ToUpper(strings.TrimSpace(sysOrigin)) + ":" + cycleKey + ":" + strconv.FormatInt(userID, 10) + ":" + strconv.Itoa(level) }