|
|
@@ -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;
|