校园生活信息平台开发实战:SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0 技术拆解

发布时间:2026/10/3 7:15:28
校园生活信息平台开发实战:SpringBoot2 + Vue3 + MyBatis-Plus + MySQL8.0 技术拆解
1. 这个项目到底是做什么的技术选型背后的底层逻辑先说结论这不是那种花架子项目而是一个标准的前后端分离校园生活信息平台。它解决的问题很具体——把校园里的信息发布、失物招领、二手交易、活动通知、校园新鲜事这些零散场景集中到一个系统里学生和老师在一个地方就能完成信息浏览、发布、评论、收藏、联系发布者等操作。对于计算机专业的学生来说这种“全能型”项目的价值在于它把 Java Web 开发的主线技术栈全串起来了从后端接口到前端页面从数据库建模到权限认证你写完这一个系统Spring Boot 开发的核心链路就走了一遍。为什么选SpringBoot2 Vue3 MyBatis-Plus MySQL8.0这套组合我逐个拆解给你看。**SpringBoot2 依然是国内企业级开发的主力版本。**虽然 SpringBoot3 已经发布但很多公司的存量项目、云服务商的默认镜像、面试官的题库里SpringBoot2 仍是绝对主流。2.x 版本的自动配置机制成熟稳定社区资料丰富遇到问题搜一下基本都有答案。对于做毕设或项目经验积累来说用 2.x 更能对齐企业的真实技术栈也避开了 SpringBoot3 中 Jakarta EE 命名空间变更带来的一堆坑。**Vue3 是前端框架的必然选择。**Vue2 已经停止维护组合式 API 和script setup语法让逻辑复用和代码组织方式发生了质变。Vue3 配合 Vite 开发服务器热更新速度比 Webpack 时代快了一个量级写页面时的体感完全不同。而且 Vue3 Element Plus 的组件库生态已经非常成熟后台管理类页面的开发效率极高。**MyBatis-Plus 的价值在于把单表 CRUD 的重复劳动降到了极低。**单表操作基本不需要写 SQLBaseMapper 内置了 insert、deleteById、selectPage、selectList 等现成方法。配合条件构造器 QueryWrapper/LambdaQueryWrapper复杂的动态条件查询也能用链式调用表达。对于校园信息平台这种以单表查询为主、关联查询为辅的业务场景MyBatis-Plus 比原生 MyBatis 能节省大概 40% 的持久层代码量。**MySQL8.0 相比 5.7 的核心优势在于 utf8mb4 字符集默认开启。**5.7 时代的 utf8 字符集是不完整的存 emoji 表情和生僻字会出现乱码问题而 8.0 直接以 utf8mb4 为默认字符集还引入了窗口函数、公用表表达式等实用特性。另外 8.0 的默认排序规则 utf8mb4_0900_ai_ci 对中文排序更友好caching_sha2_password 认证插件虽然给老客户端带来点兼容性问题但换来的是更高的安全性。这套技术栈的真实定位是用最主流的方案最低的学习成本覆盖一个完整业务系统开发中的绝大多数真实场景。你不需要为了炫技引入微服务、消息队列、Redis 缓存集群这类重量级组件先把单体应用的精髓吃透比什么都强。2. 数据库建模校园生活平台的数据骨架是怎么设计的2.1 核心表结构设计的思路拆解数据库设计决定了业务的上限。我在设计这个系统的表结构时遵循了“先梳理业务实体再设计表关系最后确定字段细节”的流程。校园生活信息平台的核心实体包括用户、信息分类、信息内容、评论、收藏、失物招领、二手物品、点赞记录。用户表是最基础的它需要承载登录认证和基本信息展示两个职责。关键字段包括 id、username、password、nickname、avatar、gender、role、college学院、phone、email、status、create_time。这里的 role 字段我建议用字符串类型存储比如 student、teacher、admin比用数字枚举更直观也避免前后端对枚举值语义不一致的问题。信息表是整个系统的核心。一个设计容易踩坑的地方是要不要把失物招领、二手交易、校园通知这些类型做成独立表我的方案是用一个 info 主表 type 字段区分类型再用扩展字段补充各类型的差异信息。这样做的理由很实际这些类型的公共属性——标题、内容、图片、发布人、发布时间、浏览量——占 90% 以上只有二手交易需要价格失物招领需要地点和时间。如果每个类型单独建表查询“最新校园动态”时需要联查多张表再合并排序复杂度和维护成本都会显著上升。所以我用 info 表存公共字段用 price、pickup_location、lost_time 这些可空字段做差异化存储单表查询用 where type 即可完成类型过滤效率很高。评论表的设计核心在于支持楼中楼回复。我在 comment 表里设计了 parent_id 字段顶级评论的 parent_id 为 0回复某条评论时填写对应的评论 id。这种设计比搞一个 reply_user_id 字段去匹配用户再手动组树要干净得多前端拿到平铺列表后按 parent_id 递归构建树形结构几十行代码就能搞定。2.2 MySQL8.0 特性在表结构中的实际应用使用 MySQL8.0 后有些 5.7 时代的旧习惯可以改一改了。字符集与排序规则建库时统一使用DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_unicode_ci这个排序规则对中文友好也不区分大小写适合做搜索和排序。时间字段的类型选择对于 create_time、update_time 这类自动填充字段建议使用DATETIME。8.0 支持DEFAULT CURRENT_TIMESTAMP和ON UPDATE CURRENT_TIMESTAMP可以让数据库层自动维护时间戳代码里就不必手动塞时间了。很多老项目习惯用TIMESTAMP 毫秒时间戳数字存储但这会牺牲可读性排查问题时肉眼根本看不出哪条记录是最近的。JSON 字段的妙用MySQL8.0 的 JSON 类型是真正可查询的不只是字符串的“花架子”。比如二手物品需要存储成色信息可以用 JSON 字段保存{condition: 95新, original_price: 2999}查询时用JSON_EXTRACT函数直接取属性。不过要注意JSON 字段走不上常规索引如果查询频率很高还是要单独建列。我的建议是JSON 只适合存储低频访问的扩展属性高频查询字段必须独立成列。索引设计信息表的 query 条件通常是 type status create_time 的组合我建了一个联合索引(type, status, create_time)让列表页的分页查询走索引覆盖。这里有个容易忽略的点联合索引遵循最左前缀原则查询条件里如果缺少最左边的 type索引就不会生效。所以写 Mapper 时要注意查询条件的顺序尽量和索引字段顺序保持一致。数据库建表 SQL 示例简化版核心表CREATE TABLE sys_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, username VARCHAR(50) NOT NULL COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT 加密密码, nickname VARCHAR(50) DEFAULT NULL COMMENT 昵称, avatar VARCHAR(255) DEFAULT NULL COMMENT 头像URL, gender TINYINT DEFAULT 0 COMMENT 性别 0未知 1男 2女, role VARCHAR(20) DEFAULT student COMMENT 角色 student/teacher/admin, college VARCHAR(100) DEFAULT NULL COMMENT 所属学院, phone VARCHAR(20) DEFAULT NULL COMMENT 手机号, status TINYINT 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 info ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 发布人ID, category_id BIGINT NOT NULL COMMENT 分类ID, type TINYINT NOT NULL COMMENT 类型 1资讯 2失物招领 3二手交易 4活动, title VARCHAR(100) NOT NULL, content TEXT COMMENT 正文内容, images VARCHAR(1000) DEFAULT NULL COMMENT 图片URL逗号分隔, price DECIMAL(10,2) DEFAULT NULL COMMENT 二手价格, pickup_location VARCHAR(200) DEFAULT NULL COMMENT 失物地点, contact VARCHAR(100) DEFAULT NULL COMMENT 联系方式, view_count INT DEFAULT 0 COMMENT 浏览量, status TINYINT DEFAULT 1 COMMENT 状态 0草稿 1发布 2下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_type_category_time (type, category_id, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT信息表;3. 后端架构里的关键落点SpringBoot2 分层与 MyBatis-Plus 实战3.1 后端分包结构与统一响应体设计拿到需求后第一步不是写代码而是定包结构。我用的分层结构是经典的controller - service - mappercom.campus.info ├── controller # 接口层只做参数接收和响应 ├── service # 业务逻辑层 │ └── impl # 接口实现 ├── mapper # MyBatis-Plus 数据访问层 ├── entity # 数据库实体 ├── dto # 前端交互的数据传输对象 ├── vo # 视图对象部分场景复用dto ├── config # 配置类MybatisPlus、WebMvc、跨域 ├── common # 通用类统一响应R、异常、常量 └── utils # 工具类JWT、上传等controller 层我曾经走过一段时间弯路——把所有参数校验、业务判断都堆在 controller 里。后来复盘发现这样做的恶果是业务逻辑没法复用service 层变成摆设代码越写越乱。现在的规范是controller 只做三件事——接收参数、调用 service、包装返回结果。参数校验交给 Spring Validation 注解处理业务规则全部下沉到 service 层。统一响应体是前后端协作的基石。我定义了一个R类结构固定为code msg datacode200 表示成功非 200 表示业务失败或异常public class RT { private Integer code; private String msg; private T data; public static T RT ok() { return build(200, 操作成功, null); } public static T RT ok(T data) { return build(200, 操作成功, data); } public static T RT fail(String msg) { return build(500, msg, null); } public static T RT fail(Integer code, String msg) { return build(code, msg, null); } }配合全局异常处理器把业务异常和系统异常统一转成 R 结构返回前端 axios 拦截器里就直接对code ! 200做统一 toast 提示不用每个接口单独判断。3.2 MyBatis-Plus 的核心配置与三个高频特性引入 MyBatis-Plus 之后大部分单表操作彻底告别 SQL。但有几个配置项必须处理正确否则会掉坑里。驼峰映射MyBatis-Plus 默认开启map-underscore-to-camel-case数据库字段 create_time 能自动映射到实体属性 createTime。这个配置千万别关。如果做批量插入或者复杂映射时发现字段为 null优先检查数据库列名和实体属性名是否对得上再检查这个配置是否被改过。mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0逻辑删除对业务系统来说“删除”用户的数据是高风险操作直接物理删除意味着数据不可恢复也破坏了引用完整性。我在表里统一加了 deleted 字段通过 MyBatis-Plus 的全局逻辑删除配置所有 delete 操作自动变成 update。做了这个配置之后有三点要特别注意第一唯一索引和逻辑删除是互斥的比如用户表用 username 做唯一索引一旦用户被逻辑删除再次注册同用户名时插入会撞唯一索引报错第二查询时 MyBatis-Plus 自动拼接deleted0条件但手写 SQL 时容易漏掉这个过滤条件凡是自定义 SQL 都要留意第三逻辑删除的联合唯一约束难以实现需要靠查询时额外判断处理。逻辑删除适合业务数据但像日志表、点赞记录表这类纯记录型的数据建议直接物理删除不然表数据膨胀得很快查询性能会逐渐劣化逻辑删除的“安全边际”也需要承担规模成本。分页插件MyBatis-Plus 的分页插件配置非常简单但对版本有要求。3.4.x 之后用MybatisPlusInterceptor老版本用PaginationInterceptor两者 API 不兼容Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); interceptor.addInnerInterceptor(pagination); return interceptor; } }分页插件生效后的分页查询很简单page(new Page(current, size), queryWrapper)返回值是 IPage里面有 records、total、pages 等字段前端拿到这些字段渲染分页组件即可。3.3 条件构造器动态查询的正确姿势校园信息平台的列表页几乎都是多条件组合查询按类型、按分类、按关键词、按时间范围、按发布人。用 MyBatis-Plus 的 LambdaQueryWrapper 写动态 SQL 非常顺手关键是所有条件都要用condition参数控制是否拼接public IPageInfoVO getInfoPage(InfoQueryDTO dto) { LambdaQueryWrapperInfo wrapper new LambdaQueryWrapper(); wrapper.eq(dto.getType() ! null, Info::getType, dto.getType()) .eq(dto.getCategoryId() ! null, Info::getCategoryId, dto.getCategoryId()) .like(StringUtils.hasText(dto.getKeyword()), Info::getTitle, dto.getKeyword()) .ge(dto.getStartTime() ! null, Info::getCreateTime, dto.getStartTime()) .le(dto.getEndTime() ! null, Info::getCreateTime, dto.getEndTime()) .eq(Info::getStatus, 1) .orderByDesc(Info::getCreateTime); return infoMapper.selectPage(new Page(dto.getCurrent(), dto.getSize()), wrapper); }这里每个eq/ge/le的第一个参数是布尔值为 false 时该条件自动失效。这样前端传什么参数就拼接什么条件不需要写 if 判断代码干净很多。另外查询返回的实体需要转换成 VO 再返回前端避免把密码、状态码这些内部字段暴露出去。4. 核心业务模块实现要点拆解4.1 登录认证JWT 拦截器 角色权限的全链路设计登录认证是校园平台逃不开的模块。我采用的是 JWT 无状态方案不依赖 Session 存储。登录成功后将用户 id、role 封装进 Token设置 7 天过期时间前端存 localStorage每次请求带在 Authorization 头里。后端用拦截器校验 Token 并解析用户信息public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求 if (OPTIONS.equals(request.getMethod())) { return true; } String token request.getHeader(Authorization); if (StringUtils.hasText(token)) { try { Claims claims JwtUtil.parseToken(token.replace(Bearer , )); request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } catch (Exception e) { // token 失效 } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write(JSON.toJSONString(R.fail(401, 未登录或登录已过期))); return false; } }拦截器注册时要注意放行白名单登录接口、注册接口、静态资源、公开的资讯浏览接口。角色权限我用了一个简单实用的方案——自定义RequireRole注解标注在 controller 方法上方法执行前检查当前用户角色是否匹配RequireRole({admin}) PostMapping(/admin/info/delete) public RString adminDelete(RequestParam Long id) { infoService.removeById(id); return R.ok(); }这个方案比集成 Spring Security 或 Shiro 轻量得多。如果项目后续权限模型变复杂再引入安全框架不迟。毕设和企业实际项目最大的差距就在这里——权限往往不是简单的角色判断而是数据范围的控制。4.2 信息发布与文件上传本地存储的方案与改造成本图片上传是信息发布的核心配套。初期我建议先把图片存到本地磁盘构建一个静态资源映射目录通过拦截器将/upload/**路径映射到磁盘目录。这个方案最大的好处是开发调试零成本不需要配置对象存储的密钥和桶也不消耗外网流量费用。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath System.getProperty(user.dir) /upload/; registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath); } }上传接口的思路接收 MultipartFile校验文件大小和扩展名生成 UUID 文件名保存到磁盘返回访问 URL。扩展名白名单务必做不要只校验 Content-Type因为前端的 Content-Type 是可以伪造的而扩展名是文件系统的客观事实。常见的坑是把jpg图片文件改名为png也能上传成功但浏览器解析时因文件头不匹配导致图片无法显示。上线部署时本地存储的时间久了会占用磁盘较多的空间得靠定期清理。如果后续有对象存储如阿里云 OSS只需要替换上传工具类的实现Controller 和 Service 层的代码结构不用大改这也是“面向接口编程”的回报。4.3 评论、点赞与浏览量三个最容易忽略的并发细节评论和点赞看着简单实际上有不少细节。浏览量的防刷直接对 info 表的 view_count 做UPDATE ... SET view_count view_count 1是原子操作不存在并发丢失更新。但如果不加任何限制刷一次列表页就能给每条信息 1 浏览。我简单处理是用session或redis记录用户最近访问过的信息 id同一个用户同一个信息 24 小时内只计一次浏览防刷逻辑不必过度设计。点赞表的幂等设计点赞表加入(info_id, user_id)联合唯一索引从根本上防止同一用户对同一信息重复点赞。点击赞时先查记录有则删除取消赞无则添加点赞。两个操作合在一起加不加事务都行因为是单行操作直接由索引约束兜底天然不会有重复数据。评论的树形结构组装查询评论列表时一次性取出该信息的所有评论按 parent_id 在内存中组装树而不是递归查库。前者性能好很多后者在数据量大时会产生 N1 查询问题。组装树的方式是典型的空间换时间代码实现大概 20 行逻辑也很容易梳理。4.4 数据导出功能中业务规则的落地管理员后台要导出信息列表 Excel。这个功能的业务规则值得想想到底导出哪些字段用户能看到什么样的文件以信息列表导出为例我导出的列包括标题、发布人、分类、类型、发布时间、浏览量、状态。其中状态字段不能直接导出 0、1、2需要转换成“草稿、已发布、已下架”分类字段不能直接导出 category_id要 join 分类表换成分类名称。很多同学导出时直接 select * 然后逐行写 Excel导出的文件别人拿到的是一堆数字业务价值就大打折扣了。我的处理方案是先查询出信息主表数据再根据 category_id 集合作一次分类查询用 Map 做内存映射避免在循环里单条查询分类信息。导出用 EasyExcel实体上加ExcelProperty注解定义导出列名和顺序代码量不大。5. Vue3 前端工程化实践组合式 API 与前后端联调体验5.1 项目初始化和目录结构规划前端部分我用 Vite 创建 Vue3 项目推荐用npm create vuelatest命令初始化。这个命令会询问是否需要 Router、Pinia、ESLint 等按需勾选即可。我最终的目录组织是这样的src ├── api # 接口请求模块 ├── assets # 静态资源 ├── components # 通用组件 ├── layout # 布局组件 ├── router # 路由配置 ├── stores # Pinia 状态管理 ├── views # 页面组件 ├── utils # 工具库request封装等 └── App.vue关键的工程化决策有几个。第一API 请求必须集中封装不要在每个页面组件里裸写 axios.get。统一封装后可以在 Request 拦截器里自动携带 Token在 Response 拦截器里处理 401 统一跳登录、400 统一弹错误提示。第二路由要区分权限普通用户和管理员看到的菜单不同前端路由守卫配合后端接口鉴权双保险但真正的安全边界在后端。5.2 组合式 API 与script setup的代码组织Vue3 组合式 API 最直观的好处是同一段业务逻辑可以集中放置而不是分散在选项式 API 的 data、methods、watch 里。以信息列表页为例script setup import { ref, onMounted } from vue import { getInfoPage } from /api/info const loading ref(false) const tableData ref([]) const total ref(0) const queryParams ref({ current: 1, size: 10, type: null, keyword: }) async function fetchList() { loading.value true try { const res await getInfoPage(queryParams.value) tableData.value res.data.records total.value res.data.total } finally { loading.value false } } function handleSearch() { queryParams.value.current 1 fetchList() } onMounted(fetchList) /script代码的可读性比选项式 API 高了一大截。后续如果要复用“分页列表”这套逻辑可以直接抽成 Composable 函数类似usePagination在多个页面间复用这才是组合式 API 的真正价值——逻辑复用能力。5.3 Axios 封装与 Vite 代理配置前后端联调时遇到的最闹心问题就是跨域。开发环境我直接用 Vite 的 proxy 配置把/api开头的请求代理到后端 8080 端口// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这个配置解决了开发环境的跨域后端接口路径统一以/api开头方便识别和代理。生产环境部署时不需要这个代理因为前端打包成静态文件后由 Nginx 统一托管Nginx 里直接配置反向代理转发/api到后端服务即可。axios 封装里的 Response 拦截器是必写的service.interceptors.response.use( response { const res response.data if (res.code 200) { return res } if (res.code 401) { router.push(/login) } ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) }, error { ElMessage.error(error.message || 网络异常) return Promise.reject(error) } )统一的错误处理能极大减少页面里的 try-catch 重复代码。业务方法里只需要关心成功分支出错交给拦截器统一处理体验要好很多。6. 环境搭建、部署与排坑实录MySQL8.0 和 SpringBoot 的落地细节6.1 本机安装 MySQL8.0Windows 下的完整流程与易错点Windows 下安装 MySQL8.0我强烈建议用 ZIP 压缩包方式不用 MSI 安装包。理由ZIP 解压即用卸载干净环境变量自己控制。MSI 安装包虽然点几下就装完但出问题之后很难彻底清除而且安装过程会配置 Windows 服务新手操作不当容易残留。ZIP 安装的关键步骤下载 mysql-8.0.x-winx64.zip解压到D:\mysql-8.0.40之类的位置。在根目录下新建my.ini配置文件内容参考[mysqld] port3306 basedirD:/mysql-8.0.40 datadirD:/mysql-8.0.40/data character-set-serverutf8mb4 collation-serverutf8mb4_unicode_ci default-authentication-pluginmysql_native_password [client] default-character-setutf8mb4这里有个坑datadir 目录不能手动创建必须让初始化命令自己生成否则后续启动时容易报权限或路径错误。default-authentication-plugin这个参数如果你用的客户端或 JDBC 驱动版本较老建议设成mysql_native_password可以规避后期连接时的认证插件报错。注意MySQL8.0 官方在新版本里默认使用caching_sha2_password如果你完全是用 8.0 对应的驱动版本如 mysql-connector-java 8.0.33保持默认就好不需要额外修改。以管理员身份打开 CMD进入 bin 目录执行mysqld --initialize-insecure这个命令会生成 data 目录并创建一个初始 root 用户密码为空。注意初始化之后必须先启动服务才能连接。安装并启动 Windows 服务mysqld --install mysql8 net start mysql8登录并修改 root 密码mysql -u root -p ALTER USER rootlocalhost IDENTIFIED BY 你的密码; FLUSH PRIVILEGES;6.2 使用 Docker 安装 MySQL8.0 的正确姿势很多人的电脑上不想直接装数据库或者需要多环境隔离这时候 Docker 是更好的选择。用 Docker 跑 MySQL8.0核心是把数据目录、配置、端口映射、时区、字符集一次搞定docker run -d \ --name mysql8 \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD123456 \ -e TZAsia/Shanghai \ -v /opt/mysql8/data:/var/lib/mysql \ -v /opt/mysql8/conf:/etc/mysql/conf.d \ --restartalways \ mysql:8.0这里有个容易出问题的地方官方镜像默认字符集可能不是 utf8mb4。需要在挂载的 conf 目录下新增配置文件比如/opt/mysql8/conf/my.cnf[mysqld] character-set-serverutf8mb4 collation-serverutf8mb4_unicode_ci不加这段配置的话建表时不显式声明字符集默认建的库表可能是 latin1存中文直接乱码。设置 TZAsia/Shanghai 也很重要否则容器时区默认 UTC数据库的NOW()函数返回值会跟本地时间差 8 小时排查时间问题时特别隐蔽。另外注意宿主机端口冲突的问题。如果本机 3306 已被占用换成-p 13306:3306连接时端口要对应修改。6.3 SpringBoot 连接 MySQL8.0 的驱动与连接串细节pom.xml 里引入 MySQL 驱动时版本必须与数据库大版本匹配。连 MySQL8.0 要用mysql-connector-j8.xpom 依赖如下dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId version8.0.33/version scoperuntime/scope /dependency连接串写法要特别留意几个参数spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/campus_info?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456serverTimezoneAsia/Shanghai必须加否则驱动默认用 JVM 时区可能出现时间偏差。allowPublicKeyRetrievaltrue是为了解决 caching_sha2_password 认证时公钥获取的报错如果连接报Public Key Retrieval is not allowed就是少了这个参数。一个经典报错是Access denied for user rootlocalhost八成是密码不对或者远程连接权限问题。MySQL8.0 的 root 用户默认只允许 localhost 连接如果你要在另一台机器上用 root 连接需要手动授权CREATE USER campus% IDENTIFIED BY 密码; GRANT ALL PRIVILEGES ON campus_info.* TO campus%; FLUSH PRIVILEGES;这种方式比直接放开 root 的远程访问更规范因为 root 泄露的后果是整个数据库沦陷而业务账号只对单库有权限安全面可控。6.4 打包部署SpringBoot 后端与 Vue3 前端的产物问题后端打包直接用 Mavenmvn clean package打出来的 jar 用java -jar campus-info.jar启动。部署上线时有几个注意点端口、数据库地址、文件上传路径这些配置不能写死在 application.yml 里我一般用application-prod.yml单独管理生产配置启动时指定 profilejava -jar campus-info.jar --spring.profiles.activeprod这样本地开发和线上部署互不干扰。前端打包npm run build产物在 dist 目录。部署到 Nginx 时要注意 Vue3 的路由如果是 history 模式Nginx 需要配置 try_files 回退location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }不加 try_files 配置的话用户访问http://域名/info刷新页面直接 404这个坑我刚部署时也踩过。history 路由模式需要服务端配合hash 模式则不需要但 URL 会带#观感差一些我建议生产环境用 history 模式配合 Nginx 配置。7. 常见问题排查实录从报错到定位的完整思路7.1 高频报错速查表把整个项目从零到部署过程中最常见的报错整理成一张表遇到问题可以直接对着找思路报错信息出现场景解决方案Access denied for user rootlocalhost数据库连接失败检查密码确认用户是否有远程权限检查连接串用户名密码是否匹配Public Key Retrieval is not allowedJDBC 连接 MySQL8.0连接串加allowPublicKeyRetrievaltrueUnknown database xxx连接不存在的库先创建数据库CREATE DATABASE xxx DEFAULT CHARSET utf8mb4;Invalid bound statement (not found)MyBatis 调用报错检查 Mapper 接口和 XML 文件的 namespace 是否一致检查 XML 文件是否在 resources 目录Property sqlSessionFactory or sqlSessionTemplate are requiredMyBatis-Plus 配置问题检查 spring-boot-starter 依赖是否引入检查启动类是否扫描 Mapper 接口Failed to configure a DataSource启动即报数据源错误检查 application.yml 的 datasource 配置项拼写和值CORS policy: No Access-Control-Allow-Origin前端跨域请求被拦截开发环境配 Vite proxy生产环境用 Nginx 反向代理不需要后端开跨域502 Bad GatewayNginx 转发失败后端服务可能挂了检查 Nginx 代理地址和端口是否正确MysqlSyntaxErrorException: Unknown column create_time查询报错检查实体属性是否和数据库字段映射正确是否开启驼峰映射No qualifying bean of type XXXXMapper注入 Mapper 失败启动类加MapperScan(com.campus.info.mapper)或在每个 Mapper 接口加Mapper7.2 一个印象深刻的排查案例分页数据重复做列表页分页时我遇到过一页数据重复的问题。翻到第二页有条记录在第一页也出现。一开始怀疑是 SQL 写错了反复检查查询条件都没问题后来打印 SQL 才发现排序字段 create_time 存在大量相同值而 MySQL 在排序值相同的情况下返回顺序不稳定导致分页边界数据重复。这类问题的定位思路很有代表性先在数据库里执行同样的 SQL查看排序结果再用ORDER BY create_time DESC, id DESC增加唯一字段排序。任何分页查询都建议带唯一性排序字段一般是主键这是分页接口稳定的前提。另一个排查教训是时间字段精度问题。如果同一秒内有大量新增数据create_time 也可能相同加上 id 排序后问题消失。所以排序字段设计时不只要考虑业务排序需求还要考虑分页场景下的绝对有序性。7.3 前端文件上传报错排查实例信息发布页需要上传图片前端用的是 FormData 对象后端接口接收 MultipartFile。遇到过一个典型报错上传大文件时报MaxUploadSizeExceededException。SpringBoot 2.x 默认单文件上传上限是 1MB多文件总大小上限是 10MB。调整配置的方式spring: servlet: multipart: max-file-size: 10MB max-request-size: 50MB改完之后大文件就能传了。但这个问题的背后还有一个隐藏点Nginx 层面有 client_max_body_size 的限制默认 1MB。如果你通过 Nginx 转发只改 SpringBoot 的配置没用因为请求在 Nginx 那层就被拒绝了。需要在 Nginx 配置里同时调大client_max_body_size 20m;前后端联调、线上部署时很多问题往往不是单一层面的而是一整条链路上的多个限制叠加。排查问题时要沿着请求链路一个环节一个环节地查不能只盯着报错出现的最后一个节点。8. 写在最后的一些体会把这个校园生活信息平台从设计到落地的完整过程走下来我最大的感受是技术栈本身不是难点难点在于把业务拆清楚、把细节做扎实。这套技术栈——SpringBoot2、Vue3、MyBatis-Plus、MySQL8.0——每一样单独拿出来都有大量教程但真正把它们组装成一个能跑的完整系统考验的是数据建模能力、接口设计规范性和前后端协作意识。最后分享几个我实际项目中沉淀下来的习惯一是所有接口必须返回统一格式哪怕报错也要有统一的错误结构前端才能做全局处理二是数据库表注释一定要写清楚时间久了没人记得某个字段是什么意思三是写代码前先画一遍表结构和接口清单想清楚了再动手比起边写边改能省一半的时间。这个项目如果再往前扩展可以考虑接入 WebSocket 做实时消息通知用 Redis 做热点信息的缓存把文件存储切到对象存储配合定时任务做数据统计报表。这些方向在本项目的架构基础上都留了扩展位。但核心思路不变先跑通主流程再谈优化和花活。希望这篇拆解对你做类似项目有所帮助。