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);
}