
最近有不少朋友找我聊全栈练手项目问来问去发现大家对“能跑起来、结构完整、技术不偏门”的源码需求特别大。今天这篇就把我一直在维护的一个老项目拆开揉碎讲清楚——基于 SpringBoot Vue 的知识管理系统持久层用 MyBatis数据库用 MySQL典型的前后端分离结构。这套东西我前前后后改过四五版从最初给同事做内部资料归档到后来开源出来给新手做参考沉淀了不少实战细节正好一次性写出来。这个项目能做什么、适合谁它的核心场景就是解决团队和个人知识碎片化的问题把散在 Word、云笔记里乱七八糟的资料统一收拢到系统里按分类和标签管理支持搜索、评论和收藏。对正在学 Java 全栈的同学来说它是一份非常完整的“毕业设计级”参考代码对想搞明白 SpringBoot 后端接口怎么写、Vue 前端怎么对接的人它是很好的源码教学案例。我尽量不堆概念直接把设计思路、配置要点、踩坑记录和核心代码都摆出来你照着敲一遍收获会比看十篇教程都大。1. 项目到底在解决什么问题在做系统设计之前先把需求搞清楚。知识管理系统听起来大而全但落到实际场景核心就三件事存得进来、找得出来、看得明白。1.1 从需求痛点反推功能模块我最初做这个项目的时候团队里用的是网盘共享文件夹加微信群资料乱七八糟今天传一版、明天传一版到了月底根本不知道哪个是最新的。后来我梳理了真实的使用场景得出一个结论知识管理系统最重要的不是花哨的可视化而是结构化存储 高效检索 互动沉淀。基于这个思路功能模块被我拆成了这几大块用户模块注册、登录、个人信息维护这是系统的入口权限控制的地基。知识管理模块知识的发布、编辑、删除、查看支持 Markdown 和富文本两种编辑形式对应不同使用习惯。分类与标签模块知识先按一级分类、二级分类归好再打上多个标签形成“分类管骨架、标签管细节”的二维结构。搜索模块基于标题和内容的模糊查询支持按分类筛选、按标签筛选、按时间排序。互动模块评论、点赞、收藏。这三点看起来简单但能极大提高知识库的活跃度让好内容浮现出来。后台管理模块用户管理禁用/启用、知识审核可选、分类维护、基础数据统计。这个功能清单不是拍脑袋定的。你可以对比一下市面上的主流知识库产品比如语雀、Notion、Confluence核心功能翻来覆去也就是这些。对个人项目来说把上面每一项做到 80 分已经是个完整度很高的系统了。1.2 数据库表怎么设计才不返工功能定了下一步就是数据库表结构。这步千万别图省事表结构设计不好后面写代码全是坑。我给出这套项目最核心的几张表结构你可以直接用-- 用户表 CREATE TABLE user ( id int(11) NOT NULL AUTO_INCREMENT, username varchar(50) NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码(BCrypt加密), nickname varchar(50) DEFAULT NULL COMMENT 昵称, avatar varchar(255) DEFAULT NULL COMMENT 头像地址, email varchar(100) DEFAULT NULL COMMENT 邮箱, role tinyint(4) DEFAULT 1 COMMENT 角色:0管理员,1普通用户, status tinyint(4) DEFAULT 1 COMMENT 状态:1正常,0禁用, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; -- 分类表 CREATE TABLE category ( id int(11) NOT NULL AUTO_INCREMENT, parent_id int(11) DEFAULT 0 COMMENT 父分类ID,0为根, name varchar(50) NOT NULL COMMENT 分类名称, sort int(11) DEFAULT 0 COMMENT 排序权重, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT知识分类表; -- 知识文档表 CREATE TABLE knowledge ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL COMMENT 发布者ID, category_id int(11) NOT NULL COMMENT 所属分类ID, title varchar(200) NOT NULL COMMENT 标题, summary varchar(500) DEFAULT NULL COMMENT 摘要, content longtext COMMENT 正文内容, cover varchar(255) DEFAULT NULL COMMENT 封面图, view_count int(11) DEFAULT 0 COMMENT 浏览量, like_count int(11) DEFAULT 0 COMMENT 点赞数, status tinyint(4) DEFAULT 1 COMMENT 状态:1发布,0草稿,2下线, create_time datetime DEFAULT CURRENT_TIMESTAMP, update_time datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_user (user_id), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT知识文档表; -- 标签表 CREATE TABLE tag ( id int(11) NOT NULL AUTO_INCREMENT, name varchar(50) NOT NULL COMMENT 标签名称, PRIMARY KEY (id), UNIQUE KEY uk_name (name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT标签表; -- 文档-标签关联表 CREATE TABLE knowledge_tag ( id int(11) NOT NULL AUTO_INCREMENT, knowledge_id int(11) NOT NULL, tag_id int(11) NOT NULL, PRIMARY KEY (id), KEY idx_kid (knowledge_id), KEY idx_tid (tag_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT文档标签关联表; -- 评论表 CREATE TABLE comment ( id int(11) NOT NULL AUTO_INCREMENT, knowledge_id int(11) NOT NULL, user_id int(11) NOT NULL, content varchar(1000) NOT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_knowledge (knowledge_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT评论表; -- 收藏表 CREATE TABLE favorite ( id int(11) NOT NULL AUTO_INCREMENT, user_id int(11) NOT NULL, knowledge_id int(11) NOT NULL, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_knowledge (user_id, knowledge_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT收藏表;这里有几个设计细节值得你细品。第一user 表的 role 字段我用了 tinyint 而不是字符串虽然可读性差点但查询效率和存储空间都更好而且在 Java 里用枚举映射比判断字符串更优雅。第二所有表默认加了 create_time 和 update_time 两个时间字段千万别省后面做排序、做审计、排查数据问题全靠它。第三关联表要建联合唯一索引比如收藏表里的 uk_user_knowledge防止用户重复收藏产生脏数据。1.3 项目功能边界怎么划新手做项目最容易犯的毛病就是什么都想加要在线预览 PDF、要协同编辑、要移动端适配、要消息推送……我劝你清醒一点功能越多代码 review 的工作量越大最后烂尾的概率越高。这套项目我刻意把功能范围收紧在“核心闭环”用户进来能发知识、能搜知识、能看详情、能互动管理员能管用户和分类。这个闭环对于学习全栈开发、理解前后端交互流程已经完全够用。如果后续真要扩展我建议往这几个方向走接入全文搜索引擎 Elasticsearch 或者用 MySQL 全文索引优化搜索、增加 OSS 做文件附件存储、引入消息队列做异步通知这些都是清晰且不破坏现有架构的扩展点。提示功能边界划清楚不仅是为了控制工作量更是为了让你在面试介绍项目时有话可说。能说清楚“我为什么不做某个功能”比机械罗列“我做了十个功能”更显水平。2. 技术选型为什么是这四件套SpringBoot、Vue、MyBatis、MySQL这组合被叫“经典四件套”不是没道理。每一样单独拎出来都不是最炫的但组合在一起恰恰是绝大多数中小型团队的真实技术栈。2.1 SpringBoot 解决了什么痛点在 Spring Boot 出现之前搭一个 Spring 项目需要写一堆 XML 配置配数据源、配事务管理器、配视图解析器、配包扫描……光是把项目从零跑起来就得半天。SpringBoot 的核心价值就一句话约定优于配置。内嵌 Tomcat一键启动Starter 机制自动装配让你把注意力从“怎么配置”转移到“怎么写业务”。本项目我建议使用 SpringBoot 2.7.x 版本最新版是 3.x但 3.x 要求 JDK17很多还在用 JDK8 的读者会卡住。2.7 在 2025 年依然是企业存量项目的主流。选它是对初学者最友好的妥协。2.2 MyBatis 还是 JPA我说点实话这个选择题几乎每个做 Java 后端的人都纠结过。JPAHibernate的优势是自动建表、CRUD 不用写 SQL开发快但问题在于 SQL 不可见一旦遇到多表联查、复杂动态条件要么写 JPQL 要么写原生 SQL反而更痛苦。MyBatis 恰好相反它把 SQL 的控制权完全交给你XML 里写什么就执行什么性能瓶颈、SQL 优化都在你掌控之中。对知识管理系统这种涉及多表查询、动态条件拼接分类筛选 标签筛选 关键词搜索的业务场景MyBatis 的动态 SQL 能力是独一档的存在。看一段实际代码你就明白select idselectKnowledgeList resultTypecom.example.vo.KnowledgeVO SELECT k.*, c.name AS category_name, u.nickname AS author_name FROM knowledge k LEFT JOIN category c ON k.category_id c.id LEFT JOIN user u ON k.user_id u.id where if testkeyword ! null and keyword ! AND (k.title LIKE CONCAT(%, #{keyword}, %) OR k.summary LIKE CONCAT(%, #{keyword}, %)) /if if testcategoryId ! null AND k.category_id #{categoryId} /if if testtagId ! null AND k.id IN (SELECT kt.knowledge_id FROM knowledge_tag kt WHERE kt.tag_id #{tagId}) /if if teststatus ! null AND k.status #{status} /if /where ORDER BY k.create_time DESC /select这种一个查询方法应对多种筛选组合的写法用 JPA 你会写出一堆 Specification 代码用 MyBatis 就是几个if标签的事够直观。2.3 Vue 前端组件化思维是关键Vue 能火起来核心是组件化开发把前端代码从“面条式”的 DOM 操作里解放出来。本项目前端我用 Vue 3 Vite Element Plus 组合这套方案在 2025 年已经是主流新项目的标配了。Vue 3 的组合式 APIComposition API相比 Vue 2 的选项式 API最大的提升是逻辑复用。比如你要在多个页面里都用“文章列表”这个组件在 Vue 2 里你可能需要 mixin这玩意命名冲突到你怀疑人生在 Vue 3 里写一个useKnowledgeList组合式函数逻辑天然内聚不同页面的差异通过参数传入清爽得很。2.4 MySQL 选型与存储细节MySQL 的选择没什么悬念5.7 还是 8.0直接上 8.0理由有三个8.0 的查询优化器更强、支持窗口函数、默认字符集已经建议直接utf8mb4。注意字符集一定用 utf8mb4 而不是 utf8因为 utf8 在 MySQL 里最多存 3 字节emoji 和部分生僻字存不进去做知识管理这种文字密集型系统存不了 emoji 会让用户瞬间不想用。存储引擎用 InnoDB外键看情况加——说实话我建表时没加物理外键只用逻辑外键应用层保证引用完整性。原因很简单物理外键在数据量大、分库分表或者做批量导入时是纯累赘而且 InnoDB 的锁机制会在外键检查上放大锁的范围。业界主流做法都是逻辑外键 索引。这点你面试时能说清楚绝对是加分项。技术选型对比维度本项目选择替代方案为什么这么选后端框架SpringBoot 2.7Spring Cloud太重、Servlet 手写不现实轻量、社区资料最多持久层MyBatisMyBatis-Plus、JPA动态 SQL 灵活可控性强MyBatis-Plus 适合纯 CRUD 场景复杂查询反而要写更多特殊处理前端框架Vue 3 ViteVue 2 Webpack组合式 API 更好组织逻辑Vite 冷启动秒开数据库MySQL 8.0PostgreSQL、SQLite生态成熟教程多托管方便注意如果你选了 MyBatis-Plus不要以为“省事”。它在单表 CRUD 上确实香但一旦你要写多表关联查询还是得老老实实回到 XML 写 SQL。本项目用原生 MyBatis就是让你把 SQL 基本功补扎实。2.5 项目目录结构看源码先看这里拿到一份源码别急着跑起来先看目录结构。我的项目结构是这样的knowledge-system/ ├── backend/ # 后端 SpringBoot 工程 │ ├── src/main/java/com/example/kms/ │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务层接口实现 │ │ ├── mapper/ # MyBatis Mapper 接口 │ │ ├── entity/ # 实体类 │ │ ├── vo/ # 前端展示对象 │ │ ├── dto/ # 请求参数对象 │ │ ├── config/ # 配置类跨域、拦截器、WebMvc │ │ ├── common/ # 统一返回结果、异常处理、工具类 │ │ └── KmsApplication.java # 启动类 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML 映射文件 │ └── application.yml # 配置文件 └── frontend/ # 前端 Vue 工程 ├── src/ │ ├── api/ # 接口封装 │ ├── assets/ # 静态资源 │ ├── components/ # 公共组件 │ ├── router/ # 路由配置 │ ├── store/ # 全局状态 │ ├── views/ # 页面组件 │ ├── utils/ # 工具函数 │ └── App.vue # 根组件 └── vite.config.js # 开发代理配置实体层entity和展示层vo分开是我特意坚持的。很多人图省事直接用实体类返回给前端这会在两个地方给你埋雷一是实体类字段可能暴露敏感信息比如密码哈希二是页面上要展示“作者昵称、分类名称”而实体类里只有 user_id 和 category_id你又不想写一堆临时 Map。所以我额外建了 VO 类做查询结果映射。看这段Data public class KnowledgeVO { private Long id; private String title; private String summary; private String content; private Integer viewCount; private Integer likeCount; private String createTime; // 冗余展示字段联表查询时填充 private String categoryName; private String authorName; }实践下来这个设计让前后端联调少吵了很多架接口文档也好写得多。3. 后端从零到一核心环节实操了解了设计和架构接下来手把手把后端跑起来。这里我只讲最关键的环节你跟着敲一遍基本就会了。3.1 环境准备与工程创建开发环境的版本组合我用这一套你直接照搬JDK 1.8如果你选 SpringBoot 2.7Maven 3.6IntelliJ IDEAMySQL 8.0Navicat 或者命令行客户端创建 SpringBoot 项目我推荐直接去 Spring Initializr 生成基础骨架比在 IDEA 里创建更干净。注意依赖勾选这几个Spring Web、MyBatis Framework、MySQL Driver、Lombok。如果你在网页上选了 SpringBoot 2.7.xGroup 填com.exampleArtifact 填kms-backend生成完导入 IDEA 就行。3.2 配置文件三分钟把项目跑起来生成完工程第一步改application.yml。这里把关键配置写全你直接就可以用server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/kms?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的数据库密码 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.kms.entity configuration: map-underscore-to-camel-case: true # 下划线转驼峰重要 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印 SQL调试用 logging: level: com.example.kms: debug这里每个配置都值得留意。数据源 URL 里的 serverTimezoneAsia/Shanghai 必须有不然 MySQL 8.0 默认时区偏差会让你查出来的时间差 8 小时。allowPublicKeyRetrievaltrue 是给 MySQL 8.0 用的否则连接时可能报 Public Key Retrieval is not allowed 错误。MyBatis 里的map-underscore-to-camel-case必须开着这样数据库的create_time才能自动映射到 Java 实体里的createTime不用你手写一堆ResultMap别名。3.3 MyBatis XML 映射动态 SQL 是灵魂配置好环境之后核心就在 Mapper 层的 XML 文件。很多人对if、where、foreach这几个标签用得不够熟其实它们才是 MyBatis 的高频用法。前面已经展示了多条件查询这里我再补一段批量插入标签关联的写法insert idinsertBatchKnowledgeTag INSERT INTO knowledge_tag (knowledge_id, tag_id) VALUES foreach collectiontagIds itemtagId separator, (#{knowledgeId}, #{tagId}) /foreach /insert这段代码解决的问题是用户在发布知识时勾选了多个标签后端一次性把关联关系全部插入。用foreach拼 SQL 批量插入比在 Java 里循环调用单条插入性能高一个数量级。你自己写代码时凡是涉及“一对多关联”的写入操作第一个就该想到批量 insert。再强调一个 XML 细节千万注意#{...}和${...}的区别。#{}是预编译占位符会生成?参数占位能防 SQL 注入${}是字符串拼接直接替换进 SQL一旦参数是用户输入就极其危险。排序字段、动态表名这些场景确实需要${}但你必须自己在代码层做白名单校验比如只允许传入create_time、view_count这种写死的枚举值而不是直接把前端传的字符串拼进去。3.4 JWT 登录鉴权权限控制的完整套路知识管理系统有用户体系登录鉴权逃不掉。我不用 Spring Security对新手太重而是用 JWT 拦截器实现一套轻量级鉴权。思路不复杂用户登录成功后后端生成一个 token里面用签名加密存放用户 id、用户名、过期时间返回给前端。前端每次请求在 Header 带上Authorization: Bearer token后端写一个拦截器统一校验。核心代码分三块。首先是 JWT 工具类Component public class JwtUtil { Value(${jwt.secret}) private String secret; // 签名密钥放到配置文件 Value(${jwt.expire}) private Long expire; // 过期时间单位秒 public String createToken(Long userId, String username) { Date now new Date(); Date expireDate new Date(now.getTime() expire * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(username, username) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); } }然后是拦截器Component public class JwtInterceptor implements HandlerInterceptor { Autowired private JwtUtil jwtUtil; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行 OPTIONS 预检请求 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { token token.substring(7); try { Claims claims jwtUtil.parseToken(token); request.setAttribute(userId, Long.valueOf(claims.getSubject())); return true; } catch (Exception e) { // token 解析失败继续往下走统一异常处理 } } throw new BusinessException(401, 未登录或登录已过期); } }最后是注册拦截器并放行登录相关接口Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private JwtInterceptor jwtInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/auth/login, /api/auth/register); } }这里要提醒你拦截器抛异常时需要有一个全局异常处理器配合否则异常会变成一长串堆栈直接甩给前端。写一个RestControllerAdvice针对BusinessException返回统一结构{code: 401, message: 未登录}前端才能优雅地捕获错误状态并跳转登录页。用 JWT 的好处是服务端无状态对前后端分离的项目来说不需要在服务器上存 Session水平扩展也方便。缺点是你得自行处理 token 刷新的问题但对这个项目体量过期时间给 7 天前端拦截 401 让用户重新登录已经够用了。3.5 分页与搜索列表接口的正确写法列表页是每个系统都有的但九成的初学者分页都写错了。所谓分页接口一定是传pageNum第几页和pageSize每页多少条后端返回总条数 当前页数据列表。手动分页的 SQL 也不难select idselectPage resultTypecom.example.kms.vo.KnowledgeVO SELECT k.*, c.name AS category_name, u.nickname AS author_name FROM knowledge k LEFT JOIN category c ON k.category_id c.id LEFT JOIN user u ON k.user_id u.id where if testkeyword ! null and keyword ! AND k.title LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY k.create_time DESC LIMIT #{offset}, #{pageSize} /selectselect idselectCount resultTypelong SELECT COUNT(*) FROM knowledge k where if testkeyword ! null and keyword ! AND k.title LIKE CONCAT(%, #{keyword}, %) /if /where /select两条 SQL一条查数据一条查总数配合PageResultT通用类封装Data public class PageResultT { private Long total; private ListT records; private Integer pageNum; private Integer pageSize; public static T PageResultT of(Long total, ListT records, Integer pageNum, Integer pageSize) { PageResultT result new PageResult(); result.setTotal(total); result.setRecords(records); result.setPageNum(pageNum); result.setPageSize(pageSize); return result; } }有人会用 PageHelper 插件自动分页但我劝你把手动 LIMIT 的写法搞懂。PageHelper 的原理是拦截器在运行时动态改写 SQL学习阶段你根本不知道它改了什么出了问题只能干瞪眼。手动写一遍你对 SQL 执行过程的掌控力会有质的提升。3.6 写业务逻辑时容易被忽略的规范这部分不是代码问题是工程习惯问题。我在 Service 层写的命名很直白createKnowledge、updateKnowledge、deleteKnowledge、pageQueryKnowledge、getKnowledgeDetail。别用save、add这种通用名一个项目大了全是 save 你就要哭了。另外统一返回结构。后端接口不管成功还是失败返回的 JSON 都长一个样{ code: 200, message: 操作成功, data: { } }前端 Axios 拦截器统一处理这个结构code 200 就继续走业务逻辑401 就清空登录态跳登录页其他 code 就弹 message 提示。这套约定一旦定下来前后端联调效率能翻倍。4. 前端核心实现从搭建到跑通后端把接口给出来前端要做的事就是把这些接口串成可操作的界面。前端部分的重点是框架选型、请求封装、路由控制、页面组件四件事。4.1 用 Vite 搭建 Vue3 工程创建前端工程我现在的习惯是直接用 Vite。相比 WebpackVite 的开发服务器冷启动快到难以置信而且基于 ESM 的依赖预构建让模块加载速度提升明显。# 创建 Vue3 项目 npm create vitelatest frontend -- --template vue cd frontend npm install # 安装核心依赖 npm install vue-router4 pinia axios element-plus element-plus/icons-vue # 启动开发服务器 npm run dev装 Element Plus 是为了省事它自带的表格、表单、消息提示组件能覆盖知识管理系统 90% 的界面需求。如果你的目标是深入理解前端原理可以自己在组件层面造轮子但做实战项目成熟组件库就是生产力。4.2 封装 Axios拦截器是重中之重前端对接后端的代码百分之八十都浓缩在 Axios 封装里。我的utils/request.js长这样import axios from axios import { ElMessage } from element-plus import router from ../router const request axios.create({ baseURL: /api, // 开发环境走 Vite 代理 timeout: 15000 }) // 请求拦截器自动携带 token request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应拦截器统一处理错误 request.interceptors.response.use( response { const res response.data if (res.code ! 200) { ElMessage.error(res.message || 请求失败) // 401 统一跳转登录 if (res.code 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(网络异常请稍后再试) return Promise.reject(error) } ) export default request两个细节值得记笔记。baseURL 填/api而不是完整地址是为了开发环境用 Vite 代理解决跨域。你在vite.config.js里这样配置export default defineConfig({ server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样浏览器里所有/api开头的请求都会被转发到后端 8080 端口完美规避跨域问题。生产环境再用 Nginx 做同样的反向代理前后端可以部署在同一域下直接免去跨域配置。第二响应拦截器里拿到的res.data是后端返回的整个 JSON 体而我把它剥了一层直接返回res.data.data。这样页面代码里调接口拿到的直接是业务数据不用每个地方都写.data.data这种反人类代码。4.3 路由守卫与布局框架前端路由用的是 Vue Router 4。配置路由文件时我把需要登录的页面都包在一个Layout组件下通过meta.requiresAuth控制访问权限const routes [ { path: /login, component: () import(../views/Login.vue) }, { path: /, component: () import(../components/Layout.vue), children: [ { path: , redirect: /home }, { path: home, component: () import(../views/Home.vue), meta: { requiresAuth: true } }, { path: knowledge/detail/:id, component: () import(../views/KnowledgeDetail.vue), meta: { requiresAuth: true } }, { path: knowledge/edit, component: () import(../views/KnowledgeEdit.vue), meta: { requiresAuth: true } }, { path: user/center, component: () import(../views/UserCenter.vue), meta: { requiresAuth: true } } ] } ]然后在路由守卫里加一道检查router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })这就是前端路由守卫的基本盘。注意前端守卫只是用户体验层面的拦截真正的安全控制永远在后端拦截器。前端路由跳转可以被绕过但后端接口校验绕不过去这个安全观要树立起来。Layout 组件里放侧边栏和顶栏侧边栏放知识分类树顶栏放搜索框和用户头像下拉菜单。Element Plus 的el-menu配合el-tree组件基本能满足需求。4.4 核心页面知识列表与编辑器知识列表页是系统里最核心的页面。我用的实现思路是左侧分类树点击筛选顶部关键词搜索中间是el-card卡片列表展示标题、摘要、作者、浏览量、点赞量分页用el-pagination。template div classknowledge-list el-row :gutter16 el-col :span4 el-tree :datacategoryTree node-keyid :props{ label: name, children: children } node-clickhandleCategoryClick / /el-col el-col :span20 div classlist-header el-input v-modelqueryForm.keyword placeholder搜索知识... clearable keyup.enterhandleSearch / el-button typeprimary clickhandleSearch搜索/el-button el-button typesuccess clickrouter.push(/knowledge/edit)发布知识/el-button /div el-card v-foritem in list :keyitem.id classknowledge-card h3 clickgoDetail(item.id){{ item.title }}/h3 p{{ item.summary }}/p div classcard-footer span{{ item.authorName }}/span span{{ item.viewCount }} 浏览/span span{{ item.likeCount }} 点赞/span span{{ item.createTime }}/span /div /el-card el-pagination v-model:current-pagequeryForm.pageNum v-model:page-sizequeryForm.pageSize :totaltotal layouttotal, prev, pager, next current-changeloadData / /el-col /el-row /div /template发布和编辑页面我推荐直接用mavon-editor这个 Markdown 编辑器组件它支持实时预览对程序员用户非常友好。安装就一行命令npm install mavon-editor在组件里注册使用后拿到的就是 Markdown 原文存到后端数据库的longtext字段里详情页再用markdown-it渲染成 HTML 展示。这一步非常顺滑也是大多数知识管理系统的标准姿势。5. 开发中踩过的坑与排查经验这部分是我觉得整篇文章最值钱的部分。以下问题全是我实打实遇到过、排查过、最后解决掉的。我把它们整理成速查表你以后遇到同款问题直接对号入座。5.1 时间字段查出来总是差 8 小时现象数据库里存的时间是对的但从后端接口返回给前端就多了 8 个小时。原因是一个链路里的三层时区问题MySQL 连接参数的 serverTimezone、Jackson 序列化的 time-zone、操作系统默认时区。解决方法是三层统一设置为Asia/Shanghai即URL 里加serverTimezoneAsia/Shanghaiapplication.yml里 Jackson 的 time-zone 也写Asia/Shanghai前端如果显示还是不对检查浏览器所在时区一般到这一步就正常了5.2 MyBatis 查询结果全是 null现象数据库表字段是create_timeJava 实体字段是createTime查询出来全是 null。这是没开驼峰映射。application.yml里加上mybatis: configuration: map-underscore-to-camel-case: true如果加了还不行就是你手写了ResultMap而 ResultMap 的映射规则优先级更高你要在 ResultMap 里显式把列名和属性名对应起来。5.3 前端请求后端一直报跨域现象前端跑在 3000 端口后端在 8080 端口Ajax 请求被浏览器拦截。新手第一反应是去后端加CrossOrigin注解但这东西有时会失效。我建议直接用 Vite 代理解决前面已经写了配置。生产环境用 Nginx 反代把/api转发到后端服务这样前后端同域压根不存在跨域问题。Nginx 配置片段也给你location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }5.4 数据库连接报 Public Key Retrieval is not allowed现象用 MySQL 8.0 连接时偶发报这个错。原因是 MySQL 8.0 默认的caching_sha2_password认证插件要求客户端先获取服务端公钥。在 JDBC URL 里加上allowPublicKeyRetrievaltrueuseSSLfalse即可解决。如果你用 Navicat 等客户端也遇到类似问题通常是连接配置里没勾选允许公钥检索在高级选项里勾上就行。5.5 文章详情页首次加载慢内容渲染不出来现象知识详情页接口要 3 秒多才返回或者直接超时。排查三步走先看后端是否打印了慢 SQL再看是不是在循环里调用查询最后看有没有 N1 问题。我踩过的坑是在组装详情页的“上一条/下一条”时在 for 循环里逐条查数据库后来改成一次查出 id 集合再用WHERE id IN (...)批量查询响应时间从 2 秒降到 300 毫秒。5.6 部署到服务器后静态资源 404现象本地跑得好好的打包部署到服务器后前端页面能打开但图片和 JS 文件 404。这基本是打包路径配置问题。Vite 默认的base是/如果你的站点部署在子路径比如http://ip:8081/kms/资源路径就全不对了。在vite.config.js里加一行export default defineConfig({ base: ./, // 使用相对路径 // ...其他配置 })打包后用相对路径引用资源放到任何目录都能跑。常见问题速查表问题现象根本原因解决方案时间差 8 小时时区未统一数据库 URL 和 Jackson 都显式指定 Asia/Shanghai查询返回全 null未开启驼峰映射map-underscore-to-camel-case: true跨域请求被拦截端口不同Vite 代理或 Nginx 反代MySQL 8.0 连接报错认证插件公钥问题allowPublicKeyRetrievaltrue详情页加载超时N1 查询用 IN 子查询批量查询替代循环单查打包后资源 404绝对路径问题Vite base 设置为 ./MyBatis 批量插入太慢逐条 insert用 foreach 拼接批量 insert页面刷新后路由 404后端未做 history 回退如果是 SpringBoot 单体部署加转发规则到 index.html说实话这套项目我从无到有做了差不多两个周末后来陆陆续续又花了很多时间打磨主要时间都耗在跟联调、权限、分页这些“小事”死磕上。但正是在这些“小事”里我才真正理解了前后端分离开发的分工和协作方式。拿到任何一份全栈源码你先跑起来再抓一条完整的数据流从头看到尾——从浏览器输入地址、到前端路由、到 Axios 拦截器、到后端 Controller、Service、Mapper、SQL、返回 JSON、前端渲染把这条链路彻底走通一遍你对整个技术的掌控就会上一个台阶之后再扩展什么功能心里都跟明镜似的。