diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..64e8508 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true + +[*.cs] +indent_style = space +indent_size = 4 diff --git a/QYZH.InteractiveMagazine.Infrastructure/Extensions/InteractiveMagazineApiDefaultsExtensions.cs b/QYZH.InteractiveMagazine.Infrastructure/Extensions/InteractiveMagazineApiDefaultsExtensions.cs new file mode 100644 index 0000000..5b9dfe5 --- /dev/null +++ b/QYZH.InteractiveMagazine.Infrastructure/Extensions/InteractiveMagazineApiDefaultsExtensions.cs @@ -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; + +/// +/// API 默认服务注册扩展。 +/// +public static class InteractiveMagazineApiDefaultsExtensions +{ + /// + /// 注册 WebApi 与 WeChatApi 共享的默认服务。 + /// + /// 应用构建器。 + /// 应用构建器。 + 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(); + }) + .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(builder.Configuration.GetSection("MedalRuleConfig")); + + builder.Services.AddCors(options => + { + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + builder.Services.Configure(options => + { + options.Level = System.IO.Compression.CompressionLevel.Optimal; + }); + builder.Services.Configure(options => + { + options.Level = System.IO.Compression.CompressionLevel.Fastest; + }); + builder.Services.AddResponseCompression(options => + { + options.EnableForHttps = true; + options.Providers.Add(); + options.Providers.Add(); + options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["image/svg+xml", "application/json", "text/plain"]); + }); + + return builder; + } +} diff --git a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs index d9392a8..7703ad1 100644 --- a/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs +++ b/QYZH.InteractiveMagazine.Models/Dto/Journal/BindJournalDto.cs @@ -180,7 +180,7 @@ public class GenerateUserJournalQrCodeMessage } /// -/// 鏈熷垔浜岀淮鐮佹煡璇㈣緭鍏TO +/// 期刊二维码查询输入DTO /// public class UserJournalQrCodeQueryInput : PageQueryModel { diff --git a/QYZH.InteractiveMagazine.Service/PointsService.cs b/QYZH.InteractiveMagazine.Service/PointsService.cs index fe2a29f..53260e2 100644 --- a/QYZH.InteractiveMagazine.Service/PointsService.cs +++ b/QYZH.InteractiveMagazine.Service/PointsService.cs @@ -73,7 +73,18 @@ public class PointsService( if (input.Amount <= 0) throw new BusinessException("增加积分数量必须大于0", ResultCode.BAD_REQUEST); - // 查询用户当前积分 + var now = DateTime.Now; + + // 原子增加积分,避免并发写回旧余额覆盖新余额 + var affectedRows = await Context.Updateable() + .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() .Where(u => u.Id == input.UserId && !u.IsDeleted) .FirstAsync(); @@ -81,15 +92,8 @@ public class PointsService( if (user == null) throw new BusinessException("用户不存在", ResultCode.NOT_FOUND); - var previousBalance = user.Points; - var newBalance = previousBalance + input.Amount; - - // 更新用户积分 - await Context.Updateable() - .SetColumns(u => u.Points == newBalance) - .SetColumns(u => u.UpdatedAt == DateTime.Now) - .Where(u => u.Id == input.UserId && !u.IsDeleted) - .ExecuteCommandAsync(); + var newBalance = user.Points; + var previousBalance = newBalance - input.Amount; // 插入积分流水记录 var record = new PointsRecord @@ -104,9 +108,9 @@ public class PointsService( Status = (int)PointsRecordStatusEnum.Success, IsDeleted = false, CreatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(), - CreatedAt = DateTime.Now, + CreatedAt = now, UpdatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(), - UpdatedAt = DateTime.Now + UpdatedAt = now }; var recordEntity = await InsertReturnEntityAsync(record); @@ -131,7 +135,27 @@ public class PointsService( if (input.Amount <= 0) throw new BusinessException("扣除积分数量必须大于0", ResultCode.BAD_REQUEST); - // 查询用户当前积分 + var now = DateTime.Now; + + // 带余额条件的原子扣减,避免并发扣减时超扣或覆盖余额 + var affectedRows = await Context.Updateable() + .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() + .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() .Where(u => u.Id == input.UserId && !u.IsDeleted) .FirstAsync(); @@ -139,20 +163,8 @@ public class PointsService( if (user == null) throw new BusinessException("用户不存在", ResultCode.NOT_FOUND); - var previousBalance = user.Points; - - // 余额不足校验 - if (previousBalance < input.Amount) - throw new BusinessException($"积分不足,需要 {input.Amount} 积分,当前余额 {previousBalance}", ResultCode.BAD_REQUEST); - - var newBalance = previousBalance - input.Amount; - - // 更新用户积分 - await Context.Updateable() - .SetColumns(u => u.Points == newBalance) - .SetColumns(u => u.UpdatedAt == DateTime.Now) - .Where(u => u.Id == input.UserId && !u.IsDeleted) - .ExecuteCommandAsync(); + var newBalance = user.Points; + var previousBalance = newBalance + input.Amount; // 插入积分流水记录 var record = new PointsRecord @@ -167,9 +179,9 @@ public class PointsService( Status = (int)PointsRecordStatusEnum.Success, IsDeleted = false, CreatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(), - CreatedAt = DateTime.Now, + CreatedAt = now, UpdatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(), - UpdatedAt = DateTime.Now + UpdatedAt = now }; var recordEntity = await InsertReturnEntityAsync(record); diff --git a/QYZH.InteractiveMagazine.WeChatApi/Program.cs b/QYZH.InteractiveMagazine.WeChatApi/Program.cs index 5144dfe..c3dd3bd 100644 --- a/QYZH.InteractiveMagazine.WeChatApi/Program.cs +++ b/QYZH.InteractiveMagazine.WeChatApi/Program.cs @@ -38,66 +38,14 @@ builder.InitSqlSugarDb(new IocConfig 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() .ReadFrom.Configuration(builder.Configuration) .Enrich.FromLogContext() .CreateLogger(); builder.Host.UseSerilog(); - -builder.Services.AddControllers(options => -{ - options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true; - options.Filters.Add(); -}) -.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.AddInteractiveMagazineApiDefaults(); builder.Services.AddScoped(typeof(BaseRepository<>)); -builder.Services.AddInfrastructureServices(builder.Configuration, builder.Environment); -builder.Services.AddSDKService(builder.Configuration); -builder.Services.Configure(builder.Configuration.GetSection("MedalRuleConfig")); - -builder.Services.AddCors(options => -{ - options.AddPolicy("AllowAll", policy => - { - policy.AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader(); - }); -}); - -builder.Services.Configure(options => -{ - options.Level = System.IO.Compression.CompressionLevel.Optimal; -}); -builder.Services.Configure(options => -{ - options.Level = System.IO.Compression.CompressionLevel.Fastest; -}); -builder.Services.AddResponseCompression(options => -{ - options.EnableForHttps = true; - options.Providers.Add(); - options.Providers.Add(); - options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["image/svg+xml", "application/json", "text/plain"]); -}); builder.Services.AddSwaggerGen(option => { diff --git a/QYZH.InteractiveMagazine.WebApi/Program.cs b/QYZH.InteractiveMagazine.WebApi/Program.cs index 392de5b..a06e381 100644 --- a/QYZH.InteractiveMagazine.WebApi/Program.cs +++ b/QYZH.InteractiveMagazine.WebApi/Program.cs @@ -47,14 +47,6 @@ builder.InitSqlSugarDb(new IocConfig() 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 Log.Logger = new LoggerConfiguration() .ReadFrom.Configuration(builder.Configuration) @@ -62,26 +54,8 @@ Log.Logger = new LoggerConfiguration() .CreateLogger(); builder.Host.UseSerilog(); -builder.Services.AddControllers(options => -{ - options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;//Required 不作为必填 - options.Filters.Add();//添加自定义模型验证 -}) -.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.AddInteractiveMagazineApiDefaults(); -// 跨域配置 -builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); -builder.AddCorsRegister(); -//builder.Services.AddSession(); -builder.Services.AddHttpClient(); // 注册 Swagger 文档 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.Configure(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(options => -{ - options.Level = System.IO.Compression.CompressionLevel.Optimal; -}); -builder.Services.Configure(options => -{ - options.Level = System.IO.Compression.CompressionLevel.Fastest; -}); - -// 2.在服务容器中注册响应压缩服务 -builder.Services.AddResponseCompression(options => -{ - // 可以在这里进行详细配置 - options.EnableForHttps = true; // 启用对HTTPS响应的压缩(请注意安全风险) - options.Providers.Add(); - options.Providers.Add(); - //指定哪些类型的响应应该被压缩。默认列表包含常见的文本类类型,如 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(); {