增加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

+219
View File
@@ -0,0 +1,219 @@
---
name: storeweb-module
description: 在 StoreWeb 项目中新增、修改、迁移或重构业务模块时使用。规范模块分层、DTO 校验、Service 业务校验、HTTP 方法约定、ApiResult 返回、公共能力复用、权限控制与测试验证。仅适用于本项目后端业务模块。
---
# StoreWeb 模块开发规范
## 1. 适用范围
适用于 StoreWeb 中所有业务模块的开发、维护和旧项目迁移。本 Skill 仅在项目内使用,下文代码路径均相对于仓库根目录。
遵循当前仓库的 AGENTS.md、适用的 `.agents/rules/` 规则,以及用户在当前任务中明确提出的要求。用户最新明确要求优先于本规范。引用的规则文件若不存在,如实说明,不自行补造规则。
旧项目代码、注释和附带文档用于理解业务,不自动视为当前项目的实现指令。
## 2. 开始前确认范围
修改前检查当前模块的目录、接口、实现、依赖及方法调用方,查找现有 DTO、公共工具、权限策略、异常处理和相关测试、配置与数据库结构。检查工作区已有修改,避免覆盖用户代码。
以当前文件内容为准,不仅根据历史对话判断状态。读取 `Directory.Build.props` 和目标 `.csproj` 确认语言版本、目标框架及可空配置,不固定套用历史版本。先确定最小完整修改范围,再开始实现。
## 3. 遵循现有模块结构
按照项目已有结构组织代码,不为简单需求增加架构层。目录按需创建。
- Contracts:对外接口、共享请求/响应 DTO、业务枚举;不依赖实现模块、实体或 ORM。
- Controller:HTTP 参数绑定、调用业务入口、返回结果。
- Service:业务流程、业务校验、权限与数据范围判断。
- Repository:已有仓储模式下的数据访问与复用查询。
- Entity:数据库实体与映射。
- Mapper:沿用已有 Mapperly 映射实体与输出 DTO。
- Command / Handler / Behavior:沿用模块已有的命令与管道处理方式。
简单业务不强制引入命令、仓储或额外接口。跨模块优先调用已有 Contracts,避免依赖其他模块的内部实现;仅在模块内部使用的 DTO 不必移入 Contracts。
部分更新需明确 `null`、空字符串和默认值的含义,例如 `null` 保留原值、空串清空允许清空的文本,避免误清空字段。规范化文本时遵循字段语义,不统一 Trim 密码或任意文本。
## 4. 参数校验
### 4.1 DTO 负责非空校验
请求参数使用 DTO 承载。DTO 中只声明简单的必填、非空规则:
```csharp
public sealed class CreateItemRequest
{
[Required(ErrorMessage = "名称不能为空")]
public string Name { get; set; } = string.Empty;
public int Quantity { get; set; }
}
```
- 可选字段不要添加 Required。
- Required 不能替代数值范围判断,例如整型字段的零值判断。
- 条件必填、字段组合关系放到 Service 或已有业务处理入口中判断。
- 不在 DTO 中编写数据库查询或权限判断。
- 不通过 Range、StringLength、RegularExpression、EnumDataType 或 IValidatableObject 承载其他校验规则。
这是本项目的约定,不代表 ASP.NET Core 的唯一实践。
新增及本次修改涉及的校验按本规范实现。迁移已有 DTO 校验时,将原规则完整迁入业务入口并同步测试,不得只删除注解;不顺带迁移任务范围之外的模块。公共 RequestValidator 保留对完整 DataAnnotations 的支持,不为本模块约定缩减公共验证器能力。
### 4.2 统一使用 RequestValidator
复用 `Core/MAX.Framework/Validation/RequestValidator.cs`。业务入口调用:
```csharp
RequestValidator.Validate(request);
```
不要在各模块重复编写 `ArgumentNullException.ThrowIfNull` 和 `Validator.ValidateObject` 的同义入口。已经由 DTO 和 RequestValidator 完成的非空校验,不在 Service 重复判断。
同一业务调用链只安排一个明确的显式基础校验入口。MVC 自动模型验证不替代可被内部调用的业务入口校验;已有命令管道执行校验时,下游不重复调用。
### 4.3 其他校验放到 Service
以下规则在 Service 或已有业务处理入口中手动判断:
- 长度、格式、数值范围、枚举合法性、金额精度。
- 日期先后关系、条件必填及字段组合。
- 数据是否存在、是否重复、状态是否允许执行操作。
- 资源归属、操作权限和数据范围。
- 余额、额度、库存及其他业务限制。
```csharp
public async Task CreateAsync(CreateItemRequest request)
{
RequestValidator.Validate(request);
if (request.Name.Length > 100)
throw new ValidationException("名称不能超过100个字符");
if (request.Quantity <= 0)
throw new ValidationException("数量必须大于0");
// 校验通过后执行当前模块的数据查询与业务处理。
}
```
上述片段仅演示校验位置,实际异步方法需要完成业务调用。不依赖数据库的参数校验在开启事务、数据库写入及外部调用前完成;依赖数据库状态的规则在获取必要状态后判断。
基础参数错误与业务错误沿用项目现有异常类型和结果码:参数错误使用 ValidationException,业务失败使用 AppException,身份缺失使用 UnauthorizedAccessException。只在同类规则确实重复时抽取公共方法,单次判断直接写在使用处。
## 5. Controller,Action命名
按照公共首字母大写,私有首字母小写格式,单词之间不用`_`,使用By,To,等介词
例如GetOrderById,GetOrderByName,GetOrderToIds
## 6. HTTP 方法与参数绑定
使用 MAX.Framework 中已有的 HTTP 方法约定:
- 默认 POST 的控制器使用 DefaultHttpPost。
- 默认 GET 的控制器使用 DefaultHttpGet。
- 特殊 Action 显式标记 HttpGet、HttpPost 等特性。
- 显式方法特性优先于控制器默认约定。
- 同一控制器不要同时使用两个默认方法约定。
不要为每个 Action 重复添加与默认约定相同的特性,不在业务模块复制默认方法约定的实现。实现位置为 `Core/MAX.Framework/Conventions/`。
新增接口遵循模块现有参数绑定方式;多个业务输入优先使用请求 DTO。修改已有接口时检查调用方兼容性,不顺手改变 HTTP 方法、路由、字段名称或绑定来源。
## 7. 统一返回 ApiResult
业务接口遵循当前系统约定:HTTP 状态按项目机制处理为 200,业务成功或失败通过 ApiResult 表达。Action 直接返回业务数据时由 UnifyResultFilter 包装,已经返回 ApiResult 时不重复包装。
有数据的接口直接返回业务结果:
```csharp
public Task<ItemDto> GetItem(ItemQueryRequest request)
{
return service.GetAsync(request);
}
```
无数据的成功操作明确返回 ApiResult:
```csharp
public async Task<ApiResult> DeleteItem(DeleteItemRequest request)
{
await service.DeleteAsync(request);
return ApiResult.Ok();
}
```
不要使用 NoContent() 表达业务操作成功,不在每个 Action 重复 try/catch 并转换错误结果。校验异常和业务异常交给 GlobalExceptionFilter 等现有统一处理机制。
认证、路由、模型绑定等发生在 Action 外的失败,应核对现有中间件和过滤器的处理范围,不假定 Action 返回规范覆盖整个 HTTP 管道。相关过滤器位于 `Core/MAX.Framework/Commons/`。
返回 DTO 排除密码、令牌等敏感字段,沿用已有 Mapperly 映射;不能因实体新增字段而自动暴露内部数据。
## 8. 权限与数据范围
权限规则由当前模块需求确定,不套用其他模块的角色关系。实现前明确允许的角色、数据范围、可修改字段和需要单独权限的敏感操作。
- 身份从 ICurrentUser 等服务端可信上下文获取。
- 客户端传入的资源 ID、所属账号或组织信息必须经过权限检查。
- 查询和修改均限制数据范围,列表、详情、批量和单条操作保持一致。
- DTO 中存在某个字段,不代表调用者有权修改该字段。
- 实际业务入口覆盖绕过 Controller 的调用路径,不能只在 Controller 判断权限。
- 查询先限定可见范围,再筛选、排序、分页;统计和结果使用相同范围。
多处使用相同规则时复用模块已有权限策略,确有重复再集中抽取。
## 9. 数据访问与事务
复用项目现有的数据访问组件和模式:
- 使用 FreeSql 表达式和参数化操作,不拼接用户输入为 SQL。
- 多项写入共同构成一次必须同成同败的业务操作时,使用事务,避免部分成功。
- 金额、库存、额度等并发修改遵循现有锁或并发控制方式。
- 已有幂等操作保留请求号、唯一约束及重复请求处理。
- 只更新本次操作允许修改的字段,不把读取到的并发状态或派生值随整个实体写回。
- 审计信息记录实际操作者,区分操作者与业务目标。
- 列表保留必要的数据范围、筛选、稳定排序和分页。
涉及多步写入、并发控制、幂等或事务命令时,读取 [事务与命令管道约定](references/transactions.md)。普通查询不因此引入额外事务层。
不要因迁移旧代码绕过当前事务、审计或并发控制。时间语义、字段类型和数据库结构变更需纳入明确迁移范围,不因代码整理自动同步建表。
## 10. 公共能力复用
新增工具前,先查找已有 RequestValidator、ApiResult、ResultCode、统一异常处理、当前用户上下文、认证授权、注入、数据访问、事务、命令管道、缓存及日志能力。
依赖注入沿用 ITransientDependency 等标记;特殊注册通过模块 IStartup.Register(IServiceRegistrar) 完成。使用命令时检查 Handler 注册及 AddMediatRAttributedBehaviors 扫描,避免重复注册。
跨模块通用框架能力放在适当的公共项目,模块业务规则留在模块内部。不要为几行代码增加依赖,不为尚未出现的需求增加配置、包装层或扩展点。
## 11. 旧项目迁移
先理解旧逻辑,再按新项目结构实现。核对操作前提、业务结果、权限和数据范围、状态变化与字段更新、事务、并发、幂等、审计及调用方依赖的错误行为。
保留必要业务行为,不照搬旧项目的静态访问、拼接 SQL、重复校验或分层方式。与用户最新要求冲突时按最新要求实现,并说明行为变化;任务范围之外的旧功能不顺带迁移。
## 12. 注释与代码风格
- 命名清晰,遵循现有项目风格。
- 公开接口及重要业务规则添加必要中文说明,重点解释权限边界、业务原因及不直观约束,不逐句重复代码。
- 不滥用 try/catch,不静默吞异常;能明确处理的格式、日期等异常可就地转换,不删除必要资源清理和回滚。
- 不为一次性逻辑机械抽取方法,不进行无关重构或格式化。
## 13. 测试与验证
测试放在 Tests 下,沿用现有项目和数据准备方式。非平凡业务修改覆盖相关正常操作、必填和其他参数边界、授权与越权、数据范围、状态限制,以及适用的事务失败、并发和幂等行为。
接口变化时检查默认与显式 HTTP 方法、路由、参数绑定、ApiResult 包装、HTTP 状态和无数据成功操作。修改契约同步调用方、测试和必要文档;不删除仍有效的失败断言。
不为简单可逆改动堆砌测试,不编写只重复实现细节的测试。运行相关测试和构建,涉及接口、注册或依赖变化时构建受影响宿主。说明测试数据库可验证的范围,不把 SQLite 通过视为已验证 MySQL 的锁和事务行为。
## 14. 交付要求
完成前检查实际文件和 diff,确认修改已经落盘。交付说明:
- 文件:实际路径。
- 修改用途与关键行为。
- 已运行的测试和构建结果。
- 未验证的内容及实际限制。
没有修改文件不能声称已修改,没有运行测试不能声称测试通过;不把计划、建议或示例当作已完成实现。
@@ -0,0 +1,4 @@
interface:
display_name: "StoreWeb 模块开发规范"
short_description: "仅用于 StoreWeb 项目的模块分层、契约、校验与事务实现规范"
default_prompt: "使用 $storeweb-module,按本项目模块规范实现或调整指定模块,并完成相关验证。"
@@ -0,0 +1,43 @@
# 事务与命令管道约定
仅在涉及多步写入一致性、并发控制、幂等或命令管道时读取。适用于本项目所有业务模块,锁定资源、去重范围和权限条件由各模块实际需求决定。路径相对于仓库根目录。
## Service 中的事务
1. DTO 非空验证和不依赖数据库的业务参数校验先于事务;依赖数据库状态的校验在获取必要状态后完成,写入前必须满足业务条件。
2. 用 `orm.CreateUnitOfWork()`、`GetOrBeginTransaction()` 开启工作单元。事务内相关读写统一使用 `work.Orm`,不能混用根 ORM 使部分操作脱离事务。
3. 全部写入成功后 `Commit()`,未提交由工作单元释放回滚。不 catch 所有异常后继续提交,不封装同义事务工具。
4. 异步数据库调用传递 CancellationToken,提交前检查取消。数据库事务不覆盖外部通知、文件或第三方服务等副作用,按现有业务机制安排执行与失败处理,不默认增加额外基础设施。
5. 只更新允许修改的字段。需要避免丢更新的操作使用既有锁或条件更新,并检查受影响行数;不要把“必须影响一行”套到允许无变化的幂等更新上。
## 并发与幂等
- 涉及金额时沿用 decimal;允许值、精度和上限由业务入口判断,可能溢出的累计运算按需要使用 checked。
- 同一操作锁定多条资源时采用稳定顺序;在锁定或并发条件保护的有效状态上判断是否允许修改。进程内锁不能替代跨进程数据库一致性。
- 业务变更与必须同步保存的流水、审计记录在同一事务提交或回滚,记录实际操作者与业务目标。
- 幂等操作明确请求号的作用范围和重复请求结果,保留现有请求号、唯一约束及处理机制;不能把某模块的去重键当作全项目统一规则。
- 去重检查和写入使用能阻止竞态的数据库约束或锁机制,不能仅无保护地先查后写。重试按接口约定复用请求号。
- MySQL 表引擎、约束、隔离及并发行为需要对应环境验证,SQLite 只承担部分持久化和回滚验证。
## MediatR 管道
复用以下框架实现,按当前文件确认行为:
- `Core/MAX.Framework/Mediation/ValidationBehavior.cs`
- `Core/MAX.Framework/Mediation/TransactionBehavior.cs`
- `Core/MAX.Framework/Mediation/UnitOfWorkExecutionContext.cs`
- `Core/MAX.Framework/DependencyInjection/MediatRBehaviorServiceCollectionExtensions.cs`
命令携带业务输入 DTO,可信操作者由服务端上下文提供。普通 Service 方法不必建立同义命令。
- 通过 `[MediatRBehavior(typeof(...))]` 选择必要步骤,顺序由实际依赖决定。基础输入校验先于事务;需要事务内状态的业务步骤位于事务内部。
- `IValidatableRequest.Validate()` 调用 `RequestValidator.Validate(Input)` 完成 DTO 非空校验;长度、格式、范围及组合规则仍放在 Service 或已有业务处理入口,不迁入 DTO。后续 Handler 不重复基础校验。
- 不依赖数据库的业务参数校验在事务前执行,优先使用已有业务处理入口或适用的业务管道,不为单次判断机械新增一层。
- 事务内管道和 Handler 统一使用 `UnitOfWorkExecutionContext.Orm`。
- TransactionBehavior 统一提交、取消检查和回滚;当前实现拒绝嵌套事务命令。组合操作在同一事务用例中完成,不递归发送另一个事务命令。
- 已授权上下文是内部执行结果,不是客户端输入。保留必要上下文清理、执行顺序和重入约束,不把这些当作重复 DTO 校验删除。
- 在 `IStartup.Register(IServiceRegistrar)` 注册 Handler,宿主通过 AddMediatRAttributedBehaviors 扫描特性。确认模块程序集被现有机制发现,不另写扫描器或重复注册。
## 验证
按本次变化验证成功提交、任一步骤失败时整体回滚、权限或状态拒绝时无写入、重复请求处理,以及实际操作者的审计记录。涉及管道时使用真实管道入口验证顺序和注册;并发锁验证使用对应数据库,不以单线程 SQLite 测试替代。
+44
View File
@@ -0,0 +1,44 @@
# 项目规则入口
本文件适用于当前仓库及其子目录。规则正文统一维护在 `.agents/rules/`;以下路径均相对于仓库根目录。
规则文件使用 `.md` 格式,按以下入口要求加载。
## 主要编码规范
要有完整的代码注释
入口只校验一次,拒绝重复判空和滥用try/catch;单次逻辑不乱抽函数,低概率防御先征求同意。
## Ponytail:最小完整实现
来源:[Ponytail AGENTS.md](https://github.com/DietrichGebert/ponytail/blob/main/AGENTS.md),基于已安装的 5.3.2 版本合并。以下通用原则补充现有项目规则;涉及语言版本、编码规范、分层、依赖选择与验证方式时,以前述项目规则及具体任务要求为准。
You are a lazy senior developer. The best code is the code never written. You solve the whole problem with the least new code. End your reply with one or two lines: what you skipped or did not check, and any risk the user must know.
### Before you write
Read the task and the code it touches. List every place your change must reach: callers, tests, fixtures, config, exports. Check what your change could break for users: data it would destroy or expose, callers that stop working. That is scope. Extra features are not.
### The smallest complete change
Take the first option that fully works:
1. Does it need to exist? Skip features, options and flexibility nobody asked for, and name them in one line. A vague request ("build me X") gets the smallest version that does the core job.
2. Already in this codebase (a helper, component, service, pattern)? Use it the way the surrounding code does.
3. Standard library or a platform feature? Use it, unless the project has its own. A house component beats a native widget.
4. An installed dependency? Use it. Never add a dependency for a few lines.
5. Can it be one line a reader gets at a glance? One line.
6. Otherwise: the minimum code that works.
- Be lazy about the solution, never about the change itself: finish every part the task needs, including the callers, tests and fixtures your change breaks.
- No abstraction, wrapper, type conversion, option, config, boilerplate or "for later" code nobody asked for. Keep values in the form the platform already gives you. Deletion beats addition. Keep the structure the codebase already has: its layers, interfaces and conventions.
- The shortest working diff wins, once you know everything it must touch. A one-liner that needs decoding is not short.
- Comment only the why the code cannot show, in one line.
- Bug fix: before you edit, grep every caller of the function you touch, then fix the root cause once in the shared code.
- Code you move or merge keeps its error handling and validation.
- Between options of equal size, take the one that is correct on edge cases.
- Lazy code without its check is unfinished: new non-trivial logic (a branch, a loop, a parser, money or security, or a whole new script or app) leaves one small test or an assert-based self-check. Trivial changes need none.
- A shortcut with a known limit gets a code comment in this form: `shortcut: <the limit>, <when to upgrade>`.
Never cut: validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs, anything the user asked for.
@@ -1,151 +0,0 @@
using Asp.Versioning.OpenApi;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using System.Security.Claims;
using System.Text;
namespace MAX.Framework.DependencyInjection;
/// <summary>
/// JWT 认证服务注册入口(由宿主 Program.cs 调用)。
/// </summary>
public static class JwtServiceCollectionExtensions
{
/// <summary>
/// 注册 JWT 认证:
/// <list type="number">
/// <item>绑定 <c>Jwt</c> 配置节到 <see cref="JwtOptions"/>,并在启动时校验签名密钥长度;</item>
/// <item>注册 <see cref="ITokenService"/>(令牌生成/校验)与 <see cref="ICurrentUser"/>(当前用户访问器);</item>
/// <item>注册 <see cref="JwtBearerDefaults"/> 认证,认证失败/授权失败统一返回 <see cref="Dtos.ApiResult{T}"/> 格式;</item>
/// <item>注册 <see cref="IOnlineUserStore"/>(数据库会话存储):多端登录、会话查询、退出撤销;</item>
/// <item>为 OpenAPI 文档声明 Bearer(JWT) 安全方案,并对标记了 [Authorize] 的接口声明需要携带令牌。</item>
/// </list>
/// </summary>
/// <remarks>
/// 需要在请求管道中调用 <c>app.UseAuthentication()</c>(位于 <c>app.UseAuthorization()</c> 之前)。
/// </remarks>
public static IServiceCollection AddStoreHubJwt(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// JwtOptions:绑定 Jwt 配置节,启动时校验 HS256 签名密钥长度
services.AddOptions<JwtOptions>()
.Bind(configuration.GetSection(JwtOptions.SectionName))
.Validate(
o => !string.IsNullOrWhiteSpace(o.SigningKey)
&& Encoding.UTF8.GetByteCount(o.SigningKey) >= 32,
"Jwt:SigningKey 长度不足,HS256 签名密钥至少需要 32 字节(256位)。")
.ValidateOnStart();
services.AddHttpContextAccessor();
// 令牌服务与当前用户访问器
services.AddSingleton<ITokenService, TokenService>();
services.AddScoped<ICurrentUser, CurrentUser>();
// 使用宿主注册的 IFreeSql 持久化会话。
services.AddSingleton<IOnlineUserStore, OnlineUserStore>();
// JwtBearer 认证
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
JwtOptions jwt = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
options.TokenValidationParameters = jwt.ToTokenValidationParameters();
options.Events = new JwtBearerEvents
{
OnChallenge = JwtBearerResponseHandler.OnChallenge,
OnForbidden = JwtBearerResponseHandler.OnForbidden,
OnMessageReceived = context =>
{
// 固定测试凭据只在 Development 环境生效,不依赖 JWT 过期时间或在线会话。
var environment = context.HttpContext.RequestServices.GetRequiredService<IHostEnvironment>();
var development = configuration.GetSection("Jwt:DevelopmentToken");
var token = development["Token"];
var userName = development["UserName"];
var authorization = context.Request.Headers.Authorization.ToString();
if (environment.IsDevelopment()
&& development.GetValue<bool>("Enabled")
&& !string.IsNullOrWhiteSpace(token)
&& !string.IsNullOrWhiteSpace(userName)
&& authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
&& string.Equals(authorization[7..].Trim(), token, StringComparison.Ordinal))
{
context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(JwtClaimTypes.UserName, userName),
new Claim(JwtClaimTypes.NickName, "开发测试用户")],
JwtBearerDefaults.AuthenticationScheme));
context.Success();
}
return Task.CompletedTask;
},
// 会话校验:必须未过期且未撤销
OnTokenValidated = context =>
{
IOnlineUserStore onlineUserStore = context.HttpContext.RequestServices.GetRequiredService<IOnlineUserStore>();
ClaimsPrincipal? principal = context.Principal;
string? userName = principal?.FindFirst(JwtClaimTypes.UserName)?.Value;
string? jti = principal?.FindFirst(JwtClaimTypes.Jti)?.Value;
if (string.IsNullOrWhiteSpace(userName)
|| string.IsNullOrWhiteSpace(jti)
|| !onlineUserStore.IsTokenActive(userName, jti))
{
context.Fail(new SecurityTokenException("登录已失效,请重新登录"));
}
return Task.CompletedTask;
},
};
});
// OpenAPI:声明 Bearer(JWT) 安全方案(Scalar 页面即可显示 Authorize/输入 token),
// 并仅对标记了 [Authorize] 的接口声明需要携带令牌(与宿主的 AddOpenApi 配置合并生效)
services.Configure<VersionedOpenApiOptions>(options =>
{
// 在 OpenAPI 文档中声明 Bearer(JWT) 安全方案
options.Document.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes.TryAdd("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "登录接口返回的 accessToken,直接粘贴令牌内容即可(无需手动加 Bearer 前缀)。",
});
return Task.CompletedTask;
});
// 仅对标记了 [Authorize] 的接口声明需要携带令牌
options.Document.AddOperationTransformer((operation, context, _) =>
{
IList<object> metadata = context.Description.ActionDescriptor.EndpointMetadata;
bool requiresAuth = metadata.OfType<IAuthorizeData>().Any()
&& !metadata.OfType<IAllowAnonymous>().Any();
if (requiresAuth)
{
operation.Security =
[
new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", context.Document, "")] = [],
},
];
}
return Task.CompletedTask;
});
});
return services;
}
}
@@ -0,0 +1,72 @@
using Asp.Versioning.OpenApi;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
namespace MAX.Framework.DependencyInjection;
public static class TokenAuthServiceCollectionExtensions
{
// Compatibility entry point; authentication no longer uses JWT.
public static IServiceCollection AddStoreHubJwt(this IServiceCollection services, IConfiguration configuration)
=> services.AddStoreHubTokenAuth(configuration);
public static IServiceCollection AddStoreHubTokenAuth(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<TokenAuthOptions>().Bind(configuration.GetSection("TokenAuth"))
.Validate(o => o.IdleDays > 0 && o.IdleDays <= 36500 && o.AbsoluteMaxDays > 0 && o.AbsoluteMaxDays <= 36500
&& o.RenewIntervalMinutes > 0 && o.RenewIntervalMinutes < o.IdleDays * 1440
&& o.CacheSeconds >= 0 && o.CacheSeconds <= 86400 && o.MaxDevicesPerUser >= 0,
"TokenAuth 配置无效:有效期必须为正,续期间隔必须小于闲置有效期,缓存和设备数不能为负。")
.ValidateOnStart();
services.AddMemoryCache();
services.AddSingleton(TimeProvider.System);
services.AddHttpContextAccessor();
services.AddSingleton<TokenService>();
services.AddSingleton<ITokenService>(sp => sp.GetRequiredService<TokenService>());
services.AddSingleton<IOnlineUserStore, OnlineUserStore>();
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddAuthentication("Bearer").AddScheme<AuthenticationSchemeOptions, TokenAuthenticationHandler>("Bearer", _ => { });
services.Configure<VersionedOpenApiOptions>(options =>
{
// 在 OpenAPI 文档中声明 Bearer 安全方案
options.Document.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes.TryAdd("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "Opaque",
Description = "登录接口返回的 accessToken,直接粘贴令牌内容即可(无需手动加 Bearer 前缀)。",
});
return Task.CompletedTask;
});
// 仅对标记了 [Authorize] 的接口声明需要携带令牌
options.Document.AddOperationTransformer((operation, context, _) =>
{
IList<object> metadata = context.Description.ActionDescriptor.EndpointMetadata;
bool requiresAuth = metadata.OfType<IAuthorizeData>().Any()
&& !metadata.OfType<IAllowAnonymous>().Any();
if (requiresAuth)
{
operation.Security =
[
new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", context.Document, "")] = [],
},
];
}
return Task.CompletedTask;
});
});
return services;
}
}
-1
View File
@@ -14,7 +14,6 @@
<PackageReference Include="FreeSql" Version="3.5.311" />
<PackageReference Include="FreeSql.Provider.MySql" Version="3.5.311" />
<PackageReference Include="FreeSql.Repository" Version="3.5.311" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
+2 -2
View File
@@ -24,14 +24,14 @@ public class TokenResult
}
/// <summary>
/// JWT 令牌服务:生成与校验访问令牌。
/// 数据库令牌服务:生成与校验访问令牌。
/// </summary>
public interface ITokenService
{
/// <summary>
/// 根据声明集合生成访问令牌
/// </summary>
TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null);
TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null, string? deviceId = null, string? deviceName = null);
/// <summary>
/// 校验令牌,校验失败返回 null
@@ -1,45 +0,0 @@
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using System.Text;
using System.Text.Json;
namespace MAX.Framework.Security;
/// <summary>
/// JwtBearer 认证失败/授权失败时,统一返回 <see cref="ApiResult{T}"/> 格式的 JSON 响应。
/// </summary>
internal static class JwtBearerResponseHandler
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// 未携带令牌或令牌无效(401)
/// </summary>
public static Task OnChallenge(JwtBearerChallengeContext context)
{
context.HandleResponse();
return WriteAsync(context.HttpContext, StatusCodes.Status401Unauthorized, "未授权:请先登录或携带有效的访问令牌");
}
/// <summary>
/// 已认证但无权限(403)
/// </summary>
public static Task OnForbidden(ForbiddenContext context)
{
return WriteAsync(context.HttpContext, StatusCodes.Status403Forbidden, "禁止访问:当前账号没有权限执行该操作");
}
private static async Task WriteAsync(HttpContext httpContext, int statusCode, string message)
{
httpContext.Response.StatusCode = statusCode;
httpContext.Response.ContentType = "application/json; charset=utf-8";
string body = JsonSerializer.Serialize(ApiResult.Fail(message, statusCode), JsonOptions);
await httpContext.Response.WriteAsync(body, Encoding.UTF8);
}
}
-74
View File
@@ -1,74 +0,0 @@
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace MAX.Framework.Security;
/// <summary>
/// JWT 配置选项,默认绑定配置节 <c>Jwt</c>。
/// </summary>
public class JwtOptions
{
/// <summary>
/// 配置节名称
/// </summary>
public const string SectionName = "Jwt";
/// <summary>
/// 签发者
/// </summary>
public string Issuer { get; set; } = "StoreHub";
/// <summary>
/// 受众
/// </summary>
public string Audience { get; set; } = "StoreHub.Client";
/// <summary>
/// 签名密钥(HS256,至少 32 字节)
/// </summary>
public string SigningKey { get; set; } = "StoreHub-Development-SigningKey-0123456789abcdef0123456789abcdef";
/// <summary>
/// 令牌有效期(分钟)
/// </summary>
public int ExpiresMinutes { get; set; } = 120;
/// <summary>
/// 时钟偏移(分钟),容忍客户端与服务器的时间偏差
/// </summary>
public int ClockSkewMinutes { get; set; } = 5;
/// <summary>
/// 是否校验签发者
/// </summary>
public bool ValidateIssuer { get; set; } = true;
/// <summary>
/// 是否校验受众
/// </summary>
public bool ValidateAudience { get; set; } = true;
/// <summary>
/// 是否校验有效期
/// </summary>
public bool ValidateLifetime { get; set; } = true;
/// <summary>
/// 根据当前选项构建 <see cref="TokenValidationParameters"/>,用于 JwtBearer 中间件与令牌校验。
/// </summary>
public TokenValidationParameters ToTokenValidationParameters()
{
byte[] keyBytes = Encoding.UTF8.GetBytes(SigningKey);
return new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ValidateIssuer = ValidateIssuer,
ValidIssuer = Issuer,
ValidateAudience = ValidateAudience,
ValidAudience = Audience,
ValidateLifetime = ValidateLifetime,
ClockSkew = TimeSpan.FromMinutes(ClockSkewMinutes),
};
}
}
@@ -1,23 +1,17 @@
using FreeSql.DataAnnotations;
namespace MAX.Framework.Security;
/// <summary>持久化登录会话;每次登录拥有独立 jti,不保存完整 JWT。</summary>
[Table(Name = "wxxd.auth_login_session")]
/// <summary>登录会话展示 DTO;Jti 为数据库 Token 记录 ID。</summary>
public class OnlineUserInfo
{
[Column(IsPrimary = true, StringLength = 32)]
public string Jti { get; set; } = string.Empty;
[Column(StringLength = 255)]
public string UserName { get; set; } = string.Empty;
[Column(StringLength = 255)]
public string NickName { get; set; } = string.Empty;
/// <summary>服务端观察到的客户端 IP;反向代理需配置受信任的转发头。</summary>
[Column(StringLength = 45)]
public string LoginIp { get; set; } = string.Empty;
/// <summary>UTC 登录时间。</summary>
public DateTime LoginTime { get; set; }
/// <summary>UTC 到期时间,与 JWT exp 对齐。</summary>
/// <summary>UTC 当前滑动到期时间。</summary>
public DateTime ExpiresAt { get; set; }
/// <summary>UTC 撤销时间;保留记录供后续面板查询。</summary>
public DateTime? RevokedAt { get; set; }
+14 -39
View File
@@ -1,47 +1,22 @@
namespace MAX.Framework.Security;
/// <summary>数据库登录会话存储,支持同账号多个独立会话。</summary>
public class OnlineUserStore(IFreeSql db) : IOnlineUserStore
/// <summary>Compatibility facade for existing session management callers.</summary>
public sealed class OnlineUserStore(IFreeSql db, TokenService tokens) : IOnlineUserStore
{
public void SignIn(OnlineUserInfo info)
{
ArgumentNullException.ThrowIfNull(info);
ArgumentException.ThrowIfNullOrWhiteSpace(info.UserName);
ArgumentException.ThrowIfNullOrWhiteSpace(info.Jti);
if (info.ExpiresAt <= info.LoginTime || info.RevokedAt is not null)
throw new ArgumentException("登录会话的有效期或撤销状态无效", nameof(info));
db.Insert(info).ExecuteAffrows();
}
public bool IsTokenActive(string userName, string jti)
{
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti)) return false;
var now = DateTime.UtcNow;
return db.Select<OnlineUserInfo>().Any(x => x.UserName == userName && x.Jti == jti
&& x.RevokedAt == null && x.ExpiresAt > now);
}
public void SignOut(string userName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
db.Update<OnlineUserInfo>().Set(x => x.RevokedAt, DateTime.UtcNow)
.Where(x => x.UserName == userName && x.RevokedAt == null).ExecuteAffrows();
}
public void SignOutSession(string userName, string jti)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
ArgumentException.ThrowIfNullOrWhiteSpace(jti);
db.Update<OnlineUserInfo>().Set(x => x.RevokedAt, DateTime.UtcNow)
.Where(x => x.UserName == userName && x.Jti == jti && x.RevokedAt == null).ExecuteAffrows();
}
public void SignIn(OnlineUserInfo info) => throw new NotSupportedException("请通过 ITokenService 签发数据库令牌");
public void SignOut(string userName) => tokens.Revoke(userName);
public void SignOutSession(string userName, string jti) => tokens.Revoke(userName, jti);
public bool IsTokenActive(string userName, string jti) => GetOnlineUsers().Any(x => x.UserName == userName && x.Jti == jti);
public IReadOnlyList<OnlineUserInfo> GetOnlineUsers()
{
var now = DateTime.UtcNow;
return db.Select<OnlineUserInfo>().Where(x => x.RevokedAt == null && x.ExpiresAt > now)
.OrderByDescending(x => x.LoginTime).ToList();
var now = tokens.CurrentTime;
var cutoff = tokens.AbsoluteCutoff;
return db.Select<UserToken, TokenAccount>()
.InnerJoin((t, u) => t.UserName == u.UserName)
.Where((t, u) => !t.Revoked && t.ExpiresAt > now && t.CreatedAt > cutoff)
.OrderByDescending((t, u) => t.CreatedAt)
.ToList((t, u) => new OnlineUserInfo { Jti = t.Id.ToString(), UserName = u.UserName,
NickName = u.NickName, LoginIp = t.Ip ?? "", LoginTime = t.CreatedAt, ExpiresAt = t.ExpiresAt });
}
public int Count => GetOnlineUsers().Count;
}
+22 -28
View File
@@ -1,34 +1,28 @@
# 登录会话数据库存储
# 数据库 Token 认证
会话使用 `ConnectionStrings:DefaultConnection` 指定的 MySQL 数据库,表名 `auth_login_session`。
FreeSql 初始化时仅同步此表结构;首次使用需要建表/索引权限。没有运行针对业务数据库的迁移或测试。
部署前执行同目录 `user_tokens.sql`,在现有业务库 `wxxd` 中建立唯一的新表;应用不自动修改表结构。
启动:`dotnet run --project Store.API/Store.Api.csproj`。宿主使用 `AddStoreHubTokenAuth`,管道继续使用 `UseAuthentication` / `UseAuthorization`。
## 行为
登录接口仍返回 `tokenType`、`token`、`expiresInMinutes`,可选传入 `deviceId`、`deviceName`(各最多 100 字符)。
客户端按 `Authorization: Bearer {token}` 发送;Token 是 32 字节安全随机数的标准 Base64(44 字符),数据库仅存 SHA256 十六进制哈希。
`expiresInMinutes` 表示签发时的闲置有效期;服务端使用滑动过期,客户端无需刷新令牌。
- 每次登录插入独立的 `Jti` 主键,保存用户名、昵称、登录 IP、UTC 登录时间、UTC 到期时间和撤销时间。不存储完整 JWT。
- 同账号可以同时登录。Logout 仅撤销当前 JWT 对应的会话;修改密码、禁用和删除账号等现有安全操作仍撤销该账号全部会话。
- 每次认证查询数据库,只有会话未过期且未撤销才接受 JWT。保留现有有效期配置(当前 2880 分钟),不新增自动刷新。
- 应用重启后,只要数据库记录、签名密钥、Issuer 和 Audience 保持一致,未过期且未撤销的 Token 可继续使用。
- 从旧内存版本升级时,已有会话不能自动迁移,用户需要重新登录一次。
- OnlineUsers 返回有效会话,包含 LoginIp;Count 是会话数,同账号可能有多条。它不代表浏览器仍然打开。现有授权规则保持不变;正式管理面板应按管理权限提供分页查询。
- 退出和过期不删除记录,以便后续查询历史。后续可根据实际留存周期添加清理任务;本次没有无限查询历史的管理接口或面板。
`TokenAuth` 默认配置:IdleDays=7、AbsoluteMaxDays=30、RenewIntervalMinutes=10、CacheSeconds=30、MaxDevicesPerUser=0。
全部时间按 UTC 保存(DATETIME 不带时区);每次使用达到续期间隔才条件更新到期时间,绝不超过创建时间加绝对期限。
内存缓存使用固定到期时间,命中不会延长缓存寿命。缓存期间仍检查闲置及绝对期限。
## IP 与代理
每次登录创建新记录;相同非空 deviceId 会替换其旧记录。未提供 deviceId 的登录按独立设备计数。
设备数为 0 时无限制,为 1 时单点登录,为 N 时保留最多 N 个有效会话,超限撤销最早的会话。
签发在事务中锁定现有用户行,使多实例的并发登录也遵守数量限制。MySQL 业务用户表需使用 InnoDB。
退出当前会话、修改密码后的全部会话撤销以及设备替换统一更新 Revoked 并清除当前实例缓存。
IP 来自 HttpContext.Connection.RemoteIpAddress,不直接信任客户端提交的 X-Forwarded-For。
若使用 Nginx、IIS 等代理,应按部署拓扑配置 ASP.NET Core ForwardedHeaders 和可信代理,在认证前运行;否则记录的可能是代理地址。
多实例下其他实例最多延迟 CacheSeconds 秒获知撤销;要求每次查库时设置 CacheSeconds=0。
用户信息在缓存未命中时从实际 `wxxd.user` 表读取 id/user_name/nick_name;当前用户表没有 IsEnabled 或 Role 字段,因此未假设这些字段或新增角色策略。
数据库故障向上抛出,由宿主处理错误,认证不会在数据库不可用时绕过校验。
## 数据库与 Redis
当前仅需 MySQL,不需要 Redis。每次受保护请求会执行按主键索引的会话查询。
未来实测认证查询成为瓶颈时可评估 Redis,但必须处理退出/禁用时的缓存失效,不能让缓存继续接受已经撤销的会话。
Redis 自身重启的数据保留取决于 RDB/AOF 持久化与故障恢复配置;不能把默认内存缓存等同于可靠数据库记录。
## 验证
运行 `dotnet test Tests/Store.Security.Tests/Store.Security.Tests.csproj`。
独立 SQLite 测试覆盖关闭/重建数据库连接后的令牌有效性、同账号多会话、IPv4/IPv6 留存、定向退出、撤销后的重启、全部撤销、会话过期和开发测试凭据。
运行 `dotnet build Store.API/Store.Api.csproj` 构建 API。
原 Store.Users.Tests 当前存在既有枚举类型及方法签名不匹配,不能整体编译;新增认证测试独立运行。
生产 MySQL 建表及真实代理 IP 仍需部署环境验证。可使用代码审查进一步检查。
切换后旧 JWT 失效,用户需要重新登录。旧 auth_login_session 表不再使用,保留供人工归档。
开发固定凭据配置位于 `TokenAuth:DevelopmentToken`(Enabled、Token、UserName):基础配置禁用,`appsettings.Development.json` 开启,仅 Development 环境生效。
使用相同的 Bearer 请求头即可测试;固定凭据不查询数据库、不创建会话、不参与滑动过期和退出吊销。关闭 Enabled 或修改 Token 可使其失效。
保留 AddStoreHubJwt 名称作为注册入口的兼容别名;JwtClaimTypes 仅保留现有声明字段名称。
校验:`dotnet test Tests/Store.Security.Tests/Store.Security.Tests.csproj`,使用独立 SQLite 临时库,不连接业务 MySQL。
测试涵盖重启持久化、滑动/绝对过期、设备限制、缓存撤销与开发凭据的环境限制;MySQL 行锁行为仍需部署环境验证。
@@ -0,0 +1,10 @@
namespace MAX.Framework.Security;
public sealed class TokenAuthOptions
{
public int IdleDays { get; set; } = 7;
public int AbsoluteMaxDays { get; set; } = 30;
public int RenewIntervalMinutes { get; set; } = 10;
public int CacheSeconds { get; set; } = 30;
public int MaxDevicesPerUser { get; set; }
}
@@ -0,0 +1,50 @@
using System.Text.Encodings.Web;
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System.Security.Claims;
namespace MAX.Framework.Security;
public sealed class TokenAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, ITokenService tokens,
IConfiguration configuration, IHostEnvironment environment)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var header = Request.Headers.Authorization.ToString();
if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return AuthenticateResult.NoResult();
var token = header[7..].Trim();
// 固定凭据仅供开发调试,生产环境即使误开启配置也不能使用。
var development = configuration.GetSection("TokenAuth:DevelopmentToken");
if (environment.IsDevelopment() && development.GetValue<bool>("Enabled")
&& !string.IsNullOrWhiteSpace(development["Token"])
&& !string.IsNullOrWhiteSpace(development["UserName"])
&& string.Equals(token, development["Token"], StringComparison.Ordinal))
{
var identity = new ClaimsIdentity([
new Claim(JwtClaimTypes.UserName, development["UserName"]!),
new Claim(JwtClaimTypes.NickName, "开发测试用户")], Scheme.Name);
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name));
}
var principal = await tokens.ValidateTokenAsync(token);
return principal == null ? AuthenticateResult.Fail("登录已失效,请重新登录")
: AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = 401;
Response.Headers.WWWAuthenticate = "Bearer";
return Response.WriteAsJsonAsync(ApiResult.Fail("未授权:请先登录或携带有效的访问令牌", 401));
}
protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
{
Response.StatusCode = 403;
return Response.WriteAsJsonAsync(ApiResult.Fail("禁止访问:当前账号没有权限执行该操作", 403));
}
}
+136 -117
View File
@@ -1,129 +1,148 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace MAX.Framework.Security;
/// <summary>
/// 基于 <see cref="JwtOptions"/> 的 JWT 令牌服务。
/// </summary>
public class TokenService : ITokenService
/// <summary>Opaque database tokens. All timestamps use UTC.</summary>
public sealed class TokenService(IFreeSql db, IOptions<TokenAuthOptions> options, IMemoryCache cache,
TimeProvider clock) : ITokenService
{
private readonly JwtOptions _options;
private readonly IOnlineUserStore _onlineUserStore;
private readonly TokenAuthOptions settings = options.Value;
// Serialize local cache reads/writes and revocations to prevent stale cache resurrection.
internal object Gate { get; } = new();
private sealed record CachedToken(UserToken Token, TokenAccount Account);
private static string Key(string hash) => "token-auth:" + hash;
private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
// MySQL DATETIME is timezone-free; preserve UTC wall time without provider-local conversion.
private DateTime Now => DateTime.SpecifyKind(DateTimeOffset.FromUnixTimeSeconds(clock.GetUtcNow().ToUnixTimeSeconds()).UtcDateTime, DateTimeKind.Unspecified);
public TokenService(IOptions<JwtOptions> options, IOnlineUserStore onlineUserStore)
{
_options = options.Value;
_onlineUserStore = onlineUserStore;
}
internal DateTime CurrentTime => Now;
internal DateTime AbsoluteCutoff => Now.AddDays(-settings.AbsoluteMaxDays);
/// <inheritdoc />
public TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null)
{
ArgumentNullException.ThrowIfNull(claims);
public TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null,
string? deviceId = null, string? deviceName = null)
{
ArgumentNullException.ThrowIfNull(claims);
var name = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserName)?.Value;
ArgumentException.ThrowIfNullOrWhiteSpace(name);
if (deviceId?.Length > 100 || deviceName?.Length > 100 || loginIp?.Length > 64)
throw new ArgumentException("设备信息或 IP 超出允许长度");
deviceId = string.IsNullOrWhiteSpace(deviceId) ? null : deviceId;
lock (Gate)
{
var now = Now;
var plain = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var expires = now.AddDays(Math.Min(settings.IdleDays, settings.AbsoluteMaxDays));
var revokedHashes = new List<string>();
db.Transaction(() =>
{
// Lock the account row so concurrent logins on different instances respect the limit.
var account = db.Select<TokenAccount>().Where(x => x.UserName == name).ForUpdate().First()
?? throw new InvalidOperationException("用户不存在,无法签发令牌");
var cutoff = now.AddDays(-settings.AbsoluteMaxDays);
var active = db.Select<UserToken>().Where(x => x.UserName == account.UserName && !x.Revoked
&& x.ExpiresAt > now && x.CreatedAt > cutoff).OrderBy(x => x.CreatedAt).OrderBy(x => x.Id).ToList();
var remove = active.Where(x => deviceId != null && x.DeviceId == deviceId).ToList();
var remaining = active.Except(remove).ToList();
if (settings.MaxDevicesPerUser > 0)
remove.AddRange(remaining.Take(Math.Max(0, remaining.Count - settings.MaxDevicesPerUser + 1)));
foreach (var old in remove)
{
db.Update<UserToken>().Set(x => x.Revoked, true).Where(x => x.Id == old.Id).ExecuteAffrows();
revokedHashes.Add(old.TokenHash);
}
db.Insert(new UserToken { TokenHash = Hash(plain), UserName = account.UserName, DeviceId = deviceId,
DeviceName = deviceName, Ip = loginIp, CreatedAt = now, LastUsedAt = now, ExpiresAt = expires })
.ExecuteAffrows();
});
foreach (var hash in revokedHashes) cache.Remove(Key(hash));
return new TokenResult { Token = plain, ExpiresInMinutes = (uint)(expires - now).TotalMinutes };
}
}
DateTime now = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds()).UtcDateTime;
DateTime expires = now.AddMinutes(_options.ExpiresMinutes);
string jti = Guid.NewGuid().ToString("N");
public Task<ClaimsPrincipal?> ValidateTokenAsync(string token)
{
// Only accept the canonical Base64 representation of 32 bytes.
Span<byte> bytes = stackalloc byte[32];
if (token is null || token.Length != 44 || !Convert.TryFromBase64String(token, bytes, out var length)
|| length != 32 || Convert.ToBase64String(bytes) != token)
return Task.FromResult<ClaimsPrincipal?>(null);
lock (Gate)
{
var hash = Hash(token);
var now = Now;
cache.TryGetValue<CachedToken>(Key(hash), out var entry);
if (entry == null)
{
var row = db.Select<UserToken>().Where(x => x.TokenHash == hash).First();
if (row == null || row.Revoked || row.ExpiresAt <= now || row.CreatedAt.AddDays(settings.AbsoluteMaxDays) <= now)
return Task.FromResult<ClaimsPrincipal?>(null);
var account = db.Select<TokenAccount>().Where(x => x.UserName == row.UserName).First();
if (account == null) return Task.FromResult<ClaimsPrincipal?>(null);
entry = new(row, account);
}
var t = entry.Token;
var absolute = t.CreatedAt.AddDays(settings.AbsoluteMaxDays);
if (t.Revoked || t.ExpiresAt <= now || absolute <= now)
{
cache.Remove(Key(hash));
return Task.FromResult<ClaimsPrincipal?>(null);
}
if (now - t.LastUsedAt >= TimeSpan.FromMinutes(settings.RenewIntervalMinutes))
{
var next = now.AddDays(settings.IdleDays);
if (next > absolute) next = absolute;
// Conditional update never renews expired/revoked tokens or overwrites a newer renewal.
var changed = db.Update<UserToken>().Set(x => x.LastUsedAt, now).Set(x => x.ExpiresAt, next)
.Where(x => x.Id == t.Id && !x.Revoked && x.ExpiresAt > now && x.LastUsedAt == t.LastUsedAt)
.ExecuteAffrows();
if (changed == 0)
{
cache.Remove(Key(hash));
var current = db.Select<UserToken>().Where(x => x.Id == t.Id).First();
if (current == null || current.Revoked || current.ExpiresAt <= now
|| current.CreatedAt.AddDays(settings.AbsoluteMaxDays) <= now)
return Task.FromResult<ClaimsPrincipal?>(null);
t = current;
entry = new(current, entry.Account);
}
else
{
t.LastUsedAt = now;
t.ExpiresAt = next;
}
}
if (settings.CacheSeconds > 0 && !cache.TryGetValue(Key(hash), out object? _))
{
var ttl = TimeSpan.FromSeconds(settings.CacheSeconds);
if (ttl > t.ExpiresAt - now) ttl = t.ExpiresAt - now;
if (ttl > absolute - now) ttl = absolute - now;
cache.Set(Key(hash), entry, ttl);
}
ClaimsPrincipal principal = new(new ClaimsIdentity([
new Claim(ClaimTypes.NameIdentifier, t.UserName.ToString()),
new Claim(JwtClaimTypes.UserName, entry.Account.UserName),
new Claim(JwtClaimTypes.NickName, entry.Account.NickName),
new Claim(JwtClaimTypes.Jti, t.Id.ToString())], "Bearer"));
return Task.FromResult<ClaimsPrincipal?>(principal);
}
}
List<Claim> allClaims = [.. claims, new Claim(JwtClaimTypes.Jti, jti)];
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.SigningKey));
JsonWebTokenHandler handler = new();
string token = handler.CreateToken(new SecurityTokenDescriptor
{
Issuer = _options.Issuer,
Audience = _options.Audience,
Claims = ToClaimDictionary(allClaims),
NotBefore = now,
Expires = expires,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
});
// 持久化独立登录会话
RegisterSession(allClaims, jti, now, expires, loginIp);
return new TokenResult
{
Token = token,
ExpiresInMinutes = (uint)_options.ExpiresMinutes,
};
}
/// <inheritdoc />
public async Task<ClaimsPrincipal?> ValidateTokenAsync(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
try
{
JsonWebTokenHandler handler = new();
TokenValidationResult result = await handler.ValidateTokenAsync(token, _options.ToTokenValidationParameters());
if (!result.IsValid)
{
return null;
}
ClaimsPrincipal principal = new(result.ClaimsIdentity);
// 会话必须仍有效(未到期、未被撤销)
string? userName = principal.FindFirst(JwtClaimTypes.UserName)?.Value;
string? jti = principal.FindFirst(JwtClaimTypes.Jti)?.Value;
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti) || !_onlineUserStore.IsTokenActive(userName, jti))
{
return null;
}
return principal;
}
catch
{
return null;
}
}
/// <summary>
/// 将令牌声明注册为在线会话。
/// </summary>
private void RegisterSession(IEnumerable<Claim> claims, string jti, DateTime loginTime, DateTime expiresAt, string? loginIp)
{
string? userName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserName)?.Value;
if (string.IsNullOrWhiteSpace(userName))
{
return; // 未携带登录账户的令牌不纳入在线会话管理
}
_onlineUserStore.SignIn(new OnlineUserInfo
{
UserName = userName,
NickName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.NickName)?.Value ?? string.Empty,
Jti = jti,
LoginIp = loginIp ?? string.Empty,
LoginTime = loginTime,
ExpiresAt = expiresAt,
});
}
/// <summary>
/// 将声明集合转换为 <see cref="SecurityTokenDescriptor.Claims"/> 所需的字典;
/// 相同类型的多个声明合并为数组。
/// </summary>
private static Dictionary<string, object> ToClaimDictionary(IEnumerable<Claim> claims)
{
Dictionary<string, object> result = new(StringComparer.Ordinal);
foreach (IGrouping<string, Claim> group in claims.GroupBy(c => c.Type, StringComparer.Ordinal))
{
object[] values = group.Select(c => (object)c.Value).ToArray();
result[group.Key] = values.Length == 1 ? values[0] : values;
}
return result;
}
internal void Revoke(string userName, string? sessionId = null)
{
lock (Gate)
{
var user = db.Select<TokenAccount>().Where(x => x.UserName == userName).First();
if (user == null) return;
var rows = db.Select<UserToken>().Where(x => x.UserName == user.UserName && !x.Revoked).ToList();
if (sessionId != null) rows = rows.Where(x => x.Id.ToString() == sessionId).ToList();
var ids = rows.Select(x => x.Id).ToArray();
if (ids.Length == 0) return;
db.Update<UserToken>().Set(x => x.Revoked, true).Where(x => ids.Contains(x.Id)).ExecuteAffrows();
foreach (var row in rows) cache.Remove(Key(row.TokenHash));
}
}
}
+35
View File
@@ -0,0 +1,35 @@
using FreeSql.DataAnnotations;
namespace MAX.Framework.Security;
[Table(Name = "wxxd.user_tokens")]
public sealed class UserToken
{
[Column(IsPrimary = true, IsIdentity = true)]
public long Id { get; set; }
[Column(StringLength = 64)]
public string TokenHash { get; set; } = "";
[Column(StringLength = 100)]
public string UserName { get; set; }
[Column(StringLength = 100)]
public string? DeviceId { get; set; }
[Column(StringLength = 100)]
public string? DeviceName { get; set; }
[Column(StringLength = 64)]
public string? Ip { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime LastUsedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public bool Revoked { get; set; }
}
// Read-only mapping of the existing user table; never synchronize this projection.
[Table(Name = "wxxd.user", DisableSyncStructure = true)]
public sealed class TokenAccount
{
[Column(Name = "id", IsPrimary = true)]
public int Id { get; set; }
[Column(Name = "user_name")]
public string UserName { get; set; } = "";
[Column(Name = "nick_name")]
public string NickName { get; set; } = "";
}
@@ -0,0 +1,17 @@
-- Run once before deploying the new authentication service. No existing business tables are modified.
CREATE TABLE IF NOT EXISTS wxxd.user_tokens (
Id BIGINT NOT NULL AUTO_INCREMENT,
TokenHash VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
UserId INT NOT NULL,
DeviceId VARCHAR(100) NULL,
DeviceName VARCHAR(100) NULL,
Ip VARCHAR(64) NULL,
CreatedAt DATETIME NOT NULL,
LastUsedAt DATETIME NOT NULL,
ExpiresAt DATETIME NOT NULL,
Revoked TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (Id),
UNIQUE KEY IX_UserTokens_TokenHash (TokenHash),
KEY IX_UserTokens_UserId (UserId),
KEY IX_UserTokens_ExpiresAt (ExpiresAt)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -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;
+6 -2
View File
@@ -29,7 +29,7 @@ namespace Store.Api.Controllers
}
/// <summary>
/// 登录:校验用户名/密码,签发 JWT(同账号支持多个独立会话)
/// 登录:校验用户名/密码,签发数据库 Token(同账号支持多个独立会话)
/// </summary>
[HttpPost]
[EndpointSummary("用户登录")]
@@ -51,7 +51,9 @@ namespace Store.Api.Controllers
new(JwtClaimTypes.UserName, user.UserName),
};
TokenResult token = _tokenService.CreateToken(claims, HttpContext.Connection.RemoteIpAddress?.ToString());
if (request.DeviceId?.Length > 100 || request.DeviceName?.Length > 100)
return ApiResult.Fail("设备信息最多 100 字符", 400);
TokenResult token = _tokenService.CreateToken(claims, HttpContext.Connection.RemoteIpAddress?.ToString(), request.DeviceId, request.DeviceName);
return ApiResult.Ok(token);
}
@@ -108,5 +110,7 @@ namespace Store.Api.Controllers
public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
public string? DeviceId { get; set; }
public string? DeviceName { get; set; }
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ public partial class Program
// 模块服务注册阶段(等价于 builder.Services.AddStoreHub(builder.Configuration, builder.Environment),额外把配置/环境交给模块)
builder.AddStore();
builder.Services.AddFreeSqlDB();
// JWT 验证:注册 JwtBearer 认证 + 令牌服务 + OpenAPI Bearer 安全方案(配置节 Jwt)
builder.Services.AddStoreHubJwt(builder.Configuration);
// 数据库 Token 认证,配置节 TokenAuth
builder.Services.AddStoreHubTokenAuth(builder.Configuration);
// Add services to the container.
//builder.Services.Configure<Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerOptions>(options =>
+2 -4
View File
@@ -1,9 +1,7 @@
{
"Jwt": {
"TokenAuth": {
"DevelopmentToken": {
"Enabled": true,
"Token": "store-local-dev-7f2c9e4a6b814d38a5910e62c4f837bd",
"UserName": "18990991234"
"Enabled": true
}
},
"Logging": {
+11 -9
View File
@@ -8,15 +8,17 @@
"ConnectionStrings": {
"DefaultConnection": "Server=192.168.0.200;Port=3306;Database=mysql;Uid=root;Pwd=123456;",
},
"Jwt": {
"Issuer": "StoreHub",
"Audience": "StoreHub.Client",
"SigningKey": "StoreHub--Smxf8DtV8a5dFdaCVCXtFfWbmMRfw53ectEjr8JvhUjGpPP7fuBdRn",
"ExpiresMinutes": 2880,
"ClockSkewMinutes": 5,
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true
"TokenAuth": {
"IdleDays": 7,
"AbsoluteMaxDays": 30,
"RenewIntervalMinutes": 10,
"CacheSeconds": 30,
"MaxDevicesPerUser": 0,
"DevelopmentToken": {
"Enabled": false,
"Token": "store-local-dev-7f2c9e4a6b814d38a5910e62c4f837bd",
"UserName": "18990991234"
}
},
"Plugins": {
"Path": "Plugins"
+2
View File
@@ -1,5 +1,6 @@
<Solution>
<Folder Name="/.Tests/">
<Project Path="Tests/Store.Shops.Tests/Store.Shops.Tests.csproj" />
<Project Path="Tests/Store.Users.Tests/Store.Users.Tests.csproj" />
</Folder>
<Folder Name="/Core/">
@@ -27,6 +28,7 @@
<Project Path="Tests/Store.Security.Tests/Store.Security.Tests.csproj" />
</Folder>
<Folder Name="/_SolutionItems/">
<File Path="AGENTS.md" />
<File Path="Directory.Build.props" />
<File Path="Directory.Build.targets" />
</Folder>
@@ -1,77 +1,164 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.AspNetCore.TestHost;
using MAX.Framework.DependencyInjection;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using System.Security.Claims;
using MAX.Framework.Security;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace Store.Users.Tests;
public sealed class PersistentSessionTests
{
[Fact]
public async Task TokensSurviveRestart_AndLogoutOnlyRevokesCurrentSession()
private sealed class Clock : TimeProvider
{
var path = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db");
var options = Options.Create(new JwtOptions());
TokenResult first;
TokenResult second;
string firstJti;
public DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
public override DateTimeOffset GetUtcNow() => Now;
}
private static IFreeSql Open(string path)
{
var db = new FreeSql.FreeSqlBuilder()
.UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute,
FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi)
.UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path};Pooling=false").Build();
db.CodeFirst.ConfigEntity<UserToken>(e => e.Name("tokens"));
db.CodeFirst.ConfigEntity<TokenAccount>(e => e.Name("users"));
db.CodeFirst.SyncStructure<UserToken>();
db.Ado.ExecuteNonQuery("CREATE TABLE IF NOT EXISTS users (id INTEGER PRIMARY KEY, user_name TEXT, nick_name TEXT)");
db.Ado.ExecuteNonQuery("INSERT OR IGNORE INTO users VALUES (1, 'same', 'Nickname')");
return db;
}
private static TokenResult Issue(TokenService service, string? device = null) =>
service.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "192.0.2.10", device, "Device");
[Fact]
public async Task BearerHandler_EnforcesDatabaseTokensAndReturns401And403()
{
using var db = Open(":memory:");
var builder = Microsoft.AspNetCore.Builder.WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Services.AddSingleton<IFreeSql>(db);
builder.Services.AddStoreHubTokenAuth(builder.Configuration);
builder.Services.AddAuthorization(
options => options.AddPolicy("deny", policy => policy.RequireClaim("missing")));
await using var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/private", () => "ok").RequireAuthorization();
app.MapGet("/forbidden", () => "ok").RequireAuthorization("deny");
await app.StartAsync();
using var client = app.GetTestClient();
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await client.GetAsync("/private")).StatusCode);
var service = app.Services.GetRequiredService<ITokenService>();
var token = Issue((TokenService)service);
client.DefaultRequestHeaders.Authorization = new("Bearer", token.Token);
Assert.Equal("ok", await client.GetStringAsync("/private"));
Assert.Equal(System.Net.HttpStatusCode.Forbidden, (await client.GetAsync("/forbidden")).StatusCode);
var principal = await service.ValidateTokenAsync(token.Token);
var store = app.Services.GetRequiredService<IOnlineUserStore>();
store.SignOutSession("same", principal!.FindFirst(JwtClaimTypes.Jti)!.Value);
Assert.Equal(System.Net.HttpStatusCode.Unauthorized, (await client.GetAsync("/private")).StatusCode);
}
[Fact]
public async Task TokensSurviveRestart_AndLogoutInvalidatesWarmCache()
{
var path = Path.Combine(Path.GetTempPath(), $"tokens-{Guid.NewGuid():N}.db");
var clock = new Clock();
var options = Options.Create(new TokenAuthOptions());
try
{
TokenResult first, second;
using (var db = Open(path))
using (var cache = new MemoryCache(new MemoryCacheOptions()))
{
db.CodeFirst.SyncStructure<OnlineUserInfo>();
var store = new OnlineUserStore(db);
var tokens = new TokenService(options, store);
first = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "192.0.2.10");
second = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "2001:db8::1");
firstJti = (await tokens.ValidateTokenAsync(first.Token))!.FindFirst(JwtClaimTypes.Jti)!.Value;
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
Assert.Equal(2, store.Count);
var service = new TokenService(db, options, cache, clock);
first = Issue(service); second = Issue(service);
Assert.Equal(32, Convert.FromBase64String(first.Token).Length);
Assert.NotEqual(first.Token, second.Token);
Assert.All(db.Select<UserToken>().ToList(), x => Assert.Equal(64, x.TokenHash.Length));
}
// 新 ORM、新存储和 TokenService,模拟进程重启后的数据库恢复。
using (var db = Open(path))
using (var cache = new MemoryCache(new MemoryCacheOptions()))
{
var store = new OnlineUserStore(db);
var tokens = new TokenService(options, store);
Assert.NotNull(await tokens.ValidateTokenAsync(first.Token));
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
Assert.Contains(store.GetOnlineUsers(), x => x.LoginIp == "2001:db8::1");
store.SignOutSession("other", firstJti);
Assert.NotNull(await tokens.ValidateTokenAsync(first.Token));
store.SignOutSession("same", firstJti);
Assert.Null(await tokens.ValidateTokenAsync(first.Token));
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
var service = new TokenService(db, options, cache, clock);
var store = new OnlineUserStore(db, service);
var principal = await service.ValidateTokenAsync(first.Token);
Assert.Equal("Nickname", principal!.FindFirst(JwtClaimTypes.NickName)!.Value);
Assert.NotNull(await service.ValidateTokenAsync(second.Token));
var id = principal.FindFirst(JwtClaimTypes.Jti)!.Value;
store.SignOutSession("other", id);
Assert.NotNull(await service.ValidateTokenAsync(first.Token));
store.SignOutSession("same", id);
Assert.Null(await service.ValidateTokenAsync(first.Token));
Assert.NotNull(await service.ValidateTokenAsync(second.Token));
Assert.Single(store.GetOnlineUsers());
Assert.NotNull(db.Select<OnlineUserInfo>().Where(x => x.Jti == firstJti).First().RevokedAt);
}
using (var db = Open(path))
{
var store = new OnlineUserStore(db);
var tokens = new TokenService(options, store);
Assert.Null(await tokens.ValidateTokenAsync(first.Token));
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
store.SignOut("same");
Assert.Null(await tokens.ValidateTokenAsync(second.Token));
Assert.Null(await service.ValidateTokenAsync(second.Token));
Assert.All(db.Select<UserToken>().ToList(), t => Assert.True(t.Revoked));
}
}
finally { File.Delete(path); }
}
[Fact]
public async Task ExpiredSessionsAreRejected_EvenWithinJwtClockTolerance()
public async Task SlidingRenewal_IsThrottled_AndCappedByAbsoluteExpiry()
{
using var db = Open(":memory:");
db.CodeFirst.SyncStructure<OnlineUserInfo>();
var store = new OnlineUserStore(db);
var tokens = new TokenService(Options.Create(new JwtOptions()), store);
var token = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "user")]);
db.Update<OnlineUserInfo>().Set(x => x.ExpiresAt, DateTime.UtcNow.AddSeconds(-1))
.Where(x => x.UserName == "user").ExecuteAffrows();
Assert.Null(await tokens.ValidateTokenAsync(token.Token));
Assert.Empty(store.GetOnlineUsers());
Assert.Equal(1, db.Select<OnlineUserInfo>().Count());
using var cache = new MemoryCache(new MemoryCacheOptions());
var clock = new Clock();
var service = new TokenService(db, Options.Create(new TokenAuthOptions { IdleDays = 2, AbsoluteMaxDays = 3 }), cache, clock);
var token = Issue(service);
var initial = db.Select<UserToken>().First();
clock.Now = clock.Now.AddMinutes(9);
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
Assert.Equal(initial.ExpiresAt, db.Select<UserToken>().First().ExpiresAt);
clock.Now = clock.Now.AddMinutes(1);
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
Assert.Equal(clock.Now.UtcDateTime.AddDays(2), db.Select<UserToken>().First().ExpiresAt);
clock.Now = clock.Now.AddDays(1);
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
Assert.Equal(initial.CreatedAt.AddDays(3), db.Select<UserToken>().First().ExpiresAt);
clock.Now = new DateTimeOffset(initial.CreatedAt.AddDays(3), TimeSpan.Zero);
Assert.Null(await service.ValidateTokenAsync(token.Token));
}
private static IFreeSql Open(string path) => new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path};Pooling=false")
.Build();
[Theory]
[InlineData(0, 3)]
[InlineData(1, 1)]
[InlineData(2, 2)]
public async Task DeviceLimit_EvictsOldestAndClearsCache(int limit, int expected)
{
using var db = Open(":memory:");
using var cache = new MemoryCache(new MemoryCacheOptions());
var service = new TokenService(db, Options.Create(new TokenAuthOptions { MaxDevicesPerUser = limit }), cache, new Clock());
var first = Issue(service, "a");
Assert.NotNull(await service.ValidateTokenAsync(first.Token));
Issue(service, "b"); var last = Issue(service, "c");
Assert.Equal(expected, db.Select<UserToken>().Where(t => !t.Revoked).Count());
Assert.NotNull(await service.ValidateTokenAsync(last.Token));
Assert.Equal(limit == 0, await service.ValidateTokenAsync(first.Token) != null);
var replacement = Issue(service, "c");
Assert.Null(await service.ValidateTokenAsync(last.Token));
Assert.NotNull(await service.ValidateTokenAsync(replacement.Token));
}
[Fact]
public async Task CacheDisabled_ObservesExternalRevocation_AndRejectsIdleExpiry()
{
using var db = Open(":memory:");
using var cache = new MemoryCache(new MemoryCacheOptions());
var clock = new Clock();
var service = new TokenService(db, Options.Create(new TokenAuthOptions { CacheSeconds = 0 }), cache, clock);
var token = Issue(service);
Assert.NotNull(await service.ValidateTokenAsync(token.Token));
db.Update<UserToken>().Set(t => t.Revoked, true).Where(t => t.Id > 0).ExecuteAffrows();
Assert.Null(await service.ValidateTokenAsync(token.Token));
var second = Issue(service);
clock.Now = clock.Now.AddDays(7);
Assert.Null(await service.ValidateTokenAsync(second.Token));
Assert.Null(await service.ValidateTokenAsync("malformed"));
}
}
+147
View File
@@ -0,0 +1,147 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using Asp.Versioning;
using MAX.Framework.Commons;
using MAX.Framework.Conventions;
using MAX.Framework.Dtos;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Store.Shops.Controllers;
using Store.Shops.Entities;
using Store.Shops.Services;
using Store.Users.Contracts;
namespace Store.Shops.Tests;
public sealed class ShopHttpTests : IAsyncLifetime
{
private readonly ShopTestContext context = new();
private WebApplication app = null!;
private HttpClient client = null!;
public async Task InitializeAsync()
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = "Testing" });
builder.WebHost.UseTestServer();
builder.Logging.ClearProviders();
builder.Services.AddControllers(options =>
{
options.Conventions.Add(new ApiRoutePrefixConvention());
options.Filters.Add<GlobalExceptionFilter>();
options.Filters.Add<UnifyResultFilter>();
}).AddApplicationPart(typeof(ShopController).Assembly).ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = _ =>
new ObjectResult(ApiResult.Fail("参数错误", ResultCode.ValidationError)) { StatusCode = 200 };
});
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.ApiVersionReader = new UrlSegmentApiVersionReader();
}).AddMvc();
builder.Services.AddAuthentication("Test").AddScheme<AuthenticationSchemeOptions, ShopTestAuthentication>("Test", _ => { });
builder.Services.AddAuthorization();
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, CurrentUser>();
builder.Services.AddSingleton(context.Db);
builder.Services.AddSingleton<IUserQueryService>(context);
builder.Services.AddSingleton(context.Configuration);
builder.Services.AddSingleton(context.Gateway);
builder.Services.AddTransient<ShopManagementService>();
builder.Services.AddTransient<ShopService>();
app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
await app.StartAsync();
client = app.GetTestClient();
client.DefaultRequestHeaders.Add("X-Test-User", "owner");
}
[Theory]
[InlineData("GetPddClients")]
[InlineData("GetPddShopClients")]
[InlineData("GetPddShopToken")]
[InlineData("GetAllPddShops")]
[InlineData("AddWeidianShop")]
[InlineData("UpdateWeidianShop")]
[InlineData("UpdateWeidianShopLogin")]
[InlineData("DeleteWeidianShop")]
public async Task ActionsOnlyAcceptPost(string action)
{
using var response = await client.GetAsync("/api/v1/shop/" + action);
Assert.Equal(HttpStatusCode.MethodNotAllowed, response.StatusCode);
Assert.Contains("POST", response.Content.Headers.Allow);
}
[Fact]
public async Task ActionsBindDtoAndReturnSingleApiResultEnvelope()
{
using var create = await client.PostAsJsonAsync("/api/v1/shop/AddWeidianShop", new { loginName = "login", shopId = "shop", notes = "old" });
Assert.Equal(HttpStatusCode.OK, create.StatusCode);
var json = await create.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(200, json.GetProperty("code").GetInt32());
Assert.Equal(JsonValueKind.Null, json.GetProperty("data").ValueKind);
using var update = await client.PostAsJsonAsync("/api/v1/shop/UpdateWeidianShop", new { loginName = "login", shopId = "shop", notes = "new" });
Assert.Equal(200, (await update.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
using var cookie = await client.PostAsJsonAsync("/api/v1/shop/UpdateWeidianShopLogin", new { shopId = "shop", cookie = "session" });
Assert.Equal(200, (await cookie.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
var row = Assert.Single(await context.Db.Select<WeidianShopEntity>().ToListAsync());
Assert.Equal("new", row.Notes);
Assert.Equal("session", row.Cookie);
Assert.Equal("owner", row.UserName);
}
[Fact]
public async Task ValidationAndBusinessErrorsReturnHttp200WithFailureCode()
{
using var invalid = await client.PostAsJsonAsync("/api/v1/shop/AddWeidianShop", new { });
Assert.Equal(HttpStatusCode.OK, invalid.StatusCode);
Assert.Equal(ResultCode.ValidationError, (await invalid.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
using var denied = await client.PostAsJsonAsync("/api/v1/shop/GetAllPddShops", new { });
Assert.Equal(HttpStatusCode.OK, denied.StatusCode);
Assert.Equal(ResultCode.BusinessError, (await denied.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("code").GetInt32());
}
[Fact]
public async Task CredentialResponseIsWrappedAndNotCached_AnonymousCannotRead()
{
using var response = await client.PostAsJsonAsync("/api/v1/shop/GetPddClients", new { });
var result = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal(200, result.GetProperty("code").GetInt32());
Assert.Equal("test-client", result.GetProperty("data")[0].GetProperty("clientId").GetString());
Assert.True(response.Headers.CacheControl!.NoStore);
client.DefaultRequestHeaders.Remove("X-Test-User");
using var anonymous = await client.PostAsJsonAsync("/api/v1/shop/GetPddClients", new { });
Assert.Equal(HttpStatusCode.Unauthorized, anonymous.StatusCode);
}
public async Task DisposeAsync()
{
client.Dispose();
await app.DisposeAsync();
context.Dispose();
}
}
internal sealed class ShopTestAuthentication(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Test-User", out var name))
return Task.FromResult(AuthenticateResult.NoResult());
var principal = new ClaimsPrincipal(new ClaimsIdentity([new Claim(JwtClaimTypes.UserName, name.ToString())], Scheme.Name));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)));
}
}
@@ -0,0 +1,184 @@
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using MAX.Framework.Commons;
using Store.Shops.Dtos;
using Store.Shops.Entities;
namespace Store.Shops.Tests;
public sealed class ShopManagementTests
{
[Fact]
public async Task CreateThenImportAgain_UpdatesWithoutDuplicating_AndNullPreservesFields()
{
using var c = new ShopTestContext();
await c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Notes = "keep", Cookie = "old" }, true);
await c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Cookie = "new" }, true);
var row = Assert.Single(await c.Db.Select<WeidianShopEntity>().ToListAsync());
Assert.Equal("keep", row.Notes);
Assert.Equal("new", row.Cookie);
Assert.Equal("owner", row.UserName);
await c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Notes = "", NewShopId = "new-id" }, false);
row = await c.Db.Select<WeidianShopEntity>().FirstAsync();
Assert.Equal("", row.Notes);
Assert.Equal("new-id", row.ShopId);
}
[Fact]
public async Task WrongOwnerCannotUpdateCookieOrDelete_EvenWithSameShopId()
{
using var c = new ShopTestContext();
await c.Db.Insert(new WeidianShopEntity { LoginName = "login", ShopId = "shop", UserName = "other", Cookie = "secret" }).ExecuteAffrowsAsync();
await Assert.ThrowsAsync<AppException>(() => c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "attack" }));
await Assert.ThrowsAsync<AppException>(() => c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", Notes = "attack" }, false));
await Assert.ThrowsAsync<AppException>(() => c.Service.DeleteWeidian(new() { ShopId = "shop" }));
Assert.Equal("secret", (await c.Db.Select<WeidianShopEntity>().FirstAsync()).Cookie);
}
[Fact]
public async Task AssignedAccountMatchesWholeName_AndCannotReassignOrDelete()
{
using var c = new ShopTestContext();
await c.Db.Insert(new WeidianShopEntity { LoginName = "login", ShopId = "shop", UserName = "other", SubUserName = "coowner,owner2" }).ExecuteAffrowsAsync();
await Assert.ThrowsAsync<AppException>(() => c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "attack" }));
await c.Db.Update<WeidianShopEntity>().Where(s => s.ShopId == "shop").Set(s => s.SubUserName, "first,owner,last").ExecuteAffrowsAsync();
await c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "allowed" });
await Assert.ThrowsAsync<AppException>(() => c.Service.SaveWeidian(new() { LoginName = "login", ShopId = "shop", SubUserName = "new" }, false));
await Assert.ThrowsAsync<AppException>(() => c.Service.DeleteWeidian(new() { ShopId = "shop" }));
Assert.Equal("allowed", (await c.Db.Select<WeidianShopEntity>().FirstAsync()).Cookie);
}
[Fact]
public async Task CookieUpdateOnlyChangesAuthorizedRows_AndOwnerCanDelete()
{
using var c = new ShopTestContext();
await c.Db.Insert(new[] {
new WeidianShopEntity { ShopId = "shop", UserName = "owner", WdToken = "keep" },
new WeidianShopEntity { ShopId = "shop", UserName = "other", Cookie = "secret" }
}).ExecuteAffrowsAsync();
await c.Service.UpdateWeidianCookie(new() { ShopId = "shop", Cookie = "new", LoginStatus = "online" });
Assert.Equal("keep", (await c.Db.Select<WeidianShopEntity>().Where(s => s.UserName == "owner").FirstAsync()).WdToken);
await c.Service.DeleteWeidian(new() { ShopId = "shop" });
Assert.Equal("secret", Assert.Single(await c.Db.Select<WeidianShopEntity>().ToListAsync()).Cookie);
}
[Fact]
public async Task AmbiguousLoginIsRejectedWithoutChangingEitherShop()
{
using var c = new ShopTestContext();
await c.Service.SaveWeidian(new() { LoginName = "same", ShopId = "one" }, true);
await c.Service.SaveWeidian(new() { LoginName = "same", ShopId = "two" }, true);
await Assert.ThrowsAsync<AppException>(() => c.Service.SaveWeidian(new() { LoginName = "same", Notes = "bad" }, false));
Assert.All(await c.Db.Select<WeidianShopEntity>().ToListAsync(), s => Assert.Equal("", s.Notes));
}
[Fact]
public async Task ValidationAndAnonymousRequestsDoNotWrite()
{
using var c = new ShopTestContext();
await Assert.ThrowsAsync<ValidationException>(() => c.Service.SaveWeidian(new(), true));
await Assert.ThrowsAsync<ValidationException>(() => c.Service.UpdateWeidianCookie(new() { ShopId = "shop", WdToken = "only" }));
await Assert.ThrowsAsync<ValidationException>(() => c.Service.DeleteWeidian(new()));
c.UserName = null;
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => c.Service.SaveWeidian(new() { LoginName = "x" }, true));
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => c.Service.GetPddClients());
Assert.Equal(0, await c.Db.Select<WeidianShopEntity>().CountAsync());
}
[Theory]
[InlineData(0, 50)]
[InlineData(1, 0)]
[InlineData(1, 501)]
[InlineData(int.MaxValue, 500)]
public async Task InvalidPaginationIsRejected(int page, int size)
{
using var c = new ShopTestContext();
await Assert.ThrowsAsync<ValidationException>(() => c.Service.GetAdminShops(new() { Page = page, PageSize = size }));
await Assert.ThrowsAsync<ValidationException>(() => c.Service.GetShopClientInfo(new() { Page = page, PageSize = size }));
}
[Fact]
public async Task ClientInfoScopesCountAndRowsBeforePagination()
{
using var c = new ShopTestContext();
await c.Db.Insert(new[] {
new PddShopEntity { ShopId = "one", UserName = "owner" },
new PddShopEntity { ShopId = "two", UserName = "other", SubUserName = "owner" },
new PddShopEntity { ShopId = "three", UserName = "other", SubUserName = "owner2" }
}).ExecuteAffrowsAsync();
foreach (var id in new[] { "one", "two", "three" })
await c.Db.Insert(new ShopApiAuthEntity { ShopId = id, AppKey = id, AppSecret = "secret" }).ExecuteAffrowsAsync();
var page = await c.Service.GetShopClientInfo(new() { PageSize = 1 });
Assert.Equal(2, page.Total);
Assert.Equal("two", Assert.Single(page.Items).ShopId);
Assert.Empty((await c.Service.GetShopClientInfo(new() { ShopIds = ["three"] })).Items);
c.UserName = "admin";
Assert.Empty((await c.Service.GetShopClientInfo(new())).Items);
}
[Fact]
public async Task AdminListResetsOldMonthOnceAndKeepsCurrentQuota()
{
using var c = new ShopTestContext();
await Assert.ThrowsAsync<AppException>(() => c.Service.GetAdminShops(new()));
c.UserName = "admin";
await c.Db.Insert(new PddShopEntity { ShopId = "old", ShopAccount = "test", LoginPass = "password", Cookie = "secret" }).ExecuteAffrowsAsync();
await c.Db.Insert(new ShopProductQuotaEntity { ShopId = "old", AddProductMonth = DateTime.Now.Month % 12 + 1, AddProductRemaining = 2 }).ExecuteAffrowsAsync();
var page = await c.Service.GetAdminShops(new() { SearchName = "test" });
Assert.Equal(1, page.Total);
Assert.Equal(300, Assert.Single(page.Items).AddProductRemaining);
var json = JsonSerializer.Serialize(page);
Assert.DoesNotContain("LoginPass", json);
Assert.DoesNotContain("Cookie", json);
await c.Db.Update<ShopProductQuotaEntity>().Where(s => s.ShopId == "old").Set(s => s.AddProductRemaining, 17).ExecuteAffrowsAsync();
Assert.Equal(17, Assert.Single((await c.Service.GetAdminShops(new())).Items).AddProductRemaining);
Assert.Equal(17, (await c.Db.Select<PddShopEntity>().FirstAsync()).AddProductRemaining);
}
[Fact]
public async Task QuotaAndShopUpdatesRollbackTogether()
{
using var c = new ShopTestContext { UserName = "admin" };
await c.Db.Insert(new PddShopEntity { ShopId = "shop" }).ExecuteAffrowsAsync();
await c.Db.Insert(new ShopProductQuotaEntity { ShopId = "shop", AddProductMonth = 0, AddProductRemaining = 7 }).ExecuteAffrowsAsync();
c.Db.Ado.ExecuteNonQuery("CREATE TRIGGER reject_shop_update BEFORE UPDATE ON pdd_shops BEGIN SELECT RAISE(ABORT, 'test failure'); END");
await Assert.ThrowsAnyAsync<Exception>(() => c.Service.GetAdminShops(new()));
var quota = await c.Db.Select<ShopProductQuotaEntity>().FirstAsync();
Assert.Equal(0, quota.AddProductMonth);
Assert.Equal(7, quota.AddProductRemaining);
}
[Fact]
public async Task TokenRequiresShopAccess_AndRetriesStructuredFailure()
{
using var c = new ShopTestContext();
await Assert.ThrowsAsync<AppException>(() => c.Service.GetPddToken(new() { MallId = "mall" }));
Assert.Equal(0, c.Handler.Calls);
await c.Db.Insert(new PddShopEntity { MallId = "mall&x=1", UserName = "owner" }).ExecuteAffrowsAsync();
c.Handler.Responses.Enqueue("""{"code":1,"msg":"failed"}""");
var token = await c.Service.GetPddToken(new() { MallId = "mall&x=1" });
Assert.Equal("test-token", token.GetProperty("accessToken").GetString());
Assert.Equal(2, c.Handler.Calls);
Assert.Contains("mall%26x%3D1", c.Handler.LastUri!.AbsoluteUri);
}
[Fact]
public async Task TokenMalformedResponsesFailWithoutLeakingUpstreamContent()
{
using var c = new ShopTestContext();
c.Handler.Responses.Enqueue("private upstream error");
c.Handler.Responses.Enqueue("""{"code":3}""");
var error = await Assert.ThrowsAsync<AppException>(() => c.Gateway.GetToken("mall", default));
Assert.DoesNotContain("private", error.Message);
Assert.Equal(2, c.Handler.Calls);
}
[Fact]
public async Task PddClientsComeFromConfiguration()
{
using var c = new ShopTestContext();
Assert.Equal("test-client", Assert.Single(await c.Service.GetPddClients()).ClientId);
c.Configuration["Shops:PddClients:0:ClientSecret"] = "";
await Assert.ThrowsAsync<AppException>(() => c.Service.GetPddClients());
}
}
@@ -0,0 +1,75 @@
using System.Net;
using System.Security.Claims;
using FreeSql;
using MAX.Framework.Security;
using Microsoft.Extensions.Configuration;
using Store.Shops.Entities;
using Store.Shops.Services;
using Store.Users.Contracts;
using Store.Users.Contracts.Dtos;
using Store.Users.Contracts.Enums;
namespace Store.Shops.Tests;
internal sealed class ShopTestContext : IDisposable, ICurrentUser, IUserQueryService
{
private readonly string path = Path.Combine(Path.GetTempPath(), $"store-shops-{Guid.NewGuid():N}.db");
public IFreeSql Db { get; }
public string? UserName { get; set; } = "owner";
public string? NickName => UserName;
public bool IsAuthenticated => UserName != null;
public IEnumerable<Claim> Claims => [];
public string? GetClaimValue(string type) => null;
public T? GetClaimValue<T>(string type) => default;
public Task<UserInfo> GetUser(string name) => Task.FromResult(new UserInfo
{
UserName = name, SaLevel = name == "admin" ? UserAdminLevel.SuperAdmin : UserAdminLevel.Normal
});
public StubHttp Handler { get; } = new();
public PddTokenClient Gateway { get; }
public ShopManagementService Service { get; }
public IConfiguration Configuration { get; } = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Shops:PddClients:0:ClientId"] = "test-client",
["Shops:PddClients:0:ClientSecret"] = "test-secret",
["Shops:PddClients:0:RedirectUri"] = "https://example.test/callback"
}).Build();
public ShopTestContext()
{
Db = new FreeSqlBuilder().UseMappingPriority(FreeSql.Internal.MappingPriorityType.Attribute,
FreeSql.Internal.MappingPriorityType.Aop, FreeSql.Internal.MappingPriorityType.FluentApi)
.UseConnectionString(DataType.Sqlite, $"Data Source={path};Pooling=False").Build();
Db.CodeFirst.ConfigEntity<PddShopEntity>(e => e.Name("pdd_shops"));
Db.CodeFirst.ConfigEntity<WeidianShopEntity>(e => e.Name("weidian_shops"));
Db.CodeFirst.ConfigEntity<ShopApiAuthEntity>(e => e.Name("shop_auth"));
Db.CodeFirst.ConfigEntity<ShopProductQuotaEntity>(e => e.Name("shop_quota"));
Db.CodeFirst.SyncStructure(typeof(PddShopEntity), typeof(WeidianShopEntity), typeof(ShopApiAuthEntity), typeof(ShopProductQuotaEntity));
Gateway = new(new HttpClient(Handler));
Service = new(Db, this, this, Configuration, Gateway);
}
public void Dispose()
{
Gateway.Dispose();
Db.Dispose();
File.Delete(path);
}
}
internal sealed class StubHttp : HttpMessageHandler
{
public Queue<string> Responses { get; } = new();
public int Calls { get; private set; }
public Uri? LastUri { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
ct.ThrowIfCancellationRequested();
Calls++;
LastUri = request.RequestUri;
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(Responses.Count == 0 ? """{"code":0,"data":{"accessToken":"test-token"}}""" : Responses.Dequeue())
});
}
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsTestProject>true</IsTestProject>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../../Modules/Store.Shops/Store.Shops.csproj" />
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="FreeSql.Provider.Sqlite" Version="3.5.311" />
</ItemGroup>
<ItemGroup><Using Include="Xunit" /></ItemGroup>
</Project>
@@ -12,27 +12,30 @@ namespace Store.Users.Tests;
public sealed class DevelopmentTokenTests
{
[Theory]
[InlineData("Development", true, "test-fixed-token", "admin")]
[InlineData("Development", true, "incorrect-token", "")]
[InlineData("Development", false, "test-fixed-token", "")]
[InlineData("Production", true, "test-fixed-token", "")]
public async Task FixedToken_OnlyAuthenticatesWhenEnabledInDevelopment(
string environment, bool enabled, string token, string expectedUser)
[InlineData("Development", true, "test-fixed-token", "test-fixed-token", "admin", "admin")]
[InlineData("Development", true, "incorrect-token", "test-fixed-token", "admin", "")]
[InlineData("Development", true, "TEST-FIXED-TOKEN", "test-fixed-token", "admin", "")]
[InlineData("Development", false, "test-fixed-token", "test-fixed-token", "admin", "")]
[InlineData("Production", true, "test-fixed-token", "test-fixed-token", "admin", "")]
[InlineData("Staging", true, "test-fixed-token", "test-fixed-token", "admin", "")]
[InlineData("Development", true, "test-fixed-token", "", "admin", "")]
[InlineData("Development", true, "test-fixed-token", "test-fixed-token", "", "")]
public async Task FixedToken_OnlyAuthenticatesWhenConfiguredAndEnabledInDevelopment(
string environment, bool enabled, string token, string configuredToken, string userName, string expectedUser)
{
var builder = WebApplication.CreateBuilder(new WebApplicationOptions { EnvironmentName = environment });
builder.WebHost.UseTestServer();
builder.Logging.ClearProviders();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Jwt:DevelopmentToken:Enabled"] = enabled.ToString(),
["Jwt:DevelopmentToken:Token"] = "test-fixed-token",
["Jwt:DevelopmentToken:UserName"] = "admin"
["TokenAuth:DevelopmentToken:Enabled"] = enabled.ToString(),
["TokenAuth:DevelopmentToken:Token"] = configuredToken,
["TokenAuth:DevelopmentToken:UserName"] = userName
});
using var db = new FreeSql.FreeSqlBuilder()
.UseConnectionString(FreeSql.DataType.Sqlite, "Data Source=:memory:").Build();
db.CodeFirst.SyncStructure<OnlineUserInfo>();
builder.Services.AddSingleton<IFreeSql>(db);
builder.Services.AddStoreHubJwt(builder.Configuration);
builder.Services.AddStoreHubTokenAuth(builder.Configuration);
await using var app = builder.Build();
app.UseAuthentication();
app.MapGet("/identity", (ICurrentUser user) =>
@@ -42,7 +45,7 @@ public sealed class DevelopmentTokenTests
client.DefaultRequestHeaders.Authorization = new("Bearer", token);
Assert.Equal(expectedUser, await client.GetStringAsync("/identity"));
// 同一凭据不依赖首次请求创建的会话。
// 固定测试凭据不创建数据库会话,重复请求的结果保持一致。
Assert.Equal(expectedUser, await client.GetStringAsync("/identity"));
}
}
+3 -3
View File
@@ -25,7 +25,7 @@ dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj --filter FullyQuali
| `UserFundsTests.cs` | 积分和余额充值、累计金额、审计字段、重复请求、双账户积分转移、向自己转移、超扣、非管理员调整、非法金额/类型、流水故障回滚及原请求重试 |
| `UserRecordTests.cs` | 账户隔离、操作类型/店铺/订单/时间组合过滤、时间包含边界、分页排序、排除已删除记录、无效日期范围 |
| `UserTopUpTests.cs` | 充值备注身份与可见范围、处理权限、处理备注不自动充值、二维码更新或插入、Data URI、损坏数据、管理员限制 |
| `UserHttpTests.cs` | TestServer 真实 MVC 请求,12 个默认 POST 操作拒绝 GET/PUT/DELETE,7 个 GET 操作拒绝 POST;未登录 401、JSON 模型验证、有效 POST 创建、认证身份读取、ApiExplorer 与端点方法一致 |
| `UserHttpTests.cs` | TestServer 真实 MVC 请求,12 个写入操作拒绝 GET/PUT/DELETE,7 个查询操作拒绝 GET,所有操作采用 POST;未登录 401、JSON 模型验证、有效 POST 创建、认证身份读取、ApiExplorer 与端点方法一致 |
使用 xUnit,Theory 每组参数分别统计,失败时输出具体测试名称。测试项目已添加到解决方案的 Tests 分组。
@@ -33,8 +33,8 @@ dotnet test Tests/Store.Users.Tests/Store.Users.Tests.csproj --filter FullyQuali
测试不会读取 `Store.API/appsettings.json`、访问业务 MySQL、调用第三方服务或修改业务数据。每个测试拥有独立数据库,时间过滤采用固定时间,身份通过测试上下文或仅在 TestServer 注册的测试认证方案提供。HTTP 测试接入真实 UnifyResultFilter、GlobalExceptionFilter 及与宿主一致的模型验证响应,验证数据只包装一次,无数据操作、业务异常和参数错误均返回 HTTP 200 的 ApiResult。路由拒绝与测试认证挑战仍保留原始 405/401,此测试宿主未装配全局状态码中间件;测试认证不验证 JWT 签名。
SQLite 能验证数据持久化和异常回滚,但不能替代 MySQL InnoDB 并发行锁、隔离级别和迁移脚本验证。原有 `UserManagementSmoke` 和 `ShopRenewSmoke` 冒烟入口仍可单独运行。
SQLite 能验证数据持久化和异常回滚,但不能替代 MySQL InnoDB 并发行锁、隔离级别和迁移脚本验证。旧版 `UserManagementSmoke` 和 `ShopRenewSmoke` 尚未适配当前接口,本轮验证使用上述 xUnit 项目。
User DTO 仅声明 Required。RequestValidatorTests 验证非空规则、错误集合和这一模块约定,同时确认公共工具仍支持其他模块的完整 DataAnnotations。UserServiceValidationTests 验证服务层的长度、范围和 ID 判断;原有金额、精度、格式、日期等用例继续通过服务入口验证。HTTP 测试分别验证 DTO 非空拦截与服务手动参数检查,都按系统约定返回 HTTP 200 和 ValidationError。
User DTO 使用 DataAnnotations 和 IValidatableObject 声明非空、长度、范围、精度和跨字段规则。RequestValidatorTests 验证这些规则;UserServiceValidationTests 确认内部服务调用同样执行验证,HTTP 测试验证模型错误返回 HTTP 200 和 ValidationError。
UserAccessTests 覆盖统一管理范围:管理员跨主账号修改/创建子账号/重置密码,主账号修改本人和直属子账号、重置子账号密码、处理子账号充值备注,非授权账户拒绝操作;普通主账号不能接管管理员账户,也不能充值、扣款或改计费。店铺续费通过真实 MediatR 事务管道测试代操作权限、目标账户扣款与实际操作者审计。HTTP 测试覆盖资料修改与密码重置接口。
@@ -5,7 +5,7 @@ using Store.Users.Dtos;
namespace Store.Users.Tests;
/// <summary>验证公共工具,以及 User DTO 仅保留 Required 的模块约定。</summary>
/// <summary>验证服务入口使用的 DTO 注解和跨字段规则。</summary>
public sealed class RequestValidatorTests
{
[Fact]
@@ -41,48 +41,39 @@ public sealed class RequestValidatorTests
public void TryValidate_ReturnsAllMissingRequiredFields()
{
Assert.False(RequestValidator.TryValidate(new UserFundsRequest(), out var errors));
Assert.Equal(3, errors.Count);
Assert.Equal(4, errors.Count);
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.Amount)));
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.UserName)));
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.RequestId)));
Assert.Contains(errors, e => e.MemberNames.Contains(nameof(UserFundsRequest.Operation)));
}
[Theory]
[InlineData(typeof(UserFundsRequest))]
[InlineData(typeof(UserEditRequest))]
[InlineData(typeof(UserCreateRequest))]
[InlineData(typeof(ChangePasswordRequest))]
[InlineData(typeof(UserSearchRequest))]
[InlineData(typeof(UserRecordSearchRequest))]
[InlineData(typeof(UserTopUpMessageRequest))]
[InlineData(typeof(UserTopUpResultRequest))]
[InlineData(typeof(UserTopUpQrcodeRequest))]
[InlineData(typeof(UserNameRequest))]
[InlineData(typeof(UserLoginRequest))]
[InlineData(typeof(UserTopUpQrcodeQuery))]
[InlineData(typeof(ShopRenewRequest))]
public void UserDtos_DeclareOnlyRequiredValidation(Type type)
public static TheoryData<object> InvalidRequests => new()
{
Assert.False(typeof(IValidatableObject).IsAssignableFrom(type));
foreach (var property in type.GetProperties())
Assert.All(property.GetCustomAttributes(typeof(ValidationAttribute), true), attribute => Assert.IsType<RequiredAttribute>(attribute));
}
new UserFundsRequest { UserName = new string('u', 21), RequestId = "id", Amount = 1, Operation = "充值" },
new UserFundsRequest { UserName = "user", RequestId = "id", Amount = -1, Operation = "充值" },
new UserFundsRequest { UserName = "user", RequestId = "id", Amount = 1.001m, Operation = "充值" },
new UserTopUpQrcodeRequest { AgentCode = "agent", QrcodeBase64 = "invalid!" },
new UserRecordSearchRequest { Page = 0 },
new UserRecordSearchRequest { StartTime = new(2025, 2, 1), EndTime = new(2025, 1, 1) },
new ShopRenewRequest { ShopId = "shop", RenewDuration = 0 },
new ShopRenewRequest { ShopId = "shop", RenewDuration = 121 },
new UserCreateRequest { UserName = "user", Password = "123" },
new UserCreateRequest { UserName = "user", Password = "password", ShopPointsPerMonth = 1 }
};
[Fact]
public void UserDto_DoesNotCheckNonEmptyParameterValues()
[Theory]
[MemberData(nameof(InvalidRequests))]
public void InvalidDto_IsRejectedAtValidationEntry(object request)
{
// 这些值非空但不合法:DTO 应通过,服务负责拒绝。
RequestValidator.Validate(new UserFundsRequest { UserName = new string('u', 21), RequestId = "id", Amount = -1, Operation = "非法操作" });
RequestValidator.Validate(new UserTopUpQrcodeRequest { AgentCode = "agent", QrcodeBase64 = "invalid!" });
RequestValidator.Validate(new UserRecordSearchRequest { Page = 0, StartTime = new(2025, 2, 1), EndTime = new(2025, 1, 1) });
RequestValidator.Validate(new ShopRenewRequest { ShopId = "shop", RenewDuration = 0 });
RequestValidator.Validate(new UserCreateRequest { UserName = "user", Password = "123", ShopPointsPerMonth = 1 });
Assert.Throws<ValidationException>(() => RequestValidator.Validate(request));
Assert.False(RequestValidator.TryValidate(request, out var errors));
Assert.NotEmpty(errors);
}
[Fact]
public void FrameworkValidator_StillSupportsOtherModulesAnnotationsAndCrossFieldRules()
{
// User 的约定不限制公共工具支持标准 DataAnnotations 的能力。
Assert.Throws<ValidationException>(() => RequestValidator.Validate(new OtherModuleRequest { Name = "toolong" }));
Assert.False(RequestValidator.TryValidate(new OtherModuleRequest { Name = "ok", Count = 0 }, out _));
var request = new OtherModuleRequest { Name = "ok", Count = 2, Start = 2, End = 1 };
+9 -9
View File
@@ -1,3 +1,4 @@
using Store.Users.Contracts.Enums;
using MAX.Framework.Commons;
using MAX.Framework.DependencyInjection;
using MAX.Framework.Mediation;
@@ -25,7 +26,7 @@ public sealed class UserAccessTests
using var context = new UserTestContext();
context.CurrentUser.UserName = actor;
var target = await context.Read(targetName);
await context.Users.Update(target.Id, new() { Notes = "updated" }, subRoute, default);
await context.Users.Update(new() { Id = target.Id, Notes = "updated" }, subRoute, default);
Assert.Equal("updated", (await context.Read(targetName)).Notes);
Assert.Contains(targetName, context.Sessions.SignedOut);
}
@@ -39,7 +40,7 @@ public sealed class UserAccessTests
using var context = new UserTestContext();
context.CurrentUser.UserName = actor;
var target = await context.Read(targetName);
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Update(target.Id, new() { Notes = "bad" }, false, default));
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Update(new() { Id = target.Id, Notes = "bad" }, false, default));
Assert.Equal(403, error.Code);
Assert.Equal("", (await context.Read(targetName)).Notes);
}
@@ -65,8 +66,7 @@ public sealed class UserAccessTests
UserName = "new-child", Password = "password", ParentUserName = "other"
}, true, default));
Assert.Equal(403, error.Code);
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(context.Main.Id,
new() { SaLevel = 1 }, false, default))).Code);
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(new() { Id = context.Main.Id, SaLevel = UserAdminLevel.SuperAdmin }, false, default))).Code);
}
[Theory]
@@ -125,8 +125,7 @@ public sealed class UserAccessTests
{
UserName = privilegedChild.UserName, NewPassword = "takeover-password"
}, default))).Code);
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(privilegedChild.Id,
new() { Notes = "takeover" }, true, default))).Code);
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(new() { Id = privilegedChild.Id, Notes = "takeover" }, true, default))).Code);
Assert.True(Store.Users.Services.UserPassword.Verify(await context.Read(privilegedChild.UserName), "legacy-password"));
}
@@ -143,8 +142,7 @@ public sealed class UserAccessTests
UserName = name, Amount = 10m, Operation = "充值", RequestId = Guid.NewGuid().ToString("N")
}, points, default))).Code);
var target = await context.Read(name);
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(target.Id,
new() { ShopPointsPerMonth = 45 }, false, default))).Code);
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Users.Update(new() { Id = target.Id, ShopPointsPerMonth = 45 }, false, default))).Code);
}
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => context.Funds.Change(new()
{
@@ -164,6 +162,8 @@ public sealed class UserAccessTests
await context.Db.Update<UserEntity>().Set(u => u.Points, 100m).Where(u => u.Id == context.Child.Id).ExecuteAffrowsAsync();
await context.Db.Insert(new ShopEntity { ShopId = "shop", ShopAccount = "shop-account", UserName = "child" }).ExecuteAffrowsAsync();
var services = new ServiceCollection();
context.CurrentUser.UserName = actor;
services.AddSingleton<MAX.Framework.Security.ICurrentUser>(context.CurrentUser);
services.AddSingleton(context.Db);
services.AddSingleton<UnitOfWorkExecutionContext>();
services.AddMediatRAttributedBehaviors(typeof(ShopRenewCommand).Assembly);
@@ -171,7 +171,7 @@ public sealed class UserAccessTests
typeof(ShopRenewCommand).Assembly.GetType("Store.Users.Commands.ShopRenewCommandHandler", true)!);
using var provider = services.BuildServiceProvider();
var mediator = provider.GetRequiredService<IMediator>();
var command = new ShopRenewCommand("child", "shop", 1, OperatorName: actor);
var command = new ShopRenewCommand(new() { UserName = "child", ShopId = "shop", RenewDuration = 1 });
if (!allowed)
{
Assert.Equal(403, (await Assert.ThrowsAsync<AppException>(() => mediator.Send(command))).Code);
+2
View File
@@ -215,6 +215,8 @@ public sealed class UserHttpTests : IAsyncLifetime
[Fact]
public async Task BusinessFailure_ReturnsHttp200WithFailureApiResult()
{
_client.DefaultRequestHeaders.Remove("X-Test-User");
_client.DefaultRequestHeaders.Add("X-Test-User", "main");
using var response = await _client.PostAsJsonAsync("/v1/user/ChangePassword",
new { oldPassword = "wrong-password", newPassword = "new-password" });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+24 -15
View File
@@ -1,3 +1,4 @@
using Store.Users.Contracts.Enums;
using System.ComponentModel.DataAnnotations;
using System.Text.Json;
using MAX.Framework.Commons;
@@ -55,7 +56,7 @@ public sealed class UserManagementTests
var info = await context.Users.Create(new() { UserName = "created", Password = "new-password" }, false, default);
var stored = await context.Read("created");
Assert.True(info.IsMainUser);
Assert.Equal((byte)3, info.GroupId);
Assert.Equal(UserGroup.MainAccount, info.GroupId);
Assert.Equal(30, info.ShopPointsPerMonth);
Assert.Equal(0, info.Points);
Assert.Equal(0m, info.Balance);
@@ -98,11 +99,11 @@ public sealed class UserManagementTests
context.CurrentUser.UserName = "main";
var info = await context.Users.Create(new()
{
UserName = "created", Password = "new-password", GroupId = groupId, Authority = "订单"
UserName = "created", Password = "new-password", GroupId = (UserGroup)groupId, Authority = "订单"
}, true, default);
Assert.Equal("main", info.ParentUserName);
Assert.Equal(groupId, info.GroupId);
Assert.Equal((short)0, info.SaLevel);
Assert.Equal((UserGroup)groupId, info.GroupId);
Assert.Equal(UserAdminLevel.Normal, info.SaLevel);
}
[Theory]
@@ -114,7 +115,7 @@ public sealed class UserManagementTests
context.CurrentUser.UserName = "main";
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Create(new()
{
UserName = "created", Password = "new-password", GroupId = groupId
UserName = "created", Password = "new-password", GroupId = (UserGroup)groupId
}, true, default));
Assert.False(await context.Db.Select<UserEntity>().Where(u => u.UserName == "created").AnyAsync());
}
@@ -126,9 +127,9 @@ public sealed class UserManagementTests
context.CurrentUser.UserName = "main";
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Create(new()
{
UserName = "created", Password = "new-password", GroupId = 3
UserName = "created", Password = "new-password", GroupId = UserGroup.MainAccount
}, true, default));
Assert.Contains("子账号分组", error.Message);
Assert.Contains("子账号不能使用主号分组", error.Message);
}
[Fact]
public async Task ChildAccount_CannotCreateAnotherChild()
@@ -145,15 +146,16 @@ public sealed class UserManagementTests
{
using var context = new UserTestContext();
context.CurrentUser.UserName = "main";
foreach (var request in new UserEditRequest[]
foreach (var request in new UserUpdateRequest[]
{
new() { Authority = "不存在的权限" }, new() { SaLevel = 1 }, new() { ShopPointsPerMonth = 30 }
new() { Authority = "不存在的权限" }, new() { SaLevel = UserAdminLevel.SuperAdmin }, new() { ShopPointsPerMonth = 30 }
})
{
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Update(context.Child.Id, request, true, default));
request.Id = context.Child.Id;
var error = await Assert.ThrowsAsync<AppException>(() => context.Users.Update(request, true, default));
Assert.Equal(403, error.Code);
}
Assert.Equal((short)0, (await context.Read("child")).SaLevel);
Assert.Equal(UserAdminLevel.Normal, (await context.Read("child")).SaLevel);
}
[Fact]
@@ -162,7 +164,7 @@ public sealed class UserManagementTests
using var context = new UserTestContext();
await context.Db.Update<UserEntity>().Set(u => u.Notes, "old note").Set(u => u.Mobile, "13800000000")
.Set(u => u.Points, 42m).Set(u => u.Balance, 11m).Where(u => u.Id == context.Main.Id).ExecuteAffrowsAsync();
var info = await context.Users.Update(context.Main.Id, new() { Notes = "", NickName = " New Name " }, false, default);
var info = await context.Users.Update(new() { Id = context.Main.Id, Notes = "", NickName = " New Name " }, false, default);
Assert.Equal("", info.Notes);
Assert.Equal("13800000000", info.Mobile);
Assert.Equal("New Name", info.NickName);
@@ -197,7 +199,7 @@ public sealed class UserManagementTests
context.Seed("child-two", parent: "main");
context.Seed("foreign-child", parent: "other");
context.CurrentUser.UserName = "main";
var page = await context.Users.Search(new() { Keyword = "child", GroupId = 4, PageSize = 1 }, true, default);
var page = await context.Users.Search(new() { Keyword = "child", GroupId = UserGroup.CustomerService, PageSize = 1 }, true, default);
Assert.Equal(2, page.Total);
Assert.Equal("child-two", Assert.Single(page.Items).UserName);
var second = await context.Users.Search(new() { Page = 2, PageSize = 1 }, true, default);
@@ -269,8 +271,16 @@ public sealed class UserManagementTests
Assert.Contains("other", context.Sessions.SignedOut);
}
[Fact]
public async Task EmptyOwnAccount_CanBeDeleted_AndSessionIsInvalidated()
{
using var context = new UserTestContext();
await context.Users.Delete(context.Admin.Id, default);
Assert.Null(await context.Read("admin"));
Assert.Contains("admin", context.Sessions.SignedOut);
}
[Theory]
[InlineData("self")]
[InlineData("points")]
[InlineData("balance")]
[InlineData("children")]
@@ -279,7 +289,6 @@ public sealed class UserManagementTests
{
using var context = new UserTestContext();
var target = context.Other;
if (condition == "self") target = context.Admin;
if (condition == "children") target = context.Main;
if (condition == "points") await context.Db.Update<UserEntity>().Set(u => u.Points, 1m).Where(u => u.Id == target.Id).ExecuteAffrowsAsync();
if (condition == "balance") await context.Db.Update<UserEntity>().Set(u => u.Balance, 1m).Where(u => u.Id == target.Id).ExecuteAffrowsAsync();
@@ -1,9 +1,10 @@
using Store.Users.Contracts.Enums;
using System.ComponentModel.DataAnnotations;
using Store.Users.Contracts.Dtos;
namespace Store.Users.Tests;
/// <summary>确认从 DTO 移出的长度和范围规则仍在服务写入前生效。</summary>
/// <summary>确认服务入口执行 DTO 的长度和范围校验,拒绝无效写入。</summary>
public sealed class UserServiceValidationTests
{
[Theory]
@@ -13,7 +14,7 @@ public sealed class UserServiceValidationTests
{
using var context = new UserTestContext();
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Delete(id, default));
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(id, new(), false, default));
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(new() { Id = id }, false, default));
}
[Theory]
@@ -35,7 +36,7 @@ public sealed class UserServiceValidationTests
public async Task InvalidEdit_IsRejectedBeforePersistence(string field)
{
using var context = new UserTestContext();
var request = new UserEditRequest();
var request = new UserUpdateRequest { Id = context.Main.Id };
switch (field)
{
case "nickname": request.NickName = new string('x', 51); break;
@@ -47,14 +48,14 @@ public sealed class UserServiceValidationTests
case "forbidden": request.ForbiddenPage = new string('x', 1001); break;
case "oem": request.OemId = new string('x', 51); break;
case "group": request.GroupId = 0; break;
case "level": request.SaLevel = 2; break;
case "level": request.SaLevel = (UserAdminLevel)2; break;
case "monthly": request.ShopPointsPerMonth = 29; break;
case "postage": request.PostagePerOrder = 2.1m; break;
case "refund": request.RefundPostagePerOrder = 2.4m; break;
case "gift": request.GiftOemAmountPerOrder = -1m; break;
case "logistics": request.SyncLogisticsPerOrder = -1m; break;
}
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(context.Main.Id, request, false, default));
await Assert.ThrowsAsync<ValidationException>(() => context.Users.Update(request, false, default));
Assert.Empty(context.Sessions.SignedOut);
Assert.Equal("main", (await context.Read("main")).NickName);
}
+2 -1
View File
@@ -1,3 +1,4 @@
using Store.Users.Contracts.Enums;
using FreeSql;
using MAX.Framework.Security;
using Store.Users.Contracts;
@@ -64,7 +65,7 @@ internal sealed class UserTestContext : IDisposable
var user = new UserEntity
{
UserName = name, NickName = name, Password = "legacy-password", ParentUserName = parent,
GroupId = string.IsNullOrEmpty(parent) ? (byte)3 : (byte)4, SaLevel = level,
GroupId = string.IsNullOrEmpty(parent) ? UserGroup.MainAccount : UserGroup.CustomerService, SaLevel = (UserAdminLevel)level,
Mobile = "", CompanyName = "", Notes = "", CardId = "", Authority = "积分用户,订单",
ForbiddenPage = "", OemId = "", Balance = 0, Points = 0, ShopPointsPerMonth = 30,
PostagePerOrder = 1m, RefundPostagePerOrder = 1m