新增一个菜单功能端到端
适用端:后端 + Web | 类型:操作
从「建一张表」到「左侧菜单能点开、按钮受权限控制」,中间有 9 个字符串必须两两对齐,错任何一个都会表现成白屏、按钮消失或 403。本页用仓库里真实存在的**岗位(Post)**当样本走完全程——所有路径、DDL、SQL、代码都是实测原文;最后给出代码生成器的真实产出(14 个文件)与仓库现状的差异清单。
前置阅读:接口约定与 CRUD 骨架、数据库约定与表总览、路由与权限。
它解决什么问题
一个功能「做完」的定义不是代码写完,而是这条链路全通:
三处最容易断的地方,都在「字符串对齐」上:
| # | 字符串 | 出现在哪几处 | 岗位的真实值 |
|---|---|---|---|
| 1 | 表名 | DDL、@TableName、H2 create_tables.sql、clean.sql | system_post |
| 2 | URL 前缀 | @RequestMapping、前端 api 的 url、权限串第 2 段 | /system/post |
| 3 | 权限串 | @PreAuthorize、system_menu.permission、v-hasPermi | system: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.type | common_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 |
| DO | enlev-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 个用例) |
| 前端 api | ainb-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(去掉字符集声明):
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='岗位信息表';四个不能省的点:
- 每列都要写
COMMENT。代码生成器把注释直接变成@Schema(description=...)、前端表单 label 和 Excel 列头;低代码建模也读它。没注释的字段生成出来是空的。 - 6 个公共列(
creator / create_time / updater / update_time / deleted加上主键)由BaseDO自动填充,一个都不能少。 - 要不要
tenant_id:enlev.tenant.enable: true,拦截器给除白名单外的所有表拼AND tenant_id = ?。租户私有数据就带上这一列;平台级事实表则不带,并且必须同步登记进enlev.tenant.ignore-tables,否则报Unknown column 'tenant_id'(详见数据库约定)。 - 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):
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 一一对应,多一个少一个都会让搜索栏「有输入框但不生效」:
// 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。
允许被编辑成空的外键字段要显式声明更新策略:
// PostDO.java
@TableField(updateStrategy = FieldStrategy.ALWAYS)
private Long deptId;updateById 默认跳过 null 字段。少了这一行,「编辑岗位、把所属组织清掉」会静默不生效:界面提示保存成功,列表刷新后组织又回来了。回归单测 PostServiceImplTest#testUpdatePost_clearDeptId 钉着这个行为。
第 3 步:错误码
enlev-module-system/.../enums/ErrorCodeConstants.java:69:
// ========== 岗位模块 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=按钮):
| id | name | type | sort | parent_id | path | component | component_name | permission | icon |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 系统管理 | 1 | 10 | 0 | /system | — | — | (空) | ep:tools |
| 9000050900 | 组织权限 | 1 | 1 | 1 | org-permission | — | — | (空) | ep:user-filled |
| 8 | 岗位管理 | 2 | 2 | 9000050900 | post | system/post/index | SystemPost | (空) | fa:address-book-o |
| 43 | 岗位查询 | 3 | 1 | 8 | (空) | (空) | — | system:post:query | # |
| 44–47 | 岗位新增/修改/删除/导出 | 3 | 2–5 | 8 | (空) | (空) | — | 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(代码生成器给的就是这种)
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 行):
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 行)的骨架,删掉具体字段后就是这个形状:
<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。
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):
三个要点:
- 刷新浏览器就够,不用重新登录。守卫的条件是
!userStore.getIsSetUser,而 permission store 是persist: false——F5 之后 pinia 重建,会重新走一遍getInfo()。SPA 内部跳转则不会重拉。 component是用String.includes+findIndex匹配的(routerHelper.ts:160-163与:197-200),不是精确匹配。'system/post/index'会命中../views/system/post/index.vue;但同目录下如果存在indexBackup.vue,includes同样为真,谁先被 glob 到谁生效。- 匹配不到时
index === -1,modules[undefined]是undefined,路由component就成了空——页面白屏,控制台一行报错都没有。排查顺序:system_menu.component的值 →src/views/下是否真有这个文件 → dev server 是否需要重启(新增文件后 glob 结果要重新收集)。
component_name 则是路由的 name(routerHelper.ts:135-138):
name: route.componentName && route.componentName.length > 0
? route.componentName
: toCamelCase(route.path, true),留空就退化成「path 转首字母大写驼峰」(post → Post)。它同时是 keep-alive 的键,必须与页面里 defineOptions({ name }) 完全一致,否则「标签页切走再切回来状态丢了」。
数据模型
system_menu 的关键列(实测 DDL):
| 列 | 类型 | 默认值 | 说明 |
|---|---|---|---|
id | bigint | AUTO_INCREMENT | 当前 AUTO_INCREMENT=9000051152;9000xxxxxx 段是本项目定制/演示数据 |
name | varchar | — | 菜单显示名,即 meta.title |
permission | varchar(100) | NOT NULL DEFAULT '' | 权限串。目录与上游菜单留空,按钮必填 |
type | tinyint | — | 1=目录、2=菜单、3=按钮 |
sort | int | — | 同级排序 |
parent_id | bigint | — | 顶级为 0 |
path | varchar | — | 路由地址;顶级目录带 /,其余不带 |
icon | varchar | '#' | 只认 ep: / fa: / fa-solid: / svg-icon: 四类前缀 |
component | varchar | — | 组件路径,相对 src/views/,不带 .vue |
component_name | varchar | — | 路由 name + keep-alive 键 |
status | tinyint | — | 0=开启、1=关闭 |
visible | bit(1) | b'1' | 0 → meta.hidden = true,路由仍注册、菜单不显示 |
keep_alive | bit(1) | b'1' | 0 → meta.noCache = true |
always_show | bit(1) | b'1' | 仅对有子节点的目录生效 |
deleted | bit(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):
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/create | system:post:create | createPost | 「新增」按钮 → PostForm |
| PUT | /system/post/update | system:post:update | updatePost | 「编辑」、状态列的 StatusSwitch |
| DELETE | /system/post/delete | system:post:delete | deletePost | 行内「删除」 |
| DELETE | /system/post/delete-list | system:post:delete | deletePostList | 「批量删除」 |
| GET | /system/post/get | system:post:query | getPost | PostForm.open(type, id) |
| GET | /system/post/page | system:post:query | getPostPage | 列表加载与搜索 |
| GET | /system/post/list-all-simple、/system/post/simple-list | 无 @PreAuthorize | getSimplePostList | 其它模块的下拉选项 |
| GET | /system/post/export-excel | system:post:export | exportPost | 「导出」 |
两点值得注意:
delete-list的注解写的是@DeleteMapping("delete-list"),缺前导/。Spring 会自动补,功能正常,但别照抄。simple-list类接口故意不加权限注解(只要登录即可)。给它加上@PreAuthorize会让无权限用户的下拉框变成空列表——这是「表单里选项没了」最常见的根因。
状态列的快捷开关没有专用端点,走的是整行 updatePost:
// 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:手写(顺序别跳)
- 建表(MySQL)+ 补 H2 的
create_tables.sql与clean.sql; enums/ErrorCodeConstants.java加错误码,选好分段并登记到ServiceErrorCodeRange;dal/dataobject/<域>/XxxDO.java:@TableName+@KeySequence+ 继承BaseDO;dal/mysql/<域>/XxxMapper.java:extends BaseMapperX<XxxDO>+default selectPage;controller/admin/<域>/vo/<业务>/:XxxSaveReqVO/XxxPageReqVO extends PageParam/XxxRespVO(要导出就加@ExcelProperty);service/<域>/:接口 +@Service @Validated实现,业务错误一律throw exception(ERROR_CODE);controller/admin/<域>/XxxController.java:八个端点按需,每个都挂@PreAuthorize(simple-list除外);- 菜单 SQL 或界面建菜单 + 授权给角色;
- 前端
src/api/<域>/<业务>/index.ts+src/views/<域>/<业务>/{index.vue, XxxForm.vue},defineOptions({ name })对齐component_name; mvn -pl <模块> test补 Service 单测(见测试规范);- 刷新浏览器验证。
路线 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/list | infra:codegen:query | 列出库里还没导入的表 |
POST /infra/codegen/create-list | infra:codegen:create | 导入表,生成 infra_codegen_table / _column 定义 |
GET /infra/codegen/table/page | infra:codegen:query | 列表页的分页查询(前端表格首屏走的就是它) |
GET /infra/codegen/table/list | infra:codegen:query | 按数据源列出已导入的表定义(不分页) |
GET /infra/codegen/detail | infra:codegen:query | 读生成配置(编辑表单用) |
PUT /infra/codegen/update | infra:codegen:update | 保存生成配置(上级菜单、类名、字段查询方式都在这里改) |
PUT /infra/codegen/sync-from-db | infra:codegen:update | 表结构变了以后同步 |
GET /infra/codegen/preview | infra:codegen:preview | 预览,返回文件路径 + 内容的数组 |
GET /infra/codegen/download | infra:codegen:download | 打包成 codegen.zip |
DELETE /infra/codegen/delete、/delete-list | infra: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 个文件 → 删除导入记录。产出的文件清单:
| # | 路径 | 行数 |
|---|---|---|
| 1 | enlev-module-system/.../controller/admin/post/vo/PostPageReqVO.java | 38 |
| 2 | .../controller/admin/post/vo/PostRespVO.java | 47 |
| 3 | .../controller/admin/post/vo/PostSaveReqVO.java | 37 |
| 4 | .../controller/admin/post/PostController.java | 104 |
| 5 | .../dal/dataobject/post/PostDO.java | 56 |
| 6 | .../dal/mysql/post/PostMapper.java | 32 |
| 7 | enlev-module-system/src/main/resources/mapper/post/PostMapper.xml | 12 |
| 8 | .../service/post/PostServiceImpl.java | 85 |
| 9 | .../service/post/PostService.java | 62 |
| 10 | .../enums/ErrorCodeConstants_手动操作.java | 3 |
| 11 | sql/sql.sql | 54 |
| 12 | ainb-ui-admin-vue3/src/views/system/post/index.vue | 278 |
| 13 | ainb-ui-admin-vue3/src/views/system/post/PostForm.vue | 122 |
| 14 | ainb-ui-admin-vue3/src/api/system/post/index.ts | 51 |
菜单 SQL 是真实产出(parentMenuId 已在生成配置里设成 9000050900):
-- 菜单 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 处(逐项实测对照):
| # | 生成器给的 | 仓库现状 / 应改成 | 为什么 |
|---|---|---|---|
| 1 | ErrorCodeConstants_手动操作.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 包下 | 生成器不知道历史分包;新业务用它没问题,别拿它去挪已有文件 |
| 3 | component_name = 'Post' + defineOptions({ name: 'Post' }) | 仓库惯例是 SystemPost(域前缀 + 实体) | 两处必须一致,否则 keep-alive 失效;改名要同时改菜单行 |
| 4 | 菜单名 '岗位信息管理'(classComment + 「管理」) | 真实是 '岗位管理' | 表注释是「岗位信息表」,生成器只截了前半段再拼「管理」 |
| 5 | PostMapper.selectPage 把每个字段都当查询条件(eqIfPresent(code/sort/deptId/status/remark) + betweenIfPresent(createTime)) | 真实的只有 code / name / status / deptId 四个 | 全字段搜索会生成一堆没人用的输入框,且 remark 这种长文本做 eq 没意义 |
| 6 | export const PostApi = { ... } 对象式 | 仓库是独立导出函数 + 页面 import * as PostApi | 两种写法调用方式相同(都是 PostApi.getPostPage(...)),但混在一个模块里风格不一致 |
| 7 | deletePostList 写成 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。
→ 本节是贴着上下文的提醒;按症状查的全域清单见坑位索引。
相关页面
- 接口约定与 CRUD 骨架:五件套每一层的写法与全项目约定;
- 数据库约定与表总览:公共字段、逻辑删除、多租户白名单;
- 路由与权限:
v-hasPermi、@PreAuthorize与定制授权引擎; - 通用组件与 hooks:
ContentWrap/Pagination/StatusSwitch/TableActions怎么用; - 测试规范:给新 Service 补单测;
- 坑位索引:全项目踩过的坑汇总。
