Files
QYZH.InteractiveMagazine/QYZH.InteractiveMagazine.Service/PointsService.cs
glz f7f505d109 refactor(api): 提取公共API默认配置并修复积分并发问题
1.  新增InteractiveMagazineApiDefaultsExtensions统一注册共享服务,简化WeChatApi和WebApi的Program.cs代码
2.  修复积分增减的并发问题,改用原子更新操作,添加余额校验避免超扣
3.  修复注释乱码问题,将简体中文注释替换为正确文本
4.  新增.editorconfig配置统一代码格式
2026-07-07 18:01:17 +08:00

287 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.Extensions.Logging;
using QYZH.InteractiveMagazine.IService;
using QYZH.InteractiveMagazine.Models.Common;
using QYZH.InteractiveMagazine.Models.Dto;
using QYZH.InteractiveMagazine.Models.Dto.Points;
using QYZH.InteractiveMagazine.Models.Entity;
using QYZH.InteractiveMagazine.Models.Enum;
using QYZH.InteractiveMagazine.Repository;
namespace QYZH.InteractiveMagazine.Service;
/// <summary>
/// 积分服务实现
/// </summary>
public class PointsService(
BaseRepository<PointsRecord> pointsRecordRepository,
ILogger<PointsService> logger)
: BaseRepository<PointsRecord>, IPointsService
{
#region
/// <summary>
/// 增加积分(带事务)
/// </summary>
public async Task<AddPointsOutput> AddPointsAsync(AddPointsInput input)
{
logger.LogInformation("用户增加积分UserId: {UserId}, Amount: {Amount}, Type: {Type}",
input.UserId, input.Amount, input.ChangeType);
if (input.Amount <= 0)
throw new BusinessException("增加积分数量必须大于0", ResultCode.BAD_REQUEST);
AddPointsOutput result = null!;
await UseTranAsync(async () =>
{
result = await AddPointsInTranAsync(input);
});
return result;
}
/// <summary>
/// 扣除积分(带事务)
/// </summary>
public async Task<DeductPointsOutput> DeductPointsAsync(DeductPointsInput input)
{
logger.LogInformation("用户扣除积分UserId: {UserId}, Amount: {Amount}, Type: {Type}",
input.UserId, input.Amount, input.ChangeType);
if (input.Amount <= 0)
throw new BusinessException("扣除积分数量必须大于0", ResultCode.BAD_REQUEST);
DeductPointsOutput result = null!;
await UseTranAsync(async () =>
{
result = await DeductPointsInTranAsync(input);
});
return result;
}
#endregion
#region
/// <summary>
/// 增加积分(无事务,需在外部事务中调用)
/// </summary>
public async Task<AddPointsOutput> AddPointsInTranAsync(AddPointsInput input)
{
if (input.Amount <= 0)
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>()
.Where(u => u.Id == input.UserId && !u.IsDeleted)
.FirstAsync();
if (user == null)
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
var newBalance = user.Points;
var previousBalance = newBalance - input.Amount;
// 插入积分流水记录
var record = new PointsRecord
{
UserId = input.UserId,
ChangeAmount = input.Amount,
BalanceAfter = newBalance,
ChangeType = input.ChangeType.ToString(),
RelatedId = input.RelatedId,
Description = input.Description,
Type = PointsFlowTypeEnum.Income,
Status = (int)PointsRecordStatusEnum.Success,
IsDeleted = false,
CreatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
CreatedAt = now,
UpdatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
UpdatedAt = now
};
var recordEntity = await InsertReturnEntityAsync(record);
logger.LogInformation("增加积分成功UserId: {UserId}, 积分: {Before} -> {After}, 变动: +{Amount}",
input.UserId, previousBalance, newBalance, input.Amount);
return new AddPointsOutput
{
RecordId = recordEntity.Id,
PreviousBalance = previousBalance,
NewBalance = newBalance,
AddedAmount = input.Amount
};
}
/// <summary>
/// 扣除积分(无事务,需在外部事务中调用)
/// </summary>
public async Task<DeductPointsOutput> DeductPointsInTranAsync(DeductPointsInput input)
{
if (input.Amount <= 0)
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>()
.Where(u => u.Id == input.UserId && !u.IsDeleted)
.FirstAsync();
if (user == null)
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
var newBalance = user.Points;
var previousBalance = newBalance + input.Amount;
// 插入积分流水记录
var record = new PointsRecord
{
UserId = input.UserId,
ChangeAmount = -input.Amount,
BalanceAfter = newBalance,
ChangeType = input.ChangeType.ToString(),
RelatedId = input.RelatedId,
Description = input.Description,
Type = PointsFlowTypeEnum.Expense,
Status = (int)PointsRecordStatusEnum.Success,
IsDeleted = false,
CreatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
CreatedAt = now,
UpdatedBy = input.OperatorName ?? user.Name ?? input.UserId.ToString(),
UpdatedAt = now
};
var recordEntity = await InsertReturnEntityAsync(record);
logger.LogInformation("扣除积分成功UserId: {UserId}, 积分: {Before} -> {After}, 变动: -{Amount}",
input.UserId, previousBalance, newBalance, input.Amount);
return new DeductPointsOutput
{
RecordId = recordEntity.Id,
PreviousBalance = previousBalance,
NewBalance = newBalance,
DeductedAmount = input.Amount
};
}
#endregion
#region
/// <summary>
/// 查询用户当前积分余额
/// </summary>
public async Task<int> GetUserPointsAsync(long userId)
{
var user = await Context.Queryable<Users>()
.Where(u => u.Id == userId && !u.IsDeleted)
.Select(u => u.Points)
.FirstAsync();
return user;
}
/// <summary>
/// 获取用户积分概览
/// </summary>
public async Task<PointsSummaryOutput> GetPointsSummaryAsync(long userId)
{
// 查询用户
var user = await Context.Queryable<Users>()
.Where(u => u.Id == userId && !u.IsDeleted)
.FirstAsync();
if (user == null)
throw new BusinessException("用户不存在", ResultCode.NOT_FOUND);
// 查询累计收入Income 类型)
var totalIncome = await Context.Queryable<PointsRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted && r.Type == PointsFlowTypeEnum.Income && r.Status == (int)PointsRecordStatusEnum.Success)
.SumAsync(r => r.ChangeAmount);
// 查询累计支出Expense 类型,取绝对值)
var totalExpense = await Context.Queryable<PointsRecord>()
.Where(r => r.UserId == userId && !r.IsDeleted && r.Type == PointsFlowTypeEnum.Expense && r.Status == (int)PointsRecordStatusEnum.Success)
.SumAsync(r => r.ChangeAmount);
return new PointsSummaryOutput
{
UserId = userId,
CurrentBalance = user.Points,
TotalIncome = totalIncome,
TotalExpense = Math.Abs(totalExpense)
};
}
/// <summary>
/// 分页查询积分流水
/// </summary>
public async Task<PageListModel<PointsRecordOutput>> GetPointsRecordsAsync(PointsRecordQueryInput input)
{
if (input.PageIndex <= 0)
throw new BusinessException("页码必须大于0", ResultCode.BAD_REQUEST);
if (input.PageSize <= 0 || input.PageSize > 100)
throw new BusinessException("每页条数必须在1-100之间", ResultCode.BAD_REQUEST);
var query = Context.Queryable<PointsRecord>()
.Where(r => r.UserId == input.UserId && !r.IsDeleted)
.WhereIF(input.ChangeType.HasValue, r => r.ChangeType == input.ChangeType.Value.ToString())
.WhereIF(input.Type.HasValue, r => r.Type == input.Type.Value)
.WhereIF(!string.IsNullOrEmpty(input.Status), r => r.Status.ToString() == input.Status)
.OrderByDescending(r => r.CreatedAt);
var total = 0;
var records = await query
.Select(r => new PointsRecordOutput
{
Id = (long)r.Id,
ChangeAmount = r.ChangeAmount,
BalanceAfter = r.BalanceAfter,
ChangeType = r.ChangeType,
Description = r.Description,
Type = r.Type.ToString(),
CreatedAt = r.CreatedAt
})
.ToPageListAsync(input.PageIndex, input.PageSize, total);
return new PageListModel<PointsRecordOutput>(records, input.PageIndex, input.PageSize, total);
}
#endregion
}