说实话,这个知识管理系统并不是我一时兴起做的。团队里的文档散落在各人网盘、微信聊天记录和本地文件夹里,每次需要一份资料都要来回问好几轮,于是我就花了两三周时间,用 SpringBoot + Vue 从零搭了一个前后端分离的系统,专门用来沉淀和检索团队知识。整个项目的后端是 Java + SpringBoot + MyBatis + MySQL,前端是 Vue 2 + Element UI,功能覆盖登录注册、角色权限、知识条目的分类管理、富文本编辑、附件上传、评论收藏、全文检索和简单的数据统计。这篇文章把从建表、接口实现到前端页面、再到部署过程中踩过的坑完整记录下来,适合正在做 SpringBoot+Vue 毕业设计,或者想找一个完整 JavaWeb 管理系统项目练手的朋友。
1. 项目定位与整体设计思路
1.1 为什么选 SpringBoot + Vue 这套组合
先说说技术选型。知识管理系统说白了就是一个典型的管理信息系统,核心业务是“数据的增删改查 + 权限控制 + 检索统计”。这种场景下,SpringBoot 是目前 Java 生态里效率最高的脚手架,它能用极少配置就把 Web 容器、数据源、事务、JSON 序列化这些基础能力全部集成好,我不用花大量时间在配置上,可以集中精力写业务。
MyBatis 在中小型管理系统里有天然优势,SQL 完全由自己掌控,复杂的连表查询、动态条件组合非常灵活。面试里经常被问“用 JPA 还是 MyBatis”,我的实际体会是:如果项目里查询条件多变、存在报表类复杂 SQL,用 MyBatis 更容易把控性能;如果业务模型稳定、实体关系复杂,JPA 能省掉大量样板代码。这个项目里我选择了 MyBatis + 通用 Mapper 的组合,并对分页做了统一处理。
Vue 侧我用的是 Vue 2 + Element UI,原因很直接:后台管理系统的页面形态高度重复,表格、表单、弹窗、分页、树形控件这些组件 Element UI 已经封装得很完善,二次开发效率明显高于原生手写。MySQL 版本建议用 8.0,默认字符集就是 utf8mb4,对中文和 emoji 的支持更好;不过如果你的服务器只提供 5.7 也不用纠结,本项目里的 SQL 没有依赖 8.0 专属特性。
| 维度 | SpringBoot + MyBatis | SpringBoot + JPA |
|---|---|---|
| SQL 控制力 | 完全可控,适合复杂查询 | 弱,复杂查询需要 @Query 拼写 |
| 学习成本 | 需要理解 XML 和动态 SQL | 较低,入门快 |
| 报表类统计 | 很灵活,可直接写原生 SQL | 别扭,常需要自定义映射 |
| 分页方案 | PageHelper 等插件成熟 | 自带 Pageable,简单场景够用 |
1.2 功能模块拆解与边界划分
功能范围从一开始就要想清楚,否则项目会越做越散。这个知识管理系统最终被我切成了七大模块。
| 模块 | 核心功能 | 面向角色 |
|---|---|---|
| 用户管理 | 登录注册、个人信息、密码修改 | 所有用户 |
| 角色权限 | 管理员/普通用户,按钮级权限控制 | 管理员 |
| 知识分类 | 分类树的增删改、层级调整 | 管理员 |
| 知识条目 | 富文本编辑、发布/下架、置顶 | 管理员 |
| 检索与浏览 | 关键词检索、浏览历史记录 | 所有用户 |
| 互动模块 | 评论、点赞、收藏 | 登录用户 |
| 数据统计 | 分类数量统计、发布趋势图 | 管理员 |
模块边界划定遵循“一个模块解决一类问题”的原则。分类和知识条目虽然都属于内容域,但在数据库上必须独立,因为分类表是树结构,需要递归组装;而知识条目的检索条件通常是标题、摘要、标签匹配,二者合在一起会让 SQL 变得异常复杂。权限这块我只用了两个角色:管理员负责内容维护,普通用户只能浏览和互动。如果你后期需要跟企业组织结构打通,再把用户-角色-菜单三张关联表引进来也不迟。
1.3 服务端代码目录结构
后台目录我按照常见的分层结构组织,没有设计得很花哨,但足够清晰:
com.example.kms ├── config // 配置类:拦截器、跨域、资源映射 ├── controller // 接口层,只做参数校验和结果封装 ├── service // 业务层,事务控制的边界 ├── mapper // MyBatis 的 Mapper 接口 ├── entity // 数据实体 ├── dto // 请求传输对象 ├── vo // 前端展示对象 ├── utils // JWT、文件上传等工具类 └── common // Result 封装、异常类、常量分层是管理系统项目里最核心的纪律。Controller 里不允许出现业务逻辑,比如判断分类下有没有子节点、能不能删除,这些都应该下沉到 Service 层。一旦乱了,日志排查就是灾难。我有段时间为了赶进度,把一个校验逻辑写在了 Controller 里,后面出问题翻代码时浪费了整整一下午,自那以后我严格按照分层写,再也没有犯过类似错误。
2. 数据库设计与核心表结构
2.1 核心表结构设计
知识管理系统的表我设计成四张核心表:用户表、分类表、知识表、评论表,再加上收藏和浏览记录两张辅助表。下面给出最核心的三张建表语句。
用户表:
CREATE TABLE t_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键', username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', nickname VARCHAR(50) DEFAULT '' COMMENT '昵称', avatar VARCHAR(255) DEFAULT '' COMMENT '头像地址', role TINYINT NOT NULL DEFAULT 1 COMMENT '0-管理员 1-普通用户', status TINYINT NOT NULL DEFAULT 1 COMMENT '1-启用 0-禁用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';分类表:
CREATE TABLE t_category ( id BIGINT AUTO_INCREMENT PRIMARY KEY, parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父分类id,0为根', name VARCHAR(50) NOT NULL, sort INT NOT NULL DEFAULT 0 COMMENT '同级排序', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识分类表';知识条目表:
CREATE TABLE t_knowledge ( id BIGINT AUTO_INCREMENT PRIMARY KEY, category_id BIGINT NOT NULL COMMENT '所属分类', title VARCHAR(200) NOT NULL, summary VARCHAR(500) DEFAULT '' COMMENT '摘要', content MEDIUMTEXT COMMENT '富文本内容', tags VARCHAR(200) DEFAULT '' COMMENT '逗号分隔的标签', cover_image VARCHAR(255) DEFAULT '' COMMENT '封面图', view_count INT NOT NULL DEFAULT 0, like_count INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT '1-已发布 0-下架', is_top TINYINT NOT NULL DEFAULT 0 COMMENT '是否置顶', create_by BIGINT NOT NULL COMMENT '创建人', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记', KEY idx_category (category_id), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识条目表';表结构设计时有几个关键点。第一,密码字段绝对不能是明文,这里存的是 BCrypt 加密后的密文,长度预留到 100 字符。第二,knowledge 表和 category 表之间不建物理外键,只做逻辑关联。真加了外键,分类删除时会非常痛苦,比如要删一个分类,得先把该分类的知识条目全部迁移或删除,这类业务约束放在 Service 层控制比数据库约束灵活得多。第三,所有查询列表的表都加了逻辑删除字段 deleted,而不是物理 DELETE,这样保留操作日志回溯的能力。
2.2 字段类型细节与索引设计
很多初学者建表时对字段类型不敏感,这里我把当初纠结过的几个点列出来。
标题字段长度定为 200,刚好保留中文标题的空间,又不至于让索引过长。内容字段用 MEDIUMTEXT 而不是 TEXT,原因很实际:一篇知识库文档经常超过 64KB(TEXT 的上限),如果一开始用 TEXT,后期数据涨上去就要改表结构,非常麻烦。至于评论表,它和知识条目是多对一的关系,评论内容用 VARCHAR(500) 就差不多了,毕竟单条评论一般不会写成长文。
索引方面,除了主键,我给 category_id 和 create_time 建了普通索引,因为最频繁的查询就是“查某个分类下的最新列表”。id 是主键索引,这里不再赘述。另外一个细节是时间字段统一用 DATETIME,不用 TIMESTAMP,原因是 TIMESTAMP 的范围只到 2038 年,而 DATETIME 范围更大,更重要的是 DATETIME 存业务时间不受数据库时区影响,排查问题时少很多干扰。
2.3 分类树的组装与排序
分类表是典型的树结构。我用了最直观的 parent_id 方式,根节点的 parent_id = 0。后端一次性返回所有分类,前端拿到后递归生成树形下拉框和菜单。这种方案适合分类数量在几百以内的系统,如果分类规模到几千甚至上万,更稳妥的做法是在 service 层用 map 组装树,避免前端递归带来的额外请求。
对应的组装代码:
public List<CategoryVO> buildTree(List<Category> list) { Map<Long, CategoryVO> map = new HashMap<>(); for (Category c : list) { CategoryVO vo = new CategoryVO(c); vo.setChildren(new ArrayList<>()); map.put(c.getId(), vo); } List<CategoryVO> roots = new ArrayList<>(); for (CategoryVO vo : map.values()) { if (vo.getParentId() != null && vo.getParentId() != 0) { map.get(vo.getParentId()).getChildren().add(vo); } else { roots.add(vo); } } // 组装完之后必须按 sort 字段排序 sortList(roots); return roots; }这里有一个容易被忽略的坑:HashMap 本身是无序的,如果你不主动对每个层级的 children 按 sort 排序,刷新几次页面,分类顺序会随机变化,用户看着会非常困惑。所以组装完树之后,一定要递归排序。这个细节我在第一版就漏了,是测试同事反馈“分类顺序不稳定”才发现的。
3. 后端关键功能实现
3.1 统一返回结构与全局异常处理
接口返回结构统一,是管理系统的底线。我定义了一个 Result<T> 封装类,所有接口都返回它:
@Getter public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.code = 500; r.message = message; return r; } }约定 code=200 是成功,401 是未登录或 token 失效,403 是无权限,500 是业务异常。配合 @RestControllerAdvice 和 @ExceptionHandler 做全局异常处理,Controller 里就不需要散落大量 try/catch 了,业务异常直接 throw new BizException("xxx"),由全局处理器统一包装返回。这个做法的收益在项目后期特别明显:不管后端哪里抛异常,返回给前端的数据结构永远是一致的,前端只需要处理一种格式,写 axios 响应拦截器时会非常舒服。
3.2 JWT 登录鉴权的实现细节
登录流程是这样:用户提交用户名密码,Service 层用 BCrypt 校验密码,成功后生成 JWT 返回给前端,前端存到 localStorage 并在后续请求的 Header 里带上 Authorization。JWT 我用的是 jjwt 库,生成逻辑如下:
public String generateToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + 24 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) .compact(); }然后写一个 HandlerInterceptor 做登录校验:从 Header 取出 token,解析成功就把 userId 放到 request 的 attribute 中,Controller 通过 @RequestAttribute 直接取。拦截器注册的时候要排除登录接口、注册接口、静态资源和 Swagger 文档路径。这里有几个实践心得:第一,token 过期时不要返回 500,要返回 401,前端根据 code 跳转登录页;第二,SECRET_KEY 不要写死在 Java 代码里,写到 application.yml 中,部署时可通过环境变量覆盖;第三,载荷里不要放敏感信息,JWT 的 payload 是 base64 编码,明文可见,放 userId 和 role 就够了。
3.3 MyBatis 复杂查询与动态 SQL
知识条目列表的查询条件非常多变:按分类、按关键词、按标签、按时间段,条件组合起来很多。如果全部用注解写 SQL 不现实,所以我用 Mapper XML 配合动态 SQL 解决。
<select id="selectPage" resultType="com.example.kms.vo.KnowledgeVO"> SELECT k.id, k.title, k.summary, k.tags, k.view_count, k.like_count, k.create_time, c.name AS categoryName FROM t_knowledge k LEFT JOIN t_category c ON k.category_id = c.id <where> k.deleted = 0 <if test="categoryId != null and categoryId != 0"> AND k.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (k.title LIKE CONCAT('%', #{keyword}, '%') OR k.summary LIKE CONCAT('%', #{keyword}, '%') OR k.tags LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="status != null"> AND k.status = #{status} </if> </where> ORDER BY k.is_top DESC, k.create_time DESC </select><where>标签会自动处理首个 AND 前缀的问题,这比我早期手动拼接“WHERE 1=1”要优雅得多。分页我用的是 PageHelper,调用方式是在执行查询前先PageHelper.startPage(pageNum, pageSize),然后紧跟的第一个查询就是分页查询。还有一个细节:如果查询列表时需要 JOIN 多张表,PageHelper 自动生成的 COUNT 语句可能不准确,这时候我会手动写 count 查询,保证 total 正确。这些坑后面第 5 节会详细展开。
3.4 附件上传与本地静态资源映射
附件上传这一块,我采用了本地磁盘存储,没用 MinIO 或者云对象存储,原因很直接:这套系统一开始就是内网部署,没有对象存储的基建条件。文件保存路径配置在 application.yml 里,上传时用 UUID 重命名文件,避免文件名冲突和路径穿越问题。
@PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) { // 校验文件大小、类型白名单 String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = UUID.randomUUID().toString().replace("-", "") + ext; String datePath = LocalDate.now().toString().replace("-", "/"); File dir = new File(uploadDir + "/" + datePath); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); String url = "/upload/" + datePath + "/" + filename; return Result.success(url); }为了让上传的图片在浏览器里直接能访问,我实现了 WebMvcConfigurer,把本地上传目录映射为/upload/**静态资源路径:
@Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir + "/"); }踩过的坑有两个。一是 Windows 和 Linux 的路径拼接方式不同,不能硬编码/或\,要用File.separator或者直接用 Paths.get 拼接。二是上传文件大小限制,Spring Boot 默认单文件最大 1MB,跑富文本编辑器插图的时候很容易超限,必须在配置文件里调大spring.servlet.multipart.max-file-size和max-request-size。
4. 前端实现要点
4.1 Vue 工程骨架与路由设计
前端脚手架用的 Vue CLI,技术栈是 Vue 2 + Element UI + axios + vue-router + vuex。目录上没有做什么特殊设计,就是常规结构:src/api放接口请求模块,src/router放路由配置,src/store放状态管理,src/views按功能模块放页面。
路由分静态路由和动态路由两类。静态路由包含登录页、注册页、404 页;登录成功之后,根据用户角色动态添加首页和管理页等路由。Vue 2 的写法是用router.addRoutes,在全局前置守卫里判断 token 存在且路由未注册时,先 addRoutes 再next({ ...to, replace: true })。这个写法的好处是普通用户即使手动输入管理端 URL,也进不去对应页面,因为路由根本不存在。
router.beforeEach((to, from, next) => { const token = store.state.user.token if (to.meta.public) { next() } else if (!token) { next('/login') } else { if (!store.state.user.userInfo) { store.dispatch('getUserInfo').then(() => { next({ ...to, replace: true }) }) } else { next() } } })刚写完路由守卫时我很开心,以为权限完备了,后来才发现路由级权限只是第一道门。页面上的按钮,比如“删除”“下架”,还是会对普通用户可见,这就引出了按钮级权限,也就是第 4.4 节的内容。
4.2 axios 封装与开发代理配置
axios 必须统一封装,否则每个页面都写错误处理会痛苦到怀疑人生。我的 request.js 做了三件事:请求拦截器自动加 Authorization 头、响应拦截器统一处理 code、请求超时时间设为 15 秒。当 code 等于 401 时,清除本地 token 并跳转登录页。
service.interceptors.response.use( response => { const res = response.data if (res.code === 401) { store.dispatch('logout') router.push('/login') return Promise.reject(new Error('登录已过期')) } if (res.code !== 200) { Message.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { Message.error(error.message || '网络异常') return Promise.reject(error) } )接口的 baseURL 设置为/api,开发环境通过 vue.config.js 代理到后端,避免跨域问题:
module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }这里要强调一个经验:生产环境不要直接把 baseURL 写成本机地址,否则打包后部署到别的服务器就废了。我习惯在 public 目录下放一个 config.js,里面声明全局变量,打包时让运维改这个文件就行,后端地址和 Nginx 转发地址全都收敛在配置里。
4.3 核心页面实现:知识列表与富文本编辑
知识列表页是系统的主页面,用 el-table 展示,顶部是搜索区,包含分类树选择、关键词输入框、状态选择。表格列有标题、分类、阅读数、状态、发布时间,操作列放编辑、上下架、删除按钮。
分类树选择器我用的 el-cascader,数据就是后端返回的分类树,配置了checkStrictly: true,允许只选父级分类,这样用户可以在不选中叶子节点的情况下查询整个分类下的知识。
富文本编辑器选了 vue-quill-editor,它是 Quill 的 Vue 封装,开箱即用。编辑器有个令人头疼的点:默认粘贴图片会把图片以 base64 形式塞进 content 字段,一篇图文并茂的文章下来,请求体可能涨到几 MB,轻则上传失败,重则把 Tomcat 的连接占满。我的方案是关闭 Quill 自带的 base64 粘贴,改成自定义图片上传:
editorOption: { modules: { toolbar: { container: toolbarOptions, handlers: { image: function () { // 弹出文件选择框,拿到 File 后调用统一上传接口 } } } } }图片文件先走上传接口拿到 URL,再把 URL 插入编辑器。这样 content 字段里存的都是相对路径,数据库体积可控,也不需要每次加载文章都传输庞大的 base64。后端对应地把上传大小限制调大,双管齐下。
4.4 按钮级权限的指令实现
按钮级权限我用了一个自定义 Vue 指令,名字叫 v-permission。登录成功后,后端把当前用户拥有的权限标识数组返回给前端,存进 vuex,指令通过比对数组决定是否移除元素:
Vue.directive('permission', { inserted(el, binding) { const required = binding.value const hasPermission = store.state.user.buttons.includes(required) if (!hasPermission) { el.parentNode && el.parentNode.removeChild(el) } } })用法很简单:
<el-button v-permission="'knowledge:delete'" type="danger">删除</el-button>权限标识的命名我统一采用“资源:操作”的格式,比如knowledge:delete、knowledge:publish、user:resetPwd。这样维护起来很清楚。这套方案比在每个页面里写 v-if 判断角色要干净,因为它把权限逻辑集中在了一块,新增权限时不需要翻遍所有页面改条件。
5. 常见问题与排查技巧实录
5.1 MySQL 连接中的时区报错
刚启动项目时控制台直接报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,这是 MySQL 8 驱动对时区校验更严格的缘故。解决办法是在 JDBC 连接串上加上参数:
jdbc:mysql://localhost:3306/kms?serverTimezone=Asia/Shanghai&useSSL=false&characterEncoding=utf8另外要注意,MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver,不要继续用老项目的com.mysql.jdbc.Driver,后者只是兼容包,没有新特性。
5.2 前后端联调跨域问题
联调阶段最常见的就是浏览器报 CORS 错误。如果是开发环境,用上面提到的 vue.config.js 代理基本能解决;如果生产环境走 Nginx,就把/api开头的请求反代到后端服务,这样前后端同域,不存在跨域问题。只有在你非要直接用 IP 加端口访问后端时才需要后端支持跨域,后端可以用 CorsFilter 或 @CrossOrigin,但要注意 allowedOrigins 不能和 allowCredentials 同时设置为不匹配的值,否则浏览器一样会拦截。
5.3 PageHelper 分页 total 不准
这个坑我踩过两次。现象是:第一页数据正常,切换到第二页后列表正常但 total 变成 1 或者一个错误值。排查下来原因有两种。第一种是 startPage 之后,紧跟的第一个 SQL 不是预期的分页查询,而是别的查询,PageHelper 的线程变量被其他查询消费了。第二种是分页 SQL 里带了 LEFT JOIN,PageHelper 自动生成的 count 语句没有过滤 JOIN 产生的重复行。我的解决方式是:确保 startPage 后面就是目标查询语句;遇到复杂 JOIN 查询时,不用 PageHelper 的自动 count,而是手动写一个 count 查询语句,单独返回。
5.4 富文本内容过大导致请求失败
富文本编辑器上传大段图文内容时,后端直接报 413 或者请求被重置。排查三步走:第一步看spring.servlet.multipart.max-file-size和max-request-size是否调大;第二步看 Tomcat 的 maxPostSize 是否被限制;第三步看前端请求是否把所有图片都以 base64 提交了。按照 4.3 节的方案,图片先上传再插入 URL,请求体通常能控制在几十 KB 内,问题基本能解决。
5.5 逻辑删除与唯一索引的冲突
我给用户表 username 加了唯一索引,同时使用了逻辑删除。删除一个用户后,再注册一个同名用户时,MySQL 直接报唯一约束冲突。解决办法有两个思路:一是把唯一索引改成联合索引 (username, deleted),让被逻辑删除的记录和现存记录不冲突;二是在删除时把用户名重写成旧用户名_deleted_时间戳。我项目里选了后者,因为联合索引在历史数据较多时,会让“唯一性”的概念变得模糊,而且对查询性能也没帮助。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 启动报时区错误 | MySQL 8 驱动时区校验 | JDBC URL 加 serverTimezone=Asia/Shanghai |
| 浏览器 CORS 报错 | 前后端不同域 | 开发代理 / Nginx 反代 / 后端 CORS 配置 |
| 分页 total 不准 | PageHelper 被其他 SQL 消费或 JOIN 重复 | startPage 后紧跟目标 SQL,复杂场景手写 count |
| 上传图片报 413 | 文件大小超限 | 调大 multipart 配置,图片先上传再插 URL |
| 逻辑删除后唯一索引冲突 | 唯一索引和 deleted 冲突 | 改联合索引或删除时重写用户名 |
结尾
这个项目做下来,我最深的感受是:所谓“系统设计”不是一开始把表设计得多么完美,而是先跑通一个最小闭环,再根据真实使用反馈迭代。第一版我只做了登录和知识条目的增删改查,上线让团队成员用起来之后,两周内根据反馈陆续补上了评论、收藏、检索和统计。另一点经验是,如果你准备拿这个题目做毕业设计或者面试项目,不要只满足于把源码跑起来,强烈建议把分类树组装、动态路由、按钮级权限这三个模块单独重构一遍,把每一步的原理讲清楚。面试官问“为什么用 MyBatis 不用 JPA”“为什么用 JWT 不用 Session”时,你能结合实现细节回答,项目才真正变成你自己的。最后再分享一个小技巧:数据库脚本、接口文档(Swagger 或 Apifox)一定要随需求变化同步更新,等到了后期回归测试的时候,你一定会感谢当时那个时刻保持文档更新的自己。