refactor(api): 提取公共API默认配置并修复积分并发问题

1.  新增InteractiveMagazineApiDefaultsExtensions统一注册共享服务,简化WeChatApi和WebApi的Program.cs代码
2.  修复积分增减的并发问题,改用原子更新操作,添加余额校验避免超扣
3.  修复注释乱码问题,将简体中文注释替换为正确文本
4.  新增.editorconfig配置统一代码格式
This commit is contained in:
glz
2026-07-07 18:01:17 +08:00
parent 1d467a5fc4
commit f7f505d109
6 changed files with 139 additions and 154 deletions

10
.editorconfig Normal file
View 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

View File

@ -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;
}
}

View File

@ -180,7 +180,7 @@ public class GenerateUserJournalQrCodeMessage
} }
/// <summary> /// <summary>
/// 鏈熷垔浜岀淮鐮佹煡璇㈣緭鍏TO /// 期刊二维码查询输入DTO
/// </summary> /// </summary>
public class UserJournalQrCodeQueryInput : PageQueryModel public class UserJournalQrCodeQueryInput : PageQueryModel
{ {

View File

@ -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);

View File

@ -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 =>
{ {

View File

@ -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();
{ {