|
|
@@ -0,0 +1,383 @@
|
|
|
+using Common;
|
|
|
+using Database.Database;
|
|
|
+using Kitty_FallVasWs;
|
|
|
+using Microsoft.Extensions.Configuration;
|
|
|
+using Microsoft.Extensions.DependencyInjection;
|
|
|
+using Microsoft.Extensions.Hosting;
|
|
|
+using Microsoft.EntityFrameworkCore;
|
|
|
+using System.Globalization;
|
|
|
+using System.ServiceModel;
|
|
|
+
|
|
|
+namespace Kitty_Fall.Woker.Services
|
|
|
+{
|
|
|
+ public class GameRewardPayoutWorker : BackgroundService
|
|
|
+ {
|
|
|
+ private const byte StatusNew = 0;
|
|
|
+ private const byte StatusProcessing = 1;
|
|
|
+ private const byte StatusSuccess = 2;
|
|
|
+ private const byte StatusFailed = 3;
|
|
|
+
|
|
|
+ private static readonly log4net.ILog log = log4net.LogManager.GetLogger(typeof(GameRewardPayoutWorker));
|
|
|
+
|
|
|
+ private readonly IServiceScopeFactory scopeFactory;
|
|
|
+ private readonly IConfiguration configuration;
|
|
|
+
|
|
|
+ public GameRewardPayoutWorker(IServiceScopeFactory scopeFactory, IConfiguration configuration)
|
|
|
+ {
|
|
|
+ this.scopeFactory = scopeFactory;
|
|
|
+ this.configuration = configuration;
|
|
|
+ }
|
|
|
+
|
|
|
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
|
+ {
|
|
|
+ var intervalSeconds = GetInt("RewardWorker:IntervalSeconds", 10);
|
|
|
+ log.Info($"GameRewardPayoutWorker started. intervalSeconds={intervalSeconds}");
|
|
|
+
|
|
|
+ while (!stoppingToken.IsCancellationRequested)
|
|
|
+ {
|
|
|
+ try
|
|
|
+ {
|
|
|
+ await ProcessPendingRewardsAsync(stoppingToken);
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ log.Error("GameRewardPayoutWorker loop error", ex);
|
|
|
+ }
|
|
|
+
|
|
|
+ await Task.Delay(TimeSpan.FromSeconds(intervalSeconds), stoppingToken);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task ProcessPendingRewardsAsync(CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ using var scope = scopeFactory.CreateScope();
|
|
|
+ var dbContext = scope.ServiceProvider.GetRequiredService<ModelContext>();
|
|
|
+
|
|
|
+ var batchSize = GetInt("RewardWorker:BatchSize", 20);
|
|
|
+ var maxRetry = GetInt("RewardWorker:MaxRetry", 3);
|
|
|
+ var retryAfterMinutes = GetInt("RewardWorker:RetryAfterMinutes", 1);
|
|
|
+ var retryBefore = DateTime.Now.AddMinutes(-retryAfterMinutes);
|
|
|
+
|
|
|
+ // Lay cac log thuong can tra qua WS. SCORE la diem noi bo nen API da xu ly.
|
|
|
+ var rewardLogs = await dbContext.GameRewardLogs
|
|
|
+ .Where(x => (x.Status == StatusNew || x.Status == StatusFailed)
|
|
|
+ && x.RetryCount < maxRetry
|
|
|
+ && (x.RewardType == "DATA" || x.RewardType == "LOYALTY")
|
|
|
+ && (x.UpdatedTime == null || x.UpdatedTime <= retryBefore))
|
|
|
+ .OrderBy(x => x.CreatedTime)
|
|
|
+ .ThenBy(x => x.RewardLogId)
|
|
|
+ .Take(batchSize)
|
|
|
+ .ToListAsync(cancellationToken);
|
|
|
+
|
|
|
+ foreach (var rewardLog in rewardLogs)
|
|
|
+ {
|
|
|
+ if (cancellationToken.IsCancellationRequested) return;
|
|
|
+ await ProcessRewardLogAsync(dbContext, rewardLog, maxRetry, cancellationToken);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task ProcessRewardLogAsync(
|
|
|
+ ModelContext dbContext,
|
|
|
+ GameRewardLog rewardLog,
|
|
|
+ int maxRetry,
|
|
|
+ CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ var now = DateTime.Now;
|
|
|
+
|
|
|
+ // Khoa mem log de tranh 2 vong worker cung tra mot reward.
|
|
|
+ rewardLog.Status = StatusProcessing;
|
|
|
+ rewardLog.RequestTime = now;
|
|
|
+ rewardLog.UpdatedTime = now;
|
|
|
+ await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
+
|
|
|
+ try
|
|
|
+ {
|
|
|
+ var limitResult = await ApplyLimitBeforePayoutAsync(dbContext, rewardLog, maxRetry, now, cancellationToken);
|
|
|
+ if (!limitResult.CanPayout)
|
|
|
+ {
|
|
|
+ await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!IsExternalReward(rewardLog.RewardType))
|
|
|
+ {
|
|
|
+ // Fallback khong can goi WS thi danh dau xong de khong retry vo nghia.
|
|
|
+ rewardLog.Status = StatusSuccess;
|
|
|
+ rewardLog.ResponseTime = DateTime.Now;
|
|
|
+ rewardLog.ResponseCode = "SUCCESS";
|
|
|
+ rewardLog.ResponseMessage = "fallback reward completed";
|
|
|
+ rewardLog.UpdatedTime = DateTime.Now;
|
|
|
+ await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ var wsResponse = await CallRewardWsAsync(rewardLog);
|
|
|
+ var response = wsResponse.Response;
|
|
|
+ var isSuccess = IsWsSuccess(response);
|
|
|
+
|
|
|
+ rewardLog.ResponseTime = DateTime.Now;
|
|
|
+ rewardLog.ResponseCode = response?.errorCode ?? response?.resultCode ?? (isSuccess ? "0" : "-1");
|
|
|
+ rewardLog.ResponseMessage = Truncate(response?.content ?? response?.status ?? wsResponse.RawMessage, 500);
|
|
|
+ rewardLog.ExternalTransId = Truncate(response?.resultCode ?? rewardLog.ExternalTransId ?? rewardLog.ChildTransId, 100);
|
|
|
+ rewardLog.UpdatedTime = DateTime.Now;
|
|
|
+
|
|
|
+ if (isSuccess)
|
|
|
+ {
|
|
|
+ rewardLog.Status = StatusSuccess;
|
|
|
+ await UpdateLimitUsageAsync(dbContext, rewardLog, now, cancellationToken);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ rewardLog.RetryCount++;
|
|
|
+ rewardLog.Status = rewardLog.RetryCount >= maxRetry ? StatusFailed : StatusNew;
|
|
|
+ }
|
|
|
+
|
|
|
+ await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
+ }
|
|
|
+ catch (Exception ex)
|
|
|
+ {
|
|
|
+ log.Error($"Process reward log error. rewardLogId={rewardLog.RewardLogId}", ex);
|
|
|
+
|
|
|
+ rewardLog.RetryCount++;
|
|
|
+ rewardLog.Status = rewardLog.RetryCount >= maxRetry ? StatusFailed : StatusNew;
|
|
|
+ rewardLog.ResponseTime = DateTime.Now;
|
|
|
+ rewardLog.ResponseCode = "EXCEPTION";
|
|
|
+ rewardLog.ResponseMessage = Truncate(ex.Message, 500);
|
|
|
+ rewardLog.UpdatedTime = DateTime.Now;
|
|
|
+ await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task<LimitCheckResult> ApplyLimitBeforePayoutAsync(
|
|
|
+ ModelContext dbContext,
|
|
|
+ GameRewardLog rewardLog,
|
|
|
+ int maxRetry,
|
|
|
+ DateTime now,
|
|
|
+ CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ // Kiem tra han muc neu DB co cau hinh, khong co cau hinh thi tra thuong binh thuong.
|
|
|
+ var limitConfigs = await dbContext.GameRewardLimitConfigs
|
|
|
+ .Where(x => x.Status != false
|
|
|
+ && x.RewardType == rewardLog.RewardType
|
|
|
+ && x.RewardUnit == rewardLog.RewardUnit
|
|
|
+ && (x.ModeId == null || x.ModeId == rewardLog.ModeId)
|
|
|
+ && (x.LevelNo == null || x.LevelNo == rewardLog.LevelNo)
|
|
|
+ && (x.EffectiveFrom == null || x.EffectiveFrom <= now)
|
|
|
+ && (x.EffectiveTo == null || x.EffectiveTo >= now))
|
|
|
+ .OrderByDescending(x => x.Priority)
|
|
|
+ .ThenBy(x => x.LimitConfigId)
|
|
|
+ .ToListAsync(cancellationToken);
|
|
|
+
|
|
|
+ foreach (var limitConfig in limitConfigs)
|
|
|
+ {
|
|
|
+ var periodKey = GetPeriodKey(limitConfig.PeriodType, now);
|
|
|
+ var usage = await dbContext.GameRewardLimitUsages
|
|
|
+ .FirstOrDefaultAsync(x => x.LimitConfigId == limitConfig.LimitConfigId
|
|
|
+ && x.PeriodKey == periodKey
|
|
|
+ && x.RewardType == rewardLog.RewardType
|
|
|
+ && x.RewardUnit == rewardLog.RewardUnit, cancellationToken);
|
|
|
+
|
|
|
+ var usedValue = usage?.UsedTotalValue ?? 0;
|
|
|
+ var usedCount = usage?.UsedTotalCount ?? 0;
|
|
|
+ var exceedValue = limitConfig.MaxTotalValue != null
|
|
|
+ && usedValue + rewardLog.RewardValue > limitConfig.MaxTotalValue.Value;
|
|
|
+ var exceedCount = limitConfig.MaxTotalCount != null
|
|
|
+ && usedCount + 1 > limitConfig.MaxTotalCount.Value;
|
|
|
+
|
|
|
+ if (!exceedValue && !exceedCount)
|
|
|
+ {
|
|
|
+ rewardLog.LimitConfigId = limitConfig.LimitConfigId;
|
|
|
+ return new LimitCheckResult(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ var action = (limitConfig.ExceedAction ?? "").Trim().ToUpperInvariant();
|
|
|
+ if (action == "FALLBACK"
|
|
|
+ && !string.IsNullOrWhiteSpace(limitConfig.FallbackRewardType)
|
|
|
+ && limitConfig.FallbackRewardValue.GetValueOrDefault() > 0
|
|
|
+ && !string.IsNullOrWhiteSpace(limitConfig.FallbackRewardUnit))
|
|
|
+ {
|
|
|
+ // Doi sang reward fallback theo cau hinh han muc.
|
|
|
+ rewardLog.LimitConfigId = limitConfig.LimitConfigId;
|
|
|
+ rewardLog.RewardType = limitConfig.FallbackRewardType!;
|
|
|
+ rewardLog.RewardValue = limitConfig.FallbackRewardValue!.Value;
|
|
|
+ rewardLog.RewardUnit = limitConfig.FallbackRewardUnit!;
|
|
|
+ return new LimitCheckResult(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Vuot han muc va khong co fallback thi danh dau failed, khong retry lap lai.
|
|
|
+ rewardLog.LimitConfigId = limitConfig.LimitConfigId;
|
|
|
+ rewardLog.Status = StatusFailed;
|
|
|
+ rewardLog.RetryCount = (short)maxRetry;
|
|
|
+ rewardLog.ResponseTime = now;
|
|
|
+ rewardLog.ResponseCode = "LIMIT_EXCEEDED";
|
|
|
+ rewardLog.ResponseMessage = $"Reward limit exceeded: {limitConfig.LimitCode}";
|
|
|
+ rewardLog.UpdatedTime = now;
|
|
|
+ return new LimitCheckResult(false);
|
|
|
+ }
|
|
|
+
|
|
|
+ return new LimitCheckResult(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task UpdateLimitUsageAsync(
|
|
|
+ ModelContext dbContext,
|
|
|
+ GameRewardLog rewardLog,
|
|
|
+ DateTime now,
|
|
|
+ CancellationToken cancellationToken)
|
|
|
+ {
|
|
|
+ if (rewardLog.LimitConfigId == null) return;
|
|
|
+
|
|
|
+ var limitConfig = await dbContext.GameRewardLimitConfigs
|
|
|
+ .AsNoTracking()
|
|
|
+ .FirstOrDefaultAsync(x => x.LimitConfigId == rewardLog.LimitConfigId.Value, cancellationToken);
|
|
|
+ if (limitConfig == null) return;
|
|
|
+
|
|
|
+ var periodKey = GetPeriodKey(limitConfig.PeriodType, now);
|
|
|
+ var usage = await dbContext.GameRewardLimitUsages
|
|
|
+ .FirstOrDefaultAsync(x => x.LimitConfigId == rewardLog.LimitConfigId.Value
|
|
|
+ && x.PeriodKey == periodKey
|
|
|
+ && x.RewardType == rewardLog.RewardType
|
|
|
+ && x.RewardUnit == rewardLog.RewardUnit, cancellationToken);
|
|
|
+
|
|
|
+ if (usage == null)
|
|
|
+ {
|
|
|
+ usage = new GameRewardLimitUsage
|
|
|
+ {
|
|
|
+ LimitUsageId = await NextDecimalIdAsync(dbContext, "SEQ_GAME_REWARD_LIMIT_USAGE"),
|
|
|
+ LimitConfigId = rewardLog.LimitConfigId.Value,
|
|
|
+ PeriodType = limitConfig.PeriodType,
|
|
|
+ PeriodKey = periodKey,
|
|
|
+ RewardType = rewardLog.RewardType,
|
|
|
+ RewardUnit = rewardLog.RewardUnit,
|
|
|
+ UsedTotalValue = 0,
|
|
|
+ UsedTotalCount = 0,
|
|
|
+ CreatedTime = now,
|
|
|
+ LastUpdateTime = now
|
|
|
+ };
|
|
|
+ dbContext.GameRewardLimitUsages.Add(usage);
|
|
|
+ }
|
|
|
+
|
|
|
+ usage.UsedTotalValue += rewardLog.RewardValue;
|
|
|
+ usage.UsedTotalCount += 1;
|
|
|
+ usage.LastRewardLogId = rewardLog.RewardLogId;
|
|
|
+ usage.LastUpdateTime = now;
|
|
|
+ usage.UpdatedTime = now;
|
|
|
+ }
|
|
|
+
|
|
|
+ private async Task<RewardWsResult> CallRewardWsAsync(GameRewardLog rewardLog)
|
|
|
+ {
|
|
|
+ var wsUrl = GetString("wsUrl");
|
|
|
+ using var wsClient = new KittyFallWsClient();
|
|
|
+ if (!string.IsNullOrWhiteSpace(wsUrl))
|
|
|
+ {
|
|
|
+ wsClient.Endpoint.Address = new EndpointAddress(wsUrl);
|
|
|
+ }
|
|
|
+
|
|
|
+ var wsUser = GetString("wsUser");
|
|
|
+ var wsPass = GetString("wsPass");
|
|
|
+ var serviceId = GetString("RewardWorker:ServiceId", GetString("RewardWorker:DefaultServiceId", "KittyFall"));
|
|
|
+ var packageName = GetPackageName(rewardLog);
|
|
|
+ var valueText = FormatRewardValue(rewardLog.RewardValue);
|
|
|
+
|
|
|
+ // Goi dung WS theo loai thuong can cong cho user.
|
|
|
+ if (string.Equals(rewardLog.RewardType, "DATA", StringComparison.OrdinalIgnoreCase))
|
|
|
+ {
|
|
|
+ var response = await wsClient.wsAddDataAsync(
|
|
|
+ wsUser,
|
|
|
+ wsPass,
|
|
|
+ rewardLog.Msisdn,
|
|
|
+ packageName,
|
|
|
+ valueText,
|
|
|
+ serviceId);
|
|
|
+
|
|
|
+ return new RewardWsResult(response.@return, $"wsAddData rewardLogId={rewardLog.RewardLogId}");
|
|
|
+ }
|
|
|
+
|
|
|
+ if (string.Equals(rewardLog.RewardType, "LOYALTY", StringComparison.OrdinalIgnoreCase))
|
|
|
+ {
|
|
|
+ var response = await wsClient.wsAddPointAsync(
|
|
|
+ wsUser,
|
|
|
+ wsPass,
|
|
|
+ rewardLog.Msisdn,
|
|
|
+ packageName,
|
|
|
+ valueText,
|
|
|
+ serviceId);
|
|
|
+
|
|
|
+ return new RewardWsResult(response.@return, $"wsAddPoint rewardLogId={rewardLog.RewardLogId}");
|
|
|
+ }
|
|
|
+
|
|
|
+ return new RewardWsResult(null, $"Unsupported reward type {rewardLog.RewardType}");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static bool IsExternalReward(string? rewardType)
|
|
|
+ {
|
|
|
+ return string.Equals(rewardType, "DATA", StringComparison.OrdinalIgnoreCase)
|
|
|
+ || string.Equals(rewardType, "LOYALTY", StringComparison.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ private string GetPackageName(GameRewardLog rewardLog)
|
|
|
+ {
|
|
|
+ if (string.Equals(rewardLog.RewardType, "DATA", StringComparison.OrdinalIgnoreCase))
|
|
|
+ {
|
|
|
+ return GetString("RewardWorker:DataPackageName", $"GAME_DATA_{FormatRewardValue(rewardLog.RewardValue)}{rewardLog.RewardUnit}");
|
|
|
+ }
|
|
|
+
|
|
|
+ if (string.Equals(rewardLog.RewardType, "LOYALTY", StringComparison.OrdinalIgnoreCase))
|
|
|
+ {
|
|
|
+ return GetString("RewardWorker:LoyaltyPackageName", "GAME_LOYALTY");
|
|
|
+ }
|
|
|
+
|
|
|
+ return GetString("RewardWorker:DefaultPackageName", "GAME_REWARD");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static bool IsWsSuccess(response? response)
|
|
|
+ {
|
|
|
+ return response?.errorCode == CommonErrorCode.Success
|
|
|
+ || response?.errorCode == CommonErrorCode.SuccessFirstRegister
|
|
|
+ || response?.resultCode == CommonErrorCode.Success
|
|
|
+ || string.Equals(response?.status, "SUCCESS", StringComparison.OrdinalIgnoreCase)
|
|
|
+ || string.Equals(response?.status, "TRUE", StringComparison.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static int GetPeriodKey(string? periodType, DateTime now)
|
|
|
+ {
|
|
|
+ return string.Equals(periodType, "MONTHLY", StringComparison.OrdinalIgnoreCase)
|
|
|
+ ? now.Year * 100 + now.Month
|
|
|
+ : now.Year * 10000 + now.Month * 100 + now.Day;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string FormatRewardValue(decimal value)
|
|
|
+ {
|
|
|
+ return value % 1 == 0
|
|
|
+ ? value.ToString("0", CultureInfo.InvariantCulture)
|
|
|
+ : value.ToString("0.##", CultureInfo.InvariantCulture);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static async Task<decimal> NextDecimalIdAsync(ModelContext dbContext, string sequence)
|
|
|
+ {
|
|
|
+ return await Database.DbLogic.GenIdAsync(dbContext, sequence)
|
|
|
+ ?? DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
|
+ }
|
|
|
+
|
|
|
+ private int GetInt(string key, int defaultValue)
|
|
|
+ {
|
|
|
+ var value = configuration[key];
|
|
|
+ return int.TryParse(value, out var result) && result > 0 ? result : defaultValue;
|
|
|
+ }
|
|
|
+
|
|
|
+ private string GetString(string key, string defaultValue = "")
|
|
|
+ {
|
|
|
+ var value = configuration[key];
|
|
|
+ return string.IsNullOrWhiteSpace(value) ? defaultValue : value;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string? Truncate(string? value, int maxLength)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(value)) return value;
|
|
|
+ return value.Length <= maxLength ? value : value[..maxLength];
|
|
|
+ }
|
|
|
+
|
|
|
+ private readonly record struct LimitCheckResult(bool CanPayout);
|
|
|
+
|
|
|
+ private readonly record struct RewardWsResult(response? Response, string RawMessage);
|
|
|
+ }
|
|
|
+}
|