Skip to content

新增一个菜单功能端到端 ​

适用端:后端 + Web | 类型:操作

从「建一张表」到「左侧菜单能点开、按钮受权限控制」,中间有 9 个字符串必须两两对齐,错任何一个都会表现成白屏、按钮消失或 403。本页用仓库里真实存在的**岗位(Post)**当样本走完全程——所有路径、DDL、SQL、代码都是实测原文;最后给出代码生成器的真实产出(14 个文件)与仓库现状的差异清单。

前置阅读:接口约定与 CRUD 骨架、数据库约定与表总览、路由与权限。

它解决什么问题 ​

一个功能「做完」的定义不是代码写完,而是这条链路全通:

三处最容易断的地方,都在「字符串对齐」上:

#字符串出现在哪几处岗位的真实值
1表名DDL、@TableName、H2 create_tables.sql、clean.sqlsystem_post
2URL 前缀@RequestMapping、前端 api 的 url、权限串第 2 段/system/post
3权限串@PreAuthorize、system_menu.permission、v-hasPermisystem:post:query 等 5 个
4组件路径system_menu.component、import.meta.glob('../views/**') 的匹配目标system/post/index
5组件名system_menu.component_name、路由 name、defineOptions({ name })SystemPost
6菜单路径system_menu.path(与父目录 path 拼成浏览器地址)post
7类名前缀五件套的类名Post
8错误码段ErrorCodeConstants 的分段注释1-002-005-000
9字典类型DICT_TYPE.XXX、system_dict_type.typecommon_status

第 4、5 两项由 src/utils/routerHelper.ts 在运行时消费,匹配失败不报错,这是白屏的头号原因(见「第 8 步」)。

代码在哪 ​

岗位这一个功能,在仓库里一共占 20 个文件:

层文件
表MySQL system_post;H2 enlev-module-system/src/test/resources/sql/create_tables.sql:147;sql/clean.sql:9
DOenlev-module-system/src/main/java/cn/enlev/ainb/module/system/dal/dataobject/dept/PostDO.java
Mapper.../dal/mysql/dept/PostMapper.java
Service.../service/dept/PostService.java + PostServiceImpl.java
Controller.../controller/admin/dept/PostController.java(114 行)
VO.../controller/admin/dept/vo/post/{PostSaveReqVO, PostPageReqVO, PostRespVO, PostSimpleRespVO}.java
对外 API.../api/dept/{PostApi, PostApiImpl}.java + api/dept/dto/PostRespDTO.java
错误码.../enums/ErrorCodeConstants.java:69-73
操作日志解析.../framework/operatelog/core/PostParseFunction.java
单测enlev-module-system/src/test/java/.../service/dept/PostServiceImplTest.java(15 个用例)
前端 apiainb-ui-admin-vue3/src/api/system/post/index.ts
前端页面ainb-ui-admin-vue3/src/views/system/post/index.vue(283 行)+ PostForm.vue(143 行)
菜单数据system_menu 的 id = 8(菜单)与 id = 43..47(五个按钮)

岗位代码在 dept 包,不在 post 包

上游把岗位当成组织域的一部分,所以后端文件散在 dept 包下,只有 VO 在 dept/vo/post/。而代码生成器一律按「一个业务一个包」生成(controller/admin/post/、dal/dataobject/post/…)。新代码照生成器的分包放没问题,但别拿它去「纠正」岗位已有文件的位置。

核心机制 ​

第 1 步:建表 ​

实测 SHOW CREATE TABLE system_post(去掉字符集声明):

sql
CREATE TABLE `system_post` (
  `id`          bigint       NOT NULL AUTO_INCREMENT COMMENT '岗位ID',
  `code`        varchar(64)  NOT NULL                COMMENT '岗位编码',
  `name`        varchar(50)  NOT NULL                COMMENT '岗位名称',
  `sort`        int          NOT NULL                COMMENT '显示顺序',
  `dept_id`     bigint       DEFAULT NULL            COMMENT '所属部门编号,关联 system_dept.id;NULL=不挂部门',
  `status`      tinyint      NOT NULL                COMMENT '状态(0正常 1停用)',
  `remark`      varchar(500) DEFAULT NULL            COMMENT '备注',
  `creator`     varchar(64)  DEFAULT ''              COMMENT '创建者',
  `create_time` datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updater`     varchar(64)  DEFAULT ''              COMMENT '更新者',
  `update_time` datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted`     bit(1)       NOT NULL DEFAULT b'0'   COMMENT '是否删除',
  `tenant_id`   bigint       NOT NULL DEFAULT '0'    COMMENT '租户编号',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB COMMENT='岗位信息表';

四个不能省的点:

  1. 每列都要写 COMMENT。代码生成器把注释直接变成 @Schema(description=...)、前端表单 label 和 Excel 列头;低代码建模也读它。没注释的字段生成出来是空的。
  2. 6 个公共列(creator / create_time / updater / update_time / deleted 加上主键)由 BaseDO 自动填充,一个都不能少。
  3. 要不要 tenant_id:enlev.tenant.enable: true,拦截器给除白名单外的所有表拼 AND tenant_id = ?。租户私有数据就带上这一列;平台级事实表则不带,并且必须同步登记进 enlev.tenant.ignore-tables,否则报 Unknown column 'tenant_id'(详见数据库约定)。
  4. H2 单测建表要同步。enlev-module-system/src/test/resources/sql/create_tables.sql 里得有一份对应的手工改写版(H2 语法、双引号包标识符),sql/clean.sql 里得有一行 DELETE FROM "system_post";。漏了这两处,BaseDbUnitTest 会在启动阶段就炸。

system_post 的 H2 版本(create_tables.sql:147)——注意 bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY 替代 AUTO_INCREMENT、bit 替代 bit(1):

sql
CREATE TABLE IF NOT EXISTS "system_post" (
    "id"          bigint      NOT NULL GENERATED BY DEFAULT AS IDENTITY,
    "code"        varchar(64) NOT NULL,
    "name"        varchar(50) NOT NULL,
    "sort"        integer     NOT NULL,
    "dept_id"     bigint               DEFAULT NULL,
    "status"      tinyint     NOT NULL,
    ...
    "tenant_id"   bigint      not null default '0',
    PRIMARY KEY ("id")
);

第 2 步:后端五件套 ​

写法完全按接口约定与 CRUD 骨架那一页,这里只补岗位样本里两个值得抄的细节。

Mapper 的查询条件与 PageReqVO 一一对应,多一个少一个都会让搜索栏「有输入框但不生效」:

java
// PostMapper.java
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));
}

PostPageReqVO 正好四个字段:code / name / status / deptId。

允许被编辑成空的外键字段要显式声明更新策略:

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

updateById 默认跳过 null 字段。少了这一行,「编辑岗位、把所属组织清掉」会静默不生效:界面提示保存成功,列表刷新后组织又回来了。回归单测 PostServiceImplTest#testUpdatePost_clearDeptId 钉着这个行为。

第 3 步:错误码 ​

enlev-module-system/.../enums/ErrorCodeConstants.java:69:

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, "已经存在该标识的岗位");

分段规则:1(业务异常)- 002(system 模块)- 005(岗位)- 000(自增)。新业务要挑一个没被占用的中间段,并把区间补进 enlev-common 的 exception/enums/ServiceErrorCodeRange.java 注释里。

第 4 步:菜单入库 ​

system_menu 一行目录 + 一行菜单 + N 行按钮。岗位的真实数据(实测查询,type:1=目录、2=菜单、3=按钮):

idnametypesortparent_idpathcomponentcomponent_namepermissionicon
1系统管理1100/system——(空)ep:tools
9000050900组织权限111org-permission——(空)ep:user-filled
8岗位管理229000050900postsystem/post/indexSystemPost(空)fa:address-book-o
43岗位查询318(空)(空)—system:post:query#
44–47岗位新增/修改/删除/导出32–58(空)(空)—system:post:{create,update,delete,export}#

三条规则:

  • 目录(type=1)只填 path,component / component_name 留空。顶级目录的 path 以 / 开头,子目录不带 /(浏览器地址是父子拼接:/system/org-permission/post)。
  • 菜单(type=2)填 component + component_name,path 不带 /。上游惯例是 permission 留空(岗位管理就是空的);本项目自研的 agent 域菜单则给 type=2 也填了 permission(如 id=9000050932 智能体管理填 agent:def:query)——两种都有,不影响功能。
  • 按钮(type=3)只填 permission,path / component 全空,component_name 是 NULL,icon 是 #。

图标只能用四类前缀

ep: / fa: / fa-solid: / svg-icon: 是离线内置的(src/plugins/svgIcon/index.ts 里 addCollection),其它前缀会走 Iconify 联网拉取,内网部署时菜单图标会空白。

两条入库路径:

路径 A:走界面(推荐,会自动清缓存)

系统管理 → 菜单管理(路由 /system/menu,页面 src/views/system/menu/index.vue)→ 新增 → 依次建目录、菜单、五个按钮。MenuForm.vue:218 与 index.vue:345 在保存/删除成功后都会执行 wsCache.delete(CACHE_KEY.ROLE_ROUTERS),所以当前浏览器立刻就能刷出新菜单。

路径 B:直接执行 SQL(代码生成器给的就是这种)

sql
INSERT INTO system_menu(name, permission, type, sort, parent_id, path, icon, component, status, component_name)
VALUES ('岗位管理', '', 2, 2, 9000050900, 'post', 'fa:address-book-o', 'system/post/index', 0, 'SystemPost');

SELECT @parentId := LAST_INSERT_ID();

INSERT INTO system_menu(name, permission, type, sort, parent_id, path, icon, component, status) VALUES
('岗位查询', 'system:post:query',  3, 1, @parentId, '', '', '', 0),
('岗位新增', 'system:post:create', 3, 2, @parentId, '', '', '', 0),
('岗位修改', 'system:post:update', 3, 3, @parentId, '', '', '', 0),
('岗位删除', 'system:post:delete', 3, 4, @parentId, '', '', '', 0),
('岗位导出', 'system:post:export', 3, 5, @parentId, '', '', '', 0);

status = 0 是「开启」(CommonStatusEnum.ENABLE),不是 1。

直接写 SQL 不会清 Redis 缓存

MenuServiceImpl 的增删改方法上都挂着 @CacheEvict(value = RedisKeyConstants.PERMISSION_MENU_ID_LIST, ...)(:60 / :81 / :104 / :125),走界面或走 /system/menu/* 接口会自动失效缓存。绕过 Service 直接 INSERT 就没有这一步:新增一个全新权限串通常没事(缓存里本来就没这个 key),但修改一个已存在的权限串会读到旧值。这时手动清一次 Redis 的 permission_menu_ids:*,或者重启后端。

第 5 步:授权给角色 ​

菜单建好只是「存在」,还得发给角色。实测岗位这 6 行菜单的授权情况:

role_id = 2(普通角色 common)  → 6 行全有
role_id = 9000000589            → 42 行

超级管理员(role.code = 'super_admin')走短路,不查 system_role_menu:RoleCodeEnum.isSuperAdmin(code) 按 code 串比对,GrantDecisionService#isSuperAdmin(userId) 委托 roleService.hasAnySuperAdmin。所以用 admin 账号测不出权限问题——一定要换一个非超管账号验证。

界面上:系统管理 → 角色 → 分配菜单权限。数据落在 system_role_menu。本项目定制的授权决策引擎(sys_resource + sys_grant)挂在这条链路下游,由 enlev.permission.grant-enabled 控制。

第 6 步:前端 api ​

ainb-ui-admin-vue3/src/api/system/post/index.ts 全文(51 行):

ts
import request from '@/config/axios'

export interface PostVO {
  id?: number
  name: string
  code: string
  sort: number
  /** 所属组织编号,为空表示不挂组织 */
  deptId?: number
  status: number
  remark: string
  createTime?: Date
}

// 查询岗位列表
export const getPostPage = async (params: PageParam) => {
  return await request.get({ url: '/system/post/page', params })
}

// 获取岗位精简信息列表
export const getSimplePostList = async (): Promise<PostVO[]> => {
  return await request.get({ url: '/system/post/simple-list' })
}

// 批量删除岗位
export const deletePostList = async (ids: number[]) => {
  return await request.delete({ url: '/system/post/delete-list', params: { ids: ids.join(',') } })
}

// 导出岗位
export const exportPost = async (params) => {
  return await request.download({ url: '/system/post/export-excel', params })
}

(中间还有 getPost / createPost / updatePost / deletePost 四个,写法同上。)

四条约定:

  • url 不带 /admin-api 前缀,也不带 http:// 主机名。base_url = VITE_BASE_URL + VITE_API_URL 由 axios 层拼(.env.local 里是 http://localhost:48080 + /admin-api)。
  • PageParam / PageResult<T> 是全局声明(types/global.d.ts),不要 import,src/api/types.ts 不存在。
  • 页面侧统一 import * as PostApi from '@/api/system/post',所以导出方式(独立函数 vs 对象)不影响调用写法。
  • 导出走 request.download(responseType: 'blob',不解包 CommonResult)。

第 7 步:前端页面 ​

src/views/system/post/index.vue(283 行)的骨架,删掉具体字段后就是这个形状:

vue
<template>
  <ContentWrap><!-- 搜索栏:el-form inline + 搜索/重置/新增/导出/批量删除 --></ContentWrap>
  <ContentWrap>
    <el-table class="xpy-list-table" stripe v-loading="loading" :data="list"
              @selection-change="handleRowCheckboxChange">
      <el-table-column type="selection" width="55" />
      <el-table-column label="序号" type="index" width="44" :index="indexMethod(queryParams)" />
      <!-- 业务列 -->
      <el-table-column label="状态" align="center" prop="status" width="90">
        <template #default="scope">
          <StatusSwitch :model-value="scope.row.status === CommonStatusEnum.ENABLE"
                        :loading="statusLoadingId === scope.row.id"
                        :permission="['system:post:update']"
                        @change="(next) => handleUpdateStatus(scope.row, next)" />
        </template>
      </el-table-column>
      <el-table-column label="操作" align="center" min-width="110" fixed="right">
        <template #default="scope">
          <el-button link type="primary" @click="openForm('update', scope.row.id)"
                     v-hasPermi="['system:post:update']">编辑</el-button>
          <el-button link type="danger" @click="handleDelete(scope.row.id)"
                     v-hasPermi="['system:post:delete']">删除</el-button>
        </template>
      </el-table-column>
    </el-table>
    <Pagination :total="total" v-model:page="queryParams.pageNo"
                v-model:limit="queryParams.pageSize" @pagination="getList" />
  </ContentWrap>
  <PostForm ref="formRef" @success="getList" />
</template>

<script lang="ts" setup>
defineOptions({ name: 'SystemPost' })   // ← 必须等于 system_menu.component_name

const queryParams = reactive({ pageNo: 1, pageSize: 20, code: '', name: '', deptId: undefined, status: undefined })

const getList = async () => {
  loading.value = true
  try {
    const data = await PostApi.getPostPage(queryParams)
    list.value = data.list
    total.value = data.total
  } finally {
    loading.value = false
  }
}
</script>

ContentWrap / Pagination / Dialog / StatusSwitch / Icon 都是全局自动注册组件,不用 import;useMessage / useI18n / DICT_TYPE / getIntDictOptions 里只有 DICT_TYPE 是自动导入的,其余按需(岗位页写的是 import { DICT_TYPE, getIntDictOptions } from '@/utils/dict')。清单见通用组件与 hooks。

表单页 PostForm.vue(143 行)的关键是 defineExpose({ open }) + emit('success') 这一对:列表页用 formRef.value.open(type, id) 打开,保存成功后回调 getList。

ts
const open = async (type: string, id?: number) => {
  dialogVisible.value = true
  dialogTitle.value = t('action.' + type)   // action.create / action.update
  formType.value = type
  resetForm()
  if (id) {
    formLoading.value = true
    try { formData.value = await PostApi.getPost(id) } finally { formLoading.value = false }
  }
  deptList.value = handleTree(await DeptApi.getSimpleDeptList())
}
defineExpose({ open })

前端校验规则和后端 VO 没对齐

PostForm.vue 的 formRules 只给了 name / code / status 三条 required,sort 没有规则;而后端 PostSaveReqVO.sort 上是 @NotNull(message = "显示顺序不能为空"),resetForm() 又把 sort 重置成 undefined。结果:新增岗位时把顺序清空、点确定,前端放行,后端返 400,用户看到的是一条右上角的 ElNotification 红条而不字段下的红字。写新页面时照着后端 VO 的校验注解逐条补 formRules,别抄这个。

第 8 步:验证 ​

浏览器地址栏直接敲 /system/org-permission/post,或从左侧菜单点进去。

菜单从数据库到路由的完整链路(src/permission.ts:45-54 → src/store/modules/permission.ts:39 → src/utils/routerHelper.ts:74):

三个要点:

  1. 刷新浏览器就够,不用重新登录。守卫的条件是 !userStore.getIsSetUser,而 permission store 是 persist: false——F5 之后 pinia 重建,会重新走一遍 getInfo()。SPA 内部跳转则不会重拉。
  2. component 是用 String.includes + findIndex 匹配的(routerHelper.ts:160-163 与 :197-200),不是精确匹配。'system/post/index' 会命中 ../views/system/post/index.vue;但同目录下如果存在 indexBackup.vue,includes 同样为真,谁先被 glob 到谁生效。
  3. 匹配不到时 index === -1,modules[undefined] 是 undefined,路由 component 就成了空——页面白屏,控制台一行报错都没有。排查顺序:system_menu.component 的值 → src/views/ 下是否真有这个文件 → dev server 是否需要重启(新增文件后 glob 结果要重新收集)。

component_name 则是路由的 name(routerHelper.ts:135-138):

ts
name: route.componentName && route.componentName.length > 0
        ? route.componentName
        : toCamelCase(route.path, true),

留空就退化成「path 转首字母大写驼峰」(post → Post)。它同时是 keep-alive 的键,必须与页面里 defineOptions({ name }) 完全一致,否则「标签页切走再切回来状态丢了」。

数据模型 ​

system_menu 的关键列(实测 DDL):

列类型默认值说明
idbigintAUTO_INCREMENT当前 AUTO_INCREMENT=9000051152;9000xxxxxx 段是本项目定制/演示数据
namevarchar—菜单显示名,即 meta.title
permissionvarchar(100)NOT NULL DEFAULT ''权限串。目录与上游菜单留空,按钮必填
typetinyint—1=目录、2=菜单、3=按钮
sortint—同级排序
parent_idbigint—顶级为 0
pathvarchar—路由地址;顶级目录带 /,其余不带
iconvarchar'#'只认 ep: / fa: / fa-solid: / svg-icon: 四类前缀
componentvarchar—组件路径,相对 src/views/,不带 .vue
component_namevarchar—路由 name + keep-alive 键
statustinyint—0=开启、1=关闭
visiblebit(1)b'1'0 → meta.hidden = true,路由仍注册、菜单不显示
keep_alivebit(1)b'1'0 → meta.noCache = true
always_showbit(1)b'1'仅对有子节点的目录生效
deletedbit(1)NOT NULL DEFAULT b'0'逻辑删除。实测 17 行 ux_child_* / cb_child_* 都是 deleted=1 的 E2E 遗留,界面上看不到

system_menu 没有 tenant_id——菜单是平台级定义,全租户共享;租户之间的差异靠 system_role_menu 的授权不同来体现。

前端拿到的 meta 是这么映射的(routerHelper.ts:100-108):

ts
const meta = {
  title: route.name,
  icon: route.icon,
  hidden: !route.visible,
  noCache: !route.keepAlive,
  alwaysShow: route.children && route.children.length > 0
    && (route.alwaysShow !== undefined ? route.alwaysShow : true)
} as any

接口清单 ​

岗位的 8 个端点(实测 PostController.java),以及它们和权限串、前端函数、页面按钮的对应关系:

方法路径权限串前端函数页面上的入口
POST/system/post/createsystem:post:createcreatePost「新增」按钮 → PostForm
PUT/system/post/updatesystem:post:updateupdatePost「编辑」、状态列的 StatusSwitch
DELETE/system/post/deletesystem:post:deletedeletePost行内「删除」
DELETE/system/post/delete-listsystem:post:deletedeletePostList「批量删除」
GET/system/post/getsystem:post:querygetPostPostForm.open(type, id)
GET/system/post/pagesystem:post:querygetPostPage列表加载与搜索
GET/system/post/list-all-simple、/system/post/simple-list无 @PreAuthorizegetSimplePostList其它模块的下拉选项
GET/system/post/export-excelsystem:post:exportexportPost「导出」

两点值得注意:

  • delete-list 的注解写的是 @DeleteMapping("delete-list"),缺前导 /。Spring 会自动补,功能正常,但别照抄。
  • simple-list 类接口故意不加权限注解(只要登录即可)。给它加上 @PreAuthorize 会让无权限用户的下拉框变成空列表——这是「表单里选项没了」最常见的根因。

状态列的快捷开关没有专用端点,走的是整行 updatePost:

ts
// index.vue
const handleUpdateStatus = async (row: any, next: boolean) => {
  statusLoadingId.value = row.id
  try {
    await PostApi.updatePost({ ...row, status: next ? CommonStatusEnum.ENABLE : CommonStatusEnum.DISABLE })
    message.success(next ? '已开启' : '已关闭')
    await getList()
  } finally { statusLoadingId.value = null }
}

...row 把整行原样回传,所以后端 SaveReqVO 的唯一性校验(name / code)不会被误触发。

怎么扩展 ​

路线 A:手写(顺序别跳) ​

  1. 建表(MySQL)+ 补 H2 的 create_tables.sql 与 clean.sql;
  2. enums/ErrorCodeConstants.java 加错误码,选好分段并登记到 ServiceErrorCodeRange;
  3. dal/dataobject/<域>/XxxDO.java:@TableName + @KeySequence + 继承 BaseDO;
  4. dal/mysql/<域>/XxxMapper.java:extends BaseMapperX<XxxDO> + default selectPage;
  5. controller/admin/<域>/vo/<业务>/:XxxSaveReqVO / XxxPageReqVO extends PageParam / XxxRespVO(要导出就加 @ExcelProperty);
  6. service/<域>/:接口 + @Service @Validated 实现,业务错误一律 throw exception(ERROR_CODE);
  7. controller/admin/<域>/XxxController.java:八个端点按需,每个都挂 @PreAuthorize(simple-list 除外);
  8. 菜单 SQL 或界面建菜单 + 授权给角色;
  9. 前端 src/api/<域>/<业务>/index.ts + src/views/<域>/<业务>/{index.vue, XxxForm.vue},defineOptions({ name }) 对齐 component_name;
  10. mvn -pl <模块> test 补 Service 单测(见测试规范);
  11. 刷新浏览器验证。

路线 B:代码生成器(实测走查) ​

入口 基础设施 → 代码生成(路由 /infra/codegen,system_menu.id = 19,权限串 infra:codegen:query)。

这个菜单在演示环境里是关闭的

实测 system_menu 里 id = 19(代码生成)和 id = 83(代码生成案例)的 status 都是 1,即关闭状态——visible 是 true 但 status 关着,所以路由压根不会注册,左侧也看不到入口。这也正是使用手册里没有这两个页面的原因(手册只覆盖已开启的菜单)。

要用生成器,先在 系统管理 → 菜单管理 里把「代码生成」的状态改成开启,再给你的角色授权(infra:codegen:query / create / update / preview / download / delete 六个权限串,缺一个就少一个按钮),然后刷新页面。用 admin 登录可以跳过授权那一步——超管短路,但测不出普通角色的授权是否配对,见坑位索引 D3。

不想动演示环境的话,本页下面这份产出清单就是实测结果,照着改也一样。

后端 CodegenController(160 行)的关键端点:

端点权限串作用
GET /infra/codegen/db/table/listinfra:codegen:query列出库里还没导入的表
POST /infra/codegen/create-listinfra:codegen:create导入表,生成 infra_codegen_table / _column 定义
GET /infra/codegen/table/pageinfra:codegen:query列表页的分页查询(前端表格首屏走的就是它)
GET /infra/codegen/table/listinfra:codegen:query按数据源列出已导入的表定义(不分页)
GET /infra/codegen/detailinfra:codegen:query读生成配置(编辑表单用)
PUT /infra/codegen/updateinfra:codegen:update保存生成配置(上级菜单、类名、字段查询方式都在这里改)
PUT /infra/codegen/sync-from-dbinfra:codegen:update表结构变了以后同步
GET /infra/codegen/previewinfra:codegen:preview预览,返回文件路径 + 内容的数组
GET /infra/codegen/downloadinfra:codegen:download打包成 codegen.zip
DELETE /infra/codegen/delete、/delete-listinfra:codegen:delete删除导入记录

两个 table/* 端点容易和 db/table/list 记混

db/table/list 读的是数据库元数据(CodegenServiceImpl.java:301 → MyBatis-Plus 的 DatabaseTableService#getTableList 返回 TableInfo),并且在 :305-307 用 codegenTableMapper.selectListByDataSourceConfigId 拿到已导入的表名集合后 removeIf 剔除;table/page 与 table/list 读的则是 infra_codegen_table 里的导入记录。排查「导入了但列表看不到」时先分清问的是哪一头:在 db/table/list 里看不到,说明已经导入过了;在 table/page 里看不到,才是真丢了记录。

生成器不会写你的工程

只有 preview 和 download 两个出口,没有「直接生成到本地」这种端点。所有产出都要你手工落盘、手工挪位置、手工改。

实测流程(对 system_post 走了一遍):导入表 → 编辑生成信息 → 预览拿到 14 个文件 → 删除导入记录。产出的文件清单:

#路径行数
1enlev-module-system/.../controller/admin/post/vo/PostPageReqVO.java38
2.../controller/admin/post/vo/PostRespVO.java47
3.../controller/admin/post/vo/PostSaveReqVO.java37
4.../controller/admin/post/PostController.java104
5.../dal/dataobject/post/PostDO.java56
6.../dal/mysql/post/PostMapper.java32
7enlev-module-system/src/main/resources/mapper/post/PostMapper.xml12
8.../service/post/PostServiceImpl.java85
9.../service/post/PostService.java62
10.../enums/ErrorCodeConstants_手动操作.java3
11sql/sql.sql54
12ainb-ui-admin-vue3/src/views/system/post/index.vue278
13ainb-ui-admin-vue3/src/views/system/post/PostForm.vue122
14ainb-ui-admin-vue3/src/api/system/post/index.ts51

菜单 SQL 是真实产出(parentMenuId 已在生成配置里设成 9000050900):

sql
-- 菜单 SQL
INSERT INTO system_menu(
    name, permission, type, sort, parent_id,
    path, icon, component, status, component_name
)
VALUES (
    '岗位信息管理', '', 2, 0, 9000050900,
    'post', '', 'system/post/index', 0, 'Post'
);

-- 按钮父菜单ID
SELECT @parentId := LAST_INSERT_ID();

-- 按钮 SQL(query / create / update / delete / export 各一条)
INSERT INTO system_menu(
    name, permission, type, sort, parent_id,
    path, icon, component, status
)
VALUES (
    '岗位信息查询', 'system:post:query', 3, 1, @parentId,
    '', '', '', 0
);

「上级菜单」不填,生成的 SQL 里会是字面量 ${table.parentMenuId}

Velocity 对 null 引用不做替换、直接输出原文。实测:不填上级菜单时预览出来的 parent_id 位置就是字符串 ${table.parentMenuId};用 PUT /infra/codegen/update 把 parentMenuId 设成 9000050900 之后重新预览,才变成真实数字。执行前一定先看一眼预览里的这一行。

生成后必须手改的 7 处(逐项实测对照):

#生成器给的仓库现状 / 应改成为什么
1ErrorCodeConstants_手动操作.java 里 new ErrorCode(TODO 补充编号, ...)挑一个未占用段位,如 1_002_005_000字面量 TODO 补充编号 编译不过;常量名生成器用 POST_NOT_EXISTS,仓库用 POST_NOT_FOUND
2包路径 controller/admin/post/、dal/dataobject/post/、service/post/岗位真实在 dept 包下生成器不知道历史分包;新业务用它没问题,别拿它去挪已有文件
3component_name = 'Post' + defineOptions({ name: 'Post' })仓库惯例是 SystemPost(域前缀 + 实体)两处必须一致,否则 keep-alive 失效;改名要同时改菜单行
4菜单名 '岗位信息管理'(classComment + 「管理」)真实是 '岗位管理'表注释是「岗位信息表」,生成器只截了前半段再拼「管理」
5PostMapper.selectPage 把每个字段都当查询条件(eqIfPresent(code/sort/deptId/status/remark) + betweenIfPresent(createTime))真实的只有 code / name / status / deptId 四个全字段搜索会生成一堆没人用的输入框,且 remark 这种长文本做 eq 没意义
6export const PostApi = { ... } 对象式仓库是独立导出函数 + 页面 import * as PostApi两种写法调用方式相同(都是 PostApi.getPostPage(...)),但混在一个模块里风格不一致
7deletePostList 写成 url: `/system/post/delete-list?ids=${ids.join(',')}` 仓库是 url: '/system/post/delete-list', params: { ids: ids.join(',') }手拼 query 不走 paramsSerializer(qs.stringify(..., {allowDots: true})),数组参数会编码不一致

另外生成器给的 PostDO 带 @author 超级管理员(取自登录用户昵称),api/system/post/index.ts 里有一句没用到的 import type { Dayjs } from 'dayjs';,接口字段缩进也不整齐——落盘前顺手清一下。

模板都在 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}
vue3/…                       # 前端 api / views / schema

导入表这一步会用登录用户的昵称当 author

CodegenController:96 是 codegenService.createCodegenList(getLoginUserNickname(), reqVO),而 infra_codegen_table.author 是 varchar(50) NOT NULL 且没有默认值。用一个昵称为空的主体(脚本、mock token、某些服务账号)调 create-list,会直接 500:java.sql.SQLException: Field 'author' doesn't have a default value。用真实账号(如 admin)登录再导入。

坑与注意 ​

  • component 写错的失败方式是白屏且无报错:findIndex 返回 -1 → modules[undefined] → 路由没有组件。先核对 system_menu.component 与 src/views/ 下的真实相对路径,再看是否需要重启 dev server。
  • component_name 与 defineOptions({ name }) 必须完全一致,否则 keep-alive 失效(切走再切回来,搜索条件和滚动位置全丢)。改其中一个必须同时改另一个,还要改 system_menu 那一行。
  • 菜单改完刷新浏览器就行,不用重新登录:permission store 是 persist: false,F5 会重走 getInfo()。但如果你是在同一个 SPA 会话里改的、又没走界面(界面会自动 wsCache.delete(CACHE_KEY.ROLE_ROUTERS)),那就得手动 F5。
  • 用超管账号测不出权限问题。super_admin 走短路不查 system_role_menu,v-hasPermi 也永远为真。验证授权必须换一个非超管账号。
  • 直接 INSERT 菜单不会清 Redis 的 permission_menu_ids。新增全新权限串通常无感,改已有权限串会读到旧缓存。
  • status = 0 是开启,不是 1。CommonStatusEnum.ENABLE = 0、DISABLE = 1,菜单、岗位、字典全用这一套。
  • visible = 0 只是不显示,路由照样注册。想彻底禁止访问要 status = 1(关闭)或删掉授权。
  • 新建表漏 tenant_id 又没登记 ignore-tables:报 Unknown column 'tenant_id';在计量、日志这类「异常被吞」的路径上,表现是整条记录静默丢失,不是接口报错。
  • H2 的 create_tables.sql / clean.sql 漏同步:BaseDbUnitTest 在 Spring 启动阶段就失败,报错信息指向 schema-locations,容易误判成配置问题。
  • 前端 formRules 与后端 VO 校验不对齐(岗位页的 sort 就是活例子):前端放行、后端 400,用户看到的是右上角红条而不是字段红字。
  • @DeleteMapping("delete-list") 少个 /:能用,但别照抄;批量参数走 params 而不是手拼 query,才能吃到 qs.stringify(..., {allowDots: true})。
  • 代码生成器的「上级菜单」必须填,否则 SQL 里是字面量 ${table.parentMenuId},执行时会被 MySQL 当成语法错误(或更糟——被当变量解析成 NULL)。
  • 菜单图标只认 ep: / fa: / fa-solid: / svg-icon:,其它前缀要联网拉 Iconify,内网部署会空白。
  • system_menu 没有 tenant_id,别给它加。菜单是平台级定义,租户差异体现在 system_role_menu。

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

相关页面 ​