后端规范(.NET 10 内核)
改后端代码前后对着这份清单核一遍,每条都是内核里已落地的硬规则。想知道某条为什么这么定,点链接看对应那页的详解。更完整的正反例见仓库 docs/coding-standards.md。
第一原则
内核以 NuGet 分发,消费方不改源码就能替换任一部件。凡新增可替换服务,一律 TryAdd* 注册、接口背书、方法拆 virtual 步。这三条没有例外。机制见 可替换性模型。
分层落点
- 依赖只能自上而下,越层禁止:
Core(契约)←SqlSugar(数据)←Services(领域+实体)←AspNetCore(宿主)←SmartAdmin(元包)。新增代码先想清楚落哪层;拿不准就回 架构分层 看全景。 - 运行时依赖只有 SqlSugarCore + Microsoft.*,核心包不引入其它第三方框架。
- 实体放
Services层,不放SqlSugar层。 - 每层装配集中在一个
*Setup.cs(SqlSugarSetup→ServicesSetup→SmartAdminSetup组合根),不散落注册。
可替换性(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 dto(ResultEnvelopeFilter兜底包信封);内置控制器为契约清晰显式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/Org、UpdateTime/User)由 AOP 填,只设业务字段。
CreateOrgId 是数据范围锚点
DataEntity 行的 CreateOrgId 不填,机构范围查询里恒为 0 行。它由 AOP 自动填,绝不手动绕过赋值。原理见 多组织数据权限。
缓存
- 模型是 cache-aside(读穿透)+ 显式失效,不是每次查库。增删改后既
RemoveAsync失效缓存,也广播事件供审计、推送等订阅,例子是DictService.InvalidateAsync→DictChangedEvent。但默认的ChannelEventBus是进程内的,事件不跨副本。跨节点失效要靠共享缓存,或者自己换IEventBus接 MQ。 - 逻辑键集中在
Core/CacheKeys.cs,禁散落魔法串;前缀Cache:KeyPrefix(默认smart:)由 provider 统一追加。 - 默认进程内
MemoryCacheProvider;多实例共享装可选包SmartAdmin.Caching.Redis,AddSmartAdminRedisCache须在AddSmartAdmin之前注册才能赢过TryAdd(业务代码零改动)。顺序之外还有一道配置开关:吃IConfiguration的重载只在Cache:Provider=Redis时接管,否则静默退回内存缓存;用AddSmartAdminRedisCache(connectionString)则调用即启用。
DI 装配
- 装配写进
*Setup.cs;内置服务显式TryAdd(不靠扫描,可预测、可替换),种子用TryAddEnumerable按实现类型防重。 - 无状态服务
Singleton(哈希、验证码生成器、文件存储、缓存 provider、事件总线);按请求Scoped(多数业务服务,与仓储一致)。
种子数据
- 实现
ISeedData<TEntity>,HasData()返默认行(返空集合合法=「库里已有就不播种」)。 - 固定 Id 保幂等:只在缺失时补,不回改已存在行,所以界面上的改动不会被重启覆盖。唯一例外是
SyncOnUpgrade(默认false):内核的DefaultMenuSeed、DefaultModuleSeed开着它,种子版本一变就覆盖同 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:标注上限与升级路径。