Skip to content

接口约定与 CRUD 骨架 ​

适用端:后端 + Web | 类型:机制

一个标准的「增删改查」功能在本项目里由五个文件构成:Controller / VO / Service / Mapper / DO。本页用**岗位(Post)**这个上游自带的最小示例逐层拆开,所有代码都是仓库原文。

前置阅读:后端架构与模块依赖。

它解决什么问题 ​

约定就三条,记住这三条就能读懂全项目 90% 的接口:

  1. URL 前缀由包名决定:controller/admin/** → /admin-api/**,controller/app/** → /app-api/**;
  2. 返回一律 CommonResult<T>,分页一律 PageResult<T>,入参分页一律继承 PageParam;
  3. 权限一律 @PreAuthorize("@ss.hasPermission('域:资源:动作')"),动作词固定为 query / create / update / delete / export。

代码在哪 ​

文件路径(enlev-module-system/src/main/java/cn/enlev/ainb/module/system/)
Controllercontroller/admin/dept/PostController.java
VOcontroller/admin/dept/vo/post/{PostSaveReqVO, PostPageReqVO, PostRespVO, PostSimpleRespVO}.java
Serviceservice/dept/PostService.java + PostServiceImpl.java
Mapperdal/mysql/dept/PostMapper.java
DOdal/dataobject/dept/PostDO.java
错误码enums/ErrorCodeConstants.java
前端配对ainb-ui-admin-vue3/src/api/system/post/index.ts ↔ src/views/system/post/index.vue + PostForm.vue

核心机制 ​

Controller ​

java
@Tag(name = "管理后台 - 岗位")
@RestController
@RequestMapping("/system/post")
@Validated
public class PostController {

    @PostMapping("/create")
    @Operation(summary = "创建岗位")
    @PreAuthorize("@ss.hasPermission('system:post:create')")
    public CommonResult<Long> createPost(@Valid @RequestBody PostSaveReqVO createReqVO) {
        Long postId = postService.createPost(createReqVO);
        return success(postId);
    }

    @GetMapping("/page")
    @Operation(summary = "获得岗位分页列表")
    @PreAuthorize("@ss.hasPermission('system:post:query')")
    public CommonResult<PageResult<PostRespVO>> getPostPage(@Validated PostPageReqVO pageReqVO) {
        PageResult<PostDO> pageResult = postService.getPostPage(pageReqVO);
        return success(BeanUtils.toBean(pageResult, PostRespVO.class));
    }

success(...) 是静态导入:import static cn.enlev.ainb.framework.common.pojo.CommonResult.success;。

整个 Controller 的端点清单(实测 PostController.java):

方法路径权限串备注
POST/createsystem:post:create返回新 id
PUT/updatesystem:post:updateSaveReqVO 带 id
DELETE/deletesystem:post:delete@RequestParam("id")
DELETEdelete-listsystem:post:delete批量;路径少了前导 /(Spring 会自动补,别照抄)
GET/getsystem:post:query单条
GET/pagesystem:post:query分页
GET/list-all-simple、simple-list无 @PreAuthorize下拉选项用,只要登录即可
GET/export-excelsystem:post:exportEasyExcel 导出

创建与修改共用一个 VO

本项目用 XxxSaveReqVO 一个类同时承担 create 与 update(id 为空即创建),没有 CreateReqVO / UpdateReqVO 之分,也没有 CreateGroup / UpdateGroup 校验分组(全仓 grep 零命中)。这是与很多 Spring Boot 项目不同的地方,从上游合并代码时注意。

VO ​

java
// PostSaveReqVO:字段级校验 + Swagger 描述
@Schema(description = "管理后台 - 岗位创建/修改 Request VO")
@Data
public class PostSaveReqVO {
    @Schema(description = "岗位名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "小土豆")
    @NotBlank(message = "岗位名称不能为空")
    @Size(max = 50, message = "岗位名称长度不能超过 50 个字符")
    private String name;

    @Schema(description = "状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @InEnum(CommonStatusEnum.class)   // 本项目自带的枚举校验注解
    private Integer status;
java
// PostPageReqVO:继承 PageParam,只写查询条件
@EqualsAndHashCode(callSuper = true)
public class PostPageReqVO extends PageParam {
    @Schema(description = "岗位编码,模糊匹配", example = "enlev")
    private String code;
java
// PostRespVO:Excel 导出注解(cn.idev.excel)
@ExcelIgnoreUnannotated
public class PostRespVO {
    @ExcelProperty("岗位序号")
    private Long id;

    @ExcelProperty(value = "状态", converter = DictConvert.class)
    @DictFormat(DictTypeConstants.COMMON_STATUS)
    private Integer status;

自定义校验注解在 enlev-common 的 common/validation/ 下,只有三个:@InEnum(配 ArrayValuable 枚举)、@Mobile、@Telephone。

Service ​

接口只有五个方法,实现类的骨架固定:

java
@Service
@Validated
public class PostServiceImpl implements PostService {

    @Resource
    private PostMapper postMapper;

    @Override
    public Long createPost(PostSaveReqVO createReqVO) {
        // 校验正确性
        validatePostForCreateOrUpdate(null, createReqVO.getName(), createReqVO.getCode());
        // 插入岗位
        PostDO post = BeanUtils.toBean(createReqVO, PostDO.class);
        postMapper.insert(post);
        return post.getId();
    }

    @Override
    public void deletePost(Long id) {
        validatePostExists(id);   // 不存在就抛业务异常
        postMapper.deleteById(id);
    }

    private void validatePostExists(Long id) {
        if (id == null) { return; }
        if (postMapper.selectById(id) == null) {
            throw exception(POST_NOT_FOUND);   // 静态导入 ServiceExceptionUtil.exception
        }
    }

三条硬约定:

  • VO → DO 用 BeanUtils.toBean(source, TargetClass.class)(enlev-common 的 common/util/object/BeanUtils.java,底层是 Hutool)。老代码里的 convert/ 包(MapStruct)仍然存在,但新代码统一用 BeanUtils;
  • 业务错误一律 throw exception(ERROR_CODE),不要 return CommonResult.error(...)——Service 层拿不到也不该拿 CommonResult;
  • 校验方法命名 validateXxx,私有方法,放在实现类底部。

Mapper ​

java
@Mapper
public interface PostMapper extends BaseMapperX<PostDO> {

    default PageResult<PostDO> selectPage(PostPageReqVO reqVO) {
        return selectPage(reqVO, new LambdaQueryWrapperX<PostDO>()
                .likeIfPresent(PostDO::getCode, reqVO.getCode())
                .likeIfPresent(PostDO::getName, reqVO.getName())
                .eqIfPresent(PostDO::getStatus, reqVO.getStatus())
                .eqIfPresent(PostDO::getDeptId, reqVO.getDeptId())
                .orderByDesc(PostDO::getId));
    }

    default PostDO selectByName(String name) {
        return selectOne(PostDO::getName, name);   // 唯一性校验用
    }

BaseMapperX 与 LambdaQueryWrapperX 都在 enlev-spring-boot-starter-mybatis 的 core/mapper 与 core/query 包下。xxxIfPresent 系列的语义是「参数为 null / 空串就不加这个条件」——这是分页查询不用写 if 的关键。BaseMapperX#selectPage 对 PageParam.PAGE_SIZE_NONE(值 -1)有特殊处理:不分页,查全部。

DO ​

java
@TableName("system_post")
@KeySequence("system_post_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
public class PostDO extends BaseDO {

    @TableId
    private Long id;

BaseDO 提供 createTime / updateTime / creator / updater / deleted(自动填充 + 逻辑删除),多租户表改继承 TenantBaseDO(多一个 tenantId)。详见数据库约定与表总览。

定制功能:岗位能挂组织,且「清空」要显式声明

PostDO 比上游多一个 deptId(所属组织编号,允许为空表示不挂组织),并且带了 @TableField(updateStrategy = FieldStrategy.ALWAYS):

java
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deptId;

MyBatis-Plus 的 updateById 默认跳过 null 字段,不加这个注解时「编辑岗位、把所属组织清掉」会静默不生效——界面提示保存成功,列表刷新后组织又回来了。同样的写法在 GrantDO 上也有一份。回归单测 PostServiceImplTest#testUpdatePost_clearDeptId(enlev-module-system/src/test/.../service/dept/PostServiceImplTest.java:78)钉着这个行为,别当 bug 改掉。

任何「允许被编辑成空」的外键字段都要照此办理。

统一返回与分页 ​

三个类都在 enlev-common 的 common/pojo/ 下:

类字段要点
CommonResult<T>Integer code / String msg / T datasuccess(data) 把 code 置 GlobalErrorCodeConstants.SUCCESS(0)、msg 置空串;error(code, msg) / error(ErrorCode)
PageResult<T>Long total / List<T> list另有静态 empty()
PageParampageNo(默认 1,@NotNull @Min(1))/ pageSize(默认 20,@Min(1) @Max(200))public static final Integer PAGE_SIZE_NONE = -1;

前端侧 PageParam / PageResult 是全局类型声明,在 ainb-ui-admin-vue3/types/global.d.ts 里,无需 import(src/api/types.ts 不存在):

ts
interface PageParam { pageSize?: number; pageNo?: number }
// 分页数据公共返回
interface PageResult<T> { list: T; total: number }

错误码体系 ​

ErrorCode 只有两个字段(Integer code / String msg)。全局码在 GlobalErrorCodeConstants:

java
ErrorCode SUCCESS = new ErrorCode(0, "成功");
ErrorCode BAD_REQUEST = new ErrorCode(400, "请求参数不正确");
ErrorCode UNAUTHORIZED = new ErrorCode(401, "账号未登录");
ErrorCode FORBIDDEN = new ErrorCode(403, "没有该操作权限");
ErrorCode NOT_FOUND = new ErrorCode(404, "请求未找到");
ErrorCode INTERNAL_SERVER_ERROR = new ErrorCode(500, "系统异常");

业务码 10 位、分四段,区间在 enlev-common 的 exception/enums/ServiceErrorCodeRange.java 里登记(该类只声明不使用):

段含义例
第 1 位类型,1 = 业务异常1
第 2–4 位系统002 = system,001 = infra,009 = bpm,022 = ai
第 5–7 位模块岗位 = 005
第 8–10 位自增序号000

模块内的写法(enlev-module-system/.../enums/ErrorCodeConstants.java):

java
// ========== 岗位模块 1-002-005-000 ==========
ErrorCode POST_NOT_FOUND = new ErrorCode(1_002_005_000, "当前岗位不存在");
ErrorCode POST_NOT_ENABLE = new ErrorCode(1_002_005_001, "岗位({}) 不处于开启状态,不允许选择");
ErrorCode POST_NAME_DUPLICATE = new ErrorCode(1_002_005_002, "已经存在该名字的岗位");
ErrorCode POST_CODE_DUPLICATE = new ErrorCode(1_002_005_003, "已经存在该标识的岗位");

{} 是占位符,抛出时传参:throw exception(POST_NOT_ENABLE, name)。

agent 模块占的 1-041 段没登记

enlev-module-agent/.../enums/ErrorCodeConstants.java 用的是 1-041-xxx-xxx(权限网关 1-041-000-000、工具执行 1-041-002-000、助手配置 1-041-001-000、会话 1-041-003-000、变更计划 1-041-004-000、配额 1-041-005-000、执行引擎 1-041-006-000、工具目录 1-041-007-000),但 ServiceErrorCodeRange 的注释里最后一条只到 ai 的 1-022。新增模块时记得把区间补进 ServiceErrorCodeRange 的注释,否则下一个人无从避让。

全局异常处理 ​

GlobalExceptionHandler(starter-web 的 web/core/handler/)把异常统一翻译成 CommonResult,Controller 里不需要 try-catch。它处理的异常类型(按 @ExceptionHandler 顺序):

异常结果
MissingServletRequestParameterException、MethodArgumentTypeMismatchException400
MethodArgumentNotValidException、BindException、ConstraintViolationException、ValidationException400 + 第一条校验消息
HttpMessageNotReadableException400
MaxUploadSizeExceededException400
NoHandlerFoundException、NoResourceFoundException404
HttpRequestMethodNotSupportedException、HttpMediaTypeNotSupportedException405 / 415
AccessDeniedExceptionCommonResult.error(FORBIDDEN) → 403
ServiceExceptionCommonResult.error(ex.getCode(), ex.getMessage())
其它 Exception兜底 500,并写 API 错误日志

HTTP 状态码永远是 200

业务错误走的是响应体里的 code,不是 HTTP 状态码。前端 src/config/axios/service.ts 就是按 data.code 分支的(code !== 0 && code !== 200 → ElNotification.error)。写脚本调接口时别用 -f 或只看 HTTP 状态判断成败。

接口文档怎么分组 ​

springdoc + knife4j,配置在 application.yaml:

yaml
springdoc:
  api-docs:
    enabled: true
    path: /v3/api-docs
  swagger-ui:
    enabled: true
    path: /swagger-ui
  default-flat-param-object: true
knife4j:
  enable: true
  setting:
    language: zh_cn

入口 http://127.0.0.1:48080/doc.html。分组是各模块自己声明一个 GroupedOpenApi bean:

java
// enlev-module-system/.../framework/web/config/SystemWebConfiguration.java
@Bean
public GroupedOpenApi systemGroupedOpenApi() {
    return EnlevSwaggerAutoConfiguration.buildGroupedOpenApi("system");
}

buildGroupedOpenApi(group) 内部用 pathsToMatch("/admin-api/" + group + "/**", "/app-api/" + group + "/**"),所以分组名必须与 URL 第一段一致。现有分组:system / infra / bpm / ai / im(im 不在 reactor,实际不生效)+ 框架自带的 all。

实测:没有 agent 分组

enlev-module-agent 缺 AgentWebConfiguration,实测 /v3/api-docs/swagger-config 只返回 ai / all / bpm / infra / system。99 条 /admin-api/agent/** 路径全在 all 组里。要补的话照上面那段抄一个 bean 即可(buildGroupedOpenApi("agent"))。

怎么扩展 ​

手写一个新 CRUD(顺序照做,别跳步):

  1. enums/ErrorCodeConstants.java 里按区间加 XXX_NOT_FOUND 等错误码;
  2. dal/dataobject/xxx/XxxDO.java:@TableName + @KeySequence + 继承 BaseDO(多租户则 TenantBaseDO);
  3. dal/mysql/xxx/XxxMapper.java:extends BaseMapperX<XxxDO> + default PageResult<XxxDO> selectPage(...);
  4. controller/admin/xxx/vo/:XxxSaveReqVO / XxxPageReqVO extends PageParam / XxxRespVO;
  5. service/xxx/:接口 + @Service @Validated 实现;
  6. controller/admin/xxx/XxxController.java:@Tag + @RestController + @RequestMapping("/域/xxx") + @Validated,八个端点按需;
  7. 菜单与权限串入库(system_menu),详见新增一个菜单功能端到端;
  8. 前端 src/api/<域>/xxx/index.ts + src/views/<域>/xxx/{index.vue, XxxForm.vue}。

用代码生成器(推荐,模板已按本项目的扁平模块结构写好):

  1. 建表(表名带模块前缀,字段带注释——注释会变成 @Schema 描述与前端 label);
  2. 菜单 基础设施 → 代码生成(路由 /codegen)→ 导入表 → 配置生成信息;
  3. 点「预览」看生成结果,或点「下载」拿 codegen.zip。

生成器不会写你的工程

CodegenController(enlev-module-infra/.../controller/admin/codegen/CodegenController.java,160 行)只有两个出口:GET /infra/codegen/preview(返回 14 个文件的字符串)与 GET /infra/codegen/download(把它们 ZipUtil.zip 成 codegen.zip)。没有「直接生成到本地工程」这种端点,所以生成结果永远要你手工落盘、手工挪位置。完整走查见新增一个菜单功能端到端。

模板在 enlev-module-infra/src/main/resources/codegen/:

java/controller/{controller.vm, vo/{saveReqVO,pageReqVO,respVO,listReqVO,importExcelVO,importRespVO}.vm}
java/dal/{do.vm, do_sub.vm, mapper.vm, mapper.xml.vm, mapper_sub.vm}
java/service/{service.vm, serviceImpl.vm}
java/enums/errorcode.vm      java/test/serviceTest.vm
sql/{sql.vm, h2.vm}          # 菜单 SQL 与 H2 测试建表
vue3/…                       # 前端 api / views / schema

相关枚举在 enlev-module-infra/.../enums/codegen/:CodegenSceneEnum(ADMIN / APP)、CodegenTemplateTypeEnum(ONE 单表 / TREE 树表 / MASTER_NORMAL / MASTER_ERP / MASTER_INNER / SUB 子表)、CodegenFrontTypeEnum、CodegenVOTypeEnum。

改模块结构必须同步改模板

代码生成器模板里的包路径是按「扁平 jar + api 只是 package」写死的。如果你把某个模块改成上游的 -api / -biz 拆分,生成出来的代码编译不过,且要同时改 codegen/ 下的模板与 enlev-module-infra 的生成逻辑。

坑与注意 ​

  • @PreAuthorize 的 ss 是谁:EnlevSecurityAutoConfiguration 里 @Bean("ss") 注册的 SecurityFrameworkService(注释原文「使用 Spring Security 的缩写,方便使用」),实现委托 PermissionCommonApi#hasAnyPermissions(userId, permissions)。本项目定制的授权决策引擎(sys_resource + sys_grant)挂在这条链路下游,由 enlev.permission.grant-enabled 控制。
  • simple-list 类接口通常不加权限注解,只要求登录。给它加 @PreAuthorize 会让下拉框在无权限用户那里变成空列表——这是「表单里选项没了」最常见的根因。
  • @PermitAll 用的是 jakarta.annotation.security.PermitAll(标准注解),不是自定义的;本项目没有 @PreAuthenticated。EnlevWebSecurityConfigurerAdapter 启动时扫出所有带 @PermitAll 的 HandlerMethod、补上 /admin-api 前缀后注册成 permitAll(),所以免登录接口只需要注解;配置项 enlev.security.permit-all-urls 是第二条路(yaml 里实际拼成了 permit-all_urls,且列的是 /admin-api/mp/open/**——mp 模块不在 reactor,等于空转)。
  • SSE / 异步请求整体放行:同一段配置里有 .dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll(),注释原文「WebFlux 异步请求,无需认证,目的:SSE 场景」。agent 模块的流式接口依赖这一条;写新的流式接口时别指望在 ASYNC dispatch 上再做认证。
  • @TenantIgnore 在 starter-biz-tenant 的 tenant/core/aop/ 下,支持 SpEL 的 enable()。跨租户查询(如平台级统计)才用它,用了就等于自己保证 where 条件不会串租户。
  • 导出用 cn.idev.excel(EasyExcel 的新坐标),不是 com.alibaba.excel。RespVO 上的 @ExcelProperty + @DictFormat(DictTypeConstants.XXX) 组合负责字典值转文字。
  • @Validated 加在类上 + @Valid 加在 body 参数上是两件事:类上的让 @RequestParam 级别的约束生效,参数上的触发 VO 内部校验。漏掉类上的注解,PostPageReqVO 里 PageParam 的 @NotNull 就不会被校验。
  • 岗位代码在 dept 包,不在 post 包:controller/admin/dept/PostController.java、service/dept/PostServiceImpl.java、dal/mysql/dept/PostMapper.java、dal/dataobject/dept/PostDO.java,只有 VO 在 controller/admin/dept/vo/post/ 下——上游把岗位当成组织域的一部分。而代码生成器一律按「一个业务一个包」生成(controller/admin/post/、dal/dataobject/post/…),所以生成出来的岗位代码和仓库里那份路径对不上。这不是 bug,生成器不知道历史分包;照生成结果放新代码没问题,但别拿它去「纠正」已有文件的位置。
  • 导出方法名与生成器不一致:真实 PostController 的导出方法叫 export、文件名写死 "岗位数据.xls"、sheet 名 "岗位列表";生成器产出的叫 exportPostExcel。两种都能在 knife4j 里跑,只是搜代码时容易漏。
  • 判断「上游自带还是本项目定制」不能靠 git log:ainb/ 后端仓库只有 3 个提交(78027a0 feat(系统):项目初始化 一次性导入 + 两个整理提交),历史全被压平了。要区分定制,只能靠代码注释里的定制标记(如 PostDO#deptId 那段中文注释)、与上游 ruoyi-vue-pro 对比、或看本手册的「定制功能」提示块。前端仓库保留了完整历史,可以用 git log --diff-filter=A 追溯。

→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。

相关页面 ​