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 ( roomTurnoverRewardStatusPending = "PENDING" roomTurnoverRewardStatusSuccess = "SUCCESS" roomTurnoverRewardStatusFailed = "FAILED" roomTurnoverRewardGoldOrigin = "ROOM_CONTRIBUTION_REWARD" ) type roomTurnoverRewardLevel struct { Level int TurnoverThreshold int64 RewardGold int64 } // RoomTurnoverRewardSettlementService 负责房间流水奖励的周结算和补发。 type RoomTurnoverRewardSettlementService struct { cfg config.Config repo *repo.Repository java *integration.Client location *time.Location } func NewRoomTurnoverRewardSettlementService(cfg config.Config, repository *repo.Repository, javaClient *integration.Client) *RoomTurnoverRewardSettlementService { location, err := time.LoadLocation(strings.TrimSpace(cfg.RoomTurnoverTimezone)) if err != nil { location = time.UTC } return &RoomTurnoverRewardSettlementService{ cfg: cfg, repo: repository, java: javaClient, location: location, } } func (s *RoomTurnoverRewardSettlementService) Start(ctx context.Context) { interval := s.cfg.RoomTurnoverSettleInterval 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("room turnover reward settlement scan failed: %v", err) } select { case <-ctx.Done(): return case <-ticker.C: } } }() } func (s *RoomTurnoverRewardSettlementService) SettleDueCycles(ctx context.Context) error { var configs []model.RoomTurnoverRewardConfig 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 } contributions, err := s.java.ListLastWeekRoomContribution(ctx) if err != nil { return err } if len(contributions) == 0 { return nil } roomProfiles, err := s.java.MapRoomProfiles(ctx, uniqueRoomTurnoverRewardRoomIDs(contributions)) if err != nil { return err } var lastErr error for index := range configs { if err := s.settleConfig(ctx, &configs[index], contributions, roomProfiles); err != nil { lastErr = err log.Printf("room turnover reward settle config failed. configId=%d err=%v", configs[index].ID, err) } } return lastErr } func (s *RoomTurnoverRewardSettlementService) settleConfig( ctx context.Context, configRow *model.RoomTurnoverRewardConfig, contributions []integration.RoomContributionActivityCount, roomProfiles map[int64]integration.RoomProfile, ) error { if configRow == nil { return nil } sysOrigin := normalizeRoomTurnoverRewardSysOrigin(configRow.SysOrigin, s.cfg.RoomTurnoverDefaultSysOrigin) levels, err := s.loadLevels(ctx, configRow.ID) if err != nil { return err } if len(levels) == 0 { return nil } byCycle := make(map[string][]integration.RoomContributionActivityCount) for _, contribution := range contributions { roomID := int64(contribution.RoomID) if roomID <= 0 { continue } profile, ok := roomProfiles[roomID] if !ok { continue } profileSysOrigin := strings.ToUpper(strings.TrimSpace(profile.SysOrigin)) if profileSysOrigin != "" && profileSysOrigin != sysOrigin { continue } cycleKey := roomTurnoverRewardCycleKey(contribution.DateNumber, time.Now().In(s.location)) if cycleKey == "" { continue } byCycle[cycleKey] = append(byCycle[cycleKey], contribution) } var lastErr error for cycleKey, cycleContributions := range byCycle { if err := s.settleCycle(ctx, configRow, sysOrigin, cycleKey, levels, cycleContributions, roomProfiles); err != nil { lastErr = err } } return lastErr } func (s *RoomTurnoverRewardSettlementService) settleCycle( ctx context.Context, configRow *model.RoomTurnoverRewardConfig, sysOrigin string, cycleKey string, levels []roomTurnoverRewardLevel, contributions []integration.RoomContributionActivityCount, roomProfiles map[int64]integration.RoomProfile, ) error { lockKey := fmt.Sprintf("room-turnover-reward:settle-lock:%d:%s", configRow.ID, cycleKey) acquired, err := s.repo.Redis.SetNX(ctx, lockKey, 1, 5*time.Minute).Result() if err != nil { return err } if !acquired { return nil } periodStart, periodEnd, err := s.periodBounds(cycleKey) if err != nil { return err } now := time.Now() var lastErr error for _, contribution := range contributions { roomID := int64(contribution.RoomID) profile := roomProfiles[roomID] level, ok := pickRoomTurnoverRewardLevel(levels, int64(contribution.ContributionValue)) if !ok { continue } record, err := s.ensureRewardRecord(ctx, configRow.ID, sysOrigin, cycleKey, periodStart, periodEnd, contribution, profile, level, now) if err != nil { lastErr = err continue } if record.Status == roomTurnoverRewardStatusSuccess { if contribution.ID != "" { if _, markErr := s.java.MarkRoomTurnoverRewardCoinsSent(ctx, contribution.ID); markErr != nil { lastErr = markErr log.Printf("mark room turnover reward source failed. recordId=%d sourceId=%s err=%v", record.ID, contribution.ID, markErr) } } continue } if record.Status != roomTurnoverRewardStatusPending && record.Status != roomTurnoverRewardStatusFailed { continue } if err := s.dispatchRewardRecord(ctx, &record, contribution.ID); err != nil { lastErr = err } } return lastErr } func (s *RoomTurnoverRewardSettlementService) loadLevels(ctx context.Context, configID int64) ([]roomTurnoverRewardLevel, error) { var rows []model.RoomTurnoverRewardLevel if err := s.repo.DB.WithContext(ctx). Where("config_id = ? AND enabled = ?", configID, true). Order("turnover_threshold desc"). Find(&rows).Error; err != nil { return nil, err } levels := make([]roomTurnoverRewardLevel, 0, len(rows)) for _, row := range rows { if row.TurnoverThreshold <= 0 || row.RewardGold <= 0 { continue } levels = append(levels, roomTurnoverRewardLevel{ Level: row.Level, TurnoverThreshold: row.TurnoverThreshold, RewardGold: row.RewardGold, }) } sort.Slice(levels, func(i, j int) bool { return levels[i].TurnoverThreshold > levels[j].TurnoverThreshold }) return levels, nil } func (s *RoomTurnoverRewardSettlementService) ensureRewardRecord( ctx context.Context, configID int64, sysOrigin string, cycleKey string, periodStart time.Time, periodEnd time.Time, contribution integration.RoomContributionActivityCount, profile integration.RoomProfile, level roomTurnoverRewardLevel, now time.Time, ) (model.RoomTurnoverRewardRecord, error) { roomID := int64(contribution.RoomID) recordID, err := util.NextID() if err != nil { return model.RoomTurnoverRewardRecord{}, err } record := model.RoomTurnoverRewardRecord{ ID: recordID, ConfigID: configID, SysOrigin: sysOrigin, CycleKey: cycleKey, PeriodStartAt: periodStart, PeriodEndAt: periodEnd, RoomID: roomID, RoomAccount: profile.RoomAccount, RoomName: profile.RoomName, RoomCover: profile.RoomCover, OwnerUserID: int64(profile.UserID), CountryCode: profile.CountryCode, CountryName: profile.CountryName, TurnoverAmount: int64(contribution.ContributionValue), Level: level.Level, TurnoverThreshold: level.TurnoverThreshold, RewardGold: level.RewardGold, TrackID: roomTurnoverRewardTrackID(sysOrigin, cycleKey, roomID), Status: roomTurnoverRewardStatusPending, CreateTime: now, UpdateTime: now, } if record.OwnerUserID <= 0 { return model.RoomTurnoverRewardRecord{}, fmt.Errorf("room owner missing. roomId=%d", roomID) } if err := s.repo.DB.WithContext(ctx). Clauses(clause.OnConflict{DoNothing: true}). Create(&record).Error; err != nil { return model.RoomTurnoverRewardRecord{}, err } var saved model.RoomTurnoverRewardRecord if err := s.repo.DB.WithContext(ctx). Where("sys_origin = ? AND cycle_key = ? AND room_id = ?", sysOrigin, cycleKey, roomID). First(&saved).Error; err != nil { return model.RoomTurnoverRewardRecord{}, err } return saved, nil } func (s *RoomTurnoverRewardSettlementService) dispatchRewardRecord(ctx context.Context, record *model.RoomTurnoverRewardRecord, sourceID string) error { if record == nil { return nil } now := time.Now() retryCount := record.RetryCount if record.Status == roomTurnoverRewardStatusFailed { retryCount++ } err := s.java.ChangeGoldBalance(ctx, integration.GoldReceiptCommand{ ReceiptType: "INCOME", UserID: record.OwnerUserID, SysOrigin: record.SysOrigin, EventID: record.TrackID, Origin: roomTurnoverRewardGoldOrigin, Remark: fmt.Sprintf("room turnover reward %s room %d", record.CycleKey, record.RoomID), Amount: integration.NewPennyAmountPayloadFromDollar(record.RewardGold), CloseDelayAsset: false, OpUserType: "BACK", }) if err != nil { updates := map[string]any{ "status": roomTurnoverRewardStatusFailed, "retry_count": retryCount, "last_error": truncateString(err.Error(), 1024), "update_time": now, } if updateErr := s.repo.DB.WithContext(context.Background()). Model(&model.RoomTurnoverRewardRecord{}). Where("id = ?", record.ID). Updates(updates).Error; updateErr != nil { return updateErr } return err } if err := s.repo.DB.WithContext(ctx). Model(&model.RoomTurnoverRewardRecord{}). Where("id = ?", record.ID). Updates(map[string]any{ "status": roomTurnoverRewardStatusSuccess, "retry_count": retryCount, "last_error": "", "sent_at": now, "update_time": now, }).Error; err != nil { return err } if strings.TrimSpace(sourceID) == "" { return nil } if marked, err := s.java.MarkRoomTurnoverRewardCoinsSent(ctx, sourceID); err != nil { return err } else if !marked { log.Printf("room turnover reward source was not modified. recordId=%d sourceId=%s", record.ID, sourceID) } return nil } func (s *RoomTurnoverRewardSettlementService) periodBounds(cycleKey string) (time.Time, time.Time, error) { start, err := time.ParseInLocation("20060102", cycleKey, s.location) if err != nil { return time.Time{}, time.Time{}, err } return start, start.AddDate(0, 0, 7), nil } func pickRoomTurnoverRewardLevel(levels []roomTurnoverRewardLevel, turnoverAmount int64) (roomTurnoverRewardLevel, bool) { if turnoverAmount <= 0 { return roomTurnoverRewardLevel{}, false } for _, level := range levels { if turnoverAmount >= level.TurnoverThreshold { return level, true } } return roomTurnoverRewardLevel{}, false } func uniqueRoomTurnoverRewardRoomIDs(contributions []integration.RoomContributionActivityCount) []int64 { result := make([]int64, 0, len(contributions)) seen := make(map[int64]struct{}, len(contributions)) for _, item := range contributions { roomID := int64(item.RoomID) if roomID <= 0 { continue } if _, exists := seen[roomID]; exists { continue } seen[roomID] = struct{}{} result = append(result, roomID) } return result } func normalizeRoomTurnoverRewardSysOrigin(values ...string) string { for _, value := range values { normalized := strings.ToUpper(strings.TrimSpace(value)) if normalized != "" { return normalized } } return "LIKEI" } func roomTurnoverRewardCycleKey(dateNumber int, now time.Time) string { if dateNumber > 0 { return fmt.Sprintf("%08d", dateNumber) } weekday := int(now.Weekday()) if weekday == 0 { weekday = 7 } monday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).AddDate(0, 0, 1-weekday-7) return monday.Format("20060102") } func roomTurnoverRewardTrackID(sysOrigin string, cycleKey string, roomID int64) string { return "room-turnover:" + strings.ToUpper(strings.TrimSpace(sysOrigin)) + ":" + cycleKey + ":" + strconv.FormatInt(roomID, 10) }