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); // 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().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().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().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().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 = actor.SaLevel == 1 ? "管理员" : "用户", OperatorName = actor.UserName, CounterpartyAccountName = counterparty, OutTradeNo = key, Remark = remark, AddTime = DateTime.Now }).ExecuteAffrowsAsync(ct) != 1) throw new AppException("写入积分流水失败"); } }