ソースを参照

chặn chặt chẻ hơn về chi phí trả thưởng tối đa 10% doanh thu hôm trước và min trần là 200k mmk

student 3 週間 前
親
コミット
ceb08aacc6

+ 191 - 0
Kitty_Fall/Kitty_Fall/Kitty_Fall.Apis/Business/Game/GameBusinessImpl.cs

@@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Mvc;
 using Microsoft.EntityFrameworkCore;
 using Microsoft.EntityFrameworkCore.Storage;
 using Newtonsoft.Json;
+using Oracle.ManagedDataAccess.Client;
 using System.Globalization;
 
 namespace Kitty_Fall.Apis.Business.Game
@@ -942,6 +943,19 @@ namespace Kitty_Fall.Apis.Business.Game
 
                     log.Info($"Milestone reward budget checked. date={now:yyyy-MM-dd}, budgetMmk={rewardBudget}, usedMmk={usedRewardBudget}, eligibleOptions={rewardOptions.Count}");
                     rewardOption = SelectRewardOption(rewardOptions);
+
+                    // Kiem tra tren chi dung voi tung request; nhieu nguoi qua level cung luc co the
+                    // cung thay "con du". Giu cho ngan sach nguyen tu truoc khi hua DATA/LOYALTY,
+                    // khong giu duoc thi chon lai trong cac lua chon khong ton ngan sach (SCORE).
+                    if (rewardOption != null
+                        && IsExternalReward(rewardOption.RewardType)
+                        && !await TryReserveDailyRewardBudgetAsync(rewardOption, rewardBudget, usedRewardBudget, now))
+                    {
+                        var fallbackOptions = rewardOptions.Where(x => !IsExternalReward(x.RewardType)).ToList();
+                        log.Warn($"Milestone reward budget reservation failed, fallback to non-external reward. msisdn={msisdn}, transId={request.transId}, level={levelNo.Value}, optionId={rewardOption.RewardOptionId}, rewardType={rewardOption.RewardType}, rewardValue={rewardOption.RewardValue}, budgetMmk={rewardBudget}, fallbackOptions={fallbackOptions.Count}");
+                        rewardOption = SelectRewardOption(fallbackOptions);
+                    }
+
                     if (rewardOption != null)
                     {
                         log.Info($"Milestone reward selected. msisdn={msisdn}, transId={request.transId}, modeId={mode.ModeId}, level={levelNo.Value}, optionId={rewardOption.RewardOptionId}, rewardType={rewardOption.RewardType}, rewardValue={rewardOption.RewardValue}, rewardUnit={rewardOption.RewardUnit}");
@@ -1087,6 +1101,8 @@ namespace Kitty_Fall.Apis.Business.Game
                 await UpsertMonthlyRankingAsync(msisdn, account?.Id, mode.ModeId, awardedScore + rewardScoreDelta, session.MaxLevelReached, now);
                 await dbContext.SaveChangesAsync();
                 await rewardTransaction.CommitAsync();
+                // Thuong da luu cung ket qua level: phan ngan sach da giu la chinh thuc.
+                pendingBudgetReservation = null;
 
                 // Response cho popup LEVEL COMPLETE chi tra thuong cua level vua
                 // hoan thanh. Tong diem van duoc luu trong session/ranking va chi
@@ -1112,6 +1128,8 @@ namespace Kitty_Fall.Apis.Business.Game
             catch (Exception exception)
             {
                 log.Error("UserSaveLevelResult Exception: ", exception);
+                // Luu ket qua loi sau khi da giu ngan sach: tra lai phan da giu.
+                await ReleasePendingBudgetReservationAsync();
             }
 
             return CommonLogic.BuildResponse(url, json, CommonErrorCode.Error, Lang.errorOccurred, new { });
@@ -1254,6 +1272,179 @@ namespace Kitty_Fall.Apis.Business.Game
                 && DateTime.UtcNow.Ticks < Volatile.Read(ref cachedRewardBudgetExpiresTicks);
         }
 
+        // Giu cho ngan sach DATA/LOYALTY trong ngay bang 1 lenh UPDATE co dieu kien tren dong
+        // GAME_REWARD_LIMIT_USAGE cua ngay (gan voi cau hinh RewardBudget:LimitCode, mac dinh
+        // DAILY_MONEY_MMK_ALL). Lenh chay tren ket noi rieng va tu commit ngay, nen dong usage chi bi
+        // khoa trong thoi gian 1 lenh (vai ms), khong bi khoa suot qua trinh luu ket qua level.
+        // Kiem tra va cong nam trong cung 1 lenh nen nhieu request dong thoi khong the cung vuot tran.
+        // Luu ket qua loi sau khi da giu thi ReleasePendingBudgetReservationAsync tra lai.
+        // Tran van la rewardBudget (10% doanh thu / REVENUE_MIN).
+        // Khong co dong cau hinh hoac dong dang tat (STATUS = 0) thi bo qua, giu nguyen cach cu.
+        private async Task<bool> TryReserveDailyRewardBudgetAsync(
+            GameLevelRewardOption option, decimal rewardBudget, decimal usedRewardBudget, DateTime now)
+        {
+            try
+            {
+                return await ReserveDailyRewardBudgetCoreAsync(option, rewardBudget, usedRewardBudget, now);
+            }
+            catch (Exception ex)
+            {
+                // Loi ky thuat khi giu cho (mat ket noi, DB cham...): khong hua DATA/LOYALTY ma cho SCORE,
+                // de khong vuot tran va khong lam hong viec luu ket qua level cua nguoi choi.
+                log.Error($"Reward budget reservation error, fallback to non-external reward. rewardType={option.RewardType}, rewardValue={option.RewardValue}", ex);
+                return false;
+            }
+        }
+
+        private async Task<bool> ReserveDailyRewardBudgetCoreAsync(
+            GameLevelRewardOption option, decimal rewardBudget, decimal usedRewardBudget, DateTime now)
+        {
+            var limitConfigId = await GetRewardBudgetLimitConfigIdAsync();
+            if (limitConfigId == null) return true;
+
+            // Cot USED_TOTAL_VALUE luu 2 so le: lam tron len de khong bao gio giu thieu.
+            var cost = Math.Ceiling(ConvertRewardToMmk(option.RewardType, option.RewardValue) * 100m) / 100m;
+            var periodKey = ToDateKey(now);
+
+            const string reserveSql = @"
+                UPDATE GAME_REWARD_LIMIT_USAGE
+                SET USED_TOTAL_VALUE = USED_TOTAL_VALUE + :cost,
+                    USED_TOTAL_COUNT = USED_TOTAL_COUNT + 1,
+                    LAST_UPDATE_TIME = :now,
+                    UPDATED_TIME = :now
+                WHERE LIMIT_CONFIG_ID = :limitConfigId
+                  AND PERIOD_KEY = :periodKey
+                  AND USED_TOTAL_VALUE + :cost <= :budget";
+            var reserveParameters = new Dictionary<string, object>
+            {
+                ["cost"] = cost,
+                ["now"] = now,
+                ["limitConfigId"] = limitConfigId.Value,
+                ["periodKey"] = periodKey,
+                ["budget"] = rewardBudget
+            };
+
+            var reserved = await ExecuteBudgetCommandAsync(reserveSql, reserveParameters) == 1;
+            if (!reserved)
+            {
+                // Dong usage cua ngay chua co (lan dau trong ngay / vua trien khai): tao voi tong da hua
+                // hien tai tinh tu GAME_LEVEL_ATTEMPT nhu cach cu, roi giu cho lai.
+                try
+                {
+                    await ExecuteBudgetCommandAsync(@"
+                        INSERT INTO GAME_REWARD_LIMIT_USAGE
+                            (LIMIT_USAGE_ID, LIMIT_CONFIG_ID, PERIOD_TYPE, PERIOD_KEY, REWARD_TYPE, REWARD_UNIT,
+                             USED_TOTAL_VALUE, USED_TOTAL_COUNT, LAST_UPDATE_TIME, CREATED_TIME)
+                        SELECT SEQ_GAME_REWARD_LIMIT_USAGE.NEXTVAL, :limitConfigId, 'DAILY', :periodKey, 'MONEY', 'MMK',
+                               :used, 0, :now, :now
+                        FROM DUAL
+                        WHERE NOT EXISTS (
+                            SELECT 1 FROM GAME_REWARD_LIMIT_USAGE
+                            WHERE LIMIT_CONFIG_ID = :limitConfigId AND PERIOD_KEY = :periodKey)",
+                        new Dictionary<string, object>
+                        {
+                            ["limitConfigId"] = limitConfigId.Value,
+                            ["periodKey"] = periodKey,
+                            // Lam tron len nhu cost: cot 2 so le khong duoc lam tron xuong.
+                            ["used"] = Math.Ceiling(Math.Max(0, usedRewardBudget) * 100m) / 100m,
+                            ["now"] = now
+                        });
+                }
+                catch (OracleException ex) when (ex.Number == 1)
+                {
+                    // Request khac vua tao dong cua ngay (UK_GRLU_LIMIT_PERIOD): dung dong do.
+                }
+
+                reserved = await ExecuteBudgetCommandAsync(reserveSql, reserveParameters) == 1;
+            }
+
+            if (reserved)
+            {
+                pendingBudgetReservation = new BudgetReservation(limitConfigId.Value, periodKey, cost);
+            }
+            return reserved;
+        }
+
+        private sealed record BudgetReservation(decimal LimitConfigId, int PeriodKey, decimal CostMmk);
+
+        // Phan ngan sach da giu cho request nay ma ket qua level chua commit (moi request 1 instance).
+        private BudgetReservation? pendingBudgetReservation;
+
+        private async Task ReleasePendingBudgetReservationAsync()
+        {
+            var reservation = pendingBudgetReservation;
+            if (reservation == null) return;
+            pendingBudgetReservation = null;
+
+            try
+            {
+                await ExecuteBudgetCommandAsync(@"
+                    UPDATE GAME_REWARD_LIMIT_USAGE
+                    SET USED_TOTAL_VALUE = GREATEST(USED_TOTAL_VALUE - :cost, 0),
+                        USED_TOTAL_COUNT = GREATEST(USED_TOTAL_COUNT - 1, 0),
+                        UPDATED_TIME = SYSDATE
+                    WHERE LIMIT_CONFIG_ID = :limitConfigId AND PERIOD_KEY = :periodKey",
+                    new Dictionary<string, object>
+                    {
+                        ["cost"] = reservation.CostMmk,
+                        ["limitConfigId"] = reservation.LimitConfigId,
+                        ["periodKey"] = reservation.PeriodKey
+                    });
+                log.Info($"Reward budget reservation released. periodKey={reservation.PeriodKey}, costMmk={reservation.CostMmk}");
+            }
+            catch (Exception ex)
+            {
+                // Khong tra lai duoc thi ngay do chi tra it hon tran mot chut (lech ve phia an toan).
+                log.Error($"Reward budget reservation release failed. periodKey={reservation.PeriodKey}, costMmk={reservation.CostMmk}", ex);
+            }
+        }
+
+        // Chay 1 lenh tren ket noi rieng (cung pool voi EF), khong thuoc transaction cua request,
+        // ODP.NET tu commit ngay khi lenh xong.
+        private async Task<int> ExecuteBudgetCommandAsync(string sql, Dictionary<string, object> parameters)
+        {
+            await using var connection = new OracleConnection(dbContext.Database.GetConnectionString());
+            connection.UseHourOffsetForUnsupportedTimezone = true;
+            await connection.OpenAsync();
+
+            await using var command = connection.CreateCommand();
+            command.BindByName = true;
+            command.CommandText = sql;
+            foreach (var parameter in parameters)
+            {
+                command.Parameters.Add(new OracleParameter(parameter.Key, parameter.Value));
+            }
+
+            return await command.ExecuteNonQueryAsync();
+        }
+
+        private sealed record RewardBudgetLimitConfigCache(decimal? LimitConfigId, DateTime ExpiresUtc);
+
+        private static volatile RewardBudgetLimitConfigCache? rewardBudgetLimitConfigCache;
+
+        private async Task<decimal?> GetRewardBudgetLimitConfigIdAsync()
+        {
+            var cache = rewardBudgetLimitConfigCache;
+            if (cache != null && DateTime.UtcNow < cache.ExpiresUtc) return cache.LimitConfigId;
+
+            var limitCode = GetParameter("RewardBudget:LimitCode");
+            if (string.IsNullOrWhiteSpace(limitCode)) limitCode = "DAILY_MONEY_MMK_ALL";
+
+            var limitConfigId = await dbContext.GameRewardLimitConfigs.AsNoTracking()
+                .Where(x => x.LimitCode == limitCode && x.Status != false)
+                .Select(x => (decimal?)x.LimitConfigId)
+                .FirstOrDefaultAsync();
+
+            if (limitConfigId == null)
+            {
+                log.Warn($"Reward budget limit config not found or disabled; budget reservation skipped. limitCode={limitCode}");
+            }
+
+            // Doc lai sau 5 phut de bat/tat bang STATUS trong DB khong can restart.
+            rewardBudgetLimitConfigCache = new RewardBudgetLimitConfigCache(limitConfigId, DateTime.UtcNow.AddMinutes(5));
+            return limitConfigId;
+        }
+
         private async Task<decimal> GetDailyExternalRewardCostAsync(DateTime now)
         {
             var start = now.Date;

+ 42 - 16
Kitty_Fall/Kitty_Fall/Kitty_Fall.Woker/Services/GameRewardPayoutWorker.cs

@@ -120,23 +120,31 @@ namespace Kitty_Fall.Woker.Services
                 // chi chan khi vuot ngan sach + bien an toan (vd 2 request dong thoi cung qua kiem tra).
                 // Bi chan o day nghia la cau hinh/logic API lech, can kiem tra.
                 var rewardDate = rewardLog.CreatedTime.Date;
-                var rewardBudget = await GetDailyExternalRewardBudgetAsync(dbContext, rewardDate, cancellationToken);
-                var promisedRewardCost = await GetDailyPromisedExternalRewardCostAsync(dbContext, rewardDate, cancellationToken);
-                var currentRewardCost = ConvertRewardToMmk(rewardLog.RewardType, rewardLog.RewardValue);
-                var overrunPercent = Math.Max(0, GetInt("RewardWorker:BudgetOverrunPercent", 10));
-                var hardLimit = rewardBudget * (100 + overrunPercent) / 100m;
-                if (promisedRewardCost > hardLimit)
+
+                // Ngay nay API da giu cho ngan sach nguyen tu qua GAME_REWARD_LIMIT_USAGE (khong vuot tran):
+                // tra dung nhung gi API da hua, khong tu tinh lai de tranh lech so voi API
+                // (vd API va worker doc REPORT_BY_TIME o hai thoi diem khac nhau).
+                var reservedByApi = await IsBudgetReservedByApiAsync(dbContext, rewardDate, cancellationToken);
+                if (!reservedByApi)
                 {
-                    rewardLog.Status = StatusFailed;
-                    rewardLog.ResponseTime = now;
-                    rewardLog.ResponseCode = "DAILY_REWARD_BUDGET_EXCEEDED";
-                    rewardLog.ResponseMessage = Truncate(
-                        $"Daily reward budget exceeded. date={rewardDate:yyyy-MM-dd}, budgetMmk={rewardBudget}, hardLimitMmk={hardLimit}, promisedMmk={promisedRewardCost}, rewardMmk={currentRewardCost}",
-                        500);
-                    rewardLog.UpdatedTime = now;
-                    await dbContext.SaveChangesAsync(cancellationToken);
-                    log.Error($"Reward payout blocked by daily budget although API promised it. rewardLogId={rewardLog.RewardLogId}, date={rewardDate:yyyy-MM-dd}, budgetMmk={rewardBudget}, hardLimitMmk={hardLimit}, promisedMmk={promisedRewardCost}, rewardMmk={currentRewardCost}");
-                    return;
+                    var rewardBudget = await GetDailyExternalRewardBudgetAsync(dbContext, rewardDate, cancellationToken);
+                    var promisedRewardCost = await GetDailyPromisedExternalRewardCostAsync(dbContext, rewardDate, cancellationToken);
+                    var currentRewardCost = ConvertRewardToMmk(rewardLog.RewardType, rewardLog.RewardValue);
+                    var overrunPercent = Math.Max(0, GetInt("RewardWorker:BudgetOverrunPercent", 10));
+                    var hardLimit = rewardBudget * (100 + overrunPercent) / 100m;
+                    if (promisedRewardCost > hardLimit)
+                    {
+                        rewardLog.Status = StatusFailed;
+                        rewardLog.ResponseTime = now;
+                        rewardLog.ResponseCode = "DAILY_REWARD_BUDGET_EXCEEDED";
+                        rewardLog.ResponseMessage = Truncate(
+                            $"Daily reward budget exceeded. date={rewardDate:yyyy-MM-dd}, budgetMmk={rewardBudget}, hardLimitMmk={hardLimit}, promisedMmk={promisedRewardCost}, rewardMmk={currentRewardCost}",
+                            500);
+                        rewardLog.UpdatedTime = now;
+                        await dbContext.SaveChangesAsync(cancellationToken);
+                        log.Error($"Reward payout blocked by daily budget although API promised it. rewardLogId={rewardLog.RewardLogId}, date={rewardDate:yyyy-MM-dd}, budgetMmk={rewardBudget}, hardLimitMmk={hardLimit}, promisedMmk={promisedRewardCost}, rewardMmk={currentRewardCost}");
+                        return;
+                    }
                 }
 
                 var payoutPackageName = GetPackageName(rewardLog);
@@ -488,6 +496,24 @@ namespace Kitty_Fall.Woker.Services
                 && DateTime.UtcNow.Ticks < Volatile.Read(ref cachedRewardBudgetExpiresTicks);
         }
 
+        // API co giu cho ngan sach cho ngay nay khong: co dong GAME_REWARD_LIMIT_USAGE cua ngay, gan voi
+        // cau hinh RewardBudget:LimitCode (mac dinh DAILY_MONEY_MMK_ALL) dang bat. Cau hinh tat thi
+        // worker quay ve tu kiem tra ngan sach nhu truoc.
+        private async Task<bool> IsBudgetReservedByApiAsync(
+            ModelContext dbContext,
+            DateTime day,
+            CancellationToken cancellationToken)
+        {
+            var limitCode = configuration["RewardBudget:LimitCode"];
+            if (string.IsNullOrWhiteSpace(limitCode)) limitCode = "DAILY_MONEY_MMK_ALL";
+            var periodKey = day.Year * 10000 + day.Month * 100 + day.Day;
+
+            return await dbContext.GameRewardLimitUsages.AsNoTracking()
+                .AnyAsync(x => x.PeriodKey == periodKey
+                    && x.LimitConfig.LimitCode == limitCode
+                    && x.LimitConfig.Status != false, cancellationToken);
+        }
+
         // Tong thuong DATA/LOYALTY da hua trong ngay, DUNG cong thuc API dung khi chon thuong
         // (GameBusinessImpl.GetDailyExternalRewardCostAsync) de hai ben khong lech nhau.
         private static async Task<decimal> GetDailyPromisedExternalRewardCostAsync(