接口约定与 CRUD 骨架
适用端:后端 + Web | 类型:机制
一个标准的「增删改查」功能在本项目里由五个文件构成:Controller / VO / Service / Mapper / DO。本页用**岗位(Post)**这个上游自带的最小示例逐层拆开,所有代码都是仓库原文。
前置阅读:后端架构与模块依赖。
它解决什么问题
约定就三条,记住这三条就能读懂全项目 90% 的接口:
- URL 前缀由包名决定:
controller/admin/**→/admin-api/**,controller/app/**→/app-api/**; - 返回一律
CommonResult<T>,分页一律PageResult<T>,入参分页一律继承PageParam; - 权限一律
@PreAuthorize("@ss.hasPermission('域:资源:动作')"),动作词固定为query / create / update / delete / export。
代码在哪
| 文件 | 路径(enlev-module-system/src/main/java/cn/enlev/ainb/module/system/) |
|---|---|
| Controller | controller/admin/dept/PostController.java |
| VO | controller/admin/dept/vo/post/{PostSaveReqVO, PostPageReqVO, PostRespVO, PostSimpleRespVO}.java |
| Service | service/dept/PostService.java + PostServiceImpl.java |
| Mapper | dal/mysql/dept/PostMapper.java |
| DO | dal/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
@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 | /create | system:post:create | 返回新 id |
| PUT | /update | system:post:update | SaveReqVO 带 id |
| DELETE | /delete | system:post:delete | @RequestParam("id") |
| DELETE | delete-list | system:post:delete | 批量;路径少了前导 /(Spring 会自动补,别照抄) |
| GET | /get | system:post:query | 单条 |
| GET | /page | system:post:query | 分页 |
| GET | /list-all-simple、simple-list | 无 @PreAuthorize | 下拉选项用,只要登录即可 |
| GET | /export-excel | system:post:export | EasyExcel 导出 |
创建与修改共用一个 VO
本项目用 XxxSaveReqVO 一个类同时承担 create 与 update(id 为空即创建),没有 CreateReqVO / UpdateReqVO 之分,也没有 CreateGroup / UpdateGroup 校验分组(全仓 grep 零命中)。这是与很多 Spring Boot 项目不同的地方,从上游合并代码时注意。
VO
// 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;// PostPageReqVO:继承 PageParam,只写查询条件
@EqualsAndHashCode(callSuper = true)
public class PostPageReqVO extends PageParam {
@Schema(description = "岗位编码,模糊匹配", example = "enlev")
private String code;// 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
接口只有五个方法,实现类的骨架固定:
@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
@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
@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):
@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 data | success(data) 把 code 置 GlobalErrorCodeConstants.SUCCESS(0)、msg 置空串;error(code, msg) / error(ErrorCode) |
PageResult<T> | Long total / List<T> list | 另有静态 empty() |
PageParam | pageNo(默认 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 不存在):
interface PageParam { pageSize?: number; pageNo?: number }
// 分页数据公共返回
interface PageResult<T> { list: T; total: number }错误码体系
ErrorCode 只有两个字段(Integer code / String msg)。全局码在 GlobalErrorCodeConstants:
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):
// ========== 岗位模块 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、MethodArgumentTypeMismatchException | 400 |
MethodArgumentNotValidException、BindException、ConstraintViolationException、ValidationException | 400 + 第一条校验消息 |
HttpMessageNotReadableException | 400 |
MaxUploadSizeExceededException | 400 |
NoHandlerFoundException、NoResourceFoundException | 404 |
HttpRequestMethodNotSupportedException、HttpMediaTypeNotSupportedException | 405 / 415 |
AccessDeniedException | CommonResult.error(FORBIDDEN) → 403 |
ServiceException | CommonResult.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:
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:
// 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(顺序照做,别跳步):
enums/ErrorCodeConstants.java里按区间加XXX_NOT_FOUND等错误码;dal/dataobject/xxx/XxxDO.java:@TableName+@KeySequence+ 继承BaseDO(多租户则TenantBaseDO);dal/mysql/xxx/XxxMapper.java:extends BaseMapperX<XxxDO>+default PageResult<XxxDO> selectPage(...);controller/admin/xxx/vo/:XxxSaveReqVO/XxxPageReqVO extends PageParam/XxxRespVO;service/xxx/:接口 +@Service @Validated实现;controller/admin/xxx/XxxController.java:@Tag+@RestController+@RequestMapping("/域/xxx")+@Validated,八个端点按需;- 菜单与权限串入库(
system_menu),详见新增一个菜单功能端到端; - 前端
src/api/<域>/xxx/index.ts+src/views/<域>/xxx/{index.vue, XxxForm.vue}。
用代码生成器(推荐,模板已按本项目的扁平模块结构写好):
- 建表(表名带模块前缀,字段带注释——注释会变成
@Schema描述与前端 label); - 菜单
基础设施 → 代码生成(路由/codegen)→ 导入表 → 配置生成信息; - 点「预览」看生成结果,或点「下载」拿
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追溯。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
相关页面
- 数据库约定与表总览:
BaseDO、拦截器与命名约定; - axios 封装与 API 约定:前端怎么调这些接口;
- 新增一个菜单功能端到端:从建表到菜单入库的完整走查;
- 测试规范:Service 层单测怎么写。
