增加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,330 @@
using System.Text.Json;
using MAX.Framework.Conventions;
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Store.Shops.Dtos;
using Store.Shops.Services;
namespace Store.Shops.Controllers;
/// <summary>统一店铺接口:微信、拼多多、微店及相关物流业务;使用当前认证、JSON DTO和ApiResult响应。</summary>
[ApiController]
[Authorize]
[DefaultHttpPost]
[Route("v{version:apiVersion}/shop/[action]")]
public sealed class ShopController(ShopService shops, ShopManagementService pddShops) : ControllerBase
{
/// <summary>查询本人或直属主账号分配的微信店铺。</summary>
[EndpointSummary("查询本人或直属主账号分配的微信店铺")]
public Task<ShopPage<ShopDetailsInfo>> GetShops(ShopQueryRequest request, CancellationToken ct)
=> shops.Search(request, ct: ct);
/// <summary>查询授权店铺的ERP商户余额。</summary>
[EndpointSummary("查询授权店铺的ERP商户余额")]
public Task<ShopPage<ErpAccountInfo>> GetErpMerchants(ErpRequest request, CancellationToken ct)
=> shops.ErpMerchants(request, ct);
/// <summary>查询登录名下的授权店铺。</summary>
[EndpointSummary("查询登录名下的授权店铺")]
public Task<IReadOnlyList<ShopDetailsInfo>> GetShopsByLoginName(LoginRequest request, CancellationToken ct)
=> shops.ByLogin(request, ct);
/// <summary>批量读取授权店铺Cookie。</summary>
[EndpointSummary("批量读取授权店铺Cookie")]
public Task<IReadOnlyList<ShopCookieInfo>> GetShopCookies(ShopIdsRequest request, CancellationToken ct)
=> shops.Cookies(request, ct);
/// <summary>主账号新增微信店铺,重复记录拒绝创建。</summary>
[EndpointSummary("主账号新增微信店铺,重复记录拒绝创建")]
public async Task<ApiResult> AddShop(ShopSaveRequest request, CancellationToken ct)
{
await shops.Save(request, true, false, ct);
return ApiResult.Ok();
}
/// <summary>删除本人所属的店铺记录。</summary>
[EndpointSummary("删除本人所属的店铺记录")]
public async Task<ApiResult> DeleteShop(LoginRequest request, CancellationToken ct)
{
await shops.Delete(request, false, ct);
return ApiResult.Ok();
}
/// <summary>部分更新授权微信店铺。</summary>
[EndpointSummary("部分更新授权微信店铺")]
public async Task<ApiResult> UpdateShop(ShopSaveRequest request, CancellationToken ct)
{
await shops.Save(request, false, false, ct);
return ApiResult.Ok();
}
/// <summary>按登录名刷新授权店铺Cookie与状态。</summary>
[EndpointSummary("按登录名刷新授权店铺Cookie与状态")]
public async Task<ApiResult> UpdateShopLogin(CookieRequest request, CancellationToken ct)
{
await shops.UpdateCookies(request, ct);
return ApiResult.Ok();
}
/// <summary>管理员分页查询全部店铺。</summary>
[EndpointSummary("管理员分页查询全部店铺")]
public Task<ShopPage<ShopDetailsInfo>> GetAllShops(ShopQueryRequest request, CancellationToken ct)
=> shops.Search(request, admin: true, ct: ct);
/// <summary>管理员按选品操作员查询店铺并刷新月度额度。</summary>
[EndpointSummary("管理员按选品操作员查询店铺并刷新月度额度")]
public Task<ShopPage<ShopDetailsInfo>> GetSelectionShops(ShopQueryRequest request, CancellationToken ct)
=> shops.Search(request, selection: true, ct: ct);
/// <summary>管理员批量设置店铺上传剩余额度。</summary>
[EndpointSummary("管理员批量设置店铺上传剩余额度")]
public async Task<ApiResult> UpdateShopQuota(QuotaRequest request, CancellationToken ct)
{
await shops.UpdateQuota(request, ct);
return ApiResult.Ok();
}
/// <summary>管理员修改指定账号的店铺。</summary>
[EndpointSummary("管理员修改指定账号的店铺")]
public async Task<ApiResult> AdminUpdateShop(ShopSaveRequest request, CancellationToken ct)
{
await shops.Save(request, false, true, ct);
return ApiResult.Ok();
}
/// <summary>管理员为指定账号新增店铺。</summary>
[EndpointSummary("管理员为指定账号新增店铺")]
public async Task<ApiResult> AdminAddShop(ShopSaveRequest request, CancellationToken ct)
{
await shops.Save(request, true, true, ct);
return ApiResult.Ok();
}
/// <summary>管理员删除指定账号店铺。</summary>
[EndpointSummary("管理员删除指定账号店铺")]
public async Task<ApiResult> AdminDeleteShop(LoginRequest request, CancellationToken ct)
{
await shops.Delete(request, true, ct);
return ApiResult.Ok();
}
/// <summary>管理员批量分配或解除选品操作员。</summary>
[EndpointSummary("管理员批量分配或解除选品操作员")]
public async Task<ApiResult> AssignShopOperator(AssignRequest request, CancellationToken ct)
{
await shops.Assign(request, ct);
return ApiResult.Ok();
}
/// <summary>向当前账号导入未使用运单。</summary>
[EndpointSummary("向当前账号导入未使用运单")]
public async Task<ApiResult> AddWaybill(WaybillAddRequest request, CancellationToken ct)
{
await shops.AddWaybill(request, ct);
return ApiResult.Ok();
}
/// <summary>删除本人尚未预占的运单。</summary>
[EndpointSummary("删除本人尚未预占的运单")]
public async Task<ApiResult> DeleteWaybills(WaybillDeleteRequest request, CancellationToken ct)
{
await shops.DeleteWaybills(request, ct);
return ApiResult.Ok();
}
/// <summary>分页查询本人运单。</summary>
[EndpointSummary("分页查询本人运单")]
public Task<ShopPage<WaybillInfo>> GetWaybills(WaybillSearchRequest request, CancellationToken ct)
=> shops.Waybills(request, false, ct);
/// <summary>管理员按订单跨账号查询运单。</summary>
[EndpointSummary("管理员按订单跨账号查询运单")]
public Task<ShopPage<WaybillInfo>> GetAllWaybills(WaybillSearchRequest request, CancellationToken ct)
=> shops.Waybills(request, true, ct);
/// <summary>在同一事务中选择并确认使用运单。</summary>
[EndpointSummary("在同一事务中选择并确认使用运单")]
public async Task<ApiResult> SelectAndUseWaybill(WaybillSelectRequest request, CancellationToken ct)
{
await shops.SelectWaybill(request, true, ct);
return ApiResult.Ok();
}
/// <summary>为授权订单预占运单,优先匹配绑定订单。</summary>
[EndpointSummary("为授权订单预占运单,优先匹配绑定订单")]
public Task<WaybillInfo> ReserveWaybill(WaybillSelectRequest request, CancellationToken ct)
=> shops.SelectWaybill(request, false, ct);
/// <summary>确认使用或释放本人预占运单。</summary>
[EndpointSummary("确认使用或释放本人预占运单")]
public async Task<ApiResult> UpdateWaybillUsage(WaybillUseRequest request, CancellationToken ct)
{
await shops.UseWaybill(request, ct);
return ApiResult.Ok();
}
/// <summary>保存授权订单物流优化结果。</summary>
[EndpointSummary("保存授权订单物流优化结果")]
public async Task<ApiResult> SaveExpressOptimizeResult(OptimizeResultRequest request, CancellationToken ct)
{
await shops.SaveOptimizeResult(request, ct);
return ApiResult.Ok();
}
/// <summary>查询授权店铺物流优化结果。</summary>
[EndpointSummary("查询授权店铺物流优化结果")]
public Task<ShopPage<OptimizeResultInfo>> GetExpressOptimizeResults(OrderSearchRequest request, CancellationToken ct)
=> shops.OptimizeResults(request, ct);
/// <summary>保存或清空授权订单物流备注。</summary>
[EndpointSummary("保存或清空授权订单物流备注")]
public async Task<ApiResult> SaveExpressOptimizeRemark(OptimizeRemarkRequest request, CancellationToken ct)
{
await shops.SaveOptimizeRemark(request, ct);
return ApiResult.Ok();
}
/// <summary>查询授权店铺物流备注。</summary>
[EndpointSummary("查询授权店铺物流备注")]
public Task<ShopPage<OptimizeRemarkInfo>> GetExpressOptimizeRemarks(OrderSearchRequest request, CancellationToken ct)
=> shops.OptimizeRemarks(request, ct);
/// <summary>维护本人的店铺默认售后地址。</summary>
[EndpointSummary("维护本人的店铺默认售后地址")]
public async Task<ApiResult> SaveShopDefaultAddress(AddressRequest request, CancellationToken ct)
{
await shops.SaveDefaultAddress(request, ct);
return ApiResult.Ok();
}
/// <summary>查询本人的店铺默认地址。</summary>
[EndpointSummary("查询本人的店铺默认地址")]
public Task<ShopPage<DefaultAddressInfo>> GetShopDefaultAddresses(AddressSearchRequest request, CancellationToken ct)
=> shops.DefaultAddresses(request, ct);
/// <summary>批量删除本人的默认地址。</summary>
[EndpointSummary("批量删除本人的默认地址")]
public async Task<ApiResult> DeleteShopDefaultAddresses(ShopIdsRequest request, CancellationToken ct)
{
await shops.DeleteDefaultAddresses(request, ct);
return ApiResult.Ok();
}
/// <summary>保存授权店铺订单解密地址。</summary>
[EndpointSummary("保存授权店铺订单解密地址")]
public async Task<ApiResult> SaveOrderAddress(OrderAddressRequest request, CancellationToken ct)
{
await shops.SaveOrderAddress(request, ct);
return ApiResult.Ok();
}
/// <summary>查询授权店铺订单解密地址。</summary>
[EndpointSummary("查询授权店铺订单解密地址")]
public Task<ShopPage<OrderAddressInfo>> GetOrderAddresses(OrderSearchRequest request, CancellationToken ct)
=> shops.OrderAddresses(request, ct);
/// <summary>维护当前主账号共享采购备注。</summary>
[EndpointSummary("维护当前主账号共享采购备注")]
public async Task<ApiResult> SaveBuyUrlNote(NoteRequest request, CancellationToken ct)
{
await shops.SaveBuyNote(request, ct);
return ApiResult.Ok();
}
/// <summary>查询当前主账号共享采购备注。</summary>
[EndpointSummary("查询当前主账号共享采购备注")]
public Task<ShopPage<BuyUrlNoteInfo>> GetBuyUrlNotes(NoteSearchRequest request, CancellationToken ct)
=> shops.BuyNotes(request, ct);
/// <summary>导入或更新微信业务下的拼多多店铺资料。</summary>
[EndpointSummary("导入或更新微信业务下的拼多多店铺资料")]
public async Task<ApiResult> SavePddShop(PddSaveRequest request, CancellationToken ct)
{
await shops.SavePdd(request, ct);
return ApiResult.Ok();
}
/// <summary>刷新微信侧拼多多店铺Cookie与状态。</summary>
[EndpointSummary("刷新微信侧拼多多店铺Cookie与状态")]
public async Task<ApiResult> UpdatePddShopLogin(CookieRequest request, CancellationToken ct)
{
await shops.UpdatePddCookies(request, ct);
return ApiResult.Ok();
}
/// <summary>删除本人微信侧拼多多店铺。</summary>
[EndpointSummary("删除本人微信侧拼多多店铺")]
public async Task<ApiResult> DeletePddShop(LoginRequest request, CancellationToken ct)
{
await shops.DeletePdd(request, ct);
return ApiResult.Ok();
}
/// <summary>查询授权微信侧拼多多店铺。</summary>
[EndpointSummary("查询授权微信侧拼多多店铺")]
public Task<ShopPage<ShopInfo>> GetPddShops(ShopSearchRequest request, CancellationToken ct)
=> shops.PddShops(request, ct);
/// <summary>管理员读取共享微信令牌列表缓存。</summary>
[EndpointSummary("管理员读取共享微信令牌列表缓存")]
public Task<TokensInfo> GetTokens(TokenRequest request, CancellationToken ct)
=> shops.TokenList(request, ct);
/// <summary>获取授权微信店铺的e57令牌。</summary>
[EndpointSummary("获取授权微信店铺的e57令牌")]
public Task<JsonElement> GetShopToken(ShopIdRequest request, CancellationToken ct)
=> shops.ShopToken(request, ct);
/// <summary>获取部署配置中的拼多多应用信息。</summary>
[EndpointSummary("获取部署配置中的拼多多应用信息")]
public Task<IReadOnlyList<PddClientInfo>> GetPddClients() => pddShops.GetPddClients();
/// <summary>查询本人或分配店铺的拼多多授权资料,读取bxx.shop_api_auth。</summary>
[EndpointSummary("查询本人或分配店铺的拼多多授权资料,读取bxx.shop_api_auth")]
public Task<ShopPage<ShopClientInfo>> GetPddShopClients(ShopClientSearchRequest request, CancellationToken ct)
=> pddShops.GetShopClientInfo(request, ct);
/// <summary>检查bxx.shop归属后获取拼多多商城令牌。</summary>
[EndpointSummary("检查bxx.shop归属后获取拼多多商城令牌")]
public Task<JsonElement> GetPddShopToken(PddTokenRequest request, CancellationToken ct)
=> pddShops.GetPddToken(request, ct);
/// <summary>管理员查询bxx.shop店铺并维护共享月度额度。</summary>
[EndpointSummary("管理员查询bxx.shop店铺并维护共享月度额度")]
public Task<ShopPage<ShopInfo>> GetAllPddShops(ShopSearchRequest request, CancellationToken ct)
=> pddShops.GetAdminShops(request, ct);
/// <summary>新增微店店铺,重复导入时更新资料。</summary>
[EndpointSummary("新增微店店铺,重复导入时更新资料")]
public async Task<ApiResult> AddWeidianShop(WeidianSaveRequest request, CancellationToken ct)
{
await pddShops.SaveWeidian(request, true, ct);
return ApiResult.Ok();
}
/// <summary>部分更新本人或已分配微店店铺。</summary>
[EndpointSummary("部分更新本人或已分配微店店铺")]
public async Task<ApiResult> UpdateWeidianShop(WeidianSaveRequest request, CancellationToken ct)
{
await pddShops.SaveWeidian(request, false, ct);
return ApiResult.Ok();
}
/// <summary>更新授权微店店铺的Cookie与登录状态。</summary>
[EndpointSummary("更新授权微店店铺的Cookie与登录状态")]
public async Task<ApiResult> UpdateWeidianShopLogin(WeidianCookieRequest request, CancellationToken ct)
{
await pddShops.UpdateWeidianCookie(request, ct);
return ApiResult.Ok();
}
/// <summary>删除当前账号拥有的微店店铺。</summary>
[EndpointSummary("删除当前账号拥有的微店店铺")]
public async Task<ApiResult> DeleteWeidianShop(ShopIdRequest request, CancellationToken ct)
{
await pddShops.DeleteWeidian(request, ct);
return ApiResult.Ok();
}
}
@@ -0,0 +1,164 @@
namespace Store.Shops.Dtos;
/// <summary>用户店铺默认售后地址的接口输出。</summary>
public sealed class DefaultAddressInfo
{
/// <summary>记录ID。</summary>
public int Id { get; set; }
/// <summary>软件账号。</summary>
public string UserId { get; set; } = "";
/// <summary>店铺ID。</summary>
public string ShopId { get; set; } = "";
/// <summary>省份。</summary>
public string ProvinceName { get; set; } = "";
/// <summary>城市。</summary>
public string CityName { get; set; } = "";
/// <summary>区县。</summary>
public string CountyName { get; set; } = "";
/// <summary>详细地址。</summary>
public string DetailInfo { get; set; } = "";
/// <summary>收件人电话。</summary>
public string TelNumber { get; set; } = "";
/// <summary>收件人姓名。</summary>
public string Receiver { get; set; } = "";
/// <summary>创建时间。</summary>
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
public DateTime MdyTime { get; set; }
}
/// <summary>订单解密地址的接口输出。</summary>
public sealed class OrderAddressInfo
{
/// <summary>记录ID。</summary>
public int Id { get; set; }
/// <summary>店铺ID。</summary>
public string ShopId { get; set; } = "";
/// <summary>订单ID。</summary>
public string OrderId { get; set; } = "";
/// <summary>解密后的地址内容。</summary>
public string OrderAddress { get; set; } = "";
/// <summary>创建时间。</summary>
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
public DateTime MdyTime { get; set; }
}
/// <summary>主账号共享采购备注的接口输出。</summary>
public sealed class BuyUrlNoteInfo
{
/// <summary>记录ID。</summary>
public int Id { get; set; }
/// <summary>所属主账号。</summary>
public string UserName { get; set; } = "";
/// <summary>商品编码。</summary>
public string SpuCode { get; set; } = "";
/// <summary>采购链接备注。</summary>
public string BuyUrlNotes { get; set; } = "";
/// <summary>创建时间。</summary>
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
public DateTime MdyTime { get; set; }
}
/// <summary>运单库存及分配状态的接口输出。</summary>
public sealed class WaybillInfo
{
/// <summary>记录ID。</summary>
public int Id { get; set; }
/// <summary>运单所属账号。</summary>
public string UserName { get; set; } = "";
/// <summary>物流公司编码。</summary>
public string DeliveryId { get; set; } = "";
/// <summary>物流公司名称。</summary>
public string DeliveryName { get; set; } = "";
/// <summary>运单号。</summary>
public string WaybillId { get; set; } = "";
/// <summary>预绑定订单ID。</summary>
public string BindOrderId { get; set; } = "";
/// <summary>分配订单ID。</summary>
public string OrderId { get; set; } = "";
/// <summary>使用店铺ID。</summary>
public string ShopId { get; set; } = "";
/// <summary>0未使用,1已预占,2已使用。</summary>
public int Status { get; set; }
/// <summary>入库时间。</summary>
public DateTime AddTime { get; set; }
/// <summary>正式使用时间。</summary>
public DateTime? UseTime { get; set; }
/// <summary>揽收时间。</summary>
public DateTime? WaybillPickupTime { get; set; }
}
/// <summary>订单物流优化结果的接口输出。</summary>
public sealed class OptimizeResultInfo
{
/// <summary>记录ID。</summary>
public int Id { get; set; }
/// <summary>店铺ID。</summary>
public string ShopId { get; set; } = "";
/// <summary>订单ID。</summary>
public string OrderId { get; set; } = "";
/// <summary>补发处理结果。</summary>
public string OptimizeRst { get; set; } = "";
/// <summary>运单号。</summary>
public string WaybillId { get; set; } = "";
/// <summary>物流公司名称。</summary>
public string DeliveryName { get; set; } = "";
/// <summary>创建时间。</summary>
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
public DateTime MdyTime { get; set; }
}
/// <summary>订单物流优化备注的接口输出。</summary>
public sealed class OptimizeRemarkInfo
{
/// <summary>记录ID。</summary>
public int Id { get; set; }
/// <summary>店铺ID。</summary>
public string ShopId { get; set; } = "";
/// <summary>订单ID。</summary>
public string OrderId { get; set; } = "";
/// <summary>备注内容,允许清空。</summary>
public string Remark { get; set; } = "";
/// <summary>创建时间。</summary>
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
public DateTime MdyTime { get; set; }
}
/// <summary>ERP商户余额的接口输出。</summary>
public sealed class ErpAccountInfo
{
/// <summary>账户ID。</summary>
public long Id { get; set; }
/// <summary>ERP用户ID。</summary>
public long UserId { get; set; }
/// <summary>账户名称。</summary>
public string AccountName { get; set; } = "";
/// <summary>账户状态。</summary>
public int AccountStatus { get; set; }
/// <summary>账户类型。</summary>
public int AccountType { get; set; }
/// <summary>可用余额。</summary>
public decimal Balance { get; set; }
/// <summary>冻结金额。</summary>
public decimal FrozenAmount { get; set; }
/// <summary>累计充值。</summary>
public decimal RechargeAmountTotal { get; set; }
/// <summary>已提现金额。</summary>
public decimal WithdrawnAmount { get; set; }
/// <summary>提现账户图片。</summary>
public string WithdrawAccountImageUrl { get; set; } = "";
/// <summary>创建人。</summary>
public long? CreateBy { get; set; }
/// <summary>修改人。</summary>
public long? UpdateBy { get; set; }
/// <summary>创建时间。</summary>
public DateTime? CreateTime { get; set; }
/// <summary>修改时间。</summary>
public DateTime? UpdateTime { get; set; }
}
@@ -0,0 +1,284 @@
using System.ComponentModel.DataAnnotations;
namespace Store.Shops.Dtos;
/// <summary>分页与统计选项。</summary>
public class PageRequest
{
/// <summary>页码,从1开始。</summary>
public int Page { get; set; } = 1;
/// <summary>每页数量,上限500。</summary>
public int PageSize { get; set; } = 50;
/// <summary>是否跳过总数统计,跳过时总数为-1。</summary>
public bool NotCount { get; set; }
}
/// <summary>微信店铺查询。</summary>
public class ShopQueryRequest : ShopSearchRequest
{
/// <summary>是否补充ERP店铺和商户编号。</summary>
public bool IsGetErpShopId { get; set; }
/// <summary>兼容旧调用的标志,到期信息始终返回。</summary>
public bool IsShowExpire { get; set; }
}
/// <summary>按登录名定位店铺。</summary>
public class LoginRequest
{
/// <summary>店铺登录名。</summary>
[Required(ErrorMessage = "店铺登录名不能为空")]
public string LoginName { get; set; } = "";
/// <summary>可选原店铺ID。</summary>
public string? ShopId { get; set; }
/// <summary>管理员操作的目标账号,普通调用忽略。</summary>
public string? ShopUserName { get; set; }
}
/// <summary>微信店铺新增或部分更新;null保留,空串清空。</summary>
public class ShopSaveRequest : LoginRequest
{
/// <summary>登录密码。</summary>
public string? LoginPass { get; set; }
/// <summary>登录Cookie。</summary>
public string? Cookie { get; set; }
/// <summary>第二组Cookie。</summary>
public string? Cookie2 { get; set; }
/// <summary>风控参数。</summary>
public string? AntiContents { get; set; }
/// <summary>更新后的店铺ID。</summary>
public string? NewShopId { get; set; }
/// <summary>店铺名称。</summary>
public string? ShopAccount { get; set; }
/// <summary>逗号分隔的直属子账号。</summary>
public string? SubUserName { get; set; }
/// <summary>店铺分组。</summary>
public string? GroupName { get; set; }
/// <summary>备注。</summary>
public string? Notes { get; set; }
/// <summary>登录状态。</summary>
public string? LoginStatus { get; set; }
}
/// <summary>同登录名下授权店铺的登录状态更新。</summary>
public class CookieRequest
{
/// <summary>店铺登录名。</summary>
[Required(ErrorMessage = "店铺登录名不能为空")]
public string LoginName { get; set; } = "";
/// <summary>登录Cookie。</summary>
public string? Cookie { get; set; }
/// <summary>第二组Cookie。</summary>
public string? Cookie2 { get; set; }
/// <summary>登录状态。</summary>
public string? LoginStatus { get; set; }
/// <summary>第二组登录状态。</summary>
public string? LoginStatus2 { get; set; }
/// <summary>微信OpenId。</summary>
public string? Openid { get; set; }
/// <summary>拼多多用户ID。</summary>
public string? PddId { get; set; }
}
/// <summary>店铺ID批量请求。</summary>
public class ShopIdsRequest
{
/// <summary>店铺ID数组。</summary>
[Required(ErrorMessage = "店铺ID数组不能为空")]
public string[] ShopIds { get; set; } = [];
}
/// <summary>设置共享上传额度。</summary>
public class QuotaRequest : ShopIdsRequest
{
/// <summary>剩余额度,不得小于0。</summary>
public int RemainCount { get; set; }
}
/// <summary>批量分配选品操作员。</summary>
public class AssignRequest
{
/// <summary>记录ID数组,优先于登录名。</summary>
public int[]? DbIds { get; set; }
/// <summary>店铺登录名数组。</summary>
public string[]? ShopLoginNames { get; set; }
/// <summary>操作员账号,空串表示解除分配。</summary>
[Required(AllowEmptyStrings = true, ErrorMessage = "操作员账号不能为null,解除分配请传空串")]
public string OperateSubSaName { get; set; } = "";
}
/// <summary>查询授权店铺相关ERP商户。</summary>
public class ErpRequest : PageRequest
{
/// <summary>可选店铺ID。</summary>
public string? ShopId { get; set; }
/// <summary>公司账号ID。</summary>
public long? CorporateAccountUserId { get; set; }
/// <summary>管理账号ID。</summary>
public long? ManageUserId { get; set; }
}
/// <summary>维护当前用户的店铺默认地址。</summary>
public class AddressRequest : ShopIdRequest
{
/// <summary>省份。</summary>
[Required(ErrorMessage = "省份不能为空")]
public string ProvinceName { get; set; } = "";
/// <summary>城市。</summary>
[Required(ErrorMessage = "城市不能为空")]
public string CityName { get; set; } = "";
/// <summary>区县。</summary>
[Required(ErrorMessage = "区县不能为空")]
public string CountyName { get; set; } = "";
/// <summary>详细地址。</summary>
[Required(ErrorMessage = "详细地址不能为空")]
public string DetailInfo { get; set; } = "";
/// <summary>收件人电话。</summary>
[Required(ErrorMessage = "收件人电话不能为空")]
public string TelNumber { get; set; } = "";
/// <summary>收件人姓名。</summary>
[Required(ErrorMessage = "收件人姓名不能为空")]
public string Receiver { get; set; } = "";
}
/// <summary>查询本人默认地址。</summary>
public class AddressSearchRequest : PageRequest
{
/// <summary>可选店铺ID数组。</summary>
public string[]? ShopIds { get; set; }
}
/// <summary>保存订单解密地址。</summary>
public class OrderAddressRequest : ShopIdRequest
{
/// <summary>订单ID。</summary>
[Required(ErrorMessage = "订单ID不能为空")]
public string OrderId { get; set; } = "";
/// <summary>解密地址内容。</summary>
[Required(ErrorMessage = "解密地址内容不能为空")]
public string OrderAddress { get; set; } = "";
}
/// <summary>按订单查询授权店铺的数据。</summary>
public class OrderSearchRequest : PageRequest
{
/// <summary>订单ID数组,地址查询必填。</summary>
public string[]? OrderIds { get; set; }
}
/// <summary>维护主账号共享采购备注。</summary>
public class NoteRequest
{
/// <summary>商品编码。</summary>
[Required(ErrorMessage = "商品编码不能为空")]
public string SpuCode { get; set; } = "";
/// <summary>采购备注。</summary>
[Required(ErrorMessage = "采购备注不能为空")]
public string BuyUrlNotes { get; set; } = "";
/// <summary>可选目标主账号,必须匹配当前主账号。</summary>
public string? MainUserName { get; set; }
}
/// <summary>查询主账号采购备注。</summary>
public class NoteSearchRequest : PageRequest
{
/// <summary>商品编码数组。</summary>
[Required(ErrorMessage = "商品编码数组不能为空")]
public string[] SpuCodes { get; set; } = [];
/// <summary>可选目标主账号。</summary>
public string? MainUserName { get; set; }
}
/// <summary>微信侧拼多多店铺资料。</summary>
public class PddSaveRequest : ShopSaveRequest
{
/// <summary>拼多多商城ID。</summary>
public string? MallId { get; set; }
}
/// <summary>查询运单库存。</summary>
public class WaybillSearchRequest : OrderSearchRequest
{
/// <summary>状态数组:0未使用、1预占、2已使用。</summary>
public int[]? Status { get; set; }
}
/// <summary>新增本人运单库存。</summary>
public class WaybillAddRequest
{
/// <summary>物流公司编码。</summary>
[Required(ErrorMessage = "物流公司编码不能为空")]
public string DeliveryId { get; set; } = "";
/// <summary>物流公司名称。</summary>
[Required(ErrorMessage = "物流公司名称不能为空")]
public string DeliveryName { get; set; } = "";
/// <summary>运单号。</summary>
[Required(ErrorMessage = "运单号不能为空")]
public string WaybillId { get; set; } = "";
/// <summary>预绑定订单ID。</summary>
public string? BindOrderId { get; set; }
/// <summary>揽收时间,与绑定订单至少填写一个。</summary>
public DateTime? WaybillPickupTime { get; set; }
}
/// <summary>删除未使用运单。</summary>
public class WaybillDeleteRequest
{
/// <summary>运单号数组。</summary>
[Required(ErrorMessage = "运单号数组不能为空")]
public string[] WaybillIds { get; set; } = [];
}
/// <summary>为授权店铺订单分配运单。</summary>
public class WaybillSelectRequest : ShopIdRequest
{
/// <summary>订单ID。</summary>
[Required(ErrorMessage = "订单ID不能为空")]
public string OrderId { get; set; } = "";
/// <summary>订单时间,按次日揽收范围选择。</summary>
public DateTime? OrderTime { get; set; }
}
/// <summary>确认使用或释放预占运单。</summary>
public class WaybillUseRequest : WaybillSelectRequest
{
/// <summary>运单号。</summary>
[Required(ErrorMessage = "运单号不能为空")]
public string WaybillId { get; set; } = "";
/// <summary>为true时释放,仅允许预占状态释放。</summary>
public bool IsRollback { get; set; }
}
/// <summary>保存授权订单的物流优化结果。</summary>
public class OptimizeResultRequest : ShopIdRequest
{
/// <summary>订单ID。</summary>
[Required(ErrorMessage = "订单ID不能为空")]
public string OrderId { get; set; } = "";
/// <summary>补发结果。</summary>
[Required(ErrorMessage = "补发结果不能为空")]
public string OptimizeRst { get; set; } = "";
/// <summary>运单号,null保留。</summary>
public string? WaybillId { get; set; }
/// <summary>物流公司名称,null保留。</summary>
public string? DeliveryName { get; set; }
}
/// <summary>保存订单物流备注。</summary>
public class OptimizeRemarkRequest : ShopIdRequest
{
/// <summary>订单ID。</summary>
[Required(ErrorMessage = "订单ID不能为空")]
public string OrderId { get; set; } = "";
/// <summary>备注内容,空串可清空,null不允许。</summary>
[Required(AllowEmptyStrings = true, ErrorMessage = "备注不能为null,清空请传空串")]
public string Remark { get; set; } = null!;
}
/// <summary>读取共享微信令牌缓存,仅管理员可调用。</summary>
public class TokenRequest
{
/// <summary>缓存分组ID,默认1。</summary>
public int DbId { get; set; } = 1;
}
@@ -0,0 +1,67 @@
using System.Text.Json;
namespace Store.Shops.Dtos;
/// <summary>微信店铺业务资料;列表不输出密码与Cookie。</summary>
public sealed class ShopDetailsInfo
{
/// <summary>记录ID。</summary>
public int Id { get; set; }
/// <summary>店铺登录名。</summary>
public string LoginName { get; set; } = "";
/// <summary>店铺ID。</summary>
public string ShopId { get; set; } = "";
/// <summary>店铺名称。</summary>
public string ShopAccount { get; set; } = "";
/// <summary>所属账号。</summary>
public string UserName { get; set; } = "";
/// <summary>分配子账号。</summary>
public string SubUserName { get; set; } = "";
/// <summary>店铺分组。</summary>
public string GroupName { get; set; } = "";
/// <summary>公司名称。</summary>
public string CompanyName { get; set; } = "";
/// <summary>备注。</summary>
public string Notes { get; set; } = "";
/// <summary>登录状态。</summary>
public string LoginStatus { get; set; } = "";
/// <summary>第二组登录状态。</summary>
public string LoginStatus2 { get; set; } = "";
/// <summary>选品操作员。</summary>
public string OperateSubSaName { get; set; } = "";
/// <summary>创建时间。</summary>
public DateTime? AddTime { get; set; }
/// <summary>修改时间。</summary>
public DateTime? MdyTime { get; set; }
/// <summary>操作员分配时间。</summary>
public DateTime? AddSubSaTime { get; set; }
/// <summary>微信OpenId。</summary>
public string Openid { get; set; } = "";
/// <summary>永久有效标志。</summary>
public bool NeverExpires { get; set; }
/// <summary>剩余上传额度。</summary>
public int AddProductRemaining { get; set; }
/// <summary>额度所属月份。</summary>
public byte AddProductMonth { get; set; }
/// <summary>到期Unix秒数。</summary>
public int ExpireTime { get; set; }
/// <summary>充值用户店铺是否已过期。</summary>
public bool IsPointShopExpire { get; set; }
/// <summary>ERP店铺ID。</summary>
public long? ErpShopId { get; set; }
/// <summary>ERP公司账号ID。</summary>
public long? ErpCorporateAccountUserId { get; set; }
/// <summary>ERP管理账号ID。</summary>
public long? ErpManageUserId { get; set; }
}
/// <summary>专用Cookie接口响应,仅返回已授权店铺的登录凭据。</summary>
/// <param name="ShopId">微信店铺AppId。</param>
/// <param name="Cookies">指定店铺的Cookie内容。</param>
public sealed record ShopCookieInfo(string ShopId, string Cookies);
/// <summary>共享令牌列表和实际缓存刷新时间,仅管理员可读取。</summary>
/// <param name="Tokens">上游返回的令牌列表JSON数组。</param>
/// <param name="MdyTime">数据库缓存的最近刷新时间。</param>
public sealed record TokensInfo(JsonElement Tokens, DateTime MdyTime);
+63
View File
@@ -0,0 +1,63 @@
using System.ComponentModel.DataAnnotations;
namespace Store.Shops.Dtos;
public class ShopSearchRequest
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 50;
public string? SearchId { get; set; }
public string? SearchUserName { get; set; }
public string? SearchLoginName { get; set; }
public string? SearchNotes { get; set; }
public string? SearchName { get; set; }
public string? OperateSubSaName { get; set; }
public string[]? ShopIds { get; set; }
public string[]? ShopNames { get; set; }
public string[]? ShopNotes { get; set; }
}
public sealed class ShopClientSearchRequest
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 500;
public string[]? ShopIds { get; set; }
}
public class ShopIdRequest
{
[Required(ErrorMessage = "店铺ID不能为空")]
public string ShopId { get; set; } = "";
}
public sealed class PddTokenRequest
{
[Required(ErrorMessage = "拼多多商城ID不能为空")]
public string MallId { get; set; } = "";
}
/// <summary>更新时 null 保留原值,空串清空;归属账号只从当前身份获取。</summary>
public sealed class WeidianSaveRequest
{
[Required(ErrorMessage = "店铺登录名不能为空")]
public string LoginName { get; set; } = "";
public string? ShopId { get; set; }
public string? NewShopId { get; set; }
public string? LoginPass { get; set; }
public string? Cookie { get; set; }
public string? Cookie2 { get; set; }
public string? WdToken { get; set; }
public string? MallId { get; set; }
public string? ShopAccount { get; set; }
public string? SubUserName { get; set; }
public string? GroupName { get; set; }
public string? Notes { get; set; }
public string? LoginStatus { get; set; }
}
public sealed class WeidianCookieRequest : ShopIdRequest
{
public string? Cookie { get; set; }
public string? WdToken { get; set; }
public string? LoginStatus { get; set; }
}
+47
View File
@@ -0,0 +1,47 @@
namespace Store.Shops.Dtos;
public sealed record ShopPage<T>(IReadOnlyList<T> Items, long Total);
/// <summary>列表只返回业务资料,登录密码、Cookie 和令牌不进入列表响应。</summary>
public sealed class ShopInfo
{
public int Id { get; set; }
public string LoginName { get; set; } = "";
public string ShopId { get; set; } = "";
public string MallId { get; set; } = "";
public string PddId { get; set; } = "";
public string ShopAccount { get; set; } = "";
public string UserName { get; set; } = "";
public string SubUserName { get; set; } = "";
public string CompanyName { get; set; } = "";
public string Notes { get; set; } = "";
public string LoginStatus { get; set; } = "";
public string LoginStatus2 { get; set; } = "";
public string OperateSubSaName { get; set; } = "";
public int AddProductRemaining { get; set; }
public int AddProductMonth { get; set; }
}
/// <summary>专用授权接口响应,仅允许读取本人或明确分配店铺的凭据。</summary>
public sealed class ShopClientInfo
{
public string ShopId { get; set; } = "";
public string AppKey { get; set; } = "";
public string AppSecret { get; set; } = "";
public string AccessToken { get; set; } = "";
public string RefreshToken { get; set; } = "";
public string ApiAuthStatus { get; set; } = "";
public DateTime? ExpiresTime { get; set; }
public DateTime? UpdateTime { get; set; }
}
/// <summary>供授权客户端使用的拼多多应用配置,通过部署配置注入。</summary>
public sealed class PddClientInfo
{
public string Describe { get; set; } = "";
public string ClientId { get; set; } = "";
public string ClientSecret { get; set; } = "";
public int MarketId { get; set; }
public string RedirectUri { get; set; } = "";
public bool IsCurrent { get; set; }
}
@@ -0,0 +1,33 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>主账号共享采购备注;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "bxx.product_buy_url_notes")]
public sealed class BuyUrlNoteEntity
{
/// <summary>记录ID。</summary>
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>所属主账号。</summary>
[Column(Name = "user_name")]
public string UserName { get; set; } = "";
/// <summary>商品编码。</summary>
[Column(Name = "spu_code")]
public string SpuCode { get; set; } = "";
/// <summary>采购链接备注。</summary>
[Column(Name = "buy_url_notes")]
public string BuyUrlNotes { get; set; } = "";
/// <summary>创建时间。</summary>
[Column(Name = "add_time")]
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
[Column(Name = "mdy_time")]
public DateTime MdyTime { get; set; }
}
@@ -0,0 +1,53 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>用户店铺默认售后地址;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "wxxd.wx_shop_default_address")]
public sealed class DefaultAddressEntity
{
/// <summary>记录ID。</summary>
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>软件账号。</summary>
[Column(Name = "user_id")]
public string UserId { get; set; } = "";
/// <summary>店铺ID。</summary>
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
/// <summary>省份。</summary>
[Column(Name = "province_name")]
public string ProvinceName { get; set; } = "";
/// <summary>城市。</summary>
[Column(Name = "city_name")]
public string CityName { get; set; } = "";
/// <summary>区县。</summary>
[Column(Name = "county_name")]
public string CountyName { get; set; } = "";
/// <summary>详细地址。</summary>
[Column(Name = "detail_info")]
public string DetailInfo { get; set; } = "";
/// <summary>收件人电话。</summary>
[Column(Name = "tel_number")]
public string TelNumber { get; set; } = "";
/// <summary>收件人姓名。</summary>
[Column(Name = "user_name")]
public string Receiver { get; set; } = "";
/// <summary>创建时间。</summary>
[Column(Name = "add_time")]
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
[Column(Name = "mdy_time")]
public DateTime MdyTime { get; set; }
}
@@ -0,0 +1,65 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>ERP商户余额;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "erp.o_user_account")]
public sealed class ErpAccountEntity
{
/// <summary>账户ID。</summary>
[Column(Name = "id", IsPrimary = true)]
public long Id { get; set; }
/// <summary>ERP用户ID。</summary>
[Column(Name = "user_id")]
public long UserId { get; set; }
/// <summary>账户名称。</summary>
[Column(Name = "account_name")]
public string AccountName { get; set; } = "";
/// <summary>账户状态。</summary>
[Column(Name = "account_status")]
public int AccountStatus { get; set; }
/// <summary>账户类型。</summary>
[Column(Name = "account_type")]
public int AccountType { get; set; }
/// <summary>可用余额。</summary>
[Column(Name = "balance")]
public decimal Balance { get; set; }
/// <summary>冻结金额。</summary>
[Column(Name = "frozen_amount")]
public decimal FrozenAmount { get; set; }
/// <summary>累计充值。</summary>
[Column(Name = "recharge_amount_total")]
public decimal RechargeAmountTotal { get; set; }
/// <summary>已提现金额。</summary>
[Column(Name = "withdrawn_amount")]
public decimal WithdrawnAmount { get; set; }
/// <summary>提现账户图片。</summary>
[Column(Name = "withdraw_account_image_url")]
public string WithdrawAccountImageUrl { get; set; } = "";
/// <summary>创建人。</summary>
[Column(Name = "create_by")]
public long? CreateBy { get; set; }
/// <summary>修改人。</summary>
[Column(Name = "update_by")]
public long? UpdateBy { get; set; }
/// <summary>创建时间。</summary>
[Column(Name = "create_time")]
public DateTime? CreateTime { get; set; }
/// <summary>修改时间。</summary>
[Column(Name = "update_time")]
public DateTime? UpdateTime { get; set; }
}
@@ -0,0 +1,17 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>ERP公司与管理账号关联;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "erp.o_shop_group")]
public sealed class ErpGroupEntity
{
/// <summary>公司账号ID。</summary>
[Column(Name = "corporate_account_user_id")]
public long CorporateAccountUserId { get; set; }
/// <summary>管理账号ID。</summary>
[Column(Name = "manage_user_id")]
public long ManageUserId { get; set; }
}
@@ -0,0 +1,25 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>微信店铺与ERP账号关联;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "erp.o_shop")]
public sealed class ErpShopEntity
{
/// <summary>ERP店铺ID。</summary>
[Column(Name = "id", IsPrimary = true)]
public long Id { get; set; }
/// <summary>微信店铺AppId。</summary>
[Column(Name = "app_key")]
public string AppKey { get; set; } = "";
/// <summary>公司账号ID。</summary>
[Column(Name = "corporate_account_user_id")]
public long? CorporateAccountUserId { get; set; }
/// <summary>管理账号ID。</summary>
[Column(Name = "manage_user_id")]
public long? ManageUserId { get; set; }
}
@@ -0,0 +1,33 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>订单物流优化备注;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "wxxd.express_optimize_remark")]
public sealed class OptimizeRemarkEntity
{
/// <summary>记录ID。</summary>
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>店铺ID。</summary>
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
/// <summary>订单ID。</summary>
[Column(Name = "order_id")]
public string OrderId { get; set; } = "";
/// <summary>备注内容,允许清空。</summary>
[Column(Name = "remark")]
public string Remark { get; set; } = "";
/// <summary>创建时间。</summary>
[Column(Name = "add_time")]
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
[Column(Name = "mdy_time")]
public DateTime MdyTime { get; set; }
}
@@ -0,0 +1,41 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>订单物流优化结果;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "wxxd.express_optimize_rst")]
public sealed class OptimizeResultEntity
{
/// <summary>记录ID。</summary>
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>店铺ID。</summary>
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
/// <summary>订单ID。</summary>
[Column(Name = "order_id")]
public string OrderId { get; set; } = "";
/// <summary>补发处理结果。</summary>
[Column(Name = "optimize_rst")]
public string OptimizeRst { get; set; } = "";
/// <summary>运单号。</summary>
[Column(Name = "waybill_id")]
public string WaybillId { get; set; } = "";
/// <summary>物流公司名称。</summary>
[Column(Name = "delivery_name")]
public string DeliveryName { get; set; } = "";
/// <summary>创建时间。</summary>
[Column(Name = "add_time")]
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
[Column(Name = "mdy_time")]
public DateTime MdyTime { get; set; }
}
@@ -0,0 +1,33 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>订单解密地址;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "wxxd.order_decrypt_address")]
public sealed class OrderAddressEntity
{
/// <summary>记录ID。</summary>
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>店铺ID。</summary>
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
/// <summary>订单ID。</summary>
[Column(Name = "order_id")]
public string OrderId { get; set; } = "";
/// <summary>解密后的地址内容。</summary>
[Column(Name = "order_address")]
public string OrderAddress { get; set; } = "";
/// <summary>创建时间。</summary>
[Column(Name = "add_time")]
public DateTime AddTime { get; set; }
/// <summary>修改时间。</summary>
[Column(Name = "mdy_time")]
public DateTime MdyTime { get; set; }
}
@@ -0,0 +1,73 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>微信业务使用的拼多多店铺;与 bxx.shop 分库存储。</summary>
[Table(Name = "wxxd.pdd_shop")]
public sealed class PddLoginShopEntity
{
/// <summary>记录ID。</summary>
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>店铺登录名。</summary>
[Column(Name = "login_name")]
public string LoginName { get; set; } = "";
/// <summary>登录密码。</summary>
[Column(Name = "login_pass")]
public string LoginPass { get; set; } = "";
/// <summary>登录Cookie。</summary>
[Column(Name = "cookie")]
public string Cookie { get; set; } = "";
/// <summary>第二组Cookie。</summary>
[Column(Name = "cookie2")]
public string Cookie2 { get; set; } = "";
/// <summary>风控参数。</summary>
[Column(Name = "anti_contents")]
public string AntiContents { get; set; } = "";
/// <summary>拼多多用户ID。</summary>
[Column(Name = "pddid")]
public string PddId { get; set; } = "";
/// <summary>商城ID。</summary>
[Column(Name = "mall_id")]
public string MallId { get; set; } = "";
/// <summary>店铺ID。</summary>
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
/// <summary>店铺名称。</summary>
[Column(Name = "shop_account")]
public string ShopAccount { get; set; } = "";
/// <summary>所属账号。</summary>
[Column(Name = "user_name")]
public string UserName { get; set; } = "";
/// <summary>分配子账号。</summary>
[Column(Name = "sub_user_name")]
public string SubUserName { get; set; } = "";
/// <summary>店铺分组。</summary>
[Column(Name = "group_name")]
public string GroupName { get; set; } = "";
/// <summary>公司名称。</summary>
[Column(Name = "company_name")]
public string CompanyName { get; set; } = "";
/// <summary>备注。</summary>
[Column(Name = "notes")]
public string Notes { get; set; } = "";
/// <summary>登录状态。</summary>
[Column(Name = "login_status")]
public string LoginStatus { get; set; } = "";
/// <summary>第二组登录状态。</summary>
[Column(Name = "login_status2")]
public string LoginStatus2 { get; set; } = "";
/// <summary>选品操作员。</summary>
[Column(Name = "operate_sub_sa_name")]
public string OperateSubSaName { get; set; } = "";
/// <summary>创建时间。</summary>
[Column(Name = "add_time")]
public DateTime? AddTime { get; set; }
/// <summary>修改时间。</summary>
[Column(Name = "mdy_time")]
public DateTime? MdyTime { get; set; }
/// <summary>操作员分配时间。</summary>
[Column(Name = "add_sub_sa_time")]
public DateTime? AddSubSaTime { get; set; }
}
@@ -0,0 +1,56 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>旧库 bxx.shop 映射;不自动同步生产结构。</summary>
[Table(Name = "bxx.shop")]
public sealed class PddShopEntity
{
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
[Column(Name = "login_name")]
public string LoginName { get; set; } = "";
[Column(Name = "login_pass")]
public string LoginPass { get; set; } = "";
[Column(Name = "cookie")]
public string Cookie { get; set; } = "";
[Column(Name = "cookie2")]
public string Cookie2 { get; set; } = "";
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
[Column(Name = "mall_id")]
public string MallId { get; set; } = "";
[Column(Name = "shop_account")]
public string ShopAccount { get; set; } = "";
[Column(Name = "sub_user_name")]
public string SubUserName { get; set; } = "";
[Column(Name = "user_name")]
public string UserName { get; set; } = "";
[Column(Name = "group_name")]
public string GroupName { get; set; } = "";
[Column(Name = "company_name")]
public string CompanyName { get; set; } = "";
[Column(Name = "notes")]
public string Notes { get; set; } = "";
[Column(Name = "login_status")]
public string LoginStatus { get; set; } = "";
[Column(Name = "login_status2")]
public string LoginStatus2 { get; set; } = "";
[Column(Name = "operate_sub_sa_name")]
public string OperateSubSaName { get; set; } = "";
[Column(Name = "add_time")]
public DateTime? AddTime { get; set; }
[Column(Name = "mdy_time")]
public DateTime? MdyTime { get; set; }
[Column(Name = "add_sub_sa_time")]
public DateTime? AddSubSaTime { get; set; }
[Column(Name = "pddid")]
public string PddId { get; set; } = "";
[Column(Name = "anti_contents")]
public string AntiContents { get; set; } = "";
[Column(Name = "add_product_remaining")]
public int AddProductRemaining { get; set; }
[Column(Name = "add_product_month")]
public int AddProductMonth { get; set; }
}
@@ -0,0 +1,17 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>选品店铺与操作员关联;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "wxxd.product_select")]
public sealed class SelectionEntity
{
/// <summary>店铺ID。</summary>
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
/// <summary>选品操作员账号。</summary>
[Column(Name = "operate_sub_sa_name")]
public string OperateSubSaName { get; set; } = "";
}
@@ -0,0 +1,28 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>旧库 bxx.shop_api_auth 映射;不自动同步生产结构。</summary>
[Table(Name = "bxx.shop_api_auth")]
public sealed class ShopApiAuthEntity
{
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
[Column(Name = "ShopId")]
public string ShopId { get; set; } = "";
[Column(Name = "AppKey")]
public string AppKey { get; set; } = "";
[Column(Name = "AppSecret")]
public string AppSecret { get; set; } = "";
[Column(Name = "AccessToken")]
public string AccessToken { get; set; } = "";
[Column(Name = "RefreshToken")]
public string RefreshToken { get; set; } = "";
[Column(Name = "ApiAuthStatus")]
public string ApiAuthStatus { get; set; } = "";
[Column(Name = "ExpiresTime")]
public DateTime? ExpiresTime { get; set; }
[Column(Name = "UpdateTime")]
public DateTime? UpdateTime { get; set; }
}
+4 -1
View File
@@ -9,6 +9,9 @@ namespace YourNamespace
[Table(Name = "wxxd.shop")]
public class ShopEntity
{
/// <summary>店铺是否永久有效;覆盖积分到期判断。</summary>
[Column(Name = "never_expires")]
public bool NeverExpires { get; set; }
/// <summary>
/// 表id
/// </summary>
@@ -141,4 +144,4 @@ namespace YourNamespace
[Column(Name = "login_status2")]
public string LoginStatus2 { get; set; }
}
}
}
@@ -0,0 +1,18 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>旧库 bxx.shop_add_prod_count 映射;不自动同步生产结构。</summary>
[Table(Name = "bxx.shop_add_prod_count")]
public sealed class ShopProductQuotaEntity
{
[Column(Name = "shop_id", IsPrimary = true)]
public string ShopId { get; set; } = "";
[Column(Name = "shop_account")]
public string ShopAccount { get; set; } = "";
[Column(Name = "add_product_remaining")]
public int AddProductRemaining { get; set; }
[Column(Name = "add_product_month")]
public int AddProductMonth { get; set; }
}
@@ -0,0 +1,25 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>微信店铺每月共享上传额度;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "wxxd.shop_add_prod_count")]
public sealed class ShopQuotaEntity
{
/// <summary>店铺ID。</summary>
[Column(Name = "shop_id", IsPrimary = true)]
public string ShopId { get; set; } = "";
/// <summary>店铺名称。</summary>
[Column(Name = "shop_account")]
public string ShopAccount { get; set; } = "";
/// <summary>剩余上传数量。</summary>
[Column(Name = "add_product_remaining")]
public int AddProductRemaining { get; set; }
/// <summary>额度所属月份。</summary>
[Column(Name = "add_product_month")]
public int AddProductMonth { get; set; }
}
@@ -0,0 +1,17 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>选品店铺与操作员关联;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "wxxd.third_product_select")]
public sealed class ThirdSelectionEntity
{
/// <summary>店铺ID。</summary>
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
/// <summary>选品操作员账号。</summary>
[Column(Name = "operate_sub_sa_name")]
public string OperateSubSaName { get; set; } = "";
}
@@ -0,0 +1,21 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>e57微信令牌列表缓存;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "wxxd.wx_token")]
public sealed class TokenEntity
{
/// <summary>缓存分组ID。</summary>
[Column(Name = "id", IsPrimary = true)]
public int Id { get; set; }
/// <summary>上游令牌列表JSON。</summary>
[Column(Name = "tokens")]
public string Tokens { get; set; } = "";
/// <summary>最近刷新时间。</summary>
[Column(Name = "mdy_time")]
public DateTime MdyTime { get; set; }
}
@@ -0,0 +1,57 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>运单库存及分配状态;映射旧库,不自动修改数据库结构。</summary>
[Table(Name = "wxxd.express_optimize")]
public sealed class WaybillEntity
{
/// <summary>记录ID。</summary>
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
/// <summary>运单所属账号。</summary>
[Column(Name = "user_name")]
public string UserName { get; set; } = "";
/// <summary>物流公司编码。</summary>
[Column(Name = "delivery_id")]
public string DeliveryId { get; set; } = "";
/// <summary>物流公司名称。</summary>
[Column(Name = "delivery_name")]
public string DeliveryName { get; set; } = "";
/// <summary>运单号。</summary>
[Column(Name = "waybill_id")]
public string WaybillId { get; set; } = "";
/// <summary>预绑定订单ID。</summary>
[Column(Name = "bind_order_id")]
public string BindOrderId { get; set; } = "";
/// <summary>分配订单ID。</summary>
[Column(Name = "order_id")]
public string OrderId { get; set; } = "";
/// <summary>使用店铺ID。</summary>
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
/// <summary>0未使用,1已预占,2已使用。</summary>
[Column(Name = "status")]
public int Status { get; set; }
/// <summary>入库时间。</summary>
[Column(Name = "add_time")]
public DateTime AddTime { get; set; }
/// <summary>正式使用时间。</summary>
[Column(Name = "use_time")]
public DateTime? UseTime { get; set; }
/// <summary>揽收时间。</summary>
[Column(Name = "waybill_pickup_time")]
public DateTime? WaybillPickupTime { get; set; }
}
@@ -0,0 +1,50 @@
using FreeSql.DataAnnotations;
namespace Store.Shops.Entities;
/// <summary>旧库 weidian.shop 映射;不自动同步生产结构。</summary>
[Table(Name = "weidian.shop")]
public sealed class WeidianShopEntity
{
[Column(Name = "id", IsPrimary = true, IsIdentity = true)]
public int Id { get; set; }
[Column(Name = "login_name")]
public string LoginName { get; set; } = "";
[Column(Name = "login_pass")]
public string LoginPass { get; set; } = "";
[Column(Name = "cookie")]
public string Cookie { get; set; } = "";
[Column(Name = "cookie2")]
public string Cookie2 { get; set; } = "";
[Column(Name = "shop_id")]
public string ShopId { get; set; } = "";
[Column(Name = "mall_id")]
public string MallId { get; set; } = "";
[Column(Name = "shop_account")]
public string ShopAccount { get; set; } = "";
[Column(Name = "sub_user_name")]
public string SubUserName { get; set; } = "";
[Column(Name = "user_name")]
public string UserName { get; set; } = "";
[Column(Name = "group_name")]
public string GroupName { get; set; } = "";
[Column(Name = "company_name")]
public string CompanyName { get; set; } = "";
[Column(Name = "notes")]
public string Notes { get; set; } = "";
[Column(Name = "login_status")]
public string LoginStatus { get; set; } = "";
[Column(Name = "login_status2")]
public string LoginStatus2 { get; set; } = "";
[Column(Name = "operate_sub_sa_name")]
public string OperateSubSaName { get; set; } = "";
[Column(Name = "add_time")]
public DateTime? AddTime { get; set; }
[Column(Name = "mdy_time")]
public DateTime? MdyTime { get; set; }
[Column(Name = "add_sub_sa_time")]
public DateTime? AddSubSaTime { get; set; }
[Column(Name = "wd_token")]
public string WdToken { get; set; } = "";
}
@@ -0,0 +1,39 @@
using Riok.Mapperly.Abstractions;
using Store.Shops.Dtos;
using Store.Shops.Entities;
using YourNamespace;
namespace Store.Shops.Mappers;
/// <summary>微信业务输出白名单;实体新增字段不会自动扩展接口响应。</summary>
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)]
internal partial class ShopDetailsMapper
{
/// <summary>映射微信店铺;到期和ERP字段由业务查询补充。</summary>
public partial ShopDetailsInfo Map(ShopEntity value);
/// <summary>映射微信侧拼多多店铺,不输出登录凭据。</summary>
public partial ShopInfo Map(PddLoginShopEntity value);
/// <summary>将用户店铺默认售后地址转换为接口数据。</summary>
public partial DefaultAddressInfo Map(DefaultAddressEntity value);
/// <summary>将订单解密地址转换为接口数据。</summary>
public partial OrderAddressInfo Map(OrderAddressEntity value);
/// <summary>将主账号共享采购备注转换为接口数据。</summary>
public partial BuyUrlNoteInfo Map(BuyUrlNoteEntity value);
/// <summary>将运单库存及分配状态转换为接口数据。</summary>
public partial WaybillInfo Map(WaybillEntity value);
/// <summary>将订单物流优化结果转换为接口数据。</summary>
public partial OptimizeResultInfo Map(OptimizeResultEntity value);
/// <summary>将订单物流优化备注转换为接口数据。</summary>
public partial OptimizeRemarkInfo Map(OptimizeRemarkEntity value);
/// <summary>将ERP商户余额转换为接口数据。</summary>
public partial ErpAccountInfo Map(ErpAccountEntity value);
}
+13
View File
@@ -0,0 +1,13 @@
using Riok.Mapperly.Abstractions;
using Store.Shops.Dtos;
using Store.Shops.Entities;
namespace Store.Shops.Mappers;
/// <summary>输出字段由 DTO 白名单限定,不映射列表凭据。</summary>
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
internal partial class ShopMapper
{
public partial ShopInfo ToInfo(PddShopEntity shop);
public partial ShopClientInfo ToClientInfo(ShopApiAuthEntity auth);
}
+195
View File
@@ -0,0 +1,195 @@
# Store.Shops 迁移说明
统一由 `Controllers/ShopController.cs` 暴露47个业务Action:微信39个、拼多多与微店8个。原 `bxx_shop` 独立控制器及重复的vdian别名已移除,原 `ShopController` 已重命名合并。
旧逻辑来自 `KJD_WebService/BLL/BXX/BXXShopManage.cs`、`BLL/WXXD/WXShopManage.cs` 和 `BLL/WXXD/WXExpressOptimizeManage.cs`。
微信店铺沿用现有实体、续费查询和被 Store.Users 引用的命名空间,补充旧表已有的 `never_expires` 字段。
下文先介绍拼多多/微店接口,微信完整映射和部署要求见末节。
模块内部类型、文件及方法统一不带平台前缀,后续平台通过模块区分。核心服务为 `ShopService`;扩展查询、详情响应和映射分别为 `ShopQueryRequest`、`ShopDetailsInfo`、`ShopDetailsMapper`。`PddLoginShopEntity` 表示导入的拼多多登录记录,`PddShopEntity` 保留 bxx 店铺映射。
原带平台标记的 Action 统一为 `SavePddShop`、`UpdatePddShopLogin`、`DeletePddShop`、`GetPddShops`、`GetTokens`、`GetShopToken`,客户端需同步更新路由。数据库映射、上游协议及已部署的 `Shops:E57WxAccounts` 配置键保持兼容。
## 调用方式
所有接口使用当前宿主认证,路由为 `POST /api/v1/shop/{Action}`,Action采用PascalCase业务命名。
不再注册 `bxx_shop`、`wxxd_shop` 路由或旧下划线Action别名,客户端需按下表更新URL。
合并仅调整HTTP入口:读取bxx库的授权资料、店铺列表和采购备注仍使用原业务服务、权限及表映射;微店业务一并保留。
请求为 JSON DTO,字段使用 camelCase;不再发送旧版 `time/code/password/obj` 参数,也不再接受旧 GET 调用。
这次重写不是旧 ASMX 的无改动替换,客户端需迁移认证、URL、JSON 参数和返回值解析。
集合参数改为 JSON 数组,不再拼接逗号或 SQL 引号。
| 映射中的旧 action | 当前 action | 请求 |
|---|---|---|
| get_pdd_client_info | GetPddClients | 无业务参数 |
| get_shop_client_info | GetPddShopClients | ShopClientSearchRequest |
| e57_get_pdd_shop_token | GetPddShopToken | PddTokenRequest |
| admin_get_shop_list | GetAllPddShops | ShopSearchRequest |
| add_weidian_shop、add_vdian | AddWeidianShop | WeidianSaveRequest |
| mdy_weidian_shop、mdy_vdian | UpdateWeidianShop | WeidianSaveRequest |
| mdy_weidian_shop_cookie、mdy_vdian_cookie | UpdateWeidianShopLogin | WeidianCookieRequest |
| del_weidian_shop | DeleteWeidianShop | ShopIdRequest |
`vdian` 的三个历史别名统一使用上述微店Action,不再单独暴露重复接口。
`GetAllPddShops` 查询bxx.shop;`GetPddShops` 查询wxxd.pdd_shop,两套数据不混用。
业务成功与失败由现有 ApiResult 管道包装,HTTP 200;无数据成功返回 `data: null`。
列表位于 `data.items`,总数位于 `data.total`。
e57 响应仅提取上游成功响应的 `data`,使用本系统的 code,不嵌套上游结果封套。
认证、路由等 Action 外的错误继续使用宿主现有处理机制。
新增或重复导入微店示例:
```json
{
"loginName": "店铺登录名",
"shopId": "店铺ID",
"shopAccount": "店铺名称",
"notes": "备注"
}
```
更新时 `shopId` 定位原店铺,`newShopId` 修改店铺 ID;
可选字段的 null/省略表示保留原值,空串表示清空。
相同账号、登录名及店铺 ID 的重复新增转为更新。未指定店铺 ID 且登录名匹配多店时拒绝操作,防止批量误改。
Cookie 更新须提供 Cookie 或登录状态;不允许仅凭 wdToken 更新。
## 权限与行为
- 管理列表要求当前账户的数据库角色为 SuperAdmin,保留原查询条件、稳定分页和每月 300 条额度重置。
- 凭据查询仅可读取本人或明确分配给本人的店铺;管理员角色本身不扩大凭据范围。
- 微店创建归属当前账号;本人或分配账号可更新,只有所有者能删除、修改分配账号。
- `sub_user_name` 按逗号分隔的完整账号匹配,避免 owner 匹配 owner2。旧库若使用其他分隔格式,需先转换为该格式;账号之间不要带空格。
- 管理列表不返回登录密码、Cookie、令牌。专用应用配置、店铺授权及令牌接口按其用途返回凭据,均禁止 HTTP 缓存。
- 应用配置接口沿用已登录用户获取公共客户端配置的用途;不可把只允许服务端使用的其他应用密钥填入该配置。
- 分配账号不能更改店铺分配关系。不存在或无权访问的资源统一报业务失败。
- 空店铺 ID 的导入记录不创建共享额度;同一 shop_id 的登录记录共享额度。
## 部署
实体分别映射现有 `bxx.shop`、`bxx.shop_api_auth`、`bxx.shop_add_prod_count`、`weidian.shop`。
不自动创建或修改生产表,不迁移数据。使用现有 IFreeSql 连接,部署账号需要访问上述库。
额度和导入操作使用同一个 FreeSql 工作单元及 Serializable 隔离,失败整体回滚。
生产表应为 InnoDB;`shop_add_prod_count.shop_id` 应有唯一约束(旧实现的 ON DUPLICATE KEY UPDATE 已依赖该约束)。
建议确认微店 `(user_name, login_name, shop_id)` 与店铺 ID 查询索引,减少范围锁扫描。
按数据库原字段保留月份额度语义;跨年同月识别需要另行升级字段,未在本次改变。
通过部署配置提供 `Shops:PddClients` 数组;不要把真实 ClientSecret 提交到源码。
可使用环境变量:
```text
Shops__PddClients__0__Describe
Shops__PddClients__0__ClientId
Shops__PddClients__0__ClientSecret
Shops__PddClients__0__MarketId
Shops__PddClients__0__RedirectUri
Shops__PddClients__0__IsCurrent
```
没有完整应用配置时接口返回明确业务错误。旧源码中的硬编码密钥未复制。
e57 使用旧实现的固定 HTTPS 地址,正常验证证书、编码 mallId,失败最多重试一次,每次超时 30 秒;
请求取消直接传递。未发送真实外部请求。
模块服务沿用 ITransientDependency;Startup 注册共享 PddTokenClient。API 通过既有程序集发现加载控制器。
## 验证
```powershell
dotnet test Tests/Store.Shops.Tests/Store.Shops.Tests.csproj
dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj
dotnet build Store.API/Store.API.csproj
dotnet run --project Store.API/Store.API.csproj
```
店铺测试使用独立临时 SQLite 库和模拟 HTTP 上游,覆盖归属/分配权限、精确账号匹配、分页、重复导入、部分更新、Cookie、删除、额度重置与回滚、上游重试,以及真实 MVC 的路由、参数绑定、统一返回和匿名拒绝。
SQLite 不验证 MySQL 范围锁、并发死锁或生产表实际结构;上线前仍需针对真实 MySQL 和 e57 联调。
可继续使用代码审查检查迁移边界和客户端适配。
## 微信业务Action映射
微信39项业务已合入ShopController,使用 `POST /api/v1/shop/{Action}`;下表保留旧名字仅用于客户端迁移对照。
旧 GET/ASMX 参数、签名和密码认证不再使用;客户端使用当前宿主令牌与 JSON DTO,字段 camelCase,批量编号传数组。
无数据操作统一 ApiResult;分页结果为 `data.items/data.total`,支持 NotCount 的请求返回 total=-1 表示未统计。
所有响应禁用缓存。通用列表和登录名查询不输出密码、Cookie;专用 Cookie/令牌接口检查资源范围后返回必要凭据。
| 旧Action | 当前Action | 请求DTO | 业务 |
|---|---|---|---|
| `get_shop_list` | `GetShops` | `ShopQueryRequest` | 查询本人或直属主账号分配的微信店铺 |
| `get_shop_erp_merchant` | `GetErpMerchants` | `ErpRequest` | 查询授权店铺的ERP商户余额 |
| `get_one_login_name_shop` | `GetShopsByLoginName` | `LoginRequest` | 查询登录名下的授权店铺 |
| `get_one_shop_cookie_by_app_id` | `GetShopCookies` | `ShopIdsRequest` | 批量读取授权店铺Cookie |
| `add_shop` | `AddShop` | `ShopSaveRequest` | 主账号新增微信店铺,重复记录拒绝创建 |
| `del_shop` | `DeleteShop` | `LoginRequest` | 删除本人所属的店铺记录 |
| `mdy_shop` | `UpdateShop` | `ShopSaveRequest` | 部分更新授权微信店铺 |
| `mdy_shop_cookie_and_login_status` | `UpdateShopLogin` | `CookieRequest` | 按登录名刷新授权店铺Cookie与状态 |
| `admin_get_shop_list` | `GetAllShops` | `ShopQueryRequest` | 管理员分页查询全部店铺 |
| `sub_sa_get_select_prod_shop` | `GetSelectionShops` | `ShopQueryRequest` | 管理员按选品操作员查询店铺并刷新月度额度 |
| `update_add_prod_remaining` | `UpdateShopQuota` | `QuotaRequest` | 管理员批量设置店铺上传剩余额度 |
| `admin_mdy_shop` | `AdminUpdateShop` | `ShopSaveRequest` | 管理员修改指定账号的店铺 |
| `admin_add_shop` | `AdminAddShop` | `ShopSaveRequest` | 管理员为指定账号新增店铺 |
| `admin_del_shop` | `AdminDeleteShop` | `LoginRequest` | 管理员删除指定账号店铺 |
| `set_shop_sub_sa` | `AssignShopOperator` | `AssignRequest` | 管理员批量分配或解除选品操作员 |
| `add_waybill` | `AddWaybill` | `WaybillAddRequest` | 向当前账号导入未使用运单 |
| `del_waybill` | `DeleteWaybills` | `WaybillDeleteRequest` | 删除本人尚未预占的运单 |
| `get_waybill` | `GetWaybills` | `WaybillSearchRequest` | 分页查询本人运单 |
| `get_waybill_no_user_name` | `GetAllWaybills` | `WaybillSearchRequest` | 管理员按订单跨账号查询运单 |
| `select_and_set_waybill` | `SelectAndUseWaybill` | `WaybillSelectRequest` | 在同一事务中选择并确认使用运单 |
| `select_waybill` | `ReserveWaybill` | `WaybillSelectRequest` | 为授权订单预占运单,优先匹配绑定订单 |
| `update_used_waybill` | `UpdateWaybillUsage` | `WaybillUseRequest` | 确认使用或释放本人预占运单 |
| `add_mdy_express_optimize_rst` | `SaveExpressOptimizeResult` | `OptimizeResultRequest` | 保存授权订单物流优化结果 |
| `get_express_optimize_rst` | `GetExpressOptimizeResults` | `OrderSearchRequest` | 查询授权店铺物流优化结果 |
| `add_mdy_express_optimize_remark` | `SaveExpressOptimizeRemark` | `OptimizeRemarkRequest` | 保存或清空授权订单物流备注 |
| `get_express_optimize_remark` | `GetExpressOptimizeRemarks` | `OrderSearchRequest` | 查询授权店铺物流备注 |
| `wx_add_mdy_shop_default_address` | `SaveShopDefaultAddress` | `AddressRequest` | 维护本人的店铺默认售后地址 |
| `wx_get_shop_default_address_list` | `GetShopDefaultAddresses` | `AddressSearchRequest` | 查询本人的店铺默认地址 |
| `wx_del_shop_default_address` | `DeleteShopDefaultAddresses` | `ShopIdsRequest` | 批量删除本人的默认地址 |
| `wx_add_mdy_order_address` | `SaveOrderAddress` | `OrderAddressRequest` | 保存授权店铺订单解密地址 |
| `wx_get_order_address` | `GetOrderAddresses` | `OrderSearchRequest` | 查询授权店铺订单解密地址 |
| `wx_add_mdy_buy_url_notes` | `SaveBuyUrlNote` | `NoteRequest` | 维护当前主账号共享采购备注 |
| `wx_get_buy_url_notes` | `GetBuyUrlNotes` | `NoteSearchRequest` | 查询当前主账号共享采购备注 |
| `wx_add_mdy_29wx_pdd_shop` | `SavePddShop` | `PddSaveRequest` | 导入或更新微信业务下的拼多多店铺资料 |
| `wx_mdy_29wx_pdd_cookie_and_login_status` | `UpdatePddShopLogin` | `CookieRequest` | 刷新微信侧拼多多店铺Cookie与状态 |
| `wx_del_29wx_pdd_shop` | `DeletePddShop` | `LoginRequest` | 删除本人微信侧拼多多店铺 |
| `wx_get_29wx_pdd_shop` | `GetPddShops` | `ShopSearchRequest` | 查询授权微信侧拼多多店铺 |
| `e57_get_wx_tokens` | `GetTokens` | `TokenRequest` | 管理员读取共享微信令牌列表缓存 |
| `e57_get_wx_shop_token` | `GetShopToken` | `ShopIdRequest` | 获取授权微信店铺的e57令牌 |
### 权限与兼容性说明
- 普通主账号查看自己的微信店铺;子账号必须同时满足父账号归属和完整账号分配匹配。账号列表用英文逗号分隔、不含空格。
- AdminAddShop、AdminUpdateShop、AdminDeleteShop、GetAllShops、选品店铺查询、设置额度和操作员、跨账号运单查询、共享令牌列表要求数据库 SuperAdmin 角色。
- 管理员的普通店铺、地址、单店令牌入口仍按本人的店铺范围处理;管理操作显式使用对应管理Action。
- 管理员新增/修改/删除须传 shopUserName;普通新增仅允许主账号。微信重复新增报错;微信侧拼多多重复导入更新资料,并保留已有 Cookie。
- 微信更新不传 shopId 时更新该登录名下授权记录;null 保留原值、空串清空。只有主账号或管理员可调整分配关系,分配对象须为直属子账号。
- 同登录名 Cookie 更新始终限制账号范围,修复旧版跨账号同名更新问题。GetShopCookies 改传 shopIds 数组,包含无权访问的店铺时整次拒绝。
- 默认地址仍按当前用户分别保存;订单解密地址和物流结果先限制可见店铺;采购备注由同一主账号及直属子账号共享。物流结果/备注保存必须传 shopId,不能凭订单号跨店覆盖。
- ERP 商户只能通过授权店铺的 ERP 关联读取;公司账号展开其管理账号。结果按余额降序和记录 ID 稳定分页,total 使用真实数量,替代旧版特殊负数占位。
- 普通店铺列表补充积分到期信息,never_expires 保留旧版有效期展示值 1893427200 且不判过期。isShowExpire 保留为兼容字段,旧实现同样未用于裁剪结果。
- 选品模式查询两张选品表,保留每月300条共享额度重置;普通列表不触发额度重置。月份仍沿用旧字段,尚不区分跨年同月。
### 运单状态与事务
- status=0 未使用、1 预占、2 已正式使用;只允许删除本人 status=0 的记录。
- 导入要求物流公司编码、名称、运单号,且揽收时间与绑定订单至少提供一个;运单号全库去重。
- ReserveWaybill 优先选取绑定当前订单的运单,否则仅从订单日期次日揽收、未绑定的库存随机选取;缺少订单时间时不会随意占用普通库存。
- SelectAndUseWaybill 在同一事务内分配并使用;普通库存不要求揽收时间匹配,但不会抢占绑定其他订单的运单。
- 分配时立即保存 shop_id,重复请求复用本人同订单已预占/使用记录;同订单不能绑定其他店铺。确认使用是幂等操作,释放只允许 status=1,不允许释放已使用运单。
- 事务、行锁和条件更新共同保护状态变更;取消或失败释放工作单元并回滚。保留旧业务的数据库处理范围,旧版 TODO 中未实现的 e57 发货提交不被伪装为已调用。
### 微信部署依赖与令牌配置
除现有 wxxd.shop、wxxd.user_points_shop_expire 外,读取/写入以下旧表,不自动创建或同步结构:
- wxxd.shop_add_prod_count、product_select、third_product_select、pdd_shop、wx_token。
- wxxd.wx_shop_default_address、order_decrypt_address、express_optimize、express_optimize_rst、express_optimize_remark。
- bxx.product_buy_url_notes;erp.o_shop、o_shop_group、o_user_account。
相关库需由现有 IFreeSql 连接访问。写表应使用 InnoDB;核对 shop_id、user_name/login_name/shop_id、waybill_id、user_name/order_id/status 及业务键查询索引,避免 Serializable 范围锁扩大扫描。
保留旧表已有的唯一约束;本次不执行任何生产 DDL。旧的无 shop_id 物流结果记录需要补齐真实归属后才会出现在普通用户查询中。
共享微信令牌缓存每60分钟刷新一次,e57账号由部署配置提供,例如环境变量 `Shops__E57WxAccounts__1` 对应 dbId=1;多个分组分别配置各自 ID。
不再硬编码旧测试账号。单店令牌复用 e57 HTTPS info?appId= 接口;共享列表使用 list?userName=,只接受 code=0 的结果。
缓存写入使用实际 dbId,同时更新 mdy_time;有效缓存可直接读取,过期或损坏缓存刷新失败时返回业务错误。
现有 PddTokenClient 扩展为共享 e57 客户端,保留拼多多入口签名和已有注册方式,不新增网络依赖。
### 本次验证边界
按用户要求,本次合并不新增、不运行测试;仅同步已有HTTP测试的路由、Action和服务注册,进行模块/API构建、Action命名及diff检查。
以前执行的店铺测试不能作为此次路由变更后的测试结论。
未连接真实 MySQL 或 e57;数据库结构、范围锁并发以及客户端调用仍需部署环境联调。可继续进行代码审查。
@@ -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("店铺不存在或无删除权限");
}
}
+4 -1
View File
@@ -14,7 +14,10 @@ namespace Store.Shops
public void Register(IServiceRegistrar registrar)
{
registrar.RegisterSingleton(new Services.PddTokenClient(new HttpClient
{
Timeout = TimeSpan.FromSeconds(30)
}));
}
public void Start(IServiceProvider serviceProvider)
+1
View File
@@ -6,5 +6,6 @@
<ItemGroup>
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
<ProjectReference Include="..\Store.Shops.Contracts\Store.Shops.Contracts.csproj" />
<ProjectReference Include="..\Store.Users.Contracts\Store.Users.Contracts.csproj" />
</ItemGroup>
</Project>
@@ -75,8 +75,19 @@ internal sealed class ShopRenewCommandHandler(UnitOfWorkExecutionContext executi
if (currentTime > expireBase)
expireBase = currentTime;
}
int newExpireTime = CalculateNewExpireTime(expireBase, request.RenewDuration);
DateTime newExpireDate;
try
{
newExpireDate = expireBase.AddMonths(request.RenewDuration);
}
catch (ArgumentOutOfRangeException)
{
throw new AppException("续费时长超出允许范围");
}
long timestamp = DateHelper.GetUnixTimestampInSeconds(newExpireDate);
if (timestamp > int.MaxValue || timestamp < 0)
throw new AppException("续费时间超出系统支持范围");
int newExpireTime = (int)timestamp;
int affected = await orm.Update<UserEntity>().Set(a => a.Points, newPoints)
.Where(a => a.Id == user.Id && a.Points == user.Points)
@@ -94,7 +105,7 @@ internal sealed class ShopRenewCommandHandler(UnitOfWorkExecutionContext executi
UserName = userName,
UserNick = user.NickName,
DeltaPoints = pointsPerMonth,
NewPoints = user.Points - (decimal)pointsPerMonth * (i + 1),
NewPoints = user.Points - pointsPerMonth * (i + 1),
ShopId = shop.ShopId,
ShopAccount = shop.ShopAccount,
Remark = remark,
@@ -127,21 +138,4 @@ internal sealed class ShopRenewCommandHandler(UnitOfWorkExecutionContext executi
cancellationToken.ThrowIfCancellationRequested();
return new ShopRenewResponse { ShopId = shopId, UserPoints = newPoints, ExpireTime‌ = newExpireTime };
}
private static int CalculateNewExpireTime(DateTime expireBase, int duration)
{
DateTime newExpireDate;
try
{
newExpireDate = expireBase.AddMonths(duration);
}
catch (ArgumentOutOfRangeException)
{
throw new AppException("续费时长超出允许范围");
}
long timestamp = DateHelper.GetUnixTimestampInSeconds(newExpireDate);
if (timestamp > int.MaxValue || timestamp < 0)
throw new AppException("续费时间超出系统支持范围");
return (int)timestamp;
}
}
@@ -12,7 +12,7 @@ using MAX.Framework.Conventions;
namespace Store.Users.Controllers;
/// <summary>用户模块接口;所有管理操作均以当前 JWT 对应的数据库账户进行授权。</summary>
/// <summary>用户模块接口;所有管理操作均以当前登录身份对应的数据库账户进行授权。</summary>
[ApiController]
[DefaultHttpPost]
[Authorize]
@@ -22,16 +22,12 @@ public class UserController(UserManagementService users, UserFundsService funds,
/// <summary>获取本人或授权范围内的用户信息,响应不包含密码。</summary>
[EndpointSummary("获取用户信息")]
public Task<UserInfo> GetUser(string? userName, CancellationToken cancellationToken)
{
return users.GetUser(userName, cancellationToken);
}
=> users.GetUser(userName, cancellationToken);
/// <summary>管理员查询全部用户;普通主账号仅查询本人及直属子账号。</summary>
[EndpointSummary("分页查询所有用户")]
public Task<UserPage<UserInfo>> GetAllUser(UserSearchRequest request, CancellationToken cancellationToken)
{
return users.Search(request, false, cancellationToken);
}
=> users.Search(request, false, cancellationToken);
/// <summary>管理员可查询所有账号的子账号,普通主账号只能查询自己的直属子账号。</summary>
/// <param name="request">管理员可通过 ParentUserName 筛选指定父账号;普通主账号仅可指定自己。</param>
@@ -39,37 +35,27 @@ public class UserController(UserManagementService users, UserFundsService funds,
[EndpointSummary("分页查询子账号")]
[EndpointDescription("管理员不传 ParentUserName 时查询全部子账号,传入时按指定父账号筛选;普通主账号只能查询自己的子账号。")]
public Task<UserPage<UserInfo>> GetSubUser(UserSearchRequest request, CancellationToken cancellationToken)
{
return users.Search(request, true, cancellationToken);
}
=> users.Search(request, true, cancellationToken);
/// <summary>管理员创建主账号。</summary>
[EndpointSummary("创建主账号")]
public Task<UserInfo> AddUser(UserCreateRequest request, CancellationToken cancellationToken)
{
return users.Create(request, false, cancellationToken);
}
=> users.Create(request, false, cancellationToken);
/// <summary>管理员可为指定主账号创建子账号,普通主账号仅可为自己创建子账号。</summary>
[EndpointSummary("创建直属子账号")]
public Task<UserInfo> AddSubUser(UserCreateRequest request, CancellationToken cancellationToken)
{
return users.Create(request, true, cancellationToken);
}
=> users.Create(request, true, cancellationToken);
/// <summary>管理员修改任意账户;普通主账号仅修改本人及直属子账号,用户名不可变更。</summary>
[EndpointSummary("修改用户资料")]
public Task<UserInfo> UpdateUser(UserUpdateRequest request, CancellationToken cancellationToken)
{
return users.Update(request, false, cancellationToken);
}
=> users.Update(request, false, cancellationToken);
/// <summary>管理员修改任意子账号,普通主账号仅修改直属子账号。</summary>
[EndpointSummary("修改直属子账号资料")]
public Task<UserInfo> UpdateSubUser(UserUpdateRequest request, CancellationToken cancellationToken)
{
return users.Update(request, true, cancellationToken);
}
=> users.Update(request, true, cancellationToken);
/// <summary>删除无资金和业务关联的账户。</summary>
[EndpointSummary("删除用户")]
@@ -90,37 +76,27 @@ public class UserController(UserManagementService users, UserFundsService funds,
/// <summary>查询授权账户的积分流水。</summary>
[EndpointSummary("查询用户积分流水")]
public Task<UserPage<UserPointsRecordEntity>> GetUserPoints(UserRecordSearchRequest request, CancellationToken cancellationToken)
{
return users.Points(request.UserName, request, cancellationToken);
}
=> users.Points(request.UserName, request, cancellationToken);
/// <summary>查询授权账户的余额流水。</summary>
[EndpointSummary("查询用户余额流水")]
public Task<UserPage<UserPaymentRecordEntity>> GetUserBalance(UserRecordSearchRequest request, CancellationToken cancellationToken)
{
return users.Balance(request.UserName, request, cancellationToken);
}
=> users.Balance(request.UserName, request, cancellationToken);
/// <summary>管理员充值积分,或主账号向直属子账号转移自己的积分。</summary>
[EndpointSummary("充值或转移用户积分")]
public Task<UserFundsResponse> UpdateUserPoints(UserFundsRequest request, CancellationToken cancellationToken)
{
return funds.Change(request, true, cancellationToken);
}
=> funds.Change(request, true, cancellationToken);
/// <summary>管理员充值或扣减余额,自动记录审计流水。</summary>
[EndpointSummary("充值或扣减用户余额")]
public Task<UserFundsResponse> UpdateUserBalance(UserFundsRequest request, CancellationToken cancellationToken)
{
return funds.Change(request, false, cancellationToken);
}
=> funds.Change(request, false, cancellationToken);
/// <summary>提交本人或管理范围内账户的充值备注。</summary>
[EndpointSummary("提交充值备注")]
public Task<long> AddUserTopUpMsg(UserTopUpMessageRequest request, CancellationToken cancellationToken)
{
return users.AddTopUpMessage(request, cancellationToken);
}
=> users.AddTopUpMessage(request, cancellationToken);
/// <summary>管理员可处理所有充值备注,普通主账号仅处理本人及直属子账号备注,不自动充值。</summary>
[EndpointSummary("处理充值备注")]
@@ -133,9 +109,7 @@ public class UserController(UserManagementService users, UserFundsService funds,
/// <summary>按管理员、主账号或子账号的授权范围分页查询充值备注。</summary>
[EndpointSummary("分页查询充值备注")]
public Task<UserPage<UserTopUpMessageEntity>> GetUserTopUpMsg(UserSearchRequest request, CancellationToken cancellationToken)
{
return users.TopUpMessages(request, cancellationToken);
}
=> users.TopUpMessages(request, cancellationToken);
/// <summary>管理员维护代理充值二维码。</summary>
[EndpointSummary("维护代理充值二维码")]
@@ -148,15 +122,10 @@ public class UserController(UserManagementService users, UserFundsService funds,
/// <summary>获取代理充值二维码。</summary>
[EndpointSummary("获取代理充值二维码")]
public Task<string?> GetTopUpQrcode(string agentCode, CancellationToken cancellationToken)
{
return users.GetTopUpQrcode(agentCode, cancellationToken);
}
=> users.GetTopUpQrcode(agentCode, cancellationToken);
/// <summary>管理员或主账号为授权账户的商铺续费,扣除店铺所属账户积分并记录实际操作者。</summary>
[EndpointSummary("使用积分为商铺续费")]
public Task<ShopRenewResponse> ShopRenew(ShopRenewRequest request, CancellationToken cancellationToken)
{
// 操作者身份、目标账户归属和消费额度统一在命令管道中检查。
return mediator.Send(new ShopRenewCommand(request), cancellationToken);
}
=> mediator.Send(new ShopRenewCommand(request), cancellationToken);
}
+2 -2
View File
@@ -1,6 +1,6 @@
# 用户模块
参考旧版 `D:/Works/KJD_WebService/KJD_WebService/BLL/WXXD/WXUserManage.cs`,沿用用户、积分、余额、充值备注等旧表。接口采用 JSON DTO 和现有 JWT 身份,不接受旧版拼接参数或客户端提供的操作者身份。
参考旧版 `D:/Works/KJD_WebService/KJD_WebService/BLL/WXXD/WXUserManage.cs`,沿用用户、积分、余额、充值备注等旧表。接口采用 JSON DTO 和现有 Bearer Token 身份,不接受旧版拼接参数或客户端提供的操作者身份。
## 文件与职责
@@ -54,4 +54,4 @@ dotnet run --project Store.API/Store.Api.csproj
连接现有 MySQL 前确认账户表用户名唯一、密码列可容纳 Identity 哈希、资金相关表支持事务。应用不自动创建或修改旧表。旧系统直接比较明文密码的登录接口不能识别新哈希,需随调用方迁移。
本次不修改或运行 Test 项目。编译和独立参数/权限边界验证不能替代真实 MySQL 的并发与表结构联调。旧文件中的 E57 令牌、销售助手 Cookie、淘宝买手不属于本模块范围。可以继续使用“代码审查”检查实现质量。
使用 `dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj` 验证参数、权限、密码、资金和 HTTP 接口。SQLite 测试不能替代真实 MySQL 的并发与表结构联调。旧文件中的 E57 令牌、销售助手 Cookie、淘宝买手不属于本模块范围。可以继续使用“代码审查”检查实现质量。
@@ -5,13 +5,13 @@ namespace Store.Users.Repositories;
public interface IUserRepository : IBaseRepository<UserEntity>
{
Task<UserEntity> GetUser(string userName);
Task<UserEntity?> GetUser(string userName);
}
/// <summary>集中处理旧系统主子账号的有效计费配置和积分权限继承。</summary>
internal class UserRepository(IFreeSql freeSql) : BaseRepository<UserEntity>(freeSql), IUserRepository, ITransientDependency
{
public async Task<UserEntity> GetUser(string userName)
public async Task<UserEntity?> GetUser(string userName)
{
var user = await Where(u => u.UserName == userName).FirstAsync();
if (user == null || user.IsMainUser) return user;
@@ -11,9 +11,6 @@ internal static class UserAccessPolicy
public static bool Owns(UserEntity actor, UserEntity target) => actor.IsMainUser
&& string.Equals(target.ParentUserName, actor.UserName, StringComparison.OrdinalIgnoreCase);
public static bool CanRead(UserEntity actor, UserEntity target) =>
CanUseSelfService(actor, target);
/// <summary>子账号可查询本人、修改本人密码、提交充值备注和为本人店铺续费。</summary>
public static bool CanUseSelfService(UserEntity actor, UserEntity target) =>
actor.Id == target.Id || CanManage(actor, target);
@@ -30,7 +30,7 @@ public sealed partial class UserManagementService
var actor = await Actor(orm, ct);
var message = await orm.Select<UserTopUpMessageEntity>().Where(m => m.Id == request.Id).FirstAsync(ct)
?? throw new AppException("充值备注不存在");
if (!IsAdmin(actor))
if (!UserAccessPolicy.IsAdmin(actor))
{
var target = await orm.Select<UserEntity>().Where(u => u.UserName == message.UserName).FirstAsync(ct);
if (target == null || !UserAccessPolicy.CanManage(actor, target))
@@ -46,7 +46,7 @@ public sealed partial class UserManagementService
RequestValidator.Validate(request);
var actor = await Actor(orm, ct);
var select = orm.Select<UserTopUpMessageEntity>();
if (!IsAdmin(actor))
if (!UserAccessPolicy.IsAdmin(actor))
{
if (actor.IsMainUser)
select = select.Where(m => m.UserName == actor.UserName || orm.Select<UserEntity>()
@@ -63,7 +63,7 @@ public sealed partial class UserManagementService
{
RequestValidator.Validate(request);
var actor = await Actor(orm, ct);
if (!IsAdmin(actor)) throw new AppException("仅管理员可以维护充值二维码", 403);
if (!UserAccessPolicy.IsAdmin(actor)) throw new AppException("仅管理员可以维护充值二维码", 403);
await orm.InsertOrUpdate<UserTopUpQrcodeEntity>().SetSource(new UserTopUpQrcodeEntity
{
AgentCode = request.AgentCode.Trim(), QrcodeBase64 = request.QrcodeBase64
@@ -24,7 +24,6 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur
?? throw new UnauthorizedAccessException("登录账户不存在");
}
private static bool IsAdmin(UserEntity actor) => UserAccessPolicy.IsAdmin(actor);
/// <summary>仅本人、直属主账号或超级管理员可以读取账户详情。</summary>
public async Task<UserInfo> GetUser(string? userName, CancellationToken ct)
{
@@ -33,7 +32,7 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur
RequestValidator.Validate(new UserNameRequest { UserName = name });
var target = await orm.Select<UserEntity>().Where(u => u.UserName == name).FirstAsync(ct)
?? throw new AppException("用户不存在");
if (!UserAccessPolicy.CanRead(actor, target))
if (!UserAccessPolicy.CanUseSelfService(actor, target))
throw new AppException("无权访问该账户", 403);
return await query.GetUser(target.UserName);
}
@@ -43,12 +42,12 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur
{
RequestValidator.Validate(request);
var actor = await Actor(orm, ct);
if (!IsAdmin(actor) && !actor.IsMainUser) throw new AppException("子账号不能管理账户列表", 403);
if (!UserAccessPolicy.IsAdmin(actor) && !actor.IsMainUser) throw new AppException("子账号不能管理账户列表", 403);
var select = orm.Select<UserEntity>();
if (subUsersOnly)
{
var parentUserName = request.ParentUserName?.Trim();
if (IsAdmin(actor))
if (UserAccessPolicy.IsAdmin(actor))
{
// 管理员省略父账号时查询所有子账号,但不混入主账号。
select = select.Where(u => u.ParentUserName != null && u.ParentUserName.Trim() != "");
@@ -63,7 +62,7 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur
select = select.Where(u => u.ParentUserName == actor.UserName && (u.SaLevel == null || u.SaLevel != UserAdminLevel.SuperAdmin));
}
}
else if (!IsAdmin(actor))
else if (!UserAccessPolicy.IsAdmin(actor))
select = select.Where(u => u.Id == actor.Id || (u.ParentUserName == actor.UserName && (u.SaLevel == null || u.SaLevel != UserAdminLevel.SuperAdmin)));
if (!subUsersOnly && !string.IsNullOrWhiteSpace(request.ParentUserName))
{
@@ -79,7 +78,8 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur
}
var total = await select.CountAsync(ct);
var items = await select.OrderByDescending(u => u.Id).Page(request.Page, request.PageSize).ToListAsync(ct);
return new UserPage<UserInfo>(total, items.Select(u => new UserMapper().ToInfo(u)).ToList());
var mapper = new UserMapper();
return new UserPage<UserInfo>(total, items.Select(mapper.ToInfo).ToList());
}
/// <summary>创建账户并初始化余额。主账号仅由管理员创建,子账号的归属由服务端指定。</summary>
@@ -94,12 +94,12 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur
// 锁住主账号,使其创建子账号和删除操作串行化。
actor = await db.Select<UserEntity>().Where(u => u.Id == actor.Id).ForUpdate().FirstAsync(ct)
?? throw new AppException("操作者账户不存在");
if (subUser ? !IsAdmin(actor) && !actor.IsMainUser : !IsAdmin(actor)) throw new AppException("无权创建该账户", 403);
if (subUser ? !UserAccessPolicy.IsAdmin(actor) && !actor.IsMainUser : !UserAccessPolicy.IsAdmin(actor)) throw new AppException("无权创建该账户", 403);
var parentName = "";
if (subUser)
{
parentName = string.IsNullOrWhiteSpace(request.ParentUserName) ? actor.UserName : request.ParentUserName.Trim();
if (!IsAdmin(actor) && !string.Equals(parentName, actor.UserName, StringComparison.OrdinalIgnoreCase))
if (!UserAccessPolicy.IsAdmin(actor) && !string.Equals(parentName, actor.UserName, StringComparison.OrdinalIgnoreCase))
throw new AppException("只能在自己的账户下创建子账号", 403);
var parent = await db.Select<UserEntity>().Where(u => u.UserName == parentName).ForUpdate().FirstAsync(ct)
?? throw new AppException("父账号不存在");
@@ -186,7 +186,7 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur
var name = string.IsNullOrWhiteSpace(request.UserName) ? actor.UserName : request.UserName.Trim();
var target = await orm.Select<UserEntity>().Where(u => u.UserName == name).FirstAsync(ct)
?? throw new AppException("用户不存在");
if (target.Id == actor.Id && !IsAdmin(actor))
if (target.Id == actor.Id && !UserAccessPolicy.IsAdmin(actor))
{
RequestValidator.Validate(new UserPasswordProofRequest { OldPassword = request.OldPassword! });
if (!UserPassword.Verify(target, request.OldPassword!)) throw new AppException("原密码错误");
@@ -242,18 +242,18 @@ public sealed partial class UserManagementService(IFreeSql orm, ICurrentUser cur
if (request.GroupId.HasValue && (subUser ? request.GroupId == UserGroup.MainAccount : request.GroupId != UserGroup.MainAccount))
throw new AppException("主账号必须使用主号分组,子账号不能使用主号分组");
// 普通主账号只能授予自己已有的权限,不能通过创建子账号提升权限。
if (!IsAdmin(actor) && request.Authority != null)
if (!UserAccessPolicy.IsAdmin(actor) && request.Authority != null)
{
var allowed = Split(actor.Authority);
if (Split(request.Authority).Any(a => !allowed.Contains(a))) throw new AppException("不能授予超出自身范围的权限", 403);
}
if (!IsAdmin(actor) && (request.OemId != null || request.ShopPointsPerMonth != null
if (!UserAccessPolicy.IsAdmin(actor) && (request.OemId != null || request.ShopPointsPerMonth != null
|| request.PostagePerOrder != null || request.RefundPostagePerOrder != null
|| request.GiftOemAmountPerOrder != null || request.SyncLogisticsPerOrder != null))
throw new AppException("仅管理员可以修改计费配置", 403);
if (request.SaLevel.HasValue)
{
if (!IsAdmin(actor)) throw new AppException("无权设置管理员级别", 403);
if (!UserAccessPolicy.IsAdmin(actor)) throw new AppException("无权设置管理员级别", 403);
user.SaLevel = request.SaLevel;
}
user.NickName = request.NickName?.Trim() ?? user.NickName;