From d9a535515ae336b544a907a6a23380a2330f30b1 Mon Sep 17 00:00:00 2001 From: gdlcf88 <47396430@qq.com> Date: Sat, 27 Jun 2026 20:50:21 +0800 Subject: [PATCH] Stop logging misleading warning when normalizing Unspecified DateTime near range boundaries AbpDateTimeConverterBase.Normalize converts an Unspecified DateTime to UTC via `new DateTimeOffset(value, offset).UtcDateTime` when the clock is UTC and a user timezone is set. For values within the timezone offset distance of DateTime.MinValue/MaxValue (most notably the DateTime.MinValue placeholder), this throws ArgumentOutOfRangeException. The exception was swallowed and logged as "Could not convert DateTime with unspecified Kind using timezone '...'." on every serialization, flooding logs for positive-offset users (e.g. Asia/Shanghai +08:00). Catch the boundary ArgumentOutOfRangeException explicitly, keep the value unchanged, and log it at Debug level instead of Warning so it stays traceable without polluting production logs. Other unexpected failures still log a warning. Also make Volo.Docs GitHubDocumentSource return a Utc-kind placeholder (DateTime.SpecifyKind(DateTime.MinValue, Utc)) and use Author.Date.UtcDateTime, so it no longer emits Unspecified placeholders in the first place. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../AbpDateTimeConverterBase.cs | 9 + .../AbpDateTimeConverterTimezone_Tests.cs | 201 ++++++++++++++++++ .../GitHub/Documents/GithubDocumentSource.cs | 12 +- 3 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs 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..abf25f773e 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 @@ -106,6 +106,15 @@ public abstract class AbpDateTimeConverterBase : JsonConverter 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); + } catch { Logger.LogWarning("Could not convert DateTime with unspecified Kind using timezone '{TimeZone}'.", CurrentTimezoneProvider.TimeZone); 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..3e1a08772b --- /dev/null +++ b/framework/test/Volo.Abp.Json.Tests/Volo/Abp/Json/AbpDateTimeConverterTimezone_Tests.cs @@ -0,0 +1,201 @@ +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 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. +/// +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/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..8841175d54 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) @@ -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; } }