refactor(api): 提取公共API默认配置并修复积分并发问题
1. 新增InteractiveMagazineApiDefaultsExtensions统一注册共享服务,简化WeChatApi和WebApi的Program.cs代码 2. 修复积分增减的并发问题,改用原子更新操作,添加余额校验避免超扣 3. 修复注释乱码问题,将简体中文注释替换为正确文本 4. 新增.editorconfig配置统一代码格式
This commit is contained in:
10
.editorconfig
Normal file
10
.editorconfig
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = crlf
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
@ -0,0 +1,85 @@
|
|||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.ResponseCompression;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using QYZH.InteractiveMagazine.Common.Helpers;
|
||||||
|
using QYZH.InteractiveMagazine.Infrastructure.Middleware;
|
||||||
|
using QYZH.InteractiveMagazine.Infrastructure.RabbitMQ;
|
||||||
|
using QYZH.InteractiveMagazine.Infrastructure.Redis;
|
||||||
|
using QYZH.InteractiveMagazine.Infrastructure.SDK;
|
||||||
|
using QYZH.InteractiveMagazine.Models.Settings;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace QYZH.InteractiveMagazine.Infrastructure.Extensions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// API 默认服务注册扩展。
|
||||||
|
/// </summary>
|
||||||
|
public static class InteractiveMagazineApiDefaultsExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 注册 WebApi 与 WeChatApi 共享的默认服务。
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="builder">应用构建器。</param>
|
||||||
|
/// <returns>应用构建器。</returns>
|
||||||
|
public static WebApplicationBuilder AddInteractiveMagazineApiDefaults(this WebApplicationBuilder builder)
|
||||||
|
{
|
||||||
|
builder.Services.AddDataProtection()
|
||||||
|
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "DataProtection")));
|
||||||
|
|
||||||
|
builder.Services.AddCSRedisCacheExtension(builder.Configuration.GetSection("RedisSettings"));
|
||||||
|
builder.Services.AddRabbitMQ(builder.Configuration);
|
||||||
|
|
||||||
|
builder.Services.AddControllers(options =>
|
||||||
|
{
|
||||||
|
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
|
||||||
|
options.Filters.Add<ModelValidActionFilterAttribute>();
|
||||||
|
})
|
||||||
|
.AddJsonOptions(options =>
|
||||||
|
{
|
||||||
|
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
||||||
|
options.JsonSerializerOptions.Converters.Add(new JsonConverterUtil.DateTimeConverter());
|
||||||
|
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
||||||
|
})
|
||||||
|
.ConfigureApiBehaviorOptions(opt => opt.SuppressModelStateInvalidFilter = true);
|
||||||
|
|
||||||
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
|
builder.AddCorsRegister();
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
builder.Services.AddInfrastructureServices(builder.Configuration, builder.Environment);
|
||||||
|
builder.Services.AddSDKService(builder.Configuration);
|
||||||
|
builder.Services.Configure<MedalRuleConfigSettings>(builder.Configuration.GetSection("MedalRuleConfig"));
|
||||||
|
|
||||||
|
builder.Services.AddCors(options =>
|
||||||
|
{
|
||||||
|
options.AddPolicy("AllowAll", policy =>
|
||||||
|
{
|
||||||
|
policy.AllowAnyOrigin()
|
||||||
|
.AllowAnyMethod()
|
||||||
|
.AllowAnyHeader();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
|
||||||
|
{
|
||||||
|
options.Level = System.IO.Compression.CompressionLevel.Optimal;
|
||||||
|
});
|
||||||
|
builder.Services.Configure<GzipCompressionProviderOptions>(options =>
|
||||||
|
{
|
||||||
|
options.Level = System.IO.Compression.CompressionLevel.Fastest;
|
||||||
|
});
|
||||||
|
builder.Services.AddResponseCompression(options =>
|
||||||
|
{
|
||||||
|
options.EnableForHttps = true;
|
||||||
|
options.Providers.Add<BrotliCompressionProvider>();
|
||||||
|
options.Providers.Add<GzipCompressionProvider>();
|
||||||
|
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["image/svg+xml", "application/json", "text/plain"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return builder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -180,7 +180,7 @@ public class GenerateUserJournalQrCodeMessage
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 鏈熷垔浜岀淮鐮佹煡璇㈣緭鍏TO
|
/// 期刊二维码查询输入DTO
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class UserJournalQrCodeQueryInput : PageQueryModel
|
public class UserJournalQrCodeQueryInput : PageQueryModel
|
||||||
{
|
{
|
||||||
|
|||||||
@ -73,7 +73,18 @@ public class PointsService(
|
|||||||
if (input.Amount <= 0)
|
if (input.Amount <= 0)
|
||||||
throw new BusinessException("增加积分数量必须大于0", ResultCode.BAD_REQUEST);
|
throw new BusinessException("增加积分数量必须大于0", ResultCode.BAD_REQUEST);
|
||||||
|
|
||||||
// 查询用户当前积分
|
var now = DateTime.Now;
|
||||||
|
|
||||||
|
// 原子增加积分,避免并发写回旧余额覆盖新余额
|
||||||
|
var affectedRows = await Context.Updateable<Users>()
|
||||||
|
.SetColumns(u => u.Points == u.Points + input.Amount)
|
||||||
|
.SetColumns(u => u.UpdatedAt == now)
|
||||||
|
.Where(u => u.Id == input.UserId && !u.IsDeleted)
|
||||||
|
.ExecuteCommandAsync();
|
||||||
|
|
||||||
|
if (affectedRows <= 0)
|
||||||
|
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
|
||||||
|
|
||||||
var user = await Context.Queryable<Users>()
|
var user = await Context.Queryable<Users>()
|
||||||
.Where(u => u.Id == input.UserId && !u.IsDeleted)
|
.Where(u => u.Id == input.UserId && !u.IsDeleted)
|
||||||
.FirstAsync();
|
.FirstAsync();
|
||||||
@ -81,15 +92,8 @@ public class PointsService(
|
|||||||
if (user == null)
|
if (user == null)
|
||||||
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
|
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
|
||||||
|
|
||||||
var previousBalance = user.Points;
|
var newBalance = user.Points;
|
||||||
var newBalance = previousBalance + input.Amount;
|
var previousBalance = newBalance - input.Amount;
|
||||||
|
|
||||||
// 更新用户积分
|
|
||||||
await Context.Updateable<Users>()
|
|
||||||
.SetColumns(u => u.Points == newBalance)
|
|
||||||
.SetColumns(u => u.UpdatedAt == DateTime.Now)
|
|
||||||
.Where(u => u.Id == input.UserId && !u.IsDeleted)
|
|
||||||
.ExecuteCommandAsync();
|
|
||||||
|
|
||||||
// 插入积分流水记录
|
// 插入积分流水记录
|
||||||
var record = new PointsRecord
|
var record = new PointsRecord
|
||||||
@ -104,9 +108,9 @@ public class PointsService(
|
|||||||
Status = (int)PointsRecordStatusEnum.Success,
|
Status = (int)PointsRecordStatusEnum.Success,
|
||||||
IsDeleted = false,
|
IsDeleted = false,
|
||||||
CreatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
|
CreatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
|
||||||
CreatedAt = DateTime.Now,
|
CreatedAt = now,
|
||||||
UpdatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
|
UpdatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
|
||||||
UpdatedAt = DateTime.Now
|
UpdatedAt = now
|
||||||
};
|
};
|
||||||
|
|
||||||
var recordEntity = await InsertReturnEntityAsync(record);
|
var recordEntity = await InsertReturnEntityAsync(record);
|
||||||
@ -131,7 +135,27 @@ public class PointsService(
|
|||||||
if (input.Amount <= 0)
|
if (input.Amount <= 0)
|
||||||
throw new BusinessException("扣除积分数量必须大于0", ResultCode.BAD_REQUEST);
|
throw new BusinessException("扣除积分数量必须大于0", ResultCode.BAD_REQUEST);
|
||||||
|
|
||||||
// 查询用户当前积分
|
var now = DateTime.Now;
|
||||||
|
|
||||||
|
// 带余额条件的原子扣减,避免并发扣减时超扣或覆盖余额
|
||||||
|
var affectedRows = await Context.Updateable<Users>()
|
||||||
|
.SetColumns(u => u.Points == u.Points - input.Amount)
|
||||||
|
.SetColumns(u => u.UpdatedAt == now)
|
||||||
|
.Where(u => u.Id == input.UserId && !u.IsDeleted && u.Points >= input.Amount)
|
||||||
|
.ExecuteCommandAsync();
|
||||||
|
|
||||||
|
if (affectedRows <= 0)
|
||||||
|
{
|
||||||
|
var currentUser = await Context.Queryable<Users>()
|
||||||
|
.Where(u => u.Id == input.UserId && !u.IsDeleted)
|
||||||
|
.FirstAsync();
|
||||||
|
|
||||||
|
if (currentUser == null)
|
||||||
|
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
|
||||||
|
|
||||||
|
throw new BusinessException($"积分不足,需要 {input.Amount} 积分,当前余额 {currentUser.Points}", ResultCode.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
var user = await Context.Queryable<Users>()
|
var user = await Context.Queryable<Users>()
|
||||||
.Where(u => u.Id == input.UserId && !u.IsDeleted)
|
.Where(u => u.Id == input.UserId && !u.IsDeleted)
|
||||||
.FirstAsync();
|
.FirstAsync();
|
||||||
@ -139,20 +163,8 @@ public class PointsService(
|
|||||||
if (user == null)
|
if (user == null)
|
||||||
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
|
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
|
||||||
|
|
||||||
var previousBalance = user.Points;
|
var newBalance = user.Points;
|
||||||
|
var previousBalance = newBalance + input.Amount;
|
||||||
// 余额不足校验
|
|
||||||
if (previousBalance < input.Amount)
|
|
||||||
throw new BusinessException($"积分不足,需要 {input.Amount} 积分,当前余额 {previousBalance}", ResultCode.BAD_REQUEST);
|
|
||||||
|
|
||||||
var newBalance = previousBalance - input.Amount;
|
|
||||||
|
|
||||||
// 更新用户积分
|
|
||||||
await Context.Updateable<Users>()
|
|
||||||
.SetColumns(u => u.Points == newBalance)
|
|
||||||
.SetColumns(u => u.UpdatedAt == DateTime.Now)
|
|
||||||
.Where(u => u.Id == input.UserId && !u.IsDeleted)
|
|
||||||
.ExecuteCommandAsync();
|
|
||||||
|
|
||||||
// 插入积分流水记录
|
// 插入积分流水记录
|
||||||
var record = new PointsRecord
|
var record = new PointsRecord
|
||||||
@ -167,9 +179,9 @@ public class PointsService(
|
|||||||
Status = (int)PointsRecordStatusEnum.Success,
|
Status = (int)PointsRecordStatusEnum.Success,
|
||||||
IsDeleted = false,
|
IsDeleted = false,
|
||||||
CreatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
|
CreatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
|
||||||
CreatedAt = DateTime.Now,
|
CreatedAt = now,
|
||||||
UpdatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
|
UpdatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
|
||||||
UpdatedAt = DateTime.Now
|
UpdatedAt = now
|
||||||
};
|
};
|
||||||
|
|
||||||
var recordEntity = await InsertReturnEntityAsync(record);
|
var recordEntity = await InsertReturnEntityAsync(record);
|
||||||
|
|||||||
@ -38,66 +38,14 @@ builder.InitSqlSugarDb(new IocConfig
|
|||||||
IsAutoCloseConnection = true,
|
IsAutoCloseConnection = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddDataProtection()
|
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(Directory.GetCurrentDirectory(), "DataProtection")));
|
|
||||||
|
|
||||||
builder.Services.AddCSRedisCacheExtension(builder.Configuration.GetSection("RedisSettings"));
|
|
||||||
builder.Services.AddRabbitMQ(builder.Configuration);
|
|
||||||
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.ReadFrom.Configuration(builder.Configuration)
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
.CreateLogger();
|
.CreateLogger();
|
||||||
|
|
||||||
builder.Host.UseSerilog();
|
builder.Host.UseSerilog();
|
||||||
|
builder.AddInteractiveMagazineApiDefaults();
|
||||||
builder.Services.AddControllers(options =>
|
|
||||||
{
|
|
||||||
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
|
|
||||||
options.Filters.Add<ModelValidActionFilterAttribute>();
|
|
||||||
})
|
|
||||||
.AddJsonOptions(options =>
|
|
||||||
{
|
|
||||||
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
|
||||||
options.JsonSerializerOptions.Converters.Add(new JsonConverterUtil.DateTimeConverter());
|
|
||||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
|
||||||
})
|
|
||||||
.ConfigureApiBehaviorOptions(opt => opt.SuppressModelStateInvalidFilter = true);
|
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.AddCorsRegister();
|
|
||||||
builder.Services.AddHttpClient();
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
|
||||||
builder.Services.AddScoped(typeof(BaseRepository<>));
|
builder.Services.AddScoped(typeof(BaseRepository<>));
|
||||||
builder.Services.AddInfrastructureServices(builder.Configuration, builder.Environment);
|
|
||||||
builder.Services.AddSDKService(builder.Configuration);
|
|
||||||
builder.Services.Configure<MedalRuleConfigSettings>(builder.Configuration.GetSection("MedalRuleConfig"));
|
|
||||||
|
|
||||||
builder.Services.AddCors(options =>
|
|
||||||
{
|
|
||||||
options.AddPolicy("AllowAll", policy =>
|
|
||||||
{
|
|
||||||
policy.AllowAnyOrigin()
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowAnyHeader();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
|
|
||||||
{
|
|
||||||
options.Level = System.IO.Compression.CompressionLevel.Optimal;
|
|
||||||
});
|
|
||||||
builder.Services.Configure<GzipCompressionProviderOptions>(options =>
|
|
||||||
{
|
|
||||||
options.Level = System.IO.Compression.CompressionLevel.Fastest;
|
|
||||||
});
|
|
||||||
builder.Services.AddResponseCompression(options =>
|
|
||||||
{
|
|
||||||
options.EnableForHttps = true;
|
|
||||||
options.Providers.Add<BrotliCompressionProvider>();
|
|
||||||
options.Providers.Add<GzipCompressionProvider>();
|
|
||||||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["image/svg+xml", "application/json", "text/plain"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddSwaggerGen(option =>
|
builder.Services.AddSwaggerGen(option =>
|
||||||
{
|
{
|
||||||
|
|||||||
@ -47,14 +47,6 @@ builder.InitSqlSugarDb(new IocConfig()
|
|||||||
IsAutoCloseConnection = true,
|
IsAutoCloseConnection = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 消除Error unprotecting the session cookie警告
|
|
||||||
builder.Services.AddDataProtection()
|
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(Directory.GetCurrentDirectory() + Path.DirectorySeparatorChar + "DataProtection"));
|
|
||||||
//redis
|
|
||||||
builder.Services.AddCSRedisCacheExtension(builder.Configuration.GetSection("RedisSettings"));
|
|
||||||
|
|
||||||
//MQ
|
|
||||||
builder.Services.AddRabbitMQ(builder.Configuration);
|
|
||||||
// 配置Serilog
|
// 配置Serilog
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.ReadFrom.Configuration(builder.Configuration)
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
@ -62,26 +54,8 @@ Log.Logger = new LoggerConfiguration()
|
|||||||
.CreateLogger();
|
.CreateLogger();
|
||||||
|
|
||||||
builder.Host.UseSerilog();
|
builder.Host.UseSerilog();
|
||||||
builder.Services.AddControllers(options =>
|
builder.AddInteractiveMagazineApiDefaults();
|
||||||
{
|
|
||||||
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;//Required 不作为必填
|
|
||||||
options.Filters.Add<ModelValidActionFilterAttribute>();//添加自定义模型验证
|
|
||||||
})
|
|
||||||
.AddJsonOptions(options =>
|
|
||||||
{
|
|
||||||
// 配置 JSON 不区分大小写(支持 camelCase 和 PascalCase)
|
|
||||||
options.JsonSerializerOptions.PropertyNameCaseInsensitive = true;
|
|
||||||
// 配置返回时间格式转换
|
|
||||||
options.JsonSerializerOptions.Converters.Add(new JsonConverterUtil.DateTimeConverter());
|
|
||||||
options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
|
|
||||||
}).ConfigureApiBehaviorOptions(opt => opt.SuppressModelStateInvalidFilter = true);//关闭默认模型验证
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
|
|
||||||
// 跨域配置
|
|
||||||
builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider();
|
|
||||||
builder.AddCorsRegister();
|
|
||||||
//builder.Services.AddSession();
|
|
||||||
builder.Services.AddHttpClient();
|
|
||||||
// 注册 Swagger 文档
|
// 注册 Swagger 文档
|
||||||
builder.Services.AddSwaggerGen(option =>
|
builder.Services.AddSwaggerGen(option =>
|
||||||
{
|
{
|
||||||
@ -148,52 +122,8 @@ builder.Services.AddSwaggerGen(option =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services.AddInfrastructureServices(builder.Configuration, builder.Environment);
|
|
||||||
|
|
||||||
//注册 HttpContextAccessor
|
|
||||||
builder.Services.AddHttpContextAccessor();
|
|
||||||
builder.Services.AddScoped(typeof(BaseRepository<>));
|
builder.Services.AddScoped(typeof(BaseRepository<>));
|
||||||
|
|
||||||
// 注册勋章规则配置
|
|
||||||
builder.Services.Configure<MedalRuleConfigSettings>(builder.Configuration.GetSection("MedalRuleConfig"));
|
|
||||||
|
|
||||||
// 添加CORS
|
|
||||||
builder.Services.AddCors(options =>
|
|
||||||
{
|
|
||||||
options.AddPolicy("AllowAll", policy =>
|
|
||||||
{
|
|
||||||
policy.AllowAnyOrigin()
|
|
||||||
.AllowAnyMethod()
|
|
||||||
.AllowAnyHeader();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
builder.Services.AddSDKService(builder.Configuration);
|
|
||||||
|
|
||||||
#region 配置数据压缩选项
|
|
||||||
|
|
||||||
// 1.首先配置压缩选项的Options>
|
|
||||||
builder.Services.Configure<BrotliCompressionProviderOptions>(options =>
|
|
||||||
{
|
|
||||||
options.Level = System.IO.Compression.CompressionLevel.Optimal;
|
|
||||||
});
|
|
||||||
builder.Services.Configure<GzipCompressionProviderOptions>(options =>
|
|
||||||
{
|
|
||||||
options.Level = System.IO.Compression.CompressionLevel.Fastest;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2.在服务容器中注册响应压缩服务
|
|
||||||
builder.Services.AddResponseCompression(options =>
|
|
||||||
{
|
|
||||||
// 可以在这里进行详细配置
|
|
||||||
options.EnableForHttps = true; // 启用对HTTPS响应的压缩(请注意安全风险)
|
|
||||||
options.Providers.Add<BrotliCompressionProvider>();
|
|
||||||
options.Providers.Add<GzipCompressionProvider>();
|
|
||||||
//指定哪些类型的响应应该被压缩。默认列表包含常见的文本类类型,如 text/html, text/css, application/javascript, application/json, text/plain等
|
|
||||||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["image/svg+xml", "application/json", "text/plain"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user