Merge pull request '🌶 增加登录模式识别逻辑;增加测试项目;修复条件必填特性已知bug;避免刷新session时更新用户登陆信息' (#433) from jasondom/Admin.NET.Pro:v2-1 into v2
Reviewed-on: https://code.adminnet.top/Admin.NET/Admin.NET.Pro/pulls/433
This commit is contained in:
commit
85760214e0
@ -68,14 +68,9 @@ public sealed class RequiredIFAttribute(
|
||||
if (targetProperty == null) return new ValidationResult($"找不到属性: {PropertyName}");
|
||||
var targetValue = targetProperty.GetValue(instance);
|
||||
|
||||
if (!ShouldValidate(targetValue))
|
||||
{
|
||||
return ValidationResult.Success;
|
||||
}
|
||||
if (!ShouldValidate(targetValue)) return ValidationResult.Success;
|
||||
|
||||
return IsEmpty(value)
|
||||
? new ValidationResult(ErrorMessage ?? $"{validationContext.MemberName}不能为空")
|
||||
: ValidationResult.Success;
|
||||
return IsEmpty(value) ? new ValidationResult(ErrorMessage ?? $"{validationContext.MemberName}不能为空") : ValidationResult.Success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -85,11 +80,65 @@ public sealed class RequiredIFAttribute(
|
||||
/// <returns>是否需要验证</returns>
|
||||
private bool ShouldValidate(object targetValue)
|
||||
{
|
||||
if (TargetValue == null) return IsEmpty(targetValue);
|
||||
switch (Comparison)
|
||||
{
|
||||
case Operator.Equal:
|
||||
return TargetValue == null ? IsEmpty(targetValue) : CompareValues(targetValue, TargetValue, Comparison);
|
||||
case Operator.NotEqual:
|
||||
return TargetValue == null ? !IsEmpty(targetValue) : CompareValues(targetValue, TargetValue, Comparison);
|
||||
case Operator.GreaterThan:
|
||||
case Operator.LessThan:
|
||||
case Operator.GreaterThanOrEqual:
|
||||
case Operator.LessThanOrEqual:
|
||||
case Operator.Contains:
|
||||
case Operator.NotContains:
|
||||
if (targetValue is IEnumerable enumerable) return enumerable.Cast<object>().Any(item => CompareValues(item, TargetValue, Comparison));
|
||||
return TargetValue == null ? !IsEmpty(targetValue) : CompareValues(targetValue, TargetValue, Comparison);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return TargetValue is IEnumerable enumerable and not string
|
||||
? enumerable.Cast<object>().Any(item => CompareValues(targetValue, item, Operator.Equal))
|
||||
: CompareValues(targetValue, TargetValue, Comparison);
|
||||
/// <summary>
|
||||
/// 比较两个值
|
||||
/// </summary>
|
||||
/// <param name="sourceValue">源值</param>
|
||||
/// <param name="targetValue">目标值</param>
|
||||
/// <param name="comparison">比较运算符</param>
|
||||
/// <returns>比较结果</returns>
|
||||
private static bool CompareValues(object sourceValue, object targetValue, Operator comparison)
|
||||
{
|
||||
switch (comparison)
|
||||
{
|
||||
case Operator.Equal:
|
||||
case Operator.NotEqual:
|
||||
case Operator.GreaterThan:
|
||||
case Operator.LessThan:
|
||||
case Operator.GreaterThanOrEqual:
|
||||
case Operator.LessThanOrEqual:
|
||||
if (sourceValue is IComparable sourceComparable && targetValue is IComparable targetComparable)
|
||||
{
|
||||
int result = sourceComparable.CompareTo(targetComparable);
|
||||
return comparison switch
|
||||
{
|
||||
Operator.Equal => result == 0,
|
||||
Operator.NotEqual => result != 0,
|
||||
Operator.GreaterThan => result > 0,
|
||||
Operator.LessThan => result < 0,
|
||||
Operator.GreaterThanOrEqual => result >= 0,
|
||||
Operator.LessThanOrEqual => result <= 0,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
return false;
|
||||
case Operator.Contains:
|
||||
case Operator.NotContains:
|
||||
if (targetValue is not IEnumerable enumerable) return false;
|
||||
bool contains = enumerable.Cast<object>().Any(item => item != null && item.Equals(sourceValue));
|
||||
return comparison == Operator.Contains ? contains : !contains;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -106,59 +155,6 @@ public sealed class RequiredIFAttribute(
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 比较两个值
|
||||
/// </summary>
|
||||
/// <param name="sourceValue">源值</param>
|
||||
/// <param name="targetValue">目标值</param>
|
||||
/// <param name="comparison">比较运算符</param>
|
||||
/// <returns>比较结果</returns>
|
||||
private static bool CompareValues(object sourceValue, object targetValue, Operator comparison)
|
||||
{
|
||||
// 处理null值比较
|
||||
if (sourceValue == null || targetValue == null)
|
||||
{
|
||||
return comparison switch
|
||||
{
|
||||
Operator.Equal => sourceValue == targetValue,
|
||||
Operator.NotEqual => sourceValue != targetValue,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
// 处理集合包含操作
|
||||
if (comparison is Operator.Contains or Operator.NotContains)
|
||||
{
|
||||
if (targetValue is not IEnumerable enumerable)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool contains = enumerable.Cast<object>().Any(item => item != null && item.Equals(sourceValue));
|
||||
return comparison == Operator.Contains ? contains : !contains;
|
||||
}
|
||||
|
||||
// 处理可比较类型
|
||||
if (sourceValue is IComparable sourceComparable && targetValue is IComparable targetComparable)
|
||||
{
|
||||
int result = sourceComparable.CompareTo(targetComparable);
|
||||
return comparison switch
|
||||
{
|
||||
Operator.Equal => result == 0,
|
||||
Operator.NotEqual => result != 0,
|
||||
Operator.GreaterThan => result > 0,
|
||||
Operator.LessThan => result < 0,
|
||||
Operator.GreaterThanOrEqual => result >= 0,
|
||||
Operator.LessThanOrEqual => result <= 0,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
// 默认比较
|
||||
bool equals = sourceValue.Equals(targetValue);
|
||||
return comparison == Operator.Equal ? equals : !equals;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@ -30,4 +30,9 @@ public class SqlSugarConst
|
||||
/// 默认租户Id
|
||||
/// </summary>
|
||||
public const long DefaultTenantId = 1300000000001;
|
||||
|
||||
/// <summary>
|
||||
/// 默认应用Id
|
||||
/// </summary>
|
||||
public const long DefaultAppId = 1300000000001;
|
||||
}
|
||||
@ -221,15 +221,16 @@ public class SysAuthService : IDynamicApiController, ITransient
|
||||
/// </summary>
|
||||
/// <param name="user"></param>
|
||||
/// <param name="loginMode"></param>
|
||||
/// <param name="isRefresh"></param>
|
||||
/// <returns></returns>
|
||||
[NonAction]
|
||||
public async Task<LoginOutput> CreateToken(SysUser user, LoginModeEnum loginMode = LoginModeEnum.PC)
|
||||
public async Task<LoginOutput> CreateToken(SysUser user, LoginModeEnum loginMode = LoginModeEnum.PC, bool isRefresh = false)
|
||||
{
|
||||
// 单用户登录
|
||||
await App.GetRequiredService<SysOnlineUserService>().SingleLogin(user.Id, loginMode);
|
||||
|
||||
// 生成Token令牌
|
||||
user.TokenVersion += 1;
|
||||
if(!isRefresh) user.TokenVersion += 1;
|
||||
var tokenExpire = await _sysConfigService.GetTokenExpire();
|
||||
var accessToken = JWTEncryption.Encrypt(new Dictionary<string, object>
|
||||
{
|
||||
@ -250,6 +251,7 @@ public class SysAuthService : IDynamicApiController, ITransient
|
||||
OrgName = user.SysOrg?.Name,
|
||||
OrgType = user.SysOrg?.Type,
|
||||
OrgLevel = user.SysOrg?.Level,
|
||||
LoginMode = loginMode,
|
||||
TokenVersion = user.TokenVersion,
|
||||
ExtProps = App.GetServices<IUserSessionExtProps>().SelectMany(u => u.GetInitExtProps(user)).ToDictionary(u => u.Key, u => u.Value)
|
||||
}, TimeSpan.FromMinutes(tokenExpire));
|
||||
@ -265,24 +267,27 @@ public class SysAuthService : IDynamicApiController, ITransient
|
||||
// ke.global.setAllHeader('Authorization', 'Bearer ' + ke.response.headers['access-token']);
|
||||
|
||||
// 更新用户登录信息
|
||||
user.LastLoginIp = _httpContextAccessor.HttpContext.GetRemoteIpAddressToIPv4(true);
|
||||
(user.LastLoginAddress, double? longitude, double? latitude) = CommonHelper.GetIpAddress(user.LastLoginIp);
|
||||
user.LastLoginTime = DateTime.Now;
|
||||
user.LastLoginDevice = CommonHelper.GetClientDeviceInfo(_httpContextAccessor.HttpContext?.Request?.Headers?.UserAgent);
|
||||
await _sysUserRep.AsUpdateable(user).UpdateColumns(u => new
|
||||
if (!isRefresh)
|
||||
{
|
||||
u.TokenVersion,
|
||||
u.LastLoginIp,
|
||||
u.LastLoginAddress,
|
||||
u.LastLoginTime,
|
||||
u.LastLoginDevice,
|
||||
}).ExecuteCommandAsync();
|
||||
user.LastLoginIp = _httpContextAccessor.HttpContext.GetRemoteIpAddressToIPv4(true);
|
||||
(user.LastLoginAddress, double? longitude, double? latitude) = CommonHelper.GetIpAddress(user.LastLoginIp);
|
||||
user.LastLoginTime = DateTime.Now;
|
||||
user.LastLoginDevice = CommonHelper.GetClientDeviceInfo(_httpContextAccessor.HttpContext?.Request?.Headers?.UserAgent);
|
||||
await _sysUserRep.AsUpdateable(user).UpdateColumns(u => new
|
||||
{
|
||||
u.TokenVersion,
|
||||
u.LastLoginIp,
|
||||
u.LastLoginAddress,
|
||||
u.LastLoginTime,
|
||||
u.LastLoginDevice,
|
||||
}).ExecuteCommandAsync();
|
||||
|
||||
// 缓存用户Token版本
|
||||
_sysCacheService.Set($"{CacheConst.KeyUserToken}{user.Id}", $"{user.TokenVersion}");
|
||||
// 缓存用户Token版本
|
||||
_sysCacheService.Set($"{CacheConst.KeyUserToken}{user.Id}", $"{user.TokenVersion}");
|
||||
|
||||
// 发布系统登录事件
|
||||
await _eventPublisher.PublishAsync(UserEventTypeEnum.Login, user);
|
||||
// 发布系统登录事件
|
||||
await _eventPublisher.PublishAsync(UserEventTypeEnum.Login, user);
|
||||
}
|
||||
|
||||
return new LoginOutput
|
||||
{
|
||||
@ -339,16 +344,17 @@ public class SysAuthService : IDynamicApiController, ITransient
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新Token
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
[NonAction]
|
||||
public async Task RefreshToken(long userId)
|
||||
{
|
||||
var user = await _sysUserRep.AsQueryable().IgnoreTenant().Includes(u => u.SysOrg).FirstAsync(u => u.Id == userId);
|
||||
await CreateToken(user);
|
||||
}
|
||||
///// <summary>
|
||||
///// 获取刷新Token 🔖
|
||||
///// </summary>
|
||||
///// <param name="accessToken"></param>
|
||||
///// <returns></returns>
|
||||
//[DisplayName("获取刷新Token")]
|
||||
//public string GetRefreshToken([FromQuery] string accessToken)
|
||||
//{
|
||||
// var refreshTokenExpire = _sysConfigService.GetRefreshTokenExpire().GetAwaiter().GetResult();
|
||||
// return JWTEncryption.GenerateRefreshToken(accessToken, refreshTokenExpire);
|
||||
//}
|
||||
|
||||
/// <summary>
|
||||
/// 退出系统 🔖
|
||||
@ -439,4 +445,15 @@ public class SysAuthService : IDynamicApiController, ITransient
|
||||
return 401;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 刷新token
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
[NonAction]
|
||||
public async Task RefreshToken(long userId)
|
||||
{
|
||||
var user = await _sysUserRep.AsQueryable().IgnoreTenant().Includes(u => u.SysOrg).FirstAsync(u => u.Id == userId);
|
||||
await CreateToken(user, CommonHelper.IsMobile(_httpContextAccessor.HttpContext?.Request.Headers.UserAgent ?? "") ? LoginModeEnum.APP : LoginModeEnum.PC, true);
|
||||
}
|
||||
}
|
||||
@ -244,10 +244,16 @@ public static class SqlSugarSetup
|
||||
entityInfo.SetValue(DateTime.Now);
|
||||
}
|
||||
// 若当前用户非空(web线程时)
|
||||
if (App.User == null) return;
|
||||
if (App.User == null || userManager.Value == null) return;
|
||||
|
||||
dynamic entityValue = entityInfo.EntityValue;
|
||||
if (entityInfo.PropertyName == nameof(EntityTenantId.TenantId))
|
||||
// 若应用Id为空则赋值当前用户应用Id,若当前用户应用Id为空,则取默认应用Id
|
||||
if (entityInfo.PropertyName == nameof(IAppIdFilter.AppId) && entityValue is IAppIdFilter)
|
||||
{
|
||||
var appId = entityInfo.EntityColumnInfo.PropertyInfo.GetValue(entityInfo.EntityValue)!;
|
||||
if (appId == null || appId.Equals(0)) entityInfo.SetValue(userManager.Value?.AppId ?? SqlSugarConst.DefaultAppId);
|
||||
}
|
||||
else if (entityInfo.PropertyName == nameof(EntityTenantId.TenantId))
|
||||
{
|
||||
if (entityValue.TenantId == null || entityValue.TenantId == 0)
|
||||
entityInfo.SetValue(userManager.Value.TenantId?.ToString() ?? SqlSugarConst.DefaultTenantId.ToString());
|
||||
@ -255,7 +261,7 @@ public static class SqlSugarSetup
|
||||
else if (entityInfo.PropertyName == nameof(EntityBase.CreateUserId))
|
||||
{
|
||||
if (entityValue.CreateUserId == null || entityValue.CreateUserId == 0)
|
||||
entityInfo.SetValue(App.User.FindFirst(ClaimConst.UserId)?.Value);
|
||||
entityInfo.SetValue(userManager.Value.UserId);
|
||||
}
|
||||
else if (entityInfo.PropertyName == nameof(EntityBase.CreateUserName))
|
||||
{
|
||||
|
||||
@ -552,4 +552,15 @@ public static class CommonHelper
|
||||
{ }
|
||||
return "未知";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 判断是否为移动端UA
|
||||
/// </summary>
|
||||
/// <param name="userAgent"></param>
|
||||
/// <returns></returns>
|
||||
public static bool IsMobile(string userAgent)
|
||||
{
|
||||
var mobilePatterns = new []{ "android.*mobile", "iphone", "ipod", "windows phone", "blackberry", "nokia", "mobile", "opera mini", "opera mobi", "palm", "webos", "bb\\d+", "meego" };
|
||||
return mobilePatterns.Any(pattern => Regex.IsMatch(userAgent, pattern, RegexOptions.IgnoreCase));
|
||||
}
|
||||
}
|
||||
28
Admin.NET/Admin.NET.Test/Admin.NET.Test.csproj
Normal file
28
Admin.NET/Admin.NET.Test/Admin.NET.Test.csproj
Normal file
@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
|
||||
<NoWarn>1701;1702;1591;8632</NoWarn>
|
||||
<DocumentationFile></DocumentationFile>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<PreserveCompilationContext>true</PreserveCompilationContext>
|
||||
<Nullable>disable</Nullable>
|
||||
<GenerateDocumentationFile>True</GenerateDocumentationFile>
|
||||
<Copyright>Admin.NET</Copyright>
|
||||
<Description>Admin.NET 通用权限开发平台</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||
<PackageReference Include="Furion.Xunit" Version="4.9.7.114" />
|
||||
<PackageReference Include="Furion.Pure" Version="4.9.7.114">
|
||||
<ExcludeAssets>compile</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="xunit.assert" Version="2.9.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Admin.NET.Web.Core\Admin.NET.Web.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
214
Admin.NET/Admin.NET.Test/Attribute/RequiredIFTest.cs
Normal file
214
Admin.NET/Admin.NET.Test/Attribute/RequiredIFTest.cs
Normal file
@ -0,0 +1,214 @@
|
||||
// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
|
||||
//
|
||||
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
namespace Admin.NET.Test.Attribute;
|
||||
|
||||
public class RequiredIFTest
|
||||
{
|
||||
#region 辅助方法:对象校验
|
||||
|
||||
private static ValidationResult ValidateProperty(object instance, string propertyName, object value)
|
||||
{
|
||||
var context = new ValidationContext(instance) { MemberName = propertyName };
|
||||
var property = instance.GetType().GetProperty(propertyName);
|
||||
return property?.GetCustomAttribute<ValidationAttribute>()?.GetValidationResult(value, context);
|
||||
}
|
||||
|
||||
private static bool IsValid(object instance) => Validator.TryValidateObject(instance, new ValidationContext(instance), null, true);
|
||||
|
||||
#endregion
|
||||
|
||||
#region 示例1:等于时必填 [RequiredIF(nameof(TarProperty), 1)]
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, null, false)] // TarProperty=1, SomeProperty=null → 失败
|
||||
[InlineData(1, "", false)] // TarProperty=1, SomeProperty="" → 失败
|
||||
[InlineData(1, " ", false)] // TarProperty=1, SomeProperty=" " → 失败
|
||||
[InlineData(1, "a", true)] // TarProperty=1, SomeProperty="a" → 成功
|
||||
[InlineData(2, null, true)] // TarProperty≠1, SomeProperty=null → 成功(不触发)
|
||||
public void RequiredIF_Equal_WhenTargetIsValue_ShouldRequire(int tarProperty, string someProperty, bool expectedValid)
|
||||
{
|
||||
var model = new RequiredIfTest1Input { TarProperty = tarProperty, SomeProperty = someProperty };
|
||||
var result = ValidateProperty(model, nameof(model.SomeProperty), someProperty);
|
||||
Assert.Equal(expectedValid, result == ValidationResult.Success);
|
||||
}
|
||||
|
||||
private class RequiredIfTest1Input
|
||||
{
|
||||
public int TarProperty { get; set; }
|
||||
[RequiredIF(nameof(TarProperty), 1, ErrorMessage = "TarProperty为1时,SomeProperty不能为空")]
|
||||
public string SomeProperty { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 示例2:不等于时必填 [RequiredIF(nameof(TarProperty), 1, Operator.NotEqual)]
|
||||
|
||||
[Theory]
|
||||
[InlineData(1, "a", true)] // TarProperty=1 → 不触发必填
|
||||
[InlineData(2, null, false)] // TarProperty≠1, SomeProperty=null → 必填失败
|
||||
[InlineData(0, "", false)] // TarProperty≠1, SomeProperty="" → 失败
|
||||
[InlineData(3, "ok", true)] // TarProperty≠1, SomeProperty=ok → 成功
|
||||
public void RequiredIF_NotEqual_WhenTargetIsNotValue_ShouldRequire(int tarProperty, string someProperty, bool expectedValid)
|
||||
{
|
||||
var model = new RequiredIfTest2Input { TarProperty = tarProperty, SomeProperty = someProperty };
|
||||
var result = ValidateProperty(model, nameof(model.SomeProperty), someProperty);
|
||||
Assert.Equal(expectedValid, result == ValidationResult.Success);
|
||||
}
|
||||
|
||||
private class RequiredIfTest2Input
|
||||
{
|
||||
public int TarProperty { get; set; }
|
||||
[RequiredIF(nameof(TarProperty), 1, Operator.NotEqual, ErrorMessage = "TarProperty不为1时,SomeProperty不能为空")]
|
||||
public string SomeProperty { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 示例3:包含时必填 [RequiredIF(nameof(TarProperty), new[]{1,2}, Operator.Contains)]
|
||||
|
||||
[Theory]
|
||||
[InlineData(new int[] { 1 }, null, false)]
|
||||
[InlineData(new int[] { 2 }, "", false)]
|
||||
[InlineData(new int[] { 1, 3 }, "x", true)]
|
||||
[InlineData(new int[] { 3, 4 }, null, true)] // 不包含1或2 → 不必填
|
||||
public void RequiredIF_Contains_WhenTargetContainsValue_ShouldRequire(int[] tarProperty, string someProperty, bool expectedValid)
|
||||
{
|
||||
var model = new RequiredIfTest3Input { TarProperty = tarProperty, SomeProperty = someProperty };
|
||||
var result = ValidateProperty(model, nameof(model.SomeProperty), someProperty);
|
||||
Assert.Equal(expectedValid, result == ValidationResult.Success);
|
||||
}
|
||||
|
||||
private class RequiredIfTest3Input
|
||||
{
|
||||
public int[] TarProperty { get; set; }
|
||||
[RequiredIF(nameof(TarProperty), new object[] { 1, 2 }, Operator.Contains, ErrorMessage = "TarProperty包含1或2时,SomeProperty不能为空")]
|
||||
public string SomeProperty { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 示例4:不包含时必填 [RequiredIF(nameof(TarProperty), new[]{1,2}, Operator.NotContains)]
|
||||
|
||||
[Theory]
|
||||
[InlineData(new[] { 1 }, null, true)] // 包含1 → 不必填
|
||||
[InlineData(new[] { 2 }, "", true)] // 包含2 → 不必填
|
||||
[InlineData(new[] { 3, 4 }, null, false)] // 不包含1,2 → 必填
|
||||
[InlineData(new[] { 3, 4 }, "ok", true)] // 不包含1,2 → 必填
|
||||
public void RequiredIF_NotContains_WhenTargetNotContainsValue_ShouldRequire(int[] tarProperty, string someProperty, bool expectedValid)
|
||||
{
|
||||
var result = IsValid(new RequiredIfTest4Input { TarProperty = tarProperty, SomeProperty = someProperty });
|
||||
Assert.Equal(expectedValid, result);
|
||||
}
|
||||
|
||||
private class RequiredIfTest4Input
|
||||
{
|
||||
public int[] TarProperty { get; set; }
|
||||
|
||||
[RequiredIF(nameof(TarProperty), new object[] { 1, 2 }, Operator.NotContains, ErrorMessage = "TarProperty不包含1和2时,SomeProperty不能为空")]
|
||||
public string SomeProperty { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 特殊类型支持:long、string、IList 等
|
||||
|
||||
[Theory]
|
||||
[InlineData(0L, null, false)] // long=0 → 必填,无值 → 失败
|
||||
[InlineData(1L, null, true)] // long=1 ≠ 0 → 不必填
|
||||
[InlineData(0L, "a", true)] // long=0 → 必填,有值 → 成功
|
||||
public void RequiredIF_WhenTargetIsLong_ShouldTreatZeroAsEmpty(long tarProperty, string someProperty, bool expectedValid)
|
||||
{
|
||||
var model = new RequiredIfTestLongInput { TarProperty = tarProperty, SomeProperty = someProperty };
|
||||
var result = ValidateProperty(model, nameof(model.SomeProperty), someProperty);
|
||||
Assert.Equal(expectedValid, result == ValidationResult.Success);
|
||||
}
|
||||
|
||||
private class RequiredIfTestLongInput
|
||||
{
|
||||
public long TarProperty { get; set; }
|
||||
|
||||
[RequiredIF(nameof(TarProperty), ErrorMessage = "TarProperty为0时,SomeProperty不能为空")]
|
||||
public string SomeProperty { get; set; }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(new string[] { }, null, false)] // 空集合 → 必填
|
||||
[InlineData(new[] { "a" }, null, true)] // 非空集合 → 非必填
|
||||
public void RequiredIF_WhenTargetIsList_ShouldTreatEmptyAsNull(string[] tarProperty, string someProperty, bool expectedValid)
|
||||
{
|
||||
var model = new RequiredIfTestListInput { TarProperty = tarProperty, SomeProperty = someProperty };
|
||||
var result = ValidateProperty(model, nameof(model.SomeProperty), someProperty);
|
||||
Assert.Equal(expectedValid, result == ValidationResult.Success);
|
||||
}
|
||||
|
||||
private class RequiredIfTestListInput
|
||||
{
|
||||
public string[] TarProperty { get; set; }
|
||||
|
||||
[RequiredIF(nameof(TarProperty), ErrorMessage = "TarProperty为空时,SomeProperty不能为空")]
|
||||
public string SomeProperty { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region 边界情况:属性不存在、null比较、可比较类型(IComparable)
|
||||
|
||||
[Fact]
|
||||
public void RequiredIF_WhenTargetPropertyNotFound_ShouldReturnError()
|
||||
{
|
||||
var model = new RequiredIfTestInvalidProperty { SomeProperty = null };
|
||||
var result = ValidateProperty(model, nameof(model.SomeProperty), null);
|
||||
Assert.IsType<ValidationResult>(result);
|
||||
Assert.Contains("找不到属性", result.ErrorMessage);
|
||||
}
|
||||
|
||||
private class RequiredIfTestInvalidProperty
|
||||
{
|
||||
[RequiredIF("NonExistentProperty", "test")]
|
||||
public string SomeProperty { get; set; }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, null, Operator.Equal, false)]
|
||||
[InlineData(null, "a", Operator.Equal, false)]
|
||||
[InlineData("a", null, Operator.NotEqual, true)]
|
||||
[InlineData(null, null, Operator.NotEqual, true)]
|
||||
public void RequiredIF_NullComparison_ShouldWork(object source, object target, Operator op, bool expectedSuccess)
|
||||
{
|
||||
var model = new RequiredIfTestNullInput { Source = source, TargetValue = target };
|
||||
var attr = new RequiredIFAttribute(nameof(RequiredIfTestNullInput.TargetValue), target, op);
|
||||
var result = attr.GetValidationResult(source, new ValidationContext(model) { MemberName = nameof(model.Source) });
|
||||
Assert.Equal(expectedSuccess, result == ValidationResult.Success);
|
||||
}
|
||||
|
||||
private class RequiredIfTestNullInput
|
||||
{
|
||||
public object Source { get; set; }
|
||||
public object TargetValue { get; set; }
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null, 4, Operator.GreaterThan, false)] // [RequiredIF(nameof(TargetValue), 3, Operator.GreaterThan))]
|
||||
[InlineData(1, 4, Operator.GreaterThan, true)] // [RequiredIF(nameof(TargetValue), 3, Operator.GreaterThan))]
|
||||
[InlineData(null, 3, Operator.GreaterThanOrEqual, false)] // [RequiredIF(nameof(TargetValue), 3, Operator.GreaterThanOrEqual))]
|
||||
[InlineData(null, 2, Operator.LessThan, false)] // [RequiredIF(nameof(TargetValue), 3, Operator.LessThan))]
|
||||
public void RequiredIF_ComparableTypes_ShouldSupportComparison(int? source, int target, Operator op, bool expectedSuccess)
|
||||
{
|
||||
var model = new RequiredIfTestComparable { Source = source, TargetValue = target };
|
||||
var attr = new RequiredIFAttribute(nameof(RequiredIfTestComparable.TargetValue), 3, op);
|
||||
var result = attr.GetValidationResult(source, new ValidationContext(model) { MemberName = nameof(model.Source) });
|
||||
Assert.Equal(expectedSuccess, result == ValidationResult.Success);
|
||||
}
|
||||
|
||||
private class RequiredIfTestComparable
|
||||
{
|
||||
public int? Source { get; set; }
|
||||
public int TargetValue { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
65
Admin.NET/Admin.NET.Test/GlobalUsings.cs
Normal file
65
Admin.NET/Admin.NET.Test/GlobalUsings.cs
Normal file
@ -0,0 +1,65 @@
|
||||
// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
|
||||
//
|
||||
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
global using Admin.NET.Core.Service;
|
||||
global using Admin.NET.Core;
|
||||
global using Furion;
|
||||
global using Furion.ConfigurableOptions;
|
||||
global using Furion.DatabaseAccessor;
|
||||
global using Furion.DataEncryption;
|
||||
global using Furion.DataValidation;
|
||||
global using Furion.DependencyInjection;
|
||||
global using Furion.DynamicApiController;
|
||||
global using Furion.EventBus;
|
||||
global using Furion.FriendlyException;
|
||||
global using Furion.HttpRemote;
|
||||
global using Furion.JsonSerialization;
|
||||
global using Furion.Logging;
|
||||
global using Furion.Logging.Extensions;
|
||||
global using Furion.Schedule;
|
||||
global using Furion.UnifyResult;
|
||||
global using Furion.ViewEngine;
|
||||
global using Magicodes.ExporterAndImporter.Core;
|
||||
global using Magicodes.ExporterAndImporter.Core.Extension;
|
||||
global using Magicodes.ExporterAndImporter.Excel;
|
||||
global using Mapster;
|
||||
global using Microsoft.AspNetCore.Authorization;
|
||||
global using Microsoft.AspNetCore.Http;
|
||||
global using Microsoft.AspNetCore.Mvc;
|
||||
global using Microsoft.AspNetCore.Mvc.Filters;
|
||||
global using Microsoft.Extensions.Configuration;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.Hosting;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Microsoft.Extensions.Options;
|
||||
global using Microsoft.SemanticKernel;
|
||||
global using NewLife;
|
||||
global using NewLife.Caching;
|
||||
global using Newtonsoft.Json.Linq;
|
||||
global using SKIT.FlurlHttpClient;
|
||||
global using SKIT.FlurlHttpClient.Wechat.Api;
|
||||
global using SKIT.FlurlHttpClient.Wechat.Api.Models;
|
||||
global using SKIT.FlurlHttpClient.Wechat.TenpayV3;
|
||||
global using SKIT.FlurlHttpClient.Wechat.TenpayV3.Events;
|
||||
global using SKIT.FlurlHttpClient.Wechat.TenpayV3.Models;
|
||||
global using SKIT.FlurlHttpClient.Wechat.TenpayV3.Settings;
|
||||
global using SqlSugar;
|
||||
global using System.Collections;
|
||||
global using System.Collections.Concurrent;
|
||||
global using System.ComponentModel;
|
||||
global using System.ComponentModel.DataAnnotations;
|
||||
global using System.Data;
|
||||
global using System.Diagnostics;
|
||||
global using System.Linq.Dynamic.Core;
|
||||
global using System.Linq.Expressions;
|
||||
global using System.Reflection;
|
||||
global using System.Runtime.InteropServices;
|
||||
global using System.Text;
|
||||
global using System.Text.RegularExpressions;
|
||||
global using System.Web;
|
||||
global using UAParser;
|
||||
global using Xunit;
|
||||
global using Yitter.IdGenerator;
|
||||
24
Admin.NET/Admin.NET.Test/TestProgram.cs
Normal file
24
Admin.NET/Admin.NET.Test/TestProgram.cs
Normal file
@ -0,0 +1,24 @@
|
||||
// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
|
||||
//
|
||||
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
// 配置启动类类型,第一个参数是 TestProgram 类完整限定名,第二个参数是当前项目程序集名称
|
||||
|
||||
using Furion.Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
[assembly: TestFramework("Admin.NET.Test.TestProgram", "Admin.NET.Test")]
|
||||
namespace Admin.NET.Test;
|
||||
|
||||
/// <summary>
|
||||
/// 单元测试启动类
|
||||
/// </summary>
|
||||
public class TestProgram : TestStartup
|
||||
{
|
||||
public TestProgram(IMessageSink messageSink) : base(messageSink)
|
||||
{
|
||||
Serve.RunNative();
|
||||
}
|
||||
}
|
||||
262
Admin.NET/Admin.NET.Test/Utils/CustomJsonHelperTest.cs
Normal file
262
Admin.NET/Admin.NET.Test/Utils/CustomJsonHelperTest.cs
Normal file
@ -0,0 +1,262 @@
|
||||
// Admin.NET 项目的版权、商标、专利和其他相关权利均受相应法律法规的保护。使用本项目应遵守相关法律法规和许可证的要求。
|
||||
//
|
||||
// 本项目主要遵循 MIT 许可证和 Apache 许可证(版本 2.0)进行分发和使用。许可证位于源代码树根目录中的 LICENSE-MIT 和 LICENSE-APACHE 文件。
|
||||
//
|
||||
// 不得利用本项目从事危害国家安全、扰乱社会秩序、侵犯他人合法权益等法律法规禁止的活动!任何基于本项目二次开发而产生的一切法律纠纷和责任,我们不承担任何责任!
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Admin.NET.Test.Utils;
|
||||
|
||||
public class CustomJsonHelperTest
|
||||
{
|
||||
// 测试基本数据类型序列化
|
||||
[Fact]
|
||||
public void Serialize_BasicTypes_ShouldWork()
|
||||
{
|
||||
var obj = new BasicTypesModel
|
||||
{
|
||||
IntValue = 42,
|
||||
StringValue = "Hello",
|
||||
BoolValue = true,
|
||||
DoubleValue = 3.14,
|
||||
DecimalValue = 123.45m
|
||||
};
|
||||
|
||||
var json = CustomJsonHelper.Serialize(obj);
|
||||
var deserialized = CustomJsonHelper.Deserialize<BasicTypesModel>(json);
|
||||
|
||||
Assert.Equal(obj.IntValue, deserialized.IntValue);
|
||||
Assert.Equal(obj.StringValue, deserialized.StringValue);
|
||||
Assert.Equal(obj.BoolValue, deserialized.BoolValue);
|
||||
Assert.Equal(obj.DoubleValue, deserialized.DoubleValue);
|
||||
Assert.Equal(obj.DecimalValue, deserialized.DecimalValue);
|
||||
}
|
||||
|
||||
// 测试可空类型
|
||||
[Fact]
|
||||
public void Serialize_NullableTypes_ShouldWork()
|
||||
{
|
||||
var obj = new NullableTypesModel
|
||||
{
|
||||
NullableInt = null,
|
||||
NullableDateTime = DateTime.Now,
|
||||
NullableBool = true
|
||||
};
|
||||
|
||||
var json = CustomJsonHelper.Serialize(obj);
|
||||
var deserialized = CustomJsonHelper.Deserialize<NullableTypesModel>(json);
|
||||
|
||||
Assert.Null(deserialized.NullableInt);
|
||||
Assert.Equal(obj.NullableDateTime, deserialized.NullableDateTime);
|
||||
Assert.True(deserialized.NullableBool);
|
||||
}
|
||||
|
||||
// 测试自定义属性名和日期格式
|
||||
[Fact]
|
||||
public void Serialize_CustomJsonProperty_ShouldUseCustomNameAndDateFormat()
|
||||
{
|
||||
var obj = new CustomPropertyModel
|
||||
{
|
||||
NormalProperty = "Normal",
|
||||
CustomNameProperty = "Custom",
|
||||
DateTimeProperty = new DateTime(2023, 1, 1, 12, 0, 0)
|
||||
};
|
||||
|
||||
var json = CustomJsonHelper.Serialize(obj);
|
||||
|
||||
// 验证自定义属性名和日期格式
|
||||
Assert.Contains("\"custom_name\"", json);
|
||||
Assert.Contains("\"2023-01-01 12:00:00\"", json);
|
||||
|
||||
var deserialized = CustomJsonHelper.Deserialize<CustomPropertyModel>(json);
|
||||
Assert.Equal(obj.NormalProperty, deserialized.NormalProperty);
|
||||
Assert.Equal(obj.CustomNameProperty, deserialized.CustomNameProperty);
|
||||
Assert.Equal(obj.DateTimeProperty, deserialized.DateTimeProperty);
|
||||
}
|
||||
|
||||
// 测试嵌套对象
|
||||
[Fact]
|
||||
public void Serialize_NestedObjects_ShouldWork()
|
||||
{
|
||||
var obj = new NestedModel
|
||||
{
|
||||
Id = 1,
|
||||
Inner = new BasicTypesModel
|
||||
{
|
||||
IntValue = 100,
|
||||
StringValue = "Inner"
|
||||
}
|
||||
};
|
||||
|
||||
var json = CustomJsonHelper.Serialize(obj);
|
||||
var deserialized = CustomJsonHelper.Deserialize<NestedModel>(json);
|
||||
|
||||
Assert.Equal(obj.Id, deserialized.Id);
|
||||
Assert.Equal(obj.Inner.IntValue, deserialized.Inner.IntValue);
|
||||
Assert.Equal(obj.Inner.StringValue, deserialized.Inner.StringValue);
|
||||
}
|
||||
|
||||
// 测试循环引用(通过引用处理)
|
||||
[Fact]
|
||||
public void Serialize_CircularReference_ShouldHandleGracefully()
|
||||
{
|
||||
var parent = new ParentModel { Name = "Parent" };
|
||||
var child = new ChildModel { Name = "Child", Parent = parent };
|
||||
parent.Child = child;
|
||||
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
ReferenceHandler = ReferenceHandler.IgnoreCycles
|
||||
};
|
||||
|
||||
var json = CustomJsonHelper.Serialize(parent);
|
||||
var deserialized = CustomJsonHelper.Deserialize<ParentModel>(json, options);
|
||||
|
||||
Assert.Equal(parent.Name, deserialized.Name);
|
||||
Assert.Equal(parent.Child.Name, deserialized.Child.Name);
|
||||
// 循环引用部分会被忽略,所以 Parent.Child.Parent 会是 null
|
||||
Assert.Null(deserialized.Child.Parent);
|
||||
}
|
||||
|
||||
// 测试忽略序列化特性
|
||||
[Fact]
|
||||
public void Serialize_JsonIgnoreAttribute_ShouldIgnoreProperty()
|
||||
{
|
||||
var obj = new IgnorePropertyModel
|
||||
{
|
||||
SerializedProperty = "Serialized",
|
||||
IgnoredProperty = "Ignored"
|
||||
};
|
||||
|
||||
var json = CustomJsonHelper.Serialize(obj);
|
||||
|
||||
Assert.Contains("\"SerializedProperty\"", json);
|
||||
Assert.DoesNotContain("\"ignoredProperty\"", json);
|
||||
|
||||
var deserialized = CustomJsonHelper.Deserialize<IgnorePropertyModel>(json);
|
||||
Assert.Equal(obj.SerializedProperty, deserialized.SerializedProperty);
|
||||
Assert.Null(deserialized.IgnoredProperty);
|
||||
}
|
||||
|
||||
// 测试枚举类型
|
||||
[Fact]
|
||||
public void Serialize_EnumTypes_ShouldWork()
|
||||
{
|
||||
var obj = new EnumModel
|
||||
{
|
||||
Status = Status.Active,
|
||||
NullableStatus = Status.Inactive
|
||||
};
|
||||
|
||||
var json = CustomJsonHelper.Serialize(obj);
|
||||
var deserialized = CustomJsonHelper.Deserialize<EnumModel>(json);
|
||||
|
||||
Assert.Equal(obj.Status, deserialized.Status);
|
||||
Assert.Equal(obj.NullableStatus, deserialized.NullableStatus);
|
||||
}
|
||||
|
||||
// 测试空对象
|
||||
[Fact]
|
||||
public void Serialize_EmptyObject_ShouldWork()
|
||||
{
|
||||
var obj = new EmptyModel();
|
||||
var json = CustomJsonHelper.Serialize(obj);
|
||||
var deserialized = CustomJsonHelper.Deserialize<EmptyModel>(json);
|
||||
|
||||
Assert.NotNull(deserialized);
|
||||
}
|
||||
|
||||
// 测试数组和集合
|
||||
[Fact]
|
||||
public void Serialize_ArraysAndCollections_ShouldWork()
|
||||
{
|
||||
var obj = new CollectionModel
|
||||
{
|
||||
IntArray = [1, 2, 3],
|
||||
StringList = ["a", "b", "c"]
|
||||
};
|
||||
|
||||
var json = CustomJsonHelper.Serialize(obj);
|
||||
var deserialized = CustomJsonHelper.Deserialize<CollectionModel>(json);
|
||||
|
||||
Assert.Equal(obj.IntArray, deserialized.IntArray);
|
||||
Assert.Equal(obj.StringList, deserialized.StringList);
|
||||
}
|
||||
|
||||
|
||||
// 测试模型类
|
||||
private class BasicTypesModel
|
||||
{
|
||||
public int IntValue { get; init; }
|
||||
public string StringValue { get; init; }
|
||||
public bool BoolValue { get; init; }
|
||||
public double DoubleValue { get; init; }
|
||||
public decimal DecimalValue { get; init; }
|
||||
}
|
||||
|
||||
private class NullableTypesModel
|
||||
{
|
||||
public int? NullableInt { get; init; }
|
||||
public DateTime? NullableDateTime { get; init; }
|
||||
public bool? NullableBool { get; init; }
|
||||
}
|
||||
|
||||
private class CustomPropertyModel
|
||||
{
|
||||
public string NormalProperty { get; init; }
|
||||
|
||||
[CustomJsonProperty("custom_name")] public string CustomNameProperty { get; init; }
|
||||
|
||||
[CustomJsonProperty("date_time", DateFormat = "yyyy-MM-dd HH:mm:ss")]
|
||||
public DateTime DateTimeProperty { get; init; }
|
||||
}
|
||||
|
||||
private class NestedModel
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public BasicTypesModel Inner { get; init; }
|
||||
}
|
||||
|
||||
public class ParentModel
|
||||
{
|
||||
public string Name { get; init; }
|
||||
public ChildModel Child { get; set; }
|
||||
}
|
||||
|
||||
public class ChildModel
|
||||
{
|
||||
public string Name { get; init; }
|
||||
public ParentModel Parent { get; init; }
|
||||
}
|
||||
|
||||
private class IgnorePropertyModel
|
||||
{
|
||||
public string SerializedProperty { get; init; }
|
||||
|
||||
[JsonIgnore] public string IgnoredProperty { get; init; }
|
||||
}
|
||||
|
||||
private enum Status
|
||||
{
|
||||
Active,
|
||||
Inactive
|
||||
}
|
||||
|
||||
private class EnumModel
|
||||
{
|
||||
public Status Status { get; init; }
|
||||
public Status? NullableStatus { get; init; }
|
||||
}
|
||||
|
||||
private class EmptyModel
|
||||
{
|
||||
}
|
||||
|
||||
private class CollectionModel
|
||||
{
|
||||
public int[] IntArray { get; init; }
|
||||
public List<string> StringList { get; init; }
|
||||
}
|
||||
}
|
||||
@ -32,6 +32,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Admin.NET.Plugin.PaddleOCR"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Admin.NET.Plugin.WorkWeixin", "Plugins\Admin.NET.Plugin.WorkWeixin\Admin.NET.Plugin.WorkWeixin.csproj", "{12998618-A875-4580-B5B1-0CC50CE85F27}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Admin.NET.Test", "Admin.NET.Test\Admin.NET.Test.csproj", "{8F4A19E9-EEBC-483A-A78B-06F1CE852C7A}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@ -82,6 +84,10 @@ Global
|
||||
{12998618-A875-4580-B5B1-0CC50CE85F27}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{12998618-A875-4580-B5B1-0CC50CE85F27}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{12998618-A875-4580-B5B1-0CC50CE85F27}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8F4A19E9-EEBC-483A-A78B-06F1CE852C7A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8F4A19E9-EEBC-483A-A78B-06F1CE852C7A}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8F4A19E9-EEBC-483A-A78B-06F1CE852C7A}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8F4A19E9-EEBC-483A-A78B-06F1CE852C7A}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
Loading…
Reference in New Issue
Block a user