增加Shop API

This commit is contained in:
chfei committed 2026-10-29 11:34:35 +08:00
1 parent 3769193696
commit 5d017740c6
83 files changed
+4451 -696

No files matched your search

@@ -0,0 +1,48 @@
using System.Text.Json;
using MAX.Framework.Commons;
namespace Store.Shops.Services;
/// <summary>e57 店铺令牌查询;固定上游地址,失败重试一次,保留证书验证。</summary>
public sealed class PddTokenClient(HttpClient http) : IDisposable
{
/// <summary>按拼多多商城ID获取授权资料。</summary>
public Task<JsonElement> GetToken(string mallId, CancellationToken ct)
=> GetResult("info/forPdd?mallId=" + Uri.EscapeDataString(mallId), "data", ct);
/// <summary>按微信AppId获取单店授权资料。</summary>
public Task<JsonElement> GetShopToken(string appId, CancellationToken ct)
=> GetResult("info?appId=" + Uri.EscapeDataString(appId), "data", ct);
/// <summary>按部署配置中的e57账号读取微信令牌列表。</summary>
public Task<JsonElement> GetTokens(string userName, CancellationToken ct)
=> GetResult("list?userName=" + Uri.EscapeDataString(userName), "list", ct);
/// <summary>解析上游业务状态,只返回指定数据节点,不泄露原始错误页面。</summary>
private async Task<JsonElement> GetResult(string path, string property, CancellationToken ct)
{
var url = "https://po.e57.cn/prod-api/api/sys-api/system/open/api/shop/" + path;
for (var attempt = 0; attempt < 2; attempt++)
{
try
{
using var response = await http.GetAsync(url, ct);
if (!response.IsSuccessStatusCode) continue;
using var json = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(ct), cancellationToken: ct);
var root = json.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("code", out var code) && code.ValueKind == JsonValueKind.Number
&& code.TryGetInt32(out var value) && value == 0
&& root.TryGetProperty(property, out var data))
return data.Clone();
}
catch (HttpRequestException) { /* 上游网络失败按旧接口约定重试一次。 */ }
catch (JsonException) { /* 非 JSON 上游响应按业务失败处理,不返回原始页面。 */ }
catch (OperationCanceledException) when (!ct.IsCancellationRequested) { /* HttpClient 超时。 */ }
}
throw new AppException("获取店铺令牌失败,请稍后重试");
}
/// <summary>随容器关闭释放共享HTTP客户端。</summary>
public void Dispose() => http.Dispose();
}
@@ -0,0 +1,33 @@
using System.Text.Json;
using MAX.Framework.Commons;
using MAX.Framework.Validation;
using Microsoft.Extensions.Configuration;
using Store.Shops.Dtos;
using Store.Shops.Entities;
namespace Store.Shops.Services;
public sealed partial class ShopManagementService
{
public async Task<IReadOnlyList<PddClientInfo>> GetPddClients()
{
await Actor();
var clients = configuration.GetSection("Shops:PddClients").Get<List<PddClientInfo>>() ?? [];
if (clients.Count == 0 || clients.Any(c => string.IsNullOrWhiteSpace(c.ClientId)
|| string.IsNullOrWhiteSpace(c.ClientSecret) || string.IsNullOrWhiteSpace(c.RedirectUri)))
throw new AppException("尚未完整配置拼多多应用信息");
return clients;
}
public async Task<JsonElement> GetPddToken(PddTokenRequest request, CancellationToken ct = default)
{
RequestValidator.Validate(request);
var actor = await Actor();
var name = actor.UserName;
var member = "," + name + ",";
if (!await db.Select<PddShopEntity>().Where(s => s.MallId == request.MallId
&& (s.UserName == name || ("," + s.SubUserName + ",").Contains(member))).AnyAsync(ct))
throw new AppException("店铺不存在或无读取令牌权限");
return await e57.GetToken(request.MallId, ct);
}
}
@@ -0,0 +1,194 @@
using System.ComponentModel.DataAnnotations;
using MAX.Core.DependencyInjection;
using MAX.Framework.Commons;
using MAX.Framework.Security;
using MAX.Framework.Validation;
using Microsoft.Extensions.Configuration;
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;
namespace Store.Shops.Services;
/// <summary>店铺业务入口;所有调用路径均从当前身份计算数据范围。</summary>
public sealed partial class ShopManagementService(
IFreeSql db, ICurrentUser currentUser, IUserQueryService users,
IConfiguration configuration, PddTokenClient e57) : ITransientDependency
{
private async Task<UserInfo> Actor()
{
if (!currentUser.IsAuthenticated || string.IsNullOrWhiteSpace(currentUser.UserName))
throw new UnauthorizedAccessException("请先登录");
return await users.GetUser(currentUser.UserName);
}
private static void ValidatePage(int page, int size)
{
if (page < 1 || size < 1 || size > 500 || (long)(page - 1) * size > int.MaxValue)
throw new ValidationException("页码必须大于0,每页数量必须为1至500,分页偏移不能超限");
}
public async Task<ShopPage<ShopClientInfo>> GetShopClientInfo(ShopClientSearchRequest request, CancellationToken ct = default)
{
RequestValidator.Validate(request);
ValidatePage(request.Page, request.PageSize);
var actor = await Actor();
var name = actor.UserName;
var member = "," + name + ",";
// 授权凭据即使由管理员请求,也必须属于本人或明确分配给本人的店铺。
var query = db.Select<ShopApiAuthEntity>()
.Where(a => db.Select<PddShopEntity>().Any(s => s.ShopId == a.ShopId
&& (s.UserName == name || ("," + s.SubUserName + ",").Contains(member))))
.WhereIf(request.ShopIds != null, a => request.ShopIds!.Contains(a.ShopId));
var total = await query.CountAsync(ct);
var rows = await query.OrderByDescending(a => a.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
var mapper = new ShopMapper();
return new(rows.Select(mapper.ToClientInfo).ToList(), total);
}
public async Task<ShopPage<ShopInfo>> GetAdminShops(ShopSearchRequest request, CancellationToken ct = default)
{
RequestValidator.Validate(request);
ValidatePage(request.Page, request.PageSize);
var actor = await Actor();
if (actor.SaLevel != UserAdminLevel.SuperAdmin)
throw new AppException("仅管理员可查询全部店铺");
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
var query = orm.Select<PddShopEntity>()
.Where(s => s.Cookie == "" || s.ShopId != "")
.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(!string.IsNullOrEmpty(request.OperateSubSaName), s => s.OperateSubSaName == request.OperateSubSaName)
.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 shops = await query.OrderByDescending(s => s.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
var mapper = new ShopMapper();
var result = shops.Select(mapper.ToInfo).ToList();
// shortcut: 旧库只保存月份;需要跨年区分同月额度时升级为年月字段。
var month = DateTime.Now.Month;
// 同店铺可能有多条登录记录,按 shop_id 加锁并只重置一次共享额度。
foreach (var group in result.Where(s => s.ShopId != "").GroupBy(s => s.ShopId).OrderBy(g => g.Key, StringComparer.Ordinal))
{
var quota = await orm.Select<ShopProductQuotaEntity>().Where(q => q.ShopId == group.Key).ForUpdate().FirstAsync(ct);
if (quota == null)
{
quota = new() { ShopId = group.Key, ShopAccount = group.First().ShopAccount, AddProductMonth = month, AddProductRemaining = 300 };
await orm.Insert(quota).ExecuteAffrowsAsync(ct);
}
else if (quota.AddProductMonth != month)
{
quota.AddProductMonth = month;
quota.AddProductRemaining = 300;
await orm.Update<ShopProductQuotaEntity>().Where(q => q.ShopId == group.Key)
.Set(q => q.AddProductMonth, month).Set(q => q.AddProductRemaining, 300).ExecuteAffrowsAsync(ct);
}
await orm.Update<PddShopEntity>().Where(s => s.ShopId == group.Key)
.Set(s => s.AddProductMonth, quota.AddProductMonth)
.Set(s => s.AddProductRemaining, quota.AddProductRemaining).ExecuteAffrowsAsync(ct);
foreach (var shop in group)
{
shop.AddProductMonth = quota.AddProductMonth;
shop.AddProductRemaining = quota.AddProductRemaining;
}
}
ct.ThrowIfCancellationRequested();
work.Commit();
return new(result, total);
}
public async Task SaveWeidian(WeidianSaveRequest request, bool create, CancellationToken ct = default)
{
RequestValidator.Validate(request);
if (request.LoginName.Length > 255)
throw new ValidationException("店铺登录名不能超过255个字符");
var actor = await Actor();
var name = actor.UserName;
var member = "," + name + ",";
using var work = db.CreateUnitOfWork();
// 锁定匹配范围,避免同一账号并发导入产生重复记录;需要 InnoDB。
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
var query = orm.Select<WeidianShopEntity>().Where(s => s.LoginName == request.LoginName)
.Where(s => s.UserName == name || (!create && ("," + s.SubUserName + ",").Contains(member)))
.WhereIf(!string.IsNullOrEmpty(request.ShopId), s => s.ShopId == request.ShopId);
var matches = await query.ForUpdate().ToListAsync(ct);
if (matches.Count > 1)
throw new AppException("登录名对应多个店铺,请指定店铺ID");
var shop = matches.SingleOrDefault();
if (shop == null)
{
if (!create) throw new AppException("店铺不存在或无操作权限");
await orm.Insert(new WeidianShopEntity
{
LoginName = request.LoginName, UserName = name, ShopId = request.ShopId ?? "",
LoginPass = request.LoginPass ?? "", Cookie = request.Cookie ?? "", Cookie2 = request.Cookie2 ?? "",
WdToken = request.WdToken ?? "", MallId = request.MallId ?? "", ShopAccount = request.ShopAccount ?? "",
SubUserName = request.SubUserName ?? "", GroupName = request.GroupName ?? "", Notes = request.Notes ?? "",
LoginStatus = request.LoginStatus ?? "", AddTime = DateTime.Now, MdyTime = DateTime.Now
}).ExecuteAffrowsAsync(ct);
}
else
{
if (request.SubUserName != null && shop.UserName != name)
throw new AppException("只有店铺所有者可以修改分配账号");
var update = orm.Update<WeidianShopEntity>().Where(s => s.Id == shop.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.WdToken != null) update.Set(s => s.WdToken, request.WdToken);
if (request.NewShopId != null) update.Set(s => s.ShopId, request.NewShopId);
if (request.MallId != null) update.Set(s => s.MallId, request.MallId);
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();
}
public async Task UpdateWeidianCookie(WeidianCookieRequest request, CancellationToken ct = default)
{
RequestValidator.Validate(request);
if (string.IsNullOrEmpty(request.Cookie) && string.IsNullOrEmpty(request.LoginStatus))
throw new ValidationException("Cookie和登录状态不能全为空");
var actor = await Actor();
var name = actor.UserName;
var member = "," + name + ",";
var scope = db.Select<WeidianShopEntity>().Where(s => s.ShopId == request.ShopId
&& (s.UserName == name || ("," + s.SubUserName + ",").Contains(member)));
if (!await scope.AnyAsync(ct)) throw new AppException("店铺不存在或无操作权限");
var update = db.Update<WeidianShopEntity>().Where(s => s.ShopId == request.ShopId
&& (s.UserName == name || ("," + s.SubUserName + ",").Contains(member)))
.Set(s => s.MdyTime, DateTime.Now);
if (request.Cookie != null) update.Set(s => s.Cookie, request.Cookie);
if (request.WdToken != null) update.Set(s => s.WdToken, request.WdToken);
if (request.LoginStatus != null) update.Set(s => s.LoginStatus, request.LoginStatus);
await update.ExecuteAffrowsAsync(ct);
}
public async Task DeleteWeidian(ShopIdRequest request, CancellationToken ct = default)
{
RequestValidator.Validate(request);
var actor = await Actor();
var affected = await db.Delete<WeidianShopEntity>()
.Where(s => s.ShopId == request.ShopId && s.UserName == actor.UserName).ExecuteAffrowsAsync(ct);
if (affected == 0) throw new AppException("店铺不存在或无删除权限");
}
}
@@ -0,0 +1,136 @@
using System.ComponentModel.DataAnnotations;
using MAX.Framework.Commons;
using MAX.Framework.Validation;
using Store.Shops.Dtos;
using Store.Shops.Entities;
using Store.Shops.Mappers;
namespace Store.Shops.Services;
public sealed partial class ShopService
{
/// <summary>保存当前账号的店铺售后地址;同账号同店铺更新原记录。</summary>
public async Task SaveDefaultAddress(AddressRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
var actor = await Actor();
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
await RequireShop(orm, actor, request.ShopId, ct);
var row = await orm.Select<DefaultAddressEntity>()
.Where(a => a.UserId == actor.UserName && a.ShopId == request.ShopId).ForUpdate().FirstAsync(ct);
if (row == null)
await orm.Insert(new DefaultAddressEntity
{
UserId = actor.UserName, ShopId = request.ShopId, ProvinceName = request.ProvinceName,
CityName = request.CityName, CountyName = request.CountyName, DetailInfo = request.DetailInfo,
Receiver = request.Receiver, TelNumber = request.TelNumber, AddTime = DateTime.Now, MdyTime = DateTime.Now
}).ExecuteAffrowsAsync(ct);
else
await orm.Update<DefaultAddressEntity>().Where(a => a.Id == row.Id)
.Set(a => a.ProvinceName, request.ProvinceName).Set(a => a.CityName, request.CityName)
.Set(a => a.CountyName, request.CountyName).Set(a => a.DetailInfo, request.DetailInfo)
.Set(a => a.Receiver, request.Receiver).Set(a => a.TelNumber, request.TelNumber)
.Set(a => a.MdyTime, DateTime.Now).ExecuteAffrowsAsync(ct);
ct.ThrowIfCancellationRequested();
work.Commit();
}
/// <summary>按当前账号和可见店铺范围查询默认地址。</summary>
public async Task<ShopPage<DefaultAddressInfo>> DefaultAddresses(AddressSearchRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Page(request.Page, request.PageSize);
var actor = await Actor();
var ids = await VisibleIds(actor, ct);
var query = db.Select<DefaultAddressEntity>().Where(a => a.UserId == actor.UserName && ids.Contains(a.ShopId))
.WhereIf(request.ShopIds != null, a => request.ShopIds!.Contains(a.ShopId));
var count = request.NotCount ? -1 : await query.CountAsync(ct);
var rows = await query.OrderByDescending(a => a.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
return new(rows.Select(new ShopDetailsMapper().Map).ToList(), count);
}
/// <summary>删除本人指定店铺的默认地址,不影响其他账号保存的地址。</summary>
public async Task DeleteDefaultAddresses(ShopIdsRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Ids(request.ShopIds);
var actor = await Actor();
var ids = await VisibleIds(actor, ct);
if (request.ShopIds.Except(ids).Any()) throw new AppException("包含无权访问的店铺");
await db.Delete<DefaultAddressEntity>().Where(a => a.UserId == actor.UserName && request.ShopIds.Contains(a.ShopId)).ExecuteAffrowsAsync(ct);
}
/// <summary>以店铺及订单为键保存解密地址,禁止跨店覆盖。</summary>
public async Task SaveOrderAddress(OrderAddressRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
var actor = await Actor();
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
await RequireShop(orm, actor, request.ShopId, ct);
var row = await orm.Select<OrderAddressEntity>().Where(a => a.ShopId == request.ShopId && a.OrderId == request.OrderId).ForUpdate().FirstAsync(ct);
if (row == null)
await orm.Insert(new OrderAddressEntity { ShopId = request.ShopId, OrderId = request.OrderId,
OrderAddress = request.OrderAddress, AddTime = DateTime.Now, MdyTime = DateTime.Now }).ExecuteAffrowsAsync(ct);
else
await orm.Update<OrderAddressEntity>().Where(a => a.Id == row.Id).Set(a => a.OrderAddress, request.OrderAddress)
.Set(a => a.MdyTime, DateTime.Now).ExecuteAffrowsAsync(ct);
ct.ThrowIfCancellationRequested();
work.Commit();
}
/// <summary>查询订单解密地址,先限制当前用户可见店铺,再按订单过滤。</summary>
public async Task<ShopPage<OrderAddressInfo>> OrderAddresses(OrderSearchRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Page(request.Page, request.PageSize);
if (request.OrderIds == null) throw new ValidationException("订单编号不能为空");
Ids(request.OrderIds);
var actor = await Actor();
var ids = await VisibleIds(actor, ct);
var query = db.Select<OrderAddressEntity>().Where(a => ids.Contains(a.ShopId) && request.OrderIds.Contains(a.OrderId));
var count = request.NotCount ? -1 : await query.CountAsync(ct);
var rows = await query.OrderByDescending(a => a.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
return new(rows.Select(new ShopDetailsMapper().Map).ToList(), count);
}
/// <summary>采购备注按可信主账号归属保存,直属子账号共享主账号的备注。</summary>
public async Task SaveBuyNote(NoteRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
var owner = MainName(await Actor());
if (request.MainUserName != null && request.MainUserName != owner) throw new AppException("无权修改其他主账号备注");
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
var row = await orm.Select<BuyUrlNoteEntity>().Where(n => n.UserName == owner && n.SpuCode == request.SpuCode).ForUpdate().FirstAsync(ct);
if (row == null)
await orm.Insert(new BuyUrlNoteEntity { UserName = owner, SpuCode = request.SpuCode,
BuyUrlNotes = request.BuyUrlNotes, AddTime = DateTime.Now, MdyTime = DateTime.Now }).ExecuteAffrowsAsync(ct);
else
await orm.Update<BuyUrlNoteEntity>().Where(n => n.Id == row.Id).Set(n => n.BuyUrlNotes, request.BuyUrlNotes)
.Set(n => n.MdyTime, DateTime.Now).ExecuteAffrowsAsync(ct);
ct.ThrowIfCancellationRequested();
work.Commit();
}
/// <summary>分页查询当前主账号的采购备注。</summary>
public async Task<ShopPage<BuyUrlNoteInfo>> BuyNotes(NoteSearchRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Page(request.Page, request.PageSize);
Ids(request.SpuCodes);
var owner = MainName(await Actor());
if (request.MainUserName != null && request.MainUserName != owner) throw new AppException("无权查询其他主账号备注");
var query = db.Select<BuyUrlNoteEntity>().Where(n => n.UserName == owner && request.SpuCodes.Contains(n.SpuCode));
var count = request.NotCount ? -1 : await query.CountAsync(ct);
var rows = await query.OrderByDescending(n => n.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
return new(rows.Select(new ShopDetailsMapper().Map).ToList(), count);
}
}
@@ -0,0 +1,121 @@
using System.ComponentModel.DataAnnotations;
using MAX.Framework.Commons;
using MAX.Framework.Validation;
using Store.Shops.Dtos;
using Store.Shops.Entities;
using Store.Shops.Mappers;
using YourNamespace;
namespace Store.Shops.Services;
public sealed partial class ShopService
{
/// <summary>选品列表跨月时重置300条额度,并同步店铺表;同店多条登录记录只处理一次。</summary>
private async Task RefreshQuotas(List<ShopDetailsInfo> shops, CancellationToken ct)
{
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
// shortcut: 旧表仅保存月份;需要区分跨年同月额度时升级为年月字段。
var month = DateTime.Now.Month;
foreach (var group in shops.Where(s => !string.IsNullOrEmpty(s.ShopId)).GroupBy(s => s.ShopId).OrderBy(g => g.Key, StringComparer.Ordinal))
{
var quota = await orm.Select<ShopQuotaEntity>().Where(q => q.ShopId == group.Key).ForUpdate().FirstAsync(ct);
if (quota == null)
{
quota = new() { ShopId = group.Key, ShopAccount = group.First().ShopAccount, AddProductMonth = month, AddProductRemaining = 300 };
await orm.Insert(quota).ExecuteAffrowsAsync(ct);
}
else if (quota.AddProductMonth != month)
{
quota.AddProductMonth = month;
quota.AddProductRemaining = 300;
await orm.Update<ShopQuotaEntity>().Where(q => q.ShopId == group.Key)
.Set(q => q.AddProductMonth, month).Set(q => q.AddProductRemaining, 300).ExecuteAffrowsAsync(ct);
}
await orm.Update<ShopEntity>().Where(s => s.ShopId == group.Key)
.Set(s => s.AddProductMonth, (byte)quota.AddProductMonth)
.Set(s => s.AddProductRemaining, quota.AddProductRemaining).ExecuteAffrowsAsync(ct);
foreach (var item in group)
{
item.AddProductMonth = (byte)quota.AddProductMonth;
item.AddProductRemaining = quota.AddProductRemaining;
}
}
ct.ThrowIfCancellationRequested();
work.Commit();
}
/// <summary>管理员设置明确指定店铺的共享额度,同步列表读取的店铺字段。</summary>
public async Task UpdateQuota(QuotaRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Ids(request.ShopIds);
if (request.RemainCount < 0) throw new ValidationException("剩余额度不能小于0");
RequireAdmin(await Actor());
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
var existing = await orm.Select<ShopQuotaEntity>().Where(q => request.ShopIds.Contains(q.ShopId)).ForUpdate().ToListAsync(ct);
if (request.ShopIds.Except(existing.Select(q => q.ShopId)).Any()) throw new AppException("部分店铺尚未建立额度记录");
await orm.Update<ShopQuotaEntity>().Where(q => request.ShopIds.Contains(q.ShopId))
.Set(q => q.AddProductRemaining, request.RemainCount).ExecuteAffrowsAsync(ct);
await orm.Update<ShopEntity>().Where(s => request.ShopIds.Contains(s.ShopId))
.Set(s => s.AddProductRemaining, request.RemainCount).ExecuteAffrowsAsync(ct);
ct.ThrowIfCancellationRequested();
work.Commit();
}
/// <summary>管理员按记录ID或登录名分配选品操作员,空账号表示解除分配。</summary>
public async Task Assign(AssignRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
if (request.DbIds is not { Length: > 0 } && request.ShopLoginNames is not { Length: > 0 })
throw new ValidationException("请选择店铺记录或登录名");
if (request.DbIds?.Any(id => id <= 0) == true) throw new ValidationException("记录ID必须大于0");
if (request.ShopLoginNames != null) Ids(request.ShopLoginNames);
RequireAdmin(await Actor());
if (request.OperateSubSaName != "") await users.GetUser(request.OperateSubSaName);
var update = db.Update<ShopEntity>();
if (request.DbIds is { Length: > 0 }) update.Where(s => request.DbIds.Contains(s.Id));
else update.Where(s => request.ShopLoginNames!.Contains(s.LoginName));
if (await update.Set(s => s.OperateSubSaName, request.OperateSubSaName)
.Set(s => s.AddSubSaTime, DateTime.Now).ExecuteAffrowsAsync(ct) == 0)
throw new AppException("未找到指定店铺");
}
/// <summary>查询授权店铺对应的ERP商户余额,不能仅凭外部商户ID读取他人余额。</summary>
public async Task<ShopPage<ErpAccountInfo>> ErpMerchants(ErpRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Page(request.Page, request.PageSize);
if (request.CorporateAccountUserId == null && request.ManageUserId == null)
throw new ValidationException("必须指定公司账号或管理账号");
if (request.CorporateAccountUserId <= 0 || request.ManageUserId <= 0)
throw new ValidationException("ERP账号ID必须大于0");
var actor = await Actor();
var ids = await VisibleIds(actor, ct);
var linked = await db.Select<ErpShopEntity>().Where(s => ids.Contains(s.AppKey))
.WhereIf(!string.IsNullOrEmpty(request.ShopId), s => s.AppKey == request.ShopId).ToListAsync(ct);
var managers = new List<long>();
if (request.ManageUserId.HasValue)
{
if (!linked.Any(s => s.ManageUserId == request.ManageUserId)) throw new AppException("无权查询该ERP管理账号");
managers.Add(request.ManageUserId.Value);
}
if (request.CorporateAccountUserId.HasValue)
{
if (!linked.Any(s => s.CorporateAccountUserId == request.CorporateAccountUserId))
throw new AppException("无权查询该ERP公司账号");
managers.AddRange(await db.Select<ErpGroupEntity>()
.Where(g => g.CorporateAccountUserId == request.CorporateAccountUserId)
.ToListAsync(g => g.ManageUserId, ct));
}
var query = db.Select<ErpAccountEntity>().Where(a => managers.Contains(a.UserId));
var count = request.NotCount ? -1 : await query.CountAsync(ct);
var rows = await query.OrderByDescending(a => a.Balance).OrderBy(a => a.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
return new(rows.Select(new ShopDetailsMapper().Map).ToList(), count);
}
}
@@ -0,0 +1,102 @@
using System.ComponentModel.DataAnnotations;
using MAX.Framework.Commons;
using MAX.Framework.Validation;
using Store.Shops.Dtos;
using Store.Shops.Entities;
using Store.Shops.Mappers;
namespace Store.Shops.Services;
public sealed partial class ShopService
{
/// <summary>保存授权店铺的物流优化结果;订单已关联其他店铺时拒绝覆盖。</summary>
public async Task SaveOptimizeResult(OptimizeResultRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
var actor = await Actor();
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
await RequireShop(orm, actor, request.ShopId, ct);
var row = await orm.Select<OptimizeResultEntity>().Where(r => r.OrderId == request.OrderId).ForUpdate().FirstAsync(ct);
if (row != null && row.ShopId != request.ShopId)
throw new AppException("订单属于其他店铺,不能覆盖记录");
if (row == null)
await orm.Insert(new OptimizeResultEntity
{
ShopId = request.ShopId, OrderId = request.OrderId, OptimizeRst = request.OptimizeRst!,
WaybillId = request.WaybillId ?? "", DeliveryName = request.DeliveryName ?? "",
AddTime = DateTime.Now, MdyTime = DateTime.Now
}).ExecuteAffrowsAsync(ct);
else
{
var update = orm.Update<OptimizeResultEntity>().Where(r => r.Id == row.Id)
.Set(r => r.OptimizeRst, request.OptimizeRst!).Set(r => r.MdyTime, DateTime.Now);
if (request.WaybillId != null) update.Set(r => r.WaybillId, request.WaybillId);
if (request.DeliveryName != null) update.Set(r => r.DeliveryName, request.DeliveryName);
await update.ExecuteAffrowsAsync(ct);
}
ct.ThrowIfCancellationRequested();
work.Commit();
}
/// <summary>分页查询可见店铺的物流优化结果,统计与数据使用相同范围。</summary>
public async Task<ShopPage<OptimizeResultInfo>> OptimizeResults(OrderSearchRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Page(request.Page, request.PageSize);
var actor = await Actor();
var ids = await VisibleIds(actor, ct);
var query = db.Select<OptimizeResultEntity>().Where(r => ids.Contains(r.ShopId))
.WhereIf(request.OrderIds != null, r => request.OrderIds!.Contains(r.OrderId));
var count = request.NotCount ? -1 : await query.CountAsync(ct);
var rows = await query.OrderByDescending(r => r.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
return new(rows.Select(new ShopDetailsMapper().Map).ToList(), count);
}
/// <summary>保存授权店铺的物流优化备注;订单已关联其他店铺时拒绝覆盖。</summary>
public async Task SaveOptimizeRemark(OptimizeRemarkRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
var actor = await Actor();
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
await RequireShop(orm, actor, request.ShopId, ct);
var row = await orm.Select<OptimizeRemarkEntity>().Where(r => r.OrderId == request.OrderId).ForUpdate().FirstAsync(ct);
if (row != null && row.ShopId != request.ShopId)
throw new AppException("订单属于其他店铺,不能覆盖记录");
if (row == null)
await orm.Insert(new OptimizeRemarkEntity
{
ShopId = request.ShopId, OrderId = request.OrderId, Remark = request.Remark!,
AddTime = DateTime.Now, MdyTime = DateTime.Now
}).ExecuteAffrowsAsync(ct);
else
{
var update = orm.Update<OptimizeRemarkEntity>().Where(r => r.Id == row.Id)
.Set(r => r.Remark, request.Remark!).Set(r => r.MdyTime, DateTime.Now);
await update.ExecuteAffrowsAsync(ct);
}
ct.ThrowIfCancellationRequested();
work.Commit();
}
/// <summary>分页查询可见店铺的物流优化备注,统计与数据使用相同范围。</summary>
public async Task<ShopPage<OptimizeRemarkInfo>> OptimizeRemarks(OrderSearchRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Page(request.Page, request.PageSize);
var actor = await Actor();
var ids = await VisibleIds(actor, ct);
var query = db.Select<OptimizeRemarkEntity>().Where(r => ids.Contains(r.ShopId))
.WhereIf(request.OrderIds != null, r => request.OrderIds!.Contains(r.OrderId));
var count = request.NotCount ? -1 : await query.CountAsync(ct);
var rows = await query.OrderByDescending(r => r.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
return new(rows.Select(new ShopDetailsMapper().Map).ToList(), count);
}
}
@@ -0,0 +1,108 @@
using System.ComponentModel.DataAnnotations;
using MAX.Framework.Commons;
using MAX.Framework.Validation;
using Store.Shops.Dtos;
using Store.Shops.Entities;
using Store.Shops.Mappers;
namespace Store.Shops.Services;
public sealed partial class ShopService
{
/// <summary>查询微信业务下的拼多多店铺,保持与 bxx.shop 数据隔离。</summary>
public async Task<ShopPage<ShopInfo>> PddShops(ShopSearchRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Page(request.Page, request.PageSize);
var actor = await Actor();
var name = actor.UserName;
var member = "," + name + ",";
var query = db.Select<PddLoginShopEntity>().Where(s => s.UserName == name || ("," + s.SubUserName + ",").Contains(member))
.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(!string.IsNullOrEmpty(request.OperateSubSaName), s => s.OperateSubSaName == request.OperateSubSaName)
.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 count = await query.CountAsync(ct);
var rows = await query.OrderByDescending(s => s.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
return new(rows.Select(new ShopDetailsMapper().Map).ToList(), count);
}
/// <summary>导入拼多多店铺,重复导入更新资料;已有Cookie只能由专用登录更新接口修改。</summary>
public async Task SavePdd(PddSaveRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
var actor = await Actor();
var name = actor.UserName;
await ValidateAssignments(request.SubUserName, name);
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
var rows = await orm.Select<PddLoginShopEntity>().Where(s => s.UserName == name && s.LoginName == request.LoginName)
.WhereIf(!string.IsNullOrEmpty(request.ShopId), s => s.ShopId == request.ShopId).ForUpdate().ToListAsync(ct);
if (rows.Count == 0)
{
await orm.Insert(new PddLoginShopEntity
{
UserName = name, LoginName = request.LoginName, LoginPass = request.LoginPass ?? "",
Cookie = request.Cookie ?? "", Cookie2 = request.Cookie2 ?? "", AntiContents = request.AntiContents ?? "",
ShopId = request.ShopId ?? "", MallId = request.MallId ?? "", ShopAccount = request.ShopAccount ?? "",
SubUserName = request.SubUserName ?? "", GroupName = request.GroupName ?? "", Notes = request.Notes ?? "",
LoginStatus = request.LoginStatus ?? "", AddTime = DateTime.Now, MdyTime = DateTime.Now
}).ExecuteAffrowsAsync(ct);
}
else
{
var ids = rows.Select(s => s.Id).ToArray();
var update = orm.Update<PddLoginShopEntity>().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.AntiContents != null) update.Set(s => s.AntiContents, request.AntiContents);
if (request.NewShopId != null) update.Set(s => s.ShopId, request.NewShopId);
if (request.MallId != null) update.Set(s => s.MallId, request.MallId);
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、状态及拼多多用户ID。</summary>
public async Task UpdatePddCookies(CookieRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
if (request.Cookie == null && request.Cookie2 == null && request.LoginStatus == null && request.LoginStatus2 == null && request.PddId == null)
throw new ValidationException("至少提供一个登录状态字段");
var actor = await Actor();
var name = actor.UserName;
var member = "," + name + ",";
var update = db.Update<PddLoginShopEntity>().Where(s => s.LoginName == request.LoginName
&& (s.UserName == name || ("," + s.SubUserName + ",").Contains(member))).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.PddId != null) update.Set(s => s.PddId, request.PddId);
if (await update.ExecuteAffrowsAsync(ct) == 0 && !await db.Select<PddLoginShopEntity>()
.Where(s => s.LoginName == request.LoginName && (s.UserName == name || ("," + s.SubUserName + ",").Contains(member))).AnyAsync(ct))
throw new AppException("店铺不存在或无操作权限");
}
/// <summary>删除当前账号自己创建的拼多多店铺,分配账号不能删除。</summary>
public async Task DeletePdd(LoginRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
var actor = await Actor();
var count = await db.Delete<PddLoginShopEntity>().Where(s => s.UserName == actor.UserName && s.LoginName == request.LoginName)
.WhereIf(!string.IsNullOrEmpty(request.ShopId), s => s.ShopId == request.ShopId).ExecuteAffrowsAsync(ct);
if (count == 0) throw new AppException("店铺不存在或无删除权限");
}
}
@@ -0,0 +1,56 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using MAX.Framework.Commons;
using MAX.Framework.Validation;
using Store.Shops.Dtos;
using Store.Shops.Entities;
namespace Store.Shops.Services;
public sealed partial class ShopService
{
/// <summary>获取当前用户授权店铺的微信令牌,外部请求不在数据库事务中执行。</summary>
public async Task<JsonElement> ShopToken(ShopIdRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
var actor = await Actor();
await RequireShop(db, actor, request.ShopId, ct);
return await tokens.GetShopToken(request.ShopId, ct);
}
/// <summary>管理员读取共享令牌列表;缓存有效期60分钟,配置按缓存ID指定e57账号。</summary>
public async Task<TokensInfo> TokenList(TokenRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
if (request.DbId <= 0) throw new ValidationException("缓存记录ID必须大于0");
RequireAdmin(await Actor());
var cached = await db.Select<TokenEntity>().Where(t => t.Id == request.DbId).FirstAsync(ct);
if (cached != null && cached.MdyTime.AddMinutes(60) > DateTime.Now)
{
try
{
using var json = JsonDocument.Parse(cached.Tokens);
if (json.RootElement.ValueKind == JsonValueKind.Array) return new(json.RootElement.Clone(), cached.MdyTime);
}
catch (JsonException) { /* 旧缓存损坏时重新向上游获取,不返回无法解析的数据。 */ }
}
var account = configuration[$"Shops:E57WxAccounts:{request.DbId}"];
if (string.IsNullOrWhiteSpace(account)) throw new AppException("尚未配置该缓存分组的e57微信账号");
var list = await tokens.GetTokens(account, ct);
if (list.ValueKind != JsonValueKind.Array) throw new AppException("e57返回的令牌列表格式无效");
var now = DateTime.Now;
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
var existing = await orm.Select<TokenEntity>().Where(t => t.Id == request.DbId).ForUpdate().FirstAsync(ct);
if (existing == null)
await orm.Insert(new TokenEntity { Id = request.DbId, Tokens = list.GetRawText(), MdyTime = now }).ExecuteAffrowsAsync(ct);
else
await orm.Update<TokenEntity>().Where(t => t.Id == request.DbId)
.Set(t => t.Tokens, list.GetRawText()).Set(t => t.MdyTime, now).ExecuteAffrowsAsync(ct);
ct.ThrowIfCancellationRequested();
work.Commit();
return new(list, now);
}
}
@@ -0,0 +1,146 @@
using System.ComponentModel.DataAnnotations;
using MAX.Framework.Commons;
using MAX.Framework.Validation;
using Store.Shops.Dtos;
using Store.Shops.Entities;
using Store.Shops.Mappers;
namespace Store.Shops.Services;
public sealed partial class ShopService
{
/// <summary>查询本人运单;跨账号按订单查询仅供管理员使用且必须指定订单。</summary>
public async Task<ShopPage<WaybillInfo>> Waybills(WaybillSearchRequest request, bool allUsers, CancellationToken ct)
{
RequestValidator.Validate(request);
Page(request.Page, request.PageSize);
if (request.Status?.Any(s => s is < 0 or > 2) == true) throw new ValidationException("运单状态只能为0、1、2");
var actor = await Actor();
if (allUsers)
{
RequireAdmin(actor);
if (request.OrderIds == null) throw new ValidationException("订单编号不能为空");
Ids(request.OrderIds);
}
var query = db.Select<WaybillEntity>().WhereIf(!allUsers, w => w.UserName == actor.UserName)
.WhereIf(request.Status != null, w => request.Status!.Contains(w.Status))
.WhereIf(request.OrderIds != null, w => request.OrderIds!.Contains(w.OrderId));
var count = request.NotCount ? -1 : await query.CountAsync(ct);
var rows = await query.OrderByDescending(w => w.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
return new(rows.Select(new ShopDetailsMapper().Map).ToList(), count);
}
/// <summary>导入运单;揽收时间或绑定订单至少提供一个,运单号全库去重。</summary>
public async Task AddWaybill(WaybillAddRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
if (request.WaybillPickupTime == null && string.IsNullOrWhiteSpace(request.BindOrderId))
throw new ValidationException("揽收时间和绑定订单不能同时为空");
var actor = await Actor();
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
if (await orm.Select<WaybillEntity>().Where(w => w.WaybillId == request.WaybillId).ForUpdate().AnyAsync(ct))
throw new AppException("运单号已存在");
await orm.Insert(new WaybillEntity
{
UserName = actor.UserName, DeliveryId = request.DeliveryId, DeliveryName = request.DeliveryName,
WaybillId = request.WaybillId, BindOrderId = request.BindOrderId ?? "", WaybillPickupTime = request.WaybillPickupTime,
Status = 0, AddTime = DateTime.Now
}).ExecuteAffrowsAsync(ct);
ct.ThrowIfCancellationRequested();
work.Commit();
}
/// <summary>只删除本人尚未使用的运单,已预占或使用的运单保持不变。</summary>
public async Task DeleteWaybills(WaybillDeleteRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
Ids(request.WaybillIds);
var actor = await Actor();
await db.Delete<WaybillEntity>().Where(w => w.UserName == actor.UserName && w.Status == 0
&& request.WaybillIds.Contains(w.WaybillId)).ExecuteAffrowsAsync(ct);
}
/// <summary>预占或直接使用运单;先复用同订单记录,再按绑定订单及次日揽收条件选择。</summary>
public async Task<WaybillInfo> SelectWaybill(WaybillSelectRequest request, bool useImmediately, CancellationToken ct)
{
RequestValidator.Validate(request);
if (request.OrderTime?.Date >= DateTime.MaxValue.Date.AddDays(-2))
throw new ValidationException("订单时间超出可匹配范围");
var actor = await Actor();
using var work = db.CreateUnitOfWork();
work.IsolationLevel = System.Data.IsolationLevel.Serializable;
work.GetOrBeginTransaction();
var orm = work.Orm;
await RequireShop(orm, actor, request.ShopId, ct);
var row = await orm.Select<WaybillEntity>()
.Where(w => w.UserName == actor.UserName && w.OrderId == request.OrderId && (w.Status == 1 || w.Status == 2))
.OrderBy(w => w.Id).ForUpdate().FirstAsync(ct);
if (row != null && !string.IsNullOrEmpty(row.ShopId) && row.ShopId != request.ShopId)
throw new AppException("订单已绑定其他店铺的运单");
if (row == null)
{
row = await orm.Select<WaybillEntity>().Where(w => w.UserName == actor.UserName && w.Status == 0
&& w.BindOrderId == request.OrderId && (w.OrderId == "" || w.OrderId == null))
.OrderBy("RAND()").ForUpdate().FirstAsync(ct);
if (row == null && (request.OrderTime.HasValue || useImmediately))
{
var available = orm.Select<WaybillEntity>().Where(w => w.UserName == actor.UserName && w.Status == 0
&& (w.OrderId == "" || w.OrderId == null) && (w.BindOrderId == "" || w.BindOrderId == null));
if (!useImmediately)
{
var start = request.OrderTime!.Value.Date.AddDays(1);
var end = start.AddDays(1);
available.Where(w => w.WaybillPickupTime >= start && w.WaybillPickupTime < end);
}
row = await available.OrderBy("RAND()").ForUpdate().FirstAsync(ct);
}
if (row == null) throw new AppException("没有可用的运单号");
}
if (row.Status != 2)
{
var status = useImmediately ? 2 : 1;
var useTime = useImmediately ? DateTime.Now : (DateTime?)null;
// 行锁与状态条件一起保护分配;请求重试不会重复占用第二张运单。
var affected = await orm.Update<WaybillEntity>().Where(w => w.Id == row.Id && w.Status == row.Status)
.Set(w => w.OrderId, request.OrderId).Set(w => w.ShopId, request.ShopId)
.Set(w => w.Status, status).Set(w => w.UseTime, useTime).ExecuteAffrowsAsync(ct);
if (affected == 0 && row.Status == 0) throw new AppException("运单已被占用,请重试");
row.OrderId = request.OrderId;
row.ShopId = request.ShopId;
row.Status = status;
row.UseTime = useTime;
}
ct.ThrowIfCancellationRequested();
work.Commit();
return new ShopDetailsMapper().Map(row);
}
/// <summary>仅对本人的预占运单确认使用或释放;不能释放已正式使用的运单。</summary>
public async Task UseWaybill(WaybillUseRequest request, CancellationToken ct)
{
RequestValidator.Validate(request);
var actor = await Actor();
using var work = db.CreateUnitOfWork();
work.GetOrBeginTransaction();
var orm = work.Orm;
await RequireShop(orm, actor, request.ShopId, ct);
var row = await orm.Select<WaybillEntity>().Where(w => w.UserName == actor.UserName
&& w.OrderId == request.OrderId && w.WaybillId == request.WaybillId)
.ForUpdate().FirstAsync(ct);
if (row == null || (row.ShopId != "" && row.ShopId != null && row.ShopId != request.ShopId))
throw new AppException("运单不存在或不属于指定店铺订单");
if (!request.IsRollback && row.Status == 2) return;
if (row.Status != 1) throw new AppException("只有预占运单可以确认或释放");
var changed = await orm.Update<WaybillEntity>().Where(w => w.Id == row.Id && w.Status == 1)
.Set(w => w.ShopId, request.IsRollback ? "" : request.ShopId)
.Set(w => w.OrderId, request.IsRollback ? "" : request.OrderId)
.Set(w => w.Status, request.IsRollback ? 0 : 2)
.Set(w => w.UseTime, request.IsRollback ? null : (DateTime?)DateTime.Now).ExecuteAffrowsAsync(ct);
if (changed != 1) throw new AppException("运单状态已变化,请重新查询");
ct.ThrowIfCancellationRequested();
work.Commit();
}
}
+251
View File
@@ -0,0 +1,251 @@
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("店铺不存在或无删除权限");
}
}