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