109 lines
6.9 KiB
C#
109 lines
6.9 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using MAX.Framework.Validation;
|
|
using FreeSql;
|
|
using MAX.Core.DependencyInjection;
|
|
using MAX.Framework.Commons;
|
|
using MAX.Framework.Security;
|
|
using Store.Users.Contracts.Dtos;
|
|
using Store.Users.Entities;
|
|
namespace Store.Users.Services;
|
|
|
|
/// <summary>资金写入集中在事务中完成。账户行锁防止超扣,流水与余额同提交、同回滚。</summary>
|
|
public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : ITransientDependency
|
|
{
|
|
public async Task<UserFundsResponse> Change(UserFundsRequest request, bool points, CancellationToken ct)
|
|
{
|
|
RequestValidator.Validate(request);
|
|
// DTO 只检查非空,其余长度、金额和操作组合在服务中手动判断。
|
|
UserInputChecks.Length(request.UserName, 20, "目标账户");
|
|
UserInputChecks.Length(request.RequestId, 64, "请求号");
|
|
UserInputChecks.Length(request.Remark, 255, "备注");
|
|
if (request.Amount is < 0.01m or > 1000000m)
|
|
throw new ValidationException("金额必须为0.01-1000000");
|
|
if (decimal.Round(request.Amount, 2) != request.Amount)
|
|
throw new ValidationException("金额最多保留两位小数");
|
|
if (points ? request.Operation is not ("充值" or "转移") : request.Operation is not ("充值" or "消费"))
|
|
throw new ValidationException("不支持的资金操作类型");
|
|
ct.ThrowIfCancellationRequested();
|
|
if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName)) throw new UnauthorizedAccessException();
|
|
using var work = orm.CreateUnitOfWork();
|
|
work.GetOrBeginTransaction();
|
|
var db = work.Orm;
|
|
var actor = await db.Select<UserEntity>().Where(u => u.UserName == currentUser.UserName).FirstAsync(ct)
|
|
?? throw new UnauthorizedAccessException();
|
|
bool transfer = points && request.Operation == "转移";
|
|
if (!transfer && actor.SaLevel != 1) throw new AppException("充值和余额调整仅允许管理员操作", 403);
|
|
var name = request.UserName.Trim();
|
|
var target = await db.Select<UserEntity>().Where(u => u.UserName == name).FirstAsync(ct)
|
|
?? throw new AppException("目标账户不存在");
|
|
if (transfer && actor.Id == target.Id) throw new AppException("不能向自己转移积分");
|
|
if (transfer && !UserAccessPolicy.CanManage(actor, target))
|
|
throw new AppException("只能向管理范围内的账户转移积分", 403);
|
|
// 双账户转移按相同主键顺序加锁,避免双方同时转账时产生反向锁顺序。
|
|
var ids = transfer ? new[] { actor.Id, target.Id }.Order().ToArray() : new[] { target.Id };
|
|
foreach (var id in ids)
|
|
{
|
|
var locked = await db.Select<UserEntity>().Where(u => u.Id == id).ForUpdate().FirstAsync(ct)
|
|
?? throw new AppException("账户已被删除");
|
|
if (id == target.Id) target = locked;
|
|
if (id == actor.Id) actor = locked;
|
|
}
|
|
var key = "user:" + request.RequestId.Trim();
|
|
// 持有目标行锁后检查请求号,相同账户的并发重试只能执行一次。
|
|
bool duplicate = points
|
|
? await db.Select<UserPointsRecordEntity>().Where(r => r.UserName == target.UserName && r.OutTradeNo == key).AnyAsync(ct)
|
|
: await db.Select<UserPaymentRecordEntity>().Where(r => r.UserName == target.UserName && r.RefundFlowNo == key).AnyAsync(ct);
|
|
if (duplicate) throw new AppException("该请求已处理,请查询流水确认结果,勿重复提交");
|
|
decimal value;
|
|
if (points)
|
|
{
|
|
value = checked(target.Points + request.Amount);
|
|
if (transfer)
|
|
{
|
|
if (actor.Points < request.Amount) throw new AppException("积分不足");
|
|
if (await db.Update<UserEntity>().Set(u => u.Points, actor.Points - request.Amount)
|
|
.Where(u => u.Id == actor.Id && u.Points == actor.Points).ExecuteAffrowsAsync(ct) != 1)
|
|
throw new AppException("转出账户状态已改变");
|
|
await AddPointsRecord(db, actor, actor, request.Amount, actor.Points - request.Amount,
|
|
"转出", target.UserName, key, request.Remark, ct);
|
|
}
|
|
var update = db.Update<UserEntity>().Set(u => u.Points, value)
|
|
.Where(u => u.Id == target.Id && u.Points == target.Points);
|
|
// 延续原系统口径:积分转入也计入接收方累计充值。
|
|
update = update.Set(u => u.RechargePointsTotal, checked((target.RechargePointsTotal ?? 0) + request.Amount));
|
|
if (await update.ExecuteAffrowsAsync(ct) != 1) throw new AppException("账户状态已改变");
|
|
await AddPointsRecord(db, target, actor, request.Amount, value, transfer ? "转入" : "充值",
|
|
transfer ? actor.UserName : null, key, request.Remark, ct);
|
|
}
|
|
else
|
|
{
|
|
bool recharge = request.Operation == "充值";
|
|
value = checked((target.Balance ?? 0) + (recharge ? request.Amount : -request.Amount));
|
|
if (value < 0) throw new AppException("余额不足");
|
|
var update = db.Update<UserEntity>().Set(u => u.Balance, value).Where(u => u.Id == target.Id && u.Balance == target.Balance);
|
|
if (recharge) update = update.Set(u => u.RechargeAmountTotal, checked((target.RechargeAmountTotal ?? 0) + request.Amount));
|
|
if (await update.ExecuteAffrowsAsync(ct) != 1) throw new AppException("账户状态已改变");
|
|
if (await db.Insert(new UserPaymentRecordEntity
|
|
{
|
|
UserName = target.UserName, Type = recharge ? 1 : 0, Amount = request.Amount, Balance = value,
|
|
CreateBy = actor.UserName, CreateTime = DateTime.Now, Remark = request.Remark ?? "",
|
|
PlatformCode = "", OrderId = "", UpdateBy = "", CounterpartyAccountName = "", RefundFlowNo = key
|
|
}).ExecuteAffrowsAsync(ct) != 1) throw new AppException("写入余额流水失败");
|
|
}
|
|
ct.ThrowIfCancellationRequested();
|
|
work.Commit();
|
|
return new(target.UserName, value, request.RequestId);
|
|
}
|
|
|
|
private static async Task AddPointsRecord(IFreeSql db, UserEntity user, UserEntity actor, decimal amount,
|
|
decimal value, string operation, string? counterparty, string key, string? remark, CancellationToken ct)
|
|
{
|
|
if (await db.Insert(new UserPointsRecordEntity
|
|
{
|
|
UserName = user.UserName, UserNick = user.NickName, DeltaPoints = amount, NewPoints = value,
|
|
OptionType = operation, OperatorType = actor.SaLevel == 1 ? "管理员" : "用户", OperatorName = actor.UserName,
|
|
CounterpartyAccountName = counterparty, OutTradeNo = key, Remark = remark, AddTime = DateTime.Now
|
|
}).ExecuteAffrowsAsync(ct) != 1) throw new AppException("写入积分流水失败");
|
|
}
|
|
}
|