Responsible Gambling 後端防護設計:Self Exclusion / Time Out 不是前端按鈕

前言

Responsible Gambling 功能看起來像會員中心裡的一個設定頁:

  • 使用者選擇 Self Exclusion
  • 使用者選擇 Time Out
  • 系統顯示限制期間

但從後端角度看,這不是一般的 profile setting。

它牽涉到:

  • 帳號狀態更新
  • Session cache 更新
  • 其他平台踢出
  • 頁面權限限制
  • 通知會員與 risk team
  • Product / Deposit / Promotion 類頁面的存取限制

因此 Responsible Gambling 不能只靠前端 UI 隱藏,必須由後端狀態與權限管線共同保護。

相關檔案

1
2
3
4
5
6
src/AgileBet.Cash.Portal.BLL/ResponsibleGamingManager.cs
src/AgileBet.Cash.Portal.Web/BLL/WebAccountManager.cs
src/AgileBet.Cash.Portal.BLL/AccountManager.cs
src/AgileBet.Cash.Portal.Web/MvcActionFilter/ResponsibleGamblingCheckerActionFilter.cs
src/AgileBet.Cash.Portal.Web/Utilities/UserPagePermissionChecker.cs
src/AgileBet.Cash.Portal.Model/MyAccount/ResponsibleGamblingSetting.cs

兩種核心狀態

Model 層有共同 base:

1
2
3
4
5
6
7
8
public interface IResponsibleGamblingBase
{
double ExpiredDate { get; }
int Period { get; set; }
double AppliedDate { get; }
DateTime ExpiredDateTime { get; set; }
DateTime AppliedDateTime { get; set; }
}

Time Out:

1
2
3
4
5
public class TimeOutSetting : ResponsibleGamblingBase
{
[DataMember(Name = "method")]
public RemovalMethod TimeOutMethod { get; set; }
}

Self Exclusion:

1
2
3
4
5
6
7
8
9
public class SelfExclusionSetting : ResponsibleGamblingBase
{
[DataMember(Name = "endDate")]
public double RemoveEndDate =>
this.SelfExclusionEndDateTime.ToJsMilliseconds();

[IgnoreDataMember]
public DateTime SelfExclusionEndDateTime { get; set; }
}

兩者都包含三個核心欄位:

  • applied date
  • expired date
  • period

Self Exclusion 則額外保留 remove request date,用來處理解除申請與到期判斷。

從 DTO 轉成前端設定

ResponsibleGamingManager 會根據 UserDtoUserRestrictionDto 組出設定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public static SelfExclusionSetting GetSelfExclusionSettingByDto(
UserDto userDto,
UserRestrictionDto userRestrictionDto)
{
var setting = new SelfExclusionSetting();
if (userDto.SelfExclusion || userDto.PermanentSelfExclusion)
{
setting.AppliedDateTime =
userRestrictionDto.MEMBERSELFEXCLUSIONLASTDATE;

setting.ExpiredDateTime = userDto.PermanentSelfExclusion
? new DateTime(9999, 12, 30)
: userRestrictionDto.UpliftDate;

setting.Period = userDto.PermanentSelfExclusion
? (int)SelfExclusionStatus.Permanent
: userRestrictionDto.SelfExcludeByMonth;

setting.SelfExclusionEndDateTime =
userRestrictionDto.SelfExclusionRemoveRequestDate != null
? userRestrictionDto.UpliftDate
: DateTime.MinValue;
}
return setting;
}

永久 Self Exclusion 使用 9999-12-30 表示沒有正常 expiry。

Time Out:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static TimeOutSetting GetTimeOutSettingByDto(
UserDto userDto,
UserRestrictionDto userRestrictionDto)
{
var setting = new TimeOutSetting();
if (userDto.Timeout)
{
setting.AppliedDateTime =
userRestrictionDto.SEVENDAYCOOLINGPERIODLASTDATE;
setting.ExpiredDateTime =
userRestrictionDto.TimeoutUpliftedDate ?? DateTime.MinValue;
setting.Period = userRestrictionDto.SelfExcludeByMonth;
setting.TimeOutMethod =
(RemovalMethod)Enum.Parse(typeof(RemovalMethod),
userRestrictionDto.RemovalMethod.ToString());
}
return setting;
}

SetSelfExclusion 流程

WebAccountManager.SetSelfExclusion 做了很多事,不只是呼叫 API。

第一步,如果目前已在 exclusion 且尚未過期,直接擋掉:

1
2
3
4
5
6
7
8
9
if (trading.SelfExclusion &&
DateTime.Now.ToJsMilliseconds() <
trading.ExclusionExpiredDate.ToJsMilliseconds())
{
internalResponse.ReturnCode =
ResponsibleGamblingEnum.UnderExclusionOrTimeout;
result.Response = (int)internalResponse.ReturnCode;
return result;
}

第二步,呼叫 Customer SPI:

1
2
3
4
5
6
7
8
9
10
11
var model = new SelfExclusionInternalRequestModel()
{
IpAddress = trading.ClientIP,
Period = (int)setting.SelfExclusionPeriod,
AccountId = trading.AccountID,
};

internalResponse = AccountManager.UpdateSelfExclusion(
model,
trading.MemberCode,
trading.AppId);

第三步,成功後更新目前 session 的 trading 狀態:

1
2
3
4
5
6
trading.ExclusionExpiredDate = internalResponse.ResponseData.UpliftedDate;
trading.ExclusionAppliedDate = internalResponse.ResponseData.AppliedDate;
trading.ExclusionPeriod = (int)setting.SelfExclusionPeriod;
trading.SelfExclusion = true;
trading.IsExcluded = true;
trading.SessionUpdateTime = DateTime.MinValue;

第四步,更新 Redis TradingSession cache:

1
2
3
4
CacheLayerManager.UpdateData<TradingSessionCache>(
CacheName.TradingSession,
trading.AccountID.ToString(),
trading);

第五步,寄信給會員與 risk team:

1
2
3
4
5
6
7
8
9
10
11
MsgHubManager.SendSelfExcludeMessage(
exclusionStatus,
trading,
profile,
trading.ExclusionAppliedDate,
trading.ExclusionExpiredDate);

MsgHubManager.SendSelfExcludeRiskMessage(
setting.EnglishPeriodMessage,
trading,
profile);

SetTimeOut 流程

Time Out 類似,但狀態欄位不同:

1
2
3
4
5
6
7
8
9
10
11
12
var model = new TimeOutInternalRequestModel()
{
IpAddress = trading.ClientIP,
Period = (int)setting.TimeOutPeriod,
AccountId = trading.AccountID,
RemovalMethod = (int)RemovalMethod.Auto
};

result = AccountManager.UpdateTimeOut(
model,
trading.MemberCode,
trading.AppId);

成功後:

1
2
3
4
5
6
7
8
9
10
11
12
trading.TimeOutMethod = RemovalMethod.Auto;
trading.IsTimeOut = true;
trading.IsExcluded = true;
trading.ExclusionAppliedDate = result.ResponseData.AppliedDate;
trading.ExclusionExpiredDate = result.ResponseData.UpliftedDate;
trading.SessionUpdateTime = DateTime.MinValue;
trading.ExclusionPeriod = (int)setting.TimeOutPeriod;

CacheLayerManager.UpdateData<TradingSessionCache>(
CacheName.TradingSession,
trading.AccountID.ToString(),
trading);

通知:

1
2
3
4
5
6
7
8
9
10
MsgHubManager.SendTimeoutMessage(
timeOutInDay,
setting.TimeOutPeriod,
trading,
profile);

MsgHubManager.SendTimeoutRiskMessage(
setting.EnglishPeriodMessage,
trading,
profile);

Customer SPI 更新

AccountManager.UpdateSelfExclusion 會呼叫 customerSpi

1
2
3
4
5
6
7
8
9
10
var updateSelfExclusionUrl = customerSpi.url +
customerSpi.AppSettings
?.FirstOrDefault(appSetting =>
appSetting.Key == "SetSelfExclusion").Value;

result = RequestUtil.SendRequest<SelfExclusionInternalResponseModel>(
updateSelfExclusionUrl,
"POST",
JsonConvert.SerializeObject(model),
"application/json");

成功時會觸發 AccountState:

1
2
3
4
5
6
7
8
9
if (result.ReturnCode == ResponsibleGamblingEnum.Success)
{
AccountStateManager.ChangeAllChannelAccountState(
memberCode,
model.AccountId,
Status.Invalidate,
ActionRemark.SelfExclusion,
appId);
}

Time Out 也是類似:

1
2
3
4
5
6
7
8
9
if (result.ReturnCode == ResponsibleGamblingEnum.Success)
{
AccountStateManager.ChangeAllChannelAccountState(
memberCode,
model.AccountId,
Status.Invalidate,
ActionRemark.TimeOut,
appId);
}

這代表 Responsible Gambling 狀態不是只改目前 session,而是要讓其他 channel 都被標記失效。

失敗時的補償動作

WebAccountManager 有一個暫時解法:

1
2
3
4
if (internalResponse.ReturnCode == ResponsibleGamblingEnum.GeneralError)
{
ForReloadAndKickOutOtherPlatform(trading);
}

補償邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
private static void ForReloadAndKickOutOtherPlatform(Trading trading)
{
trading.SessionUpdateTime = DateTime.MinValue;
CacheLayerManager.UpdateData<TradingSessionCache>(
CacheName.TradingSession,
trading.AccountID.ToString(),
trading);

AccountStateManager.ClearAccountStateOtherChannelAsync(
trading.AppId,
trading.AccountID,
trading.MemberCode);
}

即使 SPI 回 general error,也會強制目前 cache reload,並踢掉其他平台,降低狀態不一致風險。

頁面防護:ResponsibleGamblingCheckerActionFilter

MVC 頁面層有 filter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
var session = DependencyResolver.Current.GetService<SessionData>();
var permissionChecker =
DependencyResolver.Current.GetService<UserPagePermissionChecker>();

bool isForbidden = permissionChecker.IsInDisallowPath(
session,
disallowRgStatus,
HttpContext.Current.Request.Url.AbsolutePath,
byPassPathChecker);

if (isForbidden)
filterContext.Result = new RedirectResult(
$"~/{session.Trading.PreferData.Languages}/forbidden?type=limit-access");
}

API PageGuard 裡也會使用同一個 UserPagePermissionChecker

Block Path 設計

UserPagePermissionChecker 會根據 RGType 建立 blocking list:

1
2
3
blockingList.Add(RGType.TimeOut, BlockPaths);
blockingList.Add(RGType.SelfExclusion, BlockPaths);
blockingList.Add(RGType.PermanentExclusion, BlockPaths);

Block paths:

1
2
3
4
5
6
7
8
9
10
private static readonly List<PathModel> BlockPaths =
new List<PathModel>()
{
new PathModel() { pathRegex = "^/[a-z]{2}-[a-z]{2}/overlay$" },
new PathModel() { pathRegex = "^/[a-z]{2}-[a-z]{2}/news" },
new PathModel() { pathRegex = "^/[a-z]{2}-[a-z]{2}/rules" },
new PathModel() { pathRegex = "^/[a-z]{2}-[a-z]{2}/applications" },
new PathModel() { pathRegex = "^/[a-z]{2}-[a-z]{2}/my-account/deposit" },
new PathModel() { pathRegex = "^/[a-z]{2}-[a-z]{2}/my-account/settings" },
};

判斷邏輯:

1
2
3
4
5
6
7
8
9
10
if (session.Trading.IsExcluded)
{
foreach (var status in disallowRgStatus)
{
bool isExcluded = GetIsExcludedByStatus(session.Trading, status);
if (isExcluded &&
(byPassPathChecker || IsInBlockList(status, destinationPath)))
return true;
}
}

byPassPathChecker 可以讓某些情境直接 block all path,不需要再看 block list。

通知設計

Self Exclusion 會員通知:

1
2
3
4
5
6
7
8
9
10
11
12
var clientMessage = new ClientMessage()
{
EventName = "tSelfExclusion" +
GetSelfExclusionTemplateByPeriod((int)status),
Params = emailParams,
Region = trading.PreferData.RegionCode,
Language = profile.PreferLanguage,
};

MsgHubManagement.RequestMessagingToMember(
clientMessage,
trading.MemberCode);

Risk team 通知:

1
2
3
4
5
6
7
8
9
10
11
var clientMessage = new ClientMessage()
{
EventName = "tSelfExclusionConfirmForRisk",
Params = emailParams,
Recipient = riskSender,
};

MsgHubManagement.RequestMessagingToUser(
clientMessage,
trading.MemberCode,
trading.AccountID);

Time Out 也是同樣模式。

這代表 Responsible Gambling 是一個跨 domain 流程:

flowchart LR
    SPI["Account SPI"]
    Cache["Trading Cache"]
    State["AccountState"]
    Guard["PageGuard"]
    Msg["MsgHub"]

    SPI --> Cache
    Cache --> State
    State --> Guard
    State --> Msg

完整流程圖

flowchart TD
    Submit["User submits
Self Exclusion / Time Out"] Web["WebAccountManager"] Spi["AccountManager calls Customer SPI"] UpdateSession["Update Trading session fields"] UpdateCache["Update TradingSession cache"] ChangeState["ChangeAllChannelAccountState"] SendMsg["Send member + risk messages"] Compensate["Force cache reload
and kick out other platform"] Submit --> Web Web --> Spi Spi -->|Success| UpdateSession UpdateSession --> UpdateCache UpdateCache --> ChangeState ChangeState --> SendMsg Spi -->|GeneralError| Compensate

頁面防護:

flowchart TD
    Request["Next request"]
    Guard["PageGuard / ResponsibleGamblingCheckerActionFilter"]
    Checker["UserPagePermissionChecker"]
    Pass["Pass"]
    Block["403 或 redirect forbidden"]

    Request --> Guard
    Guard --> Checker
    Checker -->|allowed path| Pass
    Checker -->|blocked path| Block

小結

Responsible Gambling 在後端不該被當成普通會員設定,它是一個安全與合規管線。

這套設計值得借鑑的地方:

  • SPI 更新成功後同步 Trading cache
  • AccountState 讓其他 Channel 失效
  • PageGuard / ActionFilter 在後端攔截限制頁
  • Notification / MsgHub 同步通知會員與 risk team
  • Self Exclusion / Time Out 共用基底模型,但保留各自差異
  • 失敗情境仍做 cache reload 與其他平台 kickout,避免狀態漂移

一句話總結:Responsible Gambling 的核心不是「使用者不能看到某些按鈕」,而是「整個系統都要承認這個狀態,並在所有入口強制執行」。