424 lines
12 KiB
Go
424 lines
12 KiB
Go
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)
|
|
}
|