From a531a51a458e39a3ec833141b030d8eb39f56ffd Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sun, 8 Feb 2026 14:46:35 +0800 Subject: [PATCH 1/7] Support RowVersion --- .../Common/Dto/BaseInputDto.cs | 4 + .../Common/Dto/BaseOutputDto.cs | 4 + .../Common/Dto/BaseResponseFactory.cs | 16 +++ .../DatabaseInitializer.cs | 71 ++++++++++++ .../Repository/GenericRepository.cs | 109 +++++++++++++----- EOM.TSHotelManagement.Domain/BaseEntity.cs | 5 + .../Application/NavBar/NavBarService.cs | 7 +- .../Business/Asset/AssetService.cs | 2 +- .../Business/Customer/CustomerService.cs | 11 +- .../EnergyManagementService.cs | 2 +- .../Business/News/NewsService.cs | 7 +- .../PromotionContentService.cs | 6 +- .../Business/Reser/ReserService.cs | 18 ++- .../Business/Room/RoomService.cs | 49 ++++++-- .../Business/Room/RoomTypeService.cs | 9 +- .../Business/Sellthing/SellService.cs | 11 +- .../Business/Spend/SpendService.cs | 26 ++++- .../Employee/EmployeeService.cs | 50 ++++++-- .../Employee/Photo/EmployeePhotoService.cs | 21 +++- .../Administrator/AdminService.cs | 8 +- .../SystemManagement/Base/BaseService.cs | 42 ++++++- .../SystemManagement/Menu/MenuService.cs | 6 +- .../SystemManagement/Notice/NoticeService.cs | 4 + .../SystemManagement/Role/RoleAppService.cs | 6 +- .../SupervisionStatisticsService.cs | 7 +- .../VipRule/VipRuleAppService.cs | 11 +- .../MDB_patch_add_row_version.sql" | 26 +++++ .../MDB_patch_add_row_version.sql" | 26 +++++ .../PGDB_patch_add_row_version.sql" | 23 ++++ 29 files changed, 502 insertions(+), 85 deletions(-) create mode 100644 EOM.TSHotelManagement.Contract/Common/Dto/BaseResponseFactory.cs create mode 100644 "\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" create mode 100644 "\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" create mode 100644 "\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" diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/BaseInputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/BaseInputDto.cs index 7362bb2..3356d22 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 db10398..0d262c4 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 0000000..fb8e787 --- /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.", + "Data has been modified by another user. Please refresh and retry.")); + } + } +} diff --git a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs index 653933d..a274270 100644 --- a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs +++ b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs @@ -81,6 +81,7 @@ namespace EOM.TSHotelManagement.Data .ToArray(); db.CodeFirst.InitTables(needCreateTableTypes); + EnsureRowVersionColumns(db, entityBuilder.EntityTypes, dbSettings.DbType); Console.WriteLine("Database schema initialized"); @@ -523,5 +524,75 @@ namespace EOM.TSHotelManagement.Data } } #endregion + + private void EnsureRowVersionColumns(ISqlSugarClient db, Type[] entityTypes, DbType dbType) + { + var tableInfoList = db.DbMaintenance.GetTableInfoList() + .ToDictionary(a => a.Name.Trim().ToLower(), a => a.Name, StringComparer.OrdinalIgnoreCase); + + foreach (var entityType in entityTypes) + { + if (!typeof(BaseEntity).IsAssignableFrom(entityType)) + { + continue; + } + + var tableAttr = entityType.GetCustomAttributes(typeof(SugarTable), true) + .FirstOrDefault() as SugarTable; + var tableName = tableAttr?.TableName ?? entityType.Name; + if (string.IsNullOrWhiteSpace(tableName)) + { + continue; + } + + if (!tableInfoList.TryGetValue(tableName.Trim().ToLower(), out var actualTableName)) + { + continue; + } + + var hasRowVersion = HasRowVersionColumn(db, actualTableName, dbType); + if (hasRowVersion) + { + continue; + } + + var escapedTableName = EscapeIdentifier(actualTableName, dbType); + if (dbType == DbType.PostgreSQL) + { + db.Ado.ExecuteCommand($"ALTER TABLE {escapedTableName} ADD COLUMN row_version BIGINT NOT NULL DEFAULT 1;"); + db.Ado.ExecuteCommand($"COMMENT ON COLUMN {escapedTableName}.row_version IS 'Row version (optimistic lock)';"); + } + else + { + db.Ado.ExecuteCommand($"ALTER TABLE {escapedTableName} ADD COLUMN row_version BIGINT NOT NULL DEFAULT 1 COMMENT 'Row version (optimistic lock)';"); + } + + Console.WriteLine($"Added row_version column to table: {actualTableName}"); + } + } + + private bool HasRowVersionColumn(ISqlSugarClient db, string tableName, DbType dbType) + { + var safeTableName = tableName.Replace("'", "''"); + string sql; + + if (dbType == DbType.PostgreSQL) + { + sql = $"SELECT COUNT(1) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = '{safeTableName}' AND column_name = 'row_version';"; + } + else + { + sql = $"SELECT COUNT(1) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = '{safeTableName}' AND column_name = 'row_version';"; + } + + return db.Ado.GetInt(sql) > 0; + } + + private static string EscapeIdentifier(string identifier, DbType dbType) + { + return dbType == DbType.PostgreSQL + ? $"\"{identifier.Replace("\"", "\"\"")}\"" + : $"`{identifier.Replace("`", "``")}`"; + } } } diff --git a/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs b/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs index 9a17c9d..f3a12de 100644 --- a/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs +++ b/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs @@ -48,12 +48,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 +65,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,11 +80,17 @@ namespace EOM.TSHotelManagement.Data .Select(it => it.PropertyName) .ToList(); + var updateable = base.Context.Updateable(entity) + .IgnoreColumns(true, false); + + if (rowVersionWhere != null) + { + updateable = updateable.Where(rowVersionWhere); + } + if (primaryKeys.Count <= 1) { - return base.Context.Updateable(entity) - .IgnoreColumns(true, false) - .ExecuteCommand() > 0; + return updateable.ExecuteCommand() > 0; } var idProperty = entity.GetType().GetProperty("Id"); @@ -83,37 +101,16 @@ namespace EOM.TSHotelManagement.Data if (idValue == 0) { var otherPrimaryKeys = primaryKeys.Where(pk => pk != "Id").ToList(); + var primaryKeyWhere = BuildPrimaryKeyWhereExpression(entity, otherPrimaryKeys); - 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) + if (primaryKeyWhere != null) { - var lambda = Expression.Lambda>(whereExpression, parameter); - - return base.Context.Updateable(entity) - .Where(lambda) - .IgnoreColumns(true, false) - .ExecuteCommand() > 0; + return updateable.Where(primaryKeyWhere).ExecuteCommand() > 0; } } } - return base.Context.Updateable(entity) - .IgnoreColumns(true, false) - .ExecuteCommand() > 0; + return updateable.ExecuteCommand() > 0; } public override bool UpdateRange(List updateObjs) @@ -225,5 +222,61 @@ namespace EOM.TSHotelManagement.Data .IgnoreColumns(ignoreAllNullColumns: true, false, true) .ExecuteCommand() > 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); + } } } diff --git a/EOM.TSHotelManagement.Domain/BaseEntity.cs b/EOM.TSHotelManagement.Domain/BaseEntity.cs index 221afec..68954f7 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.Service/Application/NavBar/NavBarService.cs b/EOM.TSHotelManagement.Service/Application/NavBar/NavBarService.cs index fc6990a..a166aec 100644 --- a/EOM.TSHotelManagement.Service/Application/NavBar/NavBarService.cs +++ b/EOM.TSHotelManagement.Service/Application/NavBar/NavBarService.cs @@ -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 { diff --git a/EOM.TSHotelManagement.Service/Business/Asset/AssetService.cs b/EOM.TSHotelManagement.Service/Business/Asset/AssetService.cs index f2f0390..85bde5c 100644 --- a/EOM.TSHotelManagement.Service/Business/Asset/AssetService.cs +++ b/EOM.TSHotelManagement.Service/Business/Asset/AssetService.cs @@ -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) diff --git a/EOM.TSHotelManagement.Service/Business/Customer/CustomerService.cs b/EOM.TSHotelManagement.Service/Business/Customer/CustomerService.cs index 0526a1b..9943ea6 100644 --- a/EOM.TSHotelManagement.Service/Business/Customer/CustomerService.cs +++ b/EOM.TSHotelManagement.Service/Business/Customer/CustomerService.cs @@ -177,7 +177,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) @@ -259,11 +259,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) { @@ -271,7 +274,7 @@ namespace EOM.TSHotelManagement.Service } else { - return new BaseResponse(BusinessStatusCode.InternalServerError, LocalizationHelper.GetLocalizedString("Update Customer Type Failed", "客户类型更新失败")); + return BaseResponseFactory.ConcurrencyConflict(); } } catch (Exception ex) diff --git a/EOM.TSHotelManagement.Service/Business/EnergyManagement/EnergyManagementService.cs b/EOM.TSHotelManagement.Service/Business/EnergyManagement/EnergyManagementService.cs index c51bdd8..26824a0 100644 --- a/EOM.TSHotelManagement.Service/Business/EnergyManagement/EnergyManagementService.cs +++ b/EOM.TSHotelManagement.Service/Business/EnergyManagement/EnergyManagementService.cs @@ -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) diff --git a/EOM.TSHotelManagement.Service/Business/News/NewsService.cs b/EOM.TSHotelManagement.Service/Business/News/NewsService.cs index a55b4bd..4b1649a 100644 --- a/EOM.TSHotelManagement.Service/Business/News/NewsService.cs +++ b/EOM.TSHotelManagement.Service/Business/News/NewsService.cs @@ -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) diff --git a/EOM.TSHotelManagement.Service/Business/PromotionContent/PromotionContentService.cs b/EOM.TSHotelManagement.Service/Business/PromotionContent/PromotionContentService.cs index 15a82d3..75b6e27 100644 --- a/EOM.TSHotelManagement.Service/Business/PromotionContent/PromotionContentService.cs +++ b/EOM.TSHotelManagement.Service/Business/PromotionContent/PromotionContentService.cs @@ -180,7 +180,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 5bea6b3..5bf9d84 100644 --- a/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs +++ b/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs @@ -282,16 +282,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 db32dec..a04b26d 100644 --- a/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs +++ b/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs @@ -342,7 +342,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) { @@ -365,7 +370,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) { @@ -515,7 +525,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) { @@ -565,7 +580,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) { @@ -689,14 +708,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) @@ -763,7 +790,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 @@ -847,7 +878,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); @@ -856,7 +887,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 7dffc1e..a57f814 100644 --- a/EOM.TSHotelManagement.Service/Business/Room/RoomTypeService.cs +++ b/EOM.TSHotelManagement.Service/Business/Room/RoomTypeService.cs @@ -143,7 +143,7 @@ namespace EOM.TSHotelManagement.Service { try { - roomTypeRepository.Update(new RoomType + var result = roomTypeRepository.Update(new RoomType { RoomTypeId = roomType.RoomTypeId, Id = roomType.Id ?? 0, @@ -152,8 +152,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) { diff --git a/EOM.TSHotelManagement.Service/Business/Sellthing/SellService.cs b/EOM.TSHotelManagement.Service/Business/Sellthing/SellService.cs index 18cc93a..fc90c2f 100644 --- a/EOM.TSHotelManagement.Service/Business/Sellthing/SellService.cs +++ b/EOM.TSHotelManagement.Service/Business/Sellthing/SellService.cs @@ -122,12 +122,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) { diff --git a/EOM.TSHotelManagement.Service/Business/Spend/SpendService.cs b/EOM.TSHotelManagement.Service/Business/Spend/SpendService.cs index 0971c75..b725f57 100644 --- a/EOM.TSHotelManagement.Service/Business/Spend/SpendService.cs +++ b/EOM.TSHotelManagement.Service/Business/Spend/SpendService.cs @@ -256,8 +256,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) { @@ -315,7 +324,7 @@ namespace EOM.TSHotelManagement.Service var result = spendRepository.Update(existingSpend); if (!result) { - return new BaseResponse() { Message = "更新消费记录失败", Code = BusinessStatusCode.InternalServerError }; + return BaseResponseFactory.ConcurrencyConflict(); } } else @@ -349,7 +358,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} 添加了消费记录: " + @@ -396,13 +405,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/Employee/EmployeeService.cs b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs index 73ed12a..c788778 100644 --- a/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs +++ b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs @@ -126,10 +126,23 @@ namespace EOM.TSHotelManagement.Service updateEmployeeInputDto.PhoneNumber = sourceTelStr; updateEmployeeInputDto.IdCardNumber = sourceIdStr; - var password = workerRepository.GetFirst(a => a.EmployeeId == updateEmployeeInputDto.EmployeeId).Password; - updateEmployeeInputDto.Password = password; + var dbEmployee = workerRepository.GetFirst(a => a.EmployeeId == updateEmployeeInputDto.EmployeeId); + if (dbEmployee == null) + { + return new BaseResponse + { + Message = LocalizationHelper.GetLocalizedString("This employee does not exists", "员工不存在"), + Code = BusinessStatusCode.NotFound + }; + } - workerRepository.Update(EntityMapper.Map(updateEmployeeInputDto)); + updateEmployeeInputDto.Password = dbEmployee.Password; + + var updateResult = workerRepository.Update(EntityMapper.Map(updateEmployeeInputDto)); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -149,10 +162,23 @@ namespace EOM.TSHotelManagement.Service { try { - workerRepository.Update(a => new Employee() + var employee = workerRepository.GetFirst(a => a.EmployeeId == updateEmployeeInputDto.EmployeeId); + if (employee == null) + { + return new BaseResponse + { + Message = LocalizationHelper.GetLocalizedString("This employee does not exists", "员工不存在"), + Code = BusinessStatusCode.NotFound + }; + } + + employee.IsEnable = updateEmployeeInputDto.IsEnable; + employee.RowVersion = updateEmployeeInputDto.RowVersion ?? 0; + var updateResult = workerRepository.Update(employee); + if (!updateResult) { - IsEnable = updateEmployeeInputDto.IsEnable, - }, a => a.EmployeeId == updateEmployeeInputDto.EmployeeId); + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -473,7 +499,11 @@ namespace EOM.TSHotelManagement.Service employee.Password = encrypted; employee.IsInitialize = 1; - workerRepository.Update(employee); + var updateResult = workerRepository.Update(employee); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { @@ -522,7 +552,11 @@ namespace EOM.TSHotelManagement.Service employee.Password = encrypted; - workerRepository.Update(employee); + var updateResult = workerRepository.Update(employee); + if (!updateResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { diff --git a/EOM.TSHotelManagement.Service/Employee/Photo/EmployeePhotoService.cs b/EOM.TSHotelManagement.Service/Employee/Photo/EmployeePhotoService.cs index 6eb1df1..72b5c2b 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/SystemManagement/Administrator/AdminService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs index 2b3cf5a..7e16de0 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs @@ -255,7 +255,7 @@ namespace EOM.TSHotelManagement.Service var result = adminRepository.Update(admin); if (!result) { - return new BaseResponse { Message = LocalizationHelper.GetLocalizedString("Update Administrator Failed", "更新管理员失败"), Code = BusinessStatusCode.InternalServerError }; + return BaseResponseFactory.ConcurrencyConflict(); } } catch (Exception ex) @@ -384,7 +384,11 @@ namespace EOM.TSHotelManagement.Service { try { - adminTypeRepository.Update(EntityMapper.Map(updateAdministratorTypeInputDto)); + var result = adminTypeRepository.Update(EntityMapper.Map(updateAdministratorTypeInputDto)); + if (!result) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } catch (Exception ex) { diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Base/BaseService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Base/BaseService.cs index ac72939..db70153 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Base/BaseService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Base/BaseService.cs @@ -345,7 +345,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) @@ -478,7 +482,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) @@ -606,7 +614,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) @@ -767,6 +779,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) @@ -913,7 +929,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) @@ -1061,6 +1081,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) @@ -1208,6 +1232,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) @@ -1341,7 +1369,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 5a84cae..ae6e527 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Menu/MenuService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Menu/MenuService.cs @@ -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) diff --git a/EOM.TSHotelManagement.Service/SystemManagement/Notice/NoticeService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Notice/NoticeService.cs index cee54cf..027dda4 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Notice/NoticeService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Notice/NoticeService.cs @@ -180,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/Role/RoleAppService.cs b/EOM.TSHotelManagement.Service/SystemManagement/Role/RoleAppService.cs index 29dc733..d3c875f 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Role/RoleAppService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Role/RoleAppService.cs @@ -128,7 +128,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 b99f26d..f6f814b 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/SupervisionStatistics/SupervisionStatisticsService.cs @@ -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) { diff --git a/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs b/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs index d932697..623b984 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/VipRule/VipRuleAppService.cs @@ -192,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/\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 0000000..71dcc22 --- /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,26 @@ +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; 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 0000000..71dcc22 --- /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,26 @@ +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; 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 0000000..e9f2076 --- /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 +$$; -- Gitee From 5f81dc1d42652476cd40f15deaeb6907cf826622 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sun, 15 Feb 2026 20:48:57 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=BB=9F=E4=B8=802FA?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81TOTP=E4=B8=8E?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=E7=A0=81=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现了员工/管理员/客户账号的两步验证(2FA),支持TOTP及恢复备用码登录与管理。包括数据库表TwoFactorAuth/TwoFactorRecoveryCode、核心工具类TwoFactorHelper、配置与服务注入、API接口、权限点等全套功能。登录时如用恢复码会自动邮件提醒。修正了部分注释乱码,优化了DTO结构和参数校验,完善了.gitignore及相关文档。 --- .gitignore | 5 +- .../.config/dotnet-tools.json | 12 - .../Customer/CustomerAccountController.cs | 65 ++ .../Employee/EmployeeController.cs | 72 +- .../Administrator/AdminController.cs | 70 ++ .../Extensions/AutofacConfigExtensions.cs | 1 + .../Extensions/ServiceExtensions.cs | 1 + .../appsettings.Services.json | 12 +- EOM.TSHotelManagement.API/appsettings.json | 51 +- .../Enums/TwoFactorUserType.cs | 9 + .../Helper/DataProtectionHelper.cs | 8 + .../Helper/EntityMapper.cs | 18 +- .../Helper/LocalizationHelper.cs | 12 +- .../Helper/TwoFactorHelper.cs | 356 ++++++++++ .../Templates/EmailTemplate.cs | 23 + .../NavBar/Dto/CreateNavBarInputDto.cs | 4 +- .../Dto/CustoType/CreateCustoTypeInputDto.cs | 6 +- .../ReadCustomerAccountInputDto.cs | 6 +- .../ReadCustomerAccountOutputDto.cs | 12 +- .../Dto/TwoFactor/TwoFactorCodeInputDto.cs | 13 + .../TwoFactorRecoveryCodesOutputDto.cs | 18 + .../Dto/TwoFactor/TwoFactorSetupOutputDto.cs | 38 ++ .../Dto/TwoFactor/TwoFactorStatusOutputDto.cs | 28 + .../Employee/Dto/Employee/EmployeeLoginDto.cs | 1 + .../Dto/Employee/ReadEmployeeOutputDto.cs | 6 + .../ReadAdministratorInputDto.cs | 1 + .../ReadAdministratorOutputDto.cs | 7 + .../Dto/Position/CreatePositionInputDto.cs | 4 +- .../DatabaseInitializer.cs | 60 +- .../SystemManagement/TwoFactorAuth.cs | 35 + .../SystemManagement/TwoFactorRecoveryCode.cs | 28 + .../Config/TwoFactorConfig.cs | 48 ++ .../Factory/TwoFactorConfigFactory.cs | 40 ++ .../EntityBuilder.cs | 33 +- .../Account/CustomerAccountService.cs | 153 ++++- .../Account/ICustomerAccountService.cs | 38 ++ .../Employee/EmployeeService.cs | 138 +++- .../Employee/IEmployeeService.cs | 40 +- .../Security/ITwoFactorAuthService.cs | 72 ++ .../Security/TwoFactorAuthService.cs | 634 ++++++++++++++++++ .../Administrator/AdminService.cs | 157 ++++- .../Administrator/IAdminService.cs | 40 +- README.en.md | 6 + README.md | 6 + version.txt | Bin 18 -> 10 bytes 45 files changed, 2273 insertions(+), 114 deletions(-) delete mode 100644 EOM.TSHotelManagement.API/.config/dotnet-tools.json create mode 100644 EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs create mode 100644 EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs create mode 100644 EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs create mode 100644 EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorRecoveryCodesOutputDto.cs create mode 100644 EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorSetupOutputDto.cs create mode 100644 EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorStatusOutputDto.cs create mode 100644 EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs create mode 100644 EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs create mode 100644 EOM.TSHotelManagement.Infrastructure/Config/TwoFactorConfig.cs create mode 100644 EOM.TSHotelManagement.Infrastructure/Factory/TwoFactorConfigFactory.cs create mode 100644 EOM.TSHotelManagement.Service/Security/ITwoFactorAuthService.cs create mode 100644 EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs diff --git a/.gitignore b/.gitignore index cc917a0..c502942 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,5 @@ FodyWeavers.xsd # JetBrains Rider .idea/ -*.sln.iml \ No newline at end of file +*.sln.iml +/version.txt diff --git a/EOM.TSHotelManagement.API/.config/dotnet-tools.json b/EOM.TSHotelManagement.API/.config/dotnet-tools.json deleted file mode 100644 index 43a4368..0000000 --- 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/Controllers/Business/Customer/CustomerAccountController.cs b/EOM.TSHotelManagement.API/Controllers/Business/Customer/CustomerAccountController.cs index 50fd840..15b12f0 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/Employee/EmployeeController.cs b/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs index 64a0bad..85007ae 100644 --- a/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs +++ b/EOM.TSHotelManagement.API/Controllers/Employee/EmployeeController.cs @@ -3,6 +3,7 @@ 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 { @@ -91,6 +92,64 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return workerService.EmployeeLogin(inputDto); } + /// + /// 获取当前员工账号的 2FA 状态 + /// + /// + [RequirePermission("staffmanagement.get2fa")] + [HttpGet] + public SingleOutputDto GetTwoFactorStatus() + { + return workerService.GetTwoFactorStatus(GetCurrentSerialNumber()); + } + + /// + /// 生成当前员工账号的 2FA 绑定信息 + /// + /// + [RequirePermission("staffmanagement.generate2fa")] + [HttpPost] + public SingleOutputDto GenerateTwoFactorSetup() + { + return workerService.GenerateTwoFactorSetup(GetCurrentSerialNumber()); + } + + /// + /// 启用当前员工账号 2FA + /// + /// + /// + [RequirePermission("staffmanagement.enable2fa")] + [HttpPost] + public SingleOutputDto EnableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return workerService.EnableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 关闭当前员工账号 2FA + /// + /// + /// + [RequirePermission("staffmanagement.disable2fa")] + [HttpPost] + public BaseResponse DisableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return workerService.DisableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 重置当前员工账号恢复备用码 + /// + /// + /// + [RequirePermission("staffmanagement.recovery2fa")] + [HttpPost] + public SingleOutputDto RegenerateTwoFactorRecoveryCodes([FromBody] TwoFactorCodeInputDto inputDto) + { + return workerService.RegenerateTwoFactorRecoveryCodes(GetCurrentSerialNumber(), inputDto); + } + /// /// 修改员工账号密码 /// @@ -113,5 +172,16 @@ namespace EOM.TSHotelManagement.WebApi.Controllers { 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/SystemManagement/Administrator/AdminController.cs b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs index 6965c36..15e0112 100644 --- a/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs +++ b/EOM.TSHotelManagement.API/Controllers/SystemManagement/Administrator/AdminController.cs @@ -4,6 +4,7 @@ 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,6 +40,64 @@ namespace EOM.TSHotelManagement.WebApi.Controllers return adminService.Login(admin); } + /// + /// 获取当前管理员账号的 2FA 状态 + /// + /// + [RequirePermission("system:admin:get2fa")] + [HttpGet] + public SingleOutputDto GetTwoFactorStatus() + { + return adminService.GetTwoFactorStatus(GetCurrentSerialNumber()); + } + + /// + /// 生成当前管理员账号的 2FA 绑定信息 + /// + /// + [RequirePermission("system:admin:generate2fa")] + [HttpPost] + public SingleOutputDto GenerateTwoFactorSetup() + { + return adminService.GenerateTwoFactorSetup(GetCurrentSerialNumber()); + } + + /// + /// 启用当前管理员账号 2FA + /// + /// + /// + [RequirePermission("system:admin:enable2fa")] + [HttpPost] + public SingleOutputDto EnableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return adminService.EnableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 关闭当前管理员账号 2FA + /// + /// + /// + [RequirePermission("system:admin:disable2fa")] + [HttpPost] + public BaseResponse DisableTwoFactor([FromBody] TwoFactorCodeInputDto inputDto) + { + return adminService.DisableTwoFactor(GetCurrentSerialNumber(), inputDto); + } + + /// + /// 重置当前管理员账号恢复备用码 + /// + /// + /// + [RequirePermission("system:admin:recovery2fa")] + [HttpPost] + public SingleOutputDto RegenerateTwoFactorRecoveryCodes([FromBody] TwoFactorCodeInputDto inputDto) + { + return adminService.RegenerateTwoFactorRecoveryCodes(GetCurrentSerialNumber(), inputDto); + } + /// /// 获取所有管理员列表 /// @@ -188,5 +247,16 @@ namespace EOM.TSHotelManagement.WebApi.Controllers { return adminService.ReadUserDirectPermissions(userNumber); } + + /// + /// 从当前登录上下文中读取账号序列号 + /// + /// + private string GetCurrentSerialNumber() + { + return User.FindFirstValue(ClaimTypes.SerialNumber) + ?? User.FindFirst("serialnumber")?.Value + ?? string.Empty; + } } } diff --git a/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs b/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs index 6c75f14..cc3ded2 100644 --- a/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/AutofacConfigExtensions.cs @@ -33,6 +33,7 @@ namespace EOM.TSHotelManagement.WebApi 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/ServiceExtensions.cs b/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs index 2e9b9da..a753ece 100644 --- a/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs +++ b/EOM.TSHotelManagement.API/Extensions/ServiceExtensions.cs @@ -131,6 +131,7 @@ namespace EOM.TSHotelManagement.WebApi services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.Configure(configuration.GetSection("CsrfToken")); services.AddSingleton(); diff --git a/EOM.TSHotelManagement.API/appsettings.Services.json b/EOM.TSHotelManagement.API/appsettings.Services.json index 7c2ba5c..98919c7 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 e1c9e6f..0db3279 100644 --- a/EOM.TSHotelManagement.API/appsettings.json +++ b/EOM.TSHotelManagement.API/appsettings.json @@ -1,52 +1,3 @@ { - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft": "Warning", - "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": "" - } + } diff --git a/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs new file mode 100644 index 0000000..42996b7 --- /dev/null +++ b/EOM.TSHotelManagement.Common/Enums/TwoFactorUserType.cs @@ -0,0 +1,9 @@ +namespace EOM.TSHotelManagement.Common +{ + public enum TwoFactorUserType + { + Employee = 1, + Administrator = 2, + Customer = 3 + } +} diff --git a/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs b/EOM.TSHotelManagement.Common/Helper/DataProtectionHelper.cs index 499a564..efbff01 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,11 @@ 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); } } diff --git a/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs b/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs index 3a464f8..08f9a86 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() @@ -70,7 +70,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ת + /// 智能类型转换 /// private static object SmartConvert(object value, Type targetType) { @@ -119,7 +119,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ǷΪСֵ + /// 检查是否为最小值 /// private static bool IsMinValue(object value) { @@ -133,7 +133,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// СֵתΪֵ + /// 将最小值转换为空值 /// private static object ConvertMinValueToNull(object value, Type targetType) { @@ -151,7 +151,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// DateOnly ת + /// 处理 DateOnly 类型转换 /// private static object HandleDateOnlyConversion(DateOnly dateOnly, Type targetType) { @@ -182,7 +182,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// DateTime ת + /// 处理 DateTime 类型转换 /// private static object HandleDateTimeConversion(DateTime dateTime, Type targetType) { @@ -213,7 +213,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ַת + /// 处理字符串日期转换 /// private static object HandleStringConversion(string dateString, Type targetType) { @@ -236,7 +236,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// жǷҪת + /// 判断是否需要类型转换 /// private static bool NeedConversion(Type sourceType, Type targetType) { @@ -249,7 +249,7 @@ namespace EOM.TSHotelManagement.Common } /// - /// ӳʵб + /// 映射实体列表 /// public static List MapList(List sourceList) where TDestination : new() diff --git a/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs b/EOM.TSHotelManagement.Common/Helper/LocalizationHelper.cs index 376da2a..ff2f82b 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/TwoFactorHelper.cs b/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs new file mode 100644 index 0000000..6e0cd1b --- /dev/null +++ b/EOM.TSHotelManagement.Common/Helper/TwoFactorHelper.cs @@ -0,0 +1,356 @@ +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); + var bytes = new byte[config.RecoveryCodeLength]; + + for (var i = 0; i < config.RecoveryCodeCount; i++) + { + RandomNumberGenerator.Fill(bytes); + var chars = new char[config.RecoveryCodeLength]; + for (var j = 0; j < bytes.Length; j++) + { + chars[j] = RecoveryCodeAlphabet[bytes[j] % RecoveryCodeAlphabet.Length]; + } + + var raw = new string(chars); + result.Add(FormatRecoveryCode(raw, config.RecoveryCodeGroupSize)); + } + + return result; + } + + /// + /// 生成恢复备用码盐值 + /// + /// + public string CreateRecoveryCodeSalt() + { + return Convert.ToHexString(RandomNumberGenerator.GetBytes(16)); + } + + /// + /// 对恢复备用码进行哈希 + /// + /// 备用码(可带分隔符) + /// 盐值 + /// + public string HashRecoveryCode(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)); + } + + /// + /// 校验恢复备用码 + /// + /// 备用码(可带分隔符) + /// 盐值 + /// 库内哈希 + /// + public bool VerifyRecoveryCode(string recoveryCode, string salt, string expectedHash) + { + if (string.IsNullOrWhiteSpace(expectedHash)) + { + return false; + } + + var currentHash = HashRecoveryCode(recoveryCode, salt); + if (string.IsNullOrWhiteSpace(currentHash)) + { + return false; + } + + return FixedTimeEquals(currentHash, 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) + { + 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)) + 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 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 b503a4a..5778df4 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 a856515..5874b2b 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 3c3c321..737cf68 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 74d08ca..8e82bcb 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 3e5b69b..13f30ab 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/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs b/EOM.TSHotelManagement.Contract/Common/Dto/TwoFactor/TwoFactorCodeInputDto.cs new file mode 100644 index 0000000..169ed6b --- /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 0000000..7b9e974 --- /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 0000000..1ef3711 --- /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 0000000..b83b49b --- /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 e679724..ec6edfa 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 7ec59a5..66b0108 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 8a5f0ec..86dd7f4 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 3bbfad6..6421a0c 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/Position/CreatePositionInputDto.cs b/EOM.TSHotelManagement.Contract/SystemManagement/Dto/Position/CreatePositionInputDto.cs index 4542ca2..f1c85c4 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 48455e1..8c73666 100644 --- a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs +++ b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs @@ -15,7 +15,9 @@ namespace EOM.TSHotelManagement.Data private readonly ISqlSugarClientConnector _connector; private readonly IConfiguration _configuration; private readonly string _initialAdminEncryptedPassword; + private readonly string _initialEmployeeEncryptedPassword; private const string AdminProtectorPurpose = "AdminInfoProtector"; + private const string EmployeeProtectorPurpose = "EmployeeInfoProtector"; public DatabaseInitializer( ISqlSugarClient client, @@ -29,6 +31,9 @@ namespace EOM.TSHotelManagement.Data _initialAdminEncryptedPassword = dataProtectionProvider .CreateProtector(AdminProtectorPurpose) .Protect("admin"); + _initialEmployeeEncryptedPassword = dataProtectionProvider + .CreateProtector(EmployeeProtectorPurpose) + .Protect("WK010"); } #region initlize database @@ -72,7 +77,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,6 +97,7 @@ namespace EOM.TSHotelManagement.Data .ToArray(); db.CodeFirst.InitTables(needCreateTableTypes); + EnsureTwoFactorForeignKeys(db, dbSettings.DbType); Console.WriteLine("Database schema initialized"); @@ -248,6 +254,58 @@ 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", "custoemr_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..."); diff --git a/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs new file mode 100644 index 0000000..53554a2 --- /dev/null +++ b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorAuth.cs @@ -0,0 +1,35 @@ +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->custoemr_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; } + } +} diff --git a/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs b/EOM.TSHotelManagement.Domain/SystemManagement/TwoFactorRecoveryCode.cs new file mode 100644 index 0000000..108125a --- /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/TwoFactorConfig.cs b/EOM.TSHotelManagement.Infrastructure/Config/TwoFactorConfig.cs new file mode 100644 index 0000000..02ea824 --- /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/TwoFactorConfigFactory.cs b/EOM.TSHotelManagement.Infrastructure/Factory/TwoFactorConfigFactory.cs new file mode 100644 index 0000000..7014471 --- /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 19fcea0..58c5066 100644 --- a/EOM.TSHotelManagement.Migration/EntityBuilder.cs +++ b/EOM.TSHotelManagement.Migration/EntityBuilder.cs @@ -4,9 +4,9 @@ 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; } @@ -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() @@ -585,7 +596,7 @@ namespace EOM.TSHotelManagement.Migration EmployeeId = "WK010", EmployeeName = "阿杰", DateOfBirth = DateOnly.FromDateTime(new DateTime(1999,7,20,0,0,0)), - Password="oi6+T4604MqlB/SWAvrJBQ==·?bdc^^ 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,41 @@ 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; + } + + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate( + customerName ?? "Customer", + account ?? string.Empty, + DateTime.Now); + mailHelper.SendMail(new List { emailAddress }, template.Subject, template.Body); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send recovery-code login alert email for customer {Account}.", account ?? string.Empty); + } + } + /// /// 注册 /// @@ -245,12 +330,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 107d876..eee0de8 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/Employee/EmployeeService.cs b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs index 987d7ea..9fd91fd 100644 --- a/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs +++ b/EOM.TSHotelManagement.Service/Employee/EmployeeService.cs @@ -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; /// @@ -439,14 +445,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 +511,42 @@ 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; + } + + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate( + employeeName ?? "Employee", + employeeId ?? string.Empty, + DateTime.Now); + mailHelper.SendMail(new List { emailAddress }, template.Subject, template.Body); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send recovery-code login alert email for employee {EmployeeId}.", employeeId ?? string.Empty); + } } /// @@ -582,5 +659,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 0caeb10..1069f90 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/Security/ITwoFactorAuthService.cs b/EOM.TSHotelManagement.Service/Security/ITwoFactorAuthService.cs new file mode 100644 index 0000000..bebd177 --- /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 0000000..852efe6 --- /dev/null +++ b/EOM.TSHotelManagement.Service/Security/TwoFactorAuthService.cs @@ -0,0 +1,634 @@ +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; + + var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); + if (!_twoFactorHelper.VerifyCode(secret, code)) + { + if (!TryConsumeRecoveryCode(auth.Id, code)) + { + return false; + } + + usedRecoveryCode = true; + } + + auth.LastVerifiedAt = DateTime.Now; + _twoFactorRepository.Update(auth); + return true; + } + + /// + /// 获取账号 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 + }; + 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; + _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, + ManualEntryKey = secret, + 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 + }; + } + + var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); + if (!_twoFactorHelper.VerifyCode(secret, verificationCode)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Unauthorized, + Message = LocalizationHelper.GetLocalizedString("Invalid 2FA code", "2FA验证码错误"), + Data = null + }; + } + + auth.IsEnabled = 1; + auth.EnabledAt = DateTime.Now; + auth.LastVerifiedAt = DateTime.Now; + _twoFactorRepository.Update(auth); + + // 启用时自动生成一组恢复备用码(仅保存哈希,明文只在本次响应返回) + 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; + _twoFactorRepository.Update(auth); + + 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); + auth.LastVerifiedAt = DateTime.Now; + _twoFactorRepository.Update(auth); + + 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; + } + + var secret = _dataProtectionHelper.SafeDecryptTwoFactorData(auth.SecretKey); + if (_twoFactorHelper.VerifyCode(secret, code)) + { + return true; + } + + return allowRecoveryCode && TryConsumeRecoveryCode(auth.Id, code); + } + + /// + /// 获取剩余可用恢复备用码数量 + /// + /// + /// + private int GetRemainingRecoveryCodeCount(int twoFactorAuthId) + { + return _recoveryCodeRepository + .GetList(a => a.TwoFactorAuthPk == twoFactorAuthId && a.IsDelete != 1 && a.IsUsed != 1) + .Count; + } + + /// + /// 清理指定 2FA 的全部恢复备用码(硬删除) + /// + /// + private void ClearRecoveryCodes(int twoFactorAuthId) + { + var existing = _recoveryCodeRepository + .GetList(a => a.TwoFactorAuthPk == twoFactorAuthId); + + if (existing.Count == 0) + { + return; + } + + _recoveryCodeRepository.Delete(existing); + } + + /// + /// 重新生成恢复备用码(会清理旧数据) + /// + /// + /// 新备用码明文(仅返回一次) + 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 1e43c01..91cdfa2 100644 --- a/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs +++ b/EOM.TSHotelManagement.Service/SystemManagement/Administrator/AdminService.cs @@ -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,7 +145,49 @@ 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; @@ -134,6 +198,12 @@ namespace EOM.TSHotelManagement.Service })); var source = EntityMapper.Map(existingAdmin); + source.RequiresTwoFactor = false; + source.UsedRecoveryCodeLogin = usedRecoveryCode; + if (usedRecoveryCode) + { + NotifyRecoveryCodeLoginByEmail(existingAdmin.Account, existingAdmin.Name); + } return new SingleOutputDto { @@ -141,6 +211,30 @@ 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; + } + + try + { + var template = EmailTemplate.GetTwoFactorRecoveryCodeLoginAlertTemplate(displayName ?? "Administrator", emailCandidate, DateTime.Now); + mailHelper.SendMail(new List { emailCandidate }, template.Subject, template.Body); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to send recovery-code login alert email for admin {AdminName}.", displayName ?? string.Empty); + } + } + /// /// 获取所有管理员列表 /// @@ -879,5 +973,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 ddb36d5..a88714a 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/README.en.md b/README.en.md index 0c69549..3b030c8 100644 --- a/README.en.md +++ b/README.en.md @@ -181,6 +181,12 @@ 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| > ⚠️ **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. diff --git a/README.md b/README.md index b18aed9..ce24979 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,12 @@ 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| > ⚠️ **安全提醒**:生产环境中请勿直接通过 `-e` 明文传入密码类参数,推荐使用 Docker Secrets 或环境变量注入工具(如 HashiCorp Vault)进行保护。 diff --git a/version.txt b/version.txt index f96eaa82dab2fdf85cb4dde18b9e2e6069c90a11..315de5b54a9165b27f0cb117fab775b21812a334 100644 GIT binary patch literal 10 RcmaFAd%uyMk)ELm7XTX+1DOB- literal 18 WcmezW&xk>f0fY@1Oc;0>xEKI0t^+y% -- Gitee From b74c04ebcc821e2e729da8f40e90a32fe3ba9b6d Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sun, 15 Feb 2026 21:06:41 +0800 Subject: [PATCH 3/7] update md. --- README.en.md | 7 +++++++ README.md | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/README.en.md b/README.en.md index 3b030c8..1f8b43f 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. diff --git a/README.md b/README.md index ce24979..0f034ff 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. 业务管理模块 - **房间管理**:支持房间状态(空房、已住、维修、脏房、预约)管理,入住、退房、换房,房间配置(类型、价格)。 - **客户管理**:客户档案管理,客户账号注册登录,会员类型管理。 -- Gitee From 70d3859db200adcb74b167ab3e75fcf84a1bd023 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sun, 15 Feb 2026 21:11:20 +0800 Subject: [PATCH 4/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20.editorconfig=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E4=BB=A5=E7=BB=9F=E4=B8=80=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9552eb6 --- /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 -- Gitee From 622dbbc3985494275dd75dca4011bad3e5a08fa2 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Mon, 16 Feb 2026 14:18:05 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Docker=20Compose=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=EF=BC=8C=E5=8C=85=E5=90=AB=20.env=20?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B=E6=96=87=E4=BB=B6=E5=92=8C=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=20README=20=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 57 ++++++++++++++++++++++++++++++++++++++++++++++ .gitignore | 1 + README.en.md | 43 ++++++++++++++++++---------------- README.md | 43 ++++++++++++++++++---------------- docker-compose.yml | 12 ++++++++++ 5 files changed, 118 insertions(+), 38 deletions(-) create mode 100644 .env.example create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ec34523 --- /dev/null +++ b/.env.example @@ -0,0 +1,57 @@ +# 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=Server=your_db_host;Database=tshoteldb;User=tshoteldb;Password=your_password; +MySqlConnectStr= +PgSqlConnectStr= +SqlServerConnectStr= + +# Security +Jwt__Key=replace_with_a_long_random_secret +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 + +# Mail service +Mail__Enabled=false +Mail__Host=smtp.example.com +Mail__UserName=admin@example.com +Mail__Password=replace_with_mail_password +Mail__Port=465 +Mail__EnableSsl=true +Mail__DisplayName=TSHotel + +# Redis +Redis__Enabled=false +Redis__ConnectionString=host:port,password=your_redis_password +Redis__DefaultDatabase=0 + +# Lsky (optional) +Lsky__Enabled=false +Lsky__BaseAddress= +Lsky__Email= +Lsky__Password= +Lsky__UploadApi= +Lsky__GetTokenApi= + +# Version display (optional) +SoftwareVersion=1.0.0 diff --git a/.gitignore b/.gitignore index c502942..e305eb0 100644 --- a/.gitignore +++ b/.gitignore @@ -380,6 +380,7 @@ FodyWeavers.xsd !.vscode/launch.json !.vscode/extensions.json *.code-workspace +.env # Local History for Visual Studio Code .history/ diff --git a/README.en.md b/README.en.md index 1f8b43f..84bc2da 100644 --- a/README.en.md +++ b/README.en.md @@ -132,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, 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 ``` diff --git a/README.md b/README.md index 0f034ff..3b13228 100644 --- a/README.md +++ b/README.md @@ -132,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 ``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d4e1792 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +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" + env_file: + - .env -- Gitee From c3dee151c73856cc1cf3d3de5b9db890cf11c322 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Mon, 16 Feb 2026 16:16:48 +0800 Subject: [PATCH 6/7] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B9=90=E8=A7=82?= =?UTF-8?q?=E9=94=81=E5=A4=84=E7=90=86=EF=BC=8C=E7=A1=AE=E4=BF=9D=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=93=8D=E4=BD=9C=E7=9A=84=E5=B9=B6=E5=8F=91=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E6=A3=80=E6=B5=8B=EF=BC=9B=E6=9B=B4=E6=96=B0=20README?= =?UTF-8?q?=20=E6=96=87=E6=A1=A3=E4=B8=AD=E7=9A=84=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 +- .../appsettings.Database.json | 4 +- .../Helper/EntityMapper.cs | 10 +- .../Common/Dto/BaseResponseFactory.cs | 2 +- .../DatabaseInitializer.cs | 180 +++++++++++++----- .../Repository/GenericRepository.cs | 23 +++ .../Business/Reser/ReserService.cs | 8 +- .../Business/Room/RoomService.cs | 13 +- README.md | 2 +- 9 files changed, 187 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index e305eb0..ba544e0 100644 --- a/.gitignore +++ b/.gitignore @@ -37,7 +37,7 @@ frontend/ .buildnumber .version *.txt -docs/ +docs/* # Visual Studio 2015/2017 cache/options directory .vs/ diff --git a/EOM.TSHotelManagement.API/appsettings.Database.json b/EOM.TSHotelManagement.API/appsettings.Database.json index 0c020cb..f4844c2 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.Common/Helper/EntityMapper.cs b/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs index 08f9a86..db5d868 100644 --- a/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs +++ b/EOM.TSHotelManagement.Common/Helper/EntityMapper.cs @@ -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) { @@ -257,4 +265,4 @@ namespace EOM.TSHotelManagement.Common return sourceList?.Select(Map).ToList(); } } -} \ No newline at end of file +} diff --git a/EOM.TSHotelManagement.Contract/Common/Dto/BaseResponseFactory.cs b/EOM.TSHotelManagement.Contract/Common/Dto/BaseResponseFactory.cs index fb8e787..52abea6 100644 --- a/EOM.TSHotelManagement.Contract/Common/Dto/BaseResponseFactory.cs +++ b/EOM.TSHotelManagement.Contract/Common/Dto/BaseResponseFactory.cs @@ -10,7 +10,7 @@ namespace EOM.TSHotelManagement.Contract BusinessStatusCode.Conflict, LocalizationHelper.GetLocalizedString( "Data has been modified by another user. Please refresh and retry.", - "Data has been modified by another user. Please refresh and retry.")); + "数据已被其他用户修改,请刷新后重试。")); } } } diff --git a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs index fcad729..ea464c6 100644 --- a/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs +++ b/EOM.TSHotelManagement.Data/DatabaseInitializer/DatabaseInitializer.cs @@ -6,6 +6,7 @@ using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Configuration; using MySqlConnector; using SqlSugar; +using System.Data.Common; namespace EOM.TSHotelManagement.Data { @@ -54,11 +55,14 @@ namespace EOM.TSHotelManagement.Data { if (dbSettings.DbType == DbType.PostgreSQL) { - var dbExists = masterDb.Ado.GetInt($"SELECT 1 FROM pg_database WHERE datname = '{dbSettings.Database}'") > 0; + var dbExists = masterDb.Ado.GetInt( + "SELECT 1 FROM pg_database WHERE datname = @dbName", + new SugarParameter("@dbName", dbSettings.Database)) > 0; if (!dbExists) { Console.WriteLine($"Creating database {dbSettings.Database}..."); - masterDb.Ado.ExecuteCommand($"CREATE DATABASE \"{dbSettings.Database}\""); + var escapedDbName = EscapeIdentifier(dbSettings.Database, dbSettings.DbType); + masterDb.Ado.ExecuteCommand($"CREATE DATABASE {escapedDbName}"); Console.WriteLine("Database created successfully"); } } @@ -97,6 +101,7 @@ namespace EOM.TSHotelManagement.Data .ToArray(); db.CodeFirst.InitTables(needCreateTableTypes); + EnsureRowVersionColumns(db, entityBuilder.EntityTypes, dbSettings.DbType); Console.WriteLine("Database schema initialized"); @@ -121,15 +126,22 @@ namespace EOM.TSHotelManagement.Data case DbType.MySqlConnector: var builder = new MySqlConnectionStringBuilder(connectionString); return (builder.Database, dbType); - //case DbType.SqlServer: //This project not include reference Package.Please manual install Microsoft.EntityFrameworkCore.SqlServer - // var sqlBuilder = new SqlConnectionStringBuilder(connectionString); - // return (sqlBuilder.InitialCatalog, dbType); - //case DbType.Oracle: //This project not include reference Package.Please manual install Oracle.ManagedDataAccess.Core - // var oracleBuilder = new OracleConnectionStringBuilder(connectionString); - // return (oracleBuilder.DataSource, dbType); - //case DbType.PostgreSQL: //This project not include reference Package.Please manual install Npgsql.EntityFrameworkCore.PostgreSQL - // var pgsqlBuilder = new NpgsqlConnectionStringBuilder(connectionString); - // return (pgsqlBuilder.Database, dbType); + case DbType.PostgreSQL: + var pgBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + var pgDatabase = GetConnectionStringValue(pgBuilder, "Database"); + if (string.IsNullOrWhiteSpace(pgDatabase)) + { + throw new ArgumentException("PostgreSQL connection string must contain Database."); + } + return (pgDatabase, dbType); + case DbType.SqlServer: + var sqlBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + var sqlDatabase = GetConnectionStringValue(sqlBuilder, "Initial Catalog", "Database"); + if (string.IsNullOrWhiteSpace(sqlDatabase)) + { + throw new ArgumentException("SqlServer connection string must contain Initial Catalog or Database."); + } + return (sqlDatabase, dbType); default: throw new NotSupportedException($"Unsupported DbType: {dbType}"); } @@ -171,43 +183,33 @@ namespace EOM.TSHotelManagement.Data { var connectionString = GetConnectionString(config, dbName); - dynamic builder = null; + DbConnectionStringBuilder? masterBuilder = null; switch (dbType) { case DbType.MySql: case DbType.MySqlConnector: - builder = new MySqlConnectionStringBuilder(connectionString) + masterBuilder = new MySqlConnectionStringBuilder(connectionString) { Database = null, }; break; - //case DbType.SqlServer: //This project not include reference Package.Please manual install Microsoft.EntityFrameworkCore.SqlServer - // builder = new SqlConnectionStringBuilder(connectionString) - // { - // InitialCatalog = "master", - // ConnectTimeout = 30 - // }; - // break; - //case DbType.Oracle: //This project not include reference Package.Please manual install Oracle.ManagedDataAccess.Core - // builder = new OracleConnectionStringBuilder(connectionString) - // { - // UserID = "sys as sysdba", - // }; - // break; - //case DbType.PostgreSQL: //This project not include reference Package.Please manual install Npgsql.EntityFrameworkCore.PostgreSQL - // builder = new NpgsqlConnectionStringBuilder(connectionString) - // { - // Database = "postgres", - // Timeout = 30, - // Pooling = false - // }; - // break; + case DbType.PostgreSQL: + masterBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + SetConnectionStringValue(masterBuilder, "Database", "postgres"); + break; + case DbType.SqlServer: + masterBuilder = new DbConnectionStringBuilder { ConnectionString = connectionString }; + SetConnectionStringValue(masterBuilder, "Initial Catalog", "master"); + SetConnectionStringValue(masterBuilder, "Database", "master"); + break; + default: + throw new NotSupportedException($"Master connection is not supported for DbType: {dbType}"); } return new SqlSugarClient(new ConnectionConfig { - ConnectionString = builder?.ConnectionString ?? string.Empty, + ConnectionString = masterBuilder.ConnectionString, DbType = dbType, IsAutoCloseConnection = true, ConfigureExternalServices = new ConfigureExternalServices @@ -295,12 +297,19 @@ namespace EOM.TSHotelManagement.Data return; } - var addConstraintSql = $@"ALTER TABLE `{tableName}` - ADD CONSTRAINT `{constraintName}` - FOREIGN KEY (`{columnName}`) - REFERENCES `{referenceTable}`(`{referenceColumn}`) + var escapedTableName = EscapeMySqlIdentifier(tableName); + var escapedConstraintName = EscapeMySqlIdentifier(constraintName); + var escapedColumnName = EscapeMySqlIdentifier(columnName); + var escapedReferenceTable = EscapeMySqlIdentifier(referenceTable); + var escapedReferenceColumn = EscapeMySqlIdentifier(referenceColumn); + var normalizedOnDeleteAction = NormalizeOnDeleteAction(onDeleteAction); + + var addConstraintSql = $@"ALTER TABLE {escapedTableName} + ADD CONSTRAINT {escapedConstraintName} + FOREIGN KEY ({escapedColumnName}) + REFERENCES {escapedReferenceTable}({escapedReferenceColumn}) ON UPDATE RESTRICT - ON DELETE {onDeleteAction};"; + ON DELETE {normalizedOnDeleteAction};"; db.Ado.ExecuteCommand(addConstraintSql); } @@ -629,10 +638,14 @@ namespace EOM.TSHotelManagement.Data db.Ado.ExecuteCommand($"ALTER TABLE {escapedTableName} ADD COLUMN row_version BIGINT NOT NULL DEFAULT 1;"); db.Ado.ExecuteCommand($"COMMENT ON COLUMN {escapedTableName}.row_version IS 'Row version (optimistic lock)';"); } - else + else if (dbType is DbType.MySql or DbType.MySqlConnector) { db.Ado.ExecuteCommand($"ALTER TABLE {escapedTableName} ADD COLUMN row_version BIGINT NOT NULL DEFAULT 1 COMMENT 'Row version (optimistic lock)';"); } + else + { + db.Ado.ExecuteCommand($"ALTER TABLE {escapedTableName} ADD COLUMN row_version BIGINT NOT NULL DEFAULT 1;"); + } Console.WriteLine($"Added row_version column to table: {actualTableName}"); } @@ -640,26 +653,97 @@ namespace EOM.TSHotelManagement.Data private bool HasRowVersionColumn(ISqlSugarClient db, string tableName, DbType dbType) { - var safeTableName = tableName.Replace("'", "''"); string sql; if (dbType == DbType.PostgreSQL) { - sql = $"SELECT COUNT(1) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = '{safeTableName}' AND column_name = 'row_version';"; + sql = "SELECT COUNT(1) FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = @tableName AND column_name = 'row_version';"; + } + else if (dbType is DbType.MySql or DbType.MySqlConnector) + { + sql = "SELECT COUNT(1) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = @tableName AND column_name = 'row_version';"; + } + else if (dbType == DbType.SqlServer) + { + sql = "SELECT COUNT(1) FROM information_schema.columns WHERE table_catalog = DB_NAME() AND table_name = @tableName AND column_name = 'row_version';"; } else { - sql = $"SELECT COUNT(1) FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = '{safeTableName}' AND column_name = 'row_version';"; + sql = "SELECT COUNT(1) FROM information_schema.columns WHERE table_name = @tableName AND column_name = 'row_version';"; } - return db.Ado.GetInt(sql) > 0; + return db.Ado.GetInt(sql, new SugarParameter("@tableName", tableName)) > 0; } private static string EscapeIdentifier(string identifier, DbType dbType) { - return dbType == DbType.PostgreSQL - ? $"\"{identifier.Replace("\"", "\"\"")}\"" - : $"`{identifier.Replace("`", "``")}`"; + return dbType switch + { + DbType.PostgreSQL => $"\"{identifier.Replace("\"", "\"\"")}\"", + DbType.SqlServer => $"[{identifier.Replace("]", "]]")}]", + _ => $"`{identifier.Replace("`", "``")}`" + }; + } + + private static string EscapeMySqlIdentifier(string identifier) + { + return $"`{identifier.Replace("`", "``")}`"; + } + + private static string NormalizeOnDeleteAction(string onDeleteAction) + { + return onDeleteAction?.Trim().ToUpperInvariant() switch + { + "CASCADE" => "CASCADE", + "SET NULL" => "SET NULL", + "RESTRICT" => "RESTRICT", + "NO ACTION" => "NO ACTION", + _ => "SET NULL" + }; + } + + private static string GetConnectionStringValue(DbConnectionStringBuilder builder, params string[] keys) + { + foreach (var key in keys) + { + if (TryGetConnectionStringKey(builder, key, out var matchedKey)) + { + var value = builder[matchedKey]?.ToString(); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + } + + return string.Empty; + } + + private static void SetConnectionStringValue(DbConnectionStringBuilder builder, string key, object value) + { + if (TryGetConnectionStringKey(builder, key, out var matchedKey)) + { + builder[matchedKey] = value; + return; + } + + builder[key] = value; + } + + private static bool TryGetConnectionStringKey(DbConnectionStringBuilder builder, string key, out string matchedKey) + { + foreach (var existingKey in builder.Keys) + { + var existingKeyString = existingKey?.ToString(); + if (string.Equals(existingKeyString, key, StringComparison.OrdinalIgnoreCase)) + { + matchedKey = existingKeyString!; + return true; + } + } + + matchedKey = key; + return false; } } } diff --git a/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs b/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs index f1c0fee..c3e7250 100644 --- a/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs +++ b/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs @@ -115,6 +115,11 @@ namespace EOM.TSHotelManagement.Data public override bool UpdateRange(List updateObjs) { + if (updateObjs == null || updateObjs.Count == 0) + { + return false; + } + foreach (var entity in updateObjs) { if (entity is BaseEntity baseEntity) @@ -127,6 +132,24 @@ 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)) + { + throw new InvalidOperationException("Optimistic concurrency check failed."); + } + } + }); + + return tranResult.IsSuccess; + } + return base.Context.Updateable(updateObjs) .IgnoreColumns(ignoreAllNullColumns: true) .ExecuteCommand() > 0; diff --git a/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs b/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs index 261629d..25edada 100644 --- a/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs +++ b/EOM.TSHotelManagement.Service/Business/Reser/ReserService.cs @@ -238,7 +238,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", "预约信息删除成功")); } diff --git a/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs b/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs index d5cec08..c7c1f4e 100644 --- a/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs +++ b/EOM.TSHotelManagement.Service/Business/Room/RoomService.cs @@ -737,9 +737,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(); + } } //添加旧房间消费记录 @@ -826,7 +831,11 @@ namespace EOM.TSHotelManagement.Service spends.Add(spend); } - spendRepository.UpdateRange(spends); + var settleSpendResult = spendRepository.UpdateRange(spends); + if (!settleSpendResult) + { + return BaseResponseFactory.ConcurrencyConflict(); + } } scope.Complete(); diff --git a/README.md b/README.md index 3b13228..966ecb4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

组织logo.png

+

组织logo.png

TopskyHotelManagementSystem-WebApi

star -- Gitee From d249478d8fd2b54873539980f792a5f7f824f671 Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Mon, 16 Feb 2026 17:19:37 +0800 Subject: [PATCH 7/7] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=94=B9=E8=BF=9B?= =?UTF-8?q?=E9=A1=B9=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Repository/GenericRepository.cs | 9 +++++++-- docker-compose.yml | 5 +++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs b/EOM.TSHotelManagement.Data/Repository/GenericRepository.cs index c3e7250..2c06278 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() @@ -142,6 +146,7 @@ namespace EOM.TSHotelManagement.Data { 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."); } } diff --git a/docker-compose.yml b/docker-compose.yml index d4e1792..004c1b6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,5 +8,10 @@ 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 -- Gitee