yumi-cron/internal/service/recharge_reward_settlement.go
2026-05-21 11:15:04 +08:00

664 lines
20 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 (
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)
}