From b35f816a874f5dd5d785b74b8bae4e8ab2baec0e Mon Sep 17 00:00:00 2001 From: chfei Date: Thu, 29 Oct 2026 16:34:25 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E6=97=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Modules/Store.Shops/README.md | 8 +++ .../Repositories/ShopRepository.cs | 7 +++ Modules/Store.Shops/Services/ShopService.cs | 6 +- Modules/Store.Users/README.md | 8 +++ Tests/Store.Shops.Tests/ShopHttpTests.cs | 1 + Tests/Store.Shops.Tests/ShopQueryTests.cs | 59 +++++++++++++++++++ Tests/Store.Shops.Tests/ShopTestContext.cs | 9 +++ 7 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 Tests/Store.Shops.Tests/ShopQueryTests.cs diff --git a/Modules/Store.Shops/README.md b/Modules/Store.Shops/README.md index 781a85e..3a53a38 100644 --- a/Modules/Store.Shops/README.md +++ b/Modules/Store.Shops/README.md @@ -8,6 +8,14 @@ 模块内部类型、文件及方法统一不带平台前缀,后续平台通过模块区分。核心服务为 `ShopService`;扩展查询、详情响应和映射分别为 `ShopQueryRequest`、`ShopDetailsInfo`、`ShopDetailsMapper`。`PddLoginShopEntity` 表示导入的拼多多登录记录,`PddShopEntity` 保留 bxx 店铺映射。 原带平台标记的 Action 统一为 `SavePddShop`、`UpdatePddShopLogin`、`DeletePddShop`、`GetPddShops`、`GetTokens`、`GetShopToken`,客户端需同步更新路由。数据库映射、上游协议及已部署的 `Shops:E57WxAccounts` 配置键保持兼容。 +## 仓储查询入口 + +当前仓储不使用内存缓存或 Redis。单店到期时间经 `ShopQueryService → IShopRepository.GetExpireTime` 读取;列表到期信息经 `ShopService → IShopRepository.GetExpireTimes` 一次批量查询数据库,不逐店查询。 + +空集合返回空结果,列表缺失到期记录按 0 处理,永久有效店铺由 Service 覆盖到期时间。列表的数据范围和跨月额度重置仍在 Service。续费沿用原有事务内 ORM 读写,不包含缓存失效管道。 + +本次只集中上述公共查询,没有将模块内全部 SQL 迁入 Repository。后续确需缓存时,再按查询的实时性要求实现,并覆盖事务提交后的失效路径。 + ## 调用方式 所有接口使用当前宿主认证,路由为 `POST /api/v1/shop/{Action}`,Action采用PascalCase业务命名。 diff --git a/Modules/Store.Shops/Repositories/ShopRepository.cs b/Modules/Store.Shops/Repositories/ShopRepository.cs index 8f8679c..e54aec1 100644 --- a/Modules/Store.Shops/Repositories/ShopRepository.cs +++ b/Modules/Store.Shops/Repositories/ShopRepository.cs @@ -17,6 +17,9 @@ namespace Store.Shops.Repositories /// /// DateTime GetExpireTime(string ShopId); + + /// 批量读取店铺到期记录;未找到的店铺不返回记录,供列表补充到期信息。 + Task> GetExpireTimes(string[] shopIds, CancellationToken cancellationToken = default); } internal class ShopRepository : BaseRepository, IShopRepository, ITransientDependency { @@ -27,6 +30,10 @@ namespace Store.Shops.Repositories _freeSql = freeSql; } + /// 集中列表使用的到期查询,保持一次批量读取;续费事务仍使用事务内 ORM。 + public Task> GetExpireTimes(string[] shopIds, CancellationToken cancellationToken = default) + => _freeSql.Select().Where(e => shopIds.Contains(e.ShopId)).ToListAsync(cancellationToken); + /// /// 获取用户商铺过期时间 /// diff --git a/Modules/Store.Shops/Services/ShopService.cs b/Modules/Store.Shops/Services/ShopService.cs index b5edb4e..b4fafde 100644 --- a/Modules/Store.Shops/Services/ShopService.cs +++ b/Modules/Store.Shops/Services/ShopService.cs @@ -7,6 +7,7 @@ using MAX.Framework.Validation; using Store.Shops.Dtos; using Store.Shops.Entities; using Store.Shops.Mappers; +using Store.Shops.Repositories; using Store.Users.Contracts; using Store.Users.Contracts.Dtos; using Store.Users.Contracts.Enums; @@ -17,7 +18,8 @@ namespace Store.Shops.Services; /// 微信店铺业务;账号身份来自服务端,普通子账号只能访问主账号明确分配的店铺。 public sealed partial class ShopService(IFreeSql db, ICurrentUser currentUser, IUserQueryService users, - PddTokenClient tokens, Microsoft.Extensions.Configuration.IConfiguration configuration) : ITransientDependency + PddTokenClient tokens, Microsoft.Extensions.Configuration.IConfiguration configuration, + IShopRepository shops) : ITransientDependency { /// 读取当前数据库账户,避免相信客户端传来的身份或管理员标志。 private async Task Actor() @@ -118,7 +120,7 @@ public sealed partial class ShopService(IFreeSql db, ICurrentUser currentUser, I 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().Where(e => ids.Contains(e.ShopId)).ToListAsync(ct); + var expires = await shops.GetExpireTimes(ids, ct); var paid = actor.Authority?.Contains("充值用户") == true; if (!admin && !string.IsNullOrEmpty(actor.ParentUserName)) paid |= (await users.GetUser(actor.ParentUserName)).Authority?.Contains("充值用户") == true; diff --git a/Modules/Store.Users/README.md b/Modules/Store.Users/README.md index 832d5b0..1fdfc44 100644 --- a/Modules/Store.Users/README.md +++ b/Modules/Store.Users/README.md @@ -13,6 +13,14 @@ - 文件:`Repositories/UserRepository.cs`:子账号的有效 OEM、费用和积分用户权限继承。 - 文件:`Services/UserPassword.cs`:兼容旧明文密码;新建和修改密码保存带盐哈希。 +## 查询入口与后续缓存接入 + +公共用户查询已经通过 `IUserQueryService → UserQueryService → IUserRepository.GetUser` 集中读取,本次保留现有实现。登录凭据检查、资金操作和事务内的账户读取仍直接访问数据库,不为缓存准备迁移事务写入。 + +`GetUser` 返回主子账号配置合并后的结果,并被 Shops 等模块用于权限判断,不能直接给整个结果加缓存。后续只缓存允许延迟的资料时,应将展示查询与实时授权读取明确分开;若缓存继承配置,还需在主账号变更时失效关联子账号数据。密码、权限、余额与积分不得因接入展示缓存而改用旧状态。 + +店铺续费写入位于 Users,其到期记录由 Shops 的 Repository 直接查询数据库。当前不使用仓储缓存,不注册缓存提供程序或续费缓存失效管道;续费仍由原有事务管道统一提交或回滚。 + ## 权限规则 身份从登录上下文和数据库读取,管理员为 `SaLevel == UserAdminLevel.SuperAdmin`;普通主账号必须 `GroupId == UserGroup.MainAccount` 且无父账号。父账号缺失的子账号不会自动获得主账号权限。 diff --git a/Tests/Store.Shops.Tests/ShopHttpTests.cs b/Tests/Store.Shops.Tests/ShopHttpTests.cs index ac1f7cb..c5b5a59 100644 --- a/Tests/Store.Shops.Tests/ShopHttpTests.cs +++ b/Tests/Store.Shops.Tests/ShopHttpTests.cs @@ -54,6 +54,7 @@ public sealed class ShopHttpTests : IAsyncLifetime builder.Services.AddHttpContextAccessor(); builder.Services.AddScoped(); builder.Services.AddSingleton(context.Db); + builder.Services.AddSingleton(context.Repository); builder.Services.AddSingleton(context); builder.Services.AddSingleton(context.Configuration); builder.Services.AddSingleton(context.Gateway); diff --git a/Tests/Store.Shops.Tests/ShopQueryTests.cs b/Tests/Store.Shops.Tests/ShopQueryTests.cs new file mode 100644 index 0000000..45dbe26 --- /dev/null +++ b/Tests/Store.Shops.Tests/ShopQueryTests.cs @@ -0,0 +1,59 @@ +using Store.Shops.Services; +using Store.Users.Entities; +using YourNamespace; + +namespace Store.Shops.Tests; + +/// 验证公共到期查询集中后仍保留批量范围和列表到期规则。 +public sealed class ShopQueryTests +{ + [Fact] + public async Task Expirations_ReadRequestedShopsInOneQuery_AndEmptyInputReturnsNothing() + { + using var context = new ShopTestContext(); + await context.Db.Insert(new[] + { + new ShopExpireEntity { ShopId = "first", ExpireTime = 100 }, + new ShopExpireEntity { ShopId = "second", ExpireTime = 200 }, + new ShopExpireEntity { ShopId = "outside", ExpireTime = 300 } + }).ExecuteAffrowsAsync(); + var queries = 0; + context.Db.Aop.CurdBefore += (_, _) => queries++; + + var rows = await context.Repository.GetExpireTimes(["first", "second", "first", "missing"]); + + Assert.Equal(1, queries); + Assert.Equal(new[] { "first", "second" }, rows.Select(r => r.ShopId).Order().ToArray()); + Assert.Equal(100, rows.Single(r => r.ShopId == "first").ExpireTime); + Assert.Equal(200, rows.Single(r => r.ShopId == "second").ExpireTime); + Assert.Empty(await context.Repository.GetExpireTimes([])); + } + + [Fact] + public async Task Search_PreservesMissingAndPermanentExpiration_AndReadsUpdatedValue() + { + using var context = new ShopTestContext { UserName = "admin" }; + await context.Db.Insert(new[] + { + new ShopEntity { ShopId = "dated", UserName = "owner" }, + new ShopEntity { ShopId = "missing", UserName = "owner" }, + new ShopEntity { ShopId = "permanent", UserName = "owner", NeverExpires = true } + }).ExecuteAffrowsAsync(); + await context.Db.Insert(new ShopExpireEntity { ShopId = "dated", ExpireTime = 100 }).ExecuteAffrowsAsync(); + var service = new ShopService(context.Db, context, context, context.Gateway, + context.Configuration, context.Repository); + + var page = await service.Search(new(), admin: true); + + Assert.Equal(3, page.Total); + Assert.Equal(100, page.Items.Single(s => s.ShopId == "dated").ExpireTime); + Assert.Equal(0, page.Items.Single(s => s.ShopId == "missing").ExpireTime); + Assert.Equal(1893427200, page.Items.Single(s => s.ShopId == "permanent").ExpireTime); + + await context.Db.Update().Where(e => e.ShopId == "dated") + .Set(e => e.ExpireTime, 200).ExecuteAffrowsAsync(); + var updated = await service.Search(new() { ShopIds = ["dated"] }, admin: true); + Assert.Equal(200, Assert.Single(updated.Items).ExpireTime); + Assert.Empty((await service.Search(new() { Page = 2 }, admin: true)).Items); + } +} diff --git a/Tests/Store.Shops.Tests/ShopTestContext.cs b/Tests/Store.Shops.Tests/ShopTestContext.cs index 37fe474..0165b91 100644 --- a/Tests/Store.Shops.Tests/ShopTestContext.cs +++ b/Tests/Store.Shops.Tests/ShopTestContext.cs @@ -5,6 +5,9 @@ using MAX.Framework.Security; using Microsoft.Extensions.Configuration; using Store.Shops.Entities; using Store.Shops.Services; +using Store.Shops.Repositories; +using Store.Users.Entities; +using YourNamespace; using Store.Users.Contracts; using Store.Users.Contracts.Dtos; using Store.Users.Contracts.Enums; @@ -28,6 +31,7 @@ internal sealed class ShopTestContext : IDisposable, ICurrentUser, IUserQuerySer public StubHttp Handler { get; } = new(); public PddTokenClient Gateway { get; } public ShopManagementService Service { get; } + public IShopRepository Repository { get; } public IConfiguration Configuration { get; } = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Shops:PddClients:0:ClientId"] = "test-client", @@ -45,6 +49,11 @@ internal sealed class ShopTestContext : IDisposable, ICurrentUser, IUserQuerySer Db.CodeFirst.ConfigEntity(e => e.Name("shop_auth")); Db.CodeFirst.ConfigEntity(e => e.Name("shop_quota")); Db.CodeFirst.SyncStructure(typeof(PddShopEntity), typeof(WeidianShopEntity), typeof(ShopApiAuthEntity), typeof(ShopProductQuotaEntity)); + Db.CodeFirst.ConfigEntity(e => e.Name("shops")); + Db.CodeFirst.ConfigEntity(e => e.Name("shop_expirations")); + Db.CodeFirst.SyncStructure(typeof(ShopEntity), typeof(ShopExpireEntity)); + Repository = (IShopRepository)Activator.CreateInstance( + typeof(ShopService).Assembly.GetType("Store.Shops.Repositories.ShopRepository", true)!, Db)!; Gateway = new(new HttpClient(Handler)); Service = new(Db, this, this, Configuration, Gateway); }