From aad39cc70403727462b5b747e0f25c29a141881c Mon Sep 17 00:00:00 2001 From: ck_yeun9 Date: Sat, 14 Mar 2026 15:04:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E8=97=8F=E5=A4=B9=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=B9=90=E8=A7=82=E9=94=81=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=BC=BA=E5=9B=BE=E7=89=87=E4=B8=8A=E4=BC=A0=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 收藏夹DTO与服务增加RowVersion字段,实现乐观锁并发控制,防止快照被覆盖,冲突时返回Conflict状态码及提示。 - 优化用户身份校验,防止越权操作,统一从Claims解析当前用户。 - 上传头像时检测图片真实类型,仅允许PNG/JPEG,防止伪造Content-Type上传非图片文件。 - 移除冗余日志与参数,提升代码可读性和健壮性,增加内部record和枚举优化快照保存逻辑。 --- .../Dto/ReadFavoriteCollectionOutputDto.cs | 7 +- .../Dto/SaveFavoriteCollectionInputDto.cs | 7 +- .../Dto/SaveFavoriteCollectionOutputDto.cs | 7 +- .../FavoriteCollectionService.cs | 173 +++++++++++++----- .../Application/Profile/ProfileService.cs | 87 ++++++++- 5 files changed, 226 insertions(+), 55 deletions(-) diff --git a/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/ReadFavoriteCollectionOutputDto.cs b/EOM.TSHotelManagement.Contract/Application/FavoriteCollection/Dto/ReadFavoriteCollectionOutputDto.cs index faa23de..36e670c 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 75fe7c4..55ec5b6 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 830f7f2..eaceb54 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 561bba6..b78b673 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 7fd4e4f..fdcb290 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 -- Gitee