Files
2026-10-23 19:01:28 +08:00

102 lines
6.4 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);
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 == "转移";
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("不能向自己转移积分");
// 操作者和目标按主键顺序加锁,再按最新账户状态授权,避免权限变更与资金写入交错。
var ids = new[] { actor.Id, target.Id }.Distinct().Order().ToArray();
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;
}
if (transfer && !UserAccessPolicy.CanManage(actor, target))
throw new AppException("只能向管理范围内的账户转移积分", 403);
if (!transfer && !UserAccessPolicy.IsAdmin(actor))
throw new AppException("充值和余额调整仅允许管理员操作", 403);
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 = UserAccessPolicy.IsAdmin(actor) ? "管理员" : "用户", OperatorName = actor.UserName,
CounterpartyAccountName = counterparty, OutTradeNo = key, Remark = remark, AddTime = DateTime.Now
}).ExecuteAffrowsAsync(ct) != 1) throw new AppException("写入积分流水失败");
}
}