添加项目文件。
This commit is contained in:
71
QYZH.InteractiveMagazine.Infrastructure/Auth/JwtHelper.cs
Normal file
71
QYZH.InteractiveMagazine.Infrastructure/Auth/JwtHelper.cs
Normal file
@ -0,0 +1,71 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using QYZH.InteractiveMagazine.Models.Settings;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace QYZH.InteractiveMagazine.Infrastructure.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// JWT工具类
|
||||
/// </summary>
|
||||
public static class JwtHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// 生成JWT令牌
|
||||
/// </summary>
|
||||
/// <param name="userId">用户ID</param>
|
||||
/// <param name="userName">用户名</param>
|
||||
/// <param name="settings">JWT配置</param>
|
||||
/// <returns>JWT令牌字符串</returns>
|
||||
public static string GenerateToken(long userId, string userName, JwtSettings settings)
|
||||
{
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
|
||||
new Claim(JwtRegisteredClaimNames.Name, userName),
|
||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||
new Claim(ClaimTypes.Name, userName)
|
||||
};
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(settings.SecretKey!));
|
||||
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: settings.Issuer,
|
||||
audience: settings.Audience,
|
||||
claims: claims,
|
||||
expires: DateTime.Now.AddMinutes(settings.ExpiryMinutes),
|
||||
signingCredentials: credentials
|
||||
);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证JWT令牌
|
||||
/// </summary>
|
||||
/// <param name="token">JWT令牌</param>
|
||||
/// <param name="settings">JWT配置</param>
|
||||
/// <returns>ClaimsPrincipal对象</returns>
|
||||
public static ClaimsPrincipal ValidateToken(string token, JwtSettings settings)
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
var key = Encoding.UTF8.GetBytes(settings.SecretKey!);
|
||||
|
||||
var validationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = settings.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = settings.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(key),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
|
||||
return tokenHandler.ValidateToken(token, validationParameters, out _);
|
||||
}
|
||||
}
|
||||
119
QYZH.InteractiveMagazine.Infrastructure/Cache/RedisHelper.cs
Normal file
119
QYZH.InteractiveMagazine.Infrastructure/Cache/RedisHelper.cs
Normal file
@ -0,0 +1,119 @@
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace QYZH.InteractiveMagazine.Infrastructure.Cache;
|
||||
|
||||
/// <summary>
|
||||
/// Redis操作封装
|
||||
/// </summary>
|
||||
public static class RedisHelper
|
||||
{
|
||||
private static string? _keyPrefix;
|
||||
|
||||
/// <summary>
|
||||
/// Redis连接实例
|
||||
/// </summary>
|
||||
public static IConnectionMultiplexer? Connection { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 设置键前缀
|
||||
/// </summary>
|
||||
/// <param name="prefix">前缀字符串</param>
|
||||
public static void SetKeyPrefix(string prefix)
|
||||
{
|
||||
_keyPrefix = prefix;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取Redis数据库实例
|
||||
/// </summary>
|
||||
/// <param name="db">数据库索引</param>
|
||||
/// <returns>IDatabase实例</returns>
|
||||
public static IDatabase GetDatabase(int db = -1)
|
||||
{
|
||||
if (Connection == null || !Connection.IsConnected)
|
||||
{
|
||||
throw new InvalidOperationException("Redis连接未初始化或已断开");
|
||||
}
|
||||
|
||||
return Connection.GetDatabase(db);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置字符串值
|
||||
/// </summary>
|
||||
/// <param name="key">键</param>
|
||||
/// <param name="value">值</param>
|
||||
/// <param name="expiry">过期时间</param>
|
||||
/// <returns>是否成功</returns>
|
||||
public static async Task<bool> StringSetAsync(string key, string value, TimeSpan? expiry = null)
|
||||
{
|
||||
var db = GetDatabase();
|
||||
var prefixedKey = GetPrefixedKey(key);
|
||||
|
||||
if (expiry.HasValue)
|
||||
{
|
||||
return await db.StringSetAsync(prefixedKey, value, (Expiration)expiry.Value);
|
||||
}
|
||||
|
||||
return await db.StringSetAsync(prefixedKey, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取字符串值
|
||||
/// </summary>
|
||||
/// <param name="key">键</param>
|
||||
/// <returns>值</returns>
|
||||
public static async Task<string?> StringGetAsync(string key)
|
||||
{
|
||||
var db = GetDatabase();
|
||||
var prefixedKey = GetPrefixedKey(key);
|
||||
return await db.StringGetAsync(prefixedKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除键
|
||||
/// </summary>
|
||||
/// <param name="key">键</param>
|
||||
/// <returns>是否成功</returns>
|
||||
public static async Task<bool> KeyDeleteAsync(string key)
|
||||
{
|
||||
var db = GetDatabase();
|
||||
var prefixedKey = GetPrefixedKey(key);
|
||||
return await db.KeyDeleteAsync(prefixedKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查键是否存在
|
||||
/// </summary>
|
||||
/// <param name="key">键</param>
|
||||
/// <returns>是否存在</returns>
|
||||
public static async Task<bool> KeyExistsAsync(string key)
|
||||
{
|
||||
var db = GetDatabase();
|
||||
var prefixedKey = GetPrefixedKey(key);
|
||||
return await db.KeyExistsAsync(prefixedKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置键的过期时间
|
||||
/// </summary>
|
||||
/// <param name="key">键</param>
|
||||
/// <param name="expiry">过期时间</param>
|
||||
/// <returns>是否成功</returns>
|
||||
public static async Task<bool> KeyExpireAsync(string key, TimeSpan expiry)
|
||||
{
|
||||
var db = GetDatabase();
|
||||
var prefixedKey = GetPrefixedKey(key);
|
||||
return await db.KeyExpireAsync(prefixedKey, expiry);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取带前缀的键
|
||||
/// </summary>
|
||||
/// <param name="key">原始键</param>
|
||||
/// <returns>带前缀的键</returns>
|
||||
private static string GetPrefixedKey(string key)
|
||||
{
|
||||
return string.IsNullOrEmpty(_keyPrefix) ? key : $"{_keyPrefix}:{key}";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using QYZH.InteractiveMagazine.Infrastructure.Cache;
|
||||
using QYZH.InteractiveMagazine.Infrastructure.MessageQueue;
|
||||
using QYZH.InteractiveMagazine.Infrastructure.Middleware;
|
||||
using QYZH.InteractiveMagazine.Models.Settings;
|
||||
using RabbitMQ.Client;
|
||||
using StackExchange.Redis;
|
||||
using System.Text;
|
||||
|
||||
namespace QYZH.InteractiveMagazine.Infrastructure.Extensions;
|
||||
|
||||
/// <summary>
|
||||
/// 统一服务注册扩展
|
||||
/// </summary>
|
||||
public static class DependencyInjectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// 注册基础设施服务
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="configuration">配置</param>
|
||||
public static void AddInfrastructureServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
AddJwtAuthentication(services, configuration);
|
||||
AddRedisCache(services, configuration);
|
||||
AddRabbitMQ(services, configuration);
|
||||
|
||||
services.AddTransient<GlobalExceptionMiddleware>();
|
||||
services.AddTransient<OperationLogMiddleware>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 配置JWT认证
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="configuration">配置</param>
|
||||
private static void AddJwtAuthentication(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var jwtSettings = configuration.GetSection("JwtSettings").Get<JwtSettings>()!;
|
||||
|
||||
services.AddSingleton(jwtSettings);
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = jwtSettings.Issuer,
|
||||
ValidateAudience = true,
|
||||
ValidAudience = jwtSettings.Audience,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSettings.SecretKey!)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.Zero
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册Redis缓存
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="configuration">配置</param>
|
||||
private static void AddRedisCache(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var redisSettings = configuration.GetSection("RedisSettings").Get<RedisSettings>()!;
|
||||
|
||||
services.AddSingleton(redisSettings);
|
||||
|
||||
services.AddSingleton<IConnectionMultiplexer>(sp =>
|
||||
{
|
||||
var multiplexer = ConnectionMultiplexer.Connect(redisSettings.ConnectionString!);
|
||||
RedisHelper.Connection = multiplexer;
|
||||
RedisHelper.SetKeyPrefix(redisSettings.InstanceName!);
|
||||
return multiplexer;
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 注册RabbitMQ
|
||||
/// </summary>
|
||||
/// <param name="services">服务集合</param>
|
||||
/// <param name="configuration">配置</param>
|
||||
private static void AddRabbitMQ(IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var rabbitMQSettings = configuration.GetSection("RabbitMQSettings").Get<RabbitMQSettings>()!;
|
||||
|
||||
services.AddSingleton(rabbitMQSettings);
|
||||
|
||||
services.AddSingleton<IConnection>(sp =>
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = rabbitMQSettings.HostName!,
|
||||
Port = rabbitMQSettings.Port,
|
||||
UserName = rabbitMQSettings.UserName!,
|
||||
Password = rabbitMQSettings.Password!,
|
||||
VirtualHost = rabbitMQSettings.VirtualHost!
|
||||
};
|
||||
|
||||
return factory.CreateConnectionAsync().GetAwaiter().GetResult();
|
||||
});
|
||||
|
||||
services.AddTransient<RabbitMQPublisher>();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
using System.Text;
|
||||
|
||||
namespace QYZH.InteractiveMagazine.Infrastructure.MessageQueue;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ消息消费者基类
|
||||
/// </summary>
|
||||
public abstract class RabbitMQConsumer : IDisposable
|
||||
{
|
||||
private readonly IConnection _connection;
|
||||
private readonly ILogger<RabbitMQConsumer> _logger;
|
||||
private IChannel? _channel;
|
||||
private AsyncEventingBasicConsumer? _consumer;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="connection">RabbitMQ连接</param>
|
||||
/// <param name="logger">日志记录器</param>
|
||||
protected RabbitMQConsumer(IConnection connection, ILogger<RabbitMQConsumer> logger)
|
||||
{
|
||||
_connection = connection;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 启动消费
|
||||
/// </summary>
|
||||
/// <param name="queueName">队列名称</param>
|
||||
/// <param name="handleMessage">消息处理委托</param>
|
||||
public async Task StartConsume(string queueName, Func<string, Task> handleMessage)
|
||||
{
|
||||
_channel = await _connection.CreateChannelAsync();
|
||||
|
||||
_consumer = new AsyncEventingBasicConsumer(_channel);
|
||||
_consumer.ReceivedAsync += async (model, ea) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = ea.Body.ToArray();
|
||||
var message = Encoding.UTF8.GetString(body);
|
||||
|
||||
await handleMessage(message);
|
||||
|
||||
await _channel.BasicAckAsync(ea.DeliveryTag, false);
|
||||
|
||||
_logger.LogInformation("消息消费成功 | 队列: {QueueName} | 消息: {Message}", queueName, message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "消息消费失败 | 队列: {QueueName}", queueName);
|
||||
await _channel.BasicNackAsync(ea.DeliveryTag, false, true);
|
||||
}
|
||||
};
|
||||
|
||||
await _channel.BasicConsumeAsync(queue: queueName, autoAck: false, consumer: _consumer);
|
||||
|
||||
_logger.LogInformation("开始消费消息 | 队列: {QueueName}", queueName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 释放资源
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_channel?.DisposeAsync().GetAwaiter().GetResult();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
using System.Text;
|
||||
|
||||
namespace QYZH.InteractiveMagazine.Infrastructure.MessageQueue;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ消息发布器
|
||||
/// </summary>
|
||||
public class RabbitMQPublisher
|
||||
{
|
||||
private readonly IConnection _connection;
|
||||
private readonly ILogger<RabbitMQPublisher> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="connection">RabbitMQ连接</param>
|
||||
/// <param name="logger">日志记录器</param>
|
||||
public RabbitMQPublisher(IConnection connection, ILogger<RabbitMQPublisher> logger)
|
||||
{
|
||||
_connection = connection;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 发布消息
|
||||
/// </summary>
|
||||
/// <param name="exchange">交换机名称</param>
|
||||
/// <param name="routingKey">路由键</param>
|
||||
/// <param name="message">消息内容</param>
|
||||
public async Task PublishMessage(string exchange, string routingKey, string message)
|
||||
{
|
||||
await using var channel = await _connection.CreateChannelAsync();
|
||||
|
||||
var body = Encoding.UTF8.GetBytes(message);
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: exchange,
|
||||
routingKey: routingKey,
|
||||
body: body
|
||||
);
|
||||
|
||||
_logger.LogInformation("消息已发布 | 交换机: {Exchange} | 路由键: {RoutingKey} | 消息: {Message}",
|
||||
exchange, routingKey, message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using QYZH.InteractiveMagazine.Models.Common;
|
||||
using QYZH.InteractiveMagazine.Models.Dto;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace QYZH.InteractiveMagazine.Infrastructure.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// 全局异常中间件
|
||||
/// </summary>
|
||||
public class GlobalExceptionMiddleware : IMiddleware
|
||||
{
|
||||
private readonly ILogger<GlobalExceptionMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="logger">日志记录器</param>
|
||||
public GlobalExceptionMiddleware(ILogger<GlobalExceptionMiddleware> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行中间件
|
||||
/// </summary>
|
||||
/// <param name="context">HTTP上下文</param>
|
||||
/// <param name="next">下一个中间件委托</param>
|
||||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "业务异常:{Message}", ex.Message);
|
||||
await HandleBusinessExceptionAsync(context, ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "系统异常:{Message}", ex.Message);
|
||||
await HandleSystemExceptionAsync(context, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理业务异常
|
||||
/// </summary>
|
||||
/// <param name="context">HTTP上下文</param>
|
||||
/// <param name="ex">业务异常</param>
|
||||
private static async Task HandleBusinessExceptionAsync(HttpContext context, BusinessException ex)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
|
||||
var response = BaseResponse<object>.Fail(ex.Message, ex.Code);
|
||||
var json = JsonSerializer.Serialize(response);
|
||||
|
||||
await context.Response.WriteAsync(json);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 处理系统异常
|
||||
/// </summary>
|
||||
/// <param name="context">HTTP上下文</param>
|
||||
/// <param name="ex">系统异常</param>
|
||||
private static async Task HandleSystemExceptionAsync(HttpContext context, Exception ex)
|
||||
{
|
||||
context.Response.ContentType = "application/json";
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
|
||||
var response = BaseResponse<object>.Fail("系统内部错误,请稍后重试");
|
||||
var json = JsonSerializer.Serialize(response);
|
||||
|
||||
await context.Response.WriteAsync(json);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace QYZH.InteractiveMagazine.Infrastructure.Middleware;
|
||||
|
||||
/// <summary>
|
||||
/// 操作日志中间件
|
||||
/// </summary>
|
||||
public class OperationLogMiddleware : IMiddleware
|
||||
{
|
||||
private readonly ILogger<OperationLogMiddleware> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// 构造函数
|
||||
/// </summary>
|
||||
/// <param name="logger">日志记录器</param>
|
||||
public OperationLogMiddleware(ILogger<OperationLogMiddleware> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行中间件
|
||||
/// </summary>
|
||||
/// <param name="context">HTTP上下文</param>
|
||||
/// <param name="next">下一个中间件委托</param>
|
||||
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
var requestMethod = context.Request.Method;
|
||||
var requestUrl = context.Request.Path.ToString();
|
||||
|
||||
try
|
||||
{
|
||||
await next(context);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var elapsedMilliseconds = stopwatch.ElapsedMilliseconds;
|
||||
|
||||
_logger.LogInformation(
|
||||
"请求完成 | {Method} {Url} | 状态码: {StatusCode} | 耗时: {ElapsedMilliseconds}ms",
|
||||
requestMethod,
|
||||
requestUrl,
|
||||
context.Response.StatusCode,
|
||||
elapsedMilliseconds
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\QYZH.InteractiveMagazine.Common\QYZH.InteractiveMagazine.Common.csproj" />
|
||||
<ProjectReference Include="..\QYZH.InteractiveMagazine.Models\QYZH.InteractiveMagazine.Models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.0" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="SqlSugar" Version="5.1.4.207" />
|
||||
<PackageReference Include="StackExchange.Redis" Version="2.13.17" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user