diff --git a/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/ReadFavoriteCollectionOutputDto.cs b/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/ReadFavoriteCollectionOutputDto.cs index faa23de84206148ff0b7ec5dcdcd3df199e903cd..36e670cce75d76ba474204b721137cb6c7edd7b8 100644 --- a/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/ReadFavoriteCollectionOutputDto.cs +++ b/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/ReadFavoriteCollectionOutputDto.cs @@ -14,5 +14,10 @@ namespace EOM.TSHotelManagement.Contract /// 收藏夹最后更新时间 /// public DateTime? UpdatedAt { get; set; } + + /// + /// 当前快照版本号 + /// + public long? RowVersion { get; set; } } -} +} \ No newline at end of file diff --git a/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/SaveFavoriteCollectionInputDto.cs b/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/SaveFavoriteCollectionInputDto.cs index 75fe7c402b50ad8f41d337519ed0d6068210c54c..55ec5b6c8307e7dad801744092142b78ce091054 100644 --- a/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/SaveFavoriteCollectionInputDto.cs +++ b/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/SaveFavoriteCollectionInputDto.cs @@ -7,6 +7,11 @@ namespace EOM.TSHotelManagement.Contract /// public class SaveFavoriteCollectionInputDto { + /// + /// 乐观锁版本号,更新已有快照时必填 + /// + public long? RowVersion { get; set; } + /// /// 登录类型,前端可能传 admin 或 employee /// @@ -36,4 +41,4 @@ namespace EOM.TSHotelManagement.Contract [MaxLength(32, ErrorMessage = "TriggeredBy length cannot exceed 32 characters.")] public string? TriggeredBy { get; set; } } -} +} \ No newline at end of file diff --git a/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/SaveFavoriteCollectionOutputDto.cs b/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/SaveFavoriteCollectionOutputDto.cs index 830f7f25929a13d08f7bd48bc225e968f63ab458..eaceb54edddc780725db26eeef037c438fe99c6e 100644 --- a/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/SaveFavoriteCollectionOutputDto.cs +++ b/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/SaveFavoriteCollectionOutputDto.cs @@ -19,5 +19,10 @@ namespace EOM.TSHotelManagement.Contract /// 最终生效的更新时间 /// public DateTime UpdatedAt { get; set; } + + /// + /// 保存成功后的最新版本号 + /// + public long RowVersion { get; set; } } -} +} \ No newline at end of file diff --git a/EOM.TSHotelManagement.Service/Application/FavoriteCollection/FavoriteCollectionService.cs b/EOM.TSHotelManagement.Service/Application/FavoriteCollection/FavoriteCollectionService.cs index 561bba63c44657234e55e7cba15b93c730bf18e1..b78b673fdb337fcafb8159bdb44b8297404b58da 100644 --- a/EOM.TSHotelManagement.Service/Application/FavoriteCollection/FavoriteCollectionService.cs +++ b/EOM.TSHotelManagement.Service/Application/FavoriteCollection/FavoriteCollectionService.cs @@ -55,7 +55,7 @@ namespace EOM.TSHotelManagement.Service try { - var currentUser = ResolveCurrentUser(input); + var currentUser = ResolveCurrentUser(); if (currentUser == null) { return new SingleOutputDto @@ -66,15 +66,30 @@ namespace EOM.TSHotelManagement.Service }; } - LogIdentityMismatchIfNeeded(currentUser, input); + if (!TryValidateRequestedIdentity(currentUser, input, out var forbiddenResponse)) + { + return forbiddenResponse!; + } var normalizedRoutes = NormalizeRoutes(input.FavoriteRoutes); var normalizedUpdatedAt = NormalizeUpdatedAt(input.UpdatedAt); var normalizedTriggeredBy = NormalizeText(input.TriggeredBy, 32); var favoriteRoutesJson = JsonSerializer.Serialize(normalizedRoutes, JsonSerializerOptions); + var saveResult = TrySaveSnapshot(currentUser, input.RowVersion, favoriteRoutesJson, normalizedRoutes.Count, normalizedUpdatedAt, normalizedTriggeredBy); + + if (saveResult.Outcome == SaveSnapshotOutcome.Conflict) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.Conflict, + Message = LocalizationHelper.GetLocalizedString( + "Data has been modified by another user. Please refresh and retry.", + "数据已被其他用户修改,请刷新后重试。"), + Data = null + }; + } - var saveSuccess = TrySaveSnapshot(currentUser, favoriteRoutesJson, normalizedRoutes.Count, normalizedUpdatedAt, normalizedTriggeredBy); - if (!saveSuccess) + if (saveResult.Outcome == SaveSnapshotOutcome.Failed) { return new SingleOutputDto { @@ -91,7 +106,8 @@ namespace EOM.TSHotelManagement.Service { Saved = true, RouteCount = normalizedRoutes.Count, - UpdatedAt = normalizedUpdatedAt + UpdatedAt = normalizedUpdatedAt, + RowVersion = saveResult.RowVersion } }; } @@ -142,7 +158,8 @@ namespace EOM.TSHotelManagement.Service Data = new ReadFavoriteCollectionOutputDto { FavoriteRoutes = DeserializeRoutes(collection.FavoriteRoutesJson), - UpdatedAt = DateTime.SpecifyKind(collection.UpdatedAt, DateTimeKind.Utc) + UpdatedAt = DateTime.SpecifyKind(collection.UpdatedAt, DateTimeKind.Utc), + RowVersion = collection.RowVersion } }; } @@ -158,21 +175,31 @@ namespace EOM.TSHotelManagement.Service } } - private bool TrySaveSnapshot( + private SaveSnapshotResult TrySaveSnapshot( CurrentUserSnapshot currentUser, + long? rowVersion, string favoriteRoutesJson, int routeCount, DateTime updatedAt, string? triggeredBy) { - const int maxRetryCount = 3; + const int maxInsertRetryCount = 2; - for (var attempt = 1; attempt <= maxRetryCount; attempt++) + for (var attempt = 1; attempt <= maxInsertRetryCount; attempt++) { var existing = _favoriteCollectionRepository.GetFirst(x => x.UserNumber == currentUser.UserNumber); if (existing == null) { + if (rowVersion.HasValue && rowVersion.Value > 0) + { + _logger.LogWarning( + "Favorite collection insert rejected because client provided stale row version {RowVersion} for user {UserNumber}.", + rowVersion.Value, + currentUser.UserNumber); + return new SaveSnapshotResult(SaveSnapshotOutcome.Conflict); + } + var entity = new UserFavoriteCollection { UserNumber = currentUser.UserNumber, @@ -188,17 +215,31 @@ namespace EOM.TSHotelManagement.Service { if (_favoriteCollectionRepository.Insert(entity)) { - return true; + return new SaveSnapshotResult(SaveSnapshotOutcome.Saved, entity.RowVersion); } } catch (Exception ex) { - _logger.LogWarning(ex, "Insert favorite collection snapshot failed on attempt {Attempt} for user {UserNumber}.", attempt, currentUser.UserNumber); + _logger.LogWarning( + ex, + "Insert favorite collection snapshot failed on attempt {Attempt} for user {UserNumber}.", + attempt, + currentUser.UserNumber); } continue; } + if (!rowVersion.HasValue || rowVersion.Value <= 0) + { + _logger.LogWarning( + "Favorite collection update rejected because row version is missing for user {UserNumber}. CurrentRowVersion={CurrentRowVersion}.", + currentUser.UserNumber, + existing.RowVersion); + return new SaveSnapshotResult(SaveSnapshotOutcome.Conflict); + } + + existing.RowVersion = rowVersion.Value; existing.LoginType = currentUser.LoginType; existing.Account = currentUser.Account; existing.FavoriteRoutesJson = favoriteRoutesJson; @@ -208,16 +249,73 @@ namespace EOM.TSHotelManagement.Service if (_favoriteCollectionRepository.Update(existing)) { - return true; + return new SaveSnapshotResult(SaveSnapshotOutcome.Saved, existing.RowVersion); } - _logger.LogWarning("Favorite collection update hit a concurrency conflict on attempt {Attempt} for user {UserNumber}.", attempt, currentUser.UserNumber); + _logger.LogWarning( + "Favorite collection update hit a concurrency conflict for user {UserNumber}. ExpectedRowVersion={ExpectedRowVersion}.", + currentUser.UserNumber, + rowVersion.Value); + return new SaveSnapshotResult(SaveSnapshotOutcome.Conflict); } - return false; + return new SaveSnapshotResult(SaveSnapshotOutcome.Failed); } - private CurrentUserSnapshot? ResolveCurrentUser(SaveFavoriteCollectionInputDto? input = null) + private bool TryValidateRequestedIdentity( + CurrentUserSnapshot currentUser, + SaveFavoriteCollectionInputDto input, + out SingleOutputDto? forbiddenResponse) + { + forbiddenResponse = null; + + var requestAccount = NormalizeText(input.Account, 128); + if (!string.IsNullOrWhiteSpace(requestAccount) && !IsCurrentAccount(currentUser, requestAccount)) + { + _logger.LogWarning( + "Favorite collection request account mismatch. UserNumber={UserNumber}, RequestAccount={RequestAccount}, ResolvedAccount={ResolvedAccount}.", + currentUser.UserNumber, + requestAccount, + currentUser.Account); + + forbiddenResponse = new SingleOutputDto + { + Code = BusinessStatusCode.Forbidden, + Message = LocalizationHelper.GetLocalizedString( + "Requested identity does not match current user.", + "请求身份与当前登录用户不一致。"), + Data = null + }; + + return false; + } + + var requestLoginType = NormalizeText(input.LoginType, 32); + if (!string.IsNullOrWhiteSpace(requestLoginType) + && !string.Equals(requestLoginType, currentUser.LoginType, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Favorite collection request login type mismatch. UserNumber={UserNumber}, RequestLoginType={RequestLoginType}, ResolvedLoginType={ResolvedLoginType}.", + currentUser.UserNumber, + requestLoginType, + currentUser.LoginType); + + forbiddenResponse = new SingleOutputDto + { + Code = BusinessStatusCode.Forbidden, + Message = LocalizationHelper.GetLocalizedString( + "Requested identity does not match current user.", + "请求身份与当前登录用户不一致。"), + Data = null + }; + + return false; + } + + return true; + } + + private CurrentUserSnapshot? ResolveCurrentUser() { var principal = _httpContextAccessor.HttpContext?.User; var userNumber = principal?.FindFirst(ClaimTypes.SerialNumber)?.Value @@ -241,37 +339,20 @@ namespace EOM.TSHotelManagement.Service return new CurrentUserSnapshot(userNumber, "employee", employee.EmployeeId); } - var account = principal?.FindFirst("account")?.Value; - var loginType = NormalizeText(input?.LoginType, 32) ?? "unknown"; - var fallbackAccount = NormalizeText(account, 128) ?? NormalizeText(input?.Account, 128); + var fallbackAccount = NormalizeText( + principal?.FindFirst("account")?.Value ?? principal?.Identity?.Name, + 128); + var loginType = NormalizeText( + principal?.FindFirst("login_type")?.Value ?? principal?.FindFirst("logintype")?.Value, + 32) ?? "unknown"; return new CurrentUserSnapshot(userNumber, loginType, fallbackAccount); } - private void LogIdentityMismatchIfNeeded(CurrentUserSnapshot currentUser, SaveFavoriteCollectionInputDto input) + private static bool IsCurrentAccount(CurrentUserSnapshot currentUser, string requestAccount) { - var requestAccount = NormalizeText(input.Account, 128); - var requestLoginType = NormalizeText(input.LoginType, 32); - - if (!string.IsNullOrWhiteSpace(requestAccount) - && !string.Equals(requestAccount, currentUser.Account, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation( - "Favorite collection request account mismatch. UserNumber={UserNumber}, RequestAccount={RequestAccount}, ResolvedAccount={ResolvedAccount}.", - currentUser.UserNumber, - requestAccount, - currentUser.Account); - } - - if (!string.IsNullOrWhiteSpace(requestLoginType) - && !string.Equals(requestLoginType, currentUser.LoginType, StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation( - "Favorite collection request login type mismatch. UserNumber={UserNumber}, RequestLoginType={RequestLoginType}, ResolvedLoginType={ResolvedLoginType}.", - currentUser.UserNumber, - requestLoginType, - currentUser.LoginType); - } + return string.Equals(requestAccount, currentUser.Account, StringComparison.OrdinalIgnoreCase) + || string.Equals(requestAccount, currentUser.UserNumber, StringComparison.OrdinalIgnoreCase); } private static List NormalizeRoutes(IEnumerable? routes) @@ -340,5 +421,13 @@ namespace EOM.TSHotelManagement.Service } private sealed record CurrentUserSnapshot(string UserNumber, string LoginType, string? Account); + private sealed record SaveSnapshotResult(SaveSnapshotOutcome Outcome, long RowVersion = 0); + + private enum SaveSnapshotOutcome + { + Saved, + Conflict, + Failed + } } -} +} \ No newline at end of file diff --git a/EOM.TSHotelManagement.Service/Application/Profile/ProfileService.cs b/EOM.TSHotelManagement.Service/Application/Profile/ProfileService.cs index 7fd4e4f84abfb33071619684f9a6375572552787..fdcb290bbb966a90404e0f1917f11fca2472ade9 100644 --- a/EOM.TSHotelManagement.Service/Application/Profile/ProfileService.cs +++ b/EOM.TSHotelManagement.Service/Application/Profile/ProfileService.cs @@ -342,6 +342,25 @@ namespace EOM.TSHotelManagement.Service }; } + using var stream = file.OpenReadStream(); + if (!TryDetectImageContentType(stream, out var detectedContentType)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("Invalid image format", "图片格式不正确") + }; + } + + if (!IsAllowedDeclaredImageContentType(file.ContentType, detectedContentType)) + { + return new SingleOutputDto + { + Code = BusinessStatusCode.BadRequest, + Message = LocalizationHelper.GetLocalizedString("Invalid image format", "图片格式不正确") + }; + } + var token = _lskyHelper.GetImageStorageTokenAsync().Result; if (string.IsNullOrWhiteSpace(token)) { @@ -352,12 +371,15 @@ namespace EOM.TSHotelManagement.Service }; } - using var stream = file.OpenReadStream(); - stream.Seek(0, SeekOrigin.Begin); + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + var imageUrl = _lskyHelper.UploadImageAsync( fileStream: stream, fileName: file.FileName, - contentType: file.ContentType, + contentType: detectedContentType, token: token).Result; if (string.IsNullOrWhiteSpace(imageUrl)) @@ -490,6 +512,57 @@ namespace EOM.TSHotelManagement.Service } } + private static bool IsAllowedDeclaredImageContentType(string? declaredContentType, string detectedContentType) + { + if (string.IsNullOrWhiteSpace(declaredContentType)) + { + return true; + } + + return string.Equals(declaredContentType, detectedContentType, StringComparison.OrdinalIgnoreCase); + } + + private static bool TryDetectImageContentType(Stream stream, out string detectedContentType) + { + detectedContentType = string.Empty; + if (stream == null || !stream.CanRead) + { + return false; + } + + var header = new byte[8]; + var bytesRead = stream.Read(header, 0, header.Length); + if (stream.CanSeek) + { + stream.Seek(0, SeekOrigin.Begin); + } + + if (bytesRead >= 8 + && header[0] == 0x89 + && header[1] == 0x50 + && header[2] == 0x4E + && header[3] == 0x47 + && header[4] == 0x0D + && header[5] == 0x0A + && header[6] == 0x1A + && header[7] == 0x0A) + { + detectedContentType = "image/png"; + return true; + } + + if (bytesRead >= 3 + && header[0] == 0xFF + && header[1] == 0xD8 + && header[2] == 0xFF) + { + detectedContentType = "image/jpeg"; + return true; + } + + return false; + } + private static string FirstNonEmpty(string? primary, string? fallback) { if (!string.IsNullOrWhiteSpace(primary)) @@ -508,18 +581,12 @@ namespace EOM.TSHotelManagement.Service private sealed class CurrentUserProfile { public string LoginType { get; set; } - public string UserNumber { get; set; } - public string Account { get; set; } - public string DisplayName { get; set; } - public string PhotoUrl { get; set; } - public Administrator Admin { get; set; } - public Employee Employee { get; set; } } } -} +} \ No newline at end of file