Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bb17a67d2 | ||
|
|
150e8644b7 | ||
|
|
7c29e57623 | ||
|
|
33ef90a5cc | ||
|
|
207696d417 | ||
|
|
1fa91a9cfb | ||
|
|
b35f816a87 | ||
|
|
5d017740c6 | ||
|
|
3769193696 | ||
|
|
bb7162aaf6 | ||
|
|
d6471f97d3 | ||
|
|
a87ebeaffb | ||
|
|
96fed35907 | ||
|
|
1865623320 | ||
|
|
78b0c2bb2e | ||
|
|
4a5fada435 | ||
|
|
c9ac7412ab | ||
|
|
292cb8638c | ||
|
|
5ff52dea92 |
No files matched your search
@@ -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 测试替代。
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace MAX.Core.Mediation;
|
||||
|
||||
/// <summary>提供请求自身的参数校验;不合法时抛出 ValidationException。</summary>
|
||||
public interface IValidatableRequest
|
||||
{
|
||||
void Validate();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Adapted from ITIXO/MediatR.Extensions.AttributedBehaviors (MIT).
|
||||
// Copyright (c) 2021 ITIXO s.r.o. See ThirdPartyNotices/MediatR.Extensions.AttributedBehaviors.LICENSE.txt.
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
namespace MAX.Core.Mediation;
|
||||
|
||||
/// <summary>声明命令管道。每个特性独占一行,按源码行号从上到下执行。</summary>
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, AllowMultiple = true, Inherited = false)]
|
||||
public sealed class MediatRBehaviorAttribute(Type behaviorType, [CallerLineNumber] int lineNumber = 0) : Attribute
|
||||
{
|
||||
public Type BehaviorType { get; } = behaviorType ?? throw new ArgumentNullException(nameof(behaviorType));
|
||||
public int LineNumber { get; } = lineNumber;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
@@ -12,10 +13,12 @@ namespace MAX.Framework.Commons
|
||||
public class GlobalExceptionFilter : IExceptionFilter
|
||||
{
|
||||
private readonly ILogger<GlobalExceptionFilter> _logger;
|
||||
private readonly IHostEnvironment _environment;
|
||||
|
||||
public GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger)
|
||||
public GlobalExceptionFilter(ILogger<GlobalExceptionFilter> logger, IHostEnvironment environment)
|
||||
{
|
||||
_logger = logger;
|
||||
_environment = environment;
|
||||
}
|
||||
|
||||
public void OnException(ExceptionContext context)
|
||||
@@ -24,10 +27,13 @@ namespace MAX.Framework.Commons
|
||||
|
||||
var result = context.Exception switch
|
||||
{
|
||||
AppException be => ApiResult.Fail(be.Message, be.Code),
|
||||
ValidationException ve => ApiResult.Fail(ve.Message, ResultCode.ValidationError),
|
||||
UnauthorizedAccessException => ApiResult.Fail("未授权访问", ResultCode.Unauthorized),
|
||||
_ => ApiResult.Fail("服务器内部错误", ResultCode.ServerError)
|
||||
AppException be => ApiResult.Fail(be.Message, be.Code, traceId),
|
||||
ArgumentException arge => ApiResult.Fail(arge.Message, ResultCode.ValidationError, traceId),
|
||||
ValidationException ve => ApiResult.Fail(ve.Message, ResultCode.ValidationError, traceId),
|
||||
UnauthorizedAccessException => ApiResult.Fail("未授权访问", ResultCode.Unauthorized, traceId),
|
||||
_ => ApiResult.Fail(
|
||||
_environment.IsDevelopment() ? context.Exception.Message : "服务器内部错误",
|
||||
ResultCode.ServerError, traceId)
|
||||
};
|
||||
|
||||
_logger.LogError(context.Exception, "请求异常 TraceId: {TraceId}", traceId);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
using Microsoft.AspNetCore.Mvc.Infrastructure;
|
||||
|
||||
namespace MAX.Framework.Conventions;
|
||||
|
||||
/// <summary>为 API 控制器的属性路由统一添加 api 前缀,已含前缀的路由保持不变。</summary>
|
||||
public sealed class ApiRoutePrefixConvention : IApplicationModelConvention
|
||||
{
|
||||
public void Apply(ApplicationModel application)
|
||||
{
|
||||
foreach (var controller in application.Controllers)
|
||||
{
|
||||
if (!controller.Attributes.OfType<IApiBehaviorMetadata>().Any()
|
||||
&& !controller.ControllerType.Assembly.IsDefined(typeof(ApiControllerAttribute), true))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var hasControllerRoute = controller.Selectors.Any(selector => selector.AttributeRouteModel != null);
|
||||
foreach (var selector in controller.Selectors)
|
||||
{
|
||||
if (selector.AttributeRouteModel is { } route)
|
||||
{
|
||||
AddPrefix(route);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var action in controller.Actions)
|
||||
foreach (var selector in action.Selectors)
|
||||
{
|
||||
// 绝对动作路由会覆盖控制器路由,因此需要单独添加前缀。
|
||||
if (selector.AttributeRouteModel is { } route
|
||||
&& (!hasControllerRoute || route.IsAbsoluteTemplate))
|
||||
{
|
||||
AddPrefix(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddPrefix(AttributeRouteModel route)
|
||||
{
|
||||
var template = route.Template ?? "";
|
||||
var absolute = route.IsAbsoluteTemplate;
|
||||
template = template.StartsWith("~/", StringComparison.Ordinal) ? template[2..] : template.TrimStart('/');
|
||||
if (!template.Equals("api", StringComparison.OrdinalIgnoreCase)
|
||||
&& !template.StartsWith("api/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
template = string.IsNullOrEmpty(template) ? "api" : $"api/{template}";
|
||||
}
|
||||
|
||||
route.Template = absolute ? $"~/{template}" : template;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.ActionConstraints;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace MAX.Framework.Conventions;
|
||||
|
||||
/// <summary>
|
||||
/// 控制器内未声明 HTTP 方法的操作默认只接受 GET,供各模块复用。
|
||||
/// 显式 HTTP 方法声明优先;同时补充路由约束和端点元数据,保证接口文档一致。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = true)]
|
||||
public sealed class DefaultHttpGetAttribute : Attribute, IControllerModelConvention
|
||||
{
|
||||
public void Apply(ControllerModel controller)
|
||||
{
|
||||
foreach (var action in controller.Actions)
|
||||
foreach (var selector in action.Selectors)
|
||||
{
|
||||
if (selector.ActionConstraints.OfType<HttpMethodActionConstraint>().Any()
|
||||
|| selector.EndpointMetadata.OfType<IHttpMethodMetadata>().Any())
|
||||
continue;
|
||||
|
||||
selector.ActionConstraints.Add(new HttpMethodActionConstraint([HttpMethods.Get]));
|
||||
selector.EndpointMetadata.Add(new HttpMethodMetadata([HttpMethods.Get]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.ActionConstraints;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationModels;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace MAX.Framework.Conventions;
|
||||
|
||||
/// <summary>
|
||||
/// 控制器内未声明 HTTP 方法的操作默认只接受 POST。
|
||||
/// 同时补充动作约束和端点元数据,使路由匹配与 OpenAPI 文档保持一致;显式方法声明优先。
|
||||
/// </summary>
|
||||
[AttributeUsage(AttributeTargets.Class, Inherited = true)]
|
||||
public sealed class DefaultHttpPostAttribute : Attribute, IControllerModelConvention
|
||||
{
|
||||
public void Apply(ControllerModel controller)
|
||||
{
|
||||
foreach (var action in controller.Actions)
|
||||
foreach (var selector in action.Selectors)
|
||||
{
|
||||
if (selector.ActionConstraints.OfType<HttpMethodActionConstraint>().Any()
|
||||
|| selector.EndpointMetadata.OfType<IHttpMethodMetadata>().Any())
|
||||
continue;
|
||||
|
||||
selector.ActionConstraints.Add(new HttpMethodActionConstraint([HttpMethods.Post]));
|
||||
selector.EndpointMetadata.Add(new HttpMethodMetadata([HttpMethods.Post]));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
using Hangfire;
|
||||
using Hangfire.MySql;
|
||||
using HangfireBasicAuthenticationFilter;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MySql.Data.MySqlClient;
|
||||
|
||||
namespace MAX.Framework.DependencyInjection;
|
||||
|
||||
/// <summary>宿主共享的后台任务存储、执行服务与看板入口。</summary>
|
||||
public static class HangfireServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>从宿主配置注册 Hangfire;任务数据库由独立的 FreeSql 单例管理。</summary>
|
||||
public static IServiceCollection AddStoreHangfire(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
foreach (var key in new[] { "Hangfire:ConnectionString", "Hangfire:Dashboard:User", "Hangfire:Dashboard:Pass" })
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuration[key]))
|
||||
throw new InvalidOperationException($"缺少宿主配置 {key},无法启动 Hangfire。");
|
||||
}
|
||||
|
||||
var connection = new MySqlConnectionStringBuilder(configuration["Hangfire:ConnectionString"])
|
||||
{
|
||||
// Hangfire MySQL 存储的建表和查询语句依赖用户变量。
|
||||
AllowUserVariables = true
|
||||
};
|
||||
services.AddKeyedSingleton<IFreeSql>("HangfireConnection", (_, _) =>
|
||||
new FreeSql.FreeSqlBuilder().UseConnectionString(FreeSql.DataType.MySql, connection.ConnectionString)
|
||||
.UseAdoConnectionPool(true).Build());
|
||||
services.AddHangfire((provider, config) => config
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UseStorage(new MySqlStorage(provider.GetRequiredKeyedService<IFreeSql>("HangfireConnection"),
|
||||
new MySqlStorageOptions { TablesPrefix = "Hangfire_" })));
|
||||
services.AddHangfireServer();
|
||||
services.AddSingleton(new DashboardOptions
|
||||
{
|
||||
Authorization = new[]
|
||||
{
|
||||
new HangfireCustomBasicAuthenticationFilter
|
||||
{
|
||||
User = configuration["Hangfire:Dashboard:User"],
|
||||
Pass = configuration["Hangfire:Dashboard:Pass"]
|
||||
}
|
||||
}
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>在宿主认证、授权中间件之后挂载共享看板。</summary>
|
||||
public static IApplicationBuilder UseStoreHangfire(this IApplicationBuilder app)
|
||||
=> app.UseHangfireDashboard("/hangfire", app.ApplicationServices.GetRequiredService<DashboardOptions>());
|
||||
}
|
||||
@@ -1,127 +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.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"/>(基于 <c>IMemoryCache</c> 的在线会话存储):单点登录、在线人员查询、退出销毁;</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>();
|
||||
|
||||
// 在线会话存储(单点登录):基于 IMemoryCache 的进程内缓存,用于在线人员管理与退出销毁
|
||||
services.AddMemoryCache();
|
||||
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,
|
||||
// 单点登录校验:会话必须仍有效(未退出、未被新登录顶替)
|
||||
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,71 @@
|
||||
using MAX.Framework.Mediation;
|
||||
// Adapted from ITIXO/MediatR.Extensions.AttributedBehaviors (MIT).
|
||||
// Copyright (c) 2021 ITIXO s.r.o. See ThirdPartyNotices/MediatR.Extensions.AttributedBehaviors.LICENSE.txt.
|
||||
using System.Reflection;
|
||||
using MAX.Core.Mediation;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace MAX.Framework.DependencyInjection;
|
||||
|
||||
public static class MediatRBehaviorServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>扫描命令特性,按声明行号注册闭合管道,适配 MediatR 9。</summary>
|
||||
public static IServiceCollection AddMediatRAttributedBehaviors(this IServiceCollection services,
|
||||
params Assembly[] assemblies)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(assemblies);
|
||||
// 先完成全部校验,避免无效配置留下部分注册。
|
||||
var registrations = new List<ServiceDescriptor>();
|
||||
foreach (var request in assemblies.Distinct().SelectMany(a => a.DefinedTypes))
|
||||
{
|
||||
var attributes = request.GetCustomAttributes<MediatRBehaviorAttribute>(false)
|
||||
.OrderBy(a => a.LineNumber).ToArray();
|
||||
if (attributes.Length == 0) continue;
|
||||
if (request.IsAbstract || request.ContainsGenericParameters)
|
||||
throw new InvalidOperationException($"{request.FullName} 必须是具体的闭合请求类型。");
|
||||
var responses = request.GetInterfaces()
|
||||
.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IRequest<>))
|
||||
.Select(i => i.GetGenericArguments()[0]).Distinct().ToArray();
|
||||
if (responses.Length != 1)
|
||||
throw new InvalidOperationException($"{request.FullName} 必须实现唯一的 IRequest<TResponse>(无返回值使用 IRequest)。");
|
||||
if (attributes.GroupBy(a => a.LineNumber).Any(g => g.Count() > 1))
|
||||
throw new InvalidOperationException($"{request.FullName} 的管道特性必须独占一行,不能使用相同的行号。");
|
||||
if (attributes.GroupBy(a => a.BehaviorType).Any(g => g.Count() > 1))
|
||||
throw new InvalidOperationException($"{request.FullName} 重复声明了同一个管道。");
|
||||
|
||||
var serviceType = typeof(IPipelineBehavior<,>).MakeGenericType(request.AsType(), responses[0]);
|
||||
foreach (var attribute in attributes)
|
||||
{
|
||||
var behavior = attribute.BehaviorType;
|
||||
if (behavior.IsGenericTypeDefinition)
|
||||
{
|
||||
if (behavior.GetGenericArguments().Length != 2)
|
||||
throw new InvalidOperationException($"开放泛型管道 {behavior} 必须具有 TRequest、TResponse 两个泛型参数。");
|
||||
try
|
||||
{
|
||||
behavior = behavior.MakeGenericType(request.AsType(), responses[0]);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
throw new InvalidOperationException($"管道 {behavior} 的泛型约束与请求 {request.FullName} 不匹配。", exception);
|
||||
}
|
||||
}
|
||||
if (!behavior.IsClass || behavior.IsAbstract || behavior.ContainsGenericParameters
|
||||
|| !serviceType.IsAssignableFrom(behavior))
|
||||
throw new InvalidOperationException($"管道 {behavior.FullName} 必须是实现 {serviceType} 的具体闭合类型。");
|
||||
registrations.Add(ServiceDescriptor.Transient(serviceType, behavior));
|
||||
}
|
||||
}
|
||||
services.TryAddSingleton<UnitOfWorkExecutionContext>();
|
||||
services.TryAddTransient<ServiceFactory>(provider => provider.GetRequiredService);
|
||||
services.TryAddTransient<IMediator, Mediator>();
|
||||
services.TryAddTransient<ISender>(provider => provider.GetRequiredService<IMediator>());
|
||||
services.TryAddTransient<IPublisher>(provider => provider.GetRequiredService<IMediator>());
|
||||
foreach (var registration in registrations)
|
||||
services.TryAddEnumerable(registration);
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ using Microsoft.Win32;
|
||||
using System.Configuration;
|
||||
using System.Diagnostics;
|
||||
using System.Reflection;
|
||||
using MAX.Plugins;
|
||||
|
||||
namespace MAX.Framework.DependencyInjection
|
||||
{
|
||||
@@ -33,10 +34,13 @@ namespace MAX.Framework.DependencyInjection
|
||||
/// </summary>
|
||||
public static IServiceCollection AddStore(this IHostApplicationBuilder host)
|
||||
{
|
||||
host.AddLogger();
|
||||
Debug.WriteLine("[Store] 开始注册应用服务");
|
||||
RunDebugStep("Logger", "注册日志服务", () => host.AddLogger());
|
||||
//host.Services.AddSingleton<ILoggerFacade>(NLogger.Instance);//注册log
|
||||
|
||||
IEnumerable<Assembly> assemblies = GetApplicationAssemblies();
|
||||
IEnumerable<Assembly> assemblies = StoreAppTypeFinder.Current.GetAssemblies(host.Configuration["Plugins:Path"]).ToArray();
|
||||
foreach (var assembly in assemblies)
|
||||
Debug.WriteLine($"[Assembly] 已发现:{assembly.FullName}");
|
||||
// 同一个类可能同时实现 IStartup 与 IModule,这里缓存实例,保证两个阶段拿到的是同一个对象
|
||||
Dictionary<Type, object> instances = [];
|
||||
return AddStoreCore(host, assemblies, instances);
|
||||
@@ -54,15 +58,18 @@ namespace MAX.Framework.DependencyInjection
|
||||
IEnumerable<Assembly> assemblies,
|
||||
IDictionary<Type, object> instances)
|
||||
{
|
||||
RegisterDependencyMarkers(host.Services, assemblies);
|
||||
RunDebugStep("DI", "注册依赖标记服务", () => RegisterDependencyMarkers(host.Services, assemblies));
|
||||
|
||||
RunModules(host, assemblies, instances);
|
||||
RunDebugStep("Module", "注册全部模块", () => RunModules(host, assemblies, instances));
|
||||
RunDebugStep("Plugin", "注册全部插件", () => RunPlugins(host, assemblies, instances));
|
||||
|
||||
RunStartups(host.Services, assemblies, instances);
|
||||
RunDebugStep("Startup", "初始化并注册启动服务", () => RunStartups(host.Services, assemblies, instances));
|
||||
RunDebugStep("MediatR", "按命令特性注册管道", () => host.Services.AddMediatRAttributedBehaviors(assemblies.ToArray()));
|
||||
|
||||
|
||||
|
||||
host.Services.AddControllers(options =>
|
||||
Debug.WriteLine("[MVC] 开始注册控制器和全局过滤器");
|
||||
var mvc = host.Services.AddControllers(options =>
|
||||
{
|
||||
options.Filters.Add<GlobalExceptionFilter>();
|
||||
options.Filters.Add<UnifyResultFilter>();
|
||||
@@ -87,6 +94,12 @@ namespace MAX.Framework.DependencyInjection
|
||||
};
|
||||
|
||||
});
|
||||
foreach (var assembly in assemblies)
|
||||
{
|
||||
mvc.AddApplicationPart(assembly);
|
||||
Debug.WriteLine($"[MVC] 已添加 ApplicationPart:{assembly.GetName().Name}");
|
||||
}
|
||||
Debug.WriteLine($"[Store] 应用服务注册完成,共 {host.Services.Count} 个服务描述");
|
||||
return host.Services;
|
||||
}
|
||||
|
||||
@@ -97,7 +110,6 @@ namespace MAX.Framework.DependencyInjection
|
||||
{
|
||||
foreach (Type type in GetConcreteTypes(assemblies))
|
||||
{
|
||||
Debug.WriteLine(type.Namespace);
|
||||
Type? marker = DependencyMarkers.FirstOrDefault(m => m.IsAssignableFrom(type));
|
||||
if (marker is null)
|
||||
{
|
||||
@@ -110,6 +122,7 @@ namespace MAX.Framework.DependencyInjection
|
||||
|
||||
// 注册为自身
|
||||
services.Add(new ServiceDescriptor(type, type, lifetime));
|
||||
Debug.WriteLine($"[DI] 注册自身:{type.FullName},Lifetime={lifetime}");
|
||||
|
||||
// 注册为实现的接口(排除标记接口与系统接口)
|
||||
foreach (Type iface in type.GetInterfaces())
|
||||
@@ -120,6 +133,7 @@ namespace MAX.Framework.DependencyInjection
|
||||
}
|
||||
|
||||
services.Add(new ServiceDescriptor(iface, type, lifetime));
|
||||
Debug.WriteLine($"[DI] 注册接口:{iface.FullName} -> {type.FullName},Lifetime={lifetime}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -130,11 +144,11 @@ namespace MAX.Framework.DependencyInjection
|
||||
private static void RunStartups(IServiceCollection services, IEnumerable<Assembly> assemblies, IDictionary<Type, object> instances)
|
||||
{
|
||||
IServiceRegistrar registrar = new ServiceRegistrarProxy(services);
|
||||
Startups = CreateInstances<IStartup>(assemblies, instances).OrderByDescending(s => s.Priority);
|
||||
Startups = CreateInstances<IStartup>(assemblies, instances).Where(s => s is not IPlugin).OrderByDescending(s => s.Priority);
|
||||
foreach (IStartup startup in Startups)
|
||||
{
|
||||
startup.Initialize();
|
||||
startup.Register(registrar);
|
||||
RunDebugStep("Startup", $"初始化 {startup.GetType().FullName},Priority={startup.Priority}", startup.Initialize);
|
||||
RunDebugStep("Startup", $"注册服务 {startup.GetType().FullName}", () => startup.Register(registrar));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,17 +158,17 @@ namespace MAX.Framework.DependencyInjection
|
||||
/// </summary>
|
||||
private static void RunModules(IHostApplicationBuilder host, IEnumerable<Assembly> assemblies, IDictionary<Type, object> instances)
|
||||
{
|
||||
Modules = CreateInstances<IModule>(assemblies, instances).OrderByDescending(s => s.Priority);
|
||||
Modules = CreateInstances<IModule>(assemblies, instances).Where(m => m is not IPlugin).OrderByDescending(s => s.Priority);
|
||||
foreach (IModule module in Modules)
|
||||
{
|
||||
try
|
||||
{
|
||||
module.AddModule(host);
|
||||
RunDebugStep("Module", $"注册 {module.GetType().FullName},Priority={module.Priority}", () => module.AddModule(host));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 带上模块名,避免只看到一个没有上下文的异常
|
||||
throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 AddModule 执行失败。", ex);
|
||||
throw new InvalidOperationException($"模块 {module.GetType().FullName} 的 AddModule 执行失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -171,6 +185,11 @@ namespace MAX.Framework.DependencyInjection
|
||||
instance = Activator.CreateInstance(type, true)
|
||||
?? throw new InvalidOperationException($"无法创建 {type.FullName} 的实例(需要有参数量为 0 的构造函数)。");
|
||||
instances[type] = instance;
|
||||
Debug.WriteLine($"[Instance] 已创建:{type.FullName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.WriteLine($"[Instance] 复用实例:{type.FullName}");
|
||||
}
|
||||
|
||||
yield return (T)instance;
|
||||
@@ -192,6 +211,9 @@ namespace MAX.Framework.DependencyInjection
|
||||
}
|
||||
catch (ReflectionTypeLoadException ex)
|
||||
{
|
||||
Debug.WriteLine($"[Assembly] 部分类型加载失败:{assembly.FullName}");
|
||||
foreach (var error in ex.LoaderExceptions)
|
||||
Debug.WriteLine($"[Assembly] 类型加载错误:{error?.Message}");
|
||||
return ex.Types.OfType<Type>();
|
||||
}
|
||||
}
|
||||
@@ -205,6 +227,57 @@ namespace MAX.Framework.DependencyInjection
|
||||
return StoreAppTypeFinder.Current.GetAssemblies();
|
||||
}
|
||||
|
||||
private sealed class PluginRuntime : IDisposable
|
||||
{
|
||||
public List<(IPlugin Plugin, PluginContext Context)> Entries { get; } = [];
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var entry in Entries) entry.Context.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static void RunPlugins(IHostApplicationBuilder host, IEnumerable<Assembly> assemblies, IDictionary<Type, object> instances)
|
||||
{
|
||||
var runtime = new PluginRuntime();
|
||||
var ids = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
foreach (var plugin in CreateInstances<IPlugin>(assemblies, instances).OrderByDescending(p => p.Priority))
|
||||
{
|
||||
if (plugin is IModule || plugin is IStartup)
|
||||
throw new InvalidOperationException($"插件 {plugin.GetType().FullName} 不能同时实现 IModule/IStartup。");
|
||||
if (string.IsNullOrWhiteSpace(plugin.Id) || !ids.Add(plugin.Id))
|
||||
throw new InvalidOperationException($"插件标识为空或重复:{plugin.Id}");
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(plugin.GetType().Assembly.Location)!;
|
||||
Debug.WriteLine($"[Plugin] 加载独立配置:{plugin.Id},目录={directory},环境={host.Environment.EnvironmentName}");
|
||||
var context = new PluginContext(plugin.Id, directory, host.Environment);
|
||||
runtime.Entries.Add((plugin, context));
|
||||
host.Services.AddKeyedSingleton<PluginContext>(plugin.Id, (provider, _) =>
|
||||
{
|
||||
// 只要上下文被解析,就让容器持有并管理全部插件配置的释放。
|
||||
provider.GetRequiredService<PluginRuntime>();
|
||||
return context;
|
||||
});
|
||||
var typedContext = typeof(PluginContext<>).MakeGenericType(plugin.GetType());
|
||||
host.Services.AddSingleton(typedContext, provider =>
|
||||
// 复用键名注册,确保仅解析泛型上下文时也由容器管理配置的释放。
|
||||
Activator.CreateInstance(typedContext, provider.GetRequiredKeyedService<PluginContext>(plugin.Id))!);
|
||||
RunDebugStep("Plugin", $"注册 {plugin.Id} ({plugin.GetType().FullName}),Priority={plugin.Priority}",
|
||||
() => plugin.ConfigureServices(host.Services, context));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[Plugin] 注册失败:{plugin.Id},{ex.Message}");
|
||||
throw new InvalidOperationException($"插件 {plugin.Id} ({plugin.GetType().Assembly.Location}) 注册失败:{ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
host.Services.AddSingleton(_ => runtime);
|
||||
}
|
||||
catch { runtime.Dispose(); throw; }
|
||||
}
|
||||
|
||||
private static bool IsSystemInterface(Type type)
|
||||
{
|
||||
return type.Namespace is null
|
||||
@@ -213,18 +286,40 @@ namespace MAX.Framework.DependencyInjection
|
||||
}
|
||||
|
||||
|
||||
private static void RunDebugStep(string category, string description, Action action)
|
||||
{
|
||||
Debug.WriteLine($"[{category}] 开始:{description}");
|
||||
try
|
||||
{
|
||||
action();
|
||||
Debug.WriteLine($"[{category}] 完成:{description}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[{category}] 失败:{description},{ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static IApplicationBuilder UseStore(this IApplicationBuilder app)
|
||||
{
|
||||
app.UseMiddleware<UnifyStatusCodeMiddleware>();
|
||||
Debug.WriteLine("[Store] 开始装配应用管道");
|
||||
RunDebugStep("Middleware", "注册统一状态码中间件", () => app.UseMiddleware<UnifyStatusCodeMiddleware>());
|
||||
foreach (var (plugin, context) in app.ApplicationServices.GetRequiredService<PluginRuntime>().Entries)
|
||||
{
|
||||
try { RunDebugStep("Plugin", $"装配管道 {plugin.Id}", () => plugin.Configure(app, context)); }
|
||||
catch (Exception ex) { throw new InvalidOperationException($"插件 {plugin.Id} 管道装配失败:{ex.Message}", ex); }
|
||||
}
|
||||
foreach (IStartup startup in Startups)
|
||||
{
|
||||
startup.Start((app as IHost).Services);
|
||||
RunDebugStep("Startup", $"执行启动回调 {startup.GetType().FullName}", () => startup.Start((app as IHost).Services));
|
||||
}
|
||||
|
||||
foreach (IModule module in Modules)
|
||||
{
|
||||
module.UseModule(app);
|
||||
RunDebugStep("Module", $"装配管道 {module.GetType().FullName}", () => module.UseModule(app));
|
||||
}
|
||||
Debug.WriteLine("[Store] 应用管道装配完成");
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,18 +1,132 @@
|
||||
using MAX.Core.Reflections;
|
||||
using MAX.Core.Reflections;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Loader;
|
||||
|
||||
namespace MAX.Framework.DependencyInjection
|
||||
namespace MAX.Framework.DependencyInjection;
|
||||
|
||||
/// <summary>发现宿主程序集及独立发布的插件;入口过滤统一使用 Match。</summary>
|
||||
internal sealed class StoreAppTypeFinder : AppTypeFinder
|
||||
{
|
||||
/// <summary>
|
||||
/// 本应用的类型查找器:在 AppTypeFinder 默认前缀(MAX. / Nexus.)之外,
|
||||
/// 增加 StoreHub、KJD 前缀,用于发现宿主和各业务模块的程序集。
|
||||
/// </summary>
|
||||
internal sealed class StoreAppTypeFinder : AppTypeFinder
|
||||
{
|
||||
public static StoreAppTypeFinder Current { get; } = new();
|
||||
public static StoreAppTypeFinder Current { get; } = new();
|
||||
private readonly Dictionary<string, AssemblyDependencyResolver> resolvers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly object gate = new();
|
||||
private bool registered;
|
||||
|
||||
protected override List<string> AvailablePrefix()
|
||||
{
|
||||
return ["Store"];
|
||||
}
|
||||
}
|
||||
protected override List<string> AvailablePrefix() => ["Store"];
|
||||
|
||||
public override List<Assembly> GetAssemblies() => GetAssemblies(null);
|
||||
|
||||
public List<Assembly> GetAssemblies(string? pluginPath)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
var path = Path.GetFullPath(string.IsNullOrWhiteSpace(pluginPath) ? "Plugins" : pluginPath, AppContext.BaseDirectory);
|
||||
if (!Directory.Exists(path))
|
||||
return base.GetAssemblies();
|
||||
|
||||
// 每个独立发布目录由入口 .deps.json 标识,避免把共享框架副本当作插件。
|
||||
var entries = Directory.GetFiles(path, "*.deps.json", SearchOption.AllDirectories)
|
||||
.Select(file => file[..^".deps.json".Length] + ".dll")
|
||||
.Where(file => Match(Path.GetFileName(file)))
|
||||
.OrderBy(file => file, StringComparer.OrdinalIgnoreCase).ToArray();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(entry))
|
||||
throw new FileNotFoundException("插件入口 DLL 不存在。", entry);
|
||||
resolvers.TryAdd(entry, new AssemblyDependencyResolver(entry));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new InvalidOperationException($"无法初始化插件 {entry}。", ex);
|
||||
}
|
||||
}
|
||||
if (!registered)
|
||||
{
|
||||
AssemblyLoadContext.Default.Resolving += Resolve;
|
||||
AssemblyLoadContext.Default.ResolvingUnmanagedDll += ResolveNative;
|
||||
registered = true;
|
||||
}
|
||||
|
||||
var plugins = new List<Assembly>();
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assembly = Load(entry);
|
||||
ValidateDependencies(assembly);
|
||||
assembly.GetTypes(); // 插件类型加载失败必须在启动时暴露。
|
||||
plugins.Add(assembly);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var detail = ex is ReflectionTypeLoadException types
|
||||
? string.Join("; ", types.LoaderExceptions.Select(error => error?.Message)) : ex.Message;
|
||||
throw new InvalidOperationException($"加载插件 {entry} 失败:{detail}", ex);
|
||||
}
|
||||
}
|
||||
return base.GetAssemblies().Concat(plugins).DistinctBy(a => a.FullName).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
private static Assembly Load(string path)
|
||||
{
|
||||
var name = AssemblyName.GetAssemblyName(path);
|
||||
var existing = AssemblyLoadContext.Default.Assemblies.FirstOrDefault(a =>
|
||||
string.Equals(a.GetName().Name, name.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (existing is null)
|
||||
return AssemblyLoadContext.Default.LoadFromAssemblyPath(Path.GetFullPath(path));
|
||||
if (!string.Equals(existing.FullName, name.FullName, StringComparison.OrdinalIgnoreCase))
|
||||
throw new FileLoadException($"程序集版本冲突:请求 {name.FullName},已加载 {existing.FullName}。", path);
|
||||
return existing;
|
||||
}
|
||||
|
||||
private void ValidateDependencies(Assembly assembly)
|
||||
{
|
||||
foreach (var reference in assembly.GetReferencedAssemblies())
|
||||
{
|
||||
// 宿主 .deps.json 优先;插件发布目录中的副本仍须满足相同身份。
|
||||
var candidate = ResolvePath(reference);
|
||||
AssemblyLoadContext.Default.LoadFromAssemblyName(reference);
|
||||
if (candidate is not null)
|
||||
{
|
||||
Load(candidate);
|
||||
// 不强制遍历第三方库所有可选功能依赖;插件类型所需依赖由 GetTypes 校验。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolvePath(AssemblyName name)
|
||||
{
|
||||
string? result = null;
|
||||
foreach (var (entry, resolver) in resolvers)
|
||||
{
|
||||
var candidate = resolver.ResolveAssemblyToPath(name);
|
||||
if (candidate is null) continue;
|
||||
var identity = AssemblyName.GetAssemblyName(candidate);
|
||||
if (result is not null && !string.Equals(AssemblyName.GetAssemblyName(result).FullName, identity.FullName, StringComparison.OrdinalIgnoreCase))
|
||||
throw new FileLoadException($"插件依赖冲突:{result} 与 {candidate}(插件 {entry})。");
|
||||
result ??= candidate;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private Assembly? Resolve(AssemblyLoadContext context, AssemblyName name)
|
||||
{
|
||||
var path = ResolvePath(name);
|
||||
return path is null ? null : Load(path);
|
||||
}
|
||||
|
||||
private IntPtr ResolveNative(Assembly assembly, string name)
|
||||
{
|
||||
// 优先使用请求程序集所属插件目录,避免原生库跨插件误匹配。
|
||||
foreach (var (entry, resolver) in resolvers.OrderByDescending(pair =>
|
||||
assembly.Location.StartsWith(Path.GetDirectoryName(pair.Key)! + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
var path = resolver.ResolveUnmanagedDllToPath(name);
|
||||
if (path is not null) return System.Runtime.InteropServices.NativeLibrary.Load(path);
|
||||
}
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,10 @@ namespace MAX.Framework.Dtos
|
||||
/// <summary>提示信息</summary>
|
||||
public string? Msg { get; init; }
|
||||
|
||||
/// <summary>失败请求的跟踪标识,用于关联服务器日志。</summary>
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? TraceId { get; init; }
|
||||
|
||||
/// <summary>请求是否成功</summary>
|
||||
public bool Success => Code == ResultCode.Success;
|
||||
|
||||
@@ -52,7 +56,7 @@ namespace MAX.Framework.Dtos
|
||||
public static implicit operator ApiResult<T>(ApiResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
return new ApiResult<T> { Code = result.Code, Msg = result.Msg, Data = default };
|
||||
return new ApiResult<T> { Code = result.Code, Msg = result.Msg, TraceId = result.TraceId, Data = default };
|
||||
}
|
||||
|
||||
public static ApiResult<T> Ok(T data, string msg = "操作成功")
|
||||
@@ -85,10 +89,10 @@ namespace MAX.Framework.Dtos
|
||||
public static ApiResult Ok()
|
||||
=> new ApiResult { Code = ResultCode.Success, Msg = "操作成功" };
|
||||
|
||||
public static ApiResult Fail(string msg = "操作失败", int code = ResultCode.Fail)
|
||||
public static ApiResult Fail(string msg = "操作失败", int code = ResultCode.Fail, string? traceId = null)
|
||||
{
|
||||
ValidateFailureCode(code);
|
||||
return new ApiResult { Code = code, Msg = msg };
|
||||
return new ApiResult { Code = code, Msg = msg, TraceId = traceId };
|
||||
}
|
||||
|
||||
internal static void ValidateFailureCode(int code)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -11,10 +11,14 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
|
||||
<PackageReference Include="Asp.Versioning.OpenApi" Version="10.2.1" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
|
||||
<PackageReference Include="Hangfire.Dashboard.Basic.Authentication" Version="8.0.0" />
|
||||
<PackageReference Include="Hangfire.MySql" Version="1.0.0" />
|
||||
<!-- 覆盖 Hangfire 的旧传递依赖,修复 CVE-2024-21907。 -->
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<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" />
|
||||
@@ -26,6 +30,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\MAX.Core\MAX.Core.csproj" />
|
||||
<ProjectReference Include="..\MAX.Plugins\MAX.Plugins.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using FreeSql;
|
||||
using MediatR;
|
||||
|
||||
namespace MAX.Framework.Mediation;
|
||||
|
||||
/// <summary>为通过特性选择的命令管理事务;所有读写使用执行上下文中的 ORM。</summary>
|
||||
public sealed class TransactionBehavior<TRequest, TResponse>(
|
||||
IFreeSql freeSql, UnitOfWorkExecutionContext context) : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>
|
||||
{
|
||||
public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,
|
||||
RequestHandlerDelegate<TResponse> next)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (context.IsActive)
|
||||
throw new InvalidOperationException("不支持嵌套事务命令,请在同一个命令处理器内完成事务操作");
|
||||
using var work = freeSql.CreateUnitOfWork();
|
||||
work.GetOrBeginTransaction();
|
||||
using var execution = context.Enter(work.Orm);
|
||||
var response = await next();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
work.Commit();
|
||||
return response;
|
||||
// 未提交时,工作单元释放自动回滚;执行上下文同时清理。
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Threading;
|
||||
|
||||
namespace MAX.Framework.Mediation;
|
||||
|
||||
/// <summary>沿当前异步调用链传递事务 ORM,隔离并行命令的执行状态。</summary>
|
||||
public sealed class UnitOfWorkExecutionContext
|
||||
{
|
||||
private readonly AsyncLocal<IFreeSql?> _current = new();
|
||||
|
||||
public IFreeSql Orm => _current.Value
|
||||
?? throw new InvalidOperationException("当前操作必须在事务管道内执行");
|
||||
|
||||
public bool IsActive => _current.Value != null;
|
||||
|
||||
internal IDisposable Enter(IFreeSql orm)
|
||||
{
|
||||
if (IsActive)
|
||||
throw new InvalidOperationException("不支持嵌套事务命令,请在同一个命令处理器内完成事务操作");
|
||||
_current.Value = orm;
|
||||
return new ExecutionScope(this);
|
||||
}
|
||||
|
||||
private sealed class ExecutionScope(UnitOfWorkExecutionContext context) : IDisposable
|
||||
{
|
||||
public void Dispose() => context._current.Value = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using MAX.Core.Mediation;
|
||||
using MediatR;
|
||||
|
||||
namespace MAX.Framework.Mediation;
|
||||
|
||||
/// <summary>执行请求自身的参数校验,验证通过后才进入后续管道。</summary>
|
||||
public sealed class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : IRequest<TResponse>, IValidatableRequest
|
||||
{
|
||||
public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken,
|
||||
RequestHandlerDelegate<TResponse> next)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
request.Validate();
|
||||
return next();
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,16 @@
|
||||
namespace MAX.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 在线用户会话存储:基于 <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> 的进程内实现,
|
||||
/// 用于单点登录(同一用户仅保留最新会话)、在线人员管理与退出销毁。
|
||||
/// </summary>
|
||||
/// <summary>持久化会话存储;同账号可有多个有效会话。</summary>
|
||||
public interface IOnlineUserStore
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户登录/刷新会话:同用户的新登录会覆盖旧会话,旧令牌立即失效(单点登录)。
|
||||
/// </summary>
|
||||
void SignIn(OnlineUserInfo info);
|
||||
|
||||
/// <summary>
|
||||
/// 判断指定登录账户、指定令牌会话是否仍然有效(未被退出、未被新登录顶替)。
|
||||
/// </summary>
|
||||
bool IsTokenActive(string userName, string jti);
|
||||
|
||||
/// <summary>
|
||||
/// 退出登录:销毁该账户的在线会话,令牌立即失效。
|
||||
/// </summary>
|
||||
void SignOut(string userName);
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前在线人员列表。
|
||||
/// </summary>
|
||||
IReadOnlyList<OnlineUserInfo> GetOnlineUsers();
|
||||
|
||||
/// <summary>
|
||||
/// 当前在线人数。
|
||||
/// </summary>
|
||||
int Count { get; }
|
||||
void SignIn(OnlineUserInfo info);
|
||||
bool IsTokenActive(string userName, string jti);
|
||||
/// <summary>撤销账号全部会话,用于修改密码、禁用等安全操作。</summary>
|
||||
void SignOut(string userName);
|
||||
/// <summary>只撤销指定会话,用于当前客户端退出登录。</summary>
|
||||
void SignOutSession(string userName, string jti);
|
||||
/// <summary>未过期且未撤销的会话列表,不代表浏览器仍然打开。</summary>
|
||||
IReadOnlyList<OnlineUserInfo> GetOnlineUsers();
|
||||
/// <summary>有效会话数。</summary>
|
||||
int Count { get; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace MAX.Framework.Security;
|
||||
|
||||
@@ -24,17 +24,17 @@ public class TokenResult
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// JWT 令牌服务:生成与校验访问令牌。
|
||||
/// 数据库令牌服务:生成与校验访问令牌。
|
||||
/// </summary>
|
||||
public interface ITokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// 根据声明集合生成访问令牌
|
||||
/// </summary>
|
||||
TokenResult CreateToken(IEnumerable<Claim> claims);
|
||||
TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null, string? deviceId = null, string? deviceName = null);
|
||||
|
||||
/// <summary>
|
||||
/// 校验令牌,校验失败返回 null
|
||||
/// </summary>
|
||||
Task<ClaimsPrincipal?> ValidateTokenAsync(string token);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,18 @@
|
||||
|
||||
namespace MAX.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 在线用户会话信息(单点登录:同一用户仅保留一个有效会话)。
|
||||
/// </summary>
|
||||
/// <summary>登录会话展示 DTO;Jti 为数据库 Token 记录 ID。</summary>
|
||||
public class OnlineUserInfo
|
||||
{
|
||||
/// <summary>登录账户(唯一,作为会话键)</summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>用户昵称</summary>
|
||||
public string NickName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>当前有效会话的令牌唯一标识(jti)</summary>
|
||||
public string Jti { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>登录时间(本地时间)</summary>
|
||||
public DateTime LoginTime { get; set; }
|
||||
|
||||
/// <summary>会话过期时间(本地时间,与令牌 exp 一致)</summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public string Jti { get; set; } = string.Empty;
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string NickName { get; set; } = string.Empty;
|
||||
/// <summary>服务端观察到的客户端 IP;反向代理需配置受信任的转发头。</summary>
|
||||
public string LoginIp { get; set; } = string.Empty;
|
||||
/// <summary>UTC 登录时间。</summary>
|
||||
public DateTime LoginTime { get; set; }
|
||||
/// <summary>UTC 当前滑动到期时间。</summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
/// <summary>UTC 撤销时间;保留记录供后续面板查询。</summary>
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,80 +1,22 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MAX.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 <see cref="IMemoryCache"/> 的在线用户会话存储。
|
||||
/// </summary>
|
||||
public class OnlineUserStore : IOnlineUserStore
|
||||
/// <summary>Compatibility facade for existing session management callers.</summary>
|
||||
public sealed class OnlineUserStore(IFreeSql db, TokenService tokens) : IOnlineUserStore
|
||||
{
|
||||
private const string CacheKeyPrefix = "online:user:";
|
||||
private readonly IMemoryCache _cache;
|
||||
// 在线人员索引:IMemoryCache 不提供公共枚举 API,这里用字典维护便于查询在线列表
|
||||
private readonly ConcurrentDictionary<string, OnlineUserInfo> _onlineIndex = new(StringComparer.Ordinal);
|
||||
|
||||
public OnlineUserStore(IMemoryCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SignIn(OnlineUserInfo info)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
|
||||
// 以登录账户为键覆盖写入:旧会话(jti)被顶替,实现单点登录
|
||||
_cache.Set(GetKey(info.UserName), info, new MemoryCacheEntryOptions
|
||||
{
|
||||
// 缓存条目与令牌同时过期,过期后自动失效
|
||||
AbsoluteExpiration = info.ExpiresAt,
|
||||
});
|
||||
_onlineIndex[info.UserName] = info;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsTokenActive(string userName, string jti)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _cache.TryGetValue(GetKey(userName), out OnlineUserInfo? info)
|
||||
&& info is not null
|
||||
&& string.Equals(info.Jti, jti, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SignOut(string userName)
|
||||
{
|
||||
_cache.Remove(GetKey(userName));
|
||||
_onlineIndex.TryRemove(userName, out _);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<OnlineUserInfo> GetOnlineUsers()
|
||||
{
|
||||
DateTime now = DateTime.Now;
|
||||
List<OnlineUserInfo> result = [];
|
||||
foreach (KeyValuePair<string, OnlineUserInfo> pair in _onlineIndex)
|
||||
{
|
||||
if (pair.Value.ExpiresAt > now)
|
||||
{
|
||||
result.Add(pair.Value);
|
||||
}
|
||||
else
|
||||
{
|
||||
_onlineIndex.TryRemove(pair.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Count => GetOnlineUsers().Count;
|
||||
|
||||
private static string GetKey(string userName) => $"{CacheKeyPrefix}{userName}";
|
||||
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 = 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# 数据库 Token 认证
|
||||
|
||||
部署前执行同目录 `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` 表示签发时的闲置有效期;服务端使用滑动过期,客户端无需刷新令牌。
|
||||
|
||||
`TokenAuth` 默认配置:IdleDays=7、AbsoluteMaxDays=30、RenewIntervalMinutes=10、CacheSeconds=30、MaxDevicesPerUser=0。
|
||||
全部时间按 UTC 保存(DATETIME 不带时区);每次使用达到续期间隔才条件更新到期时间,绝不超过创建时间加绝对期限。
|
||||
内存缓存使用固定到期时间,命中不会延长缓存寿命。缓存期间仍检查闲置及绝对期限。
|
||||
|
||||
每次登录创建新记录;相同非空 deviceId 会替换其旧记录。未提供 deviceId 的登录按独立设备计数。
|
||||
设备数为 0 时无限制,为 1 时单点登录,为 N 时保留最多 N 个有效会话,超限撤销最早的会话。
|
||||
签发在事务中锁定现有用户行,使多实例的并发登录也遵守数量限制。MySQL 业务用户表需使用 InnoDB。
|
||||
退出当前会话、修改密码后的全部会话撤销以及设备替换统一更新 Revoked 并清除当前实例缓存。
|
||||
宿主注册 Hangfire 任务 `token-cleanup`,每天北京时间 03:00(UTC 19:00)删除已撤销、闲置到期或达到绝对期限的记录;可在 `/hangfire` 查看执行结果。任务执行需要宿主运行。
|
||||
|
||||
多实例下其他实例最多延迟 CacheSeconds 秒获知撤销;要求每次查库时设置 CacheSeconds=0。
|
||||
用户信息在缓存未命中时从实际 `wxxd.user` 表读取 id/user_name/nick_name;当前用户表没有 IsEnabled 或 Role 字段,因此未假设这些字段或新增角色策略。
|
||||
数据库故障向上抛出,由宿主处理错误,认证不会在数据库不可用时绕过校验。
|
||||
|
||||
切换后旧 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));
|
||||
}
|
||||
}
|
||||
@@ -1,129 +1,162 @@
|
||||
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)
|
||||
{
|
||||
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 = DateTime.Now;
|
||||
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)];
|
||||
/// <summary>清理已撤销或已到期的会话,返回删除数量,供每日后台任务调用。</summary>
|
||||
public int CleanupExpiredTokens()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
var now = Now;
|
||||
var cutoff = now.AddDays(-settings.AbsoluteMaxDays);
|
||||
// 与认证的到期边界一致;直接条件删除,避免先查询再删除时误删已续期的会话。
|
||||
return db.Delete<UserToken>()
|
||||
.Where(t => t.Revoked || t.ExpiresAt <= now || t.CreatedAt <= cutoff)
|
||||
.ExecuteAffrows();
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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? 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,
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MAX.Framework.Validation;
|
||||
|
||||
/// <summary>通用请求校验入口,供各模块复用,不依赖 HTTP、数据库或具体业务。</summary>
|
||||
public static class RequestValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// 检查对象非空,并验证全部属性上的数据注解及 IValidatableObject 规则。
|
||||
/// 不递归验证子对象;对象为空抛出 ArgumentNullException,规则不满足抛出 ValidationException。
|
||||
/// </summary>
|
||||
public static void Validate(object? request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
Validator.ValidateObject(request, new ValidationContext(request), validateAllProperties: true);
|
||||
}
|
||||
|
||||
/// <summary>不抛出规则验证异常,适用于登录等需要自行处理校验结果的入口;对象为空仍视为调用错误。</summary>
|
||||
public static bool TryValidate(object? request, out IReadOnlyList<ValidationResult> errors)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
var results = new List<ValidationResult>();
|
||||
bool valid = Validator.TryValidateObject(request, new ValidationContext(request), results, validateAllProperties: true);
|
||||
errors = results.AsReadOnly();
|
||||
return valid;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MAX.Plugins;
|
||||
|
||||
/// <summary>独立插件入口。实现类必须具有无参构造函数。</summary>
|
||||
public interface IPlugin
|
||||
{
|
||||
string Id { get; }
|
||||
int Priority { get; }
|
||||
void ConfigureServices(IServiceCollection services, PluginContext context);
|
||||
void Configure(IApplicationBuilder app, PluginContext context);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<ItemGroup><FrameworkReference Include="Microsoft.AspNetCore.App" /></ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MAX.Plugins;
|
||||
|
||||
public abstract class PluginBase : IPlugin
|
||||
{
|
||||
public abstract string Id { get; }
|
||||
public virtual int Priority => 0;
|
||||
protected PluginContext Context { get; private set; } = null!;
|
||||
|
||||
public void ConfigureServices(IServiceCollection services, PluginContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
if (!string.Equals(Id, context.Id, StringComparison.Ordinal))
|
||||
throw new ArgumentException("插件上下文标识不匹配。", nameof(context));
|
||||
if (Context is not null) throw new InvalidOperationException($"插件 {Id} 已注册。");
|
||||
Context = context;
|
||||
RegisterServices(services);
|
||||
}
|
||||
|
||||
public void Configure(IApplicationBuilder app, PluginContext context)
|
||||
{
|
||||
if (!ReferenceEquals(Context, context))
|
||||
throw new InvalidOperationException($"插件 {Id} 尚未注册或上下文不匹配。");
|
||||
ConfigureApplication(app);
|
||||
}
|
||||
protected abstract void RegisterServices(IServiceCollection services);
|
||||
protected virtual void ConfigureApplication(IApplicationBuilder app) { }
|
||||
protected string RequireConfiguration(string key) => Context.RequireConfiguration(key);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace MAX.Plugins;
|
||||
|
||||
/// <summary>插件独立配置,不合并宿主或其他插件配置。</summary>
|
||||
public sealed class PluginContext : IDisposable
|
||||
{
|
||||
private int disposed;
|
||||
public string Id { get; }
|
||||
public string DirectoryPath { get; }
|
||||
public IHostEnvironment Environment { get; }
|
||||
public IConfigurationRoot Configuration { get; }
|
||||
|
||||
public PluginContext(string id, string directoryPath, IHostEnvironment environment)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentNullException.ThrowIfNull(environment);
|
||||
Id = id;
|
||||
DirectoryPath = Path.GetFullPath(directoryPath);
|
||||
Environment = environment;
|
||||
Configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(DirectoryPath)
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
|
||||
.AddJsonFile($"appsettings.{environment.EnvironmentName}.json", optional: true, reloadOnChange: false)
|
||||
.AddEnvironmentVariables($"Plugins__{id}__")
|
||||
.Build();
|
||||
}
|
||||
|
||||
public string RequireConfiguration(string key) =>
|
||||
!string.IsNullOrWhiteSpace(Configuration[key]) ? Configuration[key]!
|
||||
: throw new InvalidOperationException($"插件 {Id} 缺少配置 {key},请检查 {Path.Combine(DirectoryPath, "appsettings.json")}。");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) == 0)
|
||||
(Configuration as IDisposable)?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>按插件类型注入的上下文视图,配置及其生命周期由宿主的 PluginContext 统一管理。</summary>
|
||||
public sealed class PluginContext<TPlugin>(PluginContext context) where TPlugin : IPlugin
|
||||
{
|
||||
/// <summary>插件唯一标识。</summary>
|
||||
public string Id => context.Id;
|
||||
/// <summary>插件程序集和配置所在目录。</summary>
|
||||
public string DirectoryPath => context.DirectoryPath;
|
||||
/// <summary>当前宿主环境。</summary>
|
||||
public IHostEnvironment Environment => context.Environment;
|
||||
/// <summary>与插件入口共享的独立配置实例。</summary>
|
||||
public IConfigurationRoot Configuration => context.Configuration;
|
||||
|
||||
/// <summary>读取必填配置,缺失时沿用插件上下文的错误说明。</summary>
|
||||
public string RequireConfiguration(string key) => context.RequireConfiguration(key);
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
<!-- See https://aka.ms/dotnet/msbuild/customize for more details on customizing your build -->
|
||||
<PropertyGroup>
|
||||
<!-- API 和独立插件共用的部署目录,可通过 MSBuild 属性覆盖。 -->
|
||||
<StoreHostOutputPath Condition="'$(StoreHostOutputPath)' == ''">$(MSBuildThisFileDirectory)Bin\</StoreHostOutputPath>
|
||||
<DefaultTargetFramework>net10.0</DefaultTargetFramework>
|
||||
<TargetFramework>$(DefaultTargetFramework)</TargetFramework>
|
||||
<LangVersion>12.0</LangVersion>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<Project>
|
||||
<UsingTask TaskName="FindHostDuplicatePluginFiles" TaskFactory="RoslynCodeTaskFactory"
|
||||
AssemblyFile="$(MSBuildToolsPath)/Microsoft.Build.Tasks.Core.dll">
|
||||
<ParameterGroup>
|
||||
<HostDirectory ParameterType="System.String" Required="true" />
|
||||
<Duplicates ParameterType="Microsoft.Build.Framework.ITaskItem[]" Output="true" />
|
||||
</ParameterGroup>
|
||||
<Task>
|
||||
<Using Namespace="System" />
|
||||
<Using Namespace="System.IO" />
|
||||
<Using Namespace="System.Collections.Generic" />
|
||||
<Using Namespace="System.Security.Cryptography" />
|
||||
<Code Type="Fragment" Language="cs"><![CDATA[
|
||||
var host = Path.GetFullPath(HostDirectory);
|
||||
var plugins = Path.GetFullPath(Path.Combine(host, "Plugins"));
|
||||
var duplicates = new List<Microsoft.Build.Framework.ITaskItem>();
|
||||
if (Directory.Exists(plugins))
|
||||
{
|
||||
foreach (var plugin in Directory.GetDirectories(plugins))
|
||||
{
|
||||
foreach (var file in Directory.GetFiles(plugin, "*", SearchOption.AllDirectories))
|
||||
{
|
||||
var relative = file.Substring(plugin.TrimEnd(Path.DirectorySeparatorChar).Length + 1);
|
||||
// Plugin settings are owned by the plugin even when identical to host settings.
|
||||
if (Path.GetFileName(file).StartsWith("appsettings", StringComparison.OrdinalIgnoreCase)
|
||||
&& string.Equals(Path.GetExtension(file), ".json", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
var shared = Path.GetFullPath(Path.Combine(host, relative));
|
||||
// Only compare files below the host, outside its plugin subtree.
|
||||
if (!shared.StartsWith(host.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
|| shared.StartsWith(plugins + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|
||||
|| !File.Exists(shared) || new FileInfo(file).Length != new FileInfo(shared).Length)
|
||||
continue;
|
||||
using (var hash = SHA256.Create())
|
||||
using (var source = File.OpenRead(file))
|
||||
using (var target = File.OpenRead(shared))
|
||||
{
|
||||
if (Convert.ToBase64String(hash.ComputeHash(source)) == Convert.ToBase64String(hash.ComputeHash(target)))
|
||||
duplicates.Add(new Microsoft.Build.Utilities.TaskItem(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Duplicates = duplicates.ToArray();
|
||||
]]></Code>
|
||||
</Task>
|
||||
</UsingTask>
|
||||
|
||||
<!-- Run again after the host build, so plugin-first build order is supported. -->
|
||||
<Target Name="RemoveHostDuplicatePluginFiles" AfterTargets="Build"
|
||||
Condition="'$(MSBuildProjectName)' == 'Store.Api' and '$(DesignTimeBuild)' != 'true'">
|
||||
<FindHostDuplicatePluginFiles HostDirectory="$(StoreHostOutputPath)">
|
||||
<Output TaskParameter="Duplicates" ItemName="_HostDuplicatePluginFile" />
|
||||
</FindHostDuplicatePluginFiles>
|
||||
<Delete Files="@(_HostDuplicatePluginFile)" />
|
||||
</Target>
|
||||
</Project>
|
||||
@@ -1,123 +0,0 @@
|
||||
using Hangfire;
|
||||
using Hangfire.MySql;
|
||||
using HangfireBasicAuthenticationFilter;
|
||||
using MAX.Core.Interfaces;
|
||||
using MAX.Framework.Interfaces;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MySql.Data.MySqlClient;
|
||||
using Store.Modules.CloudProduct.Services;
|
||||
|
||||
namespace Store.Modules.CloudProduct
|
||||
{
|
||||
|
||||
public class CloudProductModule : IModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 本模块自己的配置文件
|
||||
/// </summary>
|
||||
public const string ConfigurationFileName = "appsettings.CloudProduct.json";
|
||||
|
||||
public int Priority => IntPriority.Normal;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
|
||||
/// </summary>
|
||||
public void AddModule(IHostApplicationBuilder host)
|
||||
{
|
||||
IServiceCollection services = host.Services;
|
||||
IConfigurationManager configuration = host.Configuration;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ConfigurationFileName))
|
||||
{
|
||||
string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName);
|
||||
configuration.AddJsonFile(path, optional: false, reloadOnChange: true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//// 本模块的配置节(Search / RawData / Indexing / Delete)都在模块自己的配置文件里,
|
||||
//// 关键项缺失直接报错,避免 OpenSearch 地址为空、原始数据写到意外目录之类的隐性故障
|
||||
RequireConfiguration(configuration, "Search:Url");
|
||||
RequireConfiguration(configuration, "Search:IndexName");
|
||||
RequireConfiguration(configuration, "Search:FavoritesIndexName");
|
||||
RequireConfiguration(configuration, "RawData:Path");
|
||||
|
||||
// ===== Hangfire 后台定时任务(商品分词同步) =====
|
||||
var hangfireConnection = configuration.GetValue<string>("CloudProductConnection")
|
||||
?? throw new InvalidOperationException("缺少配置 ConnectionStrings:DefaultConnection,CloudProduct 模块的 Hangfire 无法启动。");
|
||||
|
||||
// AllowUserVariables=true:Hangfire.MySqlStorage 的建表/查询语句依赖用户变量
|
||||
var connectionStringBuilder = new MySqlConnectionStringBuilder(hangfireConnection)
|
||||
{
|
||||
AllowUserVariables = true
|
||||
};
|
||||
|
||||
services.AddHangfire(config => config
|
||||
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
|
||||
.UseSimpleAssemblyNameTypeSerializer()
|
||||
.UseRecommendedSerializerSettings()
|
||||
.UseStorage(new MySqlStorage(connectionStringBuilder.ConnectionString, new MySqlStorageOptions() { TablesPrefix = "Hangfire_" })));
|
||||
services.AddHangfireServer();
|
||||
services.AddTransient<GoodsIndexJob>();
|
||||
services.AddTransient<GoodsViolationIndexJob>();
|
||||
services.AddAuthorization(options => options.AddPolicy("CloudGoodsViolationWrite",
|
||||
policy => policy.RequireAuthenticatedUser().RequireClaim("permission", "cloudgoods:violation:write")));
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
|
||||
/// </summary>
|
||||
public void UseModule(IApplicationBuilder app)
|
||||
{
|
||||
try
|
||||
{
|
||||
var Configuration = app.ApplicationServices.GetService<IConfiguration>()!;
|
||||
// Hangfire 后台任务看板(默认地址 /hangfire)
|
||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||
{
|
||||
Authorization = new[]
|
||||
{
|
||||
new HangfireCustomBasicAuthenticationFilter(){
|
||||
User = Configuration.GetValue<string>("Hangfire:Dashboard:User") ?? "admin",
|
||||
Pass = Configuration.GetValue<string>("Hangfire:Dashboard:Pass") ?? "1234560"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 注册定时任务:把未同步(IndexStatus=0)的商品写入 OpenSearch 分词索引
|
||||
var cron = Configuration.GetValue<string>("Indexing:Cron") ?? "*/5 * * * *";
|
||||
|
||||
// 用容器里的 IRecurringJobManager,而不是静态 RecurringJob:
|
||||
// AddHangfire 只把 JobStorage 注册进容器(没有设置 JobStorage.Current),静态 API 在 Build() 之后调用会直接抛异常。
|
||||
app.ApplicationServices.GetRequiredService<IRecurringJobManager>().AddOrUpdate<GoodsIndexJob>(
|
||||
"goods-index-pending",
|
||||
job => job.ProcessPendingAsync(),
|
||||
cron);
|
||||
app.ApplicationServices.GetRequiredService<IRecurringJobManager>().AddOrUpdate<GoodsViolationIndexJob>(
|
||||
"goods-violation-delete-pending", job => job.ProcessPendingAsync(), "* * * * *");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 数据库不可用/表未初始化时避免阻断应用启动(看板与定时任务都会连库),恢复后重启即可重新装配
|
||||
Console.WriteLine("[Hangfire] 看板/定时任务装配失败: " + ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 校验模块配置项存在(值来自模块自己的 <see cref="ConfigurationFileName"/>)
|
||||
/// </summary>
|
||||
private static void RequireConfiguration(IConfiguration configuration, string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuration.GetValue<string>(key)))
|
||||
{
|
||||
throw new InvalidOperationException($"缺少配置 {key},请检查模块配置文件 {ConfigurationFileName}。");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Store.Modules.CloudProduct.Services;
|
||||
|
||||
public class GoodsViolationIndexJob(IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
public async Task ProcessPendingAsync()
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
await scope.ServiceProvider.GetRequiredService<GoodsViolationService>()
|
||||
.DeleteViolationIndexesAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Interfaces;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Store.Modules.CloudProduct.Services;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Store.Modules.CloudProduct
|
||||
{
|
||||
|
||||
public class Startup : IStartup
|
||||
{
|
||||
public int Priority => IntPriority.Normal;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void Register(IServiceRegistrar registrar)
|
||||
{
|
||||
|
||||
|
||||
|
||||
Func<IServiceProvider, IFreeSql> KJDFsqlFactory = r =>
|
||||
{
|
||||
var config = r.GetRequiredService<IConfiguration>();
|
||||
var connection = config.GetValue<string>("CloudProductConnection");
|
||||
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.MySql, connection)
|
||||
.UseAdoConnectionPool(true)
|
||||
.Build();
|
||||
return fsql;
|
||||
};
|
||||
registrar.RegisterSingleton<IFreeSql>("CloudProductConnection", (s, k) =>
|
||||
{
|
||||
return KJDFsqlFactory(s);
|
||||
});
|
||||
}
|
||||
|
||||
public async void Start(IServiceProvider serviceProvider)
|
||||
{
|
||||
var openSearchService = serviceProvider.GetRequiredService<OpenSearchService>();
|
||||
await openSearchService.GetStatusAsync();
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Store.Framework\Store.Framework.csproj" />
|
||||
</ItemGroup>
|
||||
<!--<ItemGroup>
|
||||
--><!-- 本模块自己的配置文件:源码里叫 appsettings.json,构建后复制到宿主输出目录时改名为
|
||||
appsettings.CloudProduct.json,避免和宿主自己的 appsettings.json 互相覆盖。
|
||||
由 builder.AddStoreHub() 自动并入宿主配置树(在 appsettings*.json 之后、环境变量之前)。 --><!--
|
||||
<None Update="appsettings.json" Link="appsettings.CloudProduct.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>-->
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.CloudProduct.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.25" />
|
||||
<PackageReference Include="Hangfire.Dashboard.Basic.Authentication" Version="8.0.0" />
|
||||
<PackageReference Include="Hangfire.MySqlStorage" Version="2.0.3" />
|
||||
<PackageReference Include="OpenSearch.Client" Version="2.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"CloudProductConnection": "Server=192.168.0.200;Port=3306;Database=goods_center;Uid=root;Pwd=123456;Allow User Variables=true",
|
||||
"Search": {
|
||||
"Url": "http://192.168.0.200:9200",
|
||||
"IndexName": "goods_search",
|
||||
"FavoritesIndexName": "user_goods_favorites_search"
|
||||
},
|
||||
"RawData": {
|
||||
"Path": "E:\\GoodsRawData"
|
||||
},
|
||||
"Indexing": {
|
||||
"Mode": "Background",
|
||||
"Cron": "*/5 * * * *",
|
||||
"BatchSize": 20000
|
||||
},
|
||||
"Delete": {
|
||||
"SoftDelete": true
|
||||
},
|
||||
"Hangfire": {
|
||||
"Dashboard" :{
|
||||
"User": "admin",
|
||||
"Pass": "1234560"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
|
||||
using MAX.Core.Interfaces;
|
||||
using MAX.Framework.Interfaces;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using MySql.Data.MySqlClient;
|
||||
|
||||
namespace Store.Modules.CloudProduct
|
||||
{
|
||||
public class Module : IModule
|
||||
{
|
||||
/// <summary>
|
||||
/// 本模块自己的配置文件
|
||||
/// </summary>
|
||||
public const string ConfigurationFileName = "appsettings.ImageCache.json";
|
||||
|
||||
public int Priority => IntPriority.Normal;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// 服务注册阶段(builder.Build() 之前):这里只能拿到容器和配置,碰不到 app
|
||||
/// </summary>
|
||||
public void AddModule(IHostApplicationBuilder host)
|
||||
{
|
||||
IServiceCollection services = host.Services;
|
||||
IConfigurationManager configuration = host.Configuration;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ConfigurationFileName))
|
||||
{
|
||||
string path = Path.IsPathRooted(ConfigurationFileName)? ConfigurationFileName: Path.Combine(AppContext.BaseDirectory, ConfigurationFileName);
|
||||
configuration.AddJsonFile(path, optional: false, reloadOnChange: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 管道装配阶段(builder.Build() 之后、app.Run() 之前):这里可以挂中间件、解析容器里的服务
|
||||
/// </summary>
|
||||
public void UseModule(IApplicationBuilder app)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using MAX.Core.DependencyInjection;
|
||||
using MAX.Core.Interfaces;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Store.Modules.CloudProduct
|
||||
{
|
||||
|
||||
public class Startup : IStartup
|
||||
{
|
||||
public int Priority => IntPriority.Normal;
|
||||
|
||||
public void Initialize()
|
||||
{
|
||||
}
|
||||
|
||||
public void Register(IServiceRegistrar registrar)
|
||||
{
|
||||
|
||||
|
||||
|
||||
Func<IServiceProvider, IFreeSql> KJDFsqlFactory = r =>
|
||||
{
|
||||
var config = r.GetRequiredService<IConfiguration>();
|
||||
var connection = config.GetValue<string>("ImageCache:ConnectionString");
|
||||
IFreeSql fsql = new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.MySql, connection)
|
||||
.UseAdoConnectionPool(true)
|
||||
.Build();
|
||||
return fsql;
|
||||
};
|
||||
registrar.RegisterSingleton<IFreeSql>("ImageCacheConnection", (s, k) =>
|
||||
{
|
||||
return KJDFsqlFactory(s);
|
||||
});
|
||||
}
|
||||
|
||||
public void Start(IServiceProvider serviceProvider)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Core\MAX.Plugins\MAX.Plugins.csproj" />
|
||||
<ProjectReference Include="..\Store.Users.Contracts\Store.Users.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ namespace Store.Users.Entities
|
||||
/// 过期时间
|
||||
/// </summary>
|
||||
// 数据库列名末尾包含 U+200C,不能改成普通 expire_time。
|
||||
[Column(Name = "expire_time")]
|
||||
[Column(Name = "expire_time")]
|
||||
|
||||
public int ExpireTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
# 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` 配置键保持兼容。
|
||||
|
||||
## 仓储查询入口
|
||||
|
||||
当前仓储不使用内存缓存或 Redis。单店到期时间经 `ShopQueryService → IShopRepository.GetExpireTime` 读取;列表到期信息经 `ShopService → IShopRepository.GetExpireTimes` 一次批量查询数据库,不逐店查询。
|
||||
|
||||
空集合返回空结果,列表缺失到期记录按 0 处理,永久有效店铺由 Service 覆盖到期时间。列表的数据范围和跨月额度重置仍在 Service。续费沿用原有事务内 ORM 读写,不包含缓存失效管道。
|
||||
|
||||
本次只集中上述公共查询,没有将模块内全部 SQL 迁入 Repository。后续确需缓存时,再按查询的实时性要求实现,并覆盖事务提交后的失效路径。
|
||||
|
||||
## 调用方式
|
||||
|
||||
所有接口使用当前宿主认证,路由为 `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;数据库结构、范围锁并发以及客户端调用仍需部署环境联调。可继续进行代码审查。
|
||||
@@ -17,6 +17,9 @@ namespace Store.Shops.Repositories
|
||||
/// <param name="ShopId"></param>
|
||||
/// <returns></returns>
|
||||
DateTime GetExpireTime(string ShopId);
|
||||
|
||||
/// <summary>批量读取店铺到期记录;未找到的店铺不返回记录,供列表补充到期信息。</summary>
|
||||
Task<List<ShopExpireEntity>> GetExpireTimes(string[] shopIds, CancellationToken cancellationToken = default);
|
||||
}
|
||||
internal class ShopRepository : BaseRepository<ShopEntity>, IShopRepository, ITransientDependency
|
||||
{
|
||||
@@ -27,6 +30,10 @@ namespace Store.Shops.Repositories
|
||||
_freeSql = freeSql;
|
||||
}
|
||||
|
||||
/// <summary>集中列表使用的到期查询,保持一次批量读取;续费事务仍使用事务内 ORM。</summary>
|
||||
public Task<List<ShopExpireEntity>> GetExpireTimes(string[] shopIds, CancellationToken cancellationToken = default)
|
||||
=> _freeSql.Select<ShopExpireEntity>().Where(e => shopIds.Contains(e.ShopId)).ToListAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// 获取用户商铺过期时间
|
||||
/// </summary>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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.Shops.Repositories;
|
||||
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,
|
||||
IShopRepository shops) : 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 shops.GetExpireTimes(ids, ct);
|
||||
var paid = actor.Authority?.Contains("充值用户") == true;
|
||||
if (!admin && !string.IsNullOrEmpty(actor.ParentUserName))
|
||||
paid |= (await users.GetUser(actor.ParentUserName)).Authority?.Contains("充值用户") == true;
|
||||
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("店铺不存在或无删除权限");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace Store.Users.Contracts.Dtos;
|
||||
|
||||
/// <summary>资金请求;DTO 检查输入,服务区分积分/余额操作并校验权限。</summary>
|
||||
public sealed class UserFundsRequest : IValidatableObject
|
||||
{
|
||||
[Required(ErrorMessage = "目标账户不能为空"), StringLength(20)] public string UserName { get; set; } = "";
|
||||
[Required(ErrorMessage = "请求号不能为空"), StringLength(64)] public string RequestId { get; set; } = "";
|
||||
[Range(typeof(decimal), "0.01", "1000000")]
|
||||
public decimal Amount { get; set; }
|
||||
/// <summary>积分支持充值、转移;余额支持充值、消费。</summary>
|
||||
[Required(ErrorMessage = "操作类型不能为空"), StringLength(20)] public string Operation { get; set; } = "";
|
||||
[StringLength(255)]
|
||||
public string? Remark { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (decimal.Round(Amount, 2) != Amount)
|
||||
yield return new ValidationResult("金额最多保留两位小数", [nameof(Amount)]);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UserFundsResponse(string UserName, decimal Value, string RequestId);
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace Store.Users.Contracts.Dtos;
|
||||
|
||||
/// <summary>按用户名查询账户;声明用户名非空及长度。</summary>
|
||||
public sealed class UserNameRequest
|
||||
{
|
||||
[Required(ErrorMessage = "用户名不能为空"), StringLength(20)]
|
||||
public string UserName { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>登录输入;输入在 DTO 中检查,账号存在及密码匹配由认证服务处理。</summary>
|
||||
public sealed class UserLoginRequest
|
||||
{
|
||||
[Required(ErrorMessage = "用户名不能为空"), StringLength(20)] public string UserName { get; set; } = "";
|
||||
[Required(ErrorMessage = "密码不能为空"), StringLength(128)] public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>按代理编码查询充值二维码。</summary>
|
||||
public sealed class UserTopUpQrcodeQuery
|
||||
{
|
||||
[Required(ErrorMessage = "代理编码不能为空"), StringLength(50)] public string AgentCode { get; set; } = "";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using Store.Users.Contracts.Enums;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using System.Text;
|
||||
@@ -24,7 +25,8 @@ namespace Store.Users.Contracts.Dtos
|
||||
/// <summary>
|
||||
/// 登录密码
|
||||
/// </summary>
|
||||
public string Password { get; set; }
|
||||
[System.Text.Json.Serialization.JsonIgnore]
|
||||
public string Password { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// card_id
|
||||
@@ -32,9 +34,9 @@ namespace Store.Users.Contracts.Dtos
|
||||
public string CardId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户分组 3主号 4客服 5加盟 8登录 9采购 10加盟 11上货
|
||||
/// 用户分组,参见 <see cref="UserGroup"/>。
|
||||
/// </summary>
|
||||
public byte GroupId { get; set; }
|
||||
public UserGroup GroupId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 父账户用户名
|
||||
@@ -72,9 +74,9 @@ namespace Store.Users.Contracts.Dtos
|
||||
public string Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 超管等级 0 普通用户 1 超级管理员
|
||||
/// 超管等级,参见 <see cref="UserAdminLevel"/>。
|
||||
/// </summary>
|
||||
public short? SaLevel { get; set; }
|
||||
public UserAdminLevel? SaLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 账户权限
|
||||
@@ -137,7 +139,7 @@ namespace Store.Users.Contracts.Dtos
|
||||
/// </summary>
|
||||
public bool IsMainUser
|
||||
{
|
||||
get => string.IsNullOrWhiteSpace(ParentUserName);
|
||||
get => GroupId == UserGroup.MainAccount && string.IsNullOrWhiteSpace(ParentUserName);
|
||||
}
|
||||
/// <summary>
|
||||
/// 是否OEM租户
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using Store.Users.Contracts.Enums;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace Store.Users.Contracts.Dtos;
|
||||
|
||||
/// <summary>账户资料;null 保留原值,空字符串清空文本。输入约束由 DTO 声明,权限与账户关系由服务检查。</summary>
|
||||
public class UserEditRequest
|
||||
{
|
||||
[StringLength(50)]
|
||||
public string? NickName { get; set; }
|
||||
[StringLength(30)]
|
||||
public string? Mobile { get; set; }
|
||||
[StringLength(100)]
|
||||
public string? CompanyName { get; set; }
|
||||
[StringLength(255)]
|
||||
public string? Notes { get; set; }
|
||||
[StringLength(50)]
|
||||
public string? CardId { get; set; }
|
||||
[EnumDataType(typeof(UserGroup), ErrorMessage = "请选择有效的用户分组")]
|
||||
public UserGroup? GroupId { get; set; }
|
||||
/// <summary>仅管理员可设置;普通账号不能通过资料修改提升管理员级别。</summary>
|
||||
[EnumDataType(typeof(UserAdminLevel), ErrorMessage = "管理员级别只能为普通用户或超级管理员")]
|
||||
public UserAdminLevel? SaLevel { get; set; }
|
||||
[StringLength(1000)]
|
||||
public string? Authority { get; set; }
|
||||
[StringLength(1000)]
|
||||
public string? ForbiddenPage { get; set; }
|
||||
[StringLength(50)]
|
||||
public string? OemId { get; set; }
|
||||
[Range(30, int.MaxValue, ErrorMessage = "每月续费积分不能低于30")]
|
||||
public int? ShopPointsPerMonth { get; set; }
|
||||
[Range(typeof(decimal), "2.2", "999999")]
|
||||
public decimal? PostagePerOrder { get; set; }
|
||||
[Range(typeof(decimal), "2.5", "999999")]
|
||||
public decimal? RefundPostagePerOrder { get; set; }
|
||||
[Range(typeof(decimal), "0", "999999")]
|
||||
public decimal? GiftOemAmountPerOrder { get; set; }
|
||||
[Range(typeof(decimal), "0", "999999")]
|
||||
public decimal? SyncLogisticsPerOrder { get; set; }
|
||||
|
||||
}
|
||||
|
||||
/// <summary>修改账户资料;账户 ID 与资料字段统一通过 JSON 请求体提交。</summary>
|
||||
public sealed class UserUpdateRequest : UserEditRequest
|
||||
{
|
||||
[Range(1, int.MaxValue, ErrorMessage = "ID必须大于0")]
|
||||
public int Id { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>创建主账号或当前主账号的直属子账号。</summary>
|
||||
public sealed class UserCreateRequest : UserEditRequest
|
||||
{
|
||||
/// <summary>创建子账号时的父账号;管理员可指定任意主账号,普通主账号只能指定自己。</summary>
|
||||
[StringLength(20)]
|
||||
public string? ParentUserName { get; set; }
|
||||
[Required(ErrorMessage = "用户名不能为空"), StringLength(20)] public string UserName { get; set; } = "";
|
||||
[Required(ErrorMessage = "密码不能为空"), StringLength(128, MinimumLength = 6)] public string Password { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>非管理员本人改密需原密码;管理员可重置任意账户,主账号可重置直属子账号密码。</summary>
|
||||
public sealed class ChangePasswordRequest
|
||||
{
|
||||
[StringLength(20)]
|
||||
public string? UserName { get; set; }
|
||||
[StringLength(128)]
|
||||
public string? OldPassword { get; set; }
|
||||
[Required(ErrorMessage = "新密码不能为空"), StringLength(128, MinimumLength = 6)] public string NewPassword { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>本人修改密码时验证原密码非空。</summary>
|
||||
public sealed class UserPasswordProofRequest
|
||||
{
|
||||
[Required(ErrorMessage = "原密码不能为空")] public string OldPassword { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>分页参数;在查询数据库前完成范围校验。</summary>
|
||||
public class UserSearchRequest
|
||||
{
|
||||
[Range(1, 1000000)]
|
||||
public int Page { get; set; } = 1;
|
||||
[Range(1, 200)]
|
||||
public int PageSize { get; set; } = 50;
|
||||
[StringLength(100)]
|
||||
public string? Keyword { get; set; }
|
||||
[EnumDataType(typeof(UserGroup), ErrorMessage = "请选择有效的用户分组")]
|
||||
public UserGroup? GroupId { get; set; }
|
||||
/// <summary>查询子账号时可按父账号筛选;管理员可指定任意账号,普通主账号只能指定自己。</summary>
|
||||
[StringLength(20)]
|
||||
public string? ParentUserName { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public sealed record UserPage<T>(long Total, IReadOnlyList<T> Items);
|
||||
|
||||
/// <summary>流水查询支持时间范围、操作类型、店铺及订单过滤。</summary>
|
||||
public sealed class UserRecordSearchRequest : UserSearchRequest, IValidatableObject
|
||||
{
|
||||
/// <summary>目标账户;省略时查询本人。</summary>
|
||||
[StringLength(20)]
|
||||
public string? UserName { get; set; }
|
||||
public DateTime? StartTime { get; set; }
|
||||
public DateTime? EndTime { get; set; }
|
||||
[StringLength(20)]
|
||||
public string? Operation { get; set; }
|
||||
[StringLength(50)]
|
||||
public string? ShopId { get; set; }
|
||||
public int? PaymentType { get; set; }
|
||||
[StringLength(100)]
|
||||
public string? OrderId { get; set; }
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (StartTime > EndTime)
|
||||
yield return new ValidationResult("开始时间不能晚于结束时间", [nameof(StartTime), nameof(EndTime)]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>单个账户 ID 参数的校验,供服务内部调用复用。</summary>
|
||||
public sealed class UserIdRequest
|
||||
{
|
||||
[Range(1, int.MaxValue, ErrorMessage = "ID必须大于0")]
|
||||
public int Id { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
namespace Store.Users.Contracts.Dtos;
|
||||
|
||||
/// <summary>用户提交的充值备注。</summary>
|
||||
public sealed class UserTopUpMessageRequest
|
||||
{
|
||||
/// <summary>目标账户;省略时为本人,指定时检查管理权限。</summary>
|
||||
[StringLength(20)]
|
||||
public string? UserName { get; set; }
|
||||
[Required(ErrorMessage = "充值备注不能为空"), StringLength(1000)] public string Message { get; set; } = "";
|
||||
}
|
||||
|
||||
public sealed class UserTopUpResultRequest
|
||||
{
|
||||
[Range(1, long.MaxValue, ErrorMessage = "ID必须大于0")]
|
||||
public long Id { get; set; }
|
||||
[Required(ErrorMessage = "处理结果不能为空"), StringLength(1000)] public string Result { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>充值二维码;非空、长度与 Base64 格式由 DTO 检查。</summary>
|
||||
public sealed class UserTopUpQrcodeRequest : IValidatableObject
|
||||
{
|
||||
[Required(ErrorMessage = "代理编码不能为空"), StringLength(50)] public string AgentCode { get; set; } = "";
|
||||
[Required(ErrorMessage = "二维码不能为空"), StringLength(2097152)] public string QrcodeBase64 { get; set; } = "";
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
var content = QrcodeBase64;
|
||||
if (content.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int comma = content.IndexOf(',');
|
||||
if (comma < 0 || !content[..comma].EndsWith(";base64", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return new ValidationResult("二维码格式错误", [nameof(QrcodeBase64)]);
|
||||
yield break;
|
||||
}
|
||||
content = content[(comma + 1)..];
|
||||
}
|
||||
bool valid;
|
||||
try { valid = Convert.FromBase64String(content).Length > 0; }
|
||||
catch (FormatException) { valid = false; }
|
||||
if (!valid) yield return new ValidationResult("二维码必须为有效的Base64数据", [nameof(QrcodeBase64)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Store.Users.Contracts.Enums;
|
||||
|
||||
/// <summary>超管等级,数值与旧系统 sa_level 保持一致。</summary>
|
||||
public enum UserAdminLevel : short
|
||||
{
|
||||
/// <summary>普通用户。</summary>
|
||||
[Description("普通用户")] Normal = 0,
|
||||
/// <summary>超级管理员。</summary>
|
||||
[Description("超级管理员")] SuperAdmin = 1
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Store.Users.Contracts.Enums;
|
||||
|
||||
/// <summary>用户分组,数值与旧系统 group_id 保持一致。</summary>
|
||||
public enum UserGroup : byte
|
||||
{
|
||||
/// <summary>主号。</summary>
|
||||
[Description("主号")] MainAccount = 3,
|
||||
/// <summary>客服。</summary>
|
||||
[Description("客服")] CustomerService = 4,
|
||||
/// <summary>加盟(旧系统分组 5)。</summary>
|
||||
[Description("加盟(5)")] Franchise5 = 5,
|
||||
/// <summary>登录。</summary>
|
||||
[Description("登录")] Login = 8,
|
||||
/// <summary>采购。</summary>
|
||||
[Description("采购")] Purchasing = 9,
|
||||
/// <summary>加盟(旧系统分组 10);保留与分组 5 的区别。</summary>
|
||||
[Description("加盟(10)")] Franchise10 = 10,
|
||||
/// <summary>上货。</summary>
|
||||
[Description("上货")] Listing = 11
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Store.Users.Contracts.Dtos;
|
||||
namespace Store.Users.Contracts;
|
||||
|
||||
/// <summary>用户认证入口,统一供宿主签发登录令牌之前调用。</summary>
|
||||
public interface IUserAuthenticationService
|
||||
{
|
||||
Task<UserInfo?> Authenticate(string userName, string password, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace Store.Users.Contracts
|
||||
{
|
||||
public interface IUserQueryService
|
||||
{
|
||||
Task<UserInfo> GetMainUser(string userName);
|
||||
Task<UserInfo> GetUser(string userName);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 237 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user