admin/开发复刻说明.md
2026-04-15 15:25:38 +08:00

15 KiB
Raw Blame History

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)

必须保留的关键顺序:

  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 暴露”整套一起按下面方式写。

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/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。