Skip to content

后端规范(.NET 10 内核)

改后端代码前后对着这份清单核一遍,每条都是内核里已落地的硬规则。想知道某条为什么这么定,点链接看对应那页的详解。更完整的正反例见仓库 docs/coding-standards.md

第一原则

内核以 NuGet 分发,消费方不改源码就能替换任一部件。凡新增可替换服务,一律 TryAdd* 注册、接口背书、方法拆 virtual 步。这三条没有例外。机制见 可替换性模型

分层落点

  • 依赖只能自上而下,越层禁止:Core(契约)← SqlSugar(数据)← Services(领域+实体)← AspNetCore(宿主)← SmartAdmin(元包)。新增代码先想清楚落哪层;拿不准就回 架构分层 看全景。
  • 运行时依赖只有 SqlSugarCore + Microsoft.*,核心包不引入其它第三方框架。
  • 实体放 Services 层,不放 SqlSugar 层。
  • 每层装配集中在一个 *Setup.csSqlSugarSetupServicesSetupSmartAdminSetup 组合根),不散落注册。

可替换性(ReplaceabilityTests 锁死,当契约看)

  • 内置可替换服务全部 TryAdd*严禁Add*;消费方在 AddSmartAdmin() 之前注册同接口即胜出。
  • 长方法拆成小 virtual 步(如 SessionService.EnforceConcurrencyAsync),消费方覆写一步而非抄整方法。
  • 每个服务先有 I*Service,实现类 virtual
  • 改实体扫描/控制器注册时,务必保留 options.ApplicationAssemblies 挂载路径(业务实体建表、控制器 AddApplicationPart),否则消费方模块静默失效。原理见可替换性模型

实体

  • 定义在 Services/Entities/,系统内核表命名 Sys*
  • 选基类:普通表继承 BaseEntity(主键+审计四件套+软删);需按机构隔离的继承 DataEntity(多带 CreateOrgId 锚点,见下文「数据访问」)。
  • 主键统一 Id(雪花,AOP 填,不手赋)。软删统一 IsDelete,查已删数据要显式 .ClearFilter<ISoftDelete>()
  • 表结构没预留的额外信息塞 ExtJson,不新开列。
  • 特性照 Entities/SysDictType.cs[SugarTable] / 唯一索引 [SugarIndex(IsUnique=true)] / [SugarColumn(Length, ColumnDescription, IsNullable)]
  • 不可变约定(如「Code 创建后不可变」)写进注释,并在 Service 的 Update 里落实(不改该字段)。字段与审计机制见 数据层与审计

服务

  • 一服务一目录:I{X}Service.cs + {X}Service.cs + {X}Models.cs(DTO 用 record,命名 {X}Input/{X}PageInput/{X}Output)。
  • 主构造函数注入依赖,方法 virtual,异步方法 Async 后缀且热路径收 CancellationToken
  • 分页统一 PagedList<T> + .ToPagedListAsync(current, size)
  • 校验用 AdminException.ThrowIf(条件, ErrorCode.X),不手写 if-throw。

控制器

  • [RolePermission] 无参:权限码就是 {METHOD}:/{路由模板}(如 GET:/api/v1/sys/dict/type/page)。代码里永不写 "sys:user:add" 之类魔法串,权限在角色-菜单界面勾路由即配;超管(sadm)放行。
  • 无需特定权限的登录态端点用 [ActiveSession];匿名端点显式 [AllowAnonymous](全局 RequireAuthorization() 兜底,漏挂不会静默公开)。
  • 需审计的写操作挂 [OperationLog(...)]。整模块可关停加 [Module("X")],经 Api:DisabledModules 摘除控制器路由,返 404,不动数据。模块行本身另有两条删除守卫:下挂菜单的一律拒删(ErrorCode.ModuleHasMenus,自建模块同样适用),内置 system 模块按固定 Id 永久保护(ErrorCode.ModuleProtected)。
  • 控制器可直接 return dtoResultEnvelopeFilter 兜底包信封);内置控制器为契约清晰显式 Result<T>.Ok(...)。范例照 Controllers/DictController.cs;信封在管线哪一步套上,请求管线 里有全程。

错误处理

  • 业务错误抛 AdminException(ErrorCode) 或返回 ErrorCode,由 AdminExceptionFilter 统一转信封。
  • ErrorCode 是数字枚举,永不带本地化文案(Core/ErrorCode.cs),i18n 全在前端按 msgKey 翻译。新增错误码要同时加 [MsgKey("error.<模块>.<语义>")](如 error.dict.typeNotFound)并补前端两份语言包:漏标会回退成 error.code.{数值} 原样弹给用户,且 ErrorCodeLocaleConsistencyTests 会让后端测试变红。

数据访问

  • 注入 IRepository<T>;复杂查询走 .AsQueryable(),逃生走 .Db(Db.Deleteable<>() / Db.Ado.UseTranAsync)。
  • 软删与数据范围是全局过滤器,业务代码不重复写过滤条件。
  • 唯一性查重要带上软删行:.ClearFilter<ISoftDelete>().AnyAsync(...),否则会撞库唯一索引抛原生 500。
  • 多写操作包事务 Db.Ado.UseTranAsync,失败整体回滚;缓存失效放事务提交之后,提前清了而事务回滚,缓存和库就对不上。
  • 审计字段(Id 雪花、CreateTime/User/OrgUpdateTime/User)由 AOP 填,只设业务字段。

CreateOrgId 是数据范围锚点

DataEntity 行的 CreateOrgId 不填,机构范围查询里恒为 0 行。它由 AOP 自动填,绝不手动绕过赋值。原理见 多组织数据权限

缓存

  • 模型是 cache-aside(读穿透)+ 显式失效,不是每次查库。增删改后既 RemoveAsync 失效缓存,也广播事件供审计、推送等订阅,例子是 DictService.InvalidateAsyncDictChangedEvent。但默认的 ChannelEventBus 是进程内的,事件不跨副本。跨节点失效要靠共享缓存,或者自己换 IEventBus 接 MQ。
  • 逻辑键集中在 Core/CacheKeys.cs,禁散落魔法串;前缀 Cache:KeyPrefix(默认 smart:)由 provider 统一追加。
  • 默认进程内 MemoryCacheProvider;多实例共享装可选包 SmartAdmin.Caching.RedisAddSmartAdminRedisCache 须在 AddSmartAdmin 之前注册才能赢过 TryAdd(业务代码零改动)。顺序之外还有一道配置开关:吃 IConfiguration 的重载只在 Cache:Provider=Redis 时接管,否则静默退回内存缓存;用 AddSmartAdminRedisCache(connectionString) 则调用即启用。

DI 装配

  • 装配写进 *Setup.cs;内置服务显式 TryAdd(不靠扫描,可预测、可替换),种子用 TryAddEnumerable 按实现类型防重。
  • 无状态服务 Singleton(哈希、验证码生成器、文件存储、缓存 provider、事件总线);按请求 Scoped(多数业务服务,与仓储一致)。

种子数据

  • 实现 ISeedData<TEntity>HasData() 返默认行(返空集合合法=「库里已有就不播种」)。
  • 固定 Id 保幂等:只在缺失时补,不回改已存在行,所以界面上的改动不会被重启覆盖。唯一例外是 SyncOnUpgrade(默认 false):内核的 DefaultMenuSeedDefaultModuleSeed 开着它,种子版本一变就覆盖同 Id 行,所以对内置菜单和模块的界面改动会在升级时丢失(自建的不受影响)。用户会在界面改的数据绝不能开它。
  • Id 必须落保留区间Core/SmartSeedIds.cs):内核 [1, 999]、消费方 ≥ 1000,上限是启动时刻算出的雪花地板;越界、Id=0 或与已有种子 Id 重复,启动即拒。

命名 / 组织

  • 命名空间随目录;一类型一文件;后缀 Sys* 实体、I* 接口、*Service/*Provider/*Filter/*Attribute
  • 启用可空引用类型。时间统一走注入的 TimeProvider(可测试),不用 DateTime.Now 裸调,参考写法是 SessionService。只有一处裸调是故意留的:SchemaVersionSeed 的首次建库时间戳是条静态种子行,没有 DI 时钟可注入。

包管理

  • 增/改依赖在 backend/Directory.Packages.props<PackageVersion> 里,不在各 .csproj 写版本号;共享构建/NuGet 元数据在 backend/Directory.Build.props

注释

  • 公共类型/成员用 /// <summary> 说清职责与边界;设计取舍直接写出理由,不引设计文档节号、任务号、issue 号。
  • 行内注释只解释 WHY(并发、事务顺序、边界、跨方言坑),不复述 WHAT;只写现状,不写变更史;中文,与既有代码一致。
  • 刻意简化 / 有上限的实现用 // ponytail: 标注上限与升级路径。

基于 Apache License 2.0 开源