436 lines
15 KiB
Markdown
436 lines
15 KiB
Markdown
# XXX 项目复刻开发说明
|
||
|
||
> 目的:基于当前项目快速开发一个“风格完全一致”的新项目。
|
||
> 使用方式:先完整阅读本文,再按“复刻步骤清单”执行。
|
||
> 本文最高优先级:出入参模型、分页列表、出入参约定、路由约定、服务约定。后续新增功能如果与历史存量代码存在轻微写法差异,新增代码优先遵循本文,不再继续扩散不一致风格。
|
||
|
||
## 1. 项目定位与技术基线
|
||
|
||
- 运行时:`.NET 8`(`global.json` 固定为 `8.0.303`)。
|
||
- 架构风格:`Fake` 模块化 + 分层架构(API / Application / Repositories / Domain)。
|
||
- 容器与日志:`Autofac` + `Serilog`。
|
||
- 配置中心:`Consul`(启动时加载公共与业务配置)。
|
||
- ORM:`SqlSugar`(MySQL)。
|
||
- 缓存:`FreeRedis`。
|
||
- 消息:`RabbitMQ`(事件总线)。
|
||
- 序列化:`Newtonsoft.Json`(统一驼峰、DateTime/Long 转换)。
|
||
|
||
## 2. 解决方案结构(必须保持)
|
||
|
||
`XXX.sln` 当前核心项目:
|
||
|
||
- `src/XXX.Api`:Web 入口、鉴权、Swagger、CORS、过滤器、路由暴露。
|
||
- `src/XXX.Application`:业务编排层(Service、HostService)。
|
||
- `src/XXX.Domain`:共享枚举、常量、转换器、通用 DTO、实体、仓储接口
|
||
- `src/XXX.Repositories`:仓储实现、数据库访问、基础设施
|
||
|
||
建议新项目保持同样的目录和分层职责,不要把 Repository 逻辑放到 Application,也不要把业务规则放到 API 层。
|
||
|
||
## 3. 复刻时必须优先统一的五类约定
|
||
|
||
这一节是本文最重要的部分。后续开发是否能“照着文档就写出风格一致的代码”,主要取决于这五类约定是否被严格执行。
|
||
|
||
### 3.1 出入参模型分层约定
|
||
|
||
当前项目真实风格是“所有 DTO 都放 Domain”,而是按“写模型”和“读模型”分层:
|
||
|
||
- 出参入参放 `Application/Dtos/<领域>/`。
|
||
- 跨模块复用、通用基类、公共查询基类放 `Domainon/Dtos/`。
|
||
- Entity 只用于持久化,不直接作为 API 出参返回给前端。
|
||
|
||
命名建议按当前项目风格收敛:
|
||
|
||
- 写操作入参:`CreateXxxInput`、`UpdateXxxInput`、`SaveXxxInput`、`CheckXxxInput`、`DeleteXxxInput`。
|
||
- 分页查询入参:`XxxPaginatedInput` 或 `XxxPaginatedInput`。
|
||
- 详情出参:`XxxOutput`。
|
||
- 分页列表出参:`XxxPaginatedOutput` 或 `XxxListOutput`。
|
||
- 树形出参:`XxxTreeListOutput`。
|
||
- 下拉出参:`XxxDropdownOutput` / `XxxDropDownItem`。
|
||
|
||
落地规则:
|
||
|
||
- 同一个领域内,命名风格必须保持一致,不要同时出现 `AppListOutput`、`AppListItemDto` 两种并存写法。
|
||
- “保存类”接口的入参一律使用明确 Input 模型,不要直接收多个散参数。
|
||
- 列表、树、级联、下拉等只读接口,允许直接返回 Dto,这是当前项目主流风格。
|
||
- 集合类型属性统一显式初始化,例如 `= new();`,避免空引用分支扩散。
|
||
|
||
### 3.2 分页列表约定
|
||
|
||
分页是后续最容易写散的部分,必须统一。
|
||
|
||
统一契约:
|
||
|
||
- 分页入参必须继承 `PagedQueryInput`。
|
||
- `PagedQueryInput` 统一只提供 `PageIndex`、`PageSize` 两个基础字段。
|
||
- 分页返回值统一使用 `PagedResult<T>`。
|
||
- 分页查询最终统一通过 `ToPagedListAsync(input.PageIndex, input.PageSize)` 收口。
|
||
- 分页列表默认按 `UpdateTime` 或明确业务时间字段倒序,不允许无序分页。
|
||
|
||
当前项目基线可直接复用:
|
||
|
||
```csharp
|
||
public class PagedQueryInput
|
||
{
|
||
public int PageIndex { get; set; }
|
||
|
||
public int PageSize { get; set; }
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
public static async Task<PagedResult<TEntity>> ToPagedListAsync<TEntity>(
|
||
this ISugarQueryable<TEntity> query,
|
||
int pageIndex,
|
||
int pageSize) where TEntity : class
|
||
{
|
||
RefAsync<int> totalCount = 0;
|
||
var items = await query.ToPageListAsync(pageIndex, pageSize, totalCount);
|
||
|
||
return new PagedResult<TEntity>(totalCount, items);
|
||
}
|
||
```
|
||
|
||
分页接口统一要求:
|
||
|
||
- 简单下拉、树形、级联不要伪装成分页,直接返回 `List<T>`。
|
||
- 复杂筛选分页优先 `POST` + 一个完整 Input 模型,不要拆成很多 Query 参数。
|
||
- 除非确实是动态列场景,否则分页出参必须使用强类型模型,不要默认返回 `Dictionary<string, object?>`。
|
||
- 动态列分页属于例外能力,只能在“字段配置驱动展示列”的场景使用。
|
||
|
||
### 3.3 出入参约定
|
||
|
||
新增接口时,优先按以下规则写:
|
||
|
||
- `GET`:仅用于详情、下拉、字典、简单查询,参数来自 Query。
|
||
- `POST`:用于分页查询、复杂筛选、保存、校验、导入导出等。
|
||
- `PUT`:用于明确语义的更新动作,项目里已有使用,但不是主流;是否使用要在同一领域内保持一致。
|
||
- `DELETE`:仅在该领域已经采用 REST 风格时继续沿用;否则统一使用 `POST("Xxx/Delete")` 或 `POST("Xxx/DeleteByIds")`。
|
||
|
||
参数组织约定:
|
||
|
||
- 简单标量参数用 `[Required]` 标记必填。
|
||
- 可选筛选字段统一使用可空类型或 `string?`。
|
||
- 保存型接口尽量只接收一个 Input 对象。
|
||
- 批量操作参数命名统一为 `ids`、`items`、`list` 之一,不要同义混写。
|
||
- 枚举字段优先直接使用枚举类型,不要在 Application 层传播魔法数字。
|
||
|
||
返回值约定:
|
||
|
||
- 详情:返回单个 `XxxOutput` / `XxxValueObject`。
|
||
- 列表:返回 `List<T>`。
|
||
- 分页:返回 `PagedResult<T>`。
|
||
- 保存新增:优先返回主键 `int` 或 `long`。
|
||
- 修改、删除、校验:优先返回 `bool`。
|
||
- 有业务语义的失败统一抛 `BusinessException`。
|
||
|
||
### 3.4 路由约定
|
||
|
||
路由必须显式声明,不能依赖默认约定路由。
|
||
|
||
统一规则:
|
||
|
||
- 路由模式统一为:`<领域>/<动作>`。
|
||
- 路由使用 PascalCase,与当前项目风格保持一致。
|
||
- 方法名保留 `Async` 后缀,但路由名不要再继续扩散 `Async` 后缀。
|
||
- 新增代码优先在方法上直接写完整业务路由,例如 `[HttpPost("ServiceProvider/PaginatedList")]`。
|
||
- 如果某个 Service 已使用类级别 `[Route("App")]`,则该类内所有 Action 都保持同一套路,不要一半类路由、一半全路径。
|
||
|
||
推荐动作命名:
|
||
|
||
- 分页:`<Domain>/PaginatedList`
|
||
- 树形:`<Domain>/GetTreeList`
|
||
- 下拉:`<Domain>/GetDropdownList`
|
||
- 详情:`<Domain>/GetById` 或 `<Domain>/GetXxxById`
|
||
- 新增:`<Domain>/Create` 或 `<Domain>/Add`
|
||
- 修改:`<Domain>/Update`
|
||
- 删除:`<Domain>/Delete` / `<Domain>/DeleteByIds`
|
||
- 保存型合并动作:`<Domain>/Save`
|
||
- 批量动作:`<Domain>/BatchXxx`
|
||
|
||
|
||
### 3.5 服务约定
|
||
|
||
服务层风格统一,才能保证后续代码一眼能看懂。
|
||
|
||
`ApplicationService` 职责:
|
||
|
||
- 对外暴露 API。
|
||
- 做参数校验、业务校验、权限入口控制。
|
||
- 组织事务边界。
|
||
- 调用 Repository、其他 Service、缓存、事件、外部 HTTP。
|
||
- 做输入输出对象转换。
|
||
|
||
`Repository` 职责:
|
||
|
||
- 封装数据库读写。
|
||
- 封装分页、投影、子查询、多表查询。
|
||
- 对外提供稳定查询方法,不把 SqlSugar 细节扩散到上层。
|
||
|
||
禁止事项:
|
||
|
||
- 不在 API 层写业务逻辑。
|
||
- 不把 Entity 直接返回前端。
|
||
- 不在 Service 中大段拼接复杂多表查询。
|
||
- 不把事务边界拆散到多个 Controller / Action 外部调用中。
|
||
|
||
推荐服务拆分方式:
|
||
|
||
- 一个领域一个主服务,例如 `InterfaceService`、`ServiceProviderService`。
|
||
- 独立子职责可拆成并列服务,例如 `ServiceProviderAuthService`、`ServiceProviderAccountService`。
|
||
- HostService、Forward 流程、过滤器、缓存辅助逻辑不要混进普通 CRUD 服务。
|
||
|
||
依赖注入约定:
|
||
|
||
- 当前项目同时存在构造函数注入和 `LazyServiceProvider.GetRequiredService<T>()` 两种风格。
|
||
- 新增类时可以二选一,但同一个类内不要混用。
|
||
- 如果是复刻某个现有服务,优先延续该服务当前风格,不要做无必要切换。
|
||
|
||
## 4. 全局编码约定(从现有代码提炼)
|
||
|
||
### 4.1 命名与组织
|
||
|
||
- 项目命名采用:`<产品名>.<层名>`,例如 `XXX.Application`。
|
||
- 实体、仓储接口目录放在 `Domain/<领域>Aggregate`
|
||
- 业务服务放在 `Application/Services/<领域>/`。
|
||
- 仓储实现放在 `Repositories/<领域>/`。
|
||
- 只读投影模型放在 `Domain/Dtos/<领域>/`。
|
||
- 写操作 DTO 放在 `Application/Dtos/<领域>/`。
|
||
|
||
### 4.2 API 风格
|
||
|
||
- 业务服务类继承 `ApplicationService`,直接作为控制器暴露。
|
||
- 默认全局鉴权,匿名访问需要明确放开。
|
||
- 统一使用 Swagger 分组标记 `ApiExplorerSettings(GroupName = "...")`。
|
||
- 错误处理使用 `BusinessException` 体现业务语义。
|
||
|
||
### 4.3 数据访问风格
|
||
|
||
- 仓储实现继承 `BasicRepository<TEntity>`。
|
||
- 查询统一基于 `AsQueryable()` + `WhereIF` + `Select<DTO>()`。
|
||
- 分页统一返回 `PagedResult<T>`。
|
||
- 多表/子查询场景通过 `LeftJoin`、`SqlFunc.Subqueryable<>()` 等方式收口在 Repository 内部。
|
||
|
||
### 4.4 事务与一致性
|
||
|
||
- 涉及多表写入(主表 + 参数 + 关联表)必须显式事务。
|
||
- 统一写法:
|
||
|
||
```csharp
|
||
using var ts = TransacationHelper.GetReadCommitted();
|
||
|
||
// 1. 校验
|
||
// 2. 持久化
|
||
// 3. 清缓存 / 发事件
|
||
|
||
ts.Complete();
|
||
```
|
||
|
||
- 事务粒度按“一个完整业务动作”控制,不要半截提交。
|
||
|
||
## 5. 启动链路与模块装配(复刻重点)
|
||
|
||
### 5.1 API 启动(`XXX.Api/Program.cs`)
|
||
|
||
必须保留的关键顺序:
|
||
|
||
1. `CreateSlimBuilder(args)`。
|
||
2. `AddConsul("idwms/common")` 与业务配置节点。
|
||
3. `UseAutofac().UseSerilog()`。
|
||
4. `AddApplication<YourApiModule>()`。
|
||
5. `AddServiceDefaults()`。
|
||
6. `Build()` 后 `InitializeApplication()` 再 `Run()`。
|
||
|
||
### 5.2 API 模块(`XXXApiModule`)
|
||
|
||
模块中应保留这些能力:
|
||
|
||
- 全局 Claim 映射(`FakeClaimTypes.UserId`)。
|
||
- `ApplicationServices2Controller<...>()` 自动控制器映射。
|
||
- 全局授权策略(默认全部接口需登录)。
|
||
- JSON 序列化配置(驼峰 + 自定义 converter)。
|
||
- Swagger + 审计。
|
||
- CORS 从 `App:CorsOrigins` 读取。
|
||
- 代理头配置(支持 Nginx 获取真实 IP)。
|
||
- 中间件顺序:异常处理 -> 默认端点 -> PathBase -> Swagger -> CORS -> Routing -> Auth -> ForwardedHeaders -> Controllers。
|
||
|
||
### 5.3 Application 模块
|
||
|
||
必须包含:
|
||
|
||
- AutoMapper Profile 扫描。
|
||
- Options 绑定(如 `InterfaceRelay`)。
|
||
- HostService 注册。
|
||
- Redis 初始化(缺配置直接抛异常)。
|
||
- 预热逻辑(如 `NatashaInitializer.Preheating()`)。
|
||
- 命名 HttpClient(统一超时与 SSL 策略)。
|
||
|
||
### 5.4 Repositories 模块
|
||
|
||
必须包含:
|
||
|
||
- SqlSugar DbContext 注册(连接串来自 `ConnectionStrings:Default`)。
|
||
- `IBasicRepository<>` 泛型仓储注册。
|
||
- 其他基础设施配置(例如 ElasticSearch Options)。
|
||
|
||
## 6. 配置规范(新项目必须对齐)
|
||
|
||
`src/XXX.Api/appsettings.json` 仅保留轻量入口配置,核心配置走 Consul。
|
||
|
||
建议保持同结构:
|
||
|
||
- `UseLocalConfigs`
|
||
- `Consul:Address`
|
||
- `Consul:Token`
|
||
|
||
同时在 Consul 准备以下节点(按本项目习惯):
|
||
|
||
- 公共节点:`idwms/common`
|
||
- 业务节点:`idwms/<你的业务名>`
|
||
|
||
并确保这些节在配置中心可用:
|
||
|
||
- `App:Urls`
|
||
- `App:CorsOrigins`
|
||
- `ConnectionStrings:Default`
|
||
- `Redis`
|
||
- `ElasticSearch`
|
||
- `Serilog`
|
||
- 业务 Options(如 `InterfaceRelay`)
|
||
|
||
## 7. 业务代码写法模板(建议直接复用)
|
||
|
||
### 7.1 分页列表模板
|
||
|
||
推荐把“分页入参 + 分页出参 + Repository 查询 + Application 暴露”整套一起按下面方式写。
|
||
|
||
```csharp
|
||
public class XxxPaginatedInputObjectValue : PagedQueryInput
|
||
{
|
||
public string? Keyword { get; set; }
|
||
|
||
public int? Status { get; set; }
|
||
}
|
||
|
||
public class XxxPaginatedOutputValueObject
|
||
{
|
||
public int Id { get; set; }
|
||
|
||
public string Name { get; set; }
|
||
|
||
public DateTime UpdateTime { get; set; }
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
public interface IXxxRepository : IBasicRepository<XxxEntity>
|
||
{
|
||
Task<PagedResult<XxxPaginatedOutputValueObject>> GetPaginatedListAsync(
|
||
XxxPaginatedInputObjectValue input);
|
||
}
|
||
|
||
public class XxxRepository : BasicRepository<XxxEntity>, IXxxRepository, IScopedDependency
|
||
{
|
||
public async Task<PagedResult<XxxPaginatedOutputValueObject>> GetPaginatedListAsync(
|
||
XxxPaginatedInputObjectValue input)
|
||
{
|
||
var query = AsQueryable();
|
||
|
||
query.WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), x => x.Name.Contains(input.Keyword!))
|
||
.WhereIF(input.Status != null, x => x.Status == input.Status);
|
||
|
||
return await query
|
||
.Select<XxxPaginatedOutputValueObject>()
|
||
.OrderByDescending(x => x.UpdateTime)
|
||
.ToPagedListAsync(input.PageIndex, input.PageSize);
|
||
}
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
[ApiExplorerSettings(GroupName = "示例分组")]
|
||
public class XxxService : ApplicationService
|
||
{
|
||
private IXxxRepository XxxRepository => LazyServiceProvider.GetRequiredService<IXxxRepository>();
|
||
|
||
[HttpPost("Xxx/PaginatedList")]
|
||
public async Task<PagedResult<XxxPaginatedOutputValueObject>> GetPaginatedListAsync(
|
||
XxxPaginatedInputObjectValue input)
|
||
{
|
||
return await XxxRepository.GetPaginatedListAsync(input);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7.2 保存类模板
|
||
|
||
```csharp
|
||
public class SaveXxxInput
|
||
{
|
||
public OptionType OptionType { get; set; }
|
||
|
||
public int? Id { get; set; }
|
||
|
||
public string Name { get; set; }
|
||
|
||
public List<SaveXxxItemInput> Items { get; set; } = new();
|
||
}
|
||
```
|
||
|
||
```csharp
|
||
[ApiExplorerSettings(GroupName = "示例分组")]
|
||
public class XxxService : ApplicationService
|
||
{
|
||
private IXxxRepository XxxRepository => LazyServiceProvider.GetRequiredService<IXxxRepository>();
|
||
|
||
[HttpPost("Xxx/Save")]
|
||
public async Task<int> SaveAsync(SaveXxxInput input)
|
||
{
|
||
if (input.OptionType == OptionType.删除)
|
||
{
|
||
await XxxRepository.SoftDeleteAsync(x => x.Id == input.Id);
|
||
return input.Id ?? 0;
|
||
}
|
||
|
||
using var ts = TransacationHelper.GetReadCommitted();
|
||
|
||
// 1. 业务校验
|
||
// 2. 主表保存
|
||
// 3. 明细保存
|
||
// 4. 清缓存 / 发事件
|
||
|
||
ts.Complete();
|
||
return input.Id ?? 0;
|
||
}
|
||
}
|
||
```
|
||
|
||
### 7.3 路由模板
|
||
|
||
新增领域时,优先按下面这组动作名铺开,后续别人一看就知道接口在哪:
|
||
|
||
- `Xxx/PaginatedList`
|
||
- `Xxx/GetById`
|
||
- `Xxx/GetDropdownList`
|
||
- `Xxx/Create`
|
||
- `Xxx/Update`
|
||
- `Xxx/DeleteByIds`
|
||
- `Xxx/Save`
|
||
|
||
规则:
|
||
|
||
- 只要用了 `Save`,就让 `Save` 明确承担新增/修改/删除的统一入口,不要同时再出现语义重复的 `Submit`、`Edit`、`DoSave`。
|
||
- 只要用了 `PaginatedList`,同领域内就不要再出现 `GetList`、`GetPageList`、`GetPagedData` 多套命名并存。
|
||
|
||
## 8. 复刻步骤清单(按顺序)
|
||
|
||
1. 复制当前解决方案结构,替换项目名前缀(建议全量替换 `XXX`)。
|
||
2. 保持 `global.json`、`Directory.Build.props`、`common.props` 不变或等价。
|
||
3. 按现有模式创建各层 `Module` 并配置 `[DependsOn]`。
|
||
4. 在 API `Program.cs` 保留 Consul、Serilog、Autofac、Fake 初始化顺序。
|
||
5. 完成 `appsettings.json` 与 Consul 节点准备。
|
||
6. 先建 `ApplicatiDomainon` 公共枚举和分页基类,再建 `Contracts DTO`,再建 `Repositories ValueObjects`,最后写 `Repository` 与 `Application Service`。
|
||
7. 先统一路由命名和分页契约,再批量开发业务接口,避免边写边变风格。
|
||
8. 所有多表写入逻辑补齐事务。
|
||
9. 补齐 AutoMapper Profile。
|
||
10. 通过 AppHost 本地启动联调,再单启 API 验证。
|
||
11. 重点检查:鉴权默认开启、CORS、序列化格式、路径基址、代理头、日志落库/落 ES。 |