开发规范

开发约定反模式清单

对照常见反例定位分层、上下文、数据访问与验证问题。

浏览全部手册
本页目录
文档来源与 Markdown 原文

权威正文:ineed-core/docs/project-development/convention-anti-patterns.md。网站按工作区快照同步,原文中的历史日期和验证范围保留。

开始编码前核对同版本源码;跨仓文件引用可在源码定位目录查找。

下载 Markdown 原文 ↓

本文档列出基于 Ineed-Core 开发时经常出现的错误写法。关于分层职责边界、多表协作、Service/Manager 编排的正确做法,详见 custom-business-layering-guide.md

会话上下文拆散传参

错误方式:调用方已经拿到 SecuritySession,却继续把 identityTypeprincipalIdtenantKey 等字段拆散后逐层传递。

问题:

  • 接口参数越来越多,语义反而更不完整
  • 会话字段一旦扩展,调用链会发生连锁改签名
  • 很容易出现某些字段传了、某些字段忘了传的半残调用

正确方式:围绕当前登录上下文工作的扩展接口,优先直接接收 SecuritySession 或等价聚合上下文 DTO。

把登录状态重新塞回 JWT

错误方式:为了让前端少查接口,把完整用户、租户、权限快照、完整 Session 对象或服务端过期策略重新写进 JWT payload。

问题:

  • JWT 一旦泄漏,暴露的信息面会变大
  • 用户权限、租户或会话状态变化后,旧 JWT 很容易与服务端真实状态不一致
  • 强制下线和会话续期会被 token 自身内容干扰,最终形成两套登录状态来源

正确方式:JWT 只作为传输凭证,payload 只保留 sid 等最小会话标识。服务端通过 sid -> SecuritySession 从 Redis 读取权威登录状态;前端不要解析 token 驱动页面身份、租户或权限。

在线会话继续使用用户维度命名

错误方式:把在线会话管理继续命名为用户维度的类名、路径或资源标识,或者用 principalId、用户名、token 全串作为强制下线主键。

问题:

  • 一个用户可能存在多个设备、浏览器或登录实例,在线对象本质是会话而不是用户
  • 用用户维度强制下线会误伤同账号的其他会话
  • 旧命名会让前端权限点、接口路径和 Redis 索引语义继续漂移

正确方式:在线会话统一使用 OnlineSessionVOonline-sessionsecurity.online-session,查询和强制下线以 sid 为会话标识。

同步与响应式扩展契约不分线

错误方式:为同一扩展点继续保留未分线的旧接口,或者把阻塞式与响应式方法混在同一个契约里。

问题:

  • 同步与响应式语义会互相污染
  • webmvcwebflux 的适配关系不清楚
  • 后续很容易出现一侧改了签名、另一侧还挂在旧接口上的漂移

正确方式:同一扩展点按阻塞式与响应式分开定义;阻塞式放 service/,响应式放 service/reactive/

共享扩展契约落在实现模块

错误方式:只在 webmvcwebflux 实现模块中定义接口,再让其他模块反向依赖这些实现层包名。

问题:

  • 共享契约无法被两条 Web 线稳定复用
  • 发起方与实现方的依赖方向会变得混乱
  • 实现模块一旦调整包名,跨模块调用会直接断裂

正确方式:跨模块复用的共享扩展契约优先放在发起方 core 共享层;webmvc / webflux 模块只保留各自适配实现。

响应式契约乱放包

错误方式:响应式接口或响应式适配器继续散落在 service 根包或任意自定义包名下。

问题:

  • 同一模块中同步与响应式目录结构不稳定
  • 搜索和维护响应式扩展点时难以快速定位
  • 后续修改很容易沿着旧样式继续生成错误包结构

正确方式:当同一代码模块中同时存在同步与响应式 service 契约时,响应式统一放在 service/reactive/

响应式动作型方法暴露影响行数

错误方式:把创建、编辑、删除、状态切换、账户密码更新、登录成功回写这类副作用型 reactive 方法定义成 Mono<Integer>,并把影响行数继续向 service / manager / Web 扩展契约上传递。

问题:

  • 会把底层持久化细节泄漏到上层业务契约
  • 阻塞式与响应式同一能力的签名语义会发生漂移
  • 上层调用者会错误依赖“影响行数”而不是动作是否完成

正确方式:与阻塞式语义保持一致。阻塞式为 void 的动作型契约,在响应式侧统一定义为 Mono<Void>;只有查询、统计一类需要返回结果的方法才返回具体值,例如 Mono<Integer>

对象转换层混用 Mapper 命名

错误方式:继续把对象转换实现生成为 mapstruct/ 包、XxxMapper 类,或者把 MyBatis-Flex 持久化 mapper/ 与对象转换实现混在同一个包里。

问题:

  • mapper 在当前约定中要留给 MyBatis 持久化层
  • 同一聚合里会同时出现两个语义完全不同的 XxxMapper
  • 后续修改很容易继续沿着旧命名扩散错误骨架

正确方式:对象转换实现统一放在各实现模块的 converter/ 包下,类名统一使用 XxxConverter;MyBatis-Flex 的 mapper/ 只保留持久化接口。

新代码继续生成 pojo 包

错误方式:新建模块或新增对象时,继续参考历史模块生成 pojo/aopojo/querypojo/vo

问题:

  • 新模块会和当前 core/contract/* 约定分裂
  • 前端、自动化输入和需求模板会继续出现两套对象命名
  • 后续迁移时容易在同一模块内混用 contract/*pojo/*

正确方式:新模块和新增代码统一使用 contract/aocontract/querycontract/vocontract/dto。如果目标历史模块已经大量使用 pojo/*,先明确迁移策略,不默认继续生成。

压缩 Javadoc 和字段声明

错误方式:生成 /** 订单编号。 */ 这类单行 Javadoc,或者把 @Schema(...) protected String orderNo; 挤在同一行。

问题:

  • 与当前源码格式约定不一致
  • IDE 格式化后仍难以稳定恢复成统一风格
  • 后续修改会沿着压缩格式继续扩散

正确方式:统一遵守 code-style-guide.md,类级、字段级、方法级 Javadoc 都使用标准多行格式;@Schema(...) 独占一行,字段声明另起一行。

普通 Converter 重复声明父接口方法

错误方式:每个 XxxConverter 都机械重写 ao2Entity(...)entityCopy(...)appleAo(...)entity2VO(...),但方法上没有 @Mapping@BeanMapping 等配置,也没有自定义逻辑。

问题:

  • AoEntityConverterEntityVoConverter 已经声明了这些抽象方法,MapStruct 会为继承方法生成实现
  • 重复声明会扩大代码量,后续批量修改更容易漏改或产生无用 import
  • 后续生成或修改会误以为这些方法是每个 Converter 的必备模板,从而继续扩散冗余代码

正确方式:普通 Converter 只保留 @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)、继承关系和必要的 INSTANCE。只有需要方法级 @Mapping@Mappings@BeanMapping@InheritConfiguration@InheritInverseConfiguration 等配置时,才显式声明对应方法;带派生字段或字典标签的 entity2VO(...) 应保留。

把共享认证能力重新塞回 AccountManager

错误方式:为业务主体重新生成 IAccountAuthenticationManager,或者把共享认证逻辑重新收口到通用 AccountManager

问题:

  • 共享认证能力会与主体模块自身职责重新耦合
  • 阻塞式与响应式两条线容易再次出现不一致
  • 新模块会继续复制密码治理、锁定规则和权限快照处理

正确方式:统一复用 IAccountAuthenticationService / IReactiveAccountAuthenticationServiceISecurityAuthoritiesService / IReactiveSecurityAuthoritiesService;主体模块自己的 Manager 只保留 loadByUsername(...)accountToSecurityUser(...)、主体特有查询和模块私有异常映射。

错误钩子命名

错误方式:直接在代码中凭空实现 afterDelete,或者让代码生成器生成不存在的删除后钩子名。

问题:

  • 当前框架真实钩子名不是 afterDelete
  • 生成的代码会偏离 LogicDeleteManager 现有接口

正确方式:把业务上的 afterDelete 语义明确映射到 afterLogicDelete;恢复后的补偿逻辑映射到 afterRecovery

自造响应包装

错误方式:在业务层手工创建另一套响应对象,或者绕开统一返回体系直接拼装 ResponseResults

问题:

  • 会破坏统一异常映射
  • 会让返回结构和错误码风格不一致

正确方式:成功结果在 Controller 侧继续通过 ResultBuild 返回,异常交给 GlobalExceptionHandler 处理。

权限清单字段语义混乱

错误方式:把 apipermissionUrl 写成前端路由,把 menu/pagecomponent 写成后端路径,给 buttonpermissionUrl/component/icon,或者为了分组虚构一个 *.menu 节点再把多个独立入口生成为 page

问题:

  • 前端动态路由会找不到真实组件
  • 后端接口授权和前端按钮显隐无法稳定对应
  • 授权页面读取权限树时会出现目录、页面、接口和按钮混杂

正确方式:权限类型统一使用 catalog / menu / page / api / buttoncatalog 是目录或分组,不写 permissionUrl/componentmenu 是独立菜单入口,写前端访问路径和真实组件路径;page 是不独立出现在菜单的隐藏/从属页面,常见于详情、编辑、配置、主从资源子列表页,通常伴随 ISearchOfMasterIdController / search-of-master-idapi 写后端接口路径;button 只表达页面可见操作,不写 permissionUrl/component/icon

日志缺少关键追踪信息

错误方式:只打印“操作失败”之类的空日志,不带动作名、关键入参和异常对象,也不复用请求上下文。

问题:

  • 无法关联到具体接口和具体请求
  • 排查异常时缺少 requestUrlrequestMethodsignatureapplicationKey 等线索

正确方式:日志至少带上动作名、关键入参、异常对象,并优先复用 WebMvcOperateInfoFilter / WebFluxOperateInfoFilter 提供的追踪上下文。

继续使用过期 API

错误方式:新增或修改代码时继续使用 JDK 或第三方依赖中已经标记为 @Deprecated 的 API,例如在 commons-lang3 中继续使用 StringUtils.equals(...)StringUtils.equalsIgnoreCase(...)StringUtils.contains(...)StringUtils.containsIgnoreCase(...) 做字符串比较和查找。

问题:

  • 过期 API 可能在后续依赖升级中被移除,导致模块迭代时集中编译失败
  • 编译器和 IDE 的 deprecation warning 会被噪声淹没,真正需要处理的升级风险不容易被发现
  • 同一代码库中混用旧 API 和替代 API,会让生成器、人工修改和代码审查继续沿着旧写法扩散

正确方式:不使用过期 API,也不要用 @SuppressWarnings("deprecation") 掩盖问题。commons-lang3 字符串大小写敏感比较和查找使用 Strings.CS.equals(...)Strings.CS.contains(...),大小写不敏感比较和查找使用 Strings.CI.equals(...)Strings.CI.contains(...);空白判断和默认值处理继续按未过期的 StringUtils.isBlank(...)StringUtils.defaultIfBlank(...) 等方法使用。

Service 回查多对多对象

错误方式:查询已分配对象列表时,Service 先调用关系 Manager 查 ID 集合,再调用另一个 Manager / Service 批量回查对象;保存关系时,Service 又先加载全部目标对象,再逐个过滤后保存关系。

问题:

  • Service 会混入持久化细节和对象过滤规则
  • 关系读取链路变成多次查询,代码绕、性能差,也容易漏掉逻辑删除或启用状态过滤
  • 后续前端分配列表、认证权限计算、关系写入三类场景会挤在同一批方法里,导致关系 Manager 越来越臃肿
  • Service 容易为了拿对象而横向调用其它 Service,破坏事务和职责边界

正确方式:保存关系时,Service 只做主对象校验、状态判断和 ID / 授权模式归一化,然后调用关系 Manager 覆盖保存;查询已分配对象列表时,由主对象 Manager 通过关系表 join 目标对象表并直接返回 VO。认证/鉴权链路如需高频计算,可以保留轻量 ID 查询,但不要把它复用成前端列表查询。

JPA 关系查询手写 SQL

错误方式:JPA 实现中为了多对多列表或级联查询直接写 native SQL / 拼接 SQL。

问题:

  • 字段改名、实体映射调整后,SQL 不会随 ORM 模型一起演进
  • 逻辑删除、启用状态、租户等通用过滤容易在手写 SQL 中漏掉
  • JPA、MyBatis-Flex、JDBC、R2DBC 的实现边界会混乱

正确方式:JPA 自定义关系查询优先使用框架约定查询或 QueryDSL join;MyBatis-Flex 优先使用 QueryWrapper 和 processor 生成的 TableDef;JDBC / R2DBC 暂时保持集中 SQL 实现,不向上层泄漏 SQL 细节。

级联删除散落在业务入口

错误方式:在 Controller 或任意业务 Service 中手工清理关系表、从表、后代节点,然后再调用删除方法。

问题:

  • 删除规则会随着入口数量增加而重复
  • 直接调用 physicsDelete / forceDelete 的其它入口可能绕过清理逻辑
  • 级联清理和实体生命周期脱节,后续很难判断哪些关系必须随实体物理删除而删除

正确方式:当前实体被物理删除时必须同步清理的关系表、从表和后代数据,落在该实体 ManagerbeforePhysicsDelete;物理删除成功后才有意义的外部副作用,落在 afterPhysicsDeleteforceDelete 只表示不做状态判断直接物理删除,不额外生成 beforeForceDelete / afterForceDelete

子表 count/delete 写在 Manager

错误方式:在 Manager 中为了判断是否存在引用数据或做物理级联删除,手写通用 helper,例如:

  • JDBC / R2DBC:countActiveValue(tableName, columnName, columnValue)deleteValue(tableName, columnName, columnValue),内部拼 SQL
  • JPA:在 Manager 中为简单外键计数或删除手写 QueryDSL select count / delete
  • MyBatis-Flex:在 Manager 中写 countActiveValue(BaseMapper<?> mapper, String columnName, String columnValue),通过 QueryMethods.column(columnName) 拼通用条件

问题:

  • Manager 会泄漏表名、列名、SQL/DSL 和 Mapper 细节,职责从业务生命周期滑到持久化实现
  • 相同规则在 JDBC、R2DBC、JPA、MyBatis-Flex 中容易写出四套不同风格,后续维护难以对齐
  • 子表字段改名、外键字段调整或持久化框架升级时,需要到业务层搜 SQL/列名,容易漏改
  • MyBatis-Flex 使用 BaseMapper<?> + columnName 会丢掉具体 Mapper 的语义,代码审查时看不出到底在操作哪张表

正确方式:把按外键 count/delete/findBy/exists 的动作放到对应子表、关系表、从表自己的 Repository / Mapper

  • JDBC / JPA 使用 Spring Data 派生方法,例如 countByCategoryIdAndDeleted(...)deleteByCategoryId(...)
  • R2DBC 使用响应式派生方法,例如 Mono<Long> countByCategoryIdAndDeleted(...)Mono<Void> deleteByCategoryId(...)
  • MyBatis-Flex 在具体 Mapper 中定义明确默认方法,内部用 QueryWrapper 或生成的 TableDef 表达条件
  • Manager 只保留业务判断、生命周期落点和异常语义,例如在 checkCanDelete / beforePhysicsDelete 中调用这些方法

最后更新:2026-05-18

仍有疑问?按反馈清单整理复现信息 →