15 KiB
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或明确业务时间字段倒序,不允许无序分页。
当前项目基线可直接复用:
public class PagedQueryInput
{
public int PageIndex { get; set; }
public int PageSize { get; set; }
}
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 事务与一致性
- 涉及多表写入(主表 + 参数 + 关联表)必须显式事务。
- 统一写法:
using var ts = TransacationHelper.GetReadCommitted();
// 1. 校验
// 2. 持久化
// 3. 清缓存 / 发事件
ts.Complete();
- 事务粒度按“一个完整业务动作”控制,不要半截提交。
5. 启动链路与模块装配(复刻重点)
5.1 API 启动(XXX.Api/Program.cs)
必须保留的关键顺序:
CreateSlimBuilder(args)。AddConsul("idwms/common")与业务配置节点。UseAutofac().UseSerilog()。AddApplication<YourApiModule>()。AddServiceDefaults()。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。
建议保持同结构:
UseLocalConfigsConsul:AddressConsul:Token
同时在 Consul 准备以下节点(按本项目习惯):
- 公共节点:
idwms/common - 业务节点:
idwms/<你的业务名>
并确保这些节在配置中心可用:
App:UrlsApp:CorsOriginsConnectionStrings:DefaultRedisElasticSearchSerilog- 业务 Options(如
InterfaceRelay)
7. 业务代码写法模板(建议直接复用)
7.1 分页列表模板
推荐把“分页入参 + 分页出参 + Repository 查询 + Application 暴露”整套一起按下面方式写。
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; }
}
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);
}
}
[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 保存类模板
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();
}
[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/PaginatedListXxx/GetByIdXxx/GetDropdownListXxx/CreateXxx/UpdateXxx/DeleteByIdsXxx/Save
规则:
- 只要用了
Save,就让Save明确承担新增/修改/删除的统一入口,不要同时再出现语义重复的Submit、Edit、DoSave。 - 只要用了
PaginatedList,同领域内就不要再出现GetList、GetPageList、GetPagedData多套命名并存。
8. 复刻步骤清单(按顺序)
- 复制当前解决方案结构,替换项目名前缀(建议全量替换
XXX)。 - 保持
global.json、Directory.Build.props、common.props不变或等价。 - 按现有模式创建各层
Module并配置[DependsOn]。 - 在 API
Program.cs保留 Consul、Serilog、Autofac、Fake 初始化顺序。 - 完成
appsettings.json与 Consul 节点准备。 - 先建
ApplicatiDomainon公共枚举和分页基类,再建Contracts DTO,再建Repositories ValueObjects,最后写Repository与Application Service。 - 先统一路由命名和分页契约,再批量开发业务接口,避免边写边变风格。
- 所有多表写入逻辑补齐事务。
- 补齐 AutoMapper Profile。
- 通过 AppHost 本地启动联调,再单启 API 验证。
- 重点检查:鉴权默认开启、CORS、序列化格式、路径基址、代理头、日志落库/落 ES。