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 ee39a66678..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,14 +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; + 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 new file mode 100644 index 0000000000..202c1d59e2 --- /dev/null +++ b/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Concurrent; +using System.Linq; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Shouldly; +using Volo.Abp.Timing; +using Xunit; + +namespace Volo.Abp.Json; + +/// +/// Regression tests for the warning +/// "Could not convert DateTime with unspecified Kind using timezone '...'." +/// logged by AbpDateTimeConverterBase.Normalize. +/// +/// When is the converter treats +/// an value as local time in the current user's timezone and +/// 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 +{ + private const string WarningFragment = "Could not convert DateTime with unspecified Kind"; + + private static readonly CapturingLoggerProvider LogCapture = new(); + + private readonly IJsonSerializer _jsonSerializer; + private readonly ICurrentTimezoneProvider _currentTimezoneProvider; + + public AbpDateTimeConverterTimezone_Tests() + { + _jsonSerializer = GetRequiredService(); + _currentTimezoneProvider = GetRequiredService(); + } + + protected override void AfterAddApplication(IServiceCollection services) + { + // The warning only happens with a UTC clock, where Unspecified values get converted to UTC. + services.Configure(options => options.Kind = DateTimeKind.Utc); + + LogCapture.Clear(); + services.AddSingleton(LogCapture); + + base.AfterAddApplication(services); + } + + private sealed class FileModel + { + public DateTime DateModified { get; set; } + + public DateTime DateCreated { get; set; } + } + + [Theory] + [InlineData("Asia/Shanghai")] // +08:00 + [InlineData("Europe/Brussels")] // +01:00 / +02:00 + public void Should_Not_Warn_When_Serializing_MinValue_Under_Positive_Offset_Timezone(string timeZoneId) + { + _currentTimezoneProvider.TimeZone = timeZoneId; + LogCapture.Clear(); + + DateTime.MinValue.Kind.ShouldBe(DateTimeKind.Unspecified); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = DateTime.MinValue, + DateCreated = DateTime.MinValue + }); + + // The placeholder is serialized unchanged and no warning is logged. + json.ShouldContain("0001-01-01"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Not_Warn_When_Serializing_Value_Near_MinValue_Under_Positive_Offset_Timezone() + { + // Not exactly DateTime.MinValue: any value within the offset distance of the lower bound + // overflows the same way, so the converter must absorb it rather than warn. + _currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00 + LogCapture.Clear(); + + var nearMin = DateTime.MinValue.AddHours(3); // 0001-01-01T03:00:00 - 08:00 underflows + + _jsonSerializer.Serialize(new FileModel + { + DateModified = nearMin, + DateCreated = nearMin + }); + + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Not_Warn_When_Serializing_MaxValue_Under_Negative_Offset_Timezone() + { + // The symmetric case: a negative offset would push MaxValue past DateTime.MaxValue. + _currentTimezoneProvider.TimeZone = "America/New_York"; + LogCapture.Clear(); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = DateTime.MaxValue, + DateCreated = DateTime.MaxValue + }); + + json.ShouldContain("9999-12-31"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Not_Warn_When_Serializing_Real_Utc_Timestamp_Under_Positive_Offset_Timezone() + { + _currentTimezoneProvider.TimeZone = "Asia/Shanghai"; + LogCapture.Clear(); + + var utc = new DateTime(2026, 6, 27, 10, 28, 7, DateTimeKind.Utc); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = utc, + DateCreated = utc + }); + + json.ShouldContain("2026-06-27T10:28:07Z"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + [Fact] + public void Should_Still_Convert_Real_Unspecified_Timestamp_To_Utc_Under_Positive_Offset_Timezone() + { + // A genuine (non-sentinel) Unspecified value must still be converted to UTC using the offset. + _currentTimezoneProvider.TimeZone = "Asia/Shanghai"; // +08:00 + LogCapture.Clear(); + + var unspecified = new DateTime(2026, 6, 27, 18, 0, 0, DateTimeKind.Unspecified); + + var json = _jsonSerializer.Serialize(new FileModel + { + DateModified = unspecified, + DateCreated = unspecified + }); + + // 18:00 in +08:00 == 10:00 UTC. + json.ShouldContain("2026-06-27T10:00:00Z"); + LogCapture.Warnings.ShouldNotContain(m => m.Contains(WarningFragment)); + } + + private sealed class CapturingLoggerProvider : ILoggerProvider + { + public ConcurrentQueue Warnings { get; } = new(); + + public void Clear() + { + Warnings.Clear(); + } + + public ILogger CreateLogger(string categoryName) + { + return new CapturingLogger(Warnings); + } + + public void Dispose() + { + } + + private sealed class CapturingLogger : ILogger + { + private readonly ConcurrentQueue _sink; + + public CapturingLogger(ConcurrentQueue sink) + { + _sink = sink; + } + + public IDisposable BeginScope(TState state) where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return logLevel >= LogLevel.Warning; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, + Func formatter) + { + if (logLevel >= LogLevel.Warning) + { + _sink.Enqueue(formatter(state, exception)); + } + } + } + } +} 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 8d301395c0..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 @@ -173,7 +173,7 @@ namespace Volo.Docs.GitHub.Documents { if (commits == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } var gitHubCommit = isFirstCommit ? @@ -182,20 +182,20 @@ namespace Volo.Docs.GitHub.Documents if (gitHubCommit == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } if (gitHubCommit.Commit == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } if (gitHubCommit.Commit.Author == null) { - return DateTime.MinValue; + return DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); } - return gitHubCommit.Commit.Author.Date.DateTime; + return gitHubCommit.Commit.Author.Date.UtcDateTime; } private async Task> GetGitHubCommitsOrNull(Project project, string documentName, string languageCode, string version) @@ -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) { @@ -243,7 +243,7 @@ namespace Volo.Docs.GitHub.Documents if (_githubPatchAnalyzer.HasPatchSignificantChanges(fullCommit.Files.First(f => f.Filename == fileName).Patch)) { - return gitHubCommit.Commit.Author.Date.DateTime; + return gitHubCommit.Commit.Author.Date.UtcDateTime; } }