252 lines
15 KiB
C#
252 lines
15 KiB
C#
using System.ComponentModel.DataAnnotations;
|
|
using System.Linq.Expressions;
|
|
using MAX.Core.DependencyInjection;
|
|
using MAX.Framework.Commons;
|
|
using MAX.Framework.Security;
|
|
using MAX.Framework.Validation;
|
|
using Store.Shops.Dtos;
|
|
using Store.Shops.Entities;
|
|
using Store.Shops.Mappers;
|
|
using Store.Users.Contracts;
|
|
using Store.Users.Contracts.Dtos;
|
|
using Store.Users.Contracts.Enums;
|
|
using Store.Users.Entities;
|
|
using YourNamespace;
|
|
|
|
namespace Store.Shops.Services;
|
|
|
|
/// <summary>微信店铺业务;账号身份来自服务端,普通子账号只能访问主账号明确分配的店铺。</summary>
|
|
public sealed partial class ShopService(IFreeSql db, ICurrentUser currentUser, IUserQueryService users,
|
|
PddTokenClient tokens, Microsoft.Extensions.Configuration.IConfiguration configuration) : ITransientDependency
|
|
{
|
|
/// <summary>读取当前数据库账户,避免相信客户端传来的身份或管理员标志。</summary>
|
|
private async Task<UserInfo> Actor()
|
|
{
|
|
if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName))
|
|
throw new UnauthorizedAccessException("请先登录");
|
|
return await users.GetUser(currentUser.UserName);
|
|
}
|
|
|
|
/// <summary>校验管理员专属入口。</summary>
|
|
private static void RequireAdmin(UserInfo actor)
|
|
{
|
|
if (actor.SaLevel != UserAdminLevel.SuperAdmin) throw new AppException("仅管理员可执行此操作");
|
|
}
|
|
|
|
/// <summary>主账号数据归属;子账号使用可信数据库记录中的父账号。</summary>
|
|
private static string MainName(UserInfo actor)
|
|
{
|
|
if (actor.GroupId == UserGroup.MainAccount || actor.SaLevel == UserAdminLevel.SuperAdmin)
|
|
return actor.UserName;
|
|
if (string.IsNullOrWhiteSpace(actor.ParentUserName)) throw new AppException("子账号缺少所属主账号");
|
|
return actor.ParentUserName;
|
|
}
|
|
|
|
/// <summary>本人店铺或直属主账号分配的店铺;完整匹配逗号分隔账号,避免前缀越权。</summary>
|
|
private static Expression<Func<ShopEntity, bool>> Scope(UserInfo actor)
|
|
{
|
|
var owner = MainName(actor);
|
|
var main = owner == actor.UserName;
|
|
var member = "," + actor.UserName + ",";
|
|
return s => s.UserName == owner && (main || ("," + s.SubUserName + ",").Contains(member));
|
|
}
|
|
|
|
/// <summary>为关联表查询提供当前用户可见店铺集合,不扩大管理员普通入口的范围。</summary>
|
|
private Task<List<string>> VisibleIds(UserInfo actor, CancellationToken ct)
|
|
=> db.Select<ShopEntity>().Where(Scope(actor)).Where(s => s.ShopId != "").Distinct().ToListAsync(s => s.ShopId, ct);
|
|
|
|
/// <summary>关联数据操作必须先核对店铺归属;使用事务时传入同一工作单元。</summary>
|
|
private static async Task RequireShop(IFreeSql orm, UserInfo actor, string shopId, CancellationToken ct)
|
|
{
|
|
if (!await orm.Select<ShopEntity>().Where(Scope(actor)).Where(s => s.ShopId == shopId).AnyAsync(ct))
|
|
throw new AppException("店铺不存在或无操作权限");
|
|
}
|
|
|
|
/// <summary>统一分页边界;DTO只声明基础必填规则。</summary>
|
|
private static void Page(int page, int size)
|
|
{
|
|
if (page < 1 || size is < 1 or > 500 || (long)(page - 1) * size > int.MaxValue)
|
|
throw new ValidationException("分页参数无效,每页数量必须为1至500");
|
|
}
|
|
|
|
/// <summary>批量资源必须明确指定,禁止空数组退化为全表操作。</summary>
|
|
private static void Ids(string[] ids)
|
|
{
|
|
if (ids.Length == 0 || ids.Any(string.IsNullOrWhiteSpace))
|
|
throw new ValidationException("编号数组不能为空且不能包含空编号");
|
|
}
|
|
|
|
/// <summary>分配账号必须是店铺主账号的直属子账号。</summary>
|
|
private async Task ValidateAssignments(string? names, string owner)
|
|
{
|
|
if (names == null || names == "") return;
|
|
foreach (var name in names.Split(','))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name) || name != name.Trim())
|
|
throw new ValidationException("分配账号须使用逗号分隔,不能包含空项或首尾空格");
|
|
var child = await users.GetUser(name);
|
|
if (child.ParentUserName != owner || child.SaLevel == UserAdminLevel.SuperAdmin)
|
|
throw new AppException("只能分配所属主账号的直属子账号");
|
|
}
|
|
}
|
|
|
|
/// <summary>普通或管理店铺列表;选品模式按两张选品表筛选并维护每月共享额度。</summary>
|
|
public async Task<ShopPage<ShopDetailsInfo>> Search(ShopQueryRequest request, bool admin = false,
|
|
bool selection = false, CancellationToken ct = default)
|
|
{
|
|
RequestValidator.Validate(request);
|
|
Page(request.Page, request.PageSize);
|
|
var actor = await Actor();
|
|
if (admin || selection) RequireAdmin(actor);
|
|
var query = db.Select<ShopEntity>();
|
|
if (!admin && !selection) query.Where(Scope(actor));
|
|
else query.Where(s => s.Cookie == "" || s.ShopId != "");
|
|
if (selection && !string.IsNullOrEmpty(request.OperateSubSaName))
|
|
query.Where(s => db.Select<SelectionEntity>().Any(p => p.ShopId == s.ShopId && p.OperateSubSaName == request.OperateSubSaName)
|
|
|| db.Select<ThirdSelectionEntity>().Any(p => p.ShopId == s.ShopId && p.OperateSubSaName == request.OperateSubSaName));
|
|
else query.WhereIf(!string.IsNullOrEmpty(request.OperateSubSaName), s => s.OperateSubSaName == request.OperateSubSaName);
|
|
query.WhereIf(!string.IsNullOrEmpty(request.SearchId), s => s.ShopId.Contains(request.SearchId!))
|
|
.WhereIf(!string.IsNullOrEmpty(request.SearchUserName), s => s.UserName.Contains(request.SearchUserName!))
|
|
.WhereIf(!string.IsNullOrEmpty(request.SearchLoginName), s => s.LoginName.Contains(request.SearchLoginName!))
|
|
.WhereIf(!string.IsNullOrEmpty(request.SearchName), s => s.ShopAccount.Contains(request.SearchName!))
|
|
.WhereIf(!string.IsNullOrEmpty(request.SearchNotes), s => s.Notes.Contains(request.SearchNotes!))
|
|
.WhereIf(request.ShopIds != null, s => request.ShopIds!.Contains(s.ShopId))
|
|
.WhereIf(request.ShopNames != null, s => request.ShopNames!.Contains(s.ShopAccount))
|
|
.WhereIf(request.ShopNotes != null, s => request.ShopNotes!.Contains(s.Notes));
|
|
var total = await query.CountAsync(ct);
|
|
var rows = await query.OrderByDescending(s => s.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
|
|
var mapper = new ShopDetailsMapper();
|
|
var result = rows.Select(mapper.Map).ToList();
|
|
var ids = rows.Select(s => s.ShopId).Where(s => !string.IsNullOrEmpty(s)).Distinct().ToArray();
|
|
var expires = await db.Select<ShopExpireEntity>().Where(e => ids.Contains(e.ShopId)).ToListAsync(ct);
|
|
var paid = actor.Authority?.Contains("充值用户") == true;
|
|
if (!admin && !string.IsNullOrEmpty(actor.ParentUserName))
|
|
paid |= (await users.GetUser(actor.ParentUserName)).Authority?.Contains("充值用户") == true;
|
|
var now = DateTimeOffset.Now.ToUnixTimeSeconds();
|
|
foreach (var item in result)
|
|
{
|
|
item.ExpireTime = item.NeverExpires ? 1893427200 : expires.FirstOrDefault(e => e.ShopId == item.ShopId)?.ExpireTime ?? 0;
|
|
item.IsPointShopExpire = !admin && paid && !item.NeverExpires && item.ExpireTime < now;
|
|
}
|
|
if (request.IsGetErpShopId)
|
|
{
|
|
var erp = await db.Select<ErpShopEntity>().Where(e => ids.Contains(e.AppKey)).OrderBy(e => e.Id).ToListAsync(ct);
|
|
foreach (var item in result)
|
|
{
|
|
var linked = erp.FirstOrDefault(e => e.AppKey == item.ShopId);
|
|
item.ErpShopId = linked?.Id;
|
|
item.ErpCorporateAccountUserId = linked?.CorporateAccountUserId;
|
|
item.ErpManageUserId = linked?.ManageUserId;
|
|
}
|
|
}
|
|
if (selection) await RefreshQuotas(result, ct);
|
|
return new(result, total);
|
|
}
|
|
|
|
/// <summary>查询同一登录名的授权店铺,供客户端判断新增或更新。</summary>
|
|
public async Task<IReadOnlyList<ShopDetailsInfo>> ByLogin(LoginRequest request, CancellationToken ct)
|
|
{
|
|
RequestValidator.Validate(request);
|
|
var actor = await Actor();
|
|
var rows = await db.Select<ShopEntity>().Where(Scope(actor)).Where(s => s.LoginName == request.LoginName)
|
|
.OrderBy(s => s.Id).ToListAsync(ct);
|
|
return rows.Select(new ShopDetailsMapper().Map).ToList();
|
|
}
|
|
|
|
/// <summary>批量读取授权店铺Cookie;对重复店铺ID按最新记录确定结果。</summary>
|
|
public async Task<IReadOnlyList<ShopCookieInfo>> Cookies(ShopIdsRequest request, CancellationToken ct)
|
|
{
|
|
RequestValidator.Validate(request);
|
|
Ids(request.ShopIds);
|
|
var actor = await Actor();
|
|
var rows = await db.Select<ShopEntity>().Where(Scope(actor)).Where(s => request.ShopIds.Contains(s.ShopId))
|
|
.OrderByDescending(s => s.Id).ToListAsync(ct);
|
|
if (request.ShopIds.Except(rows.Select(s => s.ShopId)).Any()) throw new AppException("包含无权访问的店铺");
|
|
return rows.DistinctBy(s => s.ShopId).Select(s => new ShopCookieInfo(s.ShopId, s.Cookie)).ToList();
|
|
}
|
|
|
|
/// <summary>新增或修改微信店铺;新增重复时报错,不使用微店的覆盖导入行为。</summary>
|
|
public async Task Save(ShopSaveRequest request, bool create, bool admin, CancellationToken ct)
|
|
{
|
|
RequestValidator.Validate(request);
|
|
var actor = await Actor();
|
|
if (admin) RequireAdmin(actor);
|
|
if (admin && string.IsNullOrWhiteSpace(request.ShopUserName)) throw new ValidationException("管理员操作必须指定店铺所属账号");
|
|
var owner = admin ? (await users.GetUser(request.ShopUserName!)).UserName : MainName(actor);
|
|
if (create && !admin && owner != actor.UserName) throw new AppException("只有主账号可新增店铺");
|
|
if (request.SubUserName != null && !admin && owner != actor.UserName) throw new AppException("子账号不能修改店铺分配关系");
|
|
await ValidateAssignments(request.SubUserName, owner);
|
|
using var work = db.CreateUnitOfWork();
|
|
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
|
|
work.GetOrBeginTransaction();
|
|
var orm = work.Orm;
|
|
var query = orm.Select<ShopEntity>().Where(s => s.UserName == owner && s.LoginName == request.LoginName)
|
|
.WhereIf(!string.IsNullOrEmpty(request.ShopId), s => s.ShopId == request.ShopId);
|
|
if (!admin) query.Where(Scope(actor));
|
|
var rows = await query.ForUpdate().ToListAsync(ct);
|
|
if (create)
|
|
{
|
|
if (rows.Count != 0) throw new AppException("店铺已存在");
|
|
await orm.Insert(new ShopEntity
|
|
{
|
|
UserName = owner, LoginName = request.LoginName, LoginPass = request.LoginPass ?? "",
|
|
ShopId = request.ShopId ?? "", ShopAccount = request.ShopAccount ?? "", Cookie = request.Cookie ?? "",
|
|
Cookie2 = request.Cookie2 ?? "", AntiContents = request.AntiContents ?? "", SubUserName = request.SubUserName ?? "",
|
|
GroupName = request.GroupName ?? "", Notes = request.Notes ?? "", Openid = "", CompanyName = "",
|
|
LoginStatus = request.LoginStatus ?? "", LoginStatus2 = "", OperateSubSaName = "",
|
|
AddTime = DateTime.Now, MdyTime = DateTime.Now
|
|
}).ExecuteAffrowsAsync(ct);
|
|
}
|
|
else
|
|
{
|
|
if (rows.Count == 0) throw new AppException("店铺不存在或无操作权限");
|
|
var ids = rows.Select(s => s.Id).ToArray();
|
|
var update = orm.Update<ShopEntity>().Where(s => ids.Contains(s.Id)).Set(s => s.MdyTime, DateTime.Now);
|
|
if (request.LoginPass != null) update.Set(s => s.LoginPass, request.LoginPass);
|
|
if (request.Cookie != null) update.Set(s => s.Cookie, request.Cookie);
|
|
if (request.Cookie2 != null) update.Set(s => s.Cookie2, request.Cookie2);
|
|
if (request.AntiContents != null) update.Set(s => s.AntiContents, request.AntiContents);
|
|
if (request.NewShopId != null) update.Set(s => s.ShopId, request.NewShopId);
|
|
if (request.ShopAccount != null) update.Set(s => s.ShopAccount, request.ShopAccount);
|
|
if (request.SubUserName != null) update.Set(s => s.SubUserName, request.SubUserName);
|
|
if (request.GroupName != null) update.Set(s => s.GroupName, request.GroupName);
|
|
if (request.Notes != null) update.Set(s => s.Notes, request.Notes);
|
|
if (request.LoginStatus != null) update.Set(s => s.LoginStatus, request.LoginStatus);
|
|
await update.ExecuteAffrowsAsync(ct);
|
|
}
|
|
ct.ThrowIfCancellationRequested();
|
|
work.Commit();
|
|
}
|
|
|
|
/// <summary>更新登录名下全部授权店铺的Cookie与状态,禁止跨账号同名更新。</summary>
|
|
public async Task UpdateCookies(CookieRequest request, CancellationToken ct)
|
|
{
|
|
RequestValidator.Validate(request);
|
|
if (request.Cookie == null && request.Cookie2 == null && request.LoginStatus == null && request.LoginStatus2 == null && request.Openid == null)
|
|
throw new ValidationException("至少提供一个登录状态字段");
|
|
var actor = await Actor();
|
|
var update = db.Update<ShopEntity>().Where(Scope(actor)).Where(s => s.LoginName == request.LoginName).Set(s => s.MdyTime, DateTime.Now);
|
|
if (request.Cookie != null) update.Set(s => s.Cookie, request.Cookie);
|
|
if (request.Cookie2 != null) update.Set(s => s.Cookie2, request.Cookie2);
|
|
if (request.LoginStatus != null) update.Set(s => s.LoginStatus, request.LoginStatus);
|
|
if (request.LoginStatus2 != null) update.Set(s => s.LoginStatus2, request.LoginStatus2);
|
|
if (request.Openid != null) update.Set(s => s.Openid, request.Openid);
|
|
if (await update.ExecuteAffrowsAsync(ct) == 0
|
|
&& !await db.Select<ShopEntity>().Where(Scope(actor)).Where(s => s.LoginName == request.LoginName).AnyAsync(ct))
|
|
throw new AppException("店铺不存在或无操作权限");
|
|
}
|
|
|
|
/// <summary>删除所属账号的店铺记录;不删除独立的续费记录。</summary>
|
|
public async Task Delete(LoginRequest request, bool admin, CancellationToken ct)
|
|
{
|
|
RequestValidator.Validate(request);
|
|
var actor = await Actor();
|
|
if (admin) RequireAdmin(actor);
|
|
if (admin && string.IsNullOrWhiteSpace(request.ShopUserName)) throw new ValidationException("必须指定店铺所属账号");
|
|
var owner = admin ? request.ShopUserName! : actor.UserName;
|
|
var deleted = await db.Delete<ShopEntity>().Where(s => s.UserName == owner && s.LoginName == request.LoginName)
|
|
.WhereIf(!string.IsNullOrEmpty(request.ShopId), s => s.ShopId == request.ShopId).ExecuteAffrowsAsync(ct);
|
|
if (deleted == 0) throw new AppException("店铺不存在或无删除权限");
|
|
}
|
|
}
|