diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000000000000000000000000000000000..9552eb63839f6c2a9399fa0f6486bb95fab8b500 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*.{cs,csproj,sln,md,json,xml,yml,yaml,config,props,targets,sql,ps1,sh,bat,txt}] +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..9920f34e706e4df5793c45ca4e1242b892322235 --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +# Docker Compose settings +TSHOTEL_IMAGE=yjj6731/tshotel-management-system-api:latest +CONTAINER_NAME=tshotel-api +API_HOST_PORT=63001 +APP_CONFIG_DIR=./docker-data/config +APP_KEYS_DIR=./docker-data/keys + +# Application settings +ASPNETCORE_ENVIRONMENT=docker +DefaultDatabase=MariaDB +InitializeDatabase=false + +# Database connection strings (pick one based on DefaultDatabase) +MariaDBConnectStr=${MARIADB_CONNECT_STR} +MySqlConnectStr=${MYSQL_CONNECT_STR} +PgSqlConnectStr=${PGSQL_CONNECT_STR} +SqlServerConnectStr=${SQLSERVER_CONNECT_STR} + +# Security +Jwt__Key=${JWT_SIGNING_KEY} +Jwt__ExpiryMinutes=20 + +# CORS +AllowedOrigins__0=http://localhost:8080 +AllowedOrigins__1=https://www.yourdomain.com + +# Quartz jobs +JobKeys__0=ReservationExpirationCheckJob +JobKeys__1=MailServiceCheckJob +JobKeys__2=RedisServiceCheckJob +ExpirationSettings__NotifyDaysBefore=3 +ExpirationSettings__CheckIntervalMinutes=5 + +# Idempotency +Idempotency__Enabled=true +Idempotency__EnforceKey=false +Idempotency__MaxKeyLength=128 +Idempotency__InProgressTtlSeconds=120 +Idempotency__CompletedTtlHours=24 +Idempotency__PersistFailureResponse=false + +# Mail service +Mail__Enabled=false +Mail__Host=smtp.example.com +Mail__UserName=admin@example.com +Mail__Password=${MAIL_PASSWORD} +Mail__Port=465 +Mail__EnableSsl=true +Mail__DisplayName=TSHotel + +# Redis +Redis__Enabled=false +Redis__ConnectionString=${REDIS_CONNECTION_STRING} +Redis__DefaultDatabase=0 + +# Lsky (optional) +Lsky__Enabled=false +Lsky__BaseAddress= +Lsky__Email= +Lsky__Password=${LSKY_PASSWORD} +Lsky__UploadApi= +Lsky__GetTokenApi= + +# Version display (optional) +SoftwareVersion=1.0.0 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..239c7996e69bbf7e22a461e468b556d102a24ea0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,18 @@ +# Normalize text files and keep line endings consistent in the repository. +* text=auto eol=lf + +# Keep Windows command scripts in CRLF for best compatibility. +*.bat text eol=crlf +*.cmd text eol=crlf + +# Common binary assets. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.pdf binary +*.zip binary +*.7z binary +*.dll binary +*.exe binary diff --git a/.gitignore b/.gitignore index cc917a08e1f15ab71f26da94a786b153d620fdc5..46058609ea76f1a2f4b7d3c4e1189f0572099e80 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ docker-images/ frontend/ .buildnumber .version +*.txt +docs/ # Visual Studio 2015/2017 cache/options directory .vs/ @@ -391,4 +393,9 @@ FodyWeavers.xsd # JetBrains Rider .idea/ -*.sln.iml \ No newline at end of file +*.sln.iml +/version.txt + +# Local environment secrets +.env +!.env.example diff --git a/EOM.TSHotelManagement.API/.config/dotnet-tools.json b/EOM.TSHotelManagement.API/.config/dotnet-tools.json deleted file mode 100644 index 43a4368a7de95b2f94dbcbc3073029fec3ec355c..0000000000000000000000000000000000000000 --- a/EOM.TSHotelManagement.API/.config/dotnet-tools.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "dotnet-ef": { - "version": "7.0.1", - "commands": [ - "dotnet-ef" - ] - } - } -} \ No newline at end of file diff --git a/EOM.TSHotelManagement.API/Authorization/CustomAuthorizationMiddlewareResultHandler.cs b/EOM.TSHotelManagement.API/Authorization/CustomAuthorizationMiddlewareResultHandler.cs index 729578de7f426b183fc4c7d38213900e3cbc7201..dbd230f468e334eeb34cd5a2f33f288f21a1e9d5 100644 --- a/EOM.TSHotelManagement.API/Authorization/CustomAuthorizationMiddlewareResultHandler.cs +++ b/EOM.TSHotelManagement.API/Authorization/CustomAuthorizationMiddlewareResultHandler.cs @@ -10,13 +10,37 @@ namespace EOM.TSHotelManagement.WebApi.Authorization public class CustomAuthorizationMiddlewareResultHandler : IAuthorizationMiddlewareResultHandler { + private const string AuthFailureReasonItemKey = JwtAuthConstants.AuthFailureReasonItemKey; + private const string AuthFailureReasonTokenRevoked = JwtAuthConstants.AuthFailureReasonTokenRevoked; + private const string AuthFailureReasonTokenExpired = JwtAuthConstants.AuthFailureReasonTokenExpired; + private readonly AuthorizationMiddlewareResultHandler _defaultHandler = new AuthorizationMiddlewareResultHandler(); public async Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult) { - if (authorizeResult.Challenged || authorizeResult.Forbidden) + if (authorizeResult.Challenged) { - var response = new BaseResponse(BusinessStatusCode.Unauthorized, + var response = new BaseResponse( + BusinessStatusCode.Unauthorized, + ResolveUnauthorizedMessage(context)); + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json; charset=utf-8"; + + var json = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DictionaryKeyPolicy = null + }); + + await context.Response.WriteAsync(json); + return; + } + + if (authorizeResult.Forbidden) + { + var response = new BaseResponse( + BusinessStatusCode.PermissionDenied, LocalizationHelper.GetLocalizedString("PermissionDenied", "该账户缺少权限,请联系管理员添加")); context.Response.StatusCode = StatusCodes.Status200OK; @@ -34,5 +58,30 @@ namespace EOM.TSHotelManagement.WebApi.Authorization await _defaultHandler.HandleAsync(next, context, policy, authorizeResult); } + + private static string ResolveUnauthorizedMessage(HttpContext context) + { + if (context.Items.TryGetValue(AuthFailureReasonItemKey, out var reasonObj)) + { + var reason = reasonObj?.ToString(); + if (string.Equals(reason, AuthFailureReasonTokenRevoked, System.StringComparison.OrdinalIgnoreCase)) + { + return LocalizationHelper.GetLocalizedString( + "Token has been revoked. Please log in again.", + "该Token已失效,请重新登录"); + } + + if (string.Equals(reason, AuthFailureReasonTokenExpired, System.StringComparison.OrdinalIgnoreCase)) + { + return LocalizationHelper.GetLocalizedString( + "Token has expired. Please log in again.", + "登录已过期,请重新登录"); + } + } + + return LocalizationHelper.GetLocalizedString( + "Unauthorized. Please log in again.", + "未授权或登录已失效,请重新登录"); + } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/Authorization/PermissionsAuthorization.cs b/EOM.TSHotelManagement.API/Authorization/PermissionsAuthorization.cs index b76c30ad42431ff41341fd94371f68e25e3ccbf7..e37fa554ac097e2c92e756db28bb331f8281a261 100644 --- a/EOM.TSHotelManagement.API/Authorization/PermissionsAuthorization.cs +++ b/EOM.TSHotelManagement.API/Authorization/PermissionsAuthorization.cs @@ -100,6 +100,13 @@ namespace EOM.TSHotelManagement.WebApi.Authorization var httpContext = _httpContextAccessor.HttpContext; var user = httpContext?.User; + if (user?.Identity?.IsAuthenticated != true) + { + // Unauthenticated requests should be handled by authentication challenge, + // not by permission checks. + return; + } + var userNumber = user?.FindFirst(ClaimTypes.SerialNumber)?.Value ?? user?.FindFirst("serialnumber")?.Value; @@ -198,4 +205,4 @@ namespace EOM.TSHotelManagement.WebApi.Authorization return null; } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/Controllers/Application/NavBar/NavBarController.cs b/EOM.TSHotelManagement.API/Controllers/Application/NavBar/NavBarController.cs index 55060f86f38bbe281bf880d5e787d70fed4000be..57b813db3841eda46cbef96b141570180b401a1b 100644 --- a/EOM.TSHotelManagement.API/Controllers/Application/NavBar/NavBarController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Application/NavBar/NavBarController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -28,7 +28,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 导航控件列表 /// /// - [RequirePermission("navbar.view")] + [RequirePermission("navbar.navbarlist")] [HttpGet] public ListOutputDto NavBarList() { @@ -39,7 +39,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("navbar.create")] + [RequirePermission("navbar.addnavbar")] [HttpPost] public BaseResponse AddNavBar([FromBody] CreateNavBarInputDto input) { @@ -50,7 +50,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("navbar.update")] + [RequirePermission("navbar.updatenavbar")] [HttpPost] public BaseResponse UpdateNavBar([FromBody] UpdateNavBarInputDto input) { @@ -61,7 +61,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("navbar.delete")] + [RequirePermission("navbar.deletenavbar")] [HttpPost] public BaseResponse DeleteNavBar([FromBody] DeleteNavBarInputDto input) { diff --git a/EOM.TSHotelManagement.API/Controllers/Business/Asset/AssetController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Asset/AssetController.cs index dc322ea2b8d6466a69e3cf25e647360ed8955932..b4f37412436d742b4dea294e6351366501c2e606 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/Asset/AssetController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/Asset/AssetController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -29,7 +29,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("internalfinance.create")] + [RequirePermission("internalfinance.addassetinfo")] [HttpPost] public BaseResponse AddAssetInfo([FromBody] CreateAssetInputDto asset) { @@ -40,7 +40,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询资产信息 /// /// - [RequirePermission("internalfinance.view")] + [RequirePermission("internalfinance.selectassetinfoall")] [HttpGet] public ListOutputDto SelectAssetInfoAll([FromQuery] ReadAssetInputDto asset) { @@ -52,7 +52,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("internalfinance.update")] + [RequirePermission("internalfinance.updassetinfo")] [HttpPost] public BaseResponse UpdAssetInfo([FromBody] UpdateAssetInputDto asset) { @@ -64,7 +64,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("internalfinance.delete")] + [RequirePermission("internalfinance.delassetinfo")] [HttpPost] public BaseResponse DelAssetInfo([FromBody] DeleteAssetInputDto asset) { diff --git a/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerAccountController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerAccountController.cs index 50fd840b8757a45ea4c409fe114916fbdd1123dc..15b12f08fb976203c99649a16fc9ca15a5d37dfb 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerAccountController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerAccountController.cs @@ -2,6 +2,7 @@ using EOM.TSHotelManagement.Service; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace EOM.TSHotelManagement.WebApi.Controllers { @@ -39,5 +40,69 @@ namespace EOM.TSHotelManagement.WebApi.Controllers { return _customerAccountService.Register(readCustomerAccountInputDto); } + + /// + /// 获取当前客户账号的 2FA 状态 + /// + /// + [HttpGet] + public SingleOutputDto GetTwoFactorStatus() + { + return _customerAccountService.GetTwoFactorStatus(GetCurrentSerialNumber()); + } + + /// + /// 生成当前客户账号的 2FA 绑定信息 + /// + /// + [HttpPost] + public SingleOutputDto GenerateTwoFactorSetup() + { + return _customerAccountService.GenerateTwoFactorSetup(GetCurrentSerialNumber()); + } + + /// + /// 启用当前客户账号 2FA + /// + /// + /// + [HttpPost] + public SingleOutputDto EnableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return _customerAccountService.EnableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 关闭当前客户账号 2FA + /// + /// + /// + [HttpPost] + public BaseResponse DisableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return _customerAccountService.DisableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 重置当前客户账号恢复备用码 + /// + /// + /// + [HttpPost] + public SingleOutputDto RegenerateTwoFactorRecoveryCodes([FromBody] TwoFactorCodeInputDto inputDto) + { + return _customerAccountService.RegenerateTwoFactorRecoveryCodes(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 从当前登录上下文中读取账号序列号 + /// + /// + private string GetCurrentSerialNumber() + { + return User.FindFirstValue(ClaimTypes.SerialNumber) + ?? User.FindFirst("serialnumber")?.Value + ?? string.Empty; + } } } diff --git a/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerController.cs index 68cf567c87f89fa5ca7c9b36909fa9d6989c293c..67fb2b8fcd1e18ff33772c994b61949e1baeab45 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -29,7 +29,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customer.create")] + [RequirePermission("customer.insertcustomerinfo")] [HttpPost] public BaseResponse InsertCustomerInfo([FromBody] CreateCustomerInputDto custo) { @@ -41,7 +41,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customer.update")] + [RequirePermission("customer.updcustomerinfo")] [HttpPost] public BaseResponse UpdCustomerInfo([FromBody] UpdateCustomerInputDto custo) { @@ -53,7 +53,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customer.delete")] + [RequirePermission("customer.delcustomerinfo")] [HttpPost] public BaseResponse DelCustomerInfo([FromBody] DeleteCustomerInputDto custo) { @@ -65,7 +65,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customer.update")] + [RequirePermission("customer.updcustomertypebycustono")] [HttpPost] public BaseResponse UpdCustomerTypeByCustoNo([FromBody] UpdateCustomerInputDto updateCustomerInputDto) { @@ -76,7 +76,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询所有客户信息 /// /// - [RequirePermission("customer.view")] + [RequirePermission("customer.selectcustomers")] [HttpGet] public ListOutputDto SelectCustomers(ReadCustomerInputDto custo) { @@ -87,7 +87,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询指定客户信息 /// /// - [RequirePermission("customer.view")] + [RequirePermission("customer.selectcustobyinfo")] [HttpGet] public SingleOutputDto SelectCustoByInfo([FromQuery] ReadCustomerInputDto custo) { diff --git a/EOM.TSHotelManagement.API/Controllers/Business/EnergyManagement/EnergyManagementController.cs b/EOM.TSHotelManagement.API/Controllers/Business/EnergyManagement/EnergyManagementController.cs index 4de9ff34804a6b72a61b939e16c59415b7aedfb3..8400acbc378046e457e51745b203296e497261e1 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/EnergyManagement/EnergyManagementController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/EnergyManagement/EnergyManagementController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -29,7 +29,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// Dto /// 符合条件的水电费信息列表 - [RequirePermission("hydroelectricinformation.view")] + [RequirePermission("hydroelectricinformation.selectenergymanagementinfo")] [HttpGet] public ListOutputDto SelectEnergyManagementInfo([FromQuery] ReadEnergyManagementInputDto readEnergyManagementInputDto) { @@ -42,7 +42,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("hydroelectricinformation.create")] + [RequirePermission("hydroelectricinformation.insertenergymanagementinfo")] [HttpPost] public BaseResponse InsertEnergyManagementInfo([FromBody] CreateEnergyManagementInputDto w) { @@ -55,7 +55,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 包含要修改的数据,以及WtiNo作为查询条件 /// - [RequirePermission("hydroelectricinformation.update")] + [RequirePermission("hydroelectricinformation.updateenergymanagementinfo")] [HttpPost] public BaseResponse UpdateEnergyManagementInfo([FromBody] UpdateEnergyManagementInputDto w) { @@ -69,7 +69,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("hydroelectricinformation.delete")] + [RequirePermission("hydroelectricinformation.deleteenergymanagementinfo")] [HttpPost] public BaseResponse DeleteEnergyManagementInfo([FromBody] DeleteEnergyManagementInputDto deleteEnergyManagementInputDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/Business/PromotionContent/PromotionContentController.cs b/EOM.TSHotelManagement.API/Controllers/Business/PromotionContent/PromotionContentController.cs index 56fb09bfa186ef41bdd68bd68717a25d837532f8..df89586595bb7675671d2bf0b74cdbb3571437e9 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/PromotionContent/PromotionContentController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/PromotionContent/PromotionContentController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -28,7 +28,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询所有宣传联动内容 /// /// - [RequirePermission("promotioncontent.view")] + [RequirePermission("promotioncontent.selectpromotioncontentall")] [HttpGet] public ListOutputDto SelectPromotionContentAll([FromQuery] ReadPromotionContentInputDto readPromotionContentInputDto) { @@ -39,7 +39,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询所有宣传联动内容(跑马灯) /// /// - [RequirePermission("promotioncontent.view")] + [RequirePermission("promotioncontent.selectpromotioncontents")] [HttpGet] public ListOutputDto SelectPromotionContents() { @@ -51,7 +51,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("promotioncontent.create")] + [RequirePermission("promotioncontent.addpromotioncontent")] [HttpPost] public BaseResponse AddPromotionContent([FromBody] CreatePromotionContentInputDto createPromotionContentInputDto) { @@ -63,7 +63,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("promotioncontent.delete")] + [RequirePermission("promotioncontent.deletepromotioncontent")] [HttpPost] public BaseResponse DeletePromotionContent([FromBody] DeletePromotionContentInputDto deletePromotionContentInputDto) { @@ -75,7 +75,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("promotioncontent.update")] + [RequirePermission("promotioncontent.updatepromotioncontent")] [HttpPost] public BaseResponse UpdatePromotionContent([FromBody] UpdatePromotionContentInputDto updatePromotionContentInputDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/Business/Reser/ReserController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Reser/ReserController.cs index 0f0f0f9327363853978106018f37045202e33515..06d75583e318f4b0faaad149337566a5b0f1a28e 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/Reser/ReserController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/Reser/ReserController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -28,7 +28,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 获取所有预约信息 /// /// - [RequirePermission("resermanagement.view")] + [RequirePermission("resermanagement.selectreserall")] [HttpGet] public ListOutputDto SelectReserAll(ReadReserInputDto readReserInputDto) { @@ -40,7 +40,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("resermanagement.view")] + [RequirePermission("resermanagement.selectreserinfobyroomno")] [HttpGet] public SingleOutputDto SelectReserInfoByRoomNo([FromQuery] ReadReserInputDto readReserInputDto) { @@ -52,7 +52,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("resermanagement.delete")] + [RequirePermission("resermanagement.deletereserinfo")] [HttpPost] public BaseResponse DeleteReserInfo([FromBody] DeleteReserInputDto reser) { @@ -64,7 +64,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("resermanagement.update")] + [RequirePermission("resermanagement.updatereserinfo")] [HttpPost] public BaseResponse UpdateReserInfo([FromBody] UpdateReserInputDto r) { @@ -76,7 +76,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("resermanagement.create")] + [RequirePermission("resermanagement.inserreserinfo")] [HttpPost] public BaseResponse InserReserInfo([FromBody] CreateReserInputDto r) { @@ -87,7 +87,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询所有预约类型 /// /// - [RequirePermission("resermanagement.view")] + [RequirePermission("resermanagement.selectresertypeall")] [HttpGet] public ListOutputDto SelectReserTypeAll() { diff --git a/EOM.TSHotelManagement.API/Controllers/Business/Room/RoomController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Room/RoomController.cs index 63796c000596bc0672ee8c78ea5286a086edeb48..5d85905d13667946ca0123b1bf49aa483c69651a 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/Room/RoomController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/Room/RoomController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,7 +22,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectroombyroomstate")] [HttpGet] public ListOutputDto SelectRoomByRoomState([FromQuery] ReadRoomInputDto inputDto) { @@ -33,7 +33,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 根据房间状态来查询可使用的房间 /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectcanuseroomall")] [HttpGet] public ListOutputDto SelectCanUseRoomAll() { @@ -44,7 +44,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 获取所有房间信息 /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectroomall")] [HttpGet] public ListOutputDto SelectRoomAll([FromQuery] ReadRoomInputDto readRoomInputDto) { @@ -56,7 +56,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectroombytypename")] [HttpGet] public ListOutputDto SelectRoomByTypeName([FromQuery] ReadRoomInputDto inputDto) { @@ -68,7 +68,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectroombyroomno")] [HttpGet] public SingleOutputDto SelectRoomByRoomNo([FromQuery] ReadRoomInputDto inputDto) { @@ -80,7 +80,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.daybyroomno")] [HttpGet] public SingleOutputDto DayByRoomNo([FromQuery] ReadRoomInputDto inputDto) { @@ -92,7 +92,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.update")] + [RequirePermission("roommanagement.updateroominfo")] [HttpPost] public BaseResponse UpdateRoomInfo([FromBody] UpdateRoomInputDto inputDto) { @@ -104,7 +104,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.update")] + [RequirePermission("roommanagement.updateroominfowithreser")] [HttpPost] public BaseResponse UpdateRoomInfoWithReser([FromBody] UpdateRoomInputDto inputDto) { @@ -115,7 +115,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询可入住房间数量 /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectcanuseroomallbyroomstate")] [HttpGet] public SingleOutputDto SelectCanUseRoomAllByRoomState() { @@ -126,7 +126,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询已入住房间数量 /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectnotuseroomallbyroomstate")] [HttpGet] public SingleOutputDto SelectNotUseRoomAllByRoomState() { @@ -138,7 +138,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectroombyroomprice")] [HttpGet] public object SelectRoomByRoomPrice([FromQuery] ReadRoomInputDto inputDto) { @@ -149,7 +149,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询脏房数量 /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectnotclearroomallbyroomstate")] [HttpGet] public SingleOutputDto SelectNotClearRoomAllByRoomState() { @@ -160,7 +160,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询维修房数量 /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectfixingroomallbyroomstate")] [HttpGet] public SingleOutputDto SelectFixingRoomAllByRoomState() { @@ -171,7 +171,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询预约房数量 /// /// - [RequirePermission("roommanagement.view")] + [RequirePermission("roommanagement.selectreservedroomallbyroomstate")] [HttpGet] public SingleOutputDto SelectReservedRoomAllByRoomState() { @@ -183,7 +183,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.update")] + [RequirePermission("roommanagement.updateroomstatebyroomno")] [HttpPost] public BaseResponse UpdateRoomStateByRoomNo([FromBody] UpdateRoomInputDto inputDto) { @@ -195,7 +195,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.create")] + [RequirePermission("roommanagement.insertroom")] [HttpPost] public BaseResponse InsertRoom([FromBody] CreateRoomInputDto inputDto) { @@ -207,7 +207,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.update")] + [RequirePermission("roommanagement.updateroom")] [HttpPost] public BaseResponse UpdateRoom([FromBody] UpdateRoomInputDto inputDto) { @@ -219,7 +219,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.delete")] + [RequirePermission("roommanagement.deleteroom")] [HttpPost] public BaseResponse DeleteRoom([FromBody] DeleteRoomInputDto inputDto) { @@ -231,7 +231,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.update")] + [RequirePermission("roommanagement.transferroom")] [HttpPost] public BaseResponse TransferRoom([FromBody] TransferRoomDto transferRoomDto) { @@ -243,7 +243,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.update")] + [RequirePermission("roommanagement.checkoutroom")] [HttpPost] public BaseResponse CheckoutRoom([FromBody] CheckoutRoomDto checkoutRoomDto) { @@ -255,7 +255,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roommanagement.update")] + [RequirePermission("roommanagement.checkinroombyreservation")] [HttpPost] public BaseResponse CheckinRoomByReservation([FromBody] CheckinRoomByReservationDto checkinRoomByReservationDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/Business/Room/RoomTypeController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Room/RoomTypeController.cs index 4574853d43609c04b3e026a18a562edaf111f0af..0d6e7fe109ed665374349de4eff02303b92b5cea 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/Room/RoomTypeController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/Room/RoomTypeController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,7 +22,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roomconfig.view")] + [RequirePermission("roomconfig.selectroomtypesall")] [HttpGet] public ListOutputDto SelectRoomTypesAll([FromQuery] ReadRoomTypeInputDto inputDto) { @@ -34,7 +34,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roomconfig.view")] + [RequirePermission("roomconfig.selectroomtypebyroomno")] [HttpGet] public SingleOutputDto SelectRoomTypeByRoomNo([FromQuery] ReadRoomTypeInputDto inputDto) { @@ -46,7 +46,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roomconfig.create")] + [RequirePermission("roomconfig.insertroomtype")] [HttpPost] public BaseResponse InsertRoomType([FromBody] CreateRoomTypeInputDto inputDto) { @@ -58,7 +58,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roomconfig.update")] + [RequirePermission("roomconfig.updateroomtype")] [HttpPost] public BaseResponse UpdateRoomType([FromBody] UpdateRoomTypeInputDto inputDto) { @@ -70,7 +70,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("roomconfig.delete")] + [RequirePermission("roomconfig.deleteroomtype")] [HttpPost] public BaseResponse DeleteRoomType([FromBody] DeleteRoomTypeInputDto inputDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/Business/Sellthing/SellthingController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Sellthing/SellthingController.cs index 6ece820020ef7f22c59fa85365a67ed9f764d55d..85d0125bd8ee3c9d2cf35846c4cc41a8e58e640e 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/Sellthing/SellthingController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/Sellthing/SellthingController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,7 +22,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("goodsmanagement.view")] + [RequirePermission("goodsmanagement.selectsellthingall")] [HttpGet] public ListOutputDto SelectSellThingAll([FromQuery] ReadSellThingInputDto sellThing = null) { @@ -34,7 +34,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("goodsmanagement.update")] + [RequirePermission("goodsmanagement.updatesellthing")] [HttpPost] public BaseResponse UpdateSellThing([FromBody] UpdateSellThingInputDto updateSellThingInputDto) { @@ -42,11 +42,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers } /// - /// 撤回客户消费信息 + /// 删除商品信息 /// /// /// - [RequirePermission("goodsmanagement.delete")] + [RequirePermission("goodsmanagement.deletesellthing")] [HttpPost] public BaseResponse DeleteSellthing([FromBody] DeleteSellThingInputDto deleteSellThingInputDto) { @@ -58,7 +58,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("goodsmanagement.view")] + [RequirePermission("goodsmanagement.selectsellthingbynameandprice")] [HttpGet] public SingleOutputDto SelectSellThingByNameAndPrice([FromQuery] ReadSellThingInputDto readSellThingInputDto) { @@ -70,7 +70,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("goodsmanagement.create")] + [RequirePermission("goodsmanagement.insertsellthing")] [HttpPost] public BaseResponse InsertSellThing([FromBody] CreateSellThingInputDto st) { diff --git a/EOM.TSHotelManagement.API/Controllers/Business/Spend/SpendController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Spend/SpendController.cs index ed91fddab292566f3316f1e5a8fcc67060776c60..1cf5bfd797b3b149a9538b344fa2979aa26cf6d2 100644 --- a/EOM.TSHotelManagement.API/Controllers/Business/Spend/SpendController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Business/Spend/SpendController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,7 +22,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customerspend.view")] + [RequirePermission("customerspend.selectspendbyroomno")] [HttpGet] public ListOutputDto SelectSpendByRoomNo([FromQuery] ReadSpendInputDto inputDto) { @@ -34,7 +34,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customerspend.view")] + [RequirePermission("customerspend.selethistoryspendinfoall")] [HttpGet] public ListOutputDto SeletHistorySpendInfoAll([FromQuery] ReadSpendInputDto inputDto) { @@ -45,7 +45,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询消费的所有信息 /// /// - [RequirePermission("customerspend.view")] + [RequirePermission("customerspend.selectspendinfoall")] [HttpGet] public ListOutputDto SelectSpendInfoAll([FromQuery] ReadSpendInputDto readSpendInputDto) { @@ -57,7 +57,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customerspend.view")] + [RequirePermission("customerspend.sumconsumptionamount")] [HttpGet] public SingleOutputDto SumConsumptionAmount([FromQuery] ReadSpendInputDto inputDto) { @@ -69,7 +69,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customerspend.delete")] + [RequirePermission("customerspend.undocustomerspend")] [HttpPost] public BaseResponse UndoCustomerSpend([FromBody] UpdateSpendInputDto updateSpendInputDto) { @@ -81,7 +81,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customerspend.create")] + [RequirePermission("customerspend.addcustomerspend")] [HttpPost] public BaseResponse AddCustomerSpend([FromBody] AddCustomerSpendInputDto addCustomerSpendInputDto) { @@ -93,7 +93,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("customerspend.update")] + [RequirePermission("customerspend.updspendinfo")] [HttpPost] public BaseResponse UpdSpendInfo([FromBody] UpdateSpendInputDto inputDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/Dashboard/DashboardController.cs b/EOM.TSHotelManagement.API/Controllers/Dashboard/DashboardController.cs index 13bb8ccf64a766e850278ad9828eed6ecead5362..5268b049b9b9be6ba5d4fc3680c24f00c6daa85b 100644 --- a/EOM.TSHotelManagement.API/Controllers/Dashboard/DashboardController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Dashboard/DashboardController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -18,7 +18,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 获取房间统计信息 /// /// - [RequirePermission("dashboard.view")] + [RequirePermission("dashboard.roomstatistics")] [HttpGet] public SingleOutputDto RoomStatistics() { @@ -29,7 +29,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 获取业务统计信息 /// /// - [RequirePermission("dashboard.view")] + [RequirePermission("dashboard.businessstatistics")] [HttpGet] public SingleOutputDto BusinessStatistics() { @@ -40,7 +40,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 获取后勤统计信息 /// /// - [RequirePermission("dashboard.view")] + [RequirePermission("dashboard.logisticsstatistics")] [HttpGet] public SingleOutputDto LogisticsStatistics() { @@ -51,7 +51,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 获取人事统计信息 /// /// - [RequirePermission("dashboard.view")] + [RequirePermission("dashboard.humanresourcesstatistics")] [HttpGet] public SingleOutputDto HumanResourcesStatistics() { diff --git a/EOM.TSHotelManagement.API/Controllers/Employee/Check/EmployeeCheckController.cs b/EOM.TSHotelManagement.API/Controllers/Employee/Check/EmployeeCheckController.cs index da0585b8d05d0b49234fc46fa1e3f6bb6bb2cea2..175e481df3c3fda63772eb6e6620cad308328902 100644 --- a/EOM.TSHotelManagement.API/Controllers/Employee/Check/EmployeeCheckController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Employee/Check/EmployeeCheckController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,7 +22,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.view")] + [RequirePermission("staffmanagement.selectcheckinfobyemployeeid")] [HttpGet] public ListOutputDto SelectCheckInfoByEmployeeId([FromQuery] ReadEmployeeCheckInputDto inputDto) { @@ -34,7 +34,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.view")] + [RequirePermission("staffmanagement.selectworkercheckdaysumbyemployeeid")] [HttpGet] public SingleOutputDto SelectWorkerCheckDaySumByEmployeeId([FromQuery] ReadEmployeeCheckInputDto inputDto) { @@ -46,7 +46,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.view")] + [RequirePermission("staffmanagement.selecttodaycheckinfobyworkerno")] [HttpGet] public SingleOutputDto SelectToDayCheckInfoByWorkerNo([FromQuery] ReadEmployeeCheckInputDto inputDto) { @@ -58,7 +58,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.create")] + [RequirePermission("staffmanagement.addcheckinfo")] [HttpPost] public BaseResponse AddCheckInfo([FromBody] CreateEmployeeCheckInputDto workerCheck) { diff --git a/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs b/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs index 64a0bad9dc6dec0e98ffba0c10a5704df3392b58..4d8a9227c9ecf6efb8849d86aaf07fe43a80fa1b 100644 --- a/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs @@ -1,8 +1,9 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace EOM.TSHotelManagement.WebApi.Controllers { @@ -23,7 +24,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.update")] + [RequirePermission("staffmanagement.updateemployee")] [HttpPost] public BaseResponse UpdateEmployee([FromBody] UpdateEmployeeInputDto worker) { @@ -35,7 +36,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.status")] + [RequirePermission("staffmanagement.manageremployeeaccount")] [HttpPost] public BaseResponse ManagerEmployeeAccount([FromBody] UpdateEmployeeInputDto worker) { @@ -47,7 +48,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.create")] + [RequirePermission("staffmanagement.addemployee")] [HttpPost] public BaseResponse AddEmployee([FromBody] CreateEmployeeInputDto worker) { @@ -59,7 +60,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.view")] + [RequirePermission("staffmanagement.selectemployeeall")] [HttpGet] public ListOutputDto SelectEmployeeAll([FromQuery] ReadEmployeeInputDto inputDto) { @@ -71,7 +72,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.view")] + [RequirePermission("staffmanagement.selectemployeeinfobyemployeeid")] [HttpGet] public SingleOutputDto SelectEmployeeInfoByEmployeeId([FromQuery] ReadEmployeeInputDto inputDto) { @@ -91,12 +92,70 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return workerService.EmployeeLogin(inputDto); } + /// + /// 获取当前员工账号的 2FA 状态 + /// + /// + [RequirePermission("staffmanagement.gettwofactorstatus")] + [HttpGet] + public SingleOutputDto GetTwoFactorStatus() + { + return workerService.GetTwoFactorStatus(GetCurrentSerialNumber()); + } + + /// + /// 生成当前员工账号的 2FA 绑定信息 + /// + /// + [RequirePermission("staffmanagement.generatetwofactorsetup")] + [HttpPost] + public SingleOutputDto GenerateTwoFactorSetup() + { + return workerService.GenerateTwoFactorSetup(GetCurrentSerialNumber()); + } + + /// + /// 启用当前员工账号 2FA + /// + /// + /// + [RequirePermission("staffmanagement.enabletwofactor")] + [HttpPost] + public SingleOutputDto EnableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return workerService.EnableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 关闭当前员工账号 2FA + /// + /// + /// + [RequirePermission("staffmanagement.disabletwofactor")] + [HttpPost] + public BaseResponse DisableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return workerService.DisableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 重置当前员工账号恢复备用码 + /// + /// + /// + [RequirePermission("staffmanagement.regeneratetwofactorrecoverycodes")] + [HttpPost] + public SingleOutputDto RegenerateTwoFactorRecoveryCodes([FromBody] TwoFactorCodeInputDto inputDto) + { + return workerService.RegenerateTwoFactorRecoveryCodes(GetCurrentSerialNumber(), inputDto); + } + /// /// 修改员工账号密码 /// /// /// - [RequirePermission("staffmanagement.reset")] + [RequirePermission("staffmanagement.updateemployeeaccountpassword")] [HttpPost] public BaseResponse UpdateEmployeeAccountPassword([FromBody] UpdateEmployeeInputDto updateEmployeeInputDto) { @@ -107,11 +166,22 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.update")] + [RequirePermission("staffmanagement.resetemployeeaccountpassword")] [HttpPost] public BaseResponse ResetEmployeeAccountPassword([FromBody] UpdateEmployeeInputDto updateEmployeeInputDto) { return workerService.ResetEmployeeAccountPassword(updateEmployeeInputDto); } + + /// + /// 从当前登录上下文中读取账号序列号 + /// + /// + private string GetCurrentSerialNumber() + { + return User.FindFirstValue(ClaimTypes.SerialNumber) + ?? User.FindFirst("serialnumber")?.Value + ?? string.Empty; + } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/Controllers/Employee/History/EmployeeHistoryController.cs b/EOM.TSHotelManagement.API/Controllers/Employee/History/EmployeeHistoryController.cs index 68ddde5fe49a7e1f7f83c4895d0ce1ae3cc31b94..26c4c8b1da808f09dd0bef2f2404f72e26eb0f08 100644 --- a/EOM.TSHotelManagement.API/Controllers/Employee/History/EmployeeHistoryController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Employee/History/EmployeeHistoryController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,7 +22,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.create")] + [RequirePermission("staffmanagement.addhistorybyemployeeid")] [HttpPost] public BaseResponse AddHistoryByEmployeeId([FromBody] CreateEmployeeHistoryInputDto workerHistory) { @@ -34,7 +34,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.view")] + [RequirePermission("staffmanagement.selecthistorybyemployeeid")] [HttpGet] public ListOutputDto SelectHistoryByEmployeeId([FromQuery] ReadEmployeeHistoryInputDto inputDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/Employee/Photo/EmployeePhotoController.cs b/EOM.TSHotelManagement.API/Controllers/Employee/Photo/EmployeePhotoController.cs index 4583b993a3ff74db20a6922f54571689a7a9d0cf..3f39185d41cfabf44d180dfa32a7df3d27cd28b2 100644 --- a/EOM.TSHotelManagement.API/Controllers/Employee/Photo/EmployeePhotoController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Employee/Photo/EmployeePhotoController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Http; @@ -23,7 +23,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.view")] + [RequirePermission("staffmanagement.employeephoto")] [HttpGet] public SingleOutputDto EmployeePhoto([FromQuery] ReadEmployeePhotoInputDto inputDto) { @@ -36,7 +36,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.create")] + [RequirePermission("staffmanagement.insertworkerphoto")] [HttpPost] public SingleOutputDto InsertWorkerPhoto([FromForm] CreateEmployeePhotoInputDto inputDto, IFormFile file) { @@ -48,7 +48,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.delete")] + [RequirePermission("staffmanagement.deleteworkerphoto")] [HttpPost] public BaseResponse DeleteWorkerPhoto([FromBody] DeleteEmployeePhotoInputDto inputDto) { @@ -60,7 +60,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("staffmanagement.update")] + [RequirePermission("staffmanagement.updateworkerphoto")] [HttpPost] public BaseResponse UpdateWorkerPhoto([FromBody] UpdateEmployeePhotoInputDto inputDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/LoginController.cs b/EOM.TSHotelManagement.API/Controllers/LoginController.cs index 10c94a3fa30e7eebae3125d37a7a793472523f61..07baeac01cfc8cb36d368dd80c9ea93de15c0576 100644 --- a/EOM.TSHotelManagement.API/Controllers/LoginController.cs +++ b/EOM.TSHotelManagement.API/Controllers/LoginController.cs @@ -1,24 +1,34 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Infrastructure; using Microsoft.AspNetCore.Antiforgery; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using System; +using System.IdentityModel.Tokens.Jwt; +using System.Linq; +using System.Security.Claims; +using System.Threading.Tasks; namespace EOM.TSHotelManagement.WebApi { public class LoginController : ControllerBase { + private const string JwtTokenUserIdItemKey = JwtAuthConstants.JwtTokenUserIdItemKey; + private readonly IAntiforgery _antiforgery; private readonly CsrfTokenConfig _csrfConfig; + private readonly JwtTokenRevocationService _tokenRevocationService; public LoginController( IAntiforgery antiforgery, - IOptions csrfConfig) + IOptions csrfConfig, + JwtTokenRevocationService tokenRevocationService) { _antiforgery = antiforgery; _csrfConfig = csrfConfig.Value; + _tokenRevocationService = tokenRevocationService; } [HttpGet] @@ -65,5 +75,87 @@ namespace EOM.TSHotelManagement.WebApi { return GetCSRFToken(); } + + [HttpPost] + public async Task Logout() + { + var authorizationHeader = Request.Headers["Authorization"].ToString(); + if (!JwtTokenRevocationService.TryGetBearerToken(authorizationHeader, out var token)) + { + return new BaseResponse( + BusinessStatusCode.BadRequest, + LocalizationHelper.GetLocalizedString( + "Missing or invalid Authorization header.", + "缺少或无效的 Authorization 请求头。")); + } + + var currentUserId = GetCurrentUserId(); + if (!TryGetTokenUserId(token, out var tokenUserId)) + { + return new BaseResponse( + BusinessStatusCode.BadRequest, + LocalizationHelper.GetLocalizedString( + "Invalid token.", + "Invalid token.")); + } + + if (!string.Equals(currentUserId, tokenUserId, StringComparison.Ordinal)) + { + return new BaseResponse( + BusinessStatusCode.PermissionDenied, + LocalizationHelper.GetLocalizedString( + "Permission denied.", + "Permission denied.")); + } + + await _tokenRevocationService.RevokeTokenAsync(token); + return new BaseResponse( + BusinessStatusCode.Success, + LocalizationHelper.GetLocalizedString("Logout success.", "登出成功。")); + } + + private string GetCurrentUserId() + { + if (HttpContext.Items.TryGetValue(JwtTokenUserIdItemKey, out var userIdObj)) + { + var userId = userIdObj?.ToString(); + if (!string.IsNullOrWhiteSpace(userId)) + { + return userId; + } + } + + return User.FindFirstValue(ClaimTypes.SerialNumber) + ?? User.FindFirst("serialnumber")?.Value + ?? User.FindFirstValue(ClaimTypes.NameIdentifier) + ?? User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value + ?? string.Empty; + } + + private static bool TryGetTokenUserId(string token, out string tokenUserId) + { + tokenUserId = string.Empty; + if (string.IsNullOrWhiteSpace(token)) + { + return false; + } + + try + { + var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(token); + tokenUserId = jwtToken.Claims.FirstOrDefault(c => + c.Type == ClaimTypes.SerialNumber || + c.Type == "serialnumber" || + c.Type == ClaimTypes.NameIdentifier || + c.Type == JwtRegisteredClaimNames.Sub)?.Value + ?? string.Empty; + + return !string.IsNullOrWhiteSpace(tokenUserId); + } + catch + { + return false; + } + } } } diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs index 6965c360cabeb00b9e1062d297fcb2aaa569b670..936525e118bba37380f802bcb6cfd1739ce09b52 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs @@ -1,9 +1,10 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Contract.SystemManagement.Dto.Permission; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using System.Security.Claims; namespace EOM.TSHotelManagement.WebApi.Controllers { @@ -39,11 +40,69 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return adminService.Login(admin); } + /// + /// 获取当前管理员账号的 2FA 状态 + /// + /// + [RequirePermission("system:admin:gettwofactorstatus")] + [HttpGet] + public SingleOutputDto GetTwoFactorStatus() + { + return adminService.GetTwoFactorStatus(GetCurrentSerialNumber()); + } + + /// + /// 生成当前管理员账号的 2FA 绑定信息 + /// + /// + [RequirePermission("system:admin:generatetwofactorsetup")] + [HttpPost] + public SingleOutputDto GenerateTwoFactorSetup() + { + return adminService.GenerateTwoFactorSetup(GetCurrentSerialNumber()); + } + + /// + /// 启用当前管理员账号 2FA + /// + /// + /// + [RequirePermission("system:admin:enabletwofactor")] + [HttpPost] + public SingleOutputDto EnableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return adminService.EnableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 关闭当前管理员账号 2FA + /// + /// + /// + [RequirePermission("system:admin:disabletwofactor")] + [HttpPost] + public BaseResponse DisableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return adminService.DisableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 重置当前管理员账号恢复备用码 + /// + /// + /// + [RequirePermission("system:admin:regeneratetwofactorrecoverycodes")] + [HttpPost] + public SingleOutputDto RegenerateTwoFactorRecoveryCodes([FromBody] TwoFactorCodeInputDto inputDto) + { + return adminService.RegenerateTwoFactorRecoveryCodes(GetCurrentSerialNumber(), inputDto); + } + /// /// 获取所有管理员列表 /// /// - [RequirePermission("system:admin:list")] + [RequirePermission("system:admin:getalladminlist")] [HttpGet] public ListOutputDto GetAllAdminList(ReadAdministratorInputDto readAdministratorInputDto) { @@ -55,7 +114,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:admin:create")] + [RequirePermission("system:admin:addadmin")] [HttpPost] public BaseResponse AddAdmin([FromBody] CreateAdministratorInputDto admin) { @@ -67,7 +126,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:admin:update")] + [RequirePermission("system:admin:updadmin")] [HttpPost] public BaseResponse UpdAdmin([FromBody] UpdateAdministratorInputDto updateAdministratorInputDto) { @@ -79,7 +138,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:admin:delete")] + [RequirePermission("system:admin:deladmin")] [HttpPost] public BaseResponse DelAdmin([FromBody] DeleteAdministratorInputDto deleteAdministratorInputDto) { @@ -90,7 +149,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 获取所有管理员类型 /// /// - [RequirePermission("system:admintype:list")] + [RequirePermission("system:admintype:getalladmintypes")] [HttpGet] public ListOutputDto GetAllAdminTypes(ReadAdministratorTypeInputDto readAdministratorTypeInputDto) { @@ -102,7 +161,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:admintype:create")] + [RequirePermission("system:admintype:addadmintype")] [HttpPost] public BaseResponse AddAdminType([FromBody] CreateAdministratorTypeInputDto createAdministratorTypeInputDto) { @@ -114,7 +173,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:admintype:update")] + [RequirePermission("system:admintype:updadmintype")] [HttpPost] public BaseResponse UpdAdminType([FromBody] UpdateAdministratorTypeInputDto updateAdministratorTypeInputDto) { @@ -126,7 +185,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:admintype:delete")] + [RequirePermission("system:admintype:deladmintype")] [HttpPost] public BaseResponse DelAdminType([FromBody] DeleteAdministratorTypeInputDto deleteAdministratorTypeInputDto) { @@ -138,7 +197,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:user:assign")] + [RequirePermission("system:user:admin:assignuserroles")] [HttpPost] public BaseResponse AssignUserRoles([FromBody] AssignUserRolesInputDto input) { @@ -148,31 +207,31 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 读取指定用户已分配的角色编码集合 /// - /// 用户编码 + /// 用户编码请求体 /// 角色编码集合(RoleNumber 列表) - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto ReadUserRoles([FromQuery] string userNumber) + [RequirePermission("system:user:admin.readuserroles")] + [HttpPost] + public ListOutputDto ReadUserRoles([FromBody] ReadByUserNumberInputDto input) { - return adminService.ReadUserRoles(userNumber); + return adminService.ReadUserRoles(input.UserNumber); } /// /// 读取指定用户的“角色-权限”明细(来自 RolePermission 关联,并联到 Permission 得到权限码与名称) /// - /// 用户编码 + /// 用户编码请求体 /// 明细列表(包含 RoleNumber、PermissionNumber、PermissionName、MenuKey) - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto ReadUserRolePermissions([FromQuery] string userNumber) + [RequirePermission("system:user:admin.readuserrolepermissions")] + [HttpPost] + public ListOutputDto ReadUserRolePermissions([FromBody] ReadByUserNumberInputDto input) { - return adminService.ReadUserRolePermissions(userNumber); + return adminService.ReadUserRolePermissions(input.UserNumber); } /// /// 为指定用户分配“直接权限”(通过专属角色 R-USER-{UserNumber} 写入 RolePermission,全量覆盖) /// - [RequirePermission("system:user:assign")] + [RequirePermission("system:user:admin:assignuserpermissions")] [HttpPost] public BaseResponse AssignUserPermissions([FromBody] AssignUserPermissionsInputDto input) { @@ -182,11 +241,22 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 读取指定用户的“直接权限”(仅来自专属角色 R-USER-{UserNumber} 的权限编码列表) /// - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto ReadUserDirectPermissions([FromQuery] string userNumber) + [RequirePermission("system:user:admin.readuserdirectpermissions")] + [HttpPost] + public ListOutputDto ReadUserDirectPermissions([FromBody] ReadByUserNumberInputDto input) + { + return adminService.ReadUserDirectPermissions(input.UserNumber); + } + + /// + /// 从当前登录上下文中读取账号序列号 + /// + /// + private string GetCurrentSerialNumber() { - return adminService.ReadUserDirectPermissions(userNumber); + return User.FindFirstValue(ClaimTypes.SerialNumber) + ?? User.FindFirst("serialnumber")?.Value + ?? string.Empty; } } } diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Base/BaseController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Base/BaseController.cs index 5d299c779130f3c3bf5133c306c7acc554fe6102..7c626b89c7ec33d81b35b25d79174771cda3c6b5 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Base/BaseController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Base/BaseController.cs @@ -1,5 +1,6 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; +using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; namespace EOM.TSHotelManagement.WebApi.Controllers @@ -67,6 +68,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers #region 职位模块 + [RequirePermission("position.view")] [HttpGet] public ListOutputDto SelectPositionAll([FromQuery] ReadPositionInputDto position = null) { @@ -79,18 +81,21 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return baseService.SelectPosition(position); } + [RequirePermission("position.create")] [HttpPost] public BaseResponse AddPosition([FromBody] CreatePositionInputDto position) { return baseService.AddPosition(position); } + [RequirePermission("position.delete")] [HttpPost] public BaseResponse DelPosition([FromBody] DeletePositionInputDto position) { return baseService.DelPosition(position); } + [RequirePermission("position.update")] [HttpPost] public BaseResponse UpdPosition([FromBody] UpdatePositionInputDto position) { @@ -101,6 +106,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers #region 民族模块 + [RequirePermission("nation.view")] [HttpGet] public ListOutputDto SelectNationAll([FromQuery] ReadNationInputDto nation = null) { @@ -113,18 +119,21 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return baseService.SelectNation(nation); } + [RequirePermission("nation.create")] [HttpPost] public BaseResponse AddNation([FromBody] CreateNationInputDto nation) { return baseService.AddNation(nation); } + [RequirePermission("nation.delete")] [HttpPost] public BaseResponse DelNation([FromBody] DeleteNationInputDto nation) { return baseService.DelNation(nation); } + [RequirePermission("nation.update")] [HttpPost] public BaseResponse UpdNation([FromBody] UpdateNationInputDto nation) { @@ -135,6 +144,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers #region 学历模块 + [RequirePermission("qualification.view")] [HttpGet] public ListOutputDto SelectEducationAll([FromQuery] ReadEducationInputDto education = null) { @@ -147,18 +157,21 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return baseService.SelectEducation(education); } + [RequirePermission("qualification.create")] [HttpPost] public BaseResponse AddEducation([FromBody] CreateEducationInputDto education) { return baseService.AddEducation(education); } + [RequirePermission("qualification.delete")] [HttpPost] public BaseResponse DelEducation([FromBody] DeleteEducationInputDto education) { return baseService.DelEducation(education); } + [RequirePermission("qualification.update")] [HttpPost] public BaseResponse UpdEducation([FromBody] UpdateEducationInputDto education) { @@ -169,6 +182,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers #region 部门模块 + [RequirePermission("department.view")] [HttpGet] public ListOutputDto SelectDeptAllCanUse() { @@ -181,24 +195,28 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return baseService.SelectDeptAll(readDepartmentInputDto); } + [RequirePermission("department.view")] [HttpGet] public SingleOutputDto SelectDept([FromQuery] ReadDepartmentInputDto dept) { return baseService.SelectDept(dept); } + [RequirePermission("department.create")] [HttpPost] public BaseResponse AddDept([FromBody] CreateDepartmentInputDto dept) { return baseService.AddDept(dept); } + [RequirePermission("department.delete")] [HttpPost] public BaseResponse DelDept([FromBody] DeleteDepartmentInputDto dept) { return baseService.DelDept(dept); } + [RequirePermission("department.update")] [HttpPost] public BaseResponse UpdDept([FromBody] UpdateDepartmentInputDto dept) { @@ -209,6 +227,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers #region 客户类型模块 + [RequirePermission("customertype.view")] [HttpGet] public ListOutputDto SelectCustoTypeAllCanUse() { @@ -221,24 +240,28 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return baseService.SelectCustoTypeAll(readCustoTypeInputDto); } + [RequirePermission("customertype.view")] [HttpGet] public SingleOutputDto SelectCustoTypeByTypeId([FromQuery] ReadCustoTypeInputDto custoType) { return baseService.SelectCustoTypeByTypeId(custoType); } + [RequirePermission("customertype.create")] [HttpPost] public BaseResponse InsertCustoType([FromBody] CreateCustoTypeInputDto custoType) { return baseService.InsertCustoType(custoType); } + [RequirePermission("customertype.delete")] [HttpPost] public BaseResponse DeleteCustoType([FromBody] DeleteCustoTypeInputDto custoType) { return baseService.DeleteCustoType(custoType); } + [RequirePermission("customertype.update")] [HttpPost] public BaseResponse UpdateCustoType([FromBody] UpdateCustoTypeInputDto custoType) { @@ -249,6 +272,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers #region 证件类型模块 + [RequirePermission("passport.view")] [HttpGet] public ListOutputDto SelectPassPortTypeAllCanUse() { @@ -261,24 +285,28 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return baseService.SelectPassPortTypeAll(readPassportTypeInputDto); } + [RequirePermission("passport.view")] [HttpGet] public SingleOutputDto SelectPassPortTypeByTypeId([FromQuery] ReadPassportTypeInputDto passPortType) { return baseService.SelectPassPortTypeByTypeId(passPortType); } + [RequirePermission("passport.create")] [HttpPost] public BaseResponse InsertPassPortType([FromBody] CreatePassportTypeInputDto passPortType) { return baseService.InsertPassPortType(passPortType); } + [RequirePermission("passport.delete")] [HttpPost] public BaseResponse DeletePassPortType([FromBody] DeletePassportTypeInputDto portType) { return baseService.DeletePassPortType(portType); } + [RequirePermission("passport.update")] [HttpPost] public BaseResponse UpdatePassPortType([FromBody] UpdatePassportTypeInputDto portType) { @@ -333,6 +361,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询所有公告类型 /// /// + [RequirePermission("noticetype.view")] [HttpGet] public ListOutputDto SelectAppointmentNoticeTypeAll([FromQuery] ReadAppointmentNoticeTypeInputDto readAppointmentNoticeTypeInputDto) { @@ -344,6 +373,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// + [RequirePermission("noticetype.create")] [HttpPost] public BaseResponse CreateAppointmentNoticeType([FromBody] CreateAppointmentNoticeTypeInputDto createAppointmentNoticeTypeInputDto) { @@ -355,6 +385,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// + [RequirePermission("noticetype.delete")] [HttpPost] public BaseResponse DeleteAppointmentNoticeType([FromBody] DeleteAppointmentNoticeTypeInputDto deleteAppointmentNoticeTypeInputDto) { @@ -366,6 +397,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// + [RequirePermission("noticetype.update")] [HttpPost] public BaseResponse UpdateAppointmentNoticeType([FromBody] UpdateAppointmentNoticeTypeInputDto updateAppointmentNoticeTypeInputDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/CustomerPermission/CustomerPermissionController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/CustomerPermission/CustomerPermissionController.cs index 8595ee3de1eac64aaee39f685c3a10e70325cb30..d10377924aad9985b6c9b76ef398e24b120b8fee 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/CustomerPermission/CustomerPermissionController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/CustomerPermission/CustomerPermissionController.cs @@ -11,11 +11,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 客户组权限分配接口(与管理员一致的 5 个接口) /// 前端将调用: - /// - POST /Customer/AssignUserRoles - /// - GET /Customer/ReadUserRoles?userNumber=... - /// - GET /Customer/ReadUserRolePermissions?userNumber=... - /// - POST /Customer/AssignUserPermissions - /// - GET /Customer/ReadUserDirectPermissions?userNumber=... + /// - POST /CustomerPermission/AssignUserRoles + /// - POST /CustomerPermission/ReadUserRoles + /// - POST /CustomerPermission/ReadUserRolePermissions + /// - POST /CustomerPermission/AssignUserPermissions + /// - POST /CustomerPermission/ReadUserDirectPermissions /// public class CustomerPermissionController : ControllerBase { @@ -31,7 +31,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 为客户分配角色(全量覆盖) /// - [RequirePermission("system:user:assign")] + [RequirePermission("system:user:customer:assignuserroles")] [HttpPost] public BaseResponse AssignUserRoles([FromBody] AssignUserRolesInputDto input) { @@ -43,11 +43,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 读取客户已分配的角色编码集合 /// - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto ReadUserRoles([FromQuery] string userNumber) + [RequirePermission("system:user:customer.readuserroles")] + [HttpPost] + public ListOutputDto ReadUserRoles([FromBody] ReadByUserNumberInputDto input) { - return customerPermService.ReadUserRoles(userNumber); + return customerPermService.ReadUserRoles(input.UserNumber); } /// filename OR language.declaration() @@ -55,11 +55,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 读取客户“角色-权限”明细 /// - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto ReadUserRolePermissions([FromQuery] string userNumber) + [RequirePermission("system:user:customer.readuserrolepermissions")] + [HttpPost] + public ListOutputDto ReadUserRolePermissions([FromBody] ReadByUserNumberInputDto input) { - return customerPermService.ReadUserRolePermissions(userNumber); + return customerPermService.ReadUserRolePermissions(input.UserNumber); } /// filename OR language.declaration() @@ -67,7 +67,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 为客户分配“直接权限”(R-USER-{UserNumber} 全量覆盖) /// - [RequirePermission("system:user:assign")] + [RequirePermission("system:user:customer:assignuserpermissions")] [HttpPost] public BaseResponse AssignUserPermissions([FromBody] AssignUserPermissionsInputDto input) { @@ -79,11 +79,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 读取客户“直接权限”权限编码集合(来自 R-USER-{UserNumber}) /// - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto ReadUserDirectPermissions([FromQuery] string userNumber) + [RequirePermission("system:user:customer.readuserdirectpermissions")] + [HttpPost] + public ListOutputDto ReadUserDirectPermissions([FromBody] ReadByUserNumberInputDto input) { - return customerPermService.ReadUserDirectPermissions(userNumber); + return customerPermService.ReadUserDirectPermissions(input.UserNumber); } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/EmployeePermission/EmployeeController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/EmployeePermission/EmployeeController.cs index 24ee02060222cb5415bcc3649b49445d56b30341..05f3b05ae6e7fd09b91cc346cff28d114cd905a0 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/EmployeePermission/EmployeeController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/EmployeePermission/EmployeeController.cs @@ -11,11 +11,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 员工组权限分配接口(与管理员一致的 5 个接口) /// 前端将调用: - /// - POST /Employee/AssignUserRoles - /// - GET /Employee/ReadUserRoles?userNumber=... - /// - GET /Employee/ReadUserRolePermissions?userNumber=... - /// - POST /Employee/AssignUserPermissions - /// - GET /Employee/ReadUserDirectPermissions?userNumber=... + /// - POST /EmployeePermission/AssignUserRoles + /// - POST /EmployeePermission/ReadUserRoles + /// - POST /EmployeePermission/ReadUserRolePermissions + /// - POST /EmployeePermission/AssignUserPermissions + /// - POST /EmployeePermission/ReadUserDirectPermissions /// public class EmployeePermissionController : ControllerBase { @@ -31,7 +31,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 为员工分配角色(全量覆盖) /// - [RequirePermission("system:user:assign")] + [RequirePermission("system:user:employee:assignuserroles")] [HttpPost] public BaseResponse AssignUserRoles([FromBody] AssignUserRolesInputDto input) { @@ -43,11 +43,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 读取员工已分配的角色编码集合 /// - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto ReadUserRoles([FromQuery] string userNumber) + [RequirePermission("system:user:employee.readuserroles")] + [HttpPost] + public ListOutputDto ReadUserRoles([FromBody] ReadByUserNumberInputDto input) { - return employeePermService.ReadUserRoles(userNumber); + return employeePermService.ReadUserRoles(input.UserNumber); } /// filename OR language.declaration() @@ -55,11 +55,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 读取员工“角色-权限”明细 /// - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto ReadUserRolePermissions([FromQuery] string userNumber) + [RequirePermission("system:user:employee.readuserrolepermissions")] + [HttpPost] + public ListOutputDto ReadUserRolePermissions([FromBody] ReadByUserNumberInputDto input) { - return employeePermService.ReadUserRolePermissions(userNumber); + return employeePermService.ReadUserRolePermissions(input.UserNumber); } /// filename OR language.declaration() @@ -67,7 +67,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 为员工分配“直接权限”(R-USER-{UserNumber} 全量覆盖) /// - [RequirePermission("system:user:assign")] + [RequirePermission("system:user:employee:assignuserpermissions")] [HttpPost] public BaseResponse AssignUserPermissions([FromBody] AssignUserPermissionsInputDto input) { @@ -79,11 +79,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 读取员工“直接权限”权限编码集合(来自 R-USER-{UserNumber}) /// - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto ReadUserDirectPermissions([FromQuery] string userNumber) + [RequirePermission("system:user:employee.readuserdirectpermissions")] + [HttpPost] + public ListOutputDto ReadUserDirectPermissions([FromBody] ReadByUserNumberInputDto input) { - return employeePermService.ReadUserDirectPermissions(userNumber); + return employeePermService.ReadUserDirectPermissions(input.UserNumber); } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Menu/MenuController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Menu/MenuController.cs index b191d5a57290b6a0756f9c2912a9e3d9dc60e20d..3e3fdb0378891def4d99d03be5169dec8b77a34e 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Menu/MenuController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Menu/MenuController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -21,7 +21,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询所有菜单信息 /// /// - [RequirePermission("menumanagement.view")] + [RequirePermission("menumanagement.selectmenuall")] [HttpGet] public ListOutputDto SelectMenuAll(ReadMenuInputDto readMenuInputDto) { @@ -32,7 +32,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 构建菜单树 /// /// - [RequirePermission("menumanagement.view")] + [RequirePermission("menumanagement.buildmenuall")] [HttpPost] public ListOutputDto BuildMenuAll([FromBody] BaseInputDto baseInputDto) { @@ -44,7 +44,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("menumanagement.create")] + [RequirePermission("menumanagement.insertmenu")] [HttpPost] public BaseResponse InsertMenu([FromBody] CreateMenuInputDto menu) { @@ -56,7 +56,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("menumanagement.update")] + [RequirePermission("menumanagement.updatemenu")] [HttpPost] public BaseResponse UpdateMenu([FromBody] UpdateMenuInputDto menu) { @@ -68,7 +68,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("menumanagement.delete")] + [RequirePermission("menumanagement.deletemenu")] [HttpPost] public BaseResponse DeleteMenu([FromBody] DeleteMenuInputDto menu) { diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Permission/PermissionController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Permission/PermissionController.cs index cb317314ce9cfd42779162b32a8f7743015ffe31..26e414bbba4c94e03be936d7623b6d019a637521 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Permission/PermissionController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Permission/PermissionController.cs @@ -22,11 +22,11 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 查询条件 /// 权限列表 - [RequirePermission("system:user:assign.view")] - [HttpGet] - public ListOutputDto SelectPermissionList([FromQuery] ReadPermissionInputDto input) + [RequirePermission("system:user:assign.selectpermissionlist")] + [HttpPost] + public ListOutputDto SelectPermissionList([FromBody] ReadPermissionInputDto input) { return _permissionAppService.SelectPermissionList(input); } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Role/RoleController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Role/RoleController.cs index 312e9f1737daa3e32b16e27c8414c3977394537d..fd271e2d3675c9899459762a7c9ed377c2c38dbf 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Role/RoleController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Role/RoleController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Contract.SystemManagement.Dto.Permission; using EOM.TSHotelManagement.Contract.SystemManagement.Dto.Role; using EOM.TSHotelManagement.Service; @@ -21,7 +21,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:role:list")] + [RequirePermission("system:role:selectrolelist")] [HttpGet] public ListOutputDto SelectRoleList([FromQuery] ReadRoleInputDto readRoleInputDto) { @@ -33,7 +33,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:role:create")] + [RequirePermission("system:role:insertrole")] [HttpPost] public BaseResponse InsertRole([FromBody] CreateRoleInputDto createRoleInputDto) { @@ -45,7 +45,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:role:update")] + [RequirePermission("system:role:updaterole")] [HttpPost] public BaseResponse UpdateRole([FromBody] UpdateRoleInputDto updateRoleInputDto) { @@ -57,7 +57,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:role:delete")] + [RequirePermission("system:role:deleterole")] [HttpPost] public BaseResponse DeleteRole([FromBody] DeleteRoleInputDto deleteRoleInputDto) { @@ -69,7 +69,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("system:role:grant")] + [RequirePermission("system:role:grantrolepermissions")] [HttpPost] public BaseResponse GrantRolePermissions([FromBody] GrantRolePermissionsInputDto input) { @@ -79,30 +79,30 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// 读取指定角色已授予的权限编码集合 /// - /// 角色编码 - [RequirePermission("system:role:list")] - [HttpGet] - public ListOutputDto ReadRolePermissions([FromQuery] string roleNumber) + /// 角色编码请求体 + [RequirePermission("system:role:readrolepermissions")] + [HttpPost] + public ListOutputDto ReadRolePermissions([FromBody] ReadByRoleNumberInputDto input) { - return _roleAppService.ReadRolePermissions(roleNumber); + return _roleAppService.ReadRolePermissions(input.RoleNumber); } /// /// 读取隶属于指定角色的管理员用户编码集合 /// - /// 角色编码 - [RequirePermission("system:role:list")] - [HttpGet] - public ListOutputDto ReadRoleUsers([FromQuery] string roleNumber) + /// 角色编码请求体 + [RequirePermission("system:role:readroleusers")] + [HttpPost] + public ListOutputDto ReadRoleUsers([FromBody] ReadByRoleNumberInputDto input) { - return _roleAppService.ReadRoleUsers(roleNumber); + return _roleAppService.ReadRoleUsers(input.RoleNumber); } /// /// 为角色分配管理员(全量覆盖) /// /// 包含角色编码与管理员编码集合 - [RequirePermission("system:role:grant")] + [RequirePermission("system:role:assignroleusers")] [HttpPost] public BaseResponse AssignRoleUsers([FromBody] AssignRoleUsersInputDto input) { diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/SupervisionStatistics/SupervisionStatisticsController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/SupervisionStatistics/SupervisionStatisticsController.cs index 21c43568c883103f141863611327fade076d616d..b9351b7dd1e9ae70e4c9111e60b4b328c969228e 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/SupervisionStatistics/SupervisionStatisticsController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/SupervisionStatistics/SupervisionStatisticsController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,7 +22,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("supervisioninfo.view")] + [RequirePermission("supervisioninfo.selectsupervisionstatisticsall")] [HttpGet] public ListOutputDto SelectSupervisionStatisticsAll([FromQuery] ReadSupervisionStatisticsInputDto inputDto) { @@ -34,7 +34,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("supervisioninfo.create")] + [RequirePermission("supervisioninfo.insertsupervisionstatistics")] [HttpPost] public BaseResponse InsertSupervisionStatistics([FromBody] CreateSupervisionStatisticsInputDto inputDto) { @@ -46,7 +46,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("supervisioninfo.update")] + [RequirePermission("supervisioninfo.updatesupervisionstatistics")] [HttpPost] public BaseResponse UpdateSupervisionStatistics([FromBody] UpdateSupervisionStatisticsInputDto inputDto) { @@ -58,7 +58,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("supervisioninfo.delete")] + [RequirePermission("supervisioninfo.deletesupervisionstatistics")] [HttpPost] public BaseResponse DeleteSupervisionStatistics([FromBody] DeleteSupervisionStatisticsInputDto inputDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/SystemManagement/VipRule/VipRuleController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/VipRule/VipRuleController.cs index e1ace8be91be11c221d4d150330ec881ddcfe94c..460c37fb084ec54e9b5fe70dd2a6ead2c20776b6 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/VipRule/VipRuleController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/VipRule/VipRuleController.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; @@ -22,7 +22,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("viplevel.view")] + [RequirePermission("viplevel.selectviprulelist")] [HttpGet] public ListOutputDto SelectVipRuleList([FromQuery] ReadVipLevelRuleInputDto inputDto) { @@ -34,7 +34,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("viplevel.view")] + [RequirePermission("viplevel.selectviprule")] [HttpGet] public SingleOutputDto SelectVipRule([FromQuery] ReadVipLevelRuleInputDto inputDto) { @@ -46,7 +46,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("viplevel.create")] + [RequirePermission("viplevel.addviprule")] [HttpPost] public BaseResponse AddVipRule([FromBody] CreateVipLevelRuleInputDto inputDto) { @@ -58,7 +58,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("viplevel.delete")] + [RequirePermission("viplevel.delviprule")] [HttpPost] public BaseResponse DelVipRule([FromBody] DeleteVipLevelRuleInputDto inputDto) { @@ -70,7 +70,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// - [RequirePermission("viplevel.update")] + [RequirePermission("viplevel.updviprule")] [HttpPost] public BaseResponse UpdVipRule([FromBody] UpdateVipLevelRuleInputDto inputDto) { diff --git a/EOM.TSHotelManagement.API/Controllers/Util/UtilityController.cs b/EOM.TSHotelManagement.API/Controllers/Util/UtilityController.cs index ad71e1ce22ac5b8e6989ddb56a103e88be123904..e89edacbe94723ce3fab4d8d1905da409cd864dc 100644 --- a/EOM.TSHotelManagement.API/Controllers/Util/UtilityController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Util/UtilityController.cs @@ -1,5 +1,6 @@ -using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Service; +using EOM.TSHotelManagement.WebApi.Authorization; using Microsoft.AspNetCore.Mvc; namespace EOM.TSHotelManagement.WebApi.Controllers @@ -42,6 +43,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// 查询所有操作日志 /// /// + [RequirePermission("operationlog.view")] [HttpGet] public ListOutputDto SelectOperationlogAll([FromQuery] ReadOperationLogInputDto readOperationLogInputDto) { @@ -53,6 +55,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// + [RequirePermission("requestlog.view")] [HttpGet] public ListOutputDto SelectRequestlogAll([FromQuery] ReadRequestLogInputDto readRequestLogInputDto) { @@ -64,6 +67,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// + [RequirePermission("requestlog.delete")] [HttpPost] public BaseResponse DeleteRequestlogByRange([FromBody] ReadRequestLogInputDto readRequestLogInputDto) { @@ -75,6 +79,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// + [RequirePermission("operationlog.delete")] [HttpPost] public BaseResponse DeleteOperationlogByRange([FromBody] ReadOperationLogInputDto readOperationLogInputDto) { @@ -86,6 +91,7 @@ namespace EOM.TSHotelManagement.WebApi.Controllers /// /// /// + [RequirePermission("operationlog.delete")] [HttpPost] public BaseResponse DeleteOperationlog([FromBody] DeleteOperationLogInputDto deleteOperationLogInputDto) { diff --git a/EOM.TSHotelManagement.API/Extensions/ApplicationExtensions.cs b/EOM.TSHotelManagement.API/Extensions/ApplicationExtensions.cs index 590b5a5fa9929a1dca59989cbf7ada95e35cf8d3..f3342a52482ace48f38036a44b7503bd6eb1b8d0 100644 --- a/EOM.TSHotelManagement.API/Extensions/ApplicationExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/ApplicationExtensions.cs @@ -33,6 +33,7 @@ namespace EOM.TSHotelManagement.WebApi app.UseAuthorization(); app.UseAntiforgery(); app.UseRequestLogging(); + app.UseIdempotencyKey(); } /// @@ -103,4 +104,4 @@ namespace EOM.TSHotelManagement.WebApi }); } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs b/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs index 6c75f1490f6ab88d148e4c2e82b8bb7fa952efae..8d902cd684b2aa1fe419307aa287ec6b0310a439 100644 --- a/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs @@ -27,12 +27,16 @@ namespace EOM.TSHotelManagement.WebApi builder.RegisterType() .InstancePerDependency(); + builder.RegisterType() + .InstancePerDependency(); builder.RegisterType().AsSelf().SingleInstance(); + builder.RegisterType().AsSelf().SingleInstance(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterType().AsSelf().InstancePerLifetimeScope(); + builder.RegisterType().AsSelf().InstancePerLifetimeScope(); builder.RegisterGeneric(typeof(GenericRepository<>)).AsSelf().InstancePerLifetimeScope(); diff --git a/EOM.TSHotelManagement.API/Extensions/MiddlewareExtensions.cs b/EOM.TSHotelManagement.API/Extensions/MiddlewareExtensions.cs index 7e7f5d4774e745563d235ed5a2af40704b8f6153..c895ba40a813dc498bb531ac762448f09eb765d0 100644 --- a/EOM.TSHotelManagement.API/Extensions/MiddlewareExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/MiddlewareExtensions.cs @@ -4,6 +4,12 @@ namespace EOM.TSHotelManagement.WebApi { public static class MiddlewareExtensions { + public static IApplicationBuilder UseIdempotencyKey( + this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + public static IApplicationBuilder UseRequestLogging( this IApplicationBuilder builder) { diff --git a/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs b/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs index 2e9b9daed0d509005e28fd9527fb2bee66face23..14b066ad61bcb5e91ec920a3407d194bbbb28cd6 100644 --- a/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs @@ -1,5 +1,6 @@ -using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Common; using EOM.TSHotelManagement.Infrastructure; +using EOM.TSHotelManagement.Service; using EOM.TSHotelManagement.WebApi.Authorization; using EOM.TSHotelManagement.WebApi.Filters; using jvncorelib.CodeLib; @@ -13,20 +14,32 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.IdentityModel.Tokens; using NSwag; using NSwag.Generation.Processors.Security; using Quartz; using System; +using System.IdentityModel.Tokens.Jwt; using System.IO; using System.Linq; +using System.Security.Claims; using System.Text; using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; namespace EOM.TSHotelManagement.WebApi { public static class ServiceExtensions { + private const string AuthFailureReasonItemKey = JwtAuthConstants.AuthFailureReasonItemKey; + private const string AuthFailureReasonTokenRevoked = JwtAuthConstants.AuthFailureReasonTokenRevoked; + private const string AuthFailureReasonTokenExpired = JwtAuthConstants.AuthFailureReasonTokenExpired; + private const string AuthFailureReasonTokenInvalid = JwtAuthConstants.AuthFailureReasonTokenInvalid; + private const string JwtTokenUserIdItemKey = JwtAuthConstants.JwtTokenUserIdItemKey; + private const string JwtTokenJtiItemKey = JwtAuthConstants.JwtTokenJtiItemKey; + public static void ConfigureDataProtection(this IServiceCollection services, IConfiguration configuration) { if (Environment.GetEnvironmentVariable(SystemConstant.Env.Code) == SystemConstant.Docker.Code) @@ -131,10 +144,13 @@ namespace EOM.TSHotelManagement.WebApi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.Configure(configuration.GetSection("CsrfToken")); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddHostedService(); // RBAC: 注册基于权限码的动态策略提供者与处理器 services.AddSingleton(); @@ -158,6 +174,48 @@ namespace EOM.TSHotelManagement.WebApi IssuerSigningKey = new SymmetricSecurityKey( Encoding.UTF8.GetBytes(configuration["Jwt:Key"] ?? throw new InvalidOperationException("JWT Key is not configured"))) }; + + options.Events = new JwtBearerEvents + { + OnTokenValidated = async context => + { + var authorizationHeader = context.HttpContext.Request.Headers["Authorization"].ToString(); + if (!JwtTokenRevocationService.TryGetBearerToken(authorizationHeader, out var token)) + { + context.Fail("Missing token."); + return; + } + + var userId = context.Principal?.FindFirst(ClaimTypes.SerialNumber)?.Value + ?? context.Principal?.FindFirst("serialnumber")?.Value + ?? context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? context.Principal?.FindFirst(JwtRegisteredClaimNames.Sub)?.Value; + if (!string.IsNullOrWhiteSpace(userId)) + { + context.HttpContext.Items[JwtTokenUserIdItemKey] = userId; + } + + var jti = context.Principal?.FindFirst(JwtRegisteredClaimNames.Jti)?.Value; + if (!string.IsNullOrWhiteSpace(jti)) + { + context.HttpContext.Items[JwtTokenJtiItemKey] = jti; + } + + var tokenRevocationService = context.HttpContext.RequestServices + .GetRequiredService(); + + if (await tokenRevocationService.IsTokenRevokedAsync(token)) + { + context.HttpContext.Items[AuthFailureReasonItemKey] = AuthFailureReasonTokenRevoked; + context.Fail("Token has been revoked."); + } + }, + OnAuthenticationFailed = context => + { + context.HttpContext.Items[AuthFailureReasonItemKey] = ResolveAuthFailureReason(context.Exception); + return Task.CompletedTask; + } + }; }); services.AddAuthorization(options => @@ -266,5 +324,34 @@ namespace EOM.TSHotelManagement.WebApi }); }); } + + private static string ResolveAuthFailureReason(Exception exception) + { + return exception switch + { + SecurityTokenExpiredException => AuthFailureReasonTokenExpired, + SecurityTokenInvalidSignatureException => AuthFailureReasonTokenInvalid, + SecurityTokenInvalidAudienceException => AuthFailureReasonTokenInvalid, + SecurityTokenInvalidIssuerException => AuthFailureReasonTokenInvalid, + SecurityTokenNoExpirationException => AuthFailureReasonTokenInvalid, + _ => AuthFailureReasonTokenInvalid + }; + } + } + internal sealed class DeleteConcurrencyHelperWarmupService : IHostedService + { + public DeleteConcurrencyHelperWarmupService(DeleteConcurrencyHelper helper) + { + } + + public Task StartAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } } } diff --git a/EOM.TSHotelManagement.API/Filters/IdempotencyKeyMiddleware.cs b/EOM.TSHotelManagement.API/Filters/IdempotencyKeyMiddleware.cs new file mode 100644 index 0000000000000000000000000000000000000000..c343372b8888e1718d6d02b52696b675171e00b8 --- /dev/null +++ b/EOM.TSHotelManagement.API/Filters/IdempotencyKeyMiddleware.cs @@ -0,0 +1,643 @@ +using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Contract; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using StackExchange.Redis; +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace EOM.TSHotelManagement.WebApi +{ + public class IdempotencyKeyMiddleware + { + private const string IdempotencyHeaderName = "Idempotency-Key"; + private const string TenantHeaderName = "X-Tenant-Id"; + private const string ReplayHeaderName = "X-Idempotent-Replay"; + private const string InProgressStatus = "IN_PROGRESS"; + private const string CompletedStatus = "COMPLETED"; + private const string DefaultContentType = "application/json; charset=utf-8"; + + private static readonly ConcurrentDictionary MemoryStore = new(); + private static long _memoryRequestCount; + + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly RedisHelper _redisHelper; + private readonly bool _enabled; + private readonly bool _enforceKey; + private readonly bool _persistFailureResponse; + private readonly int _maxKeyLength; + private readonly TimeSpan _inProgressTtl; + private readonly TimeSpan _completedTtl; + private readonly bool _useRedis; + + public IdempotencyKeyMiddleware( + RequestDelegate next, + IConfiguration configuration, + ILogger logger, + RedisHelper redisHelper) + { + _next = next; + _logger = logger; + _redisHelper = redisHelper; + + var section = configuration.GetSection("Idempotency"); + _enabled = section.GetValue("Enabled") ?? true; + _enforceKey = section.GetValue("EnforceKey") ?? false; + _persistFailureResponse = section.GetValue("PersistFailureResponse") ?? false; + _maxKeyLength = Math.Max(16, section.GetValue("MaxKeyLength") ?? 128); + + var inProgressSeconds = section.GetValue("InProgressTtlSeconds") ?? 120; + var completedHours = section.GetValue("CompletedTtlHours") ?? 24; + _inProgressTtl = TimeSpan.FromSeconds(Math.Clamp(inProgressSeconds, 30, 600)); + _completedTtl = TimeSpan.FromHours(Math.Clamp(completedHours, 1, 168)); + + _useRedis = ResolveRedisEnabled(configuration); + } + + public async Task InvokeAsync(HttpContext context) + { + if (!_enabled || !IsWriteMethod(context.Request.Method)) + { + await _next(context); + return; + } + + var idempotencyKey = context.Request.Headers[IdempotencyHeaderName].ToString().Trim(); + if (string.IsNullOrWhiteSpace(idempotencyKey)) + { + if (_enforceKey) + { + await WriteBusinessErrorAsync( + context, + StatusCodes.Status428PreconditionRequired, + BusinessStatusCode.IdempotencyKeyMissing, + LocalizationHelper.GetLocalizedString( + "Missing Idempotency-Key header.", + "缺少 Idempotency-Key 请求头。")); + return; + } + + _logger.LogWarning("Write request missing Idempotency-Key. Method={Method}, Path={Path}", context.Request.Method, context.Request.Path); + await _next(context); + return; + } + + if (idempotencyKey.Length > _maxKeyLength) + { + await WriteBusinessErrorAsync( + context, + StatusCodes.Status400BadRequest, + BusinessStatusCode.IdempotencyKeyMissing, + LocalizationHelper.GetLocalizedString( + $"Idempotency-Key exceeds max length {_maxKeyLength}.", + $"Idempotency-Key 长度超过最大限制 {_maxKeyLength}。")); + return; + } + + var requestHash = await ComputeRequestHashAsync(context.Request); + var scopeKey = BuildScopeKey(context, idempotencyKey); + + var acquireResult = await AcquireAsync(scopeKey, requestHash); + if (acquireResult.Decision == IdempotencyDecision.PayloadConflict) + { + await WriteBusinessErrorAsync( + context, + StatusCodes.Status409Conflict, + BusinessStatusCode.IdempotencyKeyPayloadConflict, + LocalizationHelper.GetLocalizedString( + "Idempotency-Key was reused with a different payload.", + "Idempotency-Key 被复用且请求体不一致。")); + return; + } + + if (acquireResult.Decision == IdempotencyDecision.InProgress) + { + await WriteBusinessErrorAsync( + context, + StatusCodes.Status409Conflict, + BusinessStatusCode.IdempotencyRequestInProgress, + LocalizationHelper.GetLocalizedString( + "A request with the same Idempotency-Key is still in progress.", + "相同 Idempotency-Key 的请求仍在处理中。")); + return; + } + + if (acquireResult.Decision == IdempotencyDecision.Replay && acquireResult.Record != null) + { + await ReplayResponseAsync(context, acquireResult.Record); + return; + } + + await ExecuteAndStoreAsync(context, scopeKey, requestHash); + } + + private async Task ExecuteAndStoreAsync(HttpContext context, string scopeKey, string requestHash) + { + var originalResponseBody = context.Response.Body; + + try + { + using var responseBuffer = new MemoryStream(); + context.Response.Body = responseBuffer; + + await _next(context); + + responseBuffer.Seek(0, SeekOrigin.Begin); + var responseBody = await new StreamReader(responseBuffer, Encoding.UTF8, leaveOpen: true).ReadToEndAsync(); + responseBuffer.Seek(0, SeekOrigin.Begin); + await responseBuffer.CopyToAsync(originalResponseBody); + + var shouldPersist = _persistFailureResponse || IsSuccessStatusCode(context.Response.StatusCode); + if (shouldPersist) + { + var completedRecord = new IdempotencyRecord + { + Status = CompletedStatus, + RequestHash = requestHash, + HttpStatus = context.Response.StatusCode, + ResponseBody = responseBody, + ContentType = context.Response.ContentType, + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow + }; + + await SaveCompletedAsync(scopeKey, completedRecord); + } + else + { + await ReleaseAsync(scopeKey); + } + } + catch + { + await ReleaseAsync(scopeKey); + throw; + } + finally + { + context.Response.Body = originalResponseBody; + } + } + + private async Task ReplayResponseAsync(HttpContext context, IdempotencyRecord record) + { + context.Response.Headers[ReplayHeaderName] = "true"; + context.Response.StatusCode = record.HttpStatus ?? StatusCodes.Status200OK; + context.Response.ContentType = string.IsNullOrWhiteSpace(record.ContentType) ? DefaultContentType : record.ContentType; + + if (!string.IsNullOrEmpty(record.ResponseBody)) + { + await context.Response.WriteAsync(record.ResponseBody); + } + } + + private async Task AcquireAsync(string scopeKey, string requestHash) + { + if (_useRedis) + { + try + { + return await AcquireFromRedisAsync(scopeKey, requestHash); + } + catch (Exception ex) + { + _logger.LogError(ex, "Idempotency acquire failed on Redis, fallback to memory store. Scope={Scope}", scopeKey); + } + } + + return AcquireFromMemory(scopeKey, requestHash); + } + + private async Task SaveCompletedAsync(string scopeKey, IdempotencyRecord record) + { + if (_useRedis) + { + try + { + await SaveCompletedToRedisAsync(scopeKey, record); + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Idempotency save-completed failed on Redis, fallback to memory store. Scope={Scope}", scopeKey); + } + } + + SaveCompletedToMemory(scopeKey, record); + } + + private async Task ReleaseAsync(string scopeKey) + { + if (_useRedis) + { + try + { + await ReleaseFromRedisAsync(scopeKey); + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Idempotency release failed on Redis, fallback to memory store. Scope={Scope}", scopeKey); + } + } + + ReleaseFromMemory(scopeKey); + } + + private async Task AcquireFromRedisAsync(string scopeKey, string requestHash) + { + var now = DateTimeOffset.UtcNow; + var inProgressRecord = new IdempotencyRecord + { + Status = InProgressStatus, + RequestHash = requestHash, + CreatedAt = now, + UpdatedAt = now + }; + + var db = _redisHelper.GetDatabase(); + var inserted = await db.StringSetAsync( + scopeKey, + JsonSerializer.Serialize(inProgressRecord), + _inProgressTtl, + when: When.NotExists); + + if (inserted) + { + return AcquireResult.Proceed(); + } + + var existingValue = await db.StringGetAsync(scopeKey); + if (existingValue.IsNullOrEmpty) + { + inserted = await db.StringSetAsync( + scopeKey, + JsonSerializer.Serialize(inProgressRecord), + _inProgressTtl, + when: When.NotExists); + + return inserted ? AcquireResult.Proceed() : AcquireResult.InProgress(); + } + + var existingRecord = DeserializeRecord(existingValue); + return ResolveDecision(existingRecord, requestHash); + } + + private async Task SaveCompletedToRedisAsync(string scopeKey, IdempotencyRecord record) + { + var db = _redisHelper.GetDatabase(); + await db.StringSetAsync(scopeKey, JsonSerializer.Serialize(record), _completedTtl); + } + + private async Task ReleaseFromRedisAsync(string scopeKey) + { + var db = _redisHelper.GetDatabase(); + await db.KeyDeleteAsync(scopeKey); + } + + private AcquireResult AcquireFromMemory(string scopeKey, string requestHash) + { + PruneMemoryStoreIfNeeded(); + + while (true) + { + var now = DateTimeOffset.UtcNow; + if (!MemoryStore.TryGetValue(scopeKey, out var cacheItem)) + { + var inProgressRecord = new IdempotencyRecord + { + Status = InProgressStatus, + RequestHash = requestHash, + CreatedAt = now, + UpdatedAt = now + }; + + var inserted = MemoryStore.TryAdd(scopeKey, new IdempotencyCacheItem + { + Record = inProgressRecord, + ExpiresAt = now.Add(_inProgressTtl) + }); + + if (inserted) + { + return AcquireResult.Proceed(); + } + + continue; + } + + if (cacheItem.ExpiresAt <= now) + { + MemoryStore.TryRemove(scopeKey, out _); + continue; + } + + return ResolveDecision(cacheItem.Record, requestHash); + } + } + + private void SaveCompletedToMemory(string scopeKey, IdempotencyRecord record) + { + var expiresAt = DateTimeOffset.UtcNow.Add(_completedTtl); + MemoryStore.AddOrUpdate( + scopeKey, + _ => new IdempotencyCacheItem + { + Record = record, + ExpiresAt = expiresAt + }, + (_, _) => new IdempotencyCacheItem + { + Record = record, + ExpiresAt = expiresAt + }); + } + + private void ReleaseFromMemory(string scopeKey) + { + MemoryStore.TryRemove(scopeKey, out _); + } + + private static AcquireResult ResolveDecision(IdempotencyRecord record, string requestHash) + { + if (record == null) + { + return AcquireResult.InProgress(); + } + + if (!string.Equals(record.RequestHash, requestHash, StringComparison.Ordinal)) + { + return AcquireResult.PayloadConflict(); + } + + if (string.Equals(record.Status, CompletedStatus, StringComparison.OrdinalIgnoreCase)) + { + return AcquireResult.Replay(record); + } + + return AcquireResult.InProgress(); + } + + private static async Task ComputeRequestHashAsync(HttpRequest request) + { + if (!IsJsonContentType(request.ContentType)) + { + return ComputeSha256Hex(string.Empty); + } + + if (request.ContentLength.HasValue && request.ContentLength.Value == 0) + { + return ComputeSha256Hex(string.Empty); + } + + request.EnableBuffering(); + request.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(request.Body, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + request.Body.Seek(0, SeekOrigin.Begin); + + var canonicalBody = CanonicalizeBody(body); + return ComputeSha256Hex(canonicalBody); + } + + private static string CanonicalizeBody(string body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return string.Empty; + } + + try + { + using var document = JsonDocument.Parse(body); + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + WriteCanonicalJson(writer, document.RootElement); + } + + return Encoding.UTF8.GetString(buffer.ToArray()); + } + catch + { + return body.Trim(); + } + } + + private static void WriteCanonicalJson(Utf8JsonWriter writer, JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (var property in element.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) + { + writer.WritePropertyName(property.Name); + WriteCanonicalJson(writer, property.Value); + } + writer.WriteEndObject(); + return; + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (var item in element.EnumerateArray()) + { + WriteCanonicalJson(writer, item); + } + writer.WriteEndArray(); + return; + default: + element.WriteTo(writer); + return; + } + } + + private static string BuildScopeKey(HttpContext context, string idempotencyKey) + { + var tenantId = ResolveTenantId(context); + var userId = ResolveUserId(context); + var method = context.Request.Method.ToUpperInvariant(); + var normalizedPath = NormalizePath(context.Request.Path.Value); + + var scope = $"{tenantId}:{userId}:{method}:{normalizedPath}:{idempotencyKey}"; + return $"idem:{ComputeSha256Hex(scope)}"; + } + + private static string ResolveTenantId(HttpContext context) + { + var tenantId = context.Request.Headers[TenantHeaderName].ToString(); + if (!string.IsNullOrWhiteSpace(tenantId)) + { + return tenantId.Trim().ToLowerInvariant(); + } + + var tenantClaim = context.User?.FindFirst("tenantId")?.Value + ?? context.User?.FindFirst("tid")?.Value + ?? "default"; + + return tenantClaim.Trim().ToLowerInvariant(); + } + + private static string ResolveUserId(HttpContext context) + { + var userId = context.User?.FindFirst(ClaimTypes.SerialNumber)?.Value + ?? context.User?.FindFirst(ClaimTypes.NameIdentifier)?.Value + ?? context.User?.Identity?.Name + ?? "anonymous"; + + return userId.Trim().ToLowerInvariant(); + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return "/"; + } + + var normalized = path.Trim().ToLowerInvariant(); + if (normalized.Length > 1) + { + normalized = normalized.TrimEnd('/'); + } + + return string.IsNullOrWhiteSpace(normalized) ? "/" : normalized; + } + + private static bool ResolveRedisEnabled(IConfiguration configuration) + { + var redisSection = configuration.GetSection("Redis"); + var enable = redisSection.GetValue("Enable"); + if (enable.HasValue) + { + return enable.Value; + } + + return redisSection.GetValue("Enabled"); + } + + private static bool IsWriteMethod(string method) + { + return HttpMethods.IsPost(method) + || HttpMethods.IsPut(method) + || HttpMethods.IsPatch(method); + } + + private static bool IsSuccessStatusCode(int statusCode) + { + return statusCode >= 200 && statusCode < 300; + } + + private static bool IsJsonContentType(string contentType) + { + return !string.IsNullOrWhiteSpace(contentType) + && contentType.Contains("json", StringComparison.OrdinalIgnoreCase); + } + + private static string ComputeSha256Hex(string input) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } + + private static IdempotencyRecord DeserializeRecord(RedisValue value) + { + if (value.IsNullOrEmpty) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(value.ToString()); + } + catch + { + return null; + } + } + + private static void PruneMemoryStoreIfNeeded() + { + if (Interlocked.Increment(ref _memoryRequestCount) % 200 != 0) + { + return; + } + + var now = DateTimeOffset.UtcNow; + foreach (var kvp in MemoryStore) + { + if (kvp.Value.ExpiresAt <= now) + { + MemoryStore.TryRemove(kvp.Key, out _); + } + } + } + + private static async Task WriteBusinessErrorAsync(HttpContext context, int httpStatus, int businessCode, string message) + { + context.Response.StatusCode = httpStatus; + context.Response.ContentType = DefaultContentType; + + var response = new BaseResponse(businessCode, message); + var payload = JsonSerializer.Serialize(response, new JsonSerializerOptions + { + PropertyNamingPolicy = null, + DictionaryKeyPolicy = null + }); + + await context.Response.WriteAsync(payload); + } + + private sealed class IdempotencyCacheItem + { + public IdempotencyRecord Record { get; set; } + public DateTimeOffset ExpiresAt { get; set; } + } + + private sealed class IdempotencyRecord + { + public string Status { get; set; } + public string RequestHash { get; set; } + public int? HttpStatus { get; set; } + public string ResponseBody { get; set; } + public string ContentType { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + } + + private enum IdempotencyDecision + { + Proceed = 0, + Replay = 1, + InProgress = 2, + PayloadConflict = 3 + } + + private sealed class AcquireResult + { + private AcquireResult(IdempotencyDecision decision, IdempotencyRecord record = null) + { + Decision = decision; + Record = record; + } + + public IdempotencyDecision Decision { get; } + public IdempotencyRecord Record { get; } + + public static AcquireResult Proceed() => new AcquireResult(IdempotencyDecision.Proceed); + public static AcquireResult Replay(IdempotencyRecord record) => new AcquireResult(IdempotencyDecision.Replay, record); + public static AcquireResult InProgress() => new AcquireResult(IdempotencyDecision.InProgress); + public static AcquireResult PayloadConflict() => new AcquireResult(IdempotencyDecision.PayloadConflict); + } + } +} diff --git a/EOM.TSHotelManagement.API/appsettings.Application.json b/EOM.TSHotelManagement.API/appsettings.Application.json index 8fd1da27f0554832b0a122d5f4409662a2f469da..b77796a8dd15e4a5d466c257ffb92b47f94e8cc8 100644 --- a/EOM.TSHotelManagement.API/appsettings.Application.json +++ b/EOM.TSHotelManagement.API/appsettings.Application.json @@ -20,5 +20,13 @@ "NotifyDaysBefore": 3, "CheckIntervalMinutes": 5 }, + "Idempotency": { + "Enabled": true, + "EnforceKey": false, + "MaxKeyLength": 128, + "InProgressTtlSeconds": 120, + "CompletedTtlHours": 24, + "PersistFailureResponse": false + }, "SoftwareVersion": "1.0.0" -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/appsettings.Database.json b/EOM.TSHotelManagement.API/appsettings.Database.json index 0c020cb33fa566cef789a22a64918a785a8f5ea0..f4844c299062d2f9cfed4867e12420aadcb53eab 100644 --- a/EOM.TSHotelManagement.API/appsettings.Database.json +++ b/EOM.TSHotelManagement.API/appsettings.Database.json @@ -9,8 +9,8 @@ }, "Redis": { "Enabled": false, - "ConnectionString": "host:port,password=your_redis_password", //host:port,password=your_redis_password + "ConnectionString": "host:port,password=your_redis_password", "DefaultDatabase": 0 }, "InitializeDatabase": false -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/appsettings.Services.json b/EOM.TSHotelManagement.API/appsettings.Services.json index 7c2ba5c86f09548e840d325f34cc2ac308ac4b81..98919c7f93592bcdd935169956c2c5fa02e0756f 100644 --- a/EOM.TSHotelManagement.API/appsettings.Services.json +++ b/EOM.TSHotelManagement.API/appsettings.Services.json @@ -19,5 +19,15 @@ "Password": "", "UploadApi": "", "GetTokenApi": "" + }, + "TwoFactor": { + "Issuer": "TSHotel", + "SecretSize": 20, + "CodeDigits": 6, + "TimeStepSeconds": 30, + "AllowedDriftWindows": 1, + "RecoveryCodeCount": 8, + "RecoveryCodeLength": 10, + "RecoveryCodeGroupSize": 5 } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.API/appsettings.json b/EOM.TSHotelManagement.API/appsettings.json index e1c9e6f5b820b3a58afee6bccf4429f9188c119a..d9d9a9bff6fd6f3ee7ea00de958f135a4a28c6e6 100644 --- a/EOM.TSHotelManagement.API/appsettings.json +++ b/EOM.TSHotelManagement.API/appsettings.json @@ -6,47 +6,5 @@ "Microsoft.Hosting.Lifetime": "Information" } }, - "DefaultDatabase": "MariaDB", - "ConnectionStrings": { - "PgSqlConnectStr": "Host=my_pgsql_host;Port=5432;Username=my_pgsql_user;Password=my_pgsql_password;Database=tshoteldb;", - "MySqlConnectStr": "Server=my_mysql_host;Database=tshoteldb;User=my_mysql_user;Password=my_mysql_password;", - "MariaDBConnectStr": "Server=localhost;Database=tshoteldb;User=my_mariadb_user;Password=my_mariadb_password;", - "SqlServerConnectStr": "Server=my_sqlserver_host;Database=tshoteldb;User Id=my_sqlserver_user;Password=my_sqlserver_password;", - "OracleConnectStr": "User Id=my_oracle_user;Password=my_oracle_password;Data Source=(DESCRIPTION=(ADDRESS_LIST=(ADDRESS=(PROTOCOL=TCP)(HOST=my_oracle_host)(PORT=1521)))(CONNECT_DATA=(SERVER=DEDICATED)(SERVICE_NAME=my_oracle_service_name)));" - }, - "AllowedOrigins": [ - "http://localhost:8080", - "https://tshotel.oscode.top" - ], - "AllowedHosts": "*", - "InitializeDatabase": true, - "JobKeys": [ - "ReservationExpirationCheckJob" - ], - "ExpirationSettings": { - "NotifyDaysBefore": 3, - "CheckIntervalMinutes": 5 - }, - "Jwt": { - "Key": "", - "ExpiryMinutes": 20 - }, - "Mail": { - "Enabled": false, // Whether to enable email functionality - "Host": "...", - "Port": 465, - "UserName": "", - "Password": "", - "EnableSsl": true, - "DisplayName": "" - }, - "SoftwareVersion": "1.0.0", - "Lsky": { - "Enabled": false, // Whether to enable Lsky image hosting integration - "BaseAddress": "", - "Email": "", - "Password": "", - "UploadApi": "", - "GetTokenApi": "" - } + "AllowedHosts": "*" } diff --git a/EOM.TSHotelManagement.Common/Constant/JwtAuthConstants.cs b/EOM.TSHotelManagement.Common/Constant/JwtAuthConstants.cs new file mode 100644 index 0000000000000000000000000000000000000000..264c47fa82cc4cd18f25b26a60d7bc7fdacf2f0d --- /dev/null +++ b/EOM.TSHotelManagement.Common/Constant/JwtAuthConstants.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace EOM.TSHotelManagement.Common +{ + public static class JwtAuthConstants + { + public const string AuthFailureReasonItemKey = "AuthFailureReason"; + public const string AuthFailureReasonTokenRevoked = "token_revoked"; + public const string AuthFailureReasonTokenExpired = "token_expired"; + public const string AuthFailureReasonTokenInvalid = "token_invalid"; + public const string JwtTokenUserIdItemKey = "JwtTokenUserId"; + public const string JwtTokenJtiItemKey = "JwtTokenJti"; + } +} diff --git a/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs new file mode 100644 index 0000000000000000000000000000000000000000..f2376a9a60054ced53df0be04d0259e3b70f53f1 --- /dev/null +++ b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs @@ -0,0 +1,10 @@ +namespace EOM.TSHotelManagement.Common +{ + public enum TwoFactorUserType + { + Unknown = 0, + Employee = 1, + Administrator = 2, + Customer = 3 + } +} diff --git a/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs b/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs index 499a5649cc08c254d7cd299cbdc9dc127180a85d..8241879cae3b29d555e3baba3eef53fdd76ddfcd 100644 --- a/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs +++ b/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs @@ -10,6 +10,7 @@ namespace EOM.TSHotelManagement.Common private readonly IDataProtector _reservationProtector; private readonly IDataProtector _customerProtector; private readonly IDataProtector _adminProtector; + private readonly IDataProtector _twoFactorProtector; public DataProtectionHelper(IDataProtectionProvider dataProtectionProvider) { @@ -17,6 +18,7 @@ namespace EOM.TSHotelManagement.Common _reservationProtector = dataProtectionProvider.CreateProtector("ReservationInfoProtector"); _customerProtector = dataProtectionProvider.CreateProtector("CustomerInfoProtector"); _adminProtector = dataProtectionProvider.CreateProtector("AdminInfoProtector"); + _twoFactorProtector = dataProtectionProvider.CreateProtector("TwoFactorProtector"); } private string DecryptData(string encryptedData, IDataProtector protector) @@ -111,5 +113,14 @@ namespace EOM.TSHotelManagement.Common public string EncryptAdministratorData(string plainText) => EncryptData(plainText, _adminProtector); + + public string SafeDecryptTwoFactorData(string encryptedData) + => SafeDecryptData(encryptedData, _twoFactorProtector); + + public string EncryptTwoFactorData(string plainText) + => EncryptData(plainText, _twoFactorProtector); + + public bool IsTwoFactorDataProtected(string encryptedData) + => LooksLikeDataProtectionPayload(encryptedData); } } diff --git a/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs b/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs index 3a464f8812b1b6e0f197d315c29fcffd3b0fa591..db5d8687c3ce69a1892aeb2c0cb5c4cd9ee4ad40 100644 --- a/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs +++ b/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs @@ -8,7 +8,7 @@ namespace EOM.TSHotelManagement.Common public static class EntityMapper { /// - /// ӳ䵥ʵ + /// 映射单个实体 /// public static TDestination Map(TSource source) where TDestination : new() @@ -50,6 +50,14 @@ namespace EOM.TSHotelManagement.Common if (sourceValue == null) { + // Ensure optimistic-lock version does not silently fall back to entity defaults. + if (destinationProperty.Name.Equals("RowVersion", StringComparison.OrdinalIgnoreCase) + && destinationProperty.PropertyType == typeof(long)) + { + destinationProperty.SetValue(destination, 0L); + continue; + } + if (destinationProperty.PropertyType.IsValueType && Nullable.GetUnderlyingType(destinationProperty.PropertyType) != null) { @@ -70,7 +78,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ת + /// 智能类型转换 /// private static object SmartConvert(object value, Type targetType) { @@ -119,7 +127,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ǷΪСֵ + /// 检查是否为最小值 /// private static bool IsMinValue(object value) { @@ -133,7 +141,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// СֵתΪֵ + /// 将最小值转换为空值 /// private static object ConvertMinValueToNull(object value, Type targetType) { @@ -151,7 +159,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// DateOnly ת + /// 处理 DateOnly 类型转换 /// private static object HandleDateOnlyConversion(DateOnly dateOnly, Type targetType) { @@ -182,7 +190,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// DateTime ת + /// 处理 DateTime 类型转换 /// private static object HandleDateTimeConversion(DateTime dateTime, Type targetType) { @@ -213,7 +221,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ַת + /// 处理字符串日期转换 /// private static object HandleStringConversion(string dateString, Type targetType) { @@ -236,7 +244,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// жǷҪת + /// 判断是否需要类型转换 /// private static bool NeedConversion(Type sourceType, Type targetType) { @@ -249,7 +257,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ӳʵб + /// 映射实体列表 /// public static List MapList(List sourceList) where TDestination : new() @@ -257,4 +265,4 @@ namespace EOM.TSHotelManagement.Common return sourceList?.Select(Map).ToList(); } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Common/Helper/JwtTokenRevocationService.cs b/EOM.TSHotelManagement.Common/Helper/JwtTokenRevocationService.cs new file mode 100644 index 0000000000000000000000000000000000000000..dbeddc406b6ee74496b97488851ccab9cb9b1142 --- /dev/null +++ b/EOM.TSHotelManagement.Common/Helper/JwtTokenRevocationService.cs @@ -0,0 +1,200 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Concurrent; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace EOM.TSHotelManagement.Common +{ + public class JwtTokenRevocationService + { + private const string RevokedTokenKeyPrefix = "auth:revoked:"; + private readonly ConcurrentDictionary _memoryStore = new(); + private long _memoryProbeCount; + + private readonly RedisHelper _redisHelper; + private readonly ILogger _logger; + private readonly bool _useRedis; + private readonly TimeSpan _fallbackTtl = TimeSpan.FromMinutes(30); + + + public JwtTokenRevocationService( + IConfiguration configuration, + RedisHelper redisHelper, + ILogger logger) + { + _redisHelper = redisHelper; + _logger = logger; + _useRedis = ResolveRedisEnabled(configuration); + } + + public async Task RevokeTokenAsync(string token) + { + var normalizedToken = NormalizeToken(token); + if (string.IsNullOrWhiteSpace(normalizedToken)) + { + return; + } + + var key = BuildRevokedTokenKey(normalizedToken); + var ttl = CalculateRevokedTtl(normalizedToken); + + if (_useRedis) + { + try + { + var db = _redisHelper.GetDatabase(); + await db.StringSetAsync(key, "1", ttl); + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Redis token revoke failed, fallback to memory store."); + } + } + + var memoryExpiresAt = DateTimeOffset.UtcNow.Add(ttl); + _memoryStore.AddOrUpdate(key, _ => memoryExpiresAt, (_, _) => memoryExpiresAt); + } + + public async Task IsTokenRevokedAsync(string token) + { + var normalizedToken = NormalizeToken(token); + if (string.IsNullOrWhiteSpace(normalizedToken)) + { + return false; + } + + var key = BuildRevokedTokenKey(normalizedToken); + + if (_useRedis) + { + try + { + var db = _redisHelper.GetDatabase(); + return await db.KeyExistsAsync(key); + } + catch (Exception ex) + { + _logger.LogError(ex, "Redis token revoke-check failed, fallback to memory store."); + } + } + + PruneMemoryStoreIfNeeded(); + if (!_memoryStore.TryGetValue(key, out var expiresAt)) + { + return false; + } + + if (expiresAt <= DateTimeOffset.UtcNow) + { + _memoryStore.TryRemove(key, out _); + return false; + } + + return true; + } + + public static bool TryGetBearerToken(string authorizationHeader, out string token) + { + token = string.Empty; + if (string.IsNullOrWhiteSpace(authorizationHeader)) + { + return false; + } + + token = NormalizeToken(authorizationHeader); + return !string.IsNullOrWhiteSpace(token); + } + + private static string BuildRevokedTokenKey(string token) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token)); + return $"{RevokedTokenKeyPrefix}{Convert.ToHexString(hash).ToLowerInvariant()}"; + } + + private static string NormalizeToken(string token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return string.Empty; + } + + var normalized = token.Trim(); + if (normalized.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized.Substring(7).Trim(); + } + + return normalized; + } + + private DateTimeOffset? GetTokenExpiresAtUtc(string token) + { + try + { + var jwtToken = new JwtSecurityTokenHandler().ReadJwtToken(token); + if (jwtToken.ValidTo == DateTime.MinValue) + { + _logger.LogWarning("Failed to parse JWT token expiration"); + return null; + } + + return new DateTimeOffset(DateTime.SpecifyKind(jwtToken.ValidTo, DateTimeKind.Utc)); + } + catch + { + _logger.LogWarning("Failed to parse JWT token expiration"); + return null; + } + } + + private TimeSpan CalculateRevokedTtl(string token) + { + var now = DateTimeOffset.UtcNow; + var expiresAt = GetTokenExpiresAtUtc(token) ?? now.Add(_fallbackTtl); + var remaining = expiresAt - now; + + if (remaining <= TimeSpan.Zero) + { + return TimeSpan.FromMinutes(1); + } + + var ttlMinutes = Math.Ceiling(remaining.TotalMinutes) + 1; + return TimeSpan.FromMinutes(ttlMinutes); + } + + private void PruneMemoryStoreIfNeeded() + { + if (Interlocked.Increment(ref _memoryProbeCount) % 200 != 0) + { + return; + } + + var now = DateTimeOffset.UtcNow; + foreach (var kvp in _memoryStore.ToArray()) + { + if (kvp.Value <= now) + { + _memoryStore.TryRemove(kvp.Key, out _); + } + } + } + + private static bool ResolveRedisEnabled(IConfiguration configuration) + { + var redisSection = configuration.GetSection("Redis"); + var enable = redisSection.GetValue("Enabled"); + if (enable.HasValue) + { + return enable.Value; + } + + return redisSection.GetValue("Enabled"); + } + } +} diff --git a/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs b/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs index 376da2a45633619e1355c831ba801e0720c54402..ff2f82b6336a66f27cef7e282c05b5b2aa40464b 100644 --- a/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs +++ b/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs @@ -6,11 +6,11 @@ namespace EOM.TSHotelManagement.Common public static class LocalizationHelper { /// - /// ȡػַ + /// 获取本地化字符串 /// - /// Ӣı - /// ı - /// ݵǰĻӦı + /// 英文文本 + /// 中文文本 + /// 根据当前文化返回相应的文本 public static string GetLocalizedString(string englishText, string chineseText) { var culture = CultureInfo.CurrentCulture.Name; @@ -18,9 +18,9 @@ namespace EOM.TSHotelManagement.Common } /// - /// õǰĻ + /// 设置当前文化 /// - /// Ļ + /// 文化名称 public static void SetCulture(string culture) { CultureInfo.CurrentCulture = new CultureInfo(culture); diff --git a/EOM.TSHotelManagement.Common/Helper/RedisHelper.cs b/EOM.TSHotelManagement.Common/Helper/RedisHelper.cs index 98deaa6b06f4c33b63df2968e3107f69653d2ca7..1238879225c1d203bef93855a89e92f8d4b764b1 100644 --- a/EOM.TSHotelManagement.Common/Helper/RedisHelper.cs +++ b/EOM.TSHotelManagement.Common/Helper/RedisHelper.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Infrastructure; +using EOM.TSHotelManagement.Infrastructure; using Microsoft.Extensions.Logging; using StackExchange.Redis; using System; @@ -34,6 +34,8 @@ namespace EOM.TSHotelManagement.Common return; } + int defaultDatabase = redisConfig.DefaultDatabase ?? -1; + if (string.IsNullOrWhiteSpace(redisConfig?.ConnectionString)) throw new ArgumentException("Redis连接字符串不能为空"); @@ -43,7 +45,7 @@ namespace EOM.TSHotelManagement.Common options.ReconnectRetryPolicy = new ExponentialRetry(3000); _connection = ConnectionMultiplexer.Connect(options); - _connection.GetDatabase().Ping(); + _connection.GetDatabase(defaultDatabase).Ping(); } catch (Exception ex) { diff --git a/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs b/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs new file mode 100644 index 0000000000000000000000000000000000000000..d5aeb9c518aa6819c729e3e5b4d050c54e6cb65c --- /dev/null +++ b/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs @@ -0,0 +1,411 @@ +using EOM.TSHotelManagement.Infrastructure; +using System.Security.Cryptography; +using System.Text; + +namespace EOM.TSHotelManagement.Common +{ + /// + /// TOTP(2FA)工具类 + /// + public class TwoFactorHelper + { + private const string Base32Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + private const string RecoveryCodeAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + private readonly TwoFactorConfigFactory _configFactory; + + /// + /// 构造函数 + /// + /// + public TwoFactorHelper(TwoFactorConfigFactory configFactory) + { + _configFactory = configFactory; + } + + /// + /// 生成 Base32 格式的 2FA 密钥 + /// + /// + public string GenerateSecretKey() + { + var config = GetConfig(); + var secretSize = config.SecretSize <= 0 ? 20 : config.SecretSize; + var secretBytes = RandomNumberGenerator.GetBytes(secretSize); + return Base32Encode(secretBytes); + } + + /// + /// 生成恢复备用码(仅明文返回一次) + /// + /// + public List GenerateRecoveryCodes() + { + var config = GetConfig(); + var result = new List(config.RecoveryCodeCount); + + for (var i = 0; i < config.RecoveryCodeCount; i++) + { + var chars = new char[config.RecoveryCodeLength]; + for (var j = 0; j < chars.Length; j++) + { + chars[j] = RecoveryCodeAlphabet[RandomNumberGenerator.GetInt32(0, RecoveryCodeAlphabet.Length)]; + } + + var raw = new string(chars); + result.Add(FormatRecoveryCode(raw, config.RecoveryCodeGroupSize)); + } + + return result; + } + + /// + /// 生成恢复备用码盐值 + /// + /// + public string CreateRecoveryCodeSalt() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + } + + /// + /// 对恢复备用码进行哈希 + /// + /// 备用码(可带分隔符) + /// 盐值 + /// + public string HashRecoveryCode(string recoveryCode, string salt) + { + var normalized = NormalizeRecoveryCode(recoveryCode); + if (string.IsNullOrWhiteSpace(normalized) || string.IsNullOrWhiteSpace(salt)) + { + return string.Empty; + } + + if (!TryGetSaltBytes(salt, out var saltBytes)) + { + return string.Empty; + } + + using var hmac = new HMACSHA256(saltBytes); + var payload = Encoding.UTF8.GetBytes(normalized); + return Convert.ToHexString(hmac.ComputeHash(payload)); + } + + /// + /// 校验恢复备用码 + /// + /// 备用码(可带分隔符) + /// 盐值 + /// 库内哈希 + /// + public bool VerifyRecoveryCode(string recoveryCode, string salt, string expectedHash) + { + if (string.IsNullOrWhiteSpace(expectedHash)) + { + return false; + } + + var currentHash = HashRecoveryCode(recoveryCode, salt); + if (!string.IsNullOrWhiteSpace(currentHash) && FixedTimeEquals(currentHash, expectedHash)) + { + return true; + } + + // Compatibility for historical records created with legacy SHA256(salt:code). + var legacyHash = HashRecoveryCodeLegacy(recoveryCode, salt); + return !string.IsNullOrWhiteSpace(legacyHash) && FixedTimeEquals(legacyHash, expectedHash); + } + + /// + /// 归一化恢复备用码(去空格和连接符) + /// + /// + /// + public string NormalizeRecoveryCode(string code) + { + if (string.IsNullOrWhiteSpace(code)) + { + return string.Empty; + } + + return new string(code + .Where(c => char.IsLetterOrDigit(c)) + .Select(char.ToUpperInvariant) + .ToArray()); + } + + /// + /// 生成符合 Google Authenticator 的 otpauth URI + /// + /// 账号标识 + /// Base32 密钥 + /// + public string BuildOtpAuthUri(string accountName, string secretKey) + { + var config = GetConfig(); + var issuer = config.Issuer ?? "TSHotel"; + var encodedIssuer = Uri.EscapeDataString(issuer); + var encodedAccount = Uri.EscapeDataString(accountName ?? "user"); + + return $"otpauth://totp/{encodedIssuer}:{encodedAccount}?secret={secretKey}&issuer={encodedIssuer}&digits={config.CodeDigits}&period={config.TimeStepSeconds}"; + } + + /// + /// 校验 TOTP 验证码 + /// + /// Base32 密钥 + /// 验证码 + /// 校验时间(UTC,空时取当前) + /// + public bool VerifyCode(string secretKey, string code, DateTime? utcNow = null) + { + return TryVerifyCode(secretKey, code, out _, utcNow); + } + + /// + /// 校验 TOTP 验证码,并返回命中的计数器(counter) + /// + /// Base32 密钥 + /// 验证码 + /// 命中的计数器 + /// 校验时间(UTC,空时取当前) + /// + public bool TryVerifyCode(string secretKey, string code, out long validatedCounter, DateTime? utcNow = null) + { + validatedCounter = -1; + + if (string.IsNullOrWhiteSpace(secretKey) || string.IsNullOrWhiteSpace(code)) + return false; + + var config = GetConfig(); + var normalizedCode = new string(code.Where(char.IsDigit).ToArray()); + if (normalizedCode.Length != config.CodeDigits) + return false; + + var key = Base32Decode(secretKey); + var unixTime = new DateTimeOffset(utcNow ?? DateTime.UtcNow).ToUnixTimeSeconds(); + var step = config.TimeStepSeconds <= 0 ? 30 : config.TimeStepSeconds; + var counter = unixTime / step; + var drift = config.AllowedDriftWindows < 0 ? 0 : config.AllowedDriftWindows; + + for (var i = -drift; i <= drift; i++) + { + var currentCounter = counter + i; + if (currentCounter < 0) + continue; + + var expected = ComputeTotp(key, currentCounter, config.CodeDigits); + if (FixedTimeEquals(expected, normalizedCode)) + { + validatedCounter = currentCounter; + return true; + } + } + + return false; + } + + /// + /// 获取验证码位数 + /// + /// + public int GetCodeDigits() + { + return GetConfig().CodeDigits; + } + + /// + /// 获取时间步长(秒) + /// + /// + public int GetTimeStepSeconds() + { + return GetConfig().TimeStepSeconds; + } + + private TwoFactorConfig GetConfig() + { + var config = _configFactory.GetTwoFactorConfig(); + if (config.CodeDigits is < 6 or > 8) + { + config.CodeDigits = 6; + } + + if (config.TimeStepSeconds <= 0) + { + config.TimeStepSeconds = 30; + } + + if (config.SecretSize <= 0) + { + config.SecretSize = 20; + } + + if (config.RecoveryCodeCount <= 0) + { + config.RecoveryCodeCount = 8; + } + + if (config.RecoveryCodeLength < 8) + { + config.RecoveryCodeLength = 10; + } + + if (config.RecoveryCodeGroupSize <= 0) + { + config.RecoveryCodeGroupSize = 5; + } + + return config; + } + + private static string FormatRecoveryCode(string raw, int groupSize) + { + if (string.IsNullOrWhiteSpace(raw) || groupSize <= 0) + { + return raw; + } + + var normalized = raw.ToUpperInvariant(); + var sb = new StringBuilder(normalized.Length + normalized.Length / groupSize); + + for (var i = 0; i < normalized.Length; i++) + { + if (i > 0 && i % groupSize == 0) + { + sb.Append('-'); + } + + sb.Append(normalized[i]); + } + + return sb.ToString(); + } + + private static string ComputeTotp(byte[] key, long counter, int digits) + { + var counterBytes = BitConverter.GetBytes(counter); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(counterBytes); + } + + using var hmac = new HMACSHA1(key); + var hash = hmac.ComputeHash(counterBytes); + var offset = hash[^1] & 0x0F; + var binaryCode = ((hash[offset] & 0x7F) << 24) + | (hash[offset + 1] << 16) + | (hash[offset + 2] << 8) + | hash[offset + 3]; + + var otp = binaryCode % (int)Math.Pow(10, digits); + return otp.ToString().PadLeft(digits, '0'); + } + + private static bool FixedTimeEquals(string left, string right) + { + var leftBytes = Encoding.UTF8.GetBytes(left); + var rightBytes = Encoding.UTF8.GetBytes(right); + return CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes); + } + + private static bool TryGetSaltBytes(string salt, out byte[] saltBytes) + { + saltBytes = Array.Empty(); + if (string.IsNullOrWhiteSpace(salt)) + { + return false; + } + + try + { + saltBytes = Convert.FromHexString(salt.Trim()); + return saltBytes.Length > 0; + } + catch + { + return false; + } + } + + private string HashRecoveryCodeLegacy(string recoveryCode, string salt) + { + var normalized = NormalizeRecoveryCode(recoveryCode); + if (string.IsNullOrWhiteSpace(normalized) || string.IsNullOrWhiteSpace(salt)) + { + return string.Empty; + } + + using var sha = SHA256.Create(); + var payload = Encoding.UTF8.GetBytes($"{salt}:{normalized}"); + return Convert.ToHexString(sha.ComputeHash(payload)); + } + + private static string Base32Encode(byte[] data) + { + if (data.Length == 0) + return string.Empty; + + var output = new StringBuilder((int)Math.Ceiling(data.Length / 5d) * 8); + var bitBuffer = 0; + var bitCount = 0; + + foreach (var b in data) + { + bitBuffer = (bitBuffer << 8) | b; + bitCount += 8; + + while (bitCount >= 5) + { + var index = (bitBuffer >> (bitCount - 5)) & 0x1F; + output.Append(Base32Alphabet[index]); + bitCount -= 5; + } + } + + if (bitCount > 0) + { + var index = (bitBuffer << (5 - bitCount)) & 0x1F; + output.Append(Base32Alphabet[index]); + } + + return output.ToString(); + } + + private static byte[] Base32Decode(string base32) + { + var normalized = (base32 ?? string.Empty) + .Trim() + .TrimEnd('=') + .Replace(" ", string.Empty) + .ToUpperInvariant(); + + if (normalized.Length == 0) + return Array.Empty(); + + var bytes = new List(normalized.Length * 5 / 8); + var bitBuffer = 0; + var bitCount = 0; + + foreach (var c in normalized) + { + var index = Base32Alphabet.IndexOf(c); + if (index < 0) + { + throw new ArgumentException("Invalid Base32 secret key."); + } + + bitBuffer = (bitBuffer << 5) | index; + bitCount += 5; + + if (bitCount >= 8) + { + bytes.Add((byte)((bitBuffer >> (bitCount - 8)) & 0xFF)); + bitCount -= 8; + } + } + + return bytes.ToArray(); + } + } +} diff --git a/EOM.TSHotelManagement.Common/Templates/EmailTemplate.cs b/EOM.TSHotelManagement.Common/Templates/EmailTemplate.cs index b503a4a27b85d5f9feed2673e46bcda380ddccb5..5778df491992ee8f48c2e9c2c398ea8f5ff9802f 100644 --- a/EOM.TSHotelManagement.Common/Templates/EmailTemplate.cs +++ b/EOM.TSHotelManagement.Common/Templates/EmailTemplate.cs @@ -136,6 +136,29 @@ public static class EmailTemplate }; } + public static MailTemplate GetTwoFactorRecoveryCodeLoginAlertTemplate(string userName, string userIdentity, DateTime? loginTime = null) + { + var occurredAt = loginTime ?? DateTime.Now; + var safeUserName = string.IsNullOrWhiteSpace(userName) ? "User" : userName; + var safeIdentity = string.IsNullOrWhiteSpace(userIdentity) ? "-" : userIdentity; + + return new MailTemplate + { + Subject = LocalizationHelper.GetLocalizedString("Security alert: recovery code login", "安全提醒:检测到备用码登录"), + Body = BasicTemplate( + SystemConstant.BranchName.Code, + SystemConstant.BranchLogo.Code, + "账户安全提醒", + safeUserName, + $@"

{LocalizationHelper.GetLocalizedString("A login used a 2FA recovery code.", "检测到您的账号使用了 2FA 恢复备用码登录。")}

+

{LocalizationHelper.GetLocalizedString("Time", "时间")}:{occurredAt:yyyy-MM-dd HH:mm:ss}

+

{LocalizationHelper.GetLocalizedString("User", "用户")}:{safeUserName} ({safeIdentity})

+

{LocalizationHelper.GetLocalizedString("If this was not you, please reset your password and rebind your authenticator immediately.", "如果不是本人操作,请立即修改密码并重新绑定验证器。")}

", + "#FF6600", + "中") + }; + } + public static MailTemplate SendReservationExpirationNotificationTemplate(string roomNo, string reservationChannel, string customerName, string roomType, DateTime endDate, int daysLeft) { diff --git a/EOM.TSHotelManagement.Contract/Application/NavBar/Dto/CreateNavBarInputDto.cs b/EOM.TSHotelManagement.Contract/Application/NavBar/Dto/CreateNavBarInputDto.cs index a85651503a01b2aee252c574962a3a4d7f6a012b..5874b2b9edaf1b9b101a160e763eb852502a738a 100644 --- a/EOM.TSHotelManagement.Contract/Application/NavBar/Dto/CreateNavBarInputDto.cs +++ b/EOM.TSHotelManagement.Contract/Application/NavBar/Dto/CreateNavBarInputDto.cs @@ -4,11 +4,11 @@ namespace EOM.TSHotelManagement.Contract { public class CreateNavBarInputDto : BaseInputDto { - [Required(ErrorMessage = "Ϊֶ"), MaxLength(50, ErrorMessage = "󳤶Ϊ50ַ")] + [Required(ErrorMessage = "导航栏名称为必填字段"), MaxLength(50, ErrorMessage = "导航栏名称最大长度为50字符")] public string NavigationBarName { get; set; } public int NavigationBarOrder { get; set; } public string NavigationBarImage { get; set; } - [Required(ErrorMessage = "¼Ϊֶ"), MaxLength(200, ErrorMessage = "¼󳤶Ϊ200ַ")] + [Required(ErrorMessage = "导航栏事件名为必填字段"), MaxLength(200, ErrorMessage = "导航栏事件名最大长度为200字符")] public string NavigationBarEvent { get; set; } public int MarginLeft { get; set; } } diff --git a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustoType/CreateCustoTypeInputDto.cs b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustoType/CreateCustoTypeInputDto.cs index 3c3c3216a1e551f326cb1039a508578b60b77f7c..737cf68884fa58bec83441d1c9da8fb434ee04e2 100644 --- a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustoType/CreateCustoTypeInputDto.cs +++ b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustoType/CreateCustoTypeInputDto.cs @@ -3,17 +3,17 @@ namespace EOM.TSHotelManagement.Contract public class CreateCustoTypeInputDto : BaseInputDto { /// - /// ͻ (Customer Type) + /// 客户类型 (Customer Type) /// public int CustomerType { get; set; } /// - /// ͻ (Customer Type Name) + /// 客户类型名称 (Customer Type Name) /// public string CustomerTypeName { get; set; } /// - /// Żۿ + /// 优惠折扣 /// public decimal Discount { get; set; } } diff --git a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountInputDto.cs b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountInputDto.cs index 74d08caf83c3623a9dddf02cc61b8bdfc10028c3..8e82bcbafd8b6d1c91528d8a32959158c56222eb 100644 --- a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountInputDto.cs +++ b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountInputDto.cs @@ -14,5 +14,9 @@ /// 邮箱 (Email) ///
public string? EmailAddress { get; set; } + /// + /// 二次验证码 (2FA Code) + /// + public string? TwoFactorCode { get; set; } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountOutputDto.cs b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountOutputDto.cs index 3e5b69b741f2ab32743c170b9ae7b4793c73b0cd..13f30ab1b56400a61416561bf0e9121848db61cc 100644 --- a/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountOutputDto.cs +++ b/EOM.TSHotelManagement.Contract/Business/Customer/Dto/CustomerAccount/ReadCustomerAccountOutputDto.cs @@ -30,5 +30,15 @@ /// 最后一次登录时间 (Last Login Time) /// public DateTime? LastLoginTime { get; set; } + + /// + /// 是否需要2FA + /// + public bool RequiresTwoFactor { get; set; } + + /// + /// 本次登录是否通过恢复备用码完成 2FA + /// + public bool UsedRecoveryCodeLogin { get; set; } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Contract/Business/News/Dto/ReadNewsOuputDto.cs b/EOM.TSHotelManagement.Contract/Business/News/Dto/ReadNewsOuputDto.cs index 07bc449c37fa8ca13b20b2f67c585c32dd7e0f0d..eab1283c75be207580078517f5f0e6be44a20da4 100644 --- a/EOM.TSHotelManagement.Contract/Business/News/Dto/ReadNewsOuputDto.cs +++ b/EOM.TSHotelManagement.Contract/Business/News/Dto/ReadNewsOuputDto.cs @@ -1,8 +1,7 @@ -namespace EOM.TSHotelManagement.Contract +namespace EOM.TSHotelManagement.Contract { - public class ReadNewsOuputDto : BaseDto + public class ReadNewsOuputDto : BaseOutputDto { - public int? Id { get; set; } public string NewId { get; set; } public string NewsTitle { get; set; } @@ -18,4 +17,4 @@ public string NewsStatusDescription { get; set; } public string NewsImage { get; set; } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/BaseInputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/BaseInputDto.cs index 7362bb2c34cf996599bfe0a708cad704bdc96b58..3356d22205873883fea178bd3a3d88cd2586c943 100644 --- a/EOM.TSHotelManagement.Contract/Common/Dto/BaseInputDto.cs +++ b/EOM.TSHotelManagement.Contract/Common/Dto/BaseInputDto.cs @@ -6,5 +6,9 @@ /// 删除标识 /// public int? IsDelete { get; set; } = 0; + /// + /// 行版本(乐观锁) + /// + public long? RowVersion { get; set; } } } diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/BaseOutputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/BaseOutputDto.cs index db10398c890df0ce8ee0778d9a77a2eee7ce6299..0d262c4adf647ea436481701169c82e50299a7fd 100644 --- a/EOM.TSHotelManagement.Contract/Common/Dto/BaseOutputDto.cs +++ b/EOM.TSHotelManagement.Contract/Common/Dto/BaseOutputDto.cs @@ -3,5 +3,9 @@ public class BaseOutputDto : BaseAuditDto { public int? IsDelete { get; set; } + /// + /// 行版本(乐观锁) + /// + public long? RowVersion { get; set; } } } diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/BaseResponseFactory.cs b/EOM.TSHotelManagement.Contract/Common/Dto/BaseResponseFactory.cs new file mode 100644 index 0000000000000000000000000000000000000000..52abea6f0cb19a8efde6390b7ec8db8ee4ac61b2 --- /dev/null +++ b/EOM.TSHotelManagement.Contract/Common/Dto/BaseResponseFactory.cs @@ -0,0 +1,16 @@ +using EOM.TSHotelManagement.Common; + +namespace EOM.TSHotelManagement.Contract +{ + public static class BaseResponseFactory + { + public static BaseResponse ConcurrencyConflict() + { + return new BaseResponse( + BusinessStatusCode.Conflict, + LocalizationHelper.GetLocalizedString( + "Data has been modified by another user. Please refresh and retry.", + "数据已被其他用户修改,请刷新后重试。")); + } + } +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/BusinessStatusCode.cs b/EOM.TSHotelManagement.Contract/Common/Dto/BusinessStatusCode.cs index 81618cac3623e1eee5c5f102268fe9a910d52807..70fda34ac4f3ce03e1c3caf4fb497ce8640fe743 100644 --- a/EOM.TSHotelManagement.Contract/Common/Dto/BusinessStatusCode.cs +++ b/EOM.TSHotelManagement.Contract/Common/Dto/BusinessStatusCode.cs @@ -1,4 +1,4 @@ -namespace EOM.TSHotelManagement.Contract +namespace EOM.TSHotelManagement.Contract { public static class BusinessStatusCode { @@ -55,6 +55,11 @@ /// public const int Unauthorized = 1401; + /// + /// 权限不足 + /// + public const int PermissionDenied = 1402; + /// /// 禁止访问(无权限) /// @@ -80,6 +85,22 @@ /// public const int Conflict = 1409; + // 16xx Idempotency + /// + /// 缺少幂等键 + /// + public const int IdempotencyKeyMissing = 1601; + + /// + /// 幂等键复用但请求体不一致 + /// + public const int IdempotencyKeyPayloadConflict = 1602; + + /// + /// 相同幂等键请求正在处理中 + /// + public const int IdempotencyRequestInProgress = 1603; + // 5xx Server Errors /// /// 服务器内部错误 diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/DeleteDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/DeleteDto.cs index 021f809fa2221dee35612a3a0520e6443f2bd8a6..f3d1fa313234b771dac7a5cf1576bf7c89cb657a 100644 --- a/EOM.TSHotelManagement.Contract/Common/Dto/DeleteDto.cs +++ b/EOM.TSHotelManagement.Contract/Common/Dto/DeleteDto.cs @@ -4,6 +4,12 @@ namespace EOM.TSHotelManagement.Contract { public abstract class DeleteDto { - public List DelIds { get; set; } + public List DelIds { get; set; } } -} \ No newline at end of file + + public class DeleteItemDto + { + public int Id { get; set; } + public int RowVersion { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..169ed6bafa3d64536f159369e4c4ed57a816f914 --- /dev/null +++ b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs @@ -0,0 +1,13 @@ +namespace EOM.TSHotelManagement.Contract +{ + /// + /// 2FA 验证码输入 + /// + public class TwoFactorCodeInputDto : BaseInputDto + { + /// + /// 验证码 + /// + public string VerificationCode { get; set; } = string.Empty; + } +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorRecoveryCodesOutputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorRecoveryCodesOutputDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..7b9e97403c759b6a0990ae0548c764529419675b --- /dev/null +++ b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorRecoveryCodesOutputDto.cs @@ -0,0 +1,18 @@ +namespace EOM.TSHotelManagement.Contract +{ + /// + /// 2FA 恢复备用码输出 + /// + public class TwoFactorRecoveryCodesOutputDto : BaseOutputDto + { + /// + /// 新生成的恢复备用码(仅返回一次) + /// + public List RecoveryCodes { get; set; } = new(); + + /// + /// 剩余可用恢复备用码数量 + /// + public int RemainingCount { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorSetupOutputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorSetupOutputDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..1ef37114e65e5f503fc8af1cb78edb17b0ea9504 --- /dev/null +++ b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorSetupOutputDto.cs @@ -0,0 +1,38 @@ +namespace EOM.TSHotelManagement.Contract +{ + /// + /// 2FA 绑定信息输出 + /// + public class TwoFactorSetupOutputDto : BaseOutputDto + { + /// + /// 是否已启用 2FA + /// + public bool IsEnabled { get; set; } + + /// + /// 账号标识(Authenticator 展示) + /// + public string AccountName { get; set; } = string.Empty; + + /// + /// 手动录入密钥(Base32) + /// + public string ManualEntryKey { get; set; } = string.Empty; + + /// + /// otpauth URI + /// + public string OtpAuthUri { get; set; } = string.Empty; + + /// + /// 验证码位数 + /// + public int CodeDigits { get; set; } + + /// + /// 时间步长(秒) + /// + public int TimeStepSeconds { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorStatusOutputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorStatusOutputDto.cs new file mode 100644 index 0000000000000000000000000000000000000000..b83b49bf10a6f2465ffcd1d1548ae97b0818cdd1 --- /dev/null +++ b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorStatusOutputDto.cs @@ -0,0 +1,28 @@ +namespace EOM.TSHotelManagement.Contract +{ + /// + /// 2FA 状态输出 + /// + public class TwoFactorStatusOutputDto : BaseOutputDto + { + /// + /// 是否已启用 + /// + public bool IsEnabled { get; set; } + + /// + /// 启用时间 + /// + public DateTime? EnabledAt { get; set; } + + /// + /// 最近一次验证时间 + /// + public DateTime? LastVerifiedAt { get; set; } + + /// + /// 剩余可用恢复备用码数量 + /// + public int RemainingRecoveryCodes { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/EmployeeLoginDto.cs b/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/EmployeeLoginDto.cs index e6797240c2d9018aa26be7d780faa307428ed12f..ec6edfa5e1dc9b8317746a55caa4cc57dfe93d65 100644 --- a/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/EmployeeLoginDto.cs +++ b/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/EmployeeLoginDto.cs @@ -9,5 +9,6 @@ namespace EOM.TSHotelManagement.Contract public string EmployeeId { get; set; } public string Password { get; set; } public string EmailAddress { get; set; } + public string? TwoFactorCode { get; set; } } } diff --git a/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/ReadEmployeeOutputDto.cs b/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/ReadEmployeeOutputDto.cs index 7ec59a5766e2061d610176b16286662c4af3044a..66b0108424ba7de38a6684b0b35cc618e0a0aa07 100644 --- a/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/ReadEmployeeOutputDto.cs +++ b/EOM.TSHotelManagement.Contract/Employee/Dto/Employee/ReadEmployeeOutputDto.cs @@ -38,5 +38,11 @@ public string Password { get; set; } public string EmailAddress { get; set; } public string PhotoUrl { get; set; } + public bool RequiresTwoFactor { get; set; } + + /// + /// 本次登录是否通过恢复备用码完成 2FA + /// + public bool UsedRecoveryCodeLogin { get; set; } } } diff --git a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorInputDto.cs b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorInputDto.cs index 8a5f0ec9170bbf1fe448a628b814c1b340f7ff75..86dd7f4f803f899bfd5e3aaba49ce8d3a904f61d 100644 --- a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorInputDto.cs +++ b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorInputDto.cs @@ -6,6 +6,7 @@ public string? Password { get; set; } public string? Type { get; set; } public string? Name { get; set; } + public string? TwoFactorCode { get; set; } public int? IsSuperAdmin { get; set; } } diff --git a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorOutputDto.cs b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorOutputDto.cs index 3bbfad6cf05483d9f5e71a059ca5108992153db1..6421a0cc161dbfe0378546dcf7decaa0de75c383 100644 --- a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorOutputDto.cs +++ b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Administrator/ReadAdministratorOutputDto.cs @@ -13,6 +13,13 @@ public string IsSuperAdminDescription { get; set; } public string TypeName { get; set; } + + public bool RequiresTwoFactor { get; set; } + + /// + /// 本次登录是否通过恢复备用码完成 2FA + /// + public bool UsedRecoveryCodeLogin { get; set; } } } diff --git a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Permission/SensitiveReadInputDtos.cs b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Permission/SensitiveReadInputDtos.cs new file mode 100644 index 0000000000000000000000000000000000000000..bb5d7efe081c38f1ad78a6bc299f1f01da8b8823 --- /dev/null +++ b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Permission/SensitiveReadInputDtos.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace EOM.TSHotelManagement.Contract +{ + /// + /// Request body for reading data by user number. + /// + public class ReadByUserNumberInputDto : BaseInputDto + { + [Required(ErrorMessage = "UserNumber is required.")] + [MaxLength(128, ErrorMessage = "UserNumber cannot exceed 128 characters.")] + public string UserNumber { get; set; } = null!; + } + + /// + /// Request body for reading data by role number. + /// + public class ReadByRoleNumberInputDto : BaseInputDto + { + [Required(ErrorMessage = "RoleNumber is required.")] + [MaxLength(128, ErrorMessage = "RoleNumber cannot exceed 128 characters.")] + public string RoleNumber { get; set; } = null!; + } +} diff --git a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Position/CreatePositionInputDto.cs b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Position/CreatePositionInputDto.cs index 4542ca2e6a39c87e391a42c0cc40c248ff37886e..f1c85c41d9599cd222231f9e547d3ecb63dd7fd8 100644 --- a/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Position/CreatePositionInputDto.cs +++ b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Position/CreatePositionInputDto.cs @@ -4,9 +4,9 @@ namespace EOM.TSHotelManagement.Contract { public class CreatePositionInputDto : BaseInputDto { - [Required(ErrorMessage = "ְλΪֶ"), MaxLength(128, ErrorMessage = "ְλ󳤶Ϊ128ַ")] + [Required(ErrorMessage = "职位编号为必填字段"), MaxLength(128, ErrorMessage = "职位编号最大长度为128字符")] public string PositionNumber { get; set; } - [Required(ErrorMessage = "ְλΪֶ"), MaxLength(200, ErrorMessage = "ְλ󳤶Ϊ200ַ")] + [Required(ErrorMessage = "职位名称为必填字段"), MaxLength(200, ErrorMessage = "职位名称最大长度为200字符")] public string PositionName { get; set; } } } diff --git a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs index 48455e1aa2984f73be588a6fabe1ae6371e8fc13..e86d9cf0e9153c4b339c74707da7d2c2f630dc36 100644 --- a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs +++ b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs @@ -15,7 +15,16 @@ namespace EOM.TSHotelManagement.Data private readonly ISqlSugarClientConnector _connector; private readonly IConfiguration _configuration; private readonly string _initialAdminEncryptedPassword; + private readonly string _initialEmployeeEncryptedPassword; + private readonly IDataProtector _adminPasswordProtector; + private readonly IDataProtector _employeePasswordProtector; private const string AdminProtectorPurpose = "AdminInfoProtector"; + private const string EmployeeProtectorPurpose = "EmployeeInfoProtector"; + private const string DataProtectionPayloadPrefix = "CfDJ8"; + private const string DefaultAdminAccount = "admin"; + private const string DefaultEmployeeId = "WK010"; + private const string DefaultAdminPassword = "admin"; + private const string DefaultEmployeePassword = "WK010"; public DatabaseInitializer( ISqlSugarClient client, @@ -26,9 +35,10 @@ namespace EOM.TSHotelManagement.Data _client = client; _connector = connector; _configuration = configuration; - _initialAdminEncryptedPassword = dataProtectionProvider - .CreateProtector(AdminProtectorPurpose) - .Protect("admin"); + _adminPasswordProtector = dataProtectionProvider.CreateProtector(AdminProtectorPurpose); + _employeePasswordProtector = dataProtectionProvider.CreateProtector(EmployeeProtectorPurpose); + _initialAdminEncryptedPassword = _adminPasswordProtector.Protect(DefaultAdminPassword); + _initialEmployeeEncryptedPassword = _employeePasswordProtector.Protect(DefaultEmployeePassword); } #region initlize database @@ -72,7 +82,7 @@ namespace EOM.TSHotelManagement.Data { Console.WriteLine("Initializing database schema..."); - var entityBuilder = new EntityBuilder(_initialAdminEncryptedPassword); + var entityBuilder = new EntityBuilder(_initialAdminEncryptedPassword, _initialEmployeeEncryptedPassword); var dbTables = db.DbMaintenance.GetTableInfoList() .Select(a => a.Name.Trim().ToLower()) @@ -92,7 +102,8 @@ namespace EOM.TSHotelManagement.Data .ToArray(); db.CodeFirst.InitTables(needCreateTableTypes); - + EnsureTwoFactorForeignKeys(db, dbSettings.DbType); + Console.WriteLine("Database schema initialized"); SeedInitialData(db); @@ -248,13 +259,67 @@ namespace EOM.TSHotelManagement.Data return configString; } + private void EnsureTwoFactorForeignKeys(ISqlSugarClient db, DbType dbType) + { + try + { + if (dbType is DbType.MySql or DbType.MySqlConnector) + { + EnsureMySqlForeignKey(db, "two_factor_auth", "fk_2fa_employee", "employee_pk", "employee", "id"); + EnsureMySqlForeignKey(db, "two_factor_auth", "fk_2fa_administrator", "administrator_pk", "administrator", "id"); + EnsureMySqlForeignKey(db, "two_factor_auth", "fk_2fa_customer_account", "customer_account_pk", "customer_account", "id"); + EnsureMySqlForeignKey(db, "two_factor_recovery_code", "fk_2fa_recovery_auth", "two_factor_auth_pk", "two_factor_auth", "id", "CASCADE"); + } + } + catch (Exception ex) + { + Console.WriteLine($"EnsureTwoFactorForeignKeys skipped: {ex.Message}"); + } + } + + private static void EnsureMySqlForeignKey( + ISqlSugarClient db, + string tableName, + string constraintName, + string columnName, + string referenceTable, + string referenceColumn, + string onDeleteAction = "SET NULL") + { + var existsSql = @"SELECT COUNT(1) + FROM information_schema.TABLE_CONSTRAINTS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = @tableName + AND CONSTRAINT_NAME = @constraintName"; + + var exists = db.Ado.GetInt( + existsSql, + new SugarParameter("@tableName", tableName), + new SugarParameter("@constraintName", constraintName)) > 0; + if (exists) + { + return; + } + + var addConstraintSql = $@"ALTER TABLE `{tableName}` + ADD CONSTRAINT `{constraintName}` + FOREIGN KEY (`{columnName}`) + REFERENCES `{referenceTable}`(`{referenceColumn}`) + ON UPDATE RESTRICT + ON DELETE {onDeleteAction};"; + + db.Ado.ExecuteCommand(addConstraintSql); + } + private void SeedInitialData(ISqlSugarClient db) { Console.WriteLine("Initializing database data..."); try { - var entityBuilder = new EntityBuilder(_initialAdminEncryptedPassword); + EnsureDefaultAccountPasswordsEncrypted(db); + + var entityBuilder = new EntityBuilder(_initialAdminEncryptedPassword, _initialEmployeeEncryptedPassword); var entitiesToAdd = new List(); var sortedEntities = entityBuilder.GetEntityDatas() @@ -533,6 +598,56 @@ namespace EOM.TSHotelManagement.Data Console.WriteLine($"administrator password:admin"); } } + + private void EnsureDefaultAccountPasswordsEncrypted(ISqlSugarClient db) + { + try + { + var admin = db.Queryable() + .First(a => a.Account == DefaultAdminAccount && a.IsDelete != 1); + if (admin != null && !IsProtectedValue(admin.Password, _adminPasswordProtector)) + { + var source = string.IsNullOrWhiteSpace(admin.Password) ? DefaultAdminPassword : admin.Password; + admin.Password = _adminPasswordProtector.Protect(source); + admin.DataChgUsr = "System"; + admin.DataChgDate = DateTime.Now; + db.Updateable(admin) + .UpdateColumns(a => new { a.Password, a.DataChgUsr, a.DataChgDate }) + .ExecuteCommand(); + Console.WriteLine("Auto-fixed admin password encryption during initialization."); + } + + var employee = db.Queryable() + .First(a => a.EmployeeId == DefaultEmployeeId && a.IsDelete != 1); + if (employee != null && !IsProtectedValue(employee.Password, _employeePasswordProtector)) + { + var source = string.IsNullOrWhiteSpace(employee.Password) ? DefaultEmployeePassword : employee.Password; + employee.Password = _employeePasswordProtector.Protect(source); + employee.DataChgUsr = "System"; + employee.DataChgDate = DateTime.Now; + db.Updateable(employee) + .UpdateColumns(a => new { a.Password, a.DataChgUsr, a.DataChgDate }) + .ExecuteCommand(); + Console.WriteLine("Auto-fixed employee password encryption during initialization."); + } + } + catch (Exception ex) + { + Console.WriteLine($"Ensure default account password encryption skipped: {ex.Message}"); + } + } + + private static bool IsProtectedValue(string? value, IDataProtector protector) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + _ = protector; + return value.StartsWith(DataProtectionPayloadPrefix, StringComparison.Ordinal); + } #endregion } } + diff --git a/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs b/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs index a2599873eb0f31a5951d12412207430227973647..415449c1f36aea27d5911dd554c870cd14d5ccec 100644 --- a/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs +++ b/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs @@ -1,6 +1,7 @@ -using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Common; using EOM.TSHotelManagement.Domain; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using SqlSugar; using System.Linq.Expressions; @@ -15,11 +16,14 @@ namespace EOM.TSHotelManagement.Data private readonly JWTHelper _jWTHelper; - public GenericRepository(ISqlSugarClient client, IHttpContextAccessor httpContextAccessor, JWTHelper jWTHelper) : base(client) + private readonly ILogger> _log; + + public GenericRepository(ISqlSugarClient client, IHttpContextAccessor httpContextAccessor, JWTHelper jWTHelper, ILogger> log) : base(client) { base.Context = client; _httpContextAccessor = httpContextAccessor; _jWTHelper = jWTHelper; + _log = log; } private string GetCurrentUser() @@ -48,12 +52,16 @@ namespace EOM.TSHotelManagement.Data baseEntity.DataInsDate = DateTime.Now; if (string.IsNullOrEmpty(baseEntity.DataInsUsr)) baseEntity.DataInsUsr = currentUser; + if (baseEntity.RowVersion <= 0) + baseEntity.RowVersion = 1; } return base.Insert(entity); } public override bool Update(T entity) { + Expression>? rowVersionWhere = null; + if (entity is BaseEntity baseEntity) { var currentUser = GetCurrentUser(); @@ -61,6 +69,14 @@ namespace EOM.TSHotelManagement.Data baseEntity.DataChgDate = DateTime.Now; if (string.IsNullOrEmpty(baseEntity.DataChgUsr)) baseEntity.DataChgUsr = currentUser; + + // 更新接口必须携带行版本,缺失时视为并发校验失败。 + if (baseEntity.RowVersion <= 0) + return false; + + var currentRowVersion = baseEntity.RowVersion; + rowVersionWhere = BuildEqualsLambda(nameof(BaseEntity.RowVersion), currentRowVersion); + baseEntity.RowVersion = currentRowVersion + 1; } var primaryKeys = base.Context.EntityMaintenance.GetEntityInfo().Columns @@ -68,56 +84,30 @@ namespace EOM.TSHotelManagement.Data .Select(it => it.PropertyName) .ToList(); - if (primaryKeys.Count <= 1) + var primaryKeyWhere = BuildUpdateWhereExpression(entity, primaryKeys); + if (primaryKeyWhere == null) { - return base.Context.Updateable(entity) - .IgnoreColumns(true, false) - .ExecuteCommand() > 0; + _log.LogWarning("Unable to build primary-key WHERE for entity type {EntityType}. Update aborted to avoid accidental mass update.", typeof(T).Name); + return false; } - var idProperty = entity.GetType().GetProperty("Id"); - if (idProperty != null) - { - var idValue = Convert.ToInt64(idProperty.GetValue(entity)); - - if (idValue == 0) - { - var otherPrimaryKeys = primaryKeys.Where(pk => pk != "Id").ToList(); - - var parameter = Expression.Parameter(typeof(T), "it"); - Expression whereExpression = null; - - foreach (var key in otherPrimaryKeys) - { - var property = Expression.Property(parameter, key); - var value = entity.GetType().GetProperty(key).GetValue(entity); - var constant = Expression.Constant(value); - var equal = Expression.Equal(property, constant); - - whereExpression = whereExpression == null - ? equal - : Expression.AndAlso(whereExpression, equal); - } - - if (whereExpression != null) - { - var lambda = Expression.Lambda>(whereExpression, parameter); - - return base.Context.Updateable(entity) - .Where(lambda) - .IgnoreColumns(true, false) - .ExecuteCommand() > 0; - } - } - } + var finalWhere = rowVersionWhere == null + ? primaryKeyWhere + : AndAlso(primaryKeyWhere, rowVersionWhere); return base.Context.Updateable(entity) - .IgnoreColumns(true, false) - .ExecuteCommand() > 0; + .IgnoreColumns(true, false) + .Where(finalWhere) + .ExecuteCommand() > 0; } public override bool UpdateRange(List updateObjs) { + if (updateObjs == null || updateObjs.Count == 0) + { + return false; + } + foreach (var entity in updateObjs) { if (entity is BaseEntity baseEntity) @@ -130,6 +120,25 @@ namespace EOM.TSHotelManagement.Data } } + // For BaseEntity types, route through single-entity Update in a transaction + // so optimistic-lock checks are consistently enforced. + if (typeof(BaseEntity).IsAssignableFrom(typeof(T))) + { + var tranResult = base.Context.Ado.UseTran(() => + { + foreach (var entity in updateObjs) + { + if (!Update(entity)) + { + _log.LogWarning("Optimistic concurrency check failed for entity of type {EntityType}. Update aborted.", typeof(T).Name); + throw new InvalidOperationException("Optimistic concurrency check failed."); + } + } + }); + + return tranResult.IsSuccess; + } + return base.Context.Updateable(updateObjs) .IgnoreColumns(ignoreAllNullColumns: true) .ExecuteCommand() > 0; @@ -242,5 +251,121 @@ namespace EOM.TSHotelManagement.Data return totalAffected > 0; } + + private static Expression> BuildEqualsLambda(string propertyName, object propertyValue) + { + var parameter = Expression.Parameter(typeof(T), "it"); + var property = Expression.Property(parameter, propertyName); + + object? normalizedValue = propertyValue; + var targetType = Nullable.GetUnderlyingType(property.Type) ?? property.Type; + if (normalizedValue != null && normalizedValue.GetType() != targetType) + { + normalizedValue = Convert.ChangeType(normalizedValue, targetType); + } + + var constant = Expression.Constant(normalizedValue, property.Type); + var equal = Expression.Equal(property, constant); + return Expression.Lambda>(equal, parameter); + } + + private static Expression>? BuildPrimaryKeyWhereExpression(T entity, List primaryKeys) + { + if (entity == null || primaryKeys == null || primaryKeys.Count == 0) + { + return null; + } + + var parameter = Expression.Parameter(typeof(T), "it"); + Expression? whereExpression = null; + + foreach (var key in primaryKeys) + { + var value = entity.GetType().GetProperty(key)?.GetValue(entity); + if (value == null) + { + continue; + } + + var property = Expression.Property(parameter, key); + object normalizedValue = value; + var targetType = Nullable.GetUnderlyingType(property.Type) ?? property.Type; + if (normalizedValue.GetType() != targetType) + { + normalizedValue = Convert.ChangeType(normalizedValue, targetType); + } + + var constant = Expression.Constant(normalizedValue, property.Type); + var equal = Expression.Equal(property, constant); + + whereExpression = whereExpression == null + ? equal + : Expression.AndAlso(whereExpression, equal); + } + + return whereExpression == null + ? null + : Expression.Lambda>(whereExpression, parameter); + } + + private static Expression>? BuildUpdateWhereExpression(T entity, List primaryKeys) + { + if (entity == null || primaryKeys == null || primaryKeys.Count == 0) + { + return null; + } + + // Prefer identity-style Id when provided. + var idProperty = entity.GetType().GetProperty("Id"); + if (idProperty != null) + { + var idRawValue = idProperty.GetValue(entity); + if (idRawValue != null) + { + var idValue = Convert.ToInt64(idRawValue); + if (idValue > 0) + { + return BuildEqualsLambda("Id", idValue); + } + } + } + + // Fallback to non-Id primary keys when Id is absent/invalid. + var nonIdPrimaryKeys = primaryKeys.Where(pk => !pk.Equals("Id", StringComparison.OrdinalIgnoreCase)).ToList(); + var fallbackWhere = BuildPrimaryKeyWhereExpression(entity, nonIdPrimaryKeys); + if (fallbackWhere != null) + { + return fallbackWhere; + } + + // Last chance: use all primary keys if available. + return BuildPrimaryKeyWhereExpression(entity, primaryKeys); + } + + private static Expression> AndAlso(Expression> left, Expression> right) + { + var parameter = Expression.Parameter(typeof(T), "it"); + var leftBody = new ReplaceParameterVisitor(left.Parameters[0], parameter).Visit(left.Body); + var rightBody = new ReplaceParameterVisitor(right.Parameters[0], parameter).Visit(right.Body); + var andBody = Expression.AndAlso(leftBody!, rightBody!); + return Expression.Lambda>(andBody, parameter); + } + + private sealed class ReplaceParameterVisitor : ExpressionVisitor + { + private readonly ParameterExpression _oldParameter; + private readonly ParameterExpression _newParameter; + + public ReplaceParameterVisitor(ParameterExpression oldParameter, ParameterExpression newParameter) + { + _oldParameter = oldParameter; + _newParameter = newParameter; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + return node == _oldParameter ? _newParameter : base.VisitParameter(node); + } + } } } diff --git a/EOM.TSHotelManagement.Domain/BaseEntity.cs b/EOM.TSHotelManagement.Domain/BaseEntity.cs index 221afec415589503cb9d0e988ab5193d0d551651..68954f79cd610901814f60bbe159981e32d5b17b 100644 --- a/EOM.TSHotelManagement.Domain/BaseEntity.cs +++ b/EOM.TSHotelManagement.Domain/BaseEntity.cs @@ -30,6 +30,11 @@ namespace EOM.TSHotelManagement.Domain [SqlSugar.SugarColumn(ColumnName = "datachg_date", IsOnlyIgnoreInsert = true, IsNullable = true)] public DateTime? DataChgDate { get; set; } /// + /// 行版本(乐观锁) + /// + [SqlSugar.SugarColumn(ColumnName = "row_version", IsNullable = false, DefaultValue = "1")] + public long RowVersion { get; set; } = 1; + /// /// Token /// [SqlSugar.SugarColumn(IsIgnore = true)] diff --git a/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs b/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs index 123544934e496a98c90ec2b70da163e24a8af38f..957fe625782f6fa655d3fbd8a8a146f957bf1043 100644 --- a/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs +++ b/EOM.TSHotelManagement.Domain/Business/Customer/CustomerAccount.cs @@ -1,8 +1,8 @@ -using System; +using System; namespace EOM.TSHotelManagement.Domain { - [SqlSugar.SugarTable("custoemr_account", "客户账号表")] + [SqlSugar.SugarTable("customer_account", "客户账号表")] public class CustomerAccount : BaseEntity { /// diff --git a/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs new file mode 100644 index 0000000000000000000000000000000000000000..a87512de1263ed621183552e44b024e7fb96bf0a --- /dev/null +++ b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs @@ -0,0 +1,38 @@ +using SqlSugar; + +namespace EOM.TSHotelManagement.Domain +{ + [SugarTable("two_factor_auth", "2FA配置表")] + [SugarIndex("ux_2fa_employee_pk", nameof(EmployeePk), OrderByType.Asc, true)] + [SugarIndex("ux_2fa_administrator_pk", nameof(AdministratorPk), OrderByType.Asc, true)] + [SugarIndex("ux_2fa_customer_account_pk", nameof(CustomerAccountPk), OrderByType.Asc, true)] + public class TwoFactorAuth : BaseEntity + { + [SugarColumn(ColumnName = "id", IsIdentity = true, IsPrimaryKey = true, IsNullable = false, ColumnDescription = "索引ID")] + public int Id { get; set; } + + [SugarColumn(ColumnName = "employee_pk", IsNullable = true, ColumnDescription = "员工表主键ID(FK->employee.id)")] + public int? EmployeePk { get; set; } + + [SugarColumn(ColumnName = "administrator_pk", IsNullable = true, ColumnDescription = "管理员表主键ID(FK->administrator.id)")] + public int? AdministratorPk { get; set; } + + [SugarColumn(ColumnName = "customer_account_pk", IsNullable = true, ColumnDescription = "客户账号表主键ID(FK->customer_account.id)")] + public int? CustomerAccountPk { get; set; } + + [SugarColumn(ColumnName = "secret_key", Length = 512, IsNullable = true, ColumnDescription = "2FA密钥(加密存储)")] + public string? SecretKey { get; set; } + + [SugarColumn(ColumnName = "is_enabled", IsNullable = false, DefaultValue = "0", ColumnDescription = "是否启用2FA(0:否,1:是)")] + public int IsEnabled { get; set; } = 0; + + [SugarColumn(ColumnName = "enabled_at", IsNullable = true, ColumnDescription = "启用时间")] + public DateTime? EnabledAt { get; set; } + + [SugarColumn(ColumnName = "last_verified_at", IsNullable = true, ColumnDescription = "最近一次验证时间")] + public DateTime? LastVerifiedAt { get; set; } + + [SugarColumn(ColumnName = "last_validated_counter", IsNullable = true, ColumnDescription = "last accepted TOTP counter")] + public long? LastValidatedCounter { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs new file mode 100644 index 0000000000000000000000000000000000000000..108125ac2cde198de047cf1728a83f7b53ce9978 --- /dev/null +++ b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs @@ -0,0 +1,28 @@ +using SqlSugar; + +namespace EOM.TSHotelManagement.Domain +{ + [SugarTable("two_factor_recovery_code", "2FA recovery codes")] + [SugarIndex("idx_2fa_recovery_auth", nameof(TwoFactorAuthPk), OrderByType.Asc)] + [SugarIndex("idx_2fa_recovery_used", nameof(IsUsed), OrderByType.Asc)] + public class TwoFactorRecoveryCode : BaseEntity + { + [SugarColumn(ColumnName = "id", IsIdentity = true, IsPrimaryKey = true, IsNullable = false, ColumnDescription = "Primary key")] + public int Id { get; set; } + + [SugarColumn(ColumnName = "two_factor_auth_pk", IsNullable = false, ColumnDescription = "FK->two_factor_auth.id")] + public int TwoFactorAuthPk { get; set; } + + [SugarColumn(ColumnName = "code_salt", Length = 64, IsNullable = false, ColumnDescription = "Recovery code salt")] + public string CodeSalt { get; set; } = string.Empty; + + [SugarColumn(ColumnName = "code_hash", Length = 128, IsNullable = false, ColumnDescription = "Recovery code hash")] + public string CodeHash { get; set; } = string.Empty; + + [SugarColumn(ColumnName = "is_used", IsNullable = false, DefaultValue = "0", ColumnDescription = "Whether used")] + public int IsUsed { get; set; } = 0; + + [SugarColumn(ColumnName = "used_at", IsNullable = true, ColumnDescription = "Used time")] + public DateTime? UsedAt { get; set; } + } +} diff --git a/EOM.TSHotelManagement.Infrastructure/Config/RedisConfig.cs b/EOM.TSHotelManagement.Infrastructure/Config/RedisConfig.cs index c4e27a12f44f66eac9c3ceb439ca798ec55ff0cd..967ad81bb3c4c85e77aa4eca7dfa7ef5ab8fe41f 100644 --- a/EOM.TSHotelManagement.Infrastructure/Config/RedisConfig.cs +++ b/EOM.TSHotelManagement.Infrastructure/Config/RedisConfig.cs @@ -1,8 +1,9 @@ -namespace EOM.TSHotelManagement.Infrastructure +namespace EOM.TSHotelManagement.Infrastructure { public class RedisConfig { public string ConnectionString { get; set; } public bool Enable { get; set; } + public int? DefaultDatabase { get; set; } } } diff --git a/EOM.TSHotelManagement.Infrastructure/Config/TwoFactorConfig.cs b/EOM.TSHotelManagement.Infrastructure/Config/TwoFactorConfig.cs new file mode 100644 index 0000000000000000000000000000000000000000..02ea824c428597f864584d102c6341ec010d8f83 --- /dev/null +++ b/EOM.TSHotelManagement.Infrastructure/Config/TwoFactorConfig.cs @@ -0,0 +1,48 @@ +namespace EOM.TSHotelManagement.Infrastructure +{ + /// + /// 2FA(TOTP)配置 + /// + public class TwoFactorConfig + { + /// + /// 签发方名称(Authenticator 展示) + /// + public string Issuer { get; set; } = "TSHotel"; + + /// + /// 密钥字节长度 + /// + public int SecretSize { get; set; } = 20; + + /// + /// 验证码位数 + /// + public int CodeDigits { get; set; } = 6; + + /// + /// 时间步长(秒) + /// + public int TimeStepSeconds { get; set; } = 30; + + /// + /// 允许时间漂移窗口数 + /// + public int AllowedDriftWindows { get; set; } = 1; + + /// + /// 每次生成的恢复备用码数量 + /// + public int RecoveryCodeCount { get; set; } = 8; + + /// + /// 单个恢复备用码字符长度(不含分隔符) + /// + public int RecoveryCodeLength { get; set; } = 10; + + /// + /// 恢复备用码分组长度(用于展示格式,如 5-5) + /// + public int RecoveryCodeGroupSize { get; set; } = 5; + } +} diff --git a/EOM.TSHotelManagement.Infrastructure/Factory/RedisConfigFactory.cs b/EOM.TSHotelManagement.Infrastructure/Factory/RedisConfigFactory.cs index 415087eedb3958bea489b16aec96fbf7a19767c8..981eeafaadc5f92b953104d26cce9ed66041c6e7 100644 --- a/EOM.TSHotelManagement.Infrastructure/Factory/RedisConfigFactory.cs +++ b/EOM.TSHotelManagement.Infrastructure/Factory/RedisConfigFactory.cs @@ -17,10 +17,14 @@ namespace EOM.TSHotelManagement.Infrastructure public RedisConfig GetRedisConfig() { var redisSection = _configuration.GetSection("Redis"); + var enable = redisSection.GetValue("Enable") + ?? redisSection.GetValue("Enabled") + ?? false; + var redisConfig = new RedisConfig { ConnectionString = redisSection.GetValue("ConnectionString"), - Enable = redisSection.GetValue("Enable") + Enable = enable }; return redisConfig; } diff --git a/EOM.TSHotelManagement.Infrastructure/Factory/TwoFactorConfigFactory.cs b/EOM.TSHotelManagement.Infrastructure/Factory/TwoFactorConfigFactory.cs new file mode 100644 index 0000000000000000000000000000000000000000..7014471ed2a8a63808425ee4ecc606b6b0856f75 --- /dev/null +++ b/EOM.TSHotelManagement.Infrastructure/Factory/TwoFactorConfigFactory.cs @@ -0,0 +1,40 @@ +using Microsoft.Extensions.Configuration; + +namespace EOM.TSHotelManagement.Infrastructure +{ + /// + /// 2FA 配置工厂 + /// + public class TwoFactorConfigFactory + { + private readonly IConfiguration _configuration; + + /// + /// 构造函数 + /// + /// + public TwoFactorConfigFactory(IConfiguration configuration) + { + _configuration = configuration; + } + + /// + /// 读取 2FA 配置 + /// + /// + public TwoFactorConfig GetTwoFactorConfig() + { + return new TwoFactorConfig + { + Issuer = _configuration.GetSection("TwoFactor").GetValue("Issuer") ?? "TSHotel", + SecretSize = _configuration.GetSection("TwoFactor").GetValue("SecretSize") ?? 20, + CodeDigits = _configuration.GetSection("TwoFactor").GetValue("CodeDigits") ?? 6, + TimeStepSeconds = _configuration.GetSection("TwoFactor").GetValue("TimeStepSeconds") ?? 30, + AllowedDriftWindows = _configuration.GetSection("TwoFactor").GetValue("AllowedDriftWindows") ?? 1, + RecoveryCodeCount = _configuration.GetSection("TwoFactor").GetValue("RecoveryCodeCount") ?? 8, + RecoveryCodeLength = _configuration.GetSection("TwoFactor").GetValue("RecoveryCodeLength") ?? 10, + RecoveryCodeGroupSize = _configuration.GetSection("TwoFactor").GetValue("RecoveryCodeGroupSize") ?? 5 + }; + } + } +} diff --git a/EOM.TSHotelManagement.Migration/EntityBuilder.cs b/EOM.TSHotelManagement.Migration/EntityBuilder.cs index 19fcea0ea6dc07d579a1abf27509fc46a702de43..0851d3c174cd82b856ec86945f7026a9d6eb98d6 100644 --- a/EOM.TSHotelManagement.Migration/EntityBuilder.cs +++ b/EOM.TSHotelManagement.Migration/EntityBuilder.cs @@ -1,14 +1,14 @@ -using EOM.TSHotelManagement.Domain; +using EOM.TSHotelManagement.Domain; namespace EOM.TSHotelManagement.Migration { public class EntityBuilder { - public EntityBuilder(string? initialAdminEncryptedPassword = null) + public EntityBuilder(string? initialAdminEncryptedPassword = null, string? initialEmployeeEncryptedPassword = null) { - if (string.IsNullOrWhiteSpace(initialAdminEncryptedPassword)) + if (string.IsNullOrWhiteSpace(initialAdminEncryptedPassword) || string.IsNullOrWhiteSpace(initialEmployeeEncryptedPassword)) { - return; + throw new ArgumentException("Initial encrypted passwords for administrator and employee are required."); } var admin = entityDatas @@ -19,6 +19,15 @@ namespace EOM.TSHotelManagement.Migration { admin.Password = initialAdminEncryptedPassword; } + + var employee = entityDatas + .OfType() + .FirstOrDefault(a => string.Equals(a.EmployeeId, "WK010", StringComparison.OrdinalIgnoreCase)); + + if (employee != null) + { + employee.Password = initialEmployeeEncryptedPassword; + } } private readonly Type[] entityTypes = @@ -61,7 +70,9 @@ namespace EOM.TSHotelManagement.Migration typeof(VipLevelRule), typeof(RequestLog), typeof(News), - typeof(Permission) + typeof(Permission), + typeof(TwoFactorAuth), + typeof(TwoFactorRecoveryCode) }; private readonly List entityDatas = new() @@ -78,7 +89,7 @@ namespace EOM.TSHotelManagement.Migration { Number = "1263785187301658678", Account = "admin", - Password = "admin", + Password = string.Empty, Name = "Administrator", Type = "Admin", IsSuperAdmin = 1, @@ -90,7 +101,7 @@ namespace EOM.TSHotelManagement.Migration { Key = "home", Title = "首页", - Path = "/home", + Path = "/", Parent = null, Icon = "HomeOutlined", IsDelete = 0, @@ -472,6 +483,17 @@ namespace EOM.TSHotelManagement.Migration DataInsDate = DateTime.Now, }, new Menu // 36 + { + Key = "my", + Title = "我的", + Path = "/home", + Parent = 1, + Icon = "HomeOutlined", + IsDelete = 0, + DataInsUsr = "System", + DataInsDate = DateTime.Now, + }, + new Menu // 37 { Key = "dashboard", Title = "仪表盘", @@ -482,7 +504,7 @@ namespace EOM.TSHotelManagement.Migration DataInsUsr = "System", DataInsDate = DateTime.Now, }, - new Menu // 37 + new Menu // 38 { Key = "promotioncontent", Title = "宣传联动内容", @@ -493,7 +515,7 @@ namespace EOM.TSHotelManagement.Migration DataInsUsr = "System", DataInsDate = DateTime.Now, }, - new Menu // 38 + new Menu // 39 { Key = "requestlog", Title = "请求日志", @@ -585,7 +607,7 @@ namespace EOM.TSHotelManagement.Migration EmployeeId = "WK010", EmployeeName = "阿杰", DateOfBirth = DateOnly.FromDateTime(new DateTime(1999,7,20,0,0,0)), - Password="oi6+T4604MqlB/SWAvrJBQ==·?bdc^^ entityTypes; diff --git a/EOM.TSHotelManagement.Service/Application/NavBar/NavBarService.cs b/EOM.TSHotelManagement.Service/Application/NavBar/NavBarService.cs index fe14d8f58bd2d4cb4fe794044262f51834236866..da3cf709bae56ab21c1337b8bb8d30e5868849e8 100644 --- a/EOM.TSHotelManagement.Service/Application/NavBar/NavBarService.cs +++ b/EOM.TSHotelManagement.Service/Application/NavBar/NavBarService.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Common; using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Data; using EOM.TSHotelManagement.Domain; @@ -97,14 +97,11 @@ namespace EOM.TSHotelManagement.Service navBar.NavigationBarImage = input.NavigationBarImage; navBar.NavigationBarEvent = input.NavigationBarEvent; navBar.MarginLeft = input.MarginLeft; + navBar.RowVersion = input.RowVersion ?? 0; var result = navBarRepository.Update(navBar); if (!result) { - return new BaseResponse - { - Code = BusinessStatusCode.InternalServerError, - Message = "更新失败" - }; + return BaseResponseFactory.ConcurrencyConflict(); } return new BaseResponse { @@ -130,7 +127,8 @@ namespace EOM.TSHotelManagement.Service }; } - var navBars = navBarRepository.GetList(a => input.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(input); + var navBars = navBarRepository.GetList(a => delIds.Contains(a.Id)); if (!navBars.Any()) { @@ -141,6 +139,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(input, navBars, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 批量软删除 var result = navBarRepository.SoftDeleteRange(navBars); diff --git a/EOM.TSHotelManagement.Service/Business/Asset/AssetService.cs b/EOM.TSHotelManagement.Service/Business/Asset/AssetService.cs index 3e211031f3aca27f31cce527761be38613f80de8..a3763018cf880abe5dd1c5499b902cdaee90d6f1 100644 --- a/EOM.TSHotelManagement.Service/Business/Asset/AssetService.cs +++ b/EOM.TSHotelManagement.Service/Business/Asset/AssetService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -156,7 +156,7 @@ namespace EOM.TSHotelManagement.Service var result = assetRepository.Update(entity); if (!result) { - return new BaseResponse() { Message = LocalizationHelper.GetLocalizedString("update asset failed.", "资产更新失败"), Code = BusinessStatusCode.InternalServerError }; + return BaseResponseFactory.ConcurrencyConflict(); } } catch (Exception ex) @@ -184,7 +184,8 @@ namespace EOM.TSHotelManagement.Service }; } - var assets = assetRepository.GetList(a => asset.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(asset); + var assets = assetRepository.GetList(a => delIds.Contains(a.Id)); if (!assets.Any()) { @@ -195,6 +196,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(asset, assets, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + var result = assetRepository.SoftDeleteRange(assets); if (!result) diff --git a/EOM.TSHotelManagement.Service/Business/Customer/Account/CustomerAccountService.cs b/EOM.TSHotelManagement.Service/Business/Customer/Account/CustomerAccountService.cs index f1919647c4e61ddfef944087870948ca63e6e7b5..80cb10a259471ad7587167008847c1cfda9fb129 100644 --- a/EOM.TSHotelManagement.Service/Business/Customer/Account/CustomerAccountService.cs +++ b/EOM.TSHotelManagement.Service/Business/Customer/Account/CustomerAccountService.cs @@ -5,6 +5,8 @@ using EOM.TSHotelManagement.Domain; using jvncorelib.CodeLib; using jvncorelib.EntityLib; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using System.Net.Mail; using System.Security.Claims; using System.Text.Json; using System.Text.RegularExpressions; @@ -44,6 +46,20 @@ namespace EOM.TSHotelManagement.Service /// private readonly JWTHelper jWTHelper; + /// + /// 2FA服务 + /// + private readonly ITwoFactorAuthService twoFactorAuthService; + + /// + /// 邮件助手 + /// + private readonly MailHelper mailHelper; + + /// + /// 日志 + /// + private readonly ILogger logger; private readonly IHttpContextAccessor _httpContextAccessor; @@ -55,9 +71,7 @@ namespace EOM.TSHotelManagement.Service /// /// /// - /// - /// - public CustomerAccountService(GenericRepository customerAccountRepository, GenericRepository customerRepository, GenericRepository roleRepository, GenericRepository userRoleRepository, DataProtectionHelper dataProtector, JWTHelper jWTHelper, IHttpContextAccessor httpContextAccessor, Regex accountRegex, Regex passwordRegex) + public CustomerAccountService(GenericRepository customerAccountRepository, GenericRepository customerRepository, GenericRepository roleRepository, GenericRepository userRoleRepository, DataProtectionHelper dataProtector, JWTHelper jWTHelper, ITwoFactorAuthService twoFactorAuthService, MailHelper mailHelper, IHttpContextAccessor httpContextAccessor, ILogger logger) { this.customerAccountRepository = customerAccountRepository; this.customerRepository = customerRepository; @@ -65,9 +79,10 @@ namespace EOM.TSHotelManagement.Service this.userRoleRepository = userRoleRepository; this.dataProtector = dataProtector; this.jWTHelper = jWTHelper; + this.twoFactorAuthService = twoFactorAuthService; + this.mailHelper = mailHelper; _httpContextAccessor = httpContextAccessor; - AccountRegex = accountRegex; - PasswordRegex = passwordRegex; + this.logger = logger; } /// @@ -87,6 +102,41 @@ namespace EOM.TSHotelManagement.Service if (!dataProtector.CompareCustomerData(customerAccount.Password, readCustomerAccountInputDto.Password)) return new SingleOutputDto() { Code = BusinessStatusCode.Unauthorized, Message = LocalizationHelper.GetLocalizedString("Invalid account or password", "账号或密码错误"), Data = new ReadCustomerAccountOutputDto() }; + var usedRecoveryCode = false; + if (twoFactorAuthService.RequiresTwoFactor(TwoFactorUserType.Customer, customerAccount.Id)) + { + if (string.IsNullOrWhiteSpace(readCustomerAccountInputDto.TwoFactorCode)) + { + return new SingleOutputDto() + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("2FA code is required", "需要输入2FA验证码"), + Data = new ReadCustomerAccountOutputDto + { + Account = customerAccount.Account, + Name = customerAccount.Name, + RequiresTwoFactor = true + } + }; + } + + var passed = twoFactorAuthService.VerifyLoginCode(TwoFactorUserType.Customer, customerAccount.Id, readCustomerAccountInputDto.TwoFactorCode, out usedRecoveryCode); + if (!passed) + { + return new SingleOutputDto() + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = new ReadCustomerAccountOutputDto + { + Account = customerAccount.Account, + Name = customerAccount.Name, + RequiresTwoFactor = true + } + }; + } + } + var copyCustomerAccount = customerAccount; var context = _httpContextAccessor.HttpContext; @@ -106,6 +156,11 @@ namespace EOM.TSHotelManagement.Service new Claim(ClaimTypes.UserData, JsonSerializer.Serialize(customerAccount)), }), 10080); // 7天有效期 + if (usedRecoveryCode) + { + NotifyRecoveryCodeLoginByEmail(copyCustomerAccount.EmailAddress, copyCustomerAccount.Name, copyCustomerAccount.Account); + } + return new SingleOutputDto() { Code = BusinessStatusCode.Success, @@ -118,11 +173,64 @@ namespace EOM.TSHotelManagement.Service LastLoginIp = copyCustomerAccount.LastLoginIp, LastLoginTime = copyCustomerAccount.LastLoginTime, Status = copyCustomerAccount.Status, - UserToken = copyCustomerAccount.UserToken + UserToken = copyCustomerAccount.UserToken, + RequiresTwoFactor = false, + UsedRecoveryCodeLogin = usedRecoveryCode }, }; } + /// + /// 备用码登录后邮件通知(客户) + /// + /// 邮箱 + /// 客户名称 + /// 客户账号 + private void NotifyRecoveryCodeLoginByEmail(string? emailAddress, string? customerName, string? account) + { + if (string.IsNullOrWhiteSpace(emailAddress) || !MailAddress.TryCreate(emailAddress, out _)) + { + logger.LogWarning("Recovery-code login alert skipped for customer {Account}: invalid email.", account ?? string.Empty); + return; + } + + var recipient = emailAddress; + var name = customerName ?? "Customer"; + var identity = account ?? string.Empty; + _ = Task.Run(async () => + { + await SendCustomerRecoveryCodeAlertWithRetryAsync(recipient, name, identity); + }); + } + + private async Task SendCustomerRecoveryCodeAlertWithRetryAsync(string recipient, string customerName, string account) + { + const int maxAttempts = 3; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate(customerName, account, DateTime.Now); + var sent = mailHelper.SendMail(new List { recipient }, template.Subject, template.Body); + if (sent) + { + return; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Recovery-code alert send failed for customer {Account}, attempt {Attempt}.", account, attempt); + } + + if (attempt < maxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(attempt)); + } + } + + logger.LogWarning("Recovery-code alert send exhausted retries for customer {Account}.", account); + } + /// /// 注册 /// @@ -245,12 +353,66 @@ namespace EOM.TSHotelManagement.Service LastLoginIp = customerAccount.LastLoginIp, LastLoginTime = customerAccount.LastLoginTime, Status = customerAccount.Status, - UserToken = customerAccount.UserToken + UserToken = customerAccount.UserToken, + RequiresTwoFactor = false }, }; } } + /// + /// 获取客户账号 2FA 状态 + /// + /// 客户编号(JWT SerialNumber) + /// + public SingleOutputDto GetTwoFactorStatus(string customerSerialNumber) + { + return twoFactorAuthService.GetStatus(TwoFactorUserType.Customer, customerSerialNumber); + } + + /// + /// 生成客户账号 2FA 绑定信息 + /// + /// 客户编号(JWT SerialNumber) + /// + public SingleOutputDto GenerateTwoFactorSetup(string customerSerialNumber) + { + return twoFactorAuthService.GenerateSetup(TwoFactorUserType.Customer, customerSerialNumber); + } + + /// + /// 启用客户账号 2FA + /// + /// 客户编号(JWT SerialNumber) + /// 验证码输入 + /// + public SingleOutputDto EnableTwoFactor(string customerSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Enable(TwoFactorUserType.Customer, customerSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 关闭客户账号 2FA + /// + /// 客户编号(JWT SerialNumber) + /// 验证码输入 + /// + public BaseResponse DisableTwoFactor(string customerSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Disable(TwoFactorUserType.Customer, customerSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 重置客户账号恢复备用码 + /// + /// 客户编号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + public SingleOutputDto RegenerateTwoFactorRecoveryCodes(string customerSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.RegenerateRecoveryCodes(TwoFactorUserType.Customer, customerSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + private readonly Regex AccountRegex = new Regex(@"^[a-zA-Z0-9_]+$", RegexOptions.Compiled); private readonly Regex PasswordRegex = new Regex(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d]{8,}$", RegexOptions.Compiled); diff --git a/EOM.TSHotelManagement.Service/Business/Customer/Account/ICustomerAccountService.cs b/EOM.TSHotelManagement.Service/Business/Customer/Account/ICustomerAccountService.cs index 107d876f482757d925b6b51bda1cf97e77c8ba94..eee0de87b7f5add4e38207adce3dc92a884fec55 100644 --- a/EOM.TSHotelManagement.Service/Business/Customer/Account/ICustomerAccountService.cs +++ b/EOM.TSHotelManagement.Service/Business/Customer/Account/ICustomerAccountService.cs @@ -17,5 +17,43 @@ namespace EOM.TSHotelManagement.Service /// /// SingleOutputDto Register(ReadCustomerAccountInputDto readCustomerAccountInputDto); + + /// + /// 获取客户账号的 2FA 状态 + /// + /// 客户编号(JWT SerialNumber) + /// + SingleOutputDto GetTwoFactorStatus(string customerSerialNumber); + + /// + /// 生成客户账号的 2FA 绑定信息 + /// + /// 客户编号(JWT SerialNumber) + /// + SingleOutputDto GenerateTwoFactorSetup(string customerSerialNumber); + + /// + /// 启用客户账号 2FA + /// + /// 客户编号(JWT SerialNumber) + /// 验证码输入 + /// + SingleOutputDto EnableTwoFactor(string customerSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 关闭客户账号 2FA + /// + /// 客户编号(JWT SerialNumber) + /// 验证码输入 + /// + BaseResponse DisableTwoFactor(string customerSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 重置客户账号恢复备用码 + /// + /// 客户编号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + SingleOutputDto RegenerateTwoFactorRecoveryCodes(string customerSerialNumber, TwoFactorCodeInputDto inputDto); } } diff --git a/EOM.TSHotelManagement.Service/Business/Customer/CustomerService.cs b/EOM.TSHotelManagement.Service/Business/Customer/CustomerService.cs index 7a7d57c6d342d450af16520d8a19f4661744fe0a..ef10f0c3b96c7d885fe4ac1cd3db9821dc8c859e 100644 --- a/EOM.TSHotelManagement.Service/Business/Customer/CustomerService.cs +++ b/EOM.TSHotelManagement.Service/Business/Customer/CustomerService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -187,7 +187,7 @@ namespace EOM.TSHotelManagement.Service var result = custoRepository.Update(customer); if (!result) { - return new BaseResponse() { Message = LocalizationHelper.GetLocalizedString("Update Customer Failed", "客户信息更新失败"), Code = BusinessStatusCode.InternalServerError }; + return BaseResponseFactory.ConcurrencyConflict(); } } catch (Exception ex) @@ -216,7 +216,8 @@ namespace EOM.TSHotelManagement.Service }; } - var customers = custoRepository.GetList(a => custo.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(custo); + var customers = custoRepository.GetList(a => delIds.Contains(a.Id)); if (!customers.Any()) { @@ -227,6 +228,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(custo, customers, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + var occupied = Convert.ToInt32(RoomState.Occupied); foreach (var customer in customers) { @@ -249,7 +255,7 @@ namespace EOM.TSHotelManagement.Service } catch (Exception) { - logger.LogError("Error deleting customer information for customer IDs {CustomerIds}", string.Join(", ", custo.DelIds)); + logger.LogError("Error deleting customer information for customer IDs {CustomerIds}", string.Join(", ", custo.DelIds.Select(x => x.Id))); return new BaseResponse(BusinessStatusCode.InternalServerError, LocalizationHelper.GetLocalizedString("Delete Customer Failed", "客户信息删除失败")); } } @@ -263,11 +269,14 @@ namespace EOM.TSHotelManagement.Service { try { - if (!custoRepository.IsAny(a => a.CustomerNumber == updateCustomerInputDto.CustomerNumber)) + var customer = custoRepository.GetFirst(a => a.CustomerNumber == updateCustomerInputDto.CustomerNumber && a.IsDelete != 1); + if (customer == null) { return new BaseResponse() { Message = LocalizationHelper.GetLocalizedString("customer number does not exist.", "客户编号不存在"), Code = BusinessStatusCode.InternalServerError }; } - var result = custoRepository.Update(a => new Customer { CustomerType = updateCustomerInputDto.CustomerType }, a => a.CustomerNumber == updateCustomerInputDto.CustomerNumber); + customer.CustomerType = updateCustomerInputDto.CustomerType; + customer.RowVersion = updateCustomerInputDto.RowVersion ?? 0; + var result = custoRepository.Update(customer); if (result) { @@ -275,7 +284,7 @@ namespace EOM.TSHotelManagement.Service } else { - return new BaseResponse(BusinessStatusCode.InternalServerError, LocalizationHelper.GetLocalizedString("Update Customer Type Failed", "客户类型更新失败")); + return BaseResponseFactory.ConcurrencyConflict(); } } catch (Exception ex) @@ -360,10 +369,11 @@ namespace EOM.TSHotelManagement.Service IdCardNumber = dataProtector.SafeDecryptCustomerData(source.IdCardNumber), CustomerAddress = source.CustomerAddress ?? "", DataInsUsr = source.DataInsUsr, - DataInsDate = source.DataInsDate, - DataChgUsr = source.DataChgUsr, - DataChgDate = source.DataChgDate, - IsDelete = source.IsDelete + DataInsDate = source.DataInsDate, + DataChgUsr = source.DataChgUsr, + DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, + IsDelete = source.IsDelete }; }); customerOutputDtos = dtoArray.ToList(); @@ -392,6 +402,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }); }); diff --git a/EOM.TSHotelManagement.Service/Business/EnergyManagement/EnergyManagementService.cs b/EOM.TSHotelManagement.Service/Business/EnergyManagement/EnergyManagementService.cs index a3a7c57a4a227b04a0222501f51dd7bda31d9535..09b7d43600ff470f018fc574968e52da1ceac2b2 100644 --- a/EOM.TSHotelManagement.Service/Business/EnergyManagement/EnergyManagementService.cs +++ b/EOM.TSHotelManagement.Service/Business/EnergyManagement/EnergyManagementService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -127,7 +127,7 @@ namespace EOM.TSHotelManagement.Service } else { - return new BaseResponse(BusinessStatusCode.InternalServerError, LocalizationHelper.GetLocalizedString("Update Energy Management Failed", "水电费信息更新失败")); + return BaseResponseFactory.ConcurrencyConflict(); } } catch (Exception ex) @@ -154,7 +154,8 @@ namespace EOM.TSHotelManagement.Service }; } - var energyManagements = wtiRepository.GetList(a => hydroelectricity.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(hydroelectricity); + var energyManagements = wtiRepository.GetList(a => delIds.Contains(a.Id)); if (!energyManagements.Any()) { @@ -165,6 +166,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(hydroelectricity, energyManagements, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + var result = wtiRepository.SoftDeleteRange(energyManagements); if (result) diff --git a/EOM.TSHotelManagement.Service/Business/News/NewsService.cs b/EOM.TSHotelManagement.Service/Business/News/NewsService.cs index 4968d003e1d9d84a33344ecf4d394e4349d484bf..fa5322c0e54275b9840ace79edfcbdef4304dd1e 100644 --- a/EOM.TSHotelManagement.Service/Business/News/NewsService.cs +++ b/EOM.TSHotelManagement.Service/Business/News/NewsService.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Common; using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Data; using EOM.TSHotelManagement.Domain; @@ -196,16 +196,13 @@ namespace EOM.TSHotelManagement.Service news.NewsLink = updateNewsInputDto.NewsLink; news.NewsDate = updateNewsInputDto.NewsDate; news.NewsImage = updateNewsInputDto.NewsImage; + news.RowVersion = updateNewsInputDto.RowVersion ?? 0; try { var result = _newsRepository.Update(news); if (!result) { - return new BaseResponse - { - Code = BusinessStatusCode.InternalServerError, - Message = "新闻更新失败" - }; + return BaseResponseFactory.ConcurrencyConflict(); } } catch (Exception ex) @@ -240,7 +237,8 @@ namespace EOM.TSHotelManagement.Service }; } - var news = _newsRepository.GetList(a => deleteNewsInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteNewsInputDto); + var news = _newsRepository.GetList(a => delIds.Contains(a.Id)); if (!news.Any()) { @@ -250,6 +248,11 @@ namespace EOM.TSHotelManagement.Service Message = LocalizationHelper.GetLocalizedString("News Information Not Found", "新闻信息未找到") }; } + + if (DeleteConcurrencyHelper.HasDeleteConflict(deleteNewsInputDto, news, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } try { diff --git a/EOM.TSHotelManagement.Service/Business/PromotionContent/PromotionContentService.cs b/EOM.TSHotelManagement.Service/Business/PromotionContent/PromotionContentService.cs index a59475027d763796f69fe7214d3a0cd675a8a6c2..025f1ba2883d13e1b312c16f2ceb9af40c2ae69f 100644 --- a/EOM.TSHotelManagement.Service/Business/PromotionContent/PromotionContentService.cs +++ b/EOM.TSHotelManagement.Service/Business/PromotionContent/PromotionContentService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -93,6 +93,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }; }); @@ -112,6 +113,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }); }); @@ -184,7 +186,8 @@ namespace EOM.TSHotelManagement.Service }; } - var promotionContents = fontsRepository.GetList(a => deletePromotionContentInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deletePromotionContentInputDto); + var promotionContents = fontsRepository.GetList(a => delIds.Contains(a.Id)); if (!promotionContents.Any()) { @@ -195,6 +198,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deletePromotionContentInputDto, promotionContents, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + var result = fontsRepository.SoftDeleteRange(promotionContents); if (result) @@ -223,7 +231,11 @@ namespace EOM.TSHotelManagement.Service try { var entity = EntityMapper.Map(updatePromotionContentInputDto); - fontsRepository.Update(entity); + var result = fontsRepository.Update(entity); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { diff --git a/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs b/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs index 8ac20da4457c05d917edad55145e6f1fb1817495..888c7e0eb59d27c639973223fcece540b03c14a2 100644 --- a/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs +++ b/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -123,6 +123,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }; }); @@ -148,6 +149,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }); }); @@ -211,7 +213,8 @@ namespace EOM.TSHotelManagement.Service }; } - var resers = reserRepository.GetList(a => reser.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(reser); + var resers = reserRepository.GetList(a => delIds.Contains(a.Id)); if (!resers.Any()) { @@ -222,6 +225,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(reser, resers, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + try { using (TransactionScope scope = new TransactionScope()) @@ -238,7 +246,13 @@ namespace EOM.TSHotelManagement.Service a.RoomStateId = (int)RoomState.Vacant; return a; }).ToList(); - roomRepository.UpdateRange(rooms); + + var roomUpdateResult = roomRepository.UpdateRange(rooms); + if (!roomUpdateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + scope.Complete(); return new BaseResponse(BusinessStatusCode.Success, LocalizationHelper.GetLocalizedString("Delete Reser Success", "预约信息删除成功")); } @@ -321,16 +335,28 @@ namespace EOM.TSHotelManagement.Service // 恢复预约并更新房间状态 var entity = EntityMapper.Map(reser); - reserRepository.Update(entity); + var reserUpdateResult = reserRepository.Update(entity); + if (!reserUpdateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } room.RoomStateId = (int)RoomState.Reserved; - roomRepository.Update(room); + var roomUpdateResult = roomRepository.Update(room); + if (!roomUpdateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } else { // 普通更新逻辑 var entity = EntityMapper.Map(reser); - reserRepository.Update(entity); + var reserUpdateResult = reserRepository.Update(entity); + if (!reserUpdateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } scope.Complete(); diff --git a/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs b/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs index b3a2e98f2b9583b0141e88d84eefea7376efa64a..c32440a4b44173882417295604260679207949e1 100644 --- a/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs +++ b/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -227,6 +227,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }; }); @@ -256,6 +257,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }); }); @@ -339,7 +341,12 @@ namespace EOM.TSHotelManagement.Service room.LastCheckInTime = r.LastCheckInTime; room.DataChgDate = r.DataChgDate; room.DataChgUsr = r.DataChgUsr; - roomRepository.Update(room); + room.RowVersion = r.RowVersion ?? 0; + var updateResult = roomRepository.Update(room); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -362,7 +369,12 @@ namespace EOM.TSHotelManagement.Service room.RoomStateId = r.RoomStateId; room.DataChgDate = r.DataChgDate; room.DataChgUsr = r.DataChgUsr; - roomRepository.Update(room); + room.RowVersion = r.RowVersion ?? 0; + var updateResult = roomRepository.Update(room); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -512,7 +524,12 @@ namespace EOM.TSHotelManagement.Service { var room = roomRepository.GetFirst(a => a.RoomNumber == updateRoomInputDto.RoomNumber); room.RoomStateId = updateRoomInputDto.RoomStateId; - roomRepository.Update(room); + room.RowVersion = updateRoomInputDto.RowVersion ?? 0; + var updateResult = roomRepository.Update(room); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -562,7 +579,11 @@ namespace EOM.TSHotelManagement.Service return new BaseResponse { Message = LocalizationHelper.GetLocalizedString("This room does not exist.", "房间不存在。"), Code = BusinessStatusCode.InternalServerError }; var entity = EntityMapper.Map(rn); - roomRepository.Update(entity); + var updateResult = roomRepository.Update(entity); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -591,7 +612,8 @@ namespace EOM.TSHotelManagement.Service }; } - var rooms = roomRepository.GetList(a => rn.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(rn); + var rooms = roomRepository.GetList(a => delIds.Contains(a.Id)); if (!rooms.Any()) { @@ -602,6 +624,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(rn, rooms, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 如果房间存在预约信息,则不允许删除 var roomNumbers = rooms.Select(a => a.RoomNumber).ToList(); var hasReservation = reserRepository.IsAny(a => roomNumbers.Contains(a.ReservationRoomNumber) && a.IsDelete != 1 && a.ReservationEndDate >= DateOnly.FromDateTime(DateTime.Today)); @@ -691,14 +718,22 @@ namespace EOM.TSHotelManagement.Service targetRoom.CustomerNumber = originalRoom.CustomerNumber; targetRoom.RoomStateId = (int)RoomState.Occupied; targetRoom.LastCheckInTime = DateOnly.FromDateTime(DateTime.Now); - roomRepository.Update(targetRoom); + var targetRoomUpdateResult = roomRepository.Update(targetRoom); + if (!targetRoomUpdateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } //更新原房间状态 originalRoom.CustomerNumber = string.Empty; originalRoom.RoomStateId = (int)RoomState.Dirty; originalRoom.LastCheckInTime = DateOnly.MinValue; originalRoom.LastCheckOutTime = DateOnly.MinValue; - roomRepository.Update(originalRoom); + var originalRoomUpdateResult = roomRepository.Update(originalRoom); + if (!originalRoomUpdateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } //转移原房间消费记录 if (originalSpendNumbers.Count > 0) @@ -710,9 +745,14 @@ namespace EOM.TSHotelManagement.Service { spend.SpendNumber = spend.SpendNumber; spend.RoomNumber = transferRoomDto.TargetRoomNumber; + spends.Add(spend); } - spendRepository.UpdateRange(spends); + var spendTransferResult = spendRepository.UpdateRange(spends); + if (!spendTransferResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } //添加旧房间消费记录 @@ -765,7 +805,11 @@ namespace EOM.TSHotelManagement.Service room.CustomerNumber = string.Empty; room.LastCheckOutTime = DateOnly.FromDateTime(DateTime.Now); room.RoomStateId = (int)RoomState.Dirty; - roomRepository.Update(room); + var roomUpdateResult = roomRepository.Update(room); + if (!roomUpdateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } //添加能源使用记录 var energy = new EnergyManagement @@ -795,7 +839,11 @@ namespace EOM.TSHotelManagement.Service spends.Add(spend); } - spendRepository.UpdateRange(spends); + var settleSpendResult = spendRepository.UpdateRange(spends); + if (!settleSpendResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } scope.Complete(); @@ -849,7 +897,7 @@ namespace EOM.TSHotelManagement.Service if (!roomUpdateResult) { - return new BaseResponse { Message = LocalizationHelper.GetLocalizedString("Failed to update room.", "更新房间失败。"), Code = BusinessStatusCode.InternalServerError }; + return BaseResponseFactory.ConcurrencyConflict(); } var reser = reserRepository.GetFirst(a => a.ReservationId == checkinRoomByReservationDto.ReservationId && a.IsDelete != 1); @@ -858,7 +906,7 @@ namespace EOM.TSHotelManagement.Service if (!reserUpdateResult) { - return new BaseResponse { Message = LocalizationHelper.GetLocalizedString("Failed to update reservation.", "更新预约失败。"), Code = BusinessStatusCode.InternalServerError }; + return BaseResponseFactory.ConcurrencyConflict(); } scope.Complete(); diff --git a/EOM.TSHotelManagement.Service/Business/Room/RoomTypeService.cs b/EOM.TSHotelManagement.Service/Business/Room/RoomTypeService.cs index bd80d780e1839ca9c52ff5df1fa60a772ad92768..ebfadc02d561d37aa2b48284f76c78baa580dc97 100644 --- a/EOM.TSHotelManagement.Service/Business/Room/RoomTypeService.cs +++ b/EOM.TSHotelManagement.Service/Business/Room/RoomTypeService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -103,6 +103,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }; }); @@ -124,6 +125,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }); }); @@ -190,7 +192,7 @@ namespace EOM.TSHotelManagement.Service { try { - roomTypeRepository.Update(new RoomType + var result = roomTypeRepository.Update(new RoomType { RoomTypeId = roomType.RoomTypeId, Id = roomType.Id ?? 0, @@ -199,8 +201,13 @@ namespace EOM.TSHotelManagement.Service RoomDeposit = roomType.RoomDeposit, IsDelete = roomType.IsDelete, DataChgUsr = roomType.DataChgUsr, - DataChgDate = roomType.DataChgDate + DataChgDate = roomType.DataChgDate, + RowVersion = roomType.RowVersion ?? 0 }); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -228,7 +235,8 @@ namespace EOM.TSHotelManagement.Service }; } - var roomTypes = roomTypeRepository.GetList(a => roomType.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(roomType); + var roomTypes = roomTypeRepository.GetList(a => delIds.Contains(a.Id)); if (!roomTypes.Any()) { @@ -239,6 +247,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(roomType, roomTypes, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 检查是否有房间关联到这些房间类型 var roomTypeIds = roomTypes.Select(rt => rt.RoomTypeId).ToList(); var associatedRooms = roomRepository.IsAny(r => roomTypeIds.Contains(r.RoomTypeId) && r.IsDelete != 1); diff --git a/EOM.TSHotelManagement.Service/Business/Sellthing/ISellService.cs b/EOM.TSHotelManagement.Service/Business/Sellthing/ISellService.cs index 7922687acaef645771d1bff49f0043470afe2193..5fdc58085d79d09f784f7eb16f8184a76ee956fa 100644 --- a/EOM.TSHotelManagement.Service/Business/Sellthing/ISellService.cs +++ b/EOM.TSHotelManagement.Service/Business/Sellthing/ISellService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -44,7 +44,7 @@ namespace EOM.TSHotelManagement.Service BaseResponse UpdateSellthing(UpdateSellThingInputDto sellThing); /// - /// 撤回客户消费信息 + /// 删除商品信息 /// /// /// @@ -64,4 +64,4 @@ namespace EOM.TSHotelManagement.Service /// BaseResponse InsertSellThing(CreateSellThingInputDto st); } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Service/Business/Sellthing/SellService.cs b/EOM.TSHotelManagement.Service/Business/Sellthing/SellService.cs index 69a7b072eb7a02eef947768955762fb939f266a8..2ec8f4d75bb4f2eac0201b5dd0eaa3bef38587ae 100644 --- a/EOM.TSHotelManagement.Service/Business/Sellthing/SellService.cs +++ b/EOM.TSHotelManagement.Service/Business/Sellthing/SellService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -119,6 +119,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }; }); @@ -141,6 +142,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }); }); @@ -166,12 +168,21 @@ namespace EOM.TSHotelManagement.Service try { var product = sellThingRepository.GetFirst(a => a.Id == sellThing.Id); + if (product == null) + { + return new BaseResponse(BusinessStatusCode.NotFound, LocalizationHelper.GetLocalizedString("Goods Information Not Found", "商品信息未找到")); + } product.ProductName = sellThing.ProductName; product.ProductPrice = sellThing.ProductPrice; product.Stock = sellThing.Stock; product.Specification = sellThing.Specification; product.IsDelete = sellThing.IsDelete; - sellThingRepository.Update(product); + product.RowVersion = sellThing.RowVersion ?? 0; + var result = sellThingRepository.Update(product); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -199,7 +210,8 @@ namespace EOM.TSHotelManagement.Service }; } - var sellThings = sellThingRepository.GetList(a => deleteSellThingInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteSellThingInputDto); + var sellThings = sellThingRepository.GetList(a => delIds.Contains(a.Id)); if (!sellThings.Any()) { @@ -210,6 +222,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deleteSellThingInputDto, sellThings, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + var result = sellThingRepository.SoftDeleteRange(sellThings); } diff --git a/EOM.TSHotelManagement.Service/Business/Spend/SpendService.cs b/EOM.TSHotelManagement.Service/Business/Spend/SpendService.cs index 2c107495565ac5526c76e9c15bf4a87ddeefee8c..1fb1e461ea618bd5f4d50b8197efcc113570d41d 100644 --- a/EOM.TSHotelManagement.Service/Business/Spend/SpendService.cs +++ b/EOM.TSHotelManagement.Service/Business/Spend/SpendService.cs @@ -283,8 +283,17 @@ namespace EOM.TSHotelManagement.Service try { var existingSpend = spendRepository.GetFirst(a => a.SpendNumber == updateSpendInputDto.SpendNumber && a.IsDelete != 1); + if (existingSpend == null) + { + return new BaseResponse(BusinessStatusCode.NotFound, LocalizationHelper.GetLocalizedString("Spend record not found", "消费记录不存在")); + } existingSpend.IsDelete = 1; - spendRepository.Update(existingSpend); + existingSpend.RowVersion = updateSpendInputDto.RowVersion ?? 0; + var updateResult = spendRepository.Update(existingSpend); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -342,7 +351,7 @@ namespace EOM.TSHotelManagement.Service var result = spendRepository.Update(existingSpend); if (!result) { - return new BaseResponse() { Message = "更新消费记录失败", Code = BusinessStatusCode.InternalServerError }; + return BaseResponseFactory.ConcurrencyConflict(); } } else @@ -376,7 +385,7 @@ namespace EOM.TSHotelManagement.Service var updateResult = sellThingRepository.Update(product); if (!updateResult) { - return new BaseResponse() { Message = "商品库存更新失败", Code = BusinessStatusCode.InternalServerError }; + return BaseResponseFactory.ConcurrencyConflict(); } var logContent = $"{addCustomerSpendInputDto.WorkerNo} 添加了消费记录: " + @@ -423,13 +432,22 @@ namespace EOM.TSHotelManagement.Service try { var dbSpend = spendRepository.GetFirst(a => a.SpendNumber == spend.SpendNumber && a.IsDelete != 1); + if (dbSpend == null) + { + return new BaseResponse(BusinessStatusCode.NotFound, LocalizationHelper.GetLocalizedString("Spend record not found", "消费记录不存在")); + } dbSpend.SettlementStatus = spend.SettlementStatus; dbSpend.RoomNumber = spend.RoomNumber; dbSpend.CustomerNumber = spend.CustomerNumber; dbSpend.ProductName = spend.ProductName; dbSpend.ConsumptionQuantity = spend.ConsumptionQuantity; dbSpend.ConsumptionAmount = spend.ConsumptionAmount; - spendRepository.Update(dbSpend); + dbSpend.RowVersion = spend.RowVersion ?? 0; + var updateResult = spendRepository.Update(dbSpend); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { diff --git a/EOM.TSHotelManagement.Service/Common/DeleteConcurrencyHelper.cs b/EOM.TSHotelManagement.Service/Common/DeleteConcurrencyHelper.cs new file mode 100644 index 0000000000000000000000000000000000000000..bbd9b6fe3f6c46f3650751f06bae08887324dd54 --- /dev/null +++ b/EOM.TSHotelManagement.Service/Common/DeleteConcurrencyHelper.cs @@ -0,0 +1,164 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Claims; +using System.Threading; +using EOM.TSHotelManagement.Contract; +using Microsoft.AspNetCore.Http; + +namespace EOM.TSHotelManagement.Service +{ + public class DeleteConcurrencyHelper + { + private static IHttpContextAccessor? _httpContextAccessor; + + public DeleteConcurrencyHelper(IHttpContextAccessor httpContextAccessor) + { + _httpContextAccessor = httpContextAccessor; + } + + public static List GetDeleteIds(DeleteDto deleteDto) + { + return deleteDto?.DelIds? + .Select(x => x.Id) + .Distinct() + .ToList() ?? new List(); + } + + public static bool HasDeleteConflict( + DeleteDto deleteDto, + IEnumerable entities, + Func idSelector, + Func rowVersionSelector, + Func? isAuthorizedId = null) + { + if (deleteDto?.DelIds == null || deleteDto.DelIds.Count == 0) + { + return false; + } + + var expectedVersionGroups = deleteDto.DelIds + .GroupBy(x => x.Id) + .ToList(); + + if (expectedVersionGroups.Any(g => g.Select(x => (long)x.RowVersion).Distinct().Count() > 1)) + { + return true; + } + + var expectedVersions = expectedVersionGroups + .ToDictionary(g => g.Key, g => (long)g.First().RowVersion); + + var entityList = (entities ?? Enumerable.Empty()).ToList(); + isAuthorizedId ??= BuildDefaultAuthorizationPredicate(entityList, idSelector); + + if (isAuthorizedId != null && expectedVersions.Keys.Any(id => !isAuthorizedId(id))) + { + return true; + } + + var actualVersions = entityList + .GroupBy(idSelector) + .ToDictionary(g => g.Key, g => rowVersionSelector(g.First())); + + if (expectedVersions.Count != actualVersions.Count) + { + return true; + } + + foreach (var item in expectedVersions) + { + if (!actualVersions.TryGetValue(item.Key, out var actualVersion)) + { + return true; + } + + if (actualVersion != item.Value) + { + return true; + } + } + + return false; + } + + private static Func? BuildDefaultAuthorizationPredicate( + IEnumerable entities, + Func idSelector) + { + var (currentUserNumber, isSuperAdmin) = GetCurrentUserContext(); + if (isSuperAdmin) + { + return _ => true; + } + + if (string.IsNullOrWhiteSpace(currentUserNumber)) + { + return null; + } + + var ownerProperty = typeof(TEntity).GetProperty("DataInsUsr"); + if (ownerProperty == null || ownerProperty.PropertyType != typeof(string)) + { + return null; + } + + var ownerById = entities + .GroupBy(idSelector) + .ToDictionary( + g => g.Key, + g => ownerProperty.GetValue(g.First())?.ToString()); + + return id => + { + if (!ownerById.TryGetValue(id, out var owner)) + { + return false; + } + + return string.IsNullOrWhiteSpace(owner) + || string.Equals(owner, currentUserNumber, StringComparison.OrdinalIgnoreCase); + }; + } + + private static (string? UserNumber, bool IsSuperAdmin) GetCurrentUserContext() + { + ClaimsPrincipal? user = null; + + try + { + user = _httpContextAccessor?.HttpContext?.User; + } + catch + { + // ignored + } + + user ??= Thread.CurrentPrincipal as ClaimsPrincipal; + if (user == null) + { + return (null, false); + } + + var userNumber = user.FindFirst(ClaimTypes.SerialNumber)?.Value + ?? user.FindFirst("serialnumber")?.Value + ?? user.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + var isSuperAdminClaim = user.FindFirst("is_super_admin")?.Value + ?? user.FindFirst("isSuperAdmin")?.Value + ?? user.FindFirst("issuperadmin")?.Value; + + return (userNumber, ParseBooleanLikeValue(isSuperAdminClaim)); + } + + private static bool ParseBooleanLikeValue(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return value == "1" || value.Equals("true", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs index 987d7ea1c8067cf8f0740d33d38bba5a5357f3f9..205f18924d32a39375da08fdc0a64a19a2d9800e 100644 --- a/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs +++ b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -28,6 +28,7 @@ using EOM.TSHotelManagement.Domain; using jvncorelib.EntityLib; using Microsoft.Extensions.Logging; using SqlSugar; +using System.Net.Mail; using System.Security.Claims; namespace EOM.TSHotelManagement.Service @@ -48,7 +49,7 @@ namespace EOM.TSHotelManagement.Service /// /// /// - public class EmployeeService(GenericRepository workerRepository, GenericRepository photoRepository, GenericRepository educationRepository, GenericRepository nationRepository, GenericRepository deptRepository, GenericRepository positionRepository, GenericRepository passportTypeRepository, JWTHelper jWTHelper, MailHelper mailHelper, DataProtectionHelper dataProtectionHelper, ILogger logger) : IEmployeeService + public class EmployeeService(GenericRepository workerRepository, GenericRepository photoRepository, GenericRepository educationRepository, GenericRepository nationRepository, GenericRepository deptRepository, GenericRepository positionRepository, GenericRepository passportTypeRepository, JWTHelper jWTHelper, MailHelper mailHelper, DataProtectionHelper dataProtectionHelper, ITwoFactorAuthService twoFactorAuthService, ILogger logger) : IEmployeeService { /// /// 员工信息 @@ -100,6 +101,11 @@ namespace EOM.TSHotelManagement.Service /// private readonly MailHelper mailHelper = mailHelper; + /// + /// 2FA服务 + /// + private readonly ITwoFactorAuthService twoFactorAuthService = twoFactorAuthService; + private readonly ILogger logger = logger; /// @@ -276,6 +282,7 @@ namespace EOM.TSHotelManagement.Service var source = employees[i]; dtoArray[i] = new ReadEmployeeOutputDto { + Id = source.Id, EmployeeId = source.EmployeeId, EmployeeName = source.EmployeeName, Gender = source.Gender, @@ -306,6 +313,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }; }); @@ -318,6 +326,7 @@ namespace EOM.TSHotelManagement.Service { data.Add(new ReadEmployeeOutputDto { + Id = source.Id, EmployeeId = source.EmployeeId, EmployeeName = source.EmployeeName, Gender = source.Gender, @@ -348,6 +357,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }); }); @@ -439,14 +449,50 @@ namespace EOM.TSHotelManagement.Service if (w == null) { w = null; - return new SingleOutputDto { Data = null, Message = LocalizationHelper.GetLocalizedString("Employee does not exist or entered incorrectly", "员工不存在或输入有误") }; + return new SingleOutputDto { Code = BusinessStatusCode.BadRequest, Data = null, Message = LocalizationHelper.GetLocalizedString("Employee does not exist or entered incorrectly", "员工不存在或输入有误") }; } var correctPassword = dataProtector.CompareEmployeeData(w.Password, employeeLoginDto.Password); if (!correctPassword) { - return new SingleOutputDto { Data = null, Message = LocalizationHelper.GetLocalizedString("Invalid account or password", "账号或密码错误") }; + return new SingleOutputDto { Code = BusinessStatusCode.BadRequest, Data = null, Message = LocalizationHelper.GetLocalizedString("Invalid account or password", "账号或密码错误") }; + } + + var usedRecoveryCode = false; + if (twoFactorAuthService.RequiresTwoFactor(TwoFactorUserType.Employee, w.Id)) + { + if (string.IsNullOrWhiteSpace(employeeLoginDto.TwoFactorCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("2FA code is required", "需要输入2FA验证码"), + Data = new ReadEmployeeOutputDto + { + EmployeeId = w.EmployeeId, + EmployeeName = w.EmployeeName, + RequiresTwoFactor = true + } + }; + } + + var passed = twoFactorAuthService.VerifyLoginCode(TwoFactorUserType.Employee, w.Id, employeeLoginDto.TwoFactorCode, out usedRecoveryCode); + if (!passed) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = new ReadEmployeeOutputDto + { + EmployeeId = w.EmployeeId, + EmployeeName = w.EmployeeName, + RequiresTwoFactor = true + } + }; + } } + w.Password = ""; //性别类型 var sexType = genders.SingleOrDefault(a => a.Id == w.Gender); @@ -469,7 +515,65 @@ namespace EOM.TSHotelManagement.Service new Claim(ClaimTypes.Name, w.EmployeeName), new Claim(ClaimTypes.SerialNumber, w.EmployeeId) })); - return new SingleOutputDto { Data = EntityMapper.Map(w) }; + var output = EntityMapper.Map(w); + output.RequiresTwoFactor = false; + output.UsedRecoveryCodeLogin = usedRecoveryCode; + if (usedRecoveryCode) + { + NotifyRecoveryCodeLoginByEmail(w.EmailAddress, w.EmployeeName, w.EmployeeId); + } + return new SingleOutputDto { Data = output }; + } + + /// + /// 备用码登录后邮件通知(员工) + /// + /// 邮箱 + /// 员工姓名 + /// 员工工号 + private void NotifyRecoveryCodeLoginByEmail(string? emailAddress, string? employeeName, string? employeeId) + { + if (string.IsNullOrWhiteSpace(emailAddress) || !MailAddress.TryCreate(emailAddress, out _)) + { + logger.LogWarning("Recovery-code login alert skipped for employee {EmployeeId}: invalid email.", employeeId ?? string.Empty); + return; + } + + var recipient = emailAddress; + var name = employeeName ?? "Employee"; + var identity = employeeId ?? string.Empty; + _ = Task.Run(async () => + { + await SendEmployeeRecoveryCodeAlertWithRetryAsync(recipient, name, identity); + }); + } + + private async Task SendEmployeeRecoveryCodeAlertWithRetryAsync(string recipient, string employeeName, string employeeId) + { + const int maxAttempts = 3; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate(employeeName, employeeId, DateTime.Now); + var sent = mailHelper.SendMail(new List { recipient }, template.Subject, template.Body); + if (sent) + { + return; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Recovery-code alert send failed for employee {EmployeeId}, attempt {Attempt}.", employeeId, attempt); + } + + if (attempt < maxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(attempt)); + } + } + + logger.LogWarning("Recovery-code alert send exhausted retries for employee {EmployeeId}.", employeeId); } /// @@ -582,5 +686,58 @@ namespace EOM.TSHotelManagement.Service return new BaseResponse(); } + + /// + /// 获取员工账号 2FA 状态 + /// + /// 员工工号(JWT SerialNumber) + /// + public SingleOutputDto GetTwoFactorStatus(string employeeSerialNumber) + { + return twoFactorAuthService.GetStatus(TwoFactorUserType.Employee, employeeSerialNumber); + } + + /// + /// 生成员工账号 2FA 绑定信息 + /// + /// 员工工号(JWT SerialNumber) + /// + public SingleOutputDto GenerateTwoFactorSetup(string employeeSerialNumber) + { + return twoFactorAuthService.GenerateSetup(TwoFactorUserType.Employee, employeeSerialNumber); + } + + /// + /// 启用员工账号 2FA + /// + /// 员工工号(JWT SerialNumber) + /// 验证码输入 + /// + public SingleOutputDto EnableTwoFactor(string employeeSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Enable(TwoFactorUserType.Employee, employeeSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 关闭员工账号 2FA + /// + /// 员工工号(JWT SerialNumber) + /// 验证码输入 + /// + public BaseResponse DisableTwoFactor(string employeeSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Disable(TwoFactorUserType.Employee, employeeSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 重置员工账号恢复备用码 + /// + /// 员工工号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + public SingleOutputDto RegenerateTwoFactorRecoveryCodes(string employeeSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.RegenerateRecoveryCodes(TwoFactorUserType.Employee, employeeSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } } } diff --git a/EOM.TSHotelManagement.Service/Employee/IEmployeeService.cs b/EOM.TSHotelManagement.Service/Employee/IEmployeeService.cs index 0caeb10d10326c4604ba580d1c686b3affa47c12..1069f906997c2ba00e65e8091f3a21648331692e 100644 --- a/EOM.TSHotelManagement.Service/Employee/IEmployeeService.cs +++ b/EOM.TSHotelManagement.Service/Employee/IEmployeeService.cs @@ -84,5 +84,43 @@ namespace EOM.TSHotelManagement.Service /// /// BaseResponse ResetEmployeeAccountPassword(UpdateEmployeeInputDto updateEmployeeInputDto); + + /// + /// 获取员工账号的 2FA 状态 + /// + /// 员工工号(JWT SerialNumber) + /// + SingleOutputDto GetTwoFactorStatus(string employeeSerialNumber); + + /// + /// 生成员工账号的 2FA 绑定信息 + /// + /// 员工工号(JWT SerialNumber) + /// + SingleOutputDto GenerateTwoFactorSetup(string employeeSerialNumber); + + /// + /// 启用员工账号 2FA + /// + /// 员工工号(JWT SerialNumber) + /// 验证码输入 + /// + SingleOutputDto EnableTwoFactor(string employeeSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 关闭员工账号 2FA + /// + /// 员工工号(JWT SerialNumber) + /// 验证码输入 + /// + BaseResponse DisableTwoFactor(string employeeSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 重置员工账号恢复备用码 + /// + /// 员工工号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + SingleOutputDto RegenerateTwoFactorRecoveryCodes(string employeeSerialNumber, TwoFactorCodeInputDto inputDto); } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Service/Employee/Photo/EmployeePhotoService.cs b/EOM.TSHotelManagement.Service/Employee/Photo/EmployeePhotoService.cs index 6eb1df1058e131763b371f3bda4e0eb5ccbfefa0..72b5c2b8c3effe875d773ab0cb1964e761e62726 100644 --- a/EOM.TSHotelManagement.Service/Employee/Photo/EmployeePhotoService.cs +++ b/EOM.TSHotelManagement.Service/Employee/Photo/EmployeePhotoService.cs @@ -130,7 +130,15 @@ namespace EOM.TSHotelManagement.Service { workerPicData = workerPicRepository.GetFirst(a => a.EmployeeId.Equals(createEmployeePhotoInputDto.EmployeeId)); workerPicData.PhotoPath = imageUrl; - workerPicRepository.Update(workerPicData); + var updateResult = workerPicRepository.Update(workerPicData); + if (!updateResult) + { + return new SingleOutputDto + { + Message = LocalizationHelper.GetLocalizedString("Data has been modified by another user. Please refresh and retry.", "数据已被其他用户修改,请刷新后重试。"), + Code = BusinessStatusCode.Conflict + }; + } } return new SingleOutputDto @@ -178,8 +186,17 @@ namespace EOM.TSHotelManagement.Service try { var workerPicData = workerPicRepository.GetFirst(a => a.EmployeeId.Equals(updateEmployeePhotoInputDto.EmployeeId)); + if (workerPicData == null) + { + return new BaseResponse(BusinessStatusCode.NotFound, LocalizationHelper.GetLocalizedString("Photo information not found", "照片信息不存在")); + } workerPicData.PhotoPath = updateEmployeePhotoInputDto.PhotoUrl; - workerPicRepository.Update(workerPicData); + workerPicData.RowVersion = updateEmployeePhotoInputDto.RowVersion ?? 0; + var updateResult = workerPicRepository.Update(workerPicData); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { diff --git a/EOM.TSHotelManagement.Service/Security/ITwoFactorAuthService.cs b/EOM.TSHotelManagement.Service/Security/ITwoFactorAuthService.cs new file mode 100644 index 0000000000000000000000000000000000000000..1dd44956e92662f43795ca21b59922ef6f104097 --- /dev/null +++ b/EOM.TSHotelManagement.Service/Security/ITwoFactorAuthService.cs @@ -0,0 +1,72 @@ +using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Contract; + +namespace EOM.TSHotelManagement.Service +{ + /// + /// 统一 2FA 业务接口 + /// + public interface ITwoFactorAuthService + { + /// + /// 判断账号是否已启用 2FA + /// + /// 账号类型 + /// 账号主键ID + /// 是否需要 2FA 校验 + bool RequiresTwoFactor(TwoFactorUserType userType, int userPrimaryKey); + + /// + /// 校验登录场景的 2FA 验证码(支持 TOTP 或恢复备用码) + /// + /// 账号类型 + /// 账号主键ID + /// 验证码或恢复备用码 + /// 是否使用了恢复备用码 + /// 是否校验通过 + bool VerifyLoginCode(TwoFactorUserType userType, int userPrimaryKey, string? code, out bool usedRecoveryCode); + + /// + /// 获取当前账号的 2FA 状态 + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 2FA 状态 + SingleOutputDto GetStatus(TwoFactorUserType userType, string serialNumber); + + /// + /// 生成 2FA 绑定信息(otpauth URI) + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 绑定信息 + SingleOutputDto GenerateSetup(TwoFactorUserType userType, string serialNumber); + + /// + /// 启用 2FA + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码 + /// 操作结果与首批恢复备用码 + SingleOutputDto Enable(TwoFactorUserType userType, string serialNumber, string verificationCode); + + /// + /// 关闭 2FA + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码或恢复备用码 + /// 操作结果 + BaseResponse Disable(TwoFactorUserType userType, string serialNumber, string verificationCode); + + /// + /// 重置恢复备用码(会使旧备用码全部失效) + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码或恢复备用码 + /// 新恢复备用码 + SingleOutputDto RegenerateRecoveryCodes(TwoFactorUserType userType, string serialNumber, string verificationCode); + } +} diff --git a/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs b/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs new file mode 100644 index 0000000000000000000000000000000000000000..72b8645c979eea4bc4e798350f1164ef30eaa4e8 --- /dev/null +++ b/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs @@ -0,0 +1,716 @@ +using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Contract; +using EOM.TSHotelManagement.Data; +using EOM.TSHotelManagement.Domain; +using Microsoft.Extensions.Logging; + +namespace EOM.TSHotelManagement.Service +{ + /// + /// 2FA(TOTP)统一服务实现 + /// + public class TwoFactorAuthService : ITwoFactorAuthService + { + private readonly GenericRepository _twoFactorRepository; + private readonly GenericRepository _recoveryCodeRepository; + private readonly GenericRepository _employeeRepository; + private readonly GenericRepository _administratorRepository; + private readonly GenericRepository _customerAccountRepository; + private readonly DataProtectionHelper _dataProtectionHelper; + private readonly TwoFactorHelper _twoFactorHelper; + private readonly ILogger _logger; + + /// + /// 构造函数 + /// + public TwoFactorAuthService( + GenericRepository twoFactorRepository, + GenericRepository recoveryCodeRepository, + GenericRepository employeeRepository, + GenericRepository administratorRepository, + GenericRepository customerAccountRepository, + DataProtectionHelper dataProtectionHelper, + TwoFactorHelper twoFactorHelper, + ILogger logger) + { + _twoFactorRepository = twoFactorRepository; + _recoveryCodeRepository = recoveryCodeRepository; + _employeeRepository = employeeRepository; + _administratorRepository = administratorRepository; + _customerAccountRepository = customerAccountRepository; + _dataProtectionHelper = dataProtectionHelper; + _twoFactorHelper = twoFactorHelper; + _logger = logger; + } + + /// + /// 判断指定账号是否启用了 2FA + /// + /// 账号类型 + /// 账号主键ID + /// + public bool RequiresTwoFactor(TwoFactorUserType userType, int userPrimaryKey) + { + var auth = GetByUserPrimaryKey(userType, userPrimaryKey); + return auth != null + && auth.IsDelete != 1 + && auth.IsEnabled == 1 + && !string.IsNullOrWhiteSpace(auth.SecretKey); + } + + /// + /// 校验登录 2FA 验证码(支持 TOTP 或恢复备用码) + /// + /// 账号类型 + /// 账号主键ID + /// 验证码或恢复备用码 + /// 是否使用了恢复备用码 + /// + public bool VerifyLoginCode(TwoFactorUserType userType, int userPrimaryKey, string? code, out bool usedRecoveryCode) + { + usedRecoveryCode = false; + + if (string.IsNullOrWhiteSpace(code)) + return false; + + var auth = GetByUserPrimaryKey(userType, userPrimaryKey); + if (auth == null || auth.IsDelete == 1 || auth.IsEnabled != 1 || string.IsNullOrWhiteSpace(auth.SecretKey)) + return false; + + if (TryVerifyTotp(auth, code, out var validatedCounter)) + { + return TryMarkTotpValidated(auth, validatedCounter); + } + + if (!TryConsumeRecoveryCode(auth.Id, code)) + { + return false; + } + + usedRecoveryCode = true; + return TouchLastVerifiedAt(auth.Id); + } + + /// + /// 获取账号 2FA 状态 + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// + public SingleOutputDto GetStatus(TwoFactorUserType userType, string serialNumber) + { + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("User not found", "用户不存在"), + Data = null + }; + } + + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + return new SingleOutputDto + { + Code = BusinessStatusCode.Success, + Message = LocalizationHelper.GetLocalizedString("Query success", "查询成功"), + Data = new TwoFactorStatusOutputDto + { + IsEnabled = auth?.IsEnabled == 1, + EnabledAt = auth?.EnabledAt, + LastVerifiedAt = auth?.LastVerifiedAt, + RemainingRecoveryCodes = auth == null ? 0 : GetRemainingRecoveryCodeCount(auth.Id) + } + }; + } + + /// + /// 生成账号 2FA 绑定信息(密钥与 otpauth URI) + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// + public SingleOutputDto GenerateSetup(TwoFactorUserType userType, string serialNumber) + { + try + { + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("User not found", "用户不存在"), + Data = null + }; + } + + var secret = _twoFactorHelper.GenerateSecretKey(); + var encryptedSecret = _dataProtectionHelper.EncryptTwoFactorData(secret); + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + + if (auth == null) + { + auth = new TwoFactorAuth + { + IsEnabled = 0, + SecretKey = encryptedSecret, + EnabledAt = null, + LastVerifiedAt = null, + LastValidatedCounter = null + }; + AttachUserForeignKey(auth, userType, resolved.UserPrimaryKey); + _twoFactorRepository.Insert(auth); + auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + } + else + { + auth.SecretKey = encryptedSecret; + auth.IsEnabled = 0; + auth.EnabledAt = null; + auth.LastVerifiedAt = null; + auth.LastValidatedCounter = null; + _twoFactorRepository.Update(auth); + } + + if (auth != null) + { + ClearRecoveryCodes(auth.Id); + } + + return new SingleOutputDto + { + Code = BusinessStatusCode.Success, + Message = LocalizationHelper.GetLocalizedString("2FA setup created", "2FA绑定信息已生成"), + Data = new TwoFactorSetupOutputDto + { + IsEnabled = false, + AccountName = resolved.AccountName, + OtpAuthUri = _twoFactorHelper.BuildOtpAuthUri(resolved.AccountName, secret), + CodeDigits = _twoFactorHelper.GetCodeDigits(), + TimeStepSeconds = _twoFactorHelper.GetTimeStepSeconds() + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "GenerateSetup failed for {UserType}-{SerialNumber}", userType, serialNumber); + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("2FA setup failed", "2FA绑定信息生成失败"), + Data = null + }; + } + } + + /// + /// 启用账号 2FA + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码 + /// + public SingleOutputDto Enable(TwoFactorUserType userType, string serialNumber, string verificationCode) + { + try + { + if (string.IsNullOrWhiteSpace(verificationCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("Verification code is required", "验证码不能为空"), + Data = null + }; + } + + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("User not found", "用户不存在"), + Data = null + }; + } + + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + if (auth == null || string.IsNullOrWhiteSpace(auth.SecretKey)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("Please generate 2FA setup first", "请先生成2FA绑定信息"), + Data = null + }; + } + + if (!TryVerifyTotp(auth, verificationCode, out var validatedCounter)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = null + }; + } + + if (!TryMarkTotpValidated(auth, validatedCounter)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("2FA code has already been used", "该2FA验证码已被使用,请等待下一个验证码。"), + Data = null + }; + } + + auth.IsEnabled = 1; + auth.EnabledAt = DateTime.Now; + if (!_twoFactorRepository.Update(auth)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Enable 2FA failed", "鍚敤2FA澶辫触"), + Data = null + }; + } + + // 启用时自动生成一组恢复备用码(仅保存哈希,明文只在本次响应返回) + var codes = ReplaceRecoveryCodes(auth.Id); + + return new SingleOutputDto + { + Code = BusinessStatusCode.Success, + Message = LocalizationHelper.GetLocalizedString("2FA enabled", "2FA已启用"), + Data = new TwoFactorRecoveryCodesOutputDto + { + RecoveryCodes = codes, + RemainingCount = codes.Count + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Enable 2FA failed for {UserType}-{SerialNumber}", userType, serialNumber); + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Enable 2FA failed", "启用2FA失败"), + Data = null + }; + } + } + + /// + /// 关闭账号 2FA + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码或恢复备用码 + /// + public BaseResponse Disable(TwoFactorUserType userType, string serialNumber, string verificationCode) + { + try + { + if (string.IsNullOrWhiteSpace(verificationCode)) + { + return new BaseResponse(BusinessStatusCode.BadRequest, LocalizationHelper.GetLocalizedString("Verification code is required", "验证码不能为空")); + } + + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new BaseResponse(BusinessStatusCode.NotFound, LocalizationHelper.GetLocalizedString("User not found", "用户不存在")); + } + + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + if (auth == null || auth.IsEnabled != 1 || string.IsNullOrWhiteSpace(auth.SecretKey)) + { + return new BaseResponse(BusinessStatusCode.BadRequest, LocalizationHelper.GetLocalizedString("2FA is not enabled", "2FA未启用")); + } + + if (!VerifyTotpOrRecoveryCode(auth, verificationCode, allowRecoveryCode: true)) + { + return new BaseResponse(BusinessStatusCode.Unauthorized, LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误")); + } + + auth.IsEnabled = 0; + auth.SecretKey = null; + auth.EnabledAt = null; + auth.LastVerifiedAt = DateTime.Now; + auth.LastValidatedCounter = null; + if (!_twoFactorRepository.Update(auth)) + { + return new BaseResponse(BusinessStatusCode.InternalServerError, LocalizationHelper.GetLocalizedString("Disable 2FA failed", "鍏抽棴2FA澶辫触")); + } + + ClearRecoveryCodes(auth.Id); + + return new BaseResponse(BusinessStatusCode.Success, LocalizationHelper.GetLocalizedString("2FA disabled", "2FA已关闭")); + } + catch (Exception ex) + { + _logger.LogError(ex, "Disable 2FA failed for {UserType}-{SerialNumber}", userType, serialNumber); + return new BaseResponse(BusinessStatusCode.InternalServerError, LocalizationHelper.GetLocalizedString("Disable 2FA failed", "关闭2FA失败")); + } + } + + /// + /// 重置恢复备用码(会使旧备用码全部失效) + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// 验证码或恢复备用码 + /// + public SingleOutputDto RegenerateRecoveryCodes(TwoFactorUserType userType, string serialNumber, string verificationCode) + { + try + { + if (string.IsNullOrWhiteSpace(verificationCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("Verification code is required", "验证码不能为空"), + Data = null + }; + } + + var resolved = ResolveUser(userType, serialNumber); + if (resolved == null) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("User not found", "用户不存在"), + Data = null + }; + } + + var auth = GetByUserPrimaryKey(userType, resolved.UserPrimaryKey); + if (auth == null || auth.IsEnabled != 1 || string.IsNullOrWhiteSpace(auth.SecretKey)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("2FA is not enabled", "2FA未启用"), + Data = null + }; + } + + if (!VerifyTotpOrRecoveryCode(auth, verificationCode, allowRecoveryCode: true)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = null + }; + } + + var codes = ReplaceRecoveryCodes(auth.Id); + if (!TouchLastVerifiedAt(auth.Id)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Recovery code regenerate failed", "备用码生成失败"), + Data = null + }; + } + + return new SingleOutputDto + { + Code = BusinessStatusCode.Success, + Message = LocalizationHelper.GetLocalizedString("Recovery codes regenerated", "恢复备用码已重置"), + Data = new TwoFactorRecoveryCodesOutputDto + { + RecoveryCodes = codes, + RemainingCount = codes.Count + } + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "RegenerateRecoveryCodes failed for {UserType}-{SerialNumber}", userType, serialNumber); + return new SingleOutputDto + { + Code = BusinessStatusCode.InternalServerError, + Message = LocalizationHelper.GetLocalizedString("Recovery code regenerate failed", "恢复备用码重置失败"), + Data = null + }; + } + } + + /// + /// 按账号主键查询 2FA 记录 + /// + /// 账号类型 + /// 账号主键ID + /// + private TwoFactorAuth? GetByUserPrimaryKey(TwoFactorUserType userType, int userPrimaryKey) + { + return userType switch + { + TwoFactorUserType.Employee => _twoFactorRepository.GetFirst(a => a.EmployeePk == userPrimaryKey && a.IsDelete != 1), + TwoFactorUserType.Administrator => _twoFactorRepository.GetFirst(a => a.AdministratorPk == userPrimaryKey && a.IsDelete != 1), + TwoFactorUserType.Customer => _twoFactorRepository.GetFirst(a => a.CustomerAccountPk == userPrimaryKey && a.IsDelete != 1), + _ => null + }; + } + + /// + /// 写入对应账号类型的外键字段 + /// + /// 2FA 实体 + /// 账号类型 + /// 账号主键ID + private static void AttachUserForeignKey(TwoFactorAuth auth, TwoFactorUserType userType, int userPrimaryKey) + { + switch (userType) + { + case TwoFactorUserType.Employee: + auth.EmployeePk = userPrimaryKey; + break; + case TwoFactorUserType.Administrator: + auth.AdministratorPk = userPrimaryKey; + break; + case TwoFactorUserType.Customer: + auth.CustomerAccountPk = userPrimaryKey; + break; + } + } + + /// + /// 校验 TOTP,失败后可回退校验恢复备用码(并一次性消费) + /// + /// + /// + /// + /// + private bool VerifyTotpOrRecoveryCode(TwoFactorAuth auth, string code, bool allowRecoveryCode) + { + if (string.IsNullOrWhiteSpace(auth.SecretKey) || string.IsNullOrWhiteSpace(code)) + { + return false; + } + + if (TryVerifyTotp(auth, code, out var validatedCounter)) + { + return TryMarkTotpValidated(auth, validatedCounter); + } + + return allowRecoveryCode && TryConsumeRecoveryCode(auth.Id, code); + } + + /// + /// 校验TOTP(不处理重放) + /// + private bool TryVerifyTotp(TwoFactorAuth auth, string code, out long validatedCounter) + { + validatedCounter = -1; + if (string.IsNullOrWhiteSpace(auth.SecretKey) || string.IsNullOrWhiteSpace(code)) + { + return false; + } + + var encryptedSecret = auth.SecretKey; + var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(encryptedSecret); + + // Opportunistic migration: move legacy plaintext secrets to protected storage. + if (!_dataProtectionHelper.IsTwoFactorDataProtected(encryptedSecret) && !string.IsNullOrWhiteSpace(secret)) + { + var migratedSecret = _dataProtectionHelper.EncryptTwoFactorData(secret); + if (!string.Equals(migratedSecret, encryptedSecret, StringComparison.Ordinal)) + { + auth.SecretKey = migratedSecret; + if (!_twoFactorRepository.Update(auth)) + { + _logger.LogWarning("Failed to migrate legacy plaintext 2FA secret for auth id {AuthId}", auth.Id); + auth.SecretKey = encryptedSecret; + } + } + } + + return _twoFactorHelper.TryVerifyCode(secret, code, out validatedCounter); + } + + /// + /// TOTP防重放:拒绝重复或倒退counter + /// + private bool TryMarkTotpValidated(TwoFactorAuth auth, long validatedCounter) + { + var now = DateTime.Now; + var affected = _twoFactorRepository.Context + .Updateable() + .SetColumns(a => a.LastValidatedCounter == validatedCounter) + .SetColumns(a => a.LastVerifiedAt == now) + .Where(a => a.Id == auth.Id && a.IsDelete != 1) + .Where(a => a.LastValidatedCounter == null || a.LastValidatedCounter < validatedCounter) + .ExecuteCommand(); + + if (affected <= 0) + { + return false; + } + + auth.LastValidatedCounter = validatedCounter; + auth.LastVerifiedAt = now; + return true; + } + + private bool TouchLastVerifiedAt(int authId) + { + var now = DateTime.Now; + return _twoFactorRepository.Context + .Updateable() + .SetColumns(a => a.LastVerifiedAt == now) + .Where(a => a.Id == authId && a.IsDelete != 1) + .ExecuteCommand() > 0; + } + + /// + /// 获取剩余可用恢复备用码数量 + /// + /// + /// + private int GetRemainingRecoveryCodeCount(int twoFactorAuthId) + { + return _recoveryCodeRepository + .Count(a => a.TwoFactorAuthPk == twoFactorAuthId && a.IsDelete != 1 && a.IsUsed != 1); + } + + /// + /// 清理指定 2FA 的全部恢复备用码(硬删除) + /// + /// + private void ClearRecoveryCodes(int twoFactorAuthId) + { + _recoveryCodeRepository.Delete(a => a.TwoFactorAuthPk == twoFactorAuthId); + } + + /// + /// 重新生成恢复备用码(会清理旧数据) + /// + /// + /// 新备用码明文(仅返回一次) + private List ReplaceRecoveryCodes(int twoFactorAuthId) + { + ClearRecoveryCodes(twoFactorAuthId); + + var plainCodes = _twoFactorHelper.GenerateRecoveryCodes(); + foreach (var code in plainCodes) + { + var salt = _twoFactorHelper.CreateRecoveryCodeSalt(); + var hash = _twoFactorHelper.HashRecoveryCode(code, salt); + + _recoveryCodeRepository.Insert(new TwoFactorRecoveryCode + { + TwoFactorAuthPk = twoFactorAuthId, + CodeSalt = salt, + CodeHash = hash, + IsUsed = 0, + UsedAt = null, + IsDelete = 0 + }); + } + + return plainCodes; + } + + /// + /// 尝试消费一个恢复备用码(一次性) + /// + /// + /// + /// + private bool TryConsumeRecoveryCode(int twoFactorAuthId, string candidateCode) + { + var candidates = _recoveryCodeRepository + .GetList(a => a.TwoFactorAuthPk == twoFactorAuthId && a.IsDelete != 1 && a.IsUsed != 1); + + foreach (var item in candidates) + { + if (!_twoFactorHelper.VerifyRecoveryCode(candidateCode, item.CodeSalt, item.CodeHash)) + { + continue; + } + + item.IsUsed = 1; + item.UsedAt = DateTime.Now; + _recoveryCodeRepository.Update(item); + return true; + } + + return false; + } + + /// + /// 通过业务编号解析账号主键与账号标识 + /// + /// 账号类型 + /// 账号业务编号(JWT SerialNumber) + /// + private UserResolveResult? ResolveUser(TwoFactorUserType userType, string serialNumber) + { + if (string.IsNullOrWhiteSpace(serialNumber)) + return null; + + switch (userType) + { + case TwoFactorUserType.Employee: + var employee = _employeeRepository.GetFirst(a => a.EmployeeId == serialNumber && a.IsDelete != 1); + if (employee == null) + return null; + return new UserResolveResult(employee.Id, employee.EmployeeId); + + case TwoFactorUserType.Administrator: + var admin = _administratorRepository.GetFirst(a => a.Number == serialNumber && a.IsDelete != 1); + if (admin == null) + return null; + return new UserResolveResult(admin.Id, admin.Account ?? admin.Number); + + case TwoFactorUserType.Customer: + var customer = _customerAccountRepository.GetFirst(a => a.CustomerNumber == serialNumber && a.IsDelete != 1); + if (customer == null) + return null; + return new UserResolveResult(customer.Id, customer.Account ?? customer.CustomerNumber); + + default: + return null; + } + } + + /// + /// 账号解析结果 + /// + private sealed class UserResolveResult + { + /// + /// 账号主键ID + /// + public int UserPrimaryKey { get; } + + /// + /// 账号名称(用于构建 TOTP 标识) + /// + public string AccountName { get; } + + /// + /// 构造函数 + /// + /// + /// + public UserResolveResult(int userPrimaryKey, string accountName) + { + UserPrimaryKey = userPrimaryKey; + AccountName = accountName; + } + } + } +} diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs index 1e43c01ef8e7f298e4262bdc918beeeb9403a5fb..6ad64760ef5fa594d9687a9862abc6c1a36853be 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -25,10 +25,10 @@ using EOM.TSHotelManagement.Common; using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Data; using EOM.TSHotelManagement.Domain; -using jvncorelib.EncryptorLib; using jvncorelib.EntityLib; using Microsoft.Extensions.Logging; using SqlSugar; +using System.Net.Mail; using System.Security.Claims; namespace EOM.TSHotelManagement.Service @@ -78,9 +78,19 @@ namespace EOM.TSHotelManagement.Service /// private readonly JWTHelper jWTHelper; + /// + /// 2FA服务 + /// + private readonly ITwoFactorAuthService twoFactorAuthService; + + /// + /// 邮件助手 + /// + private readonly MailHelper mailHelper; + private readonly ILogger logger; - public AdminService(GenericRepository adminRepository, GenericRepository adminTypeRepository, GenericRepository userRoleRepository, GenericRepository rolePermissionRepository, GenericRepository permissionRepository, GenericRepository roleRepository, DataProtectionHelper dataProtector, JWTHelper jWTHelper, ILogger logger) + public AdminService(GenericRepository adminRepository, GenericRepository adminTypeRepository, GenericRepository userRoleRepository, GenericRepository rolePermissionRepository, GenericRepository permissionRepository, GenericRepository roleRepository, DataProtectionHelper dataProtector, JWTHelper jWTHelper, ITwoFactorAuthService twoFactorAuthService, MailHelper mailHelper, ILogger logger) { this.adminRepository = adminRepository; this.adminTypeRepository = adminTypeRepository; @@ -90,6 +100,8 @@ namespace EOM.TSHotelManagement.Service this.roleRepository = roleRepository; this.dataProtector = dataProtector; this.jWTHelper = jWTHelper; + this.twoFactorAuthService = twoFactorAuthService; + this.mailHelper = mailHelper; this.logger = logger; } @@ -109,12 +121,22 @@ namespace EOM.TSHotelManagement.Service if (existingAdmin == null) { - return null; + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("Administrator does not exist", "管理员不存在"), + Data = null + }; } if (existingAdmin.IsDelete == 1) { - return null; + return new SingleOutputDto + { + Code = BusinessStatusCode.NotFound, + Message = LocalizationHelper.GetLocalizedString("Administrator does not exist", "管理员不存在"), + Data = null + }; } @@ -123,17 +145,66 @@ namespace EOM.TSHotelManagement.Service var passed = originalPwd == currentPwd; if (!passed) { - return null; + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid account or password", "账号或密码错误"), + Data = null + }; + } + + var usedRecoveryCode = false; + if (twoFactorAuthService.RequiresTwoFactor(TwoFactorUserType.Administrator, existingAdmin.Id)) + { + if (string.IsNullOrWhiteSpace(readAdministratorInputDto.TwoFactorCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("2FA code is required", "需要输入2FA验证码"), + Data = new ReadAdministratorOutputDto + { + Number = existingAdmin.Number, + Account = existingAdmin.Account, + Name = existingAdmin.Name, + RequiresTwoFactor = true + } + }; + } + + var twoFactorPassed = twoFactorAuthService.VerifyLoginCode(TwoFactorUserType.Administrator, existingAdmin.Id, readAdministratorInputDto.TwoFactorCode, out usedRecoveryCode); + if (!twoFactorPassed) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = new ReadAdministratorOutputDto + { + Number = existingAdmin.Number, + Account = existingAdmin.Account, + Name = existingAdmin.Name, + RequiresTwoFactor = true + } + }; + } } existingAdmin.Password = string.Empty; existingAdmin.UserToken = jWTHelper.GenerateJWT(new ClaimsIdentity(new Claim[] { new Claim(ClaimTypes.Name, existingAdmin.Name), - new Claim(ClaimTypes.SerialNumber, existingAdmin.Number) + new Claim(ClaimTypes.SerialNumber, existingAdmin.Number), + new Claim("is_super_admin", existingAdmin.IsSuperAdmin.ToString()) })); var source = EntityMapper.Map(existingAdmin); + source.RequiresTwoFactor = false; + source.UsedRecoveryCodeLogin = usedRecoveryCode; + if (usedRecoveryCode) + { + NotifyRecoveryCodeLoginByEmail(existingAdmin.Account, existingAdmin.Name); + } return new SingleOutputDto { @@ -141,6 +212,55 @@ namespace EOM.TSHotelManagement.Service }; } + /// + /// 备用码登录后邮件通知(管理员) + /// + /// 候选邮箱(管理员账号) + /// 显示名称 + private void NotifyRecoveryCodeLoginByEmail(string? emailCandidate, string? displayName) + { + if (string.IsNullOrWhiteSpace(emailCandidate) || !MailAddress.TryCreate(emailCandidate, out _)) + { + logger.LogWarning("Recovery-code login alert skipped for admin {AdminName}: account is not a valid email.", displayName ?? string.Empty); + return; + } + + var recipient = emailCandidate; + var name = displayName ?? "Administrator"; + _ = Task.Run(async () => + { + await SendRecoveryCodeLoginAlertWithRetryAsync(recipient, name, "admin"); + }); + } + + private async Task SendRecoveryCodeLoginAlertWithRetryAsync(string recipient, string displayName, string accountKind) + { + const int maxAttempts = 3; + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate(displayName, recipient, DateTime.Now); + var sent = mailHelper.SendMail(new List { recipient }, template.Subject, template.Body); + if (sent) + { + return; + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Recovery-code alert send failed for {AccountKind} {DisplayName}, attempt {Attempt}.", accountKind, displayName, attempt); + } + + if (attempt < maxAttempts) + { + await Task.Delay(TimeSpan.FromSeconds(attempt)); + } + } + + logger.LogWarning("Recovery-code alert send exhausted retries for {AccountKind} {DisplayName}.", accountKind, displayName); + } + /// /// 获取所有管理员列表 /// @@ -188,6 +308,7 @@ namespace EOM.TSHotelManagement.Service var source = administrators[i]; dtoArray[i] = new ReadAdministratorOutputDto { + Id = source.Id, Number = source.Number, Account = source.Account, Password = source.Password, @@ -200,6 +321,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }; }); @@ -212,6 +334,7 @@ namespace EOM.TSHotelManagement.Service { result.Add(new ReadAdministratorOutputDto { + Id = source.Id, Number = source.Number, Account = source.Account, Password = source.Password, @@ -224,6 +347,7 @@ namespace EOM.TSHotelManagement.Service DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }); }); @@ -318,7 +442,8 @@ namespace EOM.TSHotelManagement.Service }; } - var administrators = adminRepository.GetList(a => deleteAdministratorInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteAdministratorInputDto); + var administrators = adminRepository.GetList(a => delIds.Contains(a.Id)); if (!administrators.Any()) { @@ -329,6 +454,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deleteAdministratorInputDto, administrators, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // cannot be delete if is super admin var admin = administrators.Any(a => a.IsSuperAdmin == 1); if (admin) @@ -392,12 +522,14 @@ namespace EOM.TSHotelManagement.Service var source = administratorTypes[i]; dtoArray[i] = new ReadAdministratorTypeOutputDto { + Id = source.Id, TypeId = source.TypeId, TypeName = source.TypeName, DataInsUsr = source.DataInsUsr, DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }; }); @@ -410,12 +542,14 @@ namespace EOM.TSHotelManagement.Service { result.Add(new ReadAdministratorTypeOutputDto { + Id = source.Id, TypeId = source.TypeId, TypeName = source.TypeName, DataInsUsr = source.DataInsUsr, DataInsDate = source.DataInsDate, DataChgUsr = source.DataChgUsr, DataChgDate = source.DataChgDate, + RowVersion = source.RowVersion, IsDelete = source.IsDelete }); }); @@ -487,7 +621,8 @@ namespace EOM.TSHotelManagement.Service }; } - var administratorTypes = adminTypeRepository.GetList(a => deleteAdministratorTypeInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteAdministratorTypeInputDto); + var administratorTypes = adminTypeRepository.GetList(a => delIds.Contains(a.Id)); if (!administratorTypes.Any()) { @@ -498,6 +633,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deleteAdministratorTypeInputDto, administratorTypes, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // cannot be delete if have administrators var haveAdmin = adminRepository.IsAny(a => administratorTypes.Select(a => a.TypeId).Contains(a.Type) && a.IsDelete != 1); if (haveAdmin) @@ -879,5 +1019,58 @@ namespace EOM.TSHotelManagement.Service }; } } + + /// + /// 获取管理员账号 2FA 状态 + /// + /// 管理员编号(JWT SerialNumber) + /// + public SingleOutputDto GetTwoFactorStatus(string adminSerialNumber) + { + return twoFactorAuthService.GetStatus(TwoFactorUserType.Administrator, adminSerialNumber); + } + + /// + /// 生成管理员账号 2FA 绑定信息 + /// + /// 管理员编号(JWT SerialNumber) + /// + public SingleOutputDto GenerateTwoFactorSetup(string adminSerialNumber) + { + return twoFactorAuthService.GenerateSetup(TwoFactorUserType.Administrator, adminSerialNumber); + } + + /// + /// 启用管理员账号 2FA + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码输入 + /// + public SingleOutputDto EnableTwoFactor(string adminSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Enable(TwoFactorUserType.Administrator, adminSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 关闭管理员账号 2FA + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码输入 + /// + public BaseResponse DisableTwoFactor(string adminSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.Disable(TwoFactorUserType.Administrator, adminSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } + + /// + /// 重置管理员账号恢复备用码 + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + public SingleOutputDto RegenerateTwoFactorRecoveryCodes(string adminSerialNumber, TwoFactorCodeInputDto inputDto) + { + return twoFactorAuthService.RegenerateRecoveryCodes(TwoFactorUserType.Administrator, adminSerialNumber, inputDto?.VerificationCode ?? string.Empty); + } } } diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/IAdminService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/IAdminService.cs index ddb36d5817e625f168c632356a783c4844138b4f..a88714a82ad52638266367a0e69f353d286a4f3b 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/IAdminService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/IAdminService.cs @@ -125,5 +125,43 @@ namespace EOM.TSHotelManagement.Service /// 用户编码 /// 权限编码集合(PermissionNumber 列表) ListOutputDto ReadUserDirectPermissions(string userNumber); + + /// + /// 获取管理员账号的 2FA 状态 + /// + /// 管理员编号(JWT SerialNumber) + /// + SingleOutputDto GetTwoFactorStatus(string adminSerialNumber); + + /// + /// 生成管理员账号的 2FA 绑定信息 + /// + /// 管理员编号(JWT SerialNumber) + /// + SingleOutputDto GenerateTwoFactorSetup(string adminSerialNumber); + + /// + /// 启用管理员账号 2FA + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码输入 + /// + SingleOutputDto EnableTwoFactor(string adminSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 关闭管理员账号 2FA + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码输入 + /// + BaseResponse DisableTwoFactor(string adminSerialNumber, TwoFactorCodeInputDto inputDto); + + /// + /// 重置管理员账号恢复备用码 + /// + /// 管理员编号(JWT SerialNumber) + /// 验证码或恢复备用码输入 + /// + SingleOutputDto RegenerateTwoFactorRecoveryCodes(string adminSerialNumber, TwoFactorCodeInputDto inputDto); } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Base/BaseService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Base/BaseService.cs index f609425bd75ec1ce2a91b173e77941423f943704..23dd1d28e2022abc9d4896a1f01fc90cd95c8eb2 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Base/BaseService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Base/BaseService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -316,7 +316,8 @@ namespace EOM.TSHotelManagement.Service }; } - var positions = positionRepository.GetList(a => deletePositionInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deletePositionInputDto); + var positions = positionRepository.GetList(a => delIds.Contains(a.Id)); if (!positions.Any()) { @@ -327,6 +328,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deletePositionInputDto, positions, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 当前职位下是否有员工 var positionNumbers = positions.Select(a => a.PositionNumber).ToList(); var employeeCount = workerRepository.AsQueryable().Count(a => positionNumbers.Contains(a.Position)); @@ -360,7 +366,11 @@ namespace EOM.TSHotelManagement.Service try { var position = EntityMapper.Map(updatePositionInputDto); - positionRepository.Update(position); + var result = positionRepository.Update(position); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) @@ -455,7 +465,8 @@ namespace EOM.TSHotelManagement.Service }; } - var nations = nationRepository.GetList(a => deleteNationInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteNationInputDto); + var nations = nationRepository.GetList(a => delIds.Contains(a.Id)); if (!nations.Any()) { @@ -466,6 +477,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deleteNationInputDto, nations, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 当前民族类型下是否有员工 var nationNumbers = nations.Select(a => a.NationNumber).ToList(); var employeeCount = workerRepository.AsQueryable().Count(a => nationNumbers.Contains(a.Ethnicity)); @@ -499,7 +515,11 @@ namespace EOM.TSHotelManagement.Service try { var nation = EntityMapper.Map(updateNationInputDto); - nationRepository.Update(nation); + var result = nationRepository.Update(nation); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) @@ -592,7 +612,8 @@ namespace EOM.TSHotelManagement.Service Message = LocalizationHelper.GetLocalizedString("Parameters Invalid", "参数错误") }; } - var educations = educationRepository.GetList(a => deleteEducationInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteEducationInputDto); + var educations = educationRepository.GetList(a => delIds.Contains(a.Id)); if (!educations.Any()) { return new BaseResponse @@ -602,6 +623,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deleteEducationInputDto, educations, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 当前学历类型下是否有员工 var educationNumbers = educations.Select(a => a.EducationNumber).ToList(); var employeeCount = workerRepository.AsQueryable().Count(a => educationNumbers.Contains(a.EducationLevel)); @@ -635,7 +661,11 @@ namespace EOM.TSHotelManagement.Service try { var entity = EntityMapper.Map(education); - educationRepository.Update(entity); + var result = educationRepository.Update(entity); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) @@ -760,7 +790,8 @@ namespace EOM.TSHotelManagement.Service Message = LocalizationHelper.GetLocalizedString("Parameters Invalid", "参数错误") }; } - var departments = deptRepository.GetList(a => dept.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(dept); + var departments = deptRepository.GetList(a => delIds.Contains(a.Id)); if (!departments.Any()) { return new BaseResponse @@ -770,6 +801,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(dept, departments, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 当前部门类型下是否有员工 var departmentNumbers = departments.Select(a => a.DepartmentNumber).ToList(); var employeeCount = workerRepository.AsQueryable().Count(a => departmentNumbers.Contains(a.Department)); @@ -804,6 +840,10 @@ namespace EOM.TSHotelManagement.Service { var department = EntityMapper.Map(dept); var result = deptRepository.Update(department); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) @@ -915,7 +955,8 @@ namespace EOM.TSHotelManagement.Service Message = LocalizationHelper.GetLocalizedString("Parameters Invalid", "参数错误") }; } - var custoTypes = custoTypeRepository.GetList(a => custoType.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(custoType); + var custoTypes = custoTypeRepository.GetList(a => delIds.Contains(a.Id)); if (!custoTypes.Any()) { return new BaseResponse @@ -925,6 +966,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(custoType, custoTypes, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 当前客户类型下是否有客户 var customerTypeNumbers = custoTypes.Select(a => a.CustomerType).ToList(); var customerCount = customerRepository.AsQueryable().Count(a => customerTypeNumbers.Contains(a.CustomerType)); @@ -958,7 +1004,11 @@ namespace EOM.TSHotelManagement.Service try { var entity = EntityMapper.Map(custoType); - custoTypeRepository.Update(entity); + var result = custoTypeRepository.Update(entity); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) @@ -1070,7 +1120,8 @@ namespace EOM.TSHotelManagement.Service Message = LocalizationHelper.GetLocalizedString("Parameters Invalid", "参数错误") }; } - var passPortTypes = passPortTypeRepository.GetList(a => portType.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(portType); + var passPortTypes = passPortTypeRepository.GetList(a => delIds.Contains(a.Id)); if (!passPortTypes.Any()) { return new BaseResponse @@ -1080,6 +1131,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(portType, passPortTypes, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 当前证件类型下是否有客户 var passportTypeNumbers = passPortTypes.Select(a => a.PassportId).ToList(); var customerCount = customerRepository.AsQueryable().Count(a => passportTypeNumbers.Contains(a.PassportId)); @@ -1125,6 +1181,10 @@ namespace EOM.TSHotelManagement.Service { var entity = EntityMapper.Map(portType); var result = passPortTypeRepository.Update(entity); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) @@ -1236,7 +1296,8 @@ namespace EOM.TSHotelManagement.Service Message = LocalizationHelper.GetLocalizedString("Parameters Invalid", "参数错误") }; } - var rewardPunishmentTypes = goodbadTypeRepository.GetList(a => request.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(request); + var rewardPunishmentTypes = goodbadTypeRepository.GetList(a => delIds.Contains(a.Id)); if (!rewardPunishmentTypes.Any()) { return new BaseResponse @@ -1246,6 +1307,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(request, rewardPunishmentTypes, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + var result = goodbadTypeRepository.SoftDeleteRange(rewardPunishmentTypes); return new BaseResponse(); @@ -1268,6 +1334,10 @@ namespace EOM.TSHotelManagement.Service { var entity = EntityMapper.Map(gBType); var result = goodbadTypeRepository.Update(entity); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) @@ -1362,7 +1432,8 @@ namespace EOM.TSHotelManagement.Service Message = LocalizationHelper.GetLocalizedString("Parameters Invalid", "参数错误") }; } - var appointmentNoticeTypes = appointmentNoticeTypeRepository.GetList(a => deleteAppointmentNoticeTypeInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteAppointmentNoticeTypeInputDto); + var appointmentNoticeTypes = appointmentNoticeTypeRepository.GetList(a => delIds.Contains(a.Id)); if (!appointmentNoticeTypes.Any()) { return new BaseResponse @@ -1372,6 +1443,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deleteAppointmentNoticeTypeInputDto, appointmentNoticeTypes, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 当前公告类型下是否有公告 var noticeTypeNumbers = appointmentNoticeTypes.Select(a => a.NoticeTypeNumber).ToList(); var appointmentNoticeCount = appointmentNoticeRepository.AsQueryable().Count(a => noticeTypeNumbers.Contains(a.NoticeType)); @@ -1410,7 +1486,11 @@ namespace EOM.TSHotelManagement.Service return new BaseResponse { Code = BusinessStatusCode.InternalServerError, Message = LocalizationHelper.GetLocalizedString("appointment notice number does not already.", "公告类型编号不存在") }; } var entity = EntityMapper.Map(updateAppointmentNoticeTypeInputDto); - appointmentNoticeTypeRepository.Update(entity); + var result = appointmentNoticeTypeRepository.Update(entity); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse { Code = BusinessStatusCode.Success, Message = LocalizationHelper.GetLocalizedString("update appointment notice successful.", "公告类型更新成功") }; } diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Menu/MenuService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Menu/MenuService.cs index b2416e6cb9b58e25956f94df36c60b8129302c5e..e774e304d08abf16358bd2aeeb9efad266eb8d0d 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Menu/MenuService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Menu/MenuService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -273,7 +273,11 @@ namespace EOM.TSHotelManagement.Service { try { - menuRepository.Update(EntityMapper.Map(menu)); + var result = menuRepository.Update(EntityMapper.Map(menu)); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) @@ -301,7 +305,8 @@ namespace EOM.TSHotelManagement.Service }; } - var menus = menuRepository.GetList(a => input.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(input); + var menus = menuRepository.GetList(a => delIds.Contains(a.Id)); if (!menus.Any()) { @@ -312,6 +317,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(input, menus, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + var result = menuRepository.SoftDeleteRange(menus); return new BaseResponse(); diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Notice/NoticeService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Notice/NoticeService.cs index d71f2b77bc2a6bb943eb442f0780a083343fec05..4d11897848a943434e8cc8f21faea5844618c0ab 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Notice/NoticeService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Notice/NoticeService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -141,7 +141,8 @@ namespace EOM.TSHotelManagement.Service }; } - var appointmentNotices = noticeRepository.GetList(a => input.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(input); + var appointmentNotices = noticeRepository.GetList(a => delIds.Contains(a.Id)); if (!appointmentNotices.Any()) { @@ -152,6 +153,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(input, appointmentNotices, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + var result = noticeRepository.SoftDeleteRange(appointmentNotices); return new BaseResponse(); @@ -174,6 +180,10 @@ namespace EOM.TSHotelManagement.Service { var entity = EntityMapper.Map(updateAppointmentNoticeInputDto); var result = noticeRepository.Update(entity); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Permission/PermissionAppService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Permission/PermissionAppService.cs index bb4c791df59e9eef4efc8fe4747c086cf0150759..6b3ad3517ddbd9258ed0fbd7c7517615573ca267 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Permission/PermissionAppService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Permission/PermissionAppService.cs @@ -48,11 +48,13 @@ namespace EOM.TSHotelManagement.Service var outputItems = list.Select(p => new ReadPermissionOutputDto { + Id = p.Id, PermissionNumber = p.PermissionNumber, PermissionName = p.PermissionName, Module = p.Module, MenuKey = p.MenuKey, - Description = p.Description + Description = p.Description, + RowVersion = p.RowVersion }).ToList(); return new ListOutputDto diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Role/RoleAppService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Role/RoleAppService.cs index 7d1174852ddeba511548910186d6aacd4bae9577..390b67c33b0d122b5dc2e8ed6d9f3b52354cb7f5 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Role/RoleAppService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Role/RoleAppService.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Common; using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Contract.SystemManagement.Dto.Permission; using EOM.TSHotelManagement.Contract.SystemManagement.Dto.Role; @@ -59,7 +59,8 @@ namespace EOM.TSHotelManagement.Service }; } - var roles = roleRepository.GetList(a => deleteRoleInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteRoleInputDto); + var roles = roleRepository.GetList(a => delIds.Contains(a.Id)); if (!roles.Any()) { @@ -70,6 +71,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deleteRoleInputDto, roles, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 如果角色组存在关联的权限映射或用户绑定,则不允许删除 var roleNumbers = roles.Select(r => r.RoleNumber).ToList(); var hasRolePermissions = rolePermissionRepository.IsAny(rp => roleNumbers.Contains(rp.RoleNumber) && rp.IsDelete != 1); @@ -174,7 +180,11 @@ namespace EOM.TSHotelManagement.Service try { var entity = EntityMapper.Map(updateRoleInputDto); - roleRepository.Update(entity); + var result = roleRepository.Update(entity); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } return new BaseResponse(); } catch (Exception ex) diff --git a/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs b/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs index 7b545eafd550128eadc2329d7f3051f81c993123..1b370db4567c385fbfd1af5c233af6a8a2c52444 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -120,8 +120,13 @@ namespace EOM.TSHotelManagement.Service supervisionStatistics.SupervisionAdvice = checkInfo.SupervisionAdvice; supervisionStatistics.SupervisionStatistician = checkInfo.SupervisionStatistician; supervisionStatistics.SupervisionProgress = checkInfo.SupervisionProgress; + supervisionStatistics.RowVersion = checkInfo.RowVersion ?? 0; - checkInfoRepository.Update(supervisionStatistics); + var updateResult = checkInfoRepository.Update(supervisionStatistics); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -149,7 +154,8 @@ namespace EOM.TSHotelManagement.Service }; } - var supervisionStatistics = checkInfoRepository.GetList(a => input.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(input); + var supervisionStatistics = checkInfoRepository.GetList(a => delIds.Contains(a.Id)); if (!supervisionStatistics.Any()) { @@ -160,6 +166,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(input, supervisionStatistics, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 批量软删除 var result = checkInfoRepository.SoftDeleteRange(supervisionStatistics); diff --git a/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs b/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs index e50f0aa6bf7f519b547adec6031a722f11b4c39c..2de9ecbc0a179364462ba2ff20184837917b5735 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs @@ -1,4 +1,4 @@ -/* +/* * MIT License *Copyright (c) 2021 易开元(Easy-Open-Meta) @@ -154,7 +154,8 @@ namespace EOM.TSHotelManagement.Service }; } - var vipLevelRules = vipRuleRepository.GetList(a => vipRule.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(vipRule); + var vipLevelRules = vipRuleRepository.GetList(a => delIds.Contains(a.Id)); if (!vipLevelRules.Any()) { @@ -165,6 +166,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(vipRule, vipLevelRules, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + // 批量软删除 vipRuleRepository.SoftDeleteRange(vipLevelRules); return new BaseResponse(); @@ -186,11 +192,20 @@ namespace EOM.TSHotelManagement.Service try { var dbVipRule = vipRuleRepository.GetFirst(a => a.Id == vipRule.Id); + if (dbVipRule == null) + { + return new BaseResponse(BusinessStatusCode.NotFound, LocalizationHelper.GetLocalizedString("Vip Rule Not Found", "会员规则未找到")); + } dbVipRule.RuleName = vipRule.RuleName; dbVipRule.RuleValue = vipRule.RuleValue; dbVipRule.VipLevelId = vipRule.VipLevelId; dbVipRule.IsDelete = vipRule.IsDelete; - vipRuleRepository.Update(dbVipRule); + dbVipRule.RowVersion = vipRule.RowVersion ?? 0; + var updateResult = vipRuleRepository.Update(dbVipRule); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { diff --git a/EOM.TSHotelManagement.Service/Util/UtilService.cs b/EOM.TSHotelManagement.Service/Util/UtilService.cs index 57e29a1f39135d153b95e81533aad31b67e6583a..1f04c1e665382f2f9780c89bbde0a7867c515f8c 100644 --- a/EOM.TSHotelManagement.Service/Util/UtilService.cs +++ b/EOM.TSHotelManagement.Service/Util/UtilService.cs @@ -1,4 +1,4 @@ -using EOM.TSHotelManagement.Common; +using EOM.TSHotelManagement.Common; using EOM.TSHotelManagement.Contract; using EOM.TSHotelManagement.Data; using EOM.TSHotelManagement.Domain; @@ -210,7 +210,8 @@ namespace EOM.TSHotelManagement.Service }; } - var operationLogs = operationLogRepository.GetList(a => deleteOperationLogInputDto.DelIds.Contains(a.Id)); + var delIds = DeleteConcurrencyHelper.GetDeleteIds(deleteOperationLogInputDto); + var operationLogs = operationLogRepository.GetList(a => delIds.Contains(a.Id)); if (!operationLogs.Any()) { @@ -221,6 +222,11 @@ namespace EOM.TSHotelManagement.Service }; } + if (DeleteConcurrencyHelper.HasDeleteConflict(deleteOperationLogInputDto, operationLogs, a => a.Id, a => a.RowVersion)) + { + return BaseResponseFactory.ConcurrencyConflict(); + } + operationLogRepository.Delete(operationLogs); return new BaseResponse(); } diff --git a/README.en.md b/README.en.md index 0c6954942af887d1fc937ba289466bd4d6526f9f..424be9cad3678ff58938caa3da145e12584776a5 100644 --- a/README.en.md +++ b/README.en.md @@ -18,6 +18,13 @@ Primarily designed to achieve front-end/back-end separation following the upgrad ## Core Functional Features +### 0. Account Security Enhancements (TOTP 2FA) +- **TOTP-based 2FA support**: Staff, administrators, and customers can all enable/disable 2FA. +- **Recovery code support**: Users can complete login with one-time recovery codes when authenticator access is lost. +- **Recovery codes returned on first enablement**: `EnableTwoFactor` now returns the initial batch of recovery codes directly to avoid duplicate regeneration from the frontend. +- **Security alerting**: When a login succeeds via a recovery code, the system attempts to send an email notification before completing the response. +- **Complete API coverage**: Includes status query, binding info generation, enable/disable, recovery code reset, and remaining count query. + ### 1. Business Management Modules - **Room Management**: Supports room status management (Vacant, Occupied, Under Maintenance, Dirty, Reserved), check-in/check-out, room transfers, and configuration (type, pricing). - **Guest Management**: Guest profile management, account registration/login, membership tier administration. @@ -125,32 +132,37 @@ This project utilises the SqlSugar framework to support one-click database and t ### Docker Deployment -The project provides a Dockerfile (alternatively, images can be rapidly built via the `build.ps1` script, provided WSL 2.0 and Hyper-V are enabled locally and Docker Desktop is installed), supporting Docker containerised deployment. The API listens on port 8080 by default. +The project provides a Dockerfile (alternatively, images can be rapidly built via the `build.ps1` script, provided WSL 2.0 and Hyper-V are enabled locally and Docker Desktop is installed), supporting Docker containerised deployment. The API listens on port 8080 by default. +To avoid manually maintaining a very long `docker run` command, this repo now includes `docker-compose.yml` and `.env.example`. + +```bash +# 1) Prepare env file +cp .env.example .env + +# Windows PowerShell: +# Copy-Item .env.example .env + +# 2) Edit .env (database connection, JWT key, idempotency policy, mail settings, etc.) + +# 3) Start service +docker compose up -d + +# 4) View logs +docker compose logs -f tshotel-api + +# 5) Stop and remove container +docker compose down +``` + +If you still prefer `docker run`, you can shorten it with `--env-file`: ```bash -# Please modify environment variable parameters according to your actual setup docker run -d \ --name tshotel-api \ - --health-cmd="curl -f http://localhost:8080/health || exit 1" \ - --health-interval=30s \ - --health-timeout=10s \ - --health-retries=3 \ + --env-file .env \ -v /app/config:/app/config \ -v /app/keys:/app/keys \ -p 63001:8080 \ - -e ASPNETCORE_ENVIRONMENT=docker \ - -e DefaultDatabase=MariaDB \ - -e MariaDBConnectStr="Server=your_db_host;Database=tshoteldb;User=tshoteldb;Password=your_password;" \ - -e InitializeDatabase=true \ - -e Jwt__Key=your_super_secret_key \ - -e Jwt__ExpiryMinutes=20 \ - -e Mail__Enabled=true \ - -e Mail__Host=smtp.example.com \ - -e Mail__UserName=admin@example.com \ - -e Mail__Password=your_email_password \ - -e Mail__Port=465 \ - -e AllowedOrigins__0=http://localhost:8080 \ - -e AllowedOrigins__1=https://www.yourdomain.com \ yjj6731/tshotel-management-system-api:latest ``` @@ -181,9 +193,25 @@ docker run -d \ |AllowedOrigins__0|Allowed Domain Sites (for Development Environments)|Y|http://localhost:8080|http://localhost:8080| |AllowedOrigins__1|Allowed domain sites for production environment|Y|https://www.yourdomain.com|https://www.yourdomain.com| |SoftwareVersion|Software version number for documentation purposes|N|N/A|N/A| +|JobKeys__0|Quartz Job 1|Y|ReservationExpirationCheckJob|ReservationExpirationCheckJob| +|JobKeys__1|Quartz Job 2|Y|MailServiceCheckJob|MailServiceCheckJob| +|JobKeys__2|Quartz Job 3|Y|RedisServiceCheckJob|RedisServiceCheckJob| +|Redis__Enabled|Enable Redis|N|false|true/false| +|Redis__ConnectionString|Redis ConnectString|N|N/A|N/A| +|Redis__DefaultDatabase|Default Database of Redis|N|0|0| +|Idempotency__Enabled|Enable Idempotency-Key middleware|N|true|true/false| +|Idempotency__EnforceKey|Require Idempotency-Key for write requests|N|false|true/false| +|Idempotency__MaxKeyLength|Maximum Idempotency-Key length|N|128|integer >= 16| +|Idempotency__InProgressTtlSeconds|TTL for in-progress record (seconds)|N|120|30~600| +|Idempotency__CompletedTtlHours|TTL for completed record (hours)|N|24|1~168| +|Idempotency__PersistFailureResponse|Persist failed responses (non-2xx)|N|false|true/false| > ⚠️ **Security Advisory**: In production environments, do not directly pass password-like parameters in plaintext via the `-e` flag. It is recommended to utilise Docker Secrets or environment variable injection tools (such as HashiCorp Vault) for protection. +## Development Pace + +![development_pace](https://picrepo.oscode.top/i/2026/02/18/Development_pace.png) + ## Acknowledgements We extend our gratitude to the following outstanding open-source projects: diff --git a/README.md b/README.md index b18aed902ff1d30ef7b17878c93f12301fe06f1c..79cae06b4a42029a9cf0b21e94cd9dea5308875d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

组织logo.png

+

组织logo.png

TopskyHotelManagementSystem-WebApi

star @@ -18,6 +18,13 @@ ## 核心功能特性 +### 0. 账户安全增强(TOTP 2FA) +- **支持 TOTP 双因子认证**:员工、管理员、客户三类账号均支持开启/关闭 2FA。 +- **支持恢复备用码**:当用户丢失验证器时,可使用一次性备用码完成登录。 +- **首次启用即返回备用码**:`EnableTwoFactor` 成功后会直接返回首批备用码,避免前端重复重置生成。 +- **安全告警**:检测到“备用码登录”后,系统会在登录成功前尝试发送邮件通知用户。 +- **接口能力完整**:支持状态查询、绑定信息生成、启用、关闭、备用码重置与剩余数量查询。 + ### 1. 业务管理模块 - **房间管理**:支持房间状态(空房、已住、维修、脏房、预约)管理,入住、退房、换房,房间配置(类型、价格)。 - **客户管理**:客户档案管理,客户账号注册登录,会员类型管理。 @@ -125,32 +132,37 @@ EOM.TSHotelManagement.Web ### Docker 部署 -项目提供了 Dockerfile(亦可通过build.ps1文件快速构建镜像,前提需确保本地启用WSL2.0以及Hyper-V和安装Docker Desktop),支持 Docker 容器化部署。API 默认监听 8080 端口。 +项目提供了 Dockerfile(亦可通过build.ps1文件快速构建镜像,前提需确保本地启用WSL2.0以及Hyper-V和安装Docker Desktop),支持 Docker 容器化部署。API 默认监听 8080 端口。 +为了避免手写超长 `docker run` 命令,仓库已提供 `docker-compose.yml` + `.env.example`。 + +```bash +# 1) 准备环境变量文件 +cp .env.example .env + +# Windows PowerShell 可用: +# Copy-Item .env.example .env + +# 2) 按需修改 .env(数据库连接、JWT 密钥、幂等策略、邮箱等) + +# 3) 启动 +docker compose up -d + +# 4) 查看日志 +docker compose logs -f tshotel-api + +# 5) 停止并移除容器 +docker compose down +``` + +如果你仍想使用 `docker run`,也可以改成 `--env-file` 方式,命令会短很多: ```bash -# 请根据实际情况修改环境变量参数 docker run -d \ --name tshotel-api \ - --health-cmd="curl -f http://localhost:8080/health || exit 1" \ - --health-interval=30s \ - --health-timeout=10s \ - --health-retries=3 \ + --env-file .env \ -v /app/config:/app/config \ -v /app/keys:/app/keys \ -p 63001:8080 \ - -e ASPNETCORE_ENVIRONMENT=docker \ - -e DefaultDatabase=MariaDB \ - -e MariaDBConnectStr="Server=your_db_host;Database=tshoteldb;User=tshoteldb;Password=your_password;" \ - -e InitializeDatabase=true \ - -e Jwt__Key=your_super_secret_key \ - -e Jwt__ExpiryMinutes=20 \ - -e Mail__Enabled=true \ - -e Mail__Host=smtp.example.com \ - -e Mail__UserName=admin@example.com \ - -e Mail__Password=your_email_password \ - -e Mail__Port=465 \ - -e AllowedOrigins__0=http://localhost:8080 \ - -e AllowedOrigins__1=https://www.yourdomain.com \ yjj6731/tshotel-management-system-api:latest ``` @@ -181,9 +193,25 @@ docker run -d \ |AllowedOrigins__0|允许域站点,用于开发环境|Y|http://localhost:8080|http://localhost:8080| |AllowedOrigins__1|允许域站点,用于生产环境|Y|https://www.yourdomain.com|https://www.yourdomain.com| |SoftwareVersion|软件版本号,用于标记说明|N|N/A|N/A| +|JobKeys__0|定时任务1|Y|ReservationExpirationCheckJob|ReservationExpirationCheckJob| +|JobKeys__1|定时任务2|Y|MailServiceCheckJob|MailServiceCheckJob| +|JobKeys__2|定时任务3|Y|RedisServiceCheckJob|RedisServiceCheckJob| +|Redis__Enabled|是否启用Redis服务|N|false|true/false| +|Redis__ConnectionString|Redis连接字符串|N|N/A|N/A| +|Redis__DefaultDatabase|默认数据库|N|0|0| +|Idempotency__Enabled|是否启用幂等键中间件|N|true|true/false| +|Idempotency__EnforceKey|是否强制写请求必须携带 Idempotency-Key|N|false|true/false| +|Idempotency__MaxKeyLength|Idempotency-Key 最大长度|N|128|>=16 的整数| +|Idempotency__InProgressTtlSeconds|处理中记录 TTL(秒)|N|120|30~600| +|Idempotency__CompletedTtlHours|完成记录 TTL(小时)|N|24|1~168| +|Idempotency__PersistFailureResponse|是否缓存失败响应(非2xx)|N|false|true/false| > ⚠️ **安全提醒**:生产环境中请勿直接通过 `-e` 明文传入密码类参数,推荐使用 Docker Secrets 或环境变量注入工具(如 HashiCorp Vault)进行保护。 +## 开发节奏 + +![development_pace](https://picrepo.oscode.top/i/2026/02/18/Development_pace.png) + ## 鸣谢 感谢以下优秀的开源项目: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000000000000000000000000000000000..004c1b6df6a5a3629530116b8a01cf861c90434a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + tshotel-api: + image: ${TSHOTEL_IMAGE:-yjj6731/tshotel-management-system-api:latest} + container_name: ${CONTAINER_NAME:-tshotel-api} + restart: unless-stopped + ports: + - "${API_HOST_PORT:-63001}:8080" + volumes: + - "${APP_CONFIG_DIR:-./docker-data/config}:/app/config" + - "${APP_KEYS_DIR:-./docker-data/keys}:/app/keys" + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/health", "||", "exit", "1"] + interval: 30s + timeout: 10s + retries: 3 + env_file: + - .env diff --git a/version.txt b/version.txt index f96eaa82dab2fdf85cb4dde18b9e2e6069c90a11..315de5b54a9165b27f0cb117fab775b21812a334 100644 Binary files a/version.txt and b/version.txt differ diff --git "a/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MariaDB\347\211\210\346\234\254/MDB_patch_add_row_version.sql" "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MariaDB\347\211\210\346\234\254/MDB_patch_add_row_version.sql" new file mode 100644 index 0000000000000000000000000000000000000000..a79e8310bcdac742e28eee6500872d7d1a823817 --- /dev/null +++ "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MariaDB\347\211\210\346\234\254/MDB_patch_add_row_version.sql" @@ -0,0 +1,74 @@ +SET @schema_name = DATABASE(); +SET SESSION group_concat_max_len = 1024000; + +SELECT GROUP_CONCAT( + CONCAT( + 'ALTER TABLE `', c.table_name, '` ', + 'ADD COLUMN `row_version` BIGINT NOT NULL DEFAULT 1 COMMENT ''Row version (optimistic lock)'';' + ) + SEPARATOR ' ' + ) +INTO @ddl_sql +FROM information_schema.columns c +WHERE c.table_schema = @schema_name + AND c.column_name = 'delete_mk' + AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns x + WHERE x.table_schema = c.table_schema + AND x.table_name = c.table_name + AND x.column_name = 'row_version' + ); + +SET @ddl_sql = IFNULL(@ddl_sql, 'SELECT 1;'); +PREPARE stmt FROM @ddl_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SELECT GROUP_CONCAT( + CONCAT( + 'ALTER TABLE `', c.table_name, '` ', + 'ADD UNIQUE INDEX `uk_id_row_version` (`id`, `row_version`);' + ) + SEPARATOR ' ' + ) +INTO @idx_sql +FROM information_schema.columns c +WHERE c.table_schema = @schema_name + AND c.column_name = 'delete_mk' + AND EXISTS ( + SELECT 1 + FROM information_schema.columns x + WHERE x.table_schema = c.table_schema + AND x.table_name = c.table_name + AND x.column_name = 'id' + ) + AND EXISTS ( + SELECT 1 + FROM information_schema.columns x + WHERE x.table_schema = c.table_schema + AND x.table_name = c.table_name + AND x.column_name = 'row_version' + ) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT s.index_name, + SUM(CASE WHEN s.column_name = 'id' THEN 1 ELSE 0 END) AS has_id, + SUM(CASE WHEN s.column_name = 'row_version' THEN 1 ELSE 0 END) AS has_row_version, + COUNT(*) AS column_count + FROM information_schema.statistics s + WHERE s.table_schema = c.table_schema + AND s.table_name = c.table_name + AND s.non_unique = 0 + GROUP BY s.index_name + ) idx + WHERE idx.has_id = 1 + AND idx.has_row_version = 1 + AND idx.column_count = 2 + ); + +SET @idx_sql = IFNULL(@idx_sql, 'SELECT 1;'); +PREPARE stmt FROM @idx_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git "a/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MySQL\347\211\210\346\234\254/MDB_patch_add_row_version.sql" "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MySQL\347\211\210\346\234\254/MDB_patch_add_row_version.sql" new file mode 100644 index 0000000000000000000000000000000000000000..a79e8310bcdac742e28eee6500872d7d1a823817 --- /dev/null +++ "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/MySQL\347\211\210\346\234\254/MDB_patch_add_row_version.sql" @@ -0,0 +1,74 @@ +SET @schema_name = DATABASE(); +SET SESSION group_concat_max_len = 1024000; + +SELECT GROUP_CONCAT( + CONCAT( + 'ALTER TABLE `', c.table_name, '` ', + 'ADD COLUMN `row_version` BIGINT NOT NULL DEFAULT 1 COMMENT ''Row version (optimistic lock)'';' + ) + SEPARATOR ' ' + ) +INTO @ddl_sql +FROM information_schema.columns c +WHERE c.table_schema = @schema_name + AND c.column_name = 'delete_mk' + AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns x + WHERE x.table_schema = c.table_schema + AND x.table_name = c.table_name + AND x.column_name = 'row_version' + ); + +SET @ddl_sql = IFNULL(@ddl_sql, 'SELECT 1;'); +PREPARE stmt FROM @ddl_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SELECT GROUP_CONCAT( + CONCAT( + 'ALTER TABLE `', c.table_name, '` ', + 'ADD UNIQUE INDEX `uk_id_row_version` (`id`, `row_version`);' + ) + SEPARATOR ' ' + ) +INTO @idx_sql +FROM information_schema.columns c +WHERE c.table_schema = @schema_name + AND c.column_name = 'delete_mk' + AND EXISTS ( + SELECT 1 + FROM information_schema.columns x + WHERE x.table_schema = c.table_schema + AND x.table_name = c.table_name + AND x.column_name = 'id' + ) + AND EXISTS ( + SELECT 1 + FROM information_schema.columns x + WHERE x.table_schema = c.table_schema + AND x.table_name = c.table_name + AND x.column_name = 'row_version' + ) + AND NOT EXISTS ( + SELECT 1 + FROM ( + SELECT s.index_name, + SUM(CASE WHEN s.column_name = 'id' THEN 1 ELSE 0 END) AS has_id, + SUM(CASE WHEN s.column_name = 'row_version' THEN 1 ELSE 0 END) AS has_row_version, + COUNT(*) AS column_count + FROM information_schema.statistics s + WHERE s.table_schema = c.table_schema + AND s.table_name = c.table_name + AND s.non_unique = 0 + GROUP BY s.index_name + ) idx + WHERE idx.has_id = 1 + AND idx.has_row_version = 1 + AND idx.column_count = 2 + ); + +SET @idx_sql = IFNULL(@idx_sql, 'SELECT 1;'); +PREPARE stmt FROM @idx_sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git "a/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/PostgreSQL\347\211\210\346\234\254/PGDB_patch_add_row_version.sql" "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/PostgreSQL\347\211\210\346\234\254/PGDB_patch_add_row_version.sql" new file mode 100644 index 0000000000000000000000000000000000000000..e9f2076744bbc62b835e6e63c9d1ac7201e130d9 --- /dev/null +++ "b/\346\225\260\346\215\256\345\272\223\350\204\232\346\234\254/PostgreSQL\347\211\210\346\234\254/PGDB_patch_add_row_version.sql" @@ -0,0 +1,23 @@ +DO +$$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT c.table_name + FROM information_schema.columns c + WHERE c.table_schema = current_schema() + AND c.column_name = 'delete_mk' + AND NOT EXISTS ( + SELECT 1 + FROM information_schema.columns x + WHERE x.table_schema = c.table_schema + AND x.table_name = c.table_name + AND x.column_name = 'row_version' + ) + LOOP + EXECUTE format('ALTER TABLE %I ADD COLUMN row_version BIGINT NOT NULL DEFAULT 1;', r.table_name); + EXECUTE format('COMMENT ON COLUMN %I.row_version IS %L;', r.table_name, 'Row version (optimistic lock)'); + END LOOP; +END +$$;