diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs index ac6e44ed6c..35c62a626f 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ModelBinding/AbpDateTimeModelBinder.cs @@ -42,14 +42,14 @@ public class AbpDateTimeModelBinder : IModelBinder _clock.SupportsMultipleTimezone && !_currentTimezoneProvider.TimeZone.IsNullOrWhiteSpace()) { + var timeZone = _currentTimezoneProvider.TimeZone; try { - var timezoneInfo = _timezoneProvider.GetTimeZoneInfo(_currentTimezoneProvider.TimeZone); - dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime; + dateTime = _timezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone); } catch { - _logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", _currentTimezoneProvider.TimeZone); + _logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone); } } diff --git a/framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs b/framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs index 55c270d14e..3b278d5252 100644 --- a/framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs +++ b/framework/src/Volo.Abp.Json.Newtonsoft/Volo/Abp/Json/Newtonsoft/AbpDateTimeConverter.cs @@ -134,14 +134,14 @@ public class AbpDateTimeConverter : DateTimeConverterBase, ITransientDependency return _skipDateTimeNormalization ? dateTime : _clock.Normalize(dateTime); } + var timeZone = _currentTimezoneProvider.TimeZone; try { - var timezoneInfo = _timezoneProvider.GetTimeZoneInfo(_currentTimezoneProvider.TimeZone); - dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime; + dateTime = _timezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone); } catch { - Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", _currentTimezoneProvider.TimeZone); + Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone); } return _skipDateTimeNormalization diff --git a/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs b/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs index abf25f773e..02aaec00b2 100644 --- a/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs +++ b/framework/src/Volo.Abp.Json.SystemTextJson/Volo/Abp/Json/SystemTextJson/JsonConverters/AbpDateTimeConverterBase.cs @@ -101,23 +101,14 @@ public abstract class AbpDateTimeConverterBase : JsonConverter return IsSkipDateTimeNormalization ? dateTime : Clock.Normalize(dateTime); } + var timeZone = CurrentTimezoneProvider.TimeZone; try { - var timezoneInfo = TimezoneProvider.GetTimeZoneInfo(CurrentTimezoneProvider.TimeZone); - dateTime = new DateTimeOffset(dateTime, timezoneInfo.GetUtcOffset(dateTime)).UtcDateTime; - } - catch (ArgumentOutOfRangeException) - { - // Applying the timezone offset moved the value outside the supported DateTime range. - // This happens for values within the offset distance of DateTime.MinValue/MaxValue, - // typically placeholder values (e.g. DateTime.MinValue) that don't represent a real - // instant. Keep the value unchanged and log at Debug level instead of Warning, so it - // stays traceable without flooding production logs on every serialization. - Logger.LogDebug("Skipped timezone conversion for DateTime '{DateTime}' (kind: Unspecified) using timezone '{TimeZone}': applying the offset would move it outside the supported DateTime range.", dateTime, CurrentTimezoneProvider.TimeZone); + dateTime = TimezoneProvider.ConvertUnspecifiedToUtc(dateTime, timeZone); } catch { - Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", CurrentTimezoneProvider.TimeZone); + Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", timeZone); } return IsSkipDateTimeNormalization ? dateTime : Clock.Normalize(dateTime); diff --git a/framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs b/framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs new file mode 100644 index 0000000000..29779a08be --- /dev/null +++ b/framework/src/Volo.Abp.Timing/Volo/Abp/Timing/TimezoneProviderExtensions.cs @@ -0,0 +1,37 @@ +using System; + +namespace Volo.Abp.Timing; + +public static class TimezoneProviderExtensions +{ + /// + /// Interprets as local time in + /// and converts it to its UTC equivalent. The caller is expected to pass a + /// value; the kind is not inspected, so a value is always + /// treated as wall-clock time in the given timezone regardless of its kind. + /// + /// + /// Returns unchanged when applying the timezone offset would move it + /// outside the supported range. This happens for values within the offset + /// distance of /, typically the + /// placeholder that does not represent a real instant. Computing the + /// UTC ticks directly avoids the that + /// new DateTimeOffset(dateTime, offset) would throw for such values. + /// + public static DateTime ConvertUnspecifiedToUtc( + this ITimezoneProvider timezoneProvider, + DateTime dateTime, + string windowsOrIanaTimeZoneId) + { + Check.NotNull(timezoneProvider, nameof(timezoneProvider)); + + var timezoneInfo = timezoneProvider.GetTimeZoneInfo(windowsOrIanaTimeZoneId); + var utcTicks = dateTime.Ticks - timezoneInfo.GetUtcOffset(dateTime).Ticks; + if (utcTicks < DateTime.MinValue.Ticks || utcTicks > DateTime.MaxValue.Ticks) + { + return dateTime; + } + + return new DateTime(utcTicks, DateTimeKind.Utc); + } +} diff --git a/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs b/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs index 3e1a08772b..202c1d59e2 100644 --- a/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs +++ b/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs @@ -16,12 +16,11 @@ namespace Volo.Abp.Json; /// /// When is the converter treats /// an value as local time in the current user's timezone and -/// converts it to UTC via new DateTimeOffset(value, offset).UtcDateTime. For a placeholder -/// such as a positive offset (e.g. Asia/Shanghai = +08:00) pushes -/// the value before and throws , -/// which used to be swallowed and logged as a warning on every serialization. The converter now -/// skips the timezone conversion for the / -/// sentinel values, so no warning is emitted while the serialized output stays unchanged. +/// converts it to UTC. For a value within the offset distance of / +/// (most commonly the placeholder) a +/// non-zero offset pushes the UTC equivalent outside the supported range, which used to be swallowed +/// and logged as a warning on every serialization. The converter now detects this boundary case and +/// keeps the value unchanged, so no warning is emitted while the serialized output stays the same. /// public class AbpDateTimeConverterTimezone_Tests : AbpJsonSystemTextJsonTestBase { diff --git a/framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs b/framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs new file mode 100644 index 0000000000..6707eb8b82 --- /dev/null +++ b/framework/test/Volo.Abp.Timing.Tests/Volo/Abp/Timing/TimezoneProviderExtensions_Tests.cs @@ -0,0 +1,60 @@ +using System; +using Shouldly; +using Volo.Abp.Testing; +using Xunit; + +namespace Volo.Abp.Timing; + +public class TimezoneProviderExtensions_Tests : AbpIntegratedTest +{ + private readonly ITimezoneProvider _timezoneProvider; + + public TimezoneProviderExtensions_Tests() + { + _timezoneProvider = GetRequiredService(); + } + + [Theory] + [InlineData("Asia/Shanghai")] // +08:00 + [InlineData("Europe/Brussels")] // +01:00 / +02:00 + public void Should_Keep_MinValue_Unchanged_Under_Positive_Offset(string timeZoneId) + { + // A positive offset would push DateTime.MinValue below the supported range; keep it as-is. + var result = _timezoneProvider.ConvertUnspecifiedToUtc(DateTime.MinValue, timeZoneId); + + result.ShouldBe(DateTime.MinValue); + result.Kind.ShouldBe(DateTimeKind.Unspecified); + } + + [Fact] + public void Should_Keep_Value_Near_MinValue_Unchanged_Under_Positive_Offset() + { + var nearMin = DateTime.MinValue.AddHours(3); // 0001-01-01T03:00:00 - 08:00 underflows + + var result = _timezoneProvider.ConvertUnspecifiedToUtc(nearMin, "Asia/Shanghai"); + + result.ShouldBe(nearMin); + result.Kind.ShouldBe(DateTimeKind.Unspecified); + } + + [Fact] + public void Should_Keep_MaxValue_Unchanged_Under_Negative_Offset() + { + var result = _timezoneProvider.ConvertUnspecifiedToUtc(DateTime.MaxValue, "America/New_York"); + + result.ShouldBe(DateTime.MaxValue); + result.Kind.ShouldBe(DateTimeKind.Unspecified); + } + + [Fact] + public void Should_Convert_Unspecified_Value_To_Utc_Using_Offset() + { + var unspecified = new DateTime(2026, 6, 27, 18, 0, 0, DateTimeKind.Unspecified); + + var result = _timezoneProvider.ConvertUnspecifiedToUtc(unspecified, "Asia/Shanghai"); // +08:00 + + // 18:00 in +08:00 == 10:00 UTC. + result.ShouldBe(new DateTime(2026, 6, 27, 10, 0, 0, DateTimeKind.Utc)); + result.Kind.ShouldBe(DateTimeKind.Utc); + } +} diff --git a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs index 8841175d54..3a770e6781 100644 --- a/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs +++ b/modules/docs/src/Volo.Docs.Domain/Volo/Docs/GitHub/Documents/GithubDocumentSource.cs @@ -230,8 +230,8 @@ namespace Volo.Docs.GitHub.Documents var fileCommitsAfterCreation = commits.Take(commits.Count - 1); var commitsToEvaluate = (lastKnownSignificantUpdateTime != null - ? fileCommitsAfterCreation.Where(c => c.Commit.Author.Date.DateTime > lastKnownSignificantUpdateTime) - : fileCommitsAfterCreation).Where(c => c.Commit.Author.Date.DateTime > DateTime.Now.AddDays(-14)); + ? fileCommitsAfterCreation.Where(c => c.Commit.Author.Date.UtcDateTime > lastKnownSignificantUpdateTime) + : fileCommitsAfterCreation).Where(c => c.Commit.Author.Date.UtcDateTime > DateTime.UtcNow.AddDays(-14)); foreach (var gitHubCommit in commitsToEvaluate) {