# 开发约定反模式清单

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

## 会话上下文拆散传参

错误方式：调用方已经拿到 `SecuritySession`，却继续把 `identityType`、`principalId`、`tenantKey` 等字段拆散后逐层传递。

问题：

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

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

## 把登录状态重新塞回 JWT

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

问题：

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

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

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

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

问题：

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

正确方式：在线会话统一使用 `OnlineSessionVO`、`online-session`、`security.online-session`，查询和强制下线以 `sid` 为会话标识。

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

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

问题：

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

正确方式：同一扩展点按阻塞式与响应式分开定义；阻塞式放 `service/`，响应式放 `service/reactive/`。

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

错误方式：只在 `webmvc` 或 `webflux` 实现模块中定义接口，再让其他模块反向依赖这些实现层包名。

问题：

- 共享契约无法被两条 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/ao`、`pojo/query`、`pojo/vo`。

问题：

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

正确方式：新模块和新增代码统一使用 `contract/ao`、`contract/query`、`contract/vo`、`contract/dto`。如果目标历史模块已经大量使用 `pojo/*`，先明确迁移策略，不默认继续生成。

## 压缩 Javadoc 和字段声明

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

问题：

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

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

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

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

问题：

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

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

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

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

问题：

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

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

## 错误钩子命名

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

问题：

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

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

## 自造响应包装

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

问题：

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

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

## 权限清单字段语义混乱

错误方式：把 `api` 的 `permissionUrl` 写成前端路由，把 `menu/page` 的 `component` 写成后端路径，给 `button` 填 `permissionUrl/component/icon`，或者为了分组虚构一个 `*.menu` 节点再把多个独立入口生成为 `page`。

问题：

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

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

## 日志缺少关键追踪信息

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

问题：

- 无法关联到具体接口和具体请求
- 排查异常时缺少 `requestUrl`、`requestMethod`、`signature`、`applicationKey` 等线索

正确方式：日志至少带上动作名、关键入参、异常对象，并优先复用 `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` 的其它入口可能绕过清理逻辑
- 级联清理和实体生命周期脱节，后续很难判断哪些关系必须随实体物理删除而删除

正确方式：当前实体被物理删除时必须同步清理的关系表、从表和后代数据，落在该实体 `Manager` 的 `beforePhysicsDelete`；物理删除成功后才有意义的外部副作用，落在 `afterPhysicsDelete`。`forceDelete` 只表示不做状态判断直接物理删除，不额外生成 `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
