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; /// 资金写入集中在事务中完成。账户行锁防止超扣,流水与余额同提交、同回滚。 public sealed class UserFundsService(IFreeSql orm, ICurrentUser currentUser) : ITransientDependency { public async Task 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().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().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().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().Where(r => r.UserName == target.UserName && r.OutTradeNo == key).AnyAsync(ct) : await db.Select().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().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().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().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("写入积分流水失败"); } }