🍒 feat(core): 添加 DateTimeRange 自定义验证特性

This commit is contained in:
喵你个汪呀 2025-08-21 08:52:32 +08:00
parent fbd7d59dcb
commit b7ad4a5294
2 changed files with 327 additions and 0 deletions

View File

@ -0,0 +1,277 @@
// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
//
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
//
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
namespace Admin.NET.Core;
/// <summary>
/// 用于校验 List&lt;DateTime&gt; 时间范围参数的自定义验证特性
/// 要求列表包含两个有效 DateTime 值,构成一个时间范围
/// </summary>
/// <example>
/// <para><b>示例 1基础时间范围起始 &lt; 结束)</b></para>
/// <code>
/// public class QueryModel
/// {
/// [DateTimeRange]
/// public List&lt;DateTime&gt; TimeRange { get; set; }
/// }
/// // 合法值: [2025-01-01 00:00:00, 2025-01-07 23:59:59]
/// </code>
///
/// <para><b>示例 2仅允许日期时间部分必须为 00:00:00</b></para>
/// <code>
/// [DateTimeRange(DateOnly = true)]
/// public List&lt;DateTime&gt; DateRange { get; set; }
/// // 合法值: [2025-01-01 00:00:00, 2025-01-31 00:00:00]
/// // 非法值: [2025-01-01 08:00:00, ...] → 报错
/// </code>
///
/// <para><b>示例 3最小间隔 1 小时60分钟</b></para>
/// <code>
/// [DateTimeRange(MinInterval = 60)]
/// public List&lt;DateTime&gt; Duration { get; set; }
/// // 合法值: 间隔 ≥ 1小时如 2小时 → "2小时"
/// // 错误提示: 时间间隔必须至少为 1小时
/// </code>
///
/// <para><b>示例 4最大间隔 7 天</b></para>
/// <code>
/// [DateTimeRange(MaxInterval = 7 * 24 * 60)] // 7天 = 10080分钟
/// public List&lt;DateTime&gt; WeeklyRange { get; set; }
/// // 合法值: 间隔 ≤ 7天
/// // 错误提示: 时间间隔不能超过 7天
/// </code>
///
/// <para><b>示例 5时间范围必须在过去且可包含当前时间</b></para>
/// <code>
/// [DateTimeRange(DirectionEnum = TimeDirectionEnum.Past, AllowNow = true)]
/// public List&lt;DateTime&gt; PastEvents { get; set; }
/// // 合法值: [2025-08-01, 2025-08-20](若今天是 2025-08-20
/// // 非法值: [2025-08-21, ...] → 完全在未来
/// </code>
///
/// <para><b>示例 6时间范围必须在未来不能包含当前时间</b></para>
/// <code>
/// [DateTimeRange(DirectionEnum = TimeDirectionEnum.Future, AllowNow = false)]
/// public List&lt;DateTime&gt; FutureTasks { get; set; }
/// // 合法值: [2025-08-21 00:00, 2025-08-30 23:59]
/// // 非法值: [2025-08-20 10:00, ...] → 包含当前时间
/// </code>
///
/// <para><b>示例 7允许起止时间相等单日查询</b></para>
/// <code>
/// [DateTimeRange(AllowEqual = true, DateOnly = true)]
/// public List&lt;DateTime&gt; SingleDay { get; set; }
/// // 合法值: [2025-08-20 00:00:00, 2025-08-20 00:00:00]
/// </code>
///
/// <para><b>示例 8单元测试中固定“当前时间”</b></para>
/// <code>
/// // 用于测试未来/过去逻辑
/// var attribute = new DateTimeRangeAttribute
/// {
/// DirectionEnum = TimeDirectionEnum.Future,
/// NowProvider = () => new DateTimeOffset(2025, 1, 1, 0, 0, 0, TimeSpan.Zero)
/// };
/// </code>
/// </example>
[SuppressSniffer]
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false)]
public class DateTimeRangeAttribute : ValidationAttribute
{
/// <summary>
/// 获取或设置时间方向约束
/// </summary>
public TimeDirectionEnum DirectionEnum { get; set; } = TimeDirectionEnum.None;
/// <summary>
/// 最小时间间隔(分钟) 0表示无限制
/// </summary>
public int MinInterval { get; set; } = 0;
/// <summary>
/// 最大时间间隔(分钟) 0表示无限制
/// </summary>
public int MaxInterval { get; set; } = 0;
/// <summary>
/// 是否仅允许日期类型(时间部分必须为 00:00:00
/// </summary>
public bool DateOnly { get; set; } = false;
/// <summary>
/// 是否允许起始时间等于结束时间(即零间隔)
/// 默认为 false即起始时间必须小于结束时间
/// </summary>
public bool AllowEqual { get; set; } = false;
/// <summary>
/// 是否允许范围包含当前时间
/// 当 Direction 为 Future 或 Past 时,此属性可覆盖默认行为
/// </summary>
public bool AllowNow { get; set; } = true;
/// <summary>
/// 获取或设置自定义的“现在”时间提供器,用于单元测试或特定时区场景
/// 默认为 DateTimeOffset.Now
/// </summary>
public Func<DateTimeOffset> NowProvider { get; set; } = () => DateTimeOffset.Now;
/// <summary>
/// 获取格式化的当前时间字符串(用于错误消息)
/// </summary>
private string NowString => NowProvider().ToString("yyyy-MM-dd HH:mm:ss");
/// <summary>
/// 执行验证逻辑
/// </summary>
protected override ValidationResult IsValid(object? value, ValidationContext validationContext)
{
if (value == null) return ValidationResult.Success;
var list = value as List<DateTime>;
if (list == null) return new ValidationResult($"'{validationContext.DisplayName}' 必须是日期时间列表类型");
if (list.Count != 2) return new ValidationResult($"'{validationContext.DisplayName}' 必须包含两个日期时间值");
var start = list[0];
var end = list[1];
// 验证 DateTime 值的有效性
if (!IsValidDateTime(start) || !IsValidDateTime(end)) return new ValidationResult($"'{validationContext.DisplayName}' 包含无效的日期时间值");
// 检查 DateOnly 约束
if (DateOnly)
{
if (start.TimeOfDay != TimeSpan.Zero) return new ValidationResult($"'{validationContext.DisplayName}' 起始时间需为日期格式00:00:00当前为 {start:yyyy-MM-dd HH:mm:ss}");
if (end.TimeOfDay != TimeSpan.Zero) return new ValidationResult($"'{validationContext.DisplayName}' 结束时间需为日期格式00:00:00当前为 {end:yyyy-MM-dd HH:mm:ss}");
}
// 检查时间顺序
if (AllowEqual)
{
if (start > end) return new ValidationResult($"'{validationContext.DisplayName}' 起始时间不能晚于结束时间");
}
else
{
if (start >= end) return new ValidationResult($"'{validationContext.DisplayName}' 起始时间必须早于结束时间");
}
// 检查时间方向
var now = NowProvider().DateTime;
var directionResult = ValidateDirection(start, end, now, validationContext.DisplayName);
if (directionResult != null) return directionResult;
// 检查最小和最大间隔
var intervalResult = ValidateTimeIntervals(start, end, validationContext.DisplayName);
if (intervalResult != null) return intervalResult;
return ValidationResult.Success;
}
/// <summary>
/// 验证时间方向约束
/// </summary>
private ValidationResult? ValidateDirection(DateTime start, DateTime end, DateTime now, string? displayName)
{
return DirectionEnum switch
{
TimeDirectionEnum.Future when !AllowNow && (start <= now || end <= now) =>
new ValidationResult($"'{displayName}' 必须在 {NowString} 之后"),
TimeDirectionEnum.Future when AllowNow && (start < now && end < now) =>
new ValidationResult($"'{displayName}' 必须是未来时间"),
TimeDirectionEnum.Past when !AllowNow && (start >= now || end >= now) =>
new ValidationResult($"'{displayName}' 必须在 {NowString} 之前"),
TimeDirectionEnum.Past when AllowNow && (start > now && end > now) =>
new ValidationResult($"'{displayName}' 必须是过去时间"),
_ => null
};
}
/// <summary>
/// 验证最小和最大时间间隔
/// </summary>
private ValidationResult? ValidateTimeIntervals(DateTime start, DateTime end, string? displayName)
{
var interval = end - start;
var totalMinutes = (int)interval.TotalMinutes;
// 验证最小间隔
if (MinInterval > 0)
{
if (MinInterval < 0) return new ValidationResult($"'{displayName}' 最小间隔不能为负数");
if (totalMinutes < MinInterval)
{
var required = TimeSpan.FromMinutes(MinInterval);
var actual = TimeSpan.FromMinutes(totalMinutes);
return new ValidationResult($"'{displayName}'至少间隔 {required.FormatTimeSpanText(2)}");
}
}
// 验证最大间隔
if (MaxInterval > 0)
{
if (MaxInterval < 0) return new ValidationResult($"'{displayName}'最大间隔不能为负数");
if (totalMinutes > MaxInterval)
{
var allowed = TimeSpan.FromMinutes(MaxInterval);
var actual = TimeSpan.FromMinutes(totalMinutes);
return new ValidationResult($"'{displayName}'最多间隔 {allowed.FormatTimeSpanText(2)}");
}
}
// 验证最小不能大于最大
if (MinInterval > 0 && MaxInterval > 0 && MinInterval > MaxInterval)
{
var minSpan = TimeSpan.FromMinutes(MinInterval);
var maxSpan = TimeSpan.FromMinutes(MaxInterval);
return new ValidationResult($"'{displayName}' 最小间隔({minSpan.FormatTimeSpanText(2)})不能大于最大间隔({maxSpan.FormatTimeSpanText(2)})");
}
return null;
}
/// <summary>
/// 验证 DateTime 值是否在合理范围内
/// </summary>
private static bool IsValidDateTime(DateTime dt)
{
return dt >= new DateTime(1900, 1, 1) && dt <= new DateTime(2100, 12, 31);
}
}
/// <summary>
/// 指定范围必须是未来时间、过去时间,还是不限制
/// </summary>
[SuppressSniffer]
[Description("时间方向枚举")]
public enum TimeDirectionEnum
{
/// <summary>
/// 不限制时间方向
/// </summary>
[Description("不限")]
None,
/// <summary>
/// 范围必须在当前时间之后(未来)
/// </summary>
[Description("未来")]
Future,
/// <summary>
/// 范围必须在当前时间之前(过去)
/// </summary>
[Description("过去")]
Past
}

View File

@ -231,6 +231,56 @@ public static class DateTimeFormatExtensions
return $"{sDay} 天 {sHour} 小时 {sMinute} 分 {sSecond} 秒 {sMilliSecond} 毫秒";
}
/// <summary>
/// 将 TimeSpan 格式化为 y年M月d天h小时m分钟 的字符串值为0的单位不显示
/// 注意此方法对年、月的计算是基于近似值1年≈365.25天1月≈30.44天),适用于显示目的,不保证绝对精确
/// </summary>
/// <param name="timeSpan">要格式化的 TimeSpan</param>
/// <param name="maxUnits">保留的最大时间单位数量</param>
/// <returns>格式化后的字符串</returns>
public static string FormatTimeSpanText(this TimeSpan timeSpan, int maxUnits = int.MaxValue)
{
if (timeSpan < TimeSpan.Zero) timeSpan = TimeSpan.Zero; // 确保非负
if (maxUnits <= 0) throw new ArgumentException("maxUnits 必须大于0", nameof(maxUnits));
long totalMinutes = (long)timeSpan.TotalMinutes;
if (totalMinutes == 0) return "0分钟";
// 计算年、月、日、小时、分钟
// 使用近似值进行计算
const double minutesInYear = 365 * 24 * 60;
const double minutesInMonth = 30 * 24 * 60; // 平均每月天数
const long minutesInDay = 24 * 60;
const long minutesInHour = 60;
int years = (int)(totalMinutes / minutesInYear);
totalMinutes %= (long)minutesInYear;
int months = (int)(totalMinutes / minutesInMonth);
totalMinutes %= (long)minutesInMonth;
int days = (int)(totalMinutes / minutesInDay);
totalMinutes %= minutesInDay;
int hours = (int)(totalMinutes / minutesInHour);
int minutes = (int)(totalMinutes % minutesInHour);
var parts = new List<string>();
if (years > 0) parts.Add($"{years}年");
if (months > 0) parts.Add($"{months}个月");
if (days > 0) parts.Add($"{days}天");
if (hours > 0) parts.Add($"{hours}小时");
if (minutes > 0) parts.Add($"{minutes}分钟");
// 如果指定了最大单位数量,则截取前 maxUnits 个非零单位
if (maxUnits < int.MaxValue && parts.Count > maxUnits)
{
parts = parts.Take(maxUnits).ToList();
}
return parts.Count == 0 ? "0分钟" : string.Join("", parts);
}
/// <summary>
/// 时间转换简易字符串
/// </summary>